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.
Preview
Brok documentation cover: the wordmark over a grid of component tiles
Press plus or minus to zoom, 0 to fit, 1 for actual size, and the arrow keys to pan.
<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"
/>
Installation
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:
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.
-
resources/views/components/ui/image-viewer.blade.php -
resources/js/ui/image-viewer.js
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.
# 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
<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
<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
<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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
@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>
/**
* 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