GrImageViewer
Берут, когда картинку нужно рассмотреть.
Когда брать
- картинку нужно рассмотреть — скан документа, чертёж, фотография товара: масштаб, поворот и панорамирование уже есть;
- изображений несколько —
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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
ariaLabel | string | undefined | undefined | Доступное имя слоя. Модальный диалог без имени — нарушение `aria-dialog-name`: диктор объявит «диалог» и замолчит. |
emptyText | string | undefined | undefined | i18n: текст в пустом состоянии (нет изображений). |
closeLabel | string | undefined | undefined | i18n: aria-label кнопки закрытия. |
showProgress | boolean | undefined | false | — |
initialIndex | number | undefined | 0 | — |
zoomRate | number | undefined | 1.2 | — |
minScale | number | undefined | 0.5 | — |
maxScale | number | undefined | 5 | — |
hideOnClickModal | boolean | undefined | false | — |
closeOnPressEscape | boolean | undefined | true | — |
showZoomValue | boolean | undefined | true | — |
wheelZoom | boolean | undefined | true | Включает масштабирование колесом мыши / жестом на трекпаде. По умолчанию включено. |
draggable | boolean | undefined | false | Включает перетаскивание (pan) картинки мышью. При наведении курсор «рука». По умолчанию выключено. |
zIndexVar | string | undefined | undefined | Имя CSS-переменной слоя — escape-hatch мимо `--gr-z-modal`. Сырое число компонент не принимает: слой задаётся шкалой, см. `docs/z-index.md`. |
prevLabel | string | undefined | undefined | i18n: aria-label кнопки «предыдущее изображение». |
nextLabel | string | undefined | undefined | i18n: aria-label кнопки «следующее изображение». |
zoomInLabel | string | undefined | undefined | i18n: aria-label кнопки «увеличить». |
zoomOutLabel | string | undefined | undefined | i18n: aria-label кнопки «уменьшить». |
resetZoomLabel | string | undefined | undefined | i18n: aria-label кнопки «сбросить масштаб». |
zoomToNaturalLabel | string | undefined | undefined | i18n: aria-label кнопки «до натурального размера». |
rotateLeftLabel | string | undefined | undefined | i18n: aria-label кнопки «повернуть влево». |
rotateRightLabel | string | undefined | undefined | i18n: aria-label кнопки «повернуть вправо». |
showDownload | boolean | undefined | false | Показывать кнопку скачивания текущего кадра. |
downloadLabel | string | undefined | undefined | i18n: aria-label кнопки «скачать». |
modelValueобязательный | boolean | — | — |
urlListобязательный | GrImageViewerSource[] | — | Кадры. Строка — только адрес; объект `{ src, alt }` даёт изображению альтернативный текст: без него просмотрщик пуст для незрячего пользователя, а придумать текст за потребителя компонент не может. |
Slots
| Slot | Type | Описание |
|---|---|---|
toolbar | GrImageViewerSlotProps | Панель инструментов целиком вместо встроенной. |
toolbar-actions | GrImageViewerSlotProps | Свои кнопки рядом со встроенными — поворот, скачивание, печать. |
Events
| Event | Type | Описание |
|---|---|---|
close | [] | — |
update:modelValue | [value: boolean] | — |
change | [newIndex: number] | — |
rotate | [deg: number] | — |
download | [payload: { src: string; alt: string; index: number; }] | — |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
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 } дают изображению описание, а изменение списка не выбрасывает пользователя на первый кадр.
<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.
<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.
<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.
<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.
<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.
<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— закрыть