GrImageViewer
Opens an image in a convenient viewing and zooming mode.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- the picture has to be examined — a scan of a document, a drawing, a photograph of a product: zoom, rotation and panning are already there;
- there are several images —
urlListis paged with the arrows and by swiping, and the index is controlled throughv-model; - the original can be downloaded —
showDownloadgives a button next to the zoom; - the work is done from the keyboard — the whole set of actions has keys and labels.
When to take something else
| Need | Take |
|---|---|
| Show an image in the flow of the page | an ordinary <img> or GrCard |
| An avatar or a thumbnail | GrAvatar |
| A layout of your own on top of the page | GrModal |
| Choose and upload a file | GrFileUpload |
| A carousel gallery in the flow of the page | markup of your own: there is no carousel in the package |
The frames: a string or `{ src, alt }`
urlList accepts both variants, mixed together included:
<GrImageViewer
v-model="open"
:url-list="[
{ src: '/photos/roof.jpg', alt: 'The roof from above' },
'/photos/plan.png',
]"
/>
A string sets the address only — the alt of such a frame is empty. The component cannot
invent the text on the consumer’s behalf, but neither must it substitute the name of the
file silently: for a blind user “roof.jpg” is not a description. The object form is the way
to give a description where there is one.
The alt also arrives in the toolbar slot (src, alt in the slot props) — the toolbar
can show a caption next to the frame.
The accessible name and announcing a change of frame
The layer declares itself a dialog, so it always has a name: from the locale
(gr.imageViewer.label) or from the ariaLabel prop. A dialog without a name is a
violation of aria-dialog-name.
A change of frame goes into the shared live region of the package (announcer.md):
“Image 2 of 5” (gr.imageViewer.position). On opening the position is not announced — the
name of the dialog will say it.
The region lies outside the viewer, although the viewer covers the page with inert.
That works thanks to the exemption by data-gr-live-region in the layer stack: inert
throws a subtree out of the accessibility tree, and a region inside it would fall silent.
The showProgress counter stays purely visual: it duplicates the same information for the
eye.
Zoom, panning and gestures
The wheel and the trackpad gesture zoom into the point under the cursor rather than from the centre: otherwise reaching the corner of an enlarged frame would require a separate drag.
The offset of the frame is bounded by its own overflow — as far as the picture has come out of the viewing area, that far it can be dragged. Dragging an image into the void and losing it is impossible, and on returning to a scale of 1 the offset resets itself. A rotation by 90° swaps the axes, and the bounds are computed from the rotated frame.
An enlarged frame can always be dragged — the draggable prop permits dragging a fitted one
as well (which matters for rotated frames that come off the screen even without zoom).
The gestures have two owners, and that is visible in the behaviour. The mouse and the
pen are run by the shared useDragGesture primitive: the listeners are
on window, so the release is caught both outside the picture and when the frame changed in
the middle of the gesture. Touch is run by a tracker of its own — it has a multi-finger
model: a pinch with two fingers, a swipe for paging, and on an enlarged frame a single
gesture drags.
An interrupted gesture (the browser took the pointer — a system gesture, a call, the loss of the window) ends the dragging leaving the frame where it was brought to, and does not count as a swipe: an interrupted gesture does not page.
The nominal scale against the real one
scale is nominal: one means “the frame is fitted into the window” (object-contain) rather
than the natural size. For a photograph of 4752 px fitted into a window about 1000 px wide, a
nominal 100% is a real 21%: at such a scale the pixels are not visible at all.
Hence two different buttons in the toolbar. “100%” resets the transformation and returns the
fitted frame; “1:1” (actions.zoomToNatural) brings it to a real 100% — a pixel of the
picture per pixel of the screen. The consumer does not have to compute that themselves,
although the slot does give the data for the computation (naturalWidth, renderedWidth,
realScale).
The maxScale ceiling applies to “1:1” as well: it limits the zoom deliberately, and the
natural size is no reason to bypass it. The default maxScale: 5 does not reach a real 100%
for a large frame (the nominal zoom needed is naturalWidth / renderedWidth at scale = 1,
which for photographs is 5–12×), so for large images the ceiling is set with a prop. The
reverse works too: if the frame is smaller than its place on the screen, “1:1” will shrink it —
with an eye on minScale.
The touch gestures work on the same pointer events:
| Gesture | What it does |
|---|---|
| Two fingers | The scale by the distance between them, the anchor is the midpoint |
| A finger on a fitted frame, horizontally | Paging through the frames |
| A finger on an enlarged frame | Dragging |
The swipe threshold is 60px and a predominance of the horizontal: a vertical movement must
not page, the user was aiming elsewhere. The picture is declared touch-action: none,
otherwise the browser takes the gesture for itself and moves the page instead of zooming.
Downloading
showDownload adds a button to the toolbar: it downloads the current frame
(<a download>) and emits download with { src, alt, index } — for analytics.
A cross-origin address will not always be downloaded by the browser: the download
attribute is ignored for a foreign origin, and the file opens in a new tab. Where signed
links or a request of your own are needed, the button is switched off
(showDownload: false) and one of your own is put into the #toolbar-actions slot — it
receives the same slot props, actions.download included.
Preloading the neighbouring frames
The neighbours of the current frame are warmed up only for an open viewer: a closed one on a page does not pull two full-size images merely because it is there. On a change of frame and on closing, unfinished loads are aborted — otherwise quick paging through a gallery piles up requests nobody needs any more.
Changing the list on the fly
The viewer holds on to the frame, not to the index. If the next page of the gallery has
loaded, the open image stays on the screen together with its scale and rotation, even if it
has shifted by position. If the current frame has disappeared from the list, the index is
clamped to the bounds rather than reset to initialIndex: throwing the user back to the
first image on every load is not allowed.
The layer
--gr-z-modal, as in the other modal overlays. zIndexVar replaces the layer variable with
one of your own — the same escape hatch as in useFloating and GrLoading. The component
does not accept a raw number: the layer is set by the scale, see
../z-index.md.
The exact value is calc(var(<token>) + depth): the viewer takes its own depth among the
open modal layers from the stack, so it ends up above a window by rendering as well, not only
by Esc. With zIndexVar that works the same way — the depth is added to the substituted
variable.
Esc, inert for the lower layers and the return of focus go through the shared stack
(useOverlayLayer), so a viewer on top of a modal closes itself rather than the modal.
The chrome tokens
The panel, the buttons and the scrim are coloured with per-component tokens:
| Token | What it colours |
|---|---|
--gr-image-viewer-scrim | the scrim under the image |
--gr-image-viewer-chrome-bg | the background of the buttons and of the toolbar |
--gr-image-viewer-chrome-bg-hover | the background of a button under the cursor |
--gr-image-viewer-chrome-bg-soft | the soft highlight of the toolbar buttons |
--gr-image-viewer-chrome-fg | the icons and the text of the chrome |
--gr-image-viewer-chrome-fg-muted | the text of the empty state |
--gr-image-viewer-chrome-brd | the borders and the separators |
--gr-image-viewer-ring | the focus ring |
The values are the same in the light and the dark theme on purpose: next to a photograph a light panel argues with the content and highlights the edges of the frame. The tokens exist not for changing the theme but so that the consumer can recolour the chrome to fit their product.
The imperative API
<GrImageViewer ref="viewer" v-model="open" :url-list="images" />
viewer.value gives away close, prev, next, zoomIn, zoomOut, zoomToNatural,
reset, rotateLeft, rotateRight — the same set the toolbar slot receives.
Opening is not among them: it belongs to v-model, and a second way in would put the state
out of sync with the model.
The events
| Event | When |
|---|---|
update:modelValue | opening/closing |
close | closing |
change | another frame is shown, the argument is the new index |
rotate | a rotation, the argument is the accumulated angle in degrees |
Playground 25
Loading…
<GrImageViewer />Install
npm i @feugene/granularityImport
import { GrImageViewer } from '@feugene/granularity/components/GrImageViewer'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
ariaLabel | string | undefined | undefined | The accessible name of the layer. A modal dialog without a name is a violation of `aria-dialog-name`: a screen reader will announce "dialog" and fall silent. |
emptyText | string | undefined | undefined | i18n: the text of the empty state (there are no images). |
closeLabel | string | undefined | undefined | i18n: the aria-label of the close button. |
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 | Switches on zooming with the mouse wheel or a trackpad gesture. On by default. |
draggable | boolean | undefined | false | Switches on dragging (panning) of the picture with the mouse. On hover the cursor becomes a hand. Off by default. |
zIndexVar | string | undefined | undefined | The name of the CSS variable of the layer — an escape hatch past `--gr-z-modal`. The component does not accept a raw number: the layer is set by the scale, see `docs/z-index.md`. |
prevLabel | string | undefined | undefined | i18n: the aria-label of the "previous image" button. |
nextLabel | string | undefined | undefined | i18n: the aria-label of the "next image" button. |
zoomInLabel | string | undefined | undefined | i18n: the aria-label of the "zoom in" button. |
zoomOutLabel | string | undefined | undefined | i18n: the aria-label of the "zoom out" button. |
resetZoomLabel | string | undefined | undefined | i18n: the aria-label of the "reset the zoom" button. |
zoomToNaturalLabel | string | undefined | undefined | i18n: the aria-label of the "to the natural size" button. |
rotateLeftLabel | string | undefined | undefined | i18n: the aria-label of the "rotate left" button. |
rotateRightLabel | string | undefined | undefined | i18n: the aria-label of the "rotate right" button. |
showDownload | boolean | undefined | false | Show the button that downloads the current frame. |
downloadLabel | string | undefined | undefined | i18n: the aria-label of the "download" button. |
modelValuerequired | boolean | — | — |
urlListrequired | GrImageViewerSource[] | — | The frames. A string is an address only; an object `{ src, alt }` gives the image an alternative text: without it the viewer is empty for a blind user, and the component cannot invent the text on the consumer’s behalf. |
Slots
| Slot | Type | Description |
|---|---|---|
toolbar | GrImageViewerSlotProps | The toolbar as a whole instead of the built-in one. |
toolbar-actions | GrImageViewerSlotProps | Buttons of your own beside the built-in ones — rotation, downloading, printing. |
Events
| Event | Type | Description |
|---|---|---|
close | [] | — |
update:modelValue | [value: boolean] | — |
change | [newIndex: number] | — |
rotate | [deg: number] | — |
download | [payload: { src: string; alt: string; index: number; }] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
close | () => void | — |
prev | () => void | — |
next | () => void | — |
zoomIn | () => void | — |
zoomOut | () => void | — |
zoomToNatural | () => void | The "one to one" scale: a real 100% rather than a nominal one. |
reset | () => void | — |
rotateLeft | () => void | — |
rotateRight | () => void | — |
download | () => void | Download the current frame — the same as the toolbar button does. |
Examples 6
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>Fullscreen gallery from thumbnails
<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>Custom 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>Real image size in toolbar
<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 gallery loading
<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>Zoom to cursor and 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>Accessibility
- APG pattern
| GrToaster | F6 (проп focusHotkey) — фокус на верхний тост, дальше действия обходятся Tab; сам тост остановкой Tab не является; Delete/Backspace на сфокусированном тосте закрывают его — клавиатурный эквивалент смахивания