CanvasStage
4 partsRoot of a headless pan/zoom canvas stage — a thin photo-editing shell over
the zoom-pan viewport that adds the three classic fit modes (fit /
1:1 / fill) on top of pan + zoom, plus automatic content-size
measurement so those modes work without the consumer hand-feeding dimensions.
It owns the master Viewport (two-way via v-model:viewport, or uncontrolled
via defaultViewport), renders an internal ViewportRoot wired with the same
model + zoom constraints + the resolved content extent, and builds the
CanvasStageContext that wraps the zoom-pan ViewportApi and adds
fitView() / zoomToActual() / fitFill() + the reactive content size. The
combined api is defineExposed so consumers can drive it (and wire their own
zoom buttons) via a template ref.
Carries role="application" (downgraded to 'group' when keyboard a11y is
disabled), aria-roledescription="zoomable canvas", and tabindex 0; pass an
aria-label via $attrs. Mount your <img> / <video> / <canvas> in the
default slot — it renders inside the single transformed layer.
Demo
Anatomy
Import the parts and compose them. Each part forwards attributes to its underlying element.
import {
CanvasStageRoot,
CanvasStagePane,
CanvasStageContent,
CanvasStageZoomIndicator,
} from '@robonen/primitives/canvas/canvas-stage';
<CanvasStageRoot>
<CanvasStagePane />
<CanvasStageContent />
<CanvasStageZoomIndicator />
</CanvasStageRoot>API Reference
CanvasStageRoot
RootRoot of a headless pan/zoom **canvas stage** — a thin photo-editing shell over the `zoom-pan` viewport that adds the three classic fit modes (**fit** / **1:1** / **fill**) on top of pan + zoom, plus automatic content-size measurement so those modes work without the consumer hand-feeding dimensions. It owns the master `Viewport` (two-way via `v-model:viewport`, or uncontrolled via `defaultViewport`), renders an internal `ViewportRoot` wired with the same model + zoom constraints + the resolved content extent, and builds the {@link CanvasStageContext} that wraps the zoom-pan {@link ViewportApi} and adds `fitView()` / `zoomToActual()` / `fitFill()` + the reactive content size. The combined api is `defineExpose`d so consumers can drive it (and wire their own zoom buttons) via a template ref. Carries `role="application"` (downgraded to `'group'` when keyboard a11y is disabled), `aria-roledescription="zoomable canvas"`, and `tabindex 0`; pass an `aria-label` via `$attrs`. Mount your `<img>` / `<video>` / `<canvas>` in the default slot — it renders inside the single transformed layer.
| Prop | Type | Description |
|---|---|---|
defaultViewport? | Viewport | Uncontrolled initial viewport (ignored when v-model:viewport is bound). |
minZoom? | number | Minimum zoom level. |
maxZoom? | number | Maximum zoom level. |
contentWidth? | number | Intrinsic content width in content-space px. When omitted (with
contentHeight) the content element is auto-measured. |
contentHeight? | number | Intrinsic content height in content-space px. When omitted (with
contentWidth) the content element is auto-measured. |
fitPadding? | number | Fractional inset on each side when fitting, 0–1. |
fitOnReady? | boolean | Fit the content into view once the pane + content are measured. |
zoomStep? | number | Multiplicative zoom factor per keyboard zoom-in/out step. |
panStep? | number | Pixel step for arrow-key panning (Shift = ×5). |
disabled? | boolean | Master interactivity switch (lock). |
disableKeyboardA11y? | boolean | Disable the keyboard a11y layer (downgrades role to 'group'). |
viewport? | Viewport | undefined | Two-way bound value (v-model:viewport). |
| Event | Payload |
|---|---|
update:viewport | [value: Viewport | undefined] |
CanvasStagePane
PaneThe inner clipping / measurement box of a `CanvasStage` (mirrors flow's `FlowPane`). It renders the zoom-pan `ViewportSurface`, so it already clips the content (`overflow: hidden`), positions relatively, disables native touch gestures (`touch-action: none`), and reports its live bounding rect into the zoom-pan context as the coordinate origin. Kept a *real* element (never `as="template"`) so `getBoundingClientRect` measures the actual clip box that the fit / 1:1 / fill maths key off — the `CanvasStageRoot` may itself be `as="template"`, which is exactly why measurement lives here and not on the Root. Rendered by `CanvasStageRoot`; not usually placed directly.
No props or events — renders its element and forwards attributes.
CanvasStageContent
ContentThe transformed content layer of a `CanvasStage`. Wraps the zoom-pan `ViewportContent` (one GPU-composited `transform`, `transform-origin: 0 0`) and renders the consumer's `<img>` / `<video>` / `<canvas>` via the default slot. When the `CanvasStageRoot` has no explicit `contentWidth`/`contentHeight`, this part measures its own intrinsic content size with a `ResizeObserver` (`useElementSize`) and reports it to the context so `fitView` / `zoomToActual` / `fitFill` can compute. `ResizeObserver` reports the *layout* (content-box) size, which is immune to the CSS `transform` the viewport applies — so the measured size is the true unscaled content size at any zoom, never the `getBoundingClientRect` post-scale geometry.
No props or events — renders its element and forwards attributes.
CanvasStageZoomIndicator
ZoomIndicatorAn accessible zoom-level announcer for a `CanvasStage`. Renders a `VisuallyHidden` `aria-live="polite"` / `aria-atomic` region that announces the current zoom percentage to screen readers. To avoid flooding the live region during a pinch / wheel gesture it announces on *settle* — the value is debounced, so a burst of per-frame zoom changes collapses into a single announcement once motion stops. The default slot receives the live `{ zoom, percent }` so consumers can ALSO render a visible indicator (e.g. a "120 %" badge) with the same value.
| Prop | Type | Description |
|---|---|---|
format? | (percent: number) => string | Build the announced/visible string from the rounded zoom percentage. |
settleDelay? | number | Debounce before announcing, in ms (the "settle" window). |