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)returnspoint / zoom + (x, y). The app converts every pointer position with it before it calls the editor, so features only seePagePoint.pageToScreen(camera, point)is the inverse.
Zooming and panning
-
zoomAt(camera, anchor, factor)multiplies the zoom byfactorand keeps the page point under the screen pointanchorwhere it is. Use the cursor as the anchor for wheel zoom. The zoom is clamped toMIN_ZOOM(0.1) andMAX_ZOOM(30), the same range as Excalidraw. The anchor stays fixed after clamping too. -
panBy(camera, delta)moves the content bydeltascreen pixels. A drag to the right by 40 pixels ispanBy(camera, screenPoint(40, 0)). -
wheelZoomFactor(deltaY)turns a wheel delta into a factor forzoomAt. The delta is clamped to 10 in either direction, then the factor isexp(-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.
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 bycompareShapes, 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 }.