Skip to content

Camera

type Camera = { readonly x: number; readonly y: number; readonly zoom: number };
type View = { readonly camera: Camera; readonly viewport: Box };

x and y are the page point at the screen origin. zoom is screen pixels per page unit. viewport is the canvas area in screen space, usually { minX: 0, minY: 0, maxX: width, maxY: height }.

The camera is not part of Editor and is never in the history. The app keeps it as a separate value. A zoom during a drag cannot be rolled back when the drag commits, and undo never moves the view. See Architecture.

Converting points

  • screenToPage(camera, point) returns point / zoom + (x, y). The app converts every pointer position with it before it calls the editor, so features only see PagePoint.
  • pageToScreen(camera, point) is the inverse.

Zooming and panning

  • zoomAt(camera, anchor, factor) multiplies the zoom by factor and keeps the page point under the screen point anchor where it is. Use the cursor as the anchor for wheel zoom. The zoom is clamped to MIN_ZOOM (0.1) and MAX_ZOOM (30), the same range as Excalidraw. The anchor stays fixed after clamping too.

  • panBy(camera, delta) moves the content by delta screen pixels. A drag to the right by 40 pixels is panBy(camera, screenPoint(40, 0)).

  • wheelZoomFactor(deltaY) turns a wheel delta into a factor for zoomAt. The delta is clamped to 10 in either direction, then the factor is exp(-delta / 100). A full wheel tick of 10 zooms out by about 10 percent, which is Excalidraw’s step of 0.1 at 100%. A trackpad pinch arrives as a Ctrl+wheel with small deltas and zooms in proportion. The factor is multiplicative, so zoom steps feel the same at 25% and at 400%.

  • resetZoom(view) sets the zoom to exactly 1 and keeps the page point at the centre of the viewport where it is. This is Shift+0.

Zoom to fit

zoomToFit(doc, viewport) returns the camera that shows every shape of doc, centred, with FIT_MARGIN (48) screen pixels free on each side. It returns undefined for an empty document, so the shell leaves the camera alone. The zoom is clamped to MIN_ZOOM and never goes above FIT_MAX_ZOOM (1). Excalidraw’s Shift+1 does the same: small content is centred at 100% and only large content is zoomed out.

docs/examples/camera-fit.ts
import {
EMPTY_DOC,
pageToScreen,
putShapes,
resetZoom,
wheelZoomFactor,
zoomAt,
zoomToFit,
} from "@draw/editor";
import type { Camera, View } from "@draw/editor";
import { DEFAULT_STYLE, pagePoint, parseZIndex, radians, screenPoint, shapeId } from "@draw/shapes";
import type { ScreenPoint, Shape, ZIndex } from "@draw/shapes";
function firstIndex(): ZIndex {
const parsed = parseZIndex("a0");
if (!parsed.ok) throw new Error("bad index");
return parsed.value;
}
function box(id: string, x: number, y: number): Shape {
return {
id: shapeId(id),
frame: { x, y, width: 100, height: 100, rotation: radians(0) },
drawable: { kind: "rectangle", cornerRadius: 0 },
index: firstIndex(),
style: DEFAULT_STYLE,
groupIds: [],
};
}
const viewport = { minX: 0, minY: 0, maxX: 800, maxY: 600 };
const doc = putShapes(EMPTY_DOC, [box("a", 0, 0), box("b", 1900, 900)]);
export const fitted: Camera | undefined = zoomToFit(doc, viewport);
export const emptyFit: Camera | undefined = zoomToFit(EMPTY_DOC, viewport);
const cursor = screenPoint(200, 100);
const start: Camera = { x: 0, y: 0, zoom: 1 };
export const zoomedIn: Camera = zoomAt(start, cursor, wheelZoomFactor(-10));
export const underCursor: ScreenPoint = pageToScreen(zoomedIn, pagePoint(200, 100));
const view: View = { camera: zoomedIn, viewport };
export const reset: Camera = resetZoom(view);

Culling

  • visibleBox(view) is the viewport in page space.
  • visibleShapes(doc, view) returns the shapes whose frame bounds touch the visible box, sorted by compareShapes, ready to draw. A shape whose frame lies just outside the box is skipped even if a thick stroke would reach into view.
const view: View = {
camera: { x: 250, y: -50, zoom: 2 },
viewport: { minX: 0, minY: 0, maxX: 400, maxY: 200 },
};
visibleBox(view);

The result is { minX: 250, minY: -50, maxX: 450, maxY: 50 }.