Skip to content

Editor kit API

Everything is exported from the package root; per-module entries (/commands, /selection, /serializer, /dnd, /overlay) exist for granular imports.

Each factory returns a command object; nothing happens until a CommandStack applies it. Insertion indices are positions in parent.childNodes measured with the moved/inserted node absent, so moveNode behaves identically for same-parent and cross-parent moves.

FactoryDoes
setAttribute(node, name, value)Set an attribute (value: null removes)
removeAttribute(node, name)Sugar for setAttribute(node, name, null)
setText(node, text)Replace textContent
insertNode(parent, node, at)Insert at an insertion point
removeNode(node)Remove; undo restores the exact position
moveNode(node, parent, at)Remove + insert; undo restores the origin

An insertion point is a childNodes index or { before: Element | null } (null appends) — element-based UI code (Alt+Arrow reorder, “append to container”) never has to count whitespace text nodes. When you do need the numeric form, indexBefore(parent, ref, exclude?) converts “before this element” into the index every API here uses (the same helper the drag controller reports drops with).

const stack = new CommandStack();
stack.apply(command); // execute + record
stack.apply(command, { coalesce: true }); // merge consecutive same-target edits
stack.transact(() => { …several apply() calls… }); // ONE undo entry
stack.undo(); stack.redo(); // → boolean (false on empty)
stack.canUndo; stack.canRedo; // getters
stack.clear();
  • Coalescing merges consecutive setAttribute/setText commands on the same node + attribute — inspector typing becomes one undo step.
  • transact() groups arbitrary commands (an insert plus its default attributes); reverted in reverse order. Cannot nest.
  • Emits a change CustomEvent after every mutation (detail.action: apply | undo | redo | clear) — wire UI refresh and Selection.prune() to it.

An ordered set of elements with a primary (first-selected) node. Emits change only when the selection actually changes.

const sel = new Selection();
sel.select(node); // replace
sel.select(node, { additive: true }); // append (shift-click)
sel.toggle(node); sel.deselect(node); sel.clear();
sel.items; sel.primary; sel.size; sel.isSelected(node);
sel.prune(); // drop disconnected nodes (after undo/redo)

pickBlock(target, { root, manifest }) resolves a click target to what a canvas should select: the nearest ancestor whose class names a manifest block, else the element itself — never root or anything outside it (those return null). Cross-document safe (no instanceof), so it works for iframe canvases:

canvas.addEventListener('click', (e) => {
const node = pickBlock(e.target, { root: canvas, manifest });
node ? editor.selection.select(node) : editor.selection.clear();
});

The reserved scaffolding namespace — attributes prefixed data-hc-editor- and elements marked data-hc-editor-only — is stripped by both serializers, so editor chrome can never leak into the artifact.

serialize(root) // → artifact HTML (root's children, cleaned)
toJson(el, { manifest }) // → JSON projection (component annotation with manifest)
fromJson(json, doc?) // → DOM node

The JSON projection is bijective modulo documented normalizations: whitespace-only text nodes and comments are dropped, attribute order is sorted. The component field is derived metadata (the first class matching a manifest block) and is ignored on decode.

{
"tag": "button",
"component": "hc-button",
"attrs": { "class": "hc-button", "data-variant": "primary" },
"children": [{ "text": "Save" }]
}

createDragController is a pointer-events engine (not HTML5 DnD). Droppable regions are marked data-hc-editor-container — scaffolding, stripped on serialize.

const dnd = createDragController({
root: canvas, // required
frame: canvasIframe, // iframe hosting the canvas (see below)
canAccept(container, payload) {}, // veto → search walks up
onPreview(target) {}, // { container, index } | null
onDrop({ container, index, payload }) {},
onCancel() {},
threshold: 4, // px before a move becomes a drag
hitTest, rectOf, // injectable geometry (tests, iframes)
});
dnd.startInsert(data, pointerEvent); // from a palette (active immediately)
dnd.startMove(node, pointerEvent); // canvas node (threshold-gated)
dnd.dragging; // boolean
dnd.dispose();
  • Reported index is a childNodes position with the dragged node absent — pass it straight to insertNode/moveNode, so every drop stays undoable.
  • payload is { type: 'insert', data, node: null } or { type: 'move', node }.
  • Escape cancels; a below-threshold click never becomes a drag, so plain clicks still reach the selection.
  • Cross-document drags: with the canvas in an iframe, a palette drag starts in the host document and pointer events never cross the frame boundary. Pass the iframe as frame and the controller listens on both documents, translating host-viewport coordinates into canvas coordinates through the frame’s rect — startInsert from a parent-document palette then completes normally.

Draws selection outlines and the drop indicator in a mount element outside the canvas — the artifact DOM stays clean. Pass frame when the canvas lives in an iframe.

const overlay = new Overlay({ mount, frame? , rectOf? });
overlay.showSelection(nodes); // wire to Selection 'change'
overlay.showDropIndicator(t); // accepts exactly what onPreview emits (null hides)
overlay.refresh(); // recompute on scroll/resize/undo/redo
overlay.dispose();

Appearance hooks: .hc-editor-overlay__selection, .hc-editor-overlay__indicator (+ data-orientation, data-empty), with --hc-editor-selection-color / --hc-editor-indicator-color fallbacks. Only geometry is set inline.

const editor = createEditor({ root: canvasBody, manifest });
const overlay = new Overlay({ mount: hostLayer, frame: canvasIframe });
editor.selection.addEventListener('change', (e) => overlay.showSelection(e.detail.items));
editor.stack.addEventListener('change', () => overlay.refresh());
const dnd = createDragController({
root: canvasBody,
onPreview: (t) => overlay.showDropIndicator(t),
onDrop: ({ container, index, payload }) => {
editor.stack.apply(
payload.type === 'move'
? moveNode(payload.node, container, index)
: insertNode(container, instantiate(payload.data), index),
);
},
});