Editor
import { Editor } from '@headless-canvas/core'
const editor = new Editor({ container })The editor owns a canvas for the shapes and a DOM overlay for the controls, and keeps them in step. There is no module-level state anywhere, so several editors can coexist on one page.
Construction
interface EditorOptions {
container: HTMLElement // the editor builds its canvas and overlay inside this
shapeUtils?: readonly ShapeUtil<any>[] // replaces the default set
initialDocument?: HcDocument
messages?: Partial<Messages>
zoomRange?: readonly [number, number] // defaults to [0.02, 64]
hitTolerance?: number // click slop in screen pixels, default 5
history?: HistoryOptions
snapping?: Partial<SnapSettings>
}The container is given position: relative if it is static, overflow: hidden, touch-action: none, tabindex="0" if it has none, role="application" and the hc-container class. It must have a size of its own; the editor observes it and resizes the canvas to match.
Elements
editor.container // HTMLElement — what you passed in
editor.canvasElement // HTMLCanvasElement — aria-hidden
editor.overlayElement // HTMLElement — carries the viewport transformPut your own control DOM inside overlayElement, in world coordinates. It is the only element the camera transform is written to, which is what makes panning cost the same at any control count.
Sub-objects
editor.registry // ShapeUtilRegistry
editor.controls // Controls — see /api/controls
editor.resources // ResourceCache
editor.history // History
editor.selection // selection API, below
editor.viewport // camera API, below
editor.tools // tool registry, belowLifecycle
dispose()
Stops the render loop, disconnects the resize observer, releases cached resources and removes the elements it created. Calling any method afterwards throws. Required in a single-page application.
State
getSnapshot(): StoreSnapshot
interface StoreSnapshot {
readonly version: number
readonly shapes: ReadonlyMap<ShapeId, AnyShape>
readonly rootChildren: readonly ShapeId[] // paint order
readonly paintOrder: readonly ShapeId[] // everything, depth-first
readonly selectedIds: readonly ShapeId[]
}Immutable with structural sharing. Never shows an intermediate state from inside a transaction.
getRenderVersion(): number
A cache key covering committed changes, ephemeral changes and the camera. Compare it to skip work when nothing has moved.
subscribe(listener): () => void
Committed state changed. Never called from inside a transaction.
subscribeEphemeral(listener): () => void
An in-flight interaction changed.
subscribeNotifications(listener: (n: Notification) => void): () => void
Recoverable problems:
interface Notification {
level: 'warning' | 'error'
code: 'resource-load-failed' | 'unknown-shape-type' | 'export-failed' | 'schema-migration-failed'
message: string
detail?: unknown
}transact<T>(fn: () => T, options?: TransactOptions): T
One user action, one commit: subscribers are notified once and the history gets one entry. Reads inside see writes made earlier in the same transaction.
interface TransactOptions {
addToHistory?: boolean // false for changes the user did not make
mergeKey?: string // consecutive transactions sharing a key collapse
}notify(notification: Notification): void
Push onto the notification channel yourself — useful from a custom shape or tool.
Shapes
createShape<K>(input: CreateShapeInput<K>): ShapeId
type CreateShapeInput<K> = Partial<Omit<ShapeRegistry[K], 'id' | 'type' | 'props' | 'index'>> & {
type: K
props?: Partial<ShapeRegistry[K]['props']>
}Anything omitted comes from the type's defaults. props is merged with them.
getShape<K>(id): ShapeRegistry[K] | undefined
The committed record. Reads back writes made earlier in the same transaction.
getResolvedShape(id): AnyShape | undefined
The shape with ephemeral changes applied — what is on screen right now.
updateShape(id, changes: Partial<AnyShape>): void
props is replaced wholesale when supplied, so spread the existing value to change one field.
deleteShapes(ids: readonly ShapeId[]): void
Descendants go with their parents.
applyPatch(patches: readonly Patch[], options?: TransactOptions): void
Apply changes from outside — the same representation the history uses.
WARNING
History correctness under externally applied patches is not guaranteed in v1.0.
Ephemeral state
setEphemeral(changes: ReadonlyMap<ShapeId, Partial<AnyShape>>): void
commitEphemeral(options?: TransactOptions): void
clearEphemeral(): voidWrite these during a drag and commit once at the end. See Ephemeral state.
Hierarchy and order
getChildren(parentId: ShapeId | null): ShapeId[] // paint order
getAncestors(id: ShapeId): ShapeId[] // nearest first
setParent(ids, parentId: ShapeId | null, at?: ZIndexAnchor): void // world appearance preserved
moveTo(ids, anchor: ZIndexAnchor): void
reorder(ids, to: 'front' | 'back' | 'forward' | 'backward'): void
group(ids): ShapeId | null // the new group, or null if fewer than two
ungroup(groupId): ShapeId[] // the freed childrentype ZIndexAnchor =
| { before: ShapeId }
| { after: ShapeId }
| { position: 'first' | 'last' }Neither grouping nor ungrouping bakes the transform into the children.
Geometry
getWorldTransform(id): Matrix | null
getShapeBounds(id, space?: 'world' | 'screen'): OrientedBounds | null
hitTest(screenPoint: Vec): ShapeId | null // topmost; returns the outermost group
hitTestArea(screenBounds: Bounds): ShapeId[] // skips locked shapes
getVisibleShapeIds(): ShapeId[]hitTest returns the outermost enclosing group when it hits a grouped child, which is what users expect from a grouped object.
Selection
editor.selection.ids // readonly ShapeId[]
editor.selection.set(ids)
editor.selection.add(ids)
editor.selection.remove(ids)
editor.selection.clear()
editor.selection.selectAll()
editor.selection.getBounds() // OrientedBounds | nullA single selection reports the shape's rotated box; a multi-selection reports an axis-aligned one.
Viewport
editor.viewport.camera // { x, y, z }
editor.viewport.setCamera(next: Partial<Camera>)
editor.viewport.panBy(delta: Vec) // screen pixels
editor.viewport.zoomTo(z, centerInScreen?: Vec) // keeps that point fixed
editor.viewport.zoomToFit(ids?, padding = 40)
editor.viewport.screenToWorld(p: Vec): Vec
editor.viewport.worldToScreen(p: Vec): Vec
editor.viewport.getVisibleBounds(): BoundsZoom is clamped to zoomRange. The viewport is not part of the history.
Tools
editor.tools.register(id: string, factory: (editor: Editor) => Tool): void
editor.tools.setCurrent(id: string): void // throws on an unregistered id
editor.tools.current // string
editor.tools.instance // Tool | null
editor.tools.state // ToolState
editor.tools.setState(state: ToolState): void // drives data-hc-state
editor.tools.cancel(): void
editor.getBrush(): Bounds | null // from the active tool
editor.interactionState // alias of tools.stateselect, hand and draw are registered by default, on v, h and d. Registering an id that already exists replaces it — including the live instance if that tool is active, which is how a tool is reconfigured:
import { DrawTool } from '@headless-canvas/core'
editor.tools.register('draw', (e) => new DrawTool(e, { color: '#f00', width: 8 }))See Tools for DrawToolOptions and how a stroke is fitted.
Text editing
editor.editing.id // ShapeId | null
editor.editing.initialText // string | null
editor.editing.canEdit(id): boolean // false for locked, or no text
editor.editing.begin(id): boolean
editor.editing.commit(text: string): void // one history entry; no-ops if unchanged
editor.editing.cancel(): void
editor.editing.subscribe(fn): () => voidA shape is editable exactly when its ShapeUtil implements both getText and setText. begin also selects the shape and sets data-hc-state to editing.
subscribe is a separate channel because a session is not a document change — the store does not move, so editor.subscribe never fires for one. See Editing text.
Snapping
editor.snapping // Readonly<SnapSettings>
editor.setSnapping(settings: Partial<SnapSettings>): void
editor.getSnapGuides(): readonly SnapGuide[]
editor.computeSnap(proposed: Bounds, exclude: ReadonlySet<ShapeId>): SnapResult
editor.clearSnapGuides(): voidInput helpers
editor.beginHandleInteraction(handle: HandleId, event: PointerEvent): void
editor.nudgeHandle(handle: HandleId, delta: Vec): void
editor.nudgeSelection(delta: Vec): voidbindHandle calls the first two for you. Use them directly only if you are binding handles by hand.
Documents and export
editor.toJSON(meta?: Record<string, unknown>, options?: SerializeOptions): HcDocument
editor.loadDocument(document: HcDocument): void // clears the history
editor.getSelectionAsDocument(): HcDocument
editor.insertDocument(document: HcDocument, at?: Vec): ShapeId[]
editor.export(options?: ExportOptions): Promise<Blob> // png, jpeg
editor.exportSvg(options?: SvgExportOptions): string // synchronous; no pixels to encodeexport rejects with HcTaintedCanvasError when cross-origin images have tainted the canvas; the error names them.
exportSvg builds the document from each shape's getPath or toSvg; a shape that implements neither is left out and reported as export-failed. Both throw when there is nothing to export.
toJSON's { embedImages: true } inlines referenced images into HcDocument.resources so the document stands alone. The shapes are not rewritten — they keep naming the URL each image came from. Images a loaded document already carried are written back whether or not this save asked to embed. See Documents and export.
Rendering
editor.onFrame(render: () => void): () => void
editor.getRenderStats(): { drawn: number; culled: number; indexed: number }onFrame runs when the editor renders, and not otherwise — an idle editor draws nothing. It is how the default UI and any custom controls stay in step.
Messages
editor.message(key: keyof Messages, params?: Record<string, string | number>): stringResolves through the table supplied in EditorOptions.messages, falling back to English. See Accessibility.