Skip to content
Brok UI

Loading…

No results

Image Viewer

Open source

Opens one image in a modal viewer built on dialog, with keyboard and button zoom (fit, actual size, in, out), drag and arrow-key panning, a polite zoom announcement and an Open original link.

Version
v1.1.0
Stability
stable
License
MIT
Related
Dialog
Image Picker
Image Mask
Carousel
Hero Video Dialog

Preview

Size
previews.components.image-viewer.default.blade.php Blade
<x-ui.image-viewer
    src="{{ asset('og-default.png') }}"
    original="{{ asset('og-default.png') }}"
    alt="{{ __('Brok documentation cover: the wordmark over a grid of component tiles') }}"
    width="600"
    height="315"
    class="w-80"
/>
Lg Current
Xl Current
Full Current

Installation

terminal
php artisan ui:add image-viewer

Note

This component ships an Alpine behavior module at resources/js/ui/image-viewer.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './image-viewer.js';

Registry contract

php artisan ui:add image-viewer writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/image-viewer.blade.php
  • js resources/js/ui/image-viewer.js
Registry dependencies
dialog button
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

image-viewer.md
# Brok UI: Image Viewer (`image-viewer`)

Opens one image in a modal viewer built on dialog, with keyboard and button zoom (fit, actual size, in, out), drag and arrow-key panning, a polite zoom announcement and an Open original link.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add image-viewer
```

## Usage

```blade
<x-ui.image-viewer
    src="{{ asset('og-default.png') }}"
    original="{{ asset('og-default.png') }}"
    alt="{{ __('Brok documentation cover: the wordmark over a grid of component tiles') }}"
    width="600"
    height="315"
    class="w-80"
/>
```

## Props

- `src` (string, default `null`) — Required. The display image, shown as the thumbnail. A missing src throws.
- `original` (string|null, default `null`) — Full-size image URL. It downloads only when the viewer opens and is the target of the Open original link. Falls back to src.
- `alt` (string, default `null`) — Required text alternative for the thumbnail, the viewer image and the stage. Also the dialog title when title is empty. An empty alt throws.
- `title` (string|null, default `null`) — Visible dialog title. Defaults to alt.
- `caption` (string|null, default `null`) — Optional caption under the title, rendered as the dialog description.
- `width` (int|null, default `null`) — Intrinsic thumbnail width, so the page reserves space before the image loads.
- `height` (int|null, default `null`) — Intrinsic thumbnail height, paired with width.
- `size` (lg|xl|full, default `full`) — Viewer panel width preset passed to dialog content. full fills a phone screen and caps at 3xl from sm up.
- `maxZoom` (float, default `8`) — Highest zoom factor relative to the fitted image. Actual size is always reachable, even above this value.
- `name` (string|null, default `null`) — Passed to the dialog as its name, so open-dialog and close-dialog window events can open the viewer from elsewhere when the installed dialog supports names.
- `fill` (bool, default `false`) — Stretches the viewer root and its trigger button to fill the parent box, for a tile in a grid or strip that draws its own thumbnail through the trigger slot. Without JavaScript the dialog's message is kept for screen readers only and the Open the original image link sits over the tile's bottom-start corner. Off by default: the root stays an inline block.
- `noScriptMessage` (string, default `Zoom needs JavaScript. Use the link to open the original image.`) — Message the dialog shows without JavaScript, next to the Open the original image link.

## Use when

- Use when image or video selection, preview, or media-based comparison is part of the task.
- Letting a reader inspect one image at full size: a screenshot, a scanned document, a product photo or a diagram whose detail is too small in the page.
- Offering zoom and pan with the keyboard as well as the pointer, plus a link to the original file.

## Avoid when

- Do not require media-heavy interaction when a simpler text or list solution would be faster.
- Browsing a set of images one after another; use carousel or a gallery block.
- Picking images; use image-picker.
- A decorative image that carries no detail worth inspecting; use a plain img or image-mask.

## Anti-patterns

- Requiring media-heavy interaction when text is faster

## Rules

- Use the `<brok:image-viewer>` tag (or `<x-ui.image-viewer>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/image-viewer
- Registry JSON (files, props, contract): https://brokui.dev/r/open/image-viewer.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

caption.blade.php Blade
<x-ui.image-viewer
    src="{{ asset('og-default.png') }}"
    alt="{{ __('Brok documentation cover') }}"
    title="{{ __('Documentation cover') }}"
    caption="{{ __('Shared as the default social card. Zoom in to check the tile edges at full resolution.') }}"
    width="600"
    height="315"
    size="xl"
    class="w-80"
/>
custom-trigger.blade.php Blade
<x-ui.image-viewer
    src="{{ asset('og-default.png') }}"
    alt="{{ __('Brok documentation cover') }}"
>
    <x-slot:trigger>
        <span class="inline-flex items-center gap-2 rounded-md border border-border bg-card px-4 py-2 text-sm text-foreground">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="m21 15-5-5L5 21" /></svg>
            {{ __('View the cover image') }}
        </span>
    </x-slot:trigger>
</x-ui.image-viewer>
long-content.blade.php Blade
<div class="max-w-xs">
    <x-ui.image-viewer
        src="{{ asset('og-default.png') }}"
        alt="{{ __('A deliberately long text alternative that describes every region of the documentation cover image, so the dialog title must wrap across several lines without clipping or pushing the close button off the panel') }}"
        caption="{{ __('A long caption checks that the description wraps too: Donaudampfschifffahrtsgesellschaftskapitänsmütze, https://example.com/a/very/long/path/that/does/not/break/naturally/at/all') }}"
        width="600"
        height="315"
    />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
src string null Required. The display image, shown as the thumbnail. A missing src throws.
original string | null null Full-size image URL. It downloads only when the viewer opens and is the target of the Open original link. Falls back to src.
alt string null Required text alternative for the thumbnail, the viewer image and the stage. Also the dialog title when title is empty. An empty alt throws.
title string | null null Visible dialog title. Defaults to alt.
caption string | null null Optional caption under the title, rendered as the dialog description.
width int | null null Intrinsic thumbnail width, so the page reserves space before the image loads.
height int | null null Intrinsic thumbnail height, paired with width.
size lg | xl | full full Viewer panel width preset passed to dialog content. full fills a phone screen and caps at 3xl from sm up.
maxZoom float 8 Highest zoom factor relative to the fitted image. Actual size is always reachable, even above this value.
name string | null null Passed to the dialog as its name, so open-dialog and close-dialog window events can open the viewer from elsewhere when the installed dialog supports names.
fill bool false Stretches the viewer root and its trigger button to fill the parent box, for a tile in a grid or strip that draws its own thumbnail through the trigger slot. Without JavaScript the dialog's message is kept for screen readers only and the Open the original image link sits over the tile's bottom-start corner. Off by default: the root stays an inline block.
noScriptMessage string Zoom needs JavaScript. Use the link to open the... Message the dialog shows without JavaScript, next to the Open the original image link.

Slots

  • trigger — Custom content for the thumbnail button (for example a card). When empty, the thumbnail image renders as the trigger. Give it a text alternative: the button takes its name from this content.

Data slots

Stable hooks for CSS overrides and browser tests.

image-viewer image-viewer-image image-viewer-live image-viewer-noscript image-viewer-stage image-viewer-thumbnail image-viewer-toolbar image-viewer-zoom

Behavior

  • The thumbnail is a dialog trigger. Opening sets the full-size src, fits the image and moves focus to the image stage.
  • Plus or equals zooms in, minus zooms out, 0 fits, 1 shows actual size (one image pixel per CSS pixel). The keys work from any control in the panel. Zoom in, Zoom out, Fit and Actual size buttons do the same.
  • While zoomed, pointer drag and the arrow keys pan the image; the pan stops at the image edges. A trackpad pinch (wheel with ctrlKey) zooms.
  • Scale and pan live in the --image-viewer-scale, --image-viewer-x and --image-viewer-y custom properties on the stage; the image transform reads them. Zoom is instant, never animated.
  • Each zoom change is announced politely through window.UI.announce when present, otherwise through the panel's own live region.
  • Closing (Escape, close button, backdrop) fits the image again and returns focus to the thumbnail.
  • With fill, the root carries data-fill="true" and is a full-size block, so a composing tile (media-grid, flow-strip) sizes the trigger; everything else is unchanged.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, responsive, rtl, darkMode, localized.

Guidance

Media selection and presentation

Select, preview, or compare visual media.

Use when

  • Use when image or video selection, preview, or media-based comparison is part of the task.
  • Letting a reader inspect one image at full size: a screenshot, a scanned document, a product photo or a diagram whose detail is too small in the page.
  • Offering zoom and pan with the keyboard as well as the pointer, plus a link to the original file.

Avoid when

  • Do not require media-heavy interaction when a simpler text or list solution would be faster.
  • Browsing a set of images one after another; use carousel or a gallery block.
  • Picking images; use image-picker.
  • A decorative image that carries no detail worth inspecting; use a plain img or image-mask.

Use instead

  • Text or list representation

Anti-patterns

  • Requiring media-heavy interaction when text is faster
Anatomy
image-viewer dialog-trigger image-viewer-thumbnail dialog-content image-viewer-stage image-viewer-image image-viewer-toolbar image-viewer-zoom image-viewer-live image-viewer-noscript
Theming hooks
image-viewer dialog content button

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Enter Space + = - 1 ArrowLeft ArrowRight ArrowUp ArrowDown Tab Escape
Focus
Focus moves to the image stage on open and returns to the thumbnail on every close path.
  • The stage is a focusable group with a roledescription of zoomable image, the alt as its name and a description of the keys.
  • Zoom buttons have accessible names; at the limits they are aria-disabled rather than disabled, so focus never drops out of the dialog.
  • Without JavaScript a link to the original image stays available.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="image-viewer-{{ $record->id }}">
    <x-ui.image-viewer
        src="{{ asset('og-default.png') }}"
        original="{{ asset('og-default.png') }}"
        alt="{{ __('Brok documentation cover: the wordmark over a grid of component tiles') }}"
        width="600"
        height="315"
        class="w-80"
    />
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/image-viewer.blade.php Blade
@props([
    // Display image: the thumbnail on the page.
    'src' => null,
    // Full-size image URL. Loaded only when the viewer opens, used for zoom
    // and for the "Open original" link. Falls back to `src`.
    'original' => null,
    // Required text alternative. Also the dialog title when `title` is empty.
    'alt' => null,
    // Visible dialog title. Defaults to `alt`.
    'title' => null,
    // Optional caption under the image in the viewer (the dialog description).
    'caption' => null,
    // Intrinsic thumbnail size, so the page reserves the space before load.
    'width' => null,
    'height' => null,
    // Viewer panel width preset, passed to dialog content: 'lg' | 'xl' | 'full'.
    'size' => 'full',
    // Highest zoom factor relative to the fitted image.
    'maxZoom' => 8,
    // Optional dialog name, so `open-dialog` / `close-dialog` window events can
    // open this viewer from elsewhere once the dialog supports names.
    'name' => null,
    // Stretch the viewer root and its trigger button to fill the parent box,
    // for a tile in a grid or strip that draws its own thumbnail through the
    // `trigger` slot. Off by default: the root stays an inline block.
    'fill' => false,
    'noScriptMessage' => 'Zoom needs JavaScript. Use the link to open the original image.',
])

@php
    if (blank($src)) {
        throw new \InvalidArgumentException('image-viewer needs a src.');
    }
    if (blank($alt)) {
        throw new \InvalidArgumentException('image-viewer needs a non-empty alt text.');
    }

    $fullSrc = filled($original) ? $original : $src;
    $heading = filled($title) ? $title : $alt;
    $sizeKey = in_array($size, ['lg', 'xl', 'full'], true) ? $size : 'full';
    $stageHeight = $sizeKey === 'full' ? 'min-h-0 flex-1' : 'h-[min(60dvh,32rem)]';
    $fill = filter_var($fill, FILTER_VALIDATE_BOOLEAN);
@endphp

<div
    x-data="uiImageViewer({
        original: @js($fullSrc),
        maxZoom: @js((float) $maxZoom),
        messages: {
            zoom: @js(__('Zoom :percent%')),
            fit: @js(__('Fitted to the screen')),
        },
    })"
    data-slot="image-viewer"
    x-id="['image-viewer-help']"
    @if ($fill) data-fill="true" @endif
    {{ $attributes->merge(['class' => $fill ? 'relative block size-full min-w-0 [&_[data-slot=dialog-noscript]]:sr-only' : 'inline-block min-w-0 max-w-full']) }}
>
    <x-ui.dialog :name="filled($name) ? $name : null" :no-script-message="$noScriptMessage" hooks="{ onOpen: () => opened(), onClose: () => closed() }">
        <x-ui.dialog.trigger
            class="group relative !block max-w-full {{ $fill ? 'size-full' : '' }} cursor-zoom-in overflow-hidden rounded-md focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background"
        >
            @if (isset($trigger) && $trigger->isNotEmpty())
                {{ $trigger }}
            @else
                <img
                    src="{{ $src }}"
                    alt="{{ $alt }}"
                    @if (filled($width)) width="{{ (int) $width }}" @endif
                    @if (filled($height)) height="{{ (int) $height }}" @endif
                    loading="lazy"
                    decoding="async"
                    data-slot="image-viewer-thumbnail"
                    class="block h-auto max-w-full rounded-md border border-border bg-muted object-cover"
                />
            @endif
        </x-ui.dialog.trigger>

        <x-ui.dialog.content :size="$sizeKey" x-on:keydown="onKeydown($event)" class="flex flex-col gap-4">
            <x-ui.dialog.header class="pe-12">
                <x-ui.dialog.title class="min-w-0 break-words">{{ $heading }}</x-ui.dialog.title>
                @if (filled($caption))
                    <x-ui.dialog.description class="break-words">{{ $caption }}</x-ui.dialog.description>
                @endif
            </x-ui.dialog.header>
            <x-ui.dialog.close />

            <div
                data-slot="image-viewer-stage"
                x-ref="stage"
                tabindex="0"
                role="group"
                aria-roledescription="{{ __('zoomable image') }}"
                aria-label="{{ $alt }}"
                :aria-describedby="$id('image-viewer-help')"
                :data-zoomed="zoomed ? 'true' : 'false'"
                :class="zoomed ? (dragging ? 'cursor-grabbing touch-none' : 'cursor-grab touch-none') : ''"
                data-autofocus
                @pointerdown="startPan($event)"
                @pointermove="movePan($event)"
                @pointerup="endPan($event)"
                @pointercancel="endPan($event)"
                @wheel="onWheel($event)"
                style="--image-viewer-scale: 1; --image-viewer-x: 0px; --image-viewer-y: 0px;"
                class="relative flex {{ $stageHeight }} w-full select-none items-center justify-center overflow-hidden rounded-md bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
            >
                <img
                    x-ref="image"
                    :src="loaded ? original : null"
                    alt="{{ $alt }}"
                    decoding="async"
                    draggable="false"
                    data-slot="image-viewer-image"
                    @load="imageReady()"
                    style="transform: translate3d(var(--image-viewer-x), var(--image-viewer-y), 0) scale(var(--image-viewer-scale)); transform-origin: center;"
                    class="pointer-events-none block max-h-full max-w-full object-contain"
                />
                <p :id="$id('image-viewer-help')" class="sr-only">{{ __('Press plus or minus to zoom, 0 to fit, 1 for actual size, and the arrow keys to pan.') }}</p>
            </div>

            <div data-slot="image-viewer-toolbar" class="flex min-w-0 flex-wrap items-center gap-2">
                <x-ui.button variant="outline" icon-label="{{ __('Zoom out') }}" data-action="zoom-out" x-on:click="zoomOut()" x-bind:aria-disabled="scale <= 1 ? 'true' : 'false'">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5M8 11h6" /></svg>
                </x-ui.button>
                <x-ui.button variant="outline" icon-label="{{ __('Zoom in') }}" data-action="zoom-in" x-on:click="zoomIn()" x-bind:aria-disabled="scale >= maxScale ? 'true' : 'false'">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5M8 11h6M11 8v6" /></svg>
                </x-ui.button>
                <x-ui.button variant="outline" data-action="fit" x-on:click="fit()">{{ __('Fit') }}</x-ui.button>
                <x-ui.button variant="outline" data-action="actual-size" x-on:click="actualSize()">{{ __('Actual size') }}</x-ui.button>
                <span data-slot="image-viewer-zoom" class="min-w-12 text-sm tabular-nums text-muted-foreground" x-text="percentLabel" aria-hidden="true"></span>
                <x-ui.button variant="link" :href="$fullSrc" target="_blank" rel="noopener noreferrer" data-action="open-original" class="ms-auto">{{ __('Open original') }}<span class="sr-only"> {{ __('(opens in a new tab)') }}</span></x-ui.button>
            </div>

            <p data-slot="image-viewer-live" aria-live="polite" class="sr-only" x-text="announcement"></p>
        </x-ui.dialog.content>
    </x-ui.dialog>

    <noscript>
        <a href="{{ $fullSrc }}" data-slot="image-viewer-noscript" class="{{ $fill ? 'absolute bottom-2 start-2 rounded-sm bg-background px-2 py-1' : 'mt-2 inline-block' }} text-sm text-foreground underline underline-offset-4">{{ __('Open the original image') }}</a>
    </noscript>
</div>
resources/js/ui/image-viewer.js JS
/**
 * Image Viewer behaviour: zoom and pan inside a dialog.
 *
 * The modal itself is the dialog item (`<x-ui.dialog>` in the Blade): focus
 * trap, Escape and backdrop dismissal, scroll lock, the inert sweep and focus
 * return to the thumbnail are its job. This component owns the image:
 *  - the dialog's `onOpen` hook calls `opened()`, which sets the full-size
 *    `src` (so the original only downloads when the viewer opens);
 *  - the dialog's `onClose` hook calls `closed()`, which fits the image again.
 *
 * Scale is relative to the fitted image (1 = fit). `actual` is the scale at
 * which one image pixel is one CSS pixel. Scale and pan offsets are written to
 * the stage as `--image-viewer-scale`, `--image-viewer-x`, `--image-viewer-y`;
 * the image reads them in its transform. Zoom is instant, never animated, so
 * reduced-motion visitors see no motion either.
 *
 * Keys (on any element inside the panel): `+`/`=` zoom in, `-` zoom out,
 * `0` fit, `1` actual size, arrow keys pan while zoomed. Pointer drag pans
 * while zoomed; a pinch (wheel with ctrlKey) zooms.
 *
 * The zoom level is announced politely through `window.UI.announce` when the
 * runtime provides it, otherwise through the panel's own live region.
 *
 * `destroy()` removes the window resize listener.
 */
const STEP = 1.5;
const PAN_STEP = 48;

document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiImageViewer', (config = {}) => ({
        original: typeof config.original === 'string' ? config.original : '',
        maxZoom: Number(config.maxZoom) > 1 ? Number(config.maxZoom) : 8,
        messages: {
            zoom: config.messages?.zoom ?? 'Zoom :percent%',
            fit: config.messages?.fit ?? 'Fitted to the screen',
        },
        loaded: false,
        scale: 1,
        actual: 1,
        x: 0,
        y: 0,
        dragging: false,
        dragStart: null,
        announcement: '',
        resizeListener: null,

        get zoomed() {
            return this.scale > 1.001;
        },

        get maxScale() {
            return Math.max(this.maxZoom, this.actual);
        },

        get percentLabel() {
            return `${Math.round((this.scale / this.actual) * 100)}%`;
        },

        destroy() {
            this.removeResizeListener();
        },

        /** Dialog `onOpen` hook. */
        opened() {
            this.loaded = true;
            this.reset();
            if (!this.resizeListener) {
                this.resizeListener = () => {
                    this.measure();
                    this.setScale(Math.min(this.scale, this.maxScale), false);
                };
                window.addEventListener('resize', this.resizeListener);
            }
            this.$nextTick(() => this.measure());
        },

        /** Dialog `onClose` hook. */
        closed() {
            this.removeResizeListener();
            this.dragging = false;
            this.dragStart = null;
            this.reset();
        },

        removeResizeListener() {
            if (this.resizeListener) {
                window.removeEventListener('resize', this.resizeListener);
                this.resizeListener = null;
            }
        },

        imageReady() {
            this.measure();
        },

        /** Scale at which one image pixel is one CSS pixel, from the fitted layout. */
        measure() {
            const image = this.$refs.image;
            if (!image || !image.naturalWidth || !image.clientWidth) {
                return;
            }
            this.actual = Math.max(1, image.naturalWidth / image.clientWidth);
        },

        reset() {
            this.scale = 1;
            this.x = 0;
            this.y = 0;
            this.apply();
        },

        zoomIn() {
            this.setScale(this.scale * STEP);
        },

        zoomOut() {
            this.setScale(this.scale / STEP);
        },

        fit() {
            this.setScale(1);
        },

        actualSize() {
            this.measure();
            this.setScale(this.actual);
        },

        setScale(next, announce = true) {
            const clamped = Math.min(this.maxScale, Math.max(1, next));
            const ratio = clamped / this.scale;
            this.scale = clamped;
            this.x *= ratio;
            this.y *= ratio;
            this.clampPan();
            this.apply();
            if (announce) {
                this.announce(this.zoomed ? this.messages.zoom.replace(':percent', String(Math.round((this.scale / this.actual) * 100))) : this.messages.fit);
            }
        },

        panBy(dx, dy) {
            this.x += dx;
            this.y += dy;
            this.clampPan();
            this.apply();
        },

        /** Keep the scaled image covering the stage: never drag it past an edge. */
        clampPan() {
            const image = this.$refs.image;
            const stage = this.$refs.stage;
            if (!image || !stage) {
                return;
            }
            const maxX = Math.max(0, (image.clientWidth * this.scale - stage.clientWidth) / 2);
            const maxY = Math.max(0, (image.clientHeight * this.scale - stage.clientHeight) / 2);
            this.x = Math.min(maxX, Math.max(-maxX, this.x));
            this.y = Math.min(maxY, Math.max(-maxY, this.y));
        },

        apply() {
            const stage = this.$refs.stage;
            if (!stage) {
                return;
            }
            stage.style.setProperty('--image-viewer-scale', String(this.scale));
            stage.style.setProperty('--image-viewer-x', `${this.x}px`);
            stage.style.setProperty('--image-viewer-y', `${this.y}px`);
        },

        announce(message) {
            if (typeof window.UI?.announce === 'function') {
                window.UI.announce(message, { politeness: 'polite' });
                return;
            }
            // Clear first so the same message twice in a row is read again.
            this.announcement = '';
            this.$nextTick(() => {
                this.announcement = message;
            });
        },

        onKeydown(event) {
            if (event.ctrlKey || event.metaKey || event.altKey) {
                return;
            }
            const pans = {
                ArrowLeft: [PAN_STEP, 0],
                ArrowRight: [-PAN_STEP, 0],
                ArrowUp: [0, PAN_STEP],
                ArrowDown: [0, -PAN_STEP],
            };
            if (event.key === '+' || event.key === '=') {
                this.zoomIn();
            } else if (event.key === '-' || event.key === '_') {
                this.zoomOut();
            } else if (event.key === '0') {
                this.fit();
            } else if (event.key === '1') {
                this.actualSize();
            } else if (pans[event.key] && this.zoomed) {
                this.panBy(...pans[event.key]);
            } else {
                return;
            }
            event.preventDefault();
        },

        onWheel(event) {
            // Trackpad pinch arrives as a wheel event with ctrlKey set.
            if (!event.ctrlKey) {
                return;
            }
            event.preventDefault();
            this.setScale(this.scale * Math.exp(-event.deltaY * 0.01), false);
        },

        startPan(event) {
            if (!this.zoomed || event.button !== 0) {
                return;
            }
            this.dragging = true;
            this.dragStart = { pointer: event.pointerId, px: event.clientX, py: event.clientY, x: this.x, y: this.y };
            this.$refs.stage.setPointerCapture?.(event.pointerId);
            event.preventDefault();
        },

        movePan(event) {
            if (!this.dragging || !this.dragStart || event.pointerId !== this.dragStart.pointer) {
                return;
            }
            this.x = this.dragStart.x + (event.clientX - this.dragStart.px);
            this.y = this.dragStart.y + (event.clientY - this.dragStart.py);
            this.clampPan();
            this.apply();
        },

        endPan(event) {
            if (!this.dragStart || event.pointerId !== this.dragStart.pointer) {
                return;
            }
            this.$refs.stage.releasePointerCapture?.(event.pointerId);
            this.dragging = false;
            this.dragStart = null;
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.0
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0