CanvasStage

4 parts

Root 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

Loading demo…

Anatomy

Import the parts and compose them. Each part forwards attributes to its underlying element.

vue
import {
  CanvasStageRoot,
  CanvasStagePane,
  CanvasStageContent,
  CanvasStageZoomIndicator,
} from '@robonen/primitives/canvas/canvas-stage';

<CanvasStageRoot>
  <CanvasStagePane />
  <CanvasStageContent />
  <CanvasStageZoomIndicator />
</CanvasStageRoot>

API Reference

CanvasStageRoot

Root

Root 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.

PropTypeDescription
defaultViewport?ViewportUncontrolled initial viewport (ignored when v-model:viewport is bound).
minZoom?numberMinimum zoom level.
maxZoom?numberMaximum zoom level.
contentWidth?numberIntrinsic content width in content-space px. When omitted (with contentHeight) the content element is auto-measured.
contentHeight?numberIntrinsic content height in content-space px. When omitted (with contentWidth) the content element is auto-measured.
fitPadding?numberFractional inset on each side when fitting, 0–1.
fitOnReady?booleanFit the content into view once the pane + content are measured.
zoomStep?numberMultiplicative zoom factor per keyboard zoom-in/out step.
panStep?numberPixel step for arrow-key panning (Shift = ×5).
disabled?booleanMaster interactivity switch (lock).
disableKeyboardA11y?booleanDisable the keyboard a11y layer (downgrades role to 'group').
viewport?Viewport | undefinedTwo-way bound value (v-model:viewport).
Emits
EventPayload
update:viewport[value: Viewport | undefined]

CanvasStagePane

Pane

The 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

Content

The 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

ZoomIndicator

An 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.

PropTypeDescription
format?(percent: number) => stringBuild the announced/visible string from the rounded zoom percentage.
settleDelay?numberDebounce before announcing, in ms (the "settle" window).