Editor kit API
Everything is exported from the package root; per-module entries
(/commands, /selection, /serializer, /dnd, /overlay) exist
for granular imports.
Commands
Section titled “Commands”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.
| Factory | Does |
|---|---|
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).
CommandStack
Section titled “CommandStack”const stack = new CommandStack();stack.apply(command); // execute + recordstack.apply(command, { coalesce: true }); // merge consecutive same-target editsstack.transact(() => { …several apply() calls… }); // ONE undo entrystack.undo(); stack.redo(); // → boolean (false on empty)stack.canUndo; stack.canRedo; // gettersstack.clear();- Coalescing merges consecutive
setAttribute/setTextcommands 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
changeCustomEvent after every mutation (detail.action:apply|undo|redo|clear) — wire UI refresh andSelection.prune()to it.
Selection
Section titled “Selection”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); // replacesel.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();});Serializers
Section titled “Serializers”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 nodeThe 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" }]}Drag & drop
Section titled “Drag & drop”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; // booleandnd.dispose();- Reported
indexis a childNodes position with the dragged node absent — pass it straight toinsertNode/moveNode, so every drop stays undoable. payloadis{ 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
frameand the controller listens on both documents, translating host-viewport coordinates into canvas coordinates through the frame’s rect —startInsertfrom a parent-document palette then completes normally.
Overlay
Section titled “Overlay”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/redooverlay.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.
Wiring it together
Section titled “Wiring it together”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), ); },});