GrImageViewer

Пакет: @feugene/granularityядроГруппа: Слои

Берут, когда картинку нужно рассмотреть.

Когда брать

  • картинку нужно рассмотреть — скан документа, чертёж, фотография товара: масштаб, поворот и панорамирование уже есть;
  • изображений несколькоurlList листается стрелками и свайпом, индекс управляем через v-model;
  • исходник можно скачатьshowDownload даёт кнопку рядом с масштабом;
  • работа идёт с клавиатуры — весь набор действий имеет клавиши и подписи.

Когда взять другое

НужноБерите
Показать изображение в потоке страницыобычный <img> или GrCard
Аватар или миниатюраGrAvatar
Своя раскладка поверх страницыGrModal
Выбрать и загрузить файлGrFileUpload
Галерея-карусель в потоке страницысвоя вёрстка: карусели в пакете нет

Кадры: строка или `{ src, alt }`

urlList принимает оба варианта, в том числе вперемешку:

<GrImageViewer
  v-model="open"
  :url-list="[
    { src: '/photos/roof.jpg', alt: 'Крыша с высоты' },
    '/photos/plan.png',
  ]"
/>

Строка задаёт только адрес — alt у такого кадра пустой. Придумать текст за потребителя компонент не может, но и молча подставлять имя файла не должен: для незрячего пользователя «roof.jpg» не описание. Объектная форма — способ дать описание там, где оно есть.

alt приходит и в слот тулбара (src, alt в slot-props) — тулбар может показать подпись рядом с кадром.

Доступное имя и объявление смены кадра

Слой объявляет себя диалогом, поэтому у него всегда есть имя: из локали (gr.imageViewer.label) или из пропа ariaLabel. Диалог без имени — нарушение aria-dialog-name.

Смена кадра уходит в общий живой регион пакета (announcer.md): «Изображение 2 из 5» (gr.imageViewer.position). При открытии позиция не объявляется — её скажет имя диалога.

Регион лежит вне просмотрщика, хотя тот накрывает страницу inert. Это работает благодаря пропуску по data-gr-live-region в стеке слоёв: inert выбрасывает поддерево из дерева доступности, и регион внутри него замолчал бы.

Счётчик showProgress остаётся чисто визуальным: он дублирует ту же информацию глазами.

Зум, панорамирование и жесты

Колесо и жест трекпада увеличивают в точку под курсором, а не от центра: иначе до нужного угла увеличенного кадра пришлось бы добираться отдельным перетаскиванием.

Смещение кадра ограничено его собственным переполнением — насколько картинка вылезла за область просмотра, настолько её и можно тянуть. Утащить изображение в пустоту и потерять его нельзя, а при возврате к масштабу 1 смещение обнуляется само. Поворот на 90° меняет оси местами, и границы считаются по повёрнутому кадру.

Тянуть увеличенный кадр можно всегда — проп draggable разрешает перетаскивание и на вписанном (актуально для повёрнутых кадров, которые вылезают за экран и без зума).

У жестов два владельца, и это видно в поведении. Мышь и перо ведёт общий примитив useDragGesture: слушатели на window, поэтому отпускание ловится и за пределами картинки, и когда кадр сменился прямо посреди жеста. Касание ведёт свой трекер — у него многопальцевая модель: pinch двумя пальцами, свайп для листания, а на увеличенном кадре одиночный жест тянет.

Обрыв жеста (браузер забрал указатель — системный жест, звонок, потеря окна) заканчивает перетаскивание, оставляя кадр там, куда его довели, и не засчитывается как свайп: прерванный жест не листает.

Номинальный масштаб против реального

scale номинальный: единица — это «кадр вписан в окно» (object-contain), а не натуральный размер. У фотографии 4752 px, вписанной в окно шириной ~1000 px, номинальные 100% — это реальные 21%: пикселей на таком масштабе не видно вовсе.

Отсюда две разные кнопки тулбара. «100%» сбрасывает трансформацию и возвращает вписанный кадр; «1:1» (actions.zoomToNatural) доводит до реальных 100% — пиксель картинки на пиксель экрана. Считать это потребителю самому не нужно, хотя данные для расчёта слот отдаёт (naturalWidth, renderedWidth, realScale).

Потолок maxScale действует и на «1:1»: он ограничивает зум сознательно, и натуральный размер — не повод его обойти. Дефолтные maxScale: 5 для крупного кадра до реальных 100% не дотягивают (нужное номинальное приближение — это naturalWidth / renderedWidth при scale = 1, у фотографий это 5–12×), поэтому крупным изображениям потолок задаётся пропом. Обратное тоже работает: если кадр меньше своего места на экране, «1:1» уменьшит его — с оглядкой на minScale.

Сенсорные жесты работают на тех же pointer-событиях:

ЖестЧто делает
Два пальцаМасштаб по расстоянию между ними, якорь — середина
Палец на вписанном кадре, горизонтальноЛистание кадров
Палец на увеличенном кадреПеретаскивание

Порог свайпа — 60px и преобладание горизонтали: вертикальное движение листать не должно, пользователь целился не туда. Картинка объявлена touch-action: none, иначе браузер забирает жест себе и уводит страницу вместо зума.

Скачивание

showDownload добавляет в тулбар кнопку: она скачивает текущий кадр (<a download>) и эмитит download с { src, alt, index } — для аналитики.

Кросс-доменный адрес браузер скачает не всегда: атрибут download для чужого источника игнорируется, и файл откроется в новой вкладке. Там, где нужны подписанные ссылки или свой запрос, кнопку выключают (showDownload: false) и кладут свою в слот #toolbar-actions — он получает те же slot-props, включая actions.download.

Предзагрузка соседних кадров

Соседи текущего кадра греются только у открытого просмотрщика: закрытый на странице не тянет два полноразмерных изображения просто потому, что он там есть. При смене кадра и при закрытии незавершённые загрузки обрываются — иначе быстрое перелистывание галереи копит запросы, которые уже никому не нужны.

Изменение списка на лету

Просмотрщик держится за кадр, а не за индекс. Догрузилась следующая страница галереи — открытое изображение остаётся на экране вместе с масштабом и поворотом, даже если по позиции оно сдвинулось. Пропал текущий кадр из списка — индекс приводится к границам, а не к initialIndex: выбрасывать пользователя на первое изображение при каждой подгрузке нельзя.

Слой

--gr-z-modal, как у остальных модальных оверлеев. zIndexVar подменяет переменную слоя своей — тот же escape-hatch, что у useFloating и GrLoading. Сырого числа компонент не принимает: слой задаётся шкалой, см. ../z-index.md.

Точное значение — calc(var(<токен>) + глубина): свою глубину среди открытых модальных слоёв просмотрщик берёт из стека, поэтому поверх окна он оказывается и по отрисовке, а не только по Esc. С zIndexVar это работает так же — глубина прибавляется к подменённой переменной.

Esc, inert для нижних слоёв и возврат фокуса идут через общий стек (useOverlayLayer), поэтому просмотрщик поверх модалки закрывает себя, а не её.

Токены хрома

Панель, кнопки и подложка красятся покомпонентными токенами:

ТокенЧто красит
--gr-image-viewer-scrimподложка под изображением
--gr-image-viewer-chrome-bgфон кнопок и панели инструментов
--gr-image-viewer-chrome-bg-hoverфон кнопки под курсором
--gr-image-viewer-chrome-bg-softмягкая подсветка кнопок тулбара
--gr-image-viewer-chrome-fgиконки и текст хрома
--gr-image-viewer-chrome-fg-mutedтекст пустого состояния
--gr-image-viewer-chrome-brdрамки и разделители
--gr-image-viewer-ringкольцо фокуса

Значения одинаковы в светлой и тёмной теме намеренно: рядом с фотографией светлая панель спорит с содержимым и подсвечивает края кадра. Токены нужны не для смены темы, а чтобы потребитель мог перекрасить хром под свой продукт.

Императивный API

<GrImageViewer ref="viewer" v-model="open" :url-list="images" />

viewer.value отдаёт close, prev, next, zoomIn, zoomOut, zoomToNatural, reset, rotateLeft, rotateRight — тот же набор, что получает слот тулбара. Открытия среди них нет: оно принадлежит v-model, вторая точка входа рассинхронизировала бы состояние с моделью.

События

СобытиеКогда
update:modelValueоткрытие/закрытие
closeзакрытие
changeпоказан другой кадр, аргумент — новый индекс
rotateповорот, аргумент — накопленный угол в градусах

Playground 25

Загружается…

Код
<GrImageViewer />

Установка

npm i @feugene/granularity

Импорт

import { GrImageViewer } from '@feugene/granularity/components/GrImageViewer'

API

Props

PropTypeпо умолчаниюОписание
ariaLabelstring | undefinedundefinedДоступное имя слоя. Модальный диалог без имени — нарушение `aria-dialog-name`: диктор объявит «диалог» и замолчит.
emptyTextstring | undefinedundefinedi18n: текст в пустом состоянии (нет изображений).
closeLabelstring | undefinedundefinedi18n: aria-label кнопки закрытия.
showProgressboolean | undefinedfalse
initialIndexnumber | undefined0
zoomRatenumber | undefined1.2
minScalenumber | undefined0.5
maxScalenumber | undefined5
hideOnClickModalboolean | undefinedfalse
closeOnPressEscapeboolean | undefinedtrue
showZoomValueboolean | undefinedtrue
wheelZoomboolean | undefinedtrueВключает масштабирование колесом мыши / жестом на трекпаде. По умолчанию включено.
draggableboolean | undefinedfalseВключает перетаскивание (pan) картинки мышью. При наведении курсор «рука». По умолчанию выключено.
zIndexVarstring | undefinedundefinedИмя CSS-переменной слоя — escape-hatch мимо `--gr-z-modal`. Сырое число компонент не принимает: слой задаётся шкалой, см. `docs/z-index.md`.
prevLabelstring | undefinedundefinedi18n: aria-label кнопки «предыдущее изображение».
nextLabelstring | undefinedundefinedi18n: aria-label кнопки «следующее изображение».
zoomInLabelstring | undefinedundefinedi18n: aria-label кнопки «увеличить».
zoomOutLabelstring | undefinedundefinedi18n: aria-label кнопки «уменьшить».
resetZoomLabelstring | undefinedundefinedi18n: aria-label кнопки «сбросить масштаб».
zoomToNaturalLabelstring | undefinedundefinedi18n: aria-label кнопки «до натурального размера».
rotateLeftLabelstring | undefinedundefinedi18n: aria-label кнопки «повернуть влево».
rotateRightLabelstring | undefinedundefinedi18n: aria-label кнопки «повернуть вправо».
showDownloadboolean | undefinedfalseПоказывать кнопку скачивания текущего кадра.
downloadLabelstring | undefinedundefinedi18n: aria-label кнопки «скачать».
modelValueобязательныйboolean
urlListобязательныйGrImageViewerSource[]Кадры. Строка — только адрес; объект `{ src, alt }` даёт изображению альтернативный текст: без него просмотрщик пуст для незрячего пользователя, а придумать текст за потребителя компонент не может.

Slots

SlotTypeОписание
toolbarGrImageViewerSlotPropsПанель инструментов целиком вместо встроенной.
toolbar-actionsGrImageViewerSlotPropsСвои кнопки рядом со встроенными — поворот, скачивание, печать.

Events

EventTypeОписание
close[]
update:modelValue[value: boolean]
change[newIndex: number]
rotate[deg: number]
download[payload: { src: string; alt: string; index: number; }]

Methods / Expose

Methods / ExposeTypeОписание
close() => void
prev() => void
next() => void
zoomIn() => void
zoomOut() => void
zoomToNatural() => voidМасштаб «один к одному»: реальные 100%, а не номинальные.
reset() => void
rotateLeft() => void
rotateRight() => void
download() => voidСкачать текущий кадр — то же, что делает кнопка тулбара.

Примеры 6

Alt-текст и живой список кадров

Кадры объектами { src, alt } дают изображению описание, а изменение списка не выбрасывает пользователя на первый кадр.

2 кадров · показан 1
Список можно менять на лету: просмотрщик держится за кадр, а не за индекс — открытое изображение остаётся на экране вместе с масштабом, даже если сдвинулось по позиции.

Alt And Append
<script setup lang="ts">
import { computed, ref } from 'vue'

import type { GrImageViewerSource } from '@feugene/granularity'
import { GrBadge, GrButton, GrImageViewer } from '@feugene/granularity'

function createSlide(label: string, background: string) {
  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900">
      <rect width="1200" height="900" fill="${background}" />
      <text x="140" y="520" fill="white" font-size="114" font-family="Arial, sans-serif" font-weight="700">${label}</text>
    </svg>
  `)}`
}

const open = ref(false)
const page = ref(1)

// Кадр объектом — единственный способ дать изображению описание: имя файла
// незрячему пользователю ничего не говорит.
const slides = ref<GrImageViewerSource[]>([
  { src: createSlide('Roof', '#1d4ed8'), alt: 'Кровля здания с высоты птичьего полёта' },
  { src: createSlide('Plan', '#9333ea'), alt: 'Поэтажный план второго этажа' },
])

const currentIndex = ref(0)
const total = computed(() => slides.value.length)

function loadMore() {
  page.value += 1
  slides.value = [
    ...slides.value,
    { src: createSlide(`Page ${page.value}`, '#047857'), alt: `Скан страницы ${page.value}` },
  ]
}

function prependEarlier() {
  slides.value = [
    { src: createSlide('Earlier', '#b45309'), alt: 'Более ранний снимок объекта' },
    ...slides.value,
  ]
}
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-3">
      <GrButton size="sm" @click="open = true">
        Открыть просмотрщик
      </GrButton>
      <GrButton size="sm" variant="outline" @click="loadMore">
        Догрузить следующую страницу
      </GrButton>
      <GrButton size="sm" variant="outline" @click="prependEarlier">
        Добавить кадр в начало
      </GrButton>

      <GrBadge size="sm" tone="neutral">
        {{ total }} кадров · показан {{ currentIndex + 1 }}
      </GrBadge>
    </div>

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
      Список можно менять на лету: просмотрщик держится за кадр, а не за индекс — открытое
      изображение остаётся на экране вместе с масштабом, даже если сдвинулось по позиции.
    </div>

    <GrImageViewer
      v-model="open"
      :url-list="slides"
      show-progress
      hide-on-click-modal
      @change="currentIndex = $event"
    />
  </div>
</template>

Полноэкранная галерея из миниатюр

Базовый media-flow: открываем GrImageViewer из gallery grid и синхронизируем initialIndex c выбранной thumbnail.

Gallery
<script setup lang="ts">
import { ref } from 'vue'

import { GrBadge, GrButton, GrImageViewer } from '@feugene/granularity'

function createSlide(label: string, background: string, accent: string) {
  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900">
      <rect width="1200" height="900" fill="${background}" />
      <circle cx="930" cy="180" r="140" fill="${accent}" fill-opacity="0.35" />
      <rect x="130" y="160" width="320" height="22" rx="11" fill="white" fill-opacity="0.75" />
      <rect x="130" y="210" width="520" height="18" rx="9" fill="white" fill-opacity="0.45" />
      <rect x="130" y="660" width="420" height="26" rx="13" fill="white" fill-opacity="0.7" />
      <text x="130" y="540" fill="white" font-size="108" font-family="Arial, sans-serif" font-weight="700">${label}</text>
    </svg>
  `)}`
}

const slides = [
  { title: 'Workspace overview', url: createSlide('Overview', '#2563eb', '#a5f3fc') },
  { title: 'Risk dashboard', url: createSlide('Risk', '#7c3aed', '#f5d0fe') },
  { title: 'Approval queue', url: createSlide('Queue', '#059669', '#fde68a') },
]

const open = ref(false)
const initialIndex = ref(0)

function openAt(index: number) {
  initialIndex.value = index
  open.value = true
}
</script>

<template>
  <div class="grid gap-4">
    <div class="grid gap-3 sm:grid-cols-3">
      <button
        v-for="(slide, index) in slides"
        :key="slide.title"
        type="button"
        class="group overflow-hidden rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-bg)] text-left transition-transform hover:-translate-y-0.5"
        @click="openAt(index)"
      >
        <img :src="slide.url" :alt="slide.title" class="h-36 w-full object-cover">
        <div class="flex items-center justify-between gap-3 p-3">
          <span class="text-sm font-600 text-[var(--gr-fg)]">{{ slide.title }}</span>
          <GrBadge size="sm" tone="neutral">
            Preview
          </GrBadge>
        </div>
      </button>
    </div>

    <div>
      <GrButton size="sm" variant="outline" @click="openAt(0)">
        Open fullscreen gallery
      </GrButton>
    </div>

    <GrImageViewer
      v-model="open"
      :url-list="slides.map(slide => slide.url)"
      :initial-index="initialIndex"
      show-progress
    />
  </div>
</template>

Свой слот панели инструментов

Показываем slot-based composition: кастомный toolbar с action-кнопками и собственным progress/zoom summary поверх overlay.

`toolbar` slot подходит для брендинга, кастомных shortcuts и встроенных action-clusters поверх fullscreen overlay.

Toolbar Slot
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrImageViewer } from '@feugene/granularity'

function createSlide(label: string, background: string, accent: string) {
  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900">
      <rect width="1200" height="900" fill="${background}" />
      <rect x="120" y="120" width="960" height="660" rx="36" fill="${accent}" fill-opacity="0.28" />
      <text x="120" y="520" fill="white" font-size="120" font-family="Arial, sans-serif" font-weight="700">${label}</text>
    </svg>
  `)}`
}

const slides = [
  createSlide('Marketing hero', '#0f172a', '#38bdf8'),
  createSlide('Support knowledge base', '#111827', '#c084fc'),
  createSlide('Compliance evidence', '#1f2937', '#34d399'),
]

const open = ref(false)
</script>

<template>
  <div class="grid gap-3">
    <div class="text-sm text-[var(--gr-muted-fg)]">
      `toolbar` slot подходит для брендинга, кастомных shortcuts и встроенных action-clusters поверх fullscreen overlay.
    </div>

    <div>
      <GrButton size="sm" @click="open = true">
        Open viewer with custom toolbar
      </GrButton>
    </div>

    <GrImageViewer
      v-model="open"
      :url-list="slides"
      :initial-index="1"
      show-progress
      :show-zoom-value="false"
    >
      <template #toolbar="{ displayIndex, total, scale, rotation, actions }">
        <div class="flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--gr-bg)_20%,transparent)] bg-[color-mix(in_srgb,var(--gr-fg)_45%,transparent)] px-2 py-1 text-[var(--gr-bg)] backdrop-blur-sm">
          <span class="px-2 text-xs font-600">{{ displayIndex }} / {{ total }}</span>
          <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.prev">Prev</button>
          <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.next">Next</button>
          <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.zoomIn">+</button>
          <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.reset">Reset</button>
          <span class="px-2 text-xs text-[color-mix(in_srgb,var(--gr-bg)_75%,transparent)]">{{ Math.round(scale * 100) }}% / {{ rotation }}°</span>
        </div>
      </template>
    </GrImageViewer>
  </div>
</template>

Реальный размер изображения в панели

Картинка фиксированного размера (1000×1500): компонент сам отдаёт в slot natural-размер, фактический rendered-размер и реальный масштаб (realScalePercent), поэтому не нужно вручную читать DOM.

Real Sizeзависит от окружения витрины
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrImageViewer } from '@feugene/granularity'

// Локальный кадр 4752×3168: настоящая фотография, а не SVG-заглушка — зерно на
// реальных 100% видно только у растра. Vite отдаёт ей хешированный URL, поэтому
// путь остаётся импортом, а не строкой в `public/`.
import photo from '../../../../media/svanhove-lorem-4873426.jpg'

const IMAGE_WIDTH = 4752
const IMAGE_HEIGHT = 3168

const slides = [photo]

const open = ref(false)
</script>

<template>
  <div class="grid gap-3">
    <div class="text-sm text-[var(--gr-muted-fg)]">
      Фотография {{ IMAGE_WIDTH }}×{{ IMAGE_HEIGHT }}. Номинальный `scale` считается относительно вписанного в окно
      изображения (`object-contain`), поэтому «100%» — это не натуральный размер: зерна на нём не видно вовсе. Кнопка
      «1:1» доводит кадр до реальных 100% — пиксель в пиксель, и разница сразу видна. Компонент сам отдаёт в slot
      natural-размер, фактический rendered-размер и реальный масштаб — без ручного чтения DOM.
    </div>

    <div>
      <GrButton size="sm" @click="open = true">
        Open real-size experiment
      </GrButton>
    </div>

    <!--
      `maxScale` поднят с дефолтной пятёрки: нужное номинальное приближение — это
      `naturalWidth / renderedWidth`, у кадра 4752 px оно выходит от пяти до
      двенадцати крат в зависимости от ширины окна. С дефолтным потолком кнопка
      «1:1» упиралась бы в него, не дойдя до реальных 100%.
    -->
    <GrImageViewer
      v-model="open"
      :url-list="slides"
      :max-scale="14"
      show-progress
      :draggable="true"
      :show-zoom-value="false"
    >
      <template #toolbar="{ scale, rotation, naturalWidth, naturalHeight, renderedWidth, renderedHeight, realScalePercent, actions }">
        <div class="flex flex-col gap-2 rounded-2xl border border-[color-mix(in_srgb,var(--gr-bg)_20%,transparent)] bg-[color-mix(in_srgb,var(--gr-fg)_55%,transparent)] px-3 py-2 text-[var(--gr-bg)] backdrop-blur-sm">
          <div class="flex items-center justify-center gap-2">
            <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.zoomOut"></button>
            <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.reset">Fit</button>
            <button type="button" class="rounded-full px-3 py-1 text-xs font-600 transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.zoomToNatural">1:1</button>
            <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.zoomIn">+</button>
          </div>

          <div class="grid gap-0.5 text-[11px] leading-tight font-500">
            <span>Natural: {{ naturalWidth }} × {{ naturalHeight }} px</span>
            <span>Rendered: {{ renderedWidth }} × {{ renderedHeight }} px</span>
            <span>Nominal scale: {{ Math.round(scale * 100) }}% · rotation {{ rotation }}°</span>
            <span>Real scale: {{ realScalePercent }}%</span>
          </div>
        </div>
      </template>
    </GrImageViewer>
  </div>
</template>

Асинхронная загрузка галереи

Закрываем async/media use-case: сначала показываем loading/progress, затем открываем viewer после получения media payload.

Используйте viewer после загрузки gallery payload — так проще показать loading/progress state до fullscreen modal.

Async Media
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'

import { GrBadge, GrButton, GrImageViewer, GrProgressBar } from '@feugene/granularity'

function createSlide(label: string, background: string, accent: string) {
  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900">
      <rect width="1200" height="900" fill="${background}" />
      <circle cx="940" cy="220" r="120" fill="${accent}" fill-opacity="0.35" />
      <rect x="140" y="170" width="280" height="20" rx="10" fill="white" fill-opacity="0.7" />
      <text x="140" y="520" fill="white" font-size="114" font-family="Arial, sans-serif" font-weight="700">${label}</text>
    </svg>
  `)}`
}

const loading = ref(false)
const open = ref(false)
const progress = ref(0)
const slides = ref<string[]>([])

const hasSlides = computed(() => slides.value.length > 0)

let progressTimer: number | undefined
let resolveTimer: number | undefined

function clearTimers() {
  if (progressTimer !== undefined) {
    window.clearInterval(progressTimer)
    progressTimer = undefined
  }

  if (resolveTimer !== undefined) {
    window.clearTimeout(resolveTimer)
    resolveTimer = undefined
  }
}

function loadGallery() {
  clearTimers()
  loading.value = true
  progress.value = 12
  slides.value = []

  progressTimer = window.setInterval(() => {
    progress.value = Math.min(progress.value + 18, 88)
  }, 180)

  resolveTimer = window.setTimeout(() => {
    clearTimers()
    slides.value = [
      createSlide('Invoices', '#1d4ed8', '#93c5fd'),
      createSlide('Disputes', '#9333ea', '#e9d5ff'),
      createSlide('Fraud', '#047857', '#bbf7d0'),
    ]
    progress.value = 100
    loading.value = false
    open.value = true
  }, 1200)
}

onBeforeUnmount(() => {
  clearTimers()
})
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-3">
      <GrButton size="sm" @click="loadGallery">
        Simulate async media fetch
      </GrButton>

      <GrBadge v-if="hasSlides" size="sm" tone="neutral">
        {{ slides.length }} slides ready
      </GrBadge>
    </div>

    <div class="rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-bg)] p-4">
      <div class="mb-3 text-sm text-[var(--gr-muted-fg)]">
        Используйте viewer после загрузки gallery payload — так проще показать loading/progress state до fullscreen modal.
      </div>

      <GrProgressBar :value="progress" aria-label="Gallery loading progress" />
    </div>

    <GrImageViewer
      v-model="open"
      :url-list="slides"
      show-progress
      show-zoom-value
      hide-on-click-modal
    />
  </div>
</template>

Зум в точку курсора и скачивание

Колесо увеличивает в точку под курсором, смещение ограничено кадром, а кнопка «скачать» отдаёт файл и эмитит download.

Скачано: —
Колесо увеличивает в точку под курсором, увеличенный кадр тянется мышью и не уезжает за край. На тач-устройстве — щипок и свайп между кадрами.

Zoom Download
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrImageViewer } from '@feugene/granularity'

const blueprint = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000">
    <rect width="1600" height="1000" fill="#0f172a" />
    <g stroke="#38bdf8" stroke-opacity="0.35" stroke-width="2">
      ${Array.from({ length: 16 }, (_, i) => `<line x1="${i * 100}" y1="0" x2="${i * 100}" y2="1000" />`).join('')}
      ${Array.from({ length: 10 }, (_, i) => `<line x1="0" y1="${i * 100}" x2="1600" y2="${i * 100}" />`).join('')}
    </g>
    <rect x="120" y="120" width="520" height="360" fill="none" stroke="#f8fafc" stroke-width="6" />
    <rect x="760" y="420" width="700" height="460" fill="none" stroke="#f8fafc" stroke-width="6" />
    <text x="140" y="100" fill="#f8fafc" font-size="42" font-family="monospace">SECTOR A · scale 1:200</text>
    <text x="780" y="400" fill="#f8fafc" font-size="42" font-family="monospace">SECTOR B</text>
  </svg>
`)}`

const open = ref(false)
const lastDownload = ref('')

function onDownload(payload: { src: string, alt: string, index: number }) {
  // Событие приходит вдобавок к самому скачиванию — под аналитику и логи.
  lastDownload.value = `кадр ${payload.index + 1}: ${payload.alt || 'без описания'}`
}
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap items-center gap-3">
      <GrButton @click="open = true">
        Open blueprint
      </GrButton>
      <span class="text-xs text-[var(--gr-muted-fg)]">
        Скачано: {{ lastDownload }}
      </span>
    </div>

    <div class="text-xs text-[var(--gr-muted-fg)]">
      Колесо увеличивает в точку под курсором, увеличенный кадр тянется мышью и не
      уезжает за край. На тач-устройстве — щипок и свайп между кадрами.
    </div>

    <GrImageViewer
      v-model="open"
      :url-list="[
        { src: blueprint, alt: 'План этажа, сектор A и B' },
      ]"
      show-download
      show-zoom-value
      @download="onDownload"
    />
  </div>
</template>

Доступность

Паттерн APG
| GrToaster | F6 (проп focusHotkey) — фокус на верхний тост, дальше действия обходятся Tab; сам тост остановкой Tab не является; Delete/Backspace на сфокусированном тосте закрывают его — клавиатурный эквивалент смахивания
Клавиши
Esc — закрыть

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты