Editor Canvas Primitives.
Not a text editor. A set of composable primitives that each own exactly one concern: cursor tracking, block mutation, per-block chrome, or full contentEditable orchestration. These five live in packages/ui/src/primitives/ and form the selection, focus, and CRUD layer of the editor stack.
For data model types (BaseBlock, EditorBlock, InlineContent), see the data model reference. For the leaf vs. composition classification rules, see the architecture doc.
block-canvas.
Classification: leaf primitive. No nanostores dependency.
Purpose: selection, focus, and keyboard management for a block editor container. It tracks which block is selected or focused and wires keyboard navigation across the block list.
Public surface:
createBlockCanvas(options: BlockCanvasOptions): BlockCanvasControls
Types exported: BlockCanvasBlock, BlockCanvasOptions, BlockClickOptions, BlockCanvasControls.
Behavior contract: the canvas attaches keyboard handlers to its container element via createKeyboardHandler from keyboard-handler (block-canvas.ts:262–309). It does not handle click or keyboard events that originate from editable elements: contenteditable, input, and textarea all pass through to the host (block-canvas.ts:102). This means the canvas is safe to mount around contentEditable blocks without double-handling input events.
Quirk: slash-command menu positioning is hardcoded to rect.left + rect.width / 2 - 144 (block-canvas.ts:330). The 144px offset from the container center is not configurable. If your container is narrower than about 300px, the menu will clip.
block-handler.
Classification: composition primitive. Uses a nanostores atom directly (block-handler.ts:134).
Purpose: orchestrates block-canvas, clipboard, and history into a single reactive editing state machine. One atom drives selection state, focus, undo/redo availability, and clipboard.
Public surface:
createBlockHandler(options: BlockHandlerOptions): BlockHandlerControls
Types exported: BlockHandlerState, BlockHandlerOptions, BlockHandlerControls.
Behavior contract: the returned $state atom exposes selectedBlockId, focusedBlockId, canUndo, and canRedo. History snapshots are pushed whenever the $blocks atom changes (block-handler.ts:164–170). Undo and redo replay those snapshots and surface availability flags on the atom.
Quirk: block-handler does not manage CRUD. The comment at block-handler.ts:9–14 is explicit: no creation, deletion, reordering, or content updates. It owns selection, focus, undo/redo history, and clipboard. Anything that mutates the block list is the consumer’s responsibility. The pure functions for that are in block-operations; see below.
Known gaps: the clipboard integration does not yet support cross-document paste. See the known gaps reference for status.
block-wrapper.
Classification: leaf primitive. No nanostores dependency.
Purpose: hover chrome, drag handle, and action-menu visibility state for a single block. One instance per block in the editor.
Public surface:
createBlockWrapper(options: BlockWrapperOptions): BlockWrapperControls
Types exported: BlockWrapperOptions, BlockWrapperControls.
Behavior contract: chrome visibility follows isHovered || getIsFocused() || isMenuOpen (block-wrapper.ts:131). The wrapper imports createDraggable from the drag-drop primitive to handle the drag handle (block-wrapper.ts:140). Menu open/close state is driven by the consumer calling the controls; the wrapper only reads isMenuOpen, it does not manage it.
Quirk: because menu state is consumer-owned, you must pass the open state in through the options or controls on every render cycle. The wrapper has no internal menu toggle. This is intentional: it keeps the primitive zero-dependency and lets the consumer wire any menu implementation.
document-editor.
Classification: composition primitive. Uses a nanostores atom (document-editor.ts:131).
Purpose: orchestrates the contentEditable surface with all required leaf primitives. This is the all-in-one option for editors that do not need to compose the primitives individually.
Public surface:
createDocumentEditor(options: DocumentEditorOptions): DocumentEditorControls
Types exported: DocumentEditorState, DocumentEditorOptions, DocumentEditorControls.
Behavior contract: document-editor wires input-events, clipboard, keyboard-handler, cursor-tracker, block-operations, history, and two serializers (HTML and text) from its imports (document-editor.ts:30–53). DOM reconciliation runs on every input event (document-editor.ts:162), reading the live DOM and syncing it back to the block model.
Markdown shortcuts fire on input: # converts the current block to heading level 1, > converts to blockquote, ## through #### produce heading levels 2–4 (document-editor.ts:106–109). Keyboard shortcuts Cmd+Alt+0 through Cmd+Alt+4 convert the focused block type without triggering markdown detection (document-editor.ts:337–364).
Delete handling has a split: selection-delete (cross-block range) is browser-handled and then reconciled (document-editor.ts:225–227). Single-block deletes that cross a block boundary go through block-operations (document-editor.ts:265).
Quirk: because document-editor owns the full contentEditable surface, you cannot layer a separate block-canvas on the same container without event conflicts. Use document-editor when you want the integrated path; use block-canvas directly when you need the lower-level surface.
block-operations.
Classification: leaf primitive. Pure functions only, no side effects.
Purpose: all block CRUD as stateless transformations. Takes an immutable blocks array, returns a new one plus metadata about what changed.
Public surface:
splitBlock(blocks, blockId, offset): SplitResult
mergeWithPrevious(blocks, blockId): MergeResult
mergeWithNext(blocks, blockId): MergeResult
deleteBlock(blocks, blockId): DeleteResult
convertBlockType(blocks, blockId, newType, meta): BaseBlock[]
insertBlocksAt(blocks, newBlocks, atBlockId, offset): InsertResult
blockContentToText(content): string
New blocks generated by split and insert operations receive fresh UUIDs from crypto.randomUUID() (block-operations.ts:31). Type signatures import BaseBlock and InlineContent from the shared types module (block-operations.ts:16). See the data model reference for those shapes.
Behavior contract: no function in this file mutates input. Every result type carries both the updated blocks array and a cursor hint (focusBlockId, focusOffset, or similar) so the caller can restore selection after applying the change.
How They Compose.
The standard wiring chain from lowest to highest level:
block-operations provides pure CRUD functions with no runtime state. block-canvas consumes a container element and tracks cursor position and keyboard navigation with no knowledge of block content. block-handler mounts on top of both, adding history and clipboard via a nanostores atom; it is the reactive coordination layer. block-wrapper is instantiated once per block and owns that block’s chrome, drag handle, and menu visibility. document-editor is the all-in-one composition: it mounts a contentEditable surface, wires all the leaf primitives internally, and exposes a single atom and control surface.
Not every editor needs all five. A read-only block list needs only block-wrapper. A non-contentEditable selection surface needs only block-canvas and block-handler. The primitives are independent enough to use in subsets; document-editor is the shortcut when you want all of them.