GrImageViewer

  • core
  • overlays
  • API · 26 props
  • ⌨ keys

Opens an image in a convenient viewing and zooming mode.

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

Overview

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 — urlList is paged with the arrows and by swiping, and the index is controlled through v-model;
  • the original can be downloaded — showDownload gives 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

NeedTake
Show an image in the flow of the pagean ordinary <img> or GrCard
An avatar or a thumbnailGrAvatar
A layout of your own on top of the pageGrModal
Choose and upload a fileGrFileUpload
A carousel gallery in the flow of the pageGrCarousel

Playground controls come from the API, not from hand

Stage

The window opens above the page with the props set on the right.

0 changed· state is in the URL

Examples 3 each with a live demo and its code

  • A floor plan read at eight times

    Desk numbers on a level plan are small, and the viewer is how they get read: max-scale raises the limit to eight, zoom-rate makes each step bigger, and draggable lets even the fitted plan be moved. The wheel zooms into the point under the cursor, and «1:1» goes to real pixels rather than to «fitted».

    Your desk tomorrow: 4.17Level 4, east wing · two rows from the lifts
  • Reviewing identity scans

    The second page came in sideways: rotation is built in, and the reviewer’s own button sits beside it through toolbar-actions instead of replacing the toolbar. The slot hands over the frame — index, alt, rotation — so «Request a new scan» names the page it is about.

    Identity check · Jonas Weber2 pages uploaded today

API generated from the package · cannot drift

Props

PropTypedefaultDescription
ariaLabelstring | undefinedundefinedThe 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.
prevLabelstring | undefinedundefinedi18n: the aria-label of the "previous image" button.
nextLabelstring | undefinedundefinedi18n: the aria-label of the "next image" button.
emptyTextstring | undefinedundefinedi18n: the text of the empty state (there are no images).
closeLabelstring | undefinedundefinedi18n: the aria-label of the close button.
showProgressboolean | undefinedfalseThe "3 / 12" counter in the header. `false` by default.
initialIndexnumber | undefined0The frame the viewer opens with; changing the prop while it is open switches the frame. `0` by default.
zoomRatenumber | undefined1.2The multiplier of one zoom step — with the buttons and from the keyboard. `1.2` by default.
minScalenumber | undefined0.5The lower limit of the scale; `1` means the frame is fitted into the window. `0.5` by default.
maxScalenumber | undefined5The upper limit of the scale relative to the fitted frame. `5` by default.
hideOnClickModalboolean | undefinedfalseClose on a click on the backdrop around the frame. `false` by default.
closeOnPressEscapeboolean | undefinedtrueClose on `Esc`; opened on top of a modal, it closes itself rather than the modal. `true` by default.
showZoomValueboolean | undefinedtrueThe scale in percent in the header; 100% means the frame is fitted into the window. `true` by default.
wheelZoomboolean | undefinedtrueSwitches on zooming with the mouse wheel or a trackpad gesture. On by default.

Slots

SlotTypeDescription
toolbarGrImageViewerSlotPropsThe toolbar as a whole instead of the built-in one.
toolbar-actionsGrImageViewerSlotPropsButtons of your own beside the built-in ones — rotation, downloading, printing.

Events

EventSignatureDescription
close(event: "close"): void—
update:modelValue(event: "update:modelValue", value: boolean): void—
change(event: "change", newIndex: number): void—
rotate(event: "rotate", deg: number): void—
download(event: "download", payload: { src: string; alt: string; index: number; }): void—

Methods

MethodsSignatureDescription
close() => voidClose the viewer as the cross does: emits `update:modelValue` and `close`.
prev() => voidThe previous frame; from the first one it goes to the last.
next() => voidThe next frame; from the last one it goes to the first.
zoomIn() => voidZoom in by `zoomRate` times, no higher than `maxScale`.
zoomOut() => voidZoom out by `zoomRate` times, no lower than `minScale`.
zoomToNatural() => voidThe "one to one" scale: a real 100% rather than a nominal one.
reset() => voidReturn the frame to its original view: the scale, the rotation and the offset.
rotateLeft() => voidRotate 90° anticlockwise.
rotateRight() => voidRotate 90° clockwise.
download() => voidDownload the current frame — the same as the toolbar button does.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrImageViewerItem, GrImageViewerSource, GrImageViewerProps } from '@feugene/granularity/components/GrImageViewer'

Filedist/types/components/GrImageViewer/GrImageViewer.vue.d.ts

GrImageViewerItemtype
type GrImageViewerItem = {
    src: string;
    alt?: string;
};
GrImageViewerSourcetype
type GrImageViewerSource = string | GrImageViewerItem;
GrImageViewerPropsinterface
interface GrImageViewerProps {
    modelValue: boolean;
    urlList: GrImageViewerSource[];
    initialIndex?: number;
    zoomRate?: number;
    minScale?: number;
    maxScale?: number;
    hideOnClickModal?: boolean;
    closeOnPressEscape?: boolean;
    showProgress?: boolean;
    showZoomValue?: boolean;
    wheelZoom?: boolean;
    draggable?: boolean;
    zIndexVar?: string;
    closeLabel?: string;
    prevLabel?: string;
    nextLabel?: string;
    zoomInLabel?: string;
    zoomOutLabel?: string;
    resetZoomLabel?: string;
    zoomToNaturalLabel?: string;
    rotateLeftLabel?: string;
    rotateRightLabel?: string;
    showDownload?: boolean;
    downloadLabel?: string;
    emptyText?: string;
    ariaLabel?: string;
}
GrImageViewerEmitsinterface
interface GrImageViewerEmits {
    (e: 'update:modelValue', value: boolean): void;
    (e: 'close'): void;
    (e: 'change', newIndex: number): void;
    (e: 'rotate', deg: number): void;
    (e: 'download', payload: {
        src: string;
        alt: string;
        index: number;
    }): void;
}

Filedist/types/components/GrImageViewer/config.d.ts

grImageViewerConfigconst
const grImageViewerConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrImageViewer">;

Filedist/types/components/GrImageViewer/safelist.d.ts

grImageViewerSafelistconst
const grImageViewerSafelist: string[];

Filedist/types/components/GrImageViewer/index.d.ts

GrImageViewerInstancetype
type GrImageViewerInstance = ComponentExposed<typeof GrImageViewerComponent>;

Accessibility

Keys

Esc

Usage from the library prose

The frames: a string or { src, alt }

urlList accepts both variants, mixed together included:

vue
<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:

GestureWhat it does
Two fingersThe scale by the distance between them, the anchor is the midpoint
A finger on a fitted frame, horizontallyPaging through the frames
A finger on an enlarged frameDragging

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:

TokenWhat it colours
--gr-image-viewer-scrimthe scrim under the image
--gr-image-viewer-chrome-bgthe background of the buttons and of the toolbar
--gr-image-viewer-chrome-bg-hoverthe background of a button under the cursor
--gr-image-viewer-chrome-bg-softthe soft highlight of the toolbar buttons
--gr-image-viewer-chrome-fgthe icons and the text of the chrome
--gr-image-viewer-chrome-fg-mutedthe text of the empty state
--gr-image-viewer-chrome-brdthe borders and the separators
--gr-image-viewer-ringthe 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
vue
<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
EventWhen
update:modelValueopening/closing
closeclosing
changeanother frame is shown, the argument is the new index
rotatea rotation, the argument is the accumulated angle in degrees