Skip to content

Editor ​

ts
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 ​

ts
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 ​

ts
editor.container       // HTMLElement — what you passed in
editor.canvasElement   // HTMLCanvasElement — aria-hidden
editor.overlayElement  // HTMLElement — carries the viewport transform

Put 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 ​

ts
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, below

Lifecycle ​

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 ​

ts
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:

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

ts
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 ​

ts
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 ​

ts
setEphemeral(changes: ReadonlyMap<ShapeId, Partial<AnyShape>>): void
commitEphemeral(options?: TransactOptions): void
clearEphemeral(): void

Write these during a drag and commit once at the end. See Ephemeral state.

Hierarchy and order ​

ts
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 children
ts
type ZIndexAnchor =
  | { before: ShapeId }
  | { after: ShapeId }
  | { position: 'first' | 'last' }

Neither grouping nor ungrouping bakes the transform into the children.

Geometry ​

ts
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 ​

ts
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 | null

A single selection reports the shape's rotated box; a multi-selection reports an axis-aligned one.

Viewport ​

ts
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(): Bounds

Zoom is clamped to zoomRange. The viewport is not part of the history.

Tools ​

ts
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.state

select, 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:

ts
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 ​

ts
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): () => void

A 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 ​

ts
editor.snapping                                  // Readonly<SnapSettings>
editor.setSnapping(settings: Partial<SnapSettings>): void
editor.getSnapGuides(): readonly SnapGuide[]
editor.computeSnap(proposed: Bounds, exclude: ReadonlySet<ShapeId>): SnapResult
editor.clearSnapGuides(): void

Input helpers ​

ts
editor.beginHandleInteraction(handle: HandleId, event: PointerEvent): void
editor.nudgeHandle(handle: HandleId, delta: Vec): void
editor.nudgeSelection(delta: Vec): void

bindHandle calls the first two for you. Use them directly only if you are binding handles by hand.

Documents and export ​

ts
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 encode

export 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 ​

ts
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 ​

ts
editor.message(key: keyof Messages, params?: Record<string, string | number>): string

Resolves through the table supplied in EditorOptions.messages, falling back to English. See Accessibility.

Released under the MIT License.