;
}
```
**`id`** is a UUID. Never collides with anything in the tree. `instantiateBlocks` regenerates IDs on insertion so two clones of the same composite never share IDs.
**`type`** is a string, not an enum. The editor knows about `heading`, `text`, `code`, `quote`, `divider`, `image`, `list`, `list-item`. Consumers can introduce their own types and the editor will treat them as default `` blocks until rendered explicitly. Composites use namespaced types like `composite:login-form`.
**`content`** accepts a plain string or an `InlineContent[]` array. The array form preserves bold, italic, code, strikethrough, and link marks across serialization. The string form does not. Both render correctly. Only the array survives a round trip.
**`children`** is an array of block IDs. Not nested objects. The block array is flat. Parent and child relationships are reference-only.
**`parentId`** is the ID of the containing block. Root blocks have no `parentId`. The serializer derives input rules from root blocks and output rules from leaf blocks (no children, or empty children array).
**`meta`** is type-specific. A heading block stores `meta.level` (1–6, clamped at render time). An image block stores `meta.src` and `meta.alt`. A grid block stores `meta.columns`. The shape is `Record` because each block type defines its own.
## EditorBlock
`EditorBlock` extends `BaseBlock` with one runtime-only field. Defined at `packages/ui/src/components/ui/editor.tsx:37`.
```ts
type AppliedRule = string | { name: string; config: Record };
interface EditorBlock extends BaseBlock {
rules?: AppliedRule[];
}
```
Rules are validators. They attach to blocks and describe what's required (input rules on root) or guaranteed (output rules on leaves). A bare string is the rule name. An object form lets the rule carry parameters. The runtime validation engine doesn't exist yet — see [Editor Rules](/docs/editor-rules/) for the gap.
## CompositeBlock
The serialization-time shape. Same fields as `BaseBlock`, validated through Zod at `packages/composites/src/manifest.ts:28`. Composites carry their rules array on every block.
```ts
const CompositeBlockSchema = z.object({
id: z.string(),
type: z.string(),
content: z.union([z.string(), z.array(InlineContentSchema)]).optional(),
children: z.array(z.string()).optional(),
parentId: z.string().optional(),
meta: z.record(z.unknown()).optional(),
rules: z.array(AppliedRuleSchema).optional(),
});
```
`EditorBlock` and `CompositeBlock` are structurally identical. The split exists because the editor lives in `@rafters/ui` and composites lives in `@rafters/composites` and neither wants to import the other. Same shape, two definitions, validated independently.
## InlineContent
Rich text. Defined at `packages/ui/src/primitives/types.ts:188`.
```ts
type InlineMark = 'bold' | 'italic' | 'code' | 'strikethrough' | 'link';
interface InlineContent {
text: string;
marks?: InlineMark[];
href?: string;
}
```
A run of text with optional marks. `href` is only present when `marks` includes `'link'`.
A paragraph with mixed formatting becomes an array of segments:
```ts
content: [
{ text: 'The ' },
{ text: 'quick', marks: ['bold'] },
{ text: ' brown ' },
{ text: 'fox', marks: ['italic'] },
{ text: ' jumps.' },
]
```
The MDX serializer at `packages/ui/src/primitives/serializer-mdx.ts` round-trips this faithfully. The legacy `toMdx()` in `@rafters/composites` flattens it to plain text and loses the marks. Use `mdxSerializer` from `@rafters/ui`.
Mark application order at render time, defined in `editor.tsx:129`: code, then link, then strikethrough, then italic, then bold. Bold is the outermost wrapper. This matters when you read the rendered HTML and wonder why your `` ended up wrapping everything else.
## Command
For the slash command palette. Defined at `packages/ui/src/primitives/types.ts:227`.
```ts
interface Command {
id: string;
label: string;
description?: string;
icon?: string;
category?: string;
keywords?: string[];
shortcut?: string;
action: () => void;
}
```
`action` runs synchronously when the command is selected. Async work belongs inside it. The fuzzy search at `command-palette.ts` indexes `label` and `keywords`.
## FormatDefinition
For inline formatter presets. Defined at `packages/ui/src/primitives/types.ts:241`.
```ts
interface FormatDefinition {
name: InlineMark;
tag: string;
shortcut?: string;
attributes?: Record;
class?: string;
}
```
The formatter ships presets for bold, italic, code, strikethrough, and link. Custom marks are not supported. The mark types live in the union at `InlineMark` and adding a new mark means changing the type.
## Keyboard Types
```ts
type KeyboardKey =
| 'Enter' | 'Space' | 'Escape' | 'Tab'
| 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight'
| 'Home' | 'End' | 'PageUp' | 'PageDown'
| 'Backspace' | 'Delete';
interface KeyboardModifiers {
shift?: boolean;
ctrl?: boolean;
alt?: boolean;
meta?: boolean;
}
```
The keyboard handler normalizes `Space` to `' '` internally. That's the one thing to know.
## SelectionRange
For low-level selection work. Defined at `packages/ui/src/primitives/types.ts:216`.
```ts
interface SelectionRange {
startNode: Node;
startOffset: number;
endNode: Node;
endOffset: number;
collapsed: boolean;
}
```
Most consumers won't touch this. The `cursor-tracker` primitive handles selection plumbing for the editor.
## Palette Item Shapes
`BlockPaletteItem` lives in `packages/ui/src/primitives/block-palette.ts`. `RulePaletteItem` lives in `packages/ui/src/primitives/rule-palette.ts`. Both share the same idea: an `id`, a `label`, a `category`, and an optional `keywords` array. The fuzzy search uses label plus keywords.
`@rafters/composites/bridge.ts:56` produces `BlockPaletteItem` directly from a composite manifest. That import is the only known type-coupling between composites and ui. See [Architecture](/docs/editor-architecture/) for the violation.
## Composite Manifest
The full Zod schema at `packages/composites/src/manifest.ts:48`.
```ts
const CompositeManifestSchema = z.object({
id: z.string().regex(/^[a-z0-9-]+$/),
name: z.string(),
category: z.string(),
description: z.string(),
keywords: z.array(z.string()),
cognitiveLoad: z.number().int().min(1).max(10),
solves: z.string().optional(),
appliesWhen: z.array(z.string()).optional(),
usagePatterns: z.object({
do: z.array(z.string()),
never: z.array(z.string()),
}).optional(),
});
const CompositeFileSchema = z.object({
manifest: CompositeManifestSchema,
input: z.array(z.string()),
output: z.array(z.string()),
blocks: z.array(CompositeBlockSchema),
});
```
`id` is kebab-case and derived from `name` by `serializeToComposite`. `category` is a free-form string. It used to be an enum. It is not anymore. Don't hardcode categories.
`cognitiveLoad` is clamped 1 through 10 at serialize time. `solves`, `appliesWhen`, and `usagePatterns` are optional and were added later. Composites in the wild may not have them.
## What's Not A Type
Two things people invent that shouldn't exist:
A `BlockTree` type. There isn't one. The block array is the tree. Walk by `parentId` and `children` IDs.
A `Document` wrapper. There isn't one. The editor takes `EditorBlock[]`. Frontmatter for serialization is a separate object passed alongside, not embedded.
If a function would take `{ blocks, frontmatter }`, the fields are separate parameters or a structural object literal. Don't introduce a wrapper type.
---
## Editor Known Gaps
url: https://rafters.studio/docs/editor-known-gaps/
# Editor Known Gaps
This page exists because the editor surface drifts. Types are exported, code is stripped, two implementations of the same idea coexist. Documenting the gaps explicitly is cheaper than pretending the API is whole.
Each entry follows the same shape: where, what, what was expected, who gets hurt, what the fix looks like.
## EditorProps Missing Wiring
**Where.** `packages/ui/src/components/ui/editor.tsx:41` (the `EditorProps` interface) and `apps/demo/src/components/demos/EditorPlayground.tsx:438` (the consumer that passes the missing props).
**Current behavior.** The component exports `EditorSidebarConfig` (line 80), `EditorRulePaletteConfig` (line 92), `SlashCommand` (line 65), `SaveCompositeData` (line 73), and `RuleConfigField` (line 108). None of these types are referenced inside `EditorProps`. The demo passes `sidebar`, `rulePalette`, `commandPalette`, `inlineToolbar`, `blockContextMenu`, `onSaveAsComposite` to ``. The component receives them as part of the spread `HTMLDivAttributes` and silently drops them. No runtime error. No effect.
**Expected behavior.** The props should be declared and wired into the component. Sidebar mounts a block palette. Rule palette mounts a rule list. Command palette mounts a slash menu. Inline toolbar mounts a floating format bar. Block context menu mounts on right-click. Save-as-composite handler fires when the user requests it.
**Blast radius.** Every consumer that wants the rich editor experience has to recompose these surfaces externally. The demo already does, which is why the pattern is visible in `EditorPlayground.tsx`. New consumers who read the type exports and assume the props work will spend time debugging until they read the source.
**Fix sketch.** Restore the wiring stripped post-PR #1048. Each surface mounts as a portal or sibling of the canvas. The types are intact; only the JSX integration is missing. Estimated work: medium. The primitives that back each surface are all present and functional.
## Inline Formatter Write Path Is Dangling
**Where.** `packages/ui/src/primitives/inline-formatter.ts:619` (`serializeSelection`) and `:674` (`deserializeToDOM`). And `packages/ui/src/components/ui/editor.tsx` (no call site).
**Current behavior.** The inline formatter applies marks (bold, italic, code, strikethrough, link) directly to the contentEditable DOM. The `serializeSelection()` function exists and walks the DOM to produce `InlineContent[]`. The `deserializeToDOM()` function exists and walks `InlineContent[]` to produce a DocumentFragment. The editor never calls `serializeSelection`. So when a user formats text, the DOM updates and the visual is correct, but `block.content` is never updated. On save, the content shape is whatever it was before the edit, and the formatting is lost.
**Expected behavior.** Every inline format operation (toggle, apply, remove) should call `serializeSelection` for the affected block and write the result back into the block's `content` field through the document-editor or block-handler. Round-trip MDX edits should preserve user-applied marks the same way they preserve imported marks.
**Blast radius.** Bold, italic, code, strikethrough, and link applied via the inline toolbar do not survive save. Imported marks from MDX render correctly because `deserializeToDOM` runs on the read path. Edits in the DOM render correctly. The save path drops them. Gitpress and any other MDX-round-trip consumer is the most exposed.
**Fix sketch.** Hook the inline-toolbar commit and the contentEditable blur. On both, call `serializeSelection` for the focused block, route through `block-handler.updateBlock(id, { content: serialized })`. The bridge functions are correct; only the call site is missing.
## Editor.deselect Is A Stub
**Where.** `packages/ui/src/components/ui/editor.tsx:408`.
**Current behavior.** `deselect` is exposed on `EditorControls` and is an empty function. Calling it does nothing.
**Expected behavior.** It should clear the document-editor's selection state, blur the active contentEditable, and emit any focus-change callbacks subscribers have registered.
**Blast radius.** Small but real. Consumers who programmatically need to drop selection (after a save, after a navigation) think they have a way to do it. They do not.
**Fix sketch.** Wire `deselect()` to call `docEditorRef.current?.deselect()` if the primitive supports it, plus a manual `(document.activeElement as HTMLElement)?.blur()` as a fallback. The primitive likely needs a parallel method added.
## Two MDX Serializers Exist
**Where.** `packages/ui/src/primitives/serializer-mdx.ts` (the full bidirectional one, 873 lines) and `packages/composites/src/serializer.ts` (the legacy one-way `toMdx()`).
**Current behavior.** Both ship. The full one in `@rafters/ui` conforms to the `EditorSerializer` interface, handles `InlineContent[]` marks, supports frontmatter, supports MDX JSX, supports round-trip parsing. The legacy one in `@rafters/composites` is one-way (no parser), drops `quote`, `input`, `button`, `image` to `` comments, treats content as a plain string, and does not handle frontmatter.
**Expected behavior.** One serializer. The full one is the right one. The legacy `toMdx()` should be removed once internal callers are migrated, or kept only as an internal helper that the bridge layer uses.
**Blast radius.** Anyone reaching for "the MDX serializer" can pick the wrong one. The `@rafters/composites` version produces lossy output and looks plausible enough at first glance to ship.
**Fix sketch.** Audit callers of `composites/serializer.ts:toMdx`. Replace each with `mdxSerializer` from `@rafters/ui`. Delete the legacy file. Update tests. The visible callers are `bridge.ts` and possibly some demo code; trace from there.
## Composites Imports From UI
**Where.** `packages/composites/src/registry.ts:8` (imports `fuzzyScore` from `@rafters/ui/primitives/typeahead`) and `packages/composites/src/bridge.ts:10` (type-imports `BlockPaletteItem` from `@rafters/ui/primitives/block-palette`).
**Current behavior.** `@rafters/composites` declares zod as its only intentional workspace dependency. In practice it depends on `@rafters/ui` for one helper function and one type alias.
**Expected behavior.** Composites should depend only on zod. The architectural intent is that composites is a pure data-and-validation package any consumer can pull in without dragging the UI layer.
**Blast radius.** Today: a circular-ish dependency that complicates package boundaries. Tomorrow: anyone bundling composites for a non-React consumer (a worker, a CLI build pipeline, a service) gets the entire UI primitive layer along for the ride.
**Fix sketch.** Inline `fuzzyScore` into `composites/registry.ts`. It's a small pure function. Define `BlockPaletteItem` locally in `bridge.ts` (or in a new `composites/src/types.ts`) with the same shape. Both edits are mechanical. The two packages become structurally compatible without import-time coupling.
## Asymmetric Circular-Reference Depth Limits
**Where.** `packages/composites/src/serializer.ts` (legacy `toMdx`, MAX_DEPTH=50) and `packages/composites/src/bridge.ts:96` (`instantiateBlocks`, maxDepth=10).
**Current behavior.** The two functions cap recursion at different depths. Most composites instantiate fine because real depth is usually 2 or 3. A pathological composite that nests deeply could be serialized but not instantiated, or vice versa.
**Expected behavior.** Either a single shared constant or a documented reason for the difference.
**Blast radius.** Low. No reported bugs. The asymmetry is more of a code-smell than an active issue.
**Fix sketch.** Pick one number. 10 is probably correct (deep nesting indicates a misuse). Move it to a shared constant in `composites/src/constants.ts`. Update both call sites.
## Rule Runtime Validation Gap
**Where.** `packages/composites/src/rules.ts` and `packages/composites/src/built-in-rules/*.ts`.
**Current behavior.** `matchRules` does nominal name matching. The Zod schemas in `built-in-rules/` are loaded but never invoked from blocks. There is no engine that walks a block tree, reads each block's `rules`, resolves the rule name to its Zod schema, and validates the block's content against it.
**Expected behavior.** A consumer should be able to call something like `validateBlocks(blocks): ValidationResult[]` and get back per-block validation errors based on the rules attached to each block.
**Blast radius.** Composites that declare input rules can claim compatibility with consumers that declare matching output rules, even when the actual data shapes don't agree. The compatibility check is by name only. Errors surface late, in user-visible UI.
**Fix sketch.** Add `validateBlocks(blocks, ruleRegistry)` to `composites/src/rules.ts`. Walk blocks. For each block with rules, look up the rule's Zod schema in the registry, run `parse()` against the block's content (or the relevant `meta` field per rule), accumulate errors. The schemas exist; only the engine is missing.
## Registry Import-Path Bug In `pnpx rafters add editor`
**Where.** Distributed via the rafters CLI, surfaces in any consumer that runs `pnpx rafters add editor`.
**Current behavior.** Primitives are installed to `src/lib/primitives/` but their internal imports reference `@/src/components/ui/` instead of `@/src/lib/primitives/`. Affected files include `block-handler.ts`, `block-canvas.ts`, `block-context-menu.ts`, `escape-keydown.ts`, `inline-formatter.ts`, `collision-detector.ts`, `outside-click.ts`, `command-palette.ts`, `canvas-drop-zone.ts`, `slot.ts`, `inline-toolbar.ts`, `typeahead.ts`, `focus-trap.ts`, `roving-focus.ts`, `clipboard.ts`, `keyboard-handler.ts`, `block-palette.ts`, `rule-palette.ts`. Also: the shared `types.ts` primitive is not installed.
**Expected behavior.** Primitives' relative imports should resolve to wherever the registry installer placed them. The shared `types.ts` should be installed alongside.
**Blast radius.** Every external consumer who installs the editor via the CLI hits this on day one. They have to manually `sed` paths or copy files.
**Fix sketch.** Update the registry build to rewrite import paths based on installation target. Include `types.ts` in the editor primitive bundle. Track the registry path through the build instead of hard-coding `components/ui/`.
## Save-As-Composite UI Missing
**Where.** `packages/ui/src/components/ui/editor.tsx`. The `SaveCompositeData` type at line 73 and `onSaveAsComposite` callback are exported but no UI exists to invoke them.
**Current behavior.** Type-level support is in place. The bridge function `serializeToComposite` in `composites/src/bridge.ts:239` takes blocks and metadata and produces a CompositeFile. There is no in-editor UI to collect the metadata (name, category, description) and trigger the handler.
**Expected behavior.** A consumer-driven action (right-click menu item, slash command, toolbar button) collects metadata via a small dialog and calls `onSaveAsComposite({ name, category, description, blocks })`.
**Blast radius.** Composite authoring is a designer flow. Without UI, designers can't save composites from inside the editor. Today they edit JSON files by hand or use the CLI.
**Fix sketch.** A dialog primitive plus a small form. Wire to a context menu item or toolbar button. Call the prop. The serializer does the rest. Depends on the EditorProps wiring gap above.
## How These Get Fixed
These gaps are documentation-first. None of them are getting fixed in the same change as the docs that describe them. The sequence is intentional: write the truth, then write the fix plan, then ship the fix.
If a fix is in flight, the entry should still describe the gap as it stands today. Update the entry only when the fix lands.
---
## Editor Canvas Primitives
url: https://rafters.studio/docs/editor-primitives-canvas/
# 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](/docs/editor-data-model/). For the leaf vs. composition classification rules, see [the architecture doc](/docs/editor-architecture/).
---
## 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:**
```ts
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:**
```ts
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](/docs/editor-known-gaps/) 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:**
```ts
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:**
```ts
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:**
```ts
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](/docs/editor-data-model/) 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.
---
## Editor Drag, Drop, and State
url: https://rafters.studio/docs/editor-primitives-dnd-state/
# Editor Drag, Drop, and State.
Four leaf primitives handle everything that moves or remembers. Two deal with spatial interaction: `drag-drop` and `canvas-drop-zone`. Two deal with state: `clipboard` and `history`. None of them import nanostores. They do one thing, expose a cleanup function, and get out. The composition primitive `block-handler` wires them together for the full editor context.
---
## Drag and Drop.
`drag-drop.ts` handles mouse, keyboard, and touch drag across the entire primitive surface. The MIME type is `application/x-rafters-drag-data` with a `text/plain` fallback for cross-origin drops. Touch initiates via a 300ms long-press timer; scrolling more than 10px before the timer fires cancels it cleanly.
Keyboard drag is the part that requires attention. State for keyboard mode lives at module level, shared across every `createDraggable` instance on the page. Space picks up and drops. Arrow keys navigate between registered drop zones sorted by vertical then horizontal position. Escape cancels. Screen reader announcements fire through a single visually-hidden `aria-live="assertive"` region.
```typescript
const item = createDraggable({
element: blockEl,
data: { id: 'block-1', type: 'paragraph' },
onDragStart: (data) => setDragging(data.data),
onDragEnd: (_data, effect) => clearDragging(effect),
});
// Programmatic keyboard drag
item.startKeyboardDrag();
item.moveDown();
item.commitKeyboardDrag();
item.cleanup();
```
Because keyboard state is module-level, two things follow. First, only one drag can be active at a time. Second, if your test suite leaves drag state behind, the next test inherits it. Call `resetDragDropState()` in `afterEach`. That function removes the announcer element and reinitializes the state object; see `drag-drop.ts:905`.
`createDropZone` answers "was something dropped here?" It does not answer "where exactly within the zone?" For that second question, use `canvas-drop-zone`.
---
## Canvas Drop Zone.
`canvas-drop-zone.ts` is for block editors where the drop position matters. Not a general-purpose drop zone. A positional insert target.
The algorithm reads every `[data-block-id]` child of the container, caches their `DOMRect` values, then compares the cursor's Y coordinate against each block's midpoint. If the cursor is above a block's midpoint, the insertion index is before that block. If below all midpoints, insertion goes at the end. The `dragover` handler is rAF-throttled: each frame consumes the most recent `clientY` and skips calculation if nothing changed. See `canvas-drop-zone.ts:132`.
```typescript
const dropZone = createCanvasDropZone({
container: editorEl,
accept: (data) => (data as { type: string }).type === 'block',
onDrop: (data, index) => insertBlock(data, index),
onInsertIndicatorChange: (index, rect) => {
if (index === null) hideIndicator();
else showIndicator(rect);
},
});
// After any DOM mutation that adds, removes, or reorders blocks:
dropZone.recalculate();
dropZone.destroy();
```
Call `recalculate()` after every DOM mutation that changes block positions. Block positions are cached on `dragenter`, not on every frame. If a block animates into position after a drop, the cached rects will be stale until the next `recalculate()` call.
See [Editor Data Model](/docs/editor-data-model/) for the `BlockNode` types that flow through `onDrop`.
---
## Clipboard.
`clipboard.ts` wraps the Clipboard API with custom MIME type support and a `text/plain` fallback for environments where custom types are blocked. Permission denial is caught and swallowed; the `write` function does not throw, and `read` returns an empty `ClipboardData` object rather than rejecting.
```typescript
const clipboard = createClipboard({
container: editorEl,
customMimeType: 'application/x-rafters-blocks',
onPaste: (data) => {
if (data.custom) applyBlocks(data.custom);
else if (data.text) insertText(data.text);
},
onCopy: (data) => console.log('copied', data),
});
await clipboard.write({
text: 'Fallback text for plain paste targets',
custom: { blocks: selectedBlocks },
});
clipboard.cleanup();
```
The `ClipboardData` type carries three optional fields: `text`, `html`, and `custom`. When writing, all three are written simultaneously as separate MIME blobs inside a single `ClipboardItem`. If `ClipboardItem` rejects the custom type, the implementation falls back to `writeText` with the plain text value. See `clipboard.ts:165`.
---
## History.
`history.ts` is a typed undo/redo stack with configurable depth and an optional equality function for deduplication. The default limit is 100 entries. When that limit is exceeded, the oldest entry is dropped: FIFO, not LRU.
Batch mode is the thing to know. Inside a `batch()` call, every `push()` updates the current state without recording a history entry. When the batch closes, only the before and after states are recorded as a single undo step.
```typescript
const history = createHistory({
initialState: blocks,
limit: 50,
isEqual: (a, b) => a === b,
});
// Multiple typed characters collapse into one undo step
history.batch(() => {
history.push(applyChar(state, 'h'));
history.push(applyChar(state, 'e'));
history.push(applyChar(state, 'l'));
});
// history.undo() jumps back to the state before all three pushes
history.canUndo(); // true
history.undo();
history.redo();
history.clear();
```
Nested `batch()` calls run the inner function immediately without opening a second batch frame. See `history.ts:291`.
---
## All Leaf, No Nanostores.
None of these primitives import nanostores. Leaf primitives stay zero-dependency by rule: no shared reactive state, no composition logic, callback injection for everything. See [Editor Data Model](/docs/editor-data-model/) for the types that cross these primitive boundaries. The composition primitive `block-handler` is what wires `clipboard` and `history` through nanostores atoms so they share state across the editor surface.
---
## Editor Formatting Primitives
url: https://rafters.studio/docs/editor-primitives-formatting/
# Editor Formatting Primitives.
Three leaf primitives handle the formatting layer of the editor: `inline-formatter` applies and removes marks on text ranges, `inline-toolbar` positions the floating button strip above selections, and `command-palette` runs the slash-command menu. None of them import nanostores. None of them depend on each other. Composition happens in the React component that wires them together.
For the data types these primitives operate on (`InlineContent`, `InlineMark`, `Command`, `FormatDefinition`) see [Editor Data Model](/docs/editor-data-model/).
## inline-formatter.
Not a rich-text abstraction. A DOM manipulation layer that creates and unwraps format elements within a content region.
`createInlineFormatter(options)` returns an `InlineFormatterController`. The controller exposes six methods: `getActiveFormats()`, `isFormatActive(mark)`, `toggleFormat(mark, value?)`, `applyFormat(mark, value?)`, `removeFormat(mark)`, and two serialization methods covered below. Five format presets ship with the primitive: `BOLD`, `ITALIC`, `CODE`, `STRIKETHROUGH`, and `LINK`.
Toggle and apply handle partial range splits. If a selection covers text that is partly bold and partly not, `toggleFormat(BOLD)` determines the dominant state and applies or removes accordingly. Range splitting for partial removal is complex; see `inline-formatter.ts` lines 400-580 for the implementation.
The primitive is SSR-safe: every DOM operation is gated on `typeof window === 'undefined'` and no-ops on the server.
### The write-path gap.
`serializeSelection()` (line 619) walks the DOM inside the formatted region and returns `InlineContent[]`. `deserializeToDOM(content: InlineContent[])` (line 674) takes `InlineContent[]` and returns a `DocumentFragment` for mounting. Both directions work.
The wiring does not exist. `editor.tsx` never calls `serializeSelection()` after a user formats text. Bold, italic, code, strikethrough, and link are visible in the DOM immediately after the user applies them. They are never written back to `block.content`. On save, the block's content array still holds whatever was there before the user typed a mark. Formatting is lost.
Content imported from MDX renders correctly because `deserializeToDOM` is called on load. The read path works. The write path is currently dangling.
The bridge functions exist at lines 619 and 674. The call site in `editor.tsx` does not. Track this at [Known Gaps](/docs/editor-known-gaps/).
## inline-toolbar.
Not a component. A positioning and configuration utility that a React component calls to know where to render and what buttons to show.
`getFormatButtons()` returns `FormatButtonConfig[]`, one entry per supported mark. `adjustToolbarPosition(position, dimensions, ...)` takes a raw selection rectangle and toolbar dimensions and returns an `AdjustedToolbarPosition`. The algorithm prefers above the selection; it flips below if the toolbar would collide with the viewport edge. Eight pixels of viewport padding on all sides.
`getModifierKey()` returns `"Cmd"` on Mac and `"Ctrl"` elsewhere, so keyboard shortcut labels in the toolbar UI match the platform without branching in the component. `isValidUrl(string)` and `normalizeUrl(string)` support the link format button: validate before applying, normalize before storing.
Exported types: `ToolbarPosition`, `ToolbarDimensions`, `FormatButtonConfig`.
## command-palette.
The slash-command layer. `createCommandPalette(options)` returns a `CommandPaletteController` backed by a `CommandPaletteState` object the host component can read.
The trigger fires at start-of-line or after whitespace. Typing `/` elsewhere does nothing. Once triggered, the palette filters against the registered `Command` list using `fuzzyMatch(text, query)`, which returns a `FuzzyMatchResult` with a numeric score. Scoring: one point per matched character, two bonus points for consecutive characters, three bonus points for matches at word boundaries. The sort is stable; two commands with equal scores keep their registration order.
`CommandPaletteOptions` controls the registered command list and the callback that fires when the user confirms a selection. The controller exposes methods for opening, closing, navigating, and confirming, so a React component can bind keyboard events without importing any keyboard-handling logic directly into the palette primitive.
## Composition note.
All three primitives are leaves. They manipulate DOM, compute positions, and score strings. They do not hold reactive state, do not subscribe to stores, and do not render anything. A React component that imports all three and owns a content region is the composition layer; that is where `serializeSelection()` will eventually be called on every format change, closing the write-path gap described above.
---
## Editor Input Primitives
url: https://rafters.studio/docs/editor-primitives-input/
# Editor Input Primitives.
Not event listeners bolted onto components. Isolated, composable functions that return a cleanup reference and nothing else. Every interactive primitive in the editor composes these. They carry no state. They inject no dependencies. They are the substrate.
Types referenced here (`KeyboardKey`, `KeyboardModifiers`, `Direction`) live in [the data model docs](/docs/editor-data-model/). The leaf/composition rule that governs why these are stateless is in [the architecture docs](/docs/editor-architecture/).
## keyboard-handler.
Source: `packages/ui/src/primitives/keyboard-handler.ts`
The general-purpose keyboard routing layer. `createKeyboardHandler(element, options)` attaches a `keydown` listener and returns a `CleanupFunction`. It normalizes the `Space` key to `' '` via `KEY_MAP` (line 71) so callers work with the `KeyboardKey` union type rather than raw `event.key` strings.
```typescript
const cleanup = createKeyboardHandler(button, {
key: ['Enter', 'Space'],
handler: () => button.click(),
preventDefault: true,
});
```
Options that matter: `modifiers` narrows the handler to a specific modifier combination; `capture` moves the listener to the capture phase, which the editor uses to intercept keys before they reach nested components. Both default to off.
Three convenience constructors sit on top: `createActivationHandler` wires Enter and Space with `preventDefault`; `createDismissalHandler` wires Escape without `preventDefault` so nested dismissables still see the event bubble; `createNavigationHandler` routes arrow keys by orientation with optional `Home`/`End` support. For complex surfaces, `createKeyBindings` accepts an array of `KeyboardHandlerOptions` and returns a single cleanup that tears down all of them.
## escape-keydown.
Source: `packages/ui/src/primitives/escape-keydown.ts`
Document-level Escape listener with a single call signature: `onEscapeKeyDown(handler): CleanupFunction`. It attaches to `document`, not to the element, which is the right choice for modal and dismissable surfaces where the triggering element may not have focus.
```typescript
const cleanup = onEscapeKeyDown((event) => closeModal());
```
Use this for dialogs, sheets, popovers, and any surface that owns a layer of the stack. For element-scoped Escape handling, prefer `createDismissalHandler` from `keyboard-handler`.
## focus-trap.
Source: `packages/ui/src/primitives/focus-trap.ts`
`createFocusTrap(element): CleanupFunction` constrains Tab and Shift+Tab within the container. On mount it queries tabbable descendants using:
```
a[href], button:not([disabled]), input:not([disabled]),
select:not([disabled]), textarea:not([disabled]),
[tabindex]:not([tabindex="-1"])
```
It captures `document.activeElement` before mounting and restores focus to that element on cleanup. The list is re-queried on each keydown so dynamically added focusable nodes are picked up without a manual refresh.
`preventBodyScroll(): CleanupFunction` is exported separately. It stores and restores `overflow`, `paddingRight`, and scroll position. On iOS Safari it sets `position: fixed` on the body to suppress elastic bounce (lines 113-119), then restores the original scroll position on cleanup.
## roving-focus.
Source: `packages/ui/src/primitives/roving-focus.ts`
Implements the ARIA roving tabindex pattern. At any moment exactly one item in the container has `tabindex="0"`; the rest have `tabindex="-1"`. Tab moves between the container and the rest of the page. Arrow keys navigate within it.
```typescript
const cleanup = createRovingFocus(menuElement, {
orientation: 'vertical',
loop: true,
onNavigate: (element, index) => setActiveIndex(index),
});
```
Items are discovered via `[data-roving-item]` and standard ARIA roles (`menuitem`, `option`, `radio`, `tab`). Disabled items are skipped: the filter checks `disabled`, `data-disabled`, and `aria-disabled="true"` (lines 76-83). Hidden and `display:none` items are also excluded.
RTL support comes through the `dir` option on `RovingFocusOptions`. When `dir` is `'rtl'`, `ArrowRight` and `ArrowLeft` invert (line 142-148). `Home` and `End` jump to the first and last items regardless of orientation.
Three utility exports round out the API: `focusItem(container, index)` for programmatic focus; `getCurrentIndex(container)` to read the active index without touching tabindex state; `refreshRovingFocus(container, index)` to re-synchronize tabindex after a DOM change, such as when a block is added or removed from the editor canvas.
## cursor-tracker.
Source: `packages/ui/src/primitives/cursor-tracker.ts`
Pure functions for cursor position within `contentEditable` surfaces. Every block element must carry a `data-block-id` attribute. The tracker finds it via `findBlockElement(node)`, which walks up the DOM to the nearest `[data-block-id]` ancestor (line 37-41).
`getCursorPosition()` returns a `CursorPosition` object: `blockId`, `offset` as character count within the block's text content, and `blockLength`. The offset is computed by creating a range from the block start to the anchor node (line 44-49), which handles nested inline elements correctly.
The editor subscribes via a `selectionchange` listener and calls `findBlockElement` on the selection's anchor node to derive `focusedBlockId`. `isCursorAtBlockStart()` and `isCursorAtBlockEnd()` are derived checks built on `getCursorPosition()`. `setCursorInBlock(container, blockId, offset)` walks text nodes to place the caret at the exact character offset, clamping to the end of content if the offset exceeds block length.
## The Leaf Rule.
All five primitives are leaf primitives. None import nanostores. None carry reactive state. They receive a DOM element and callbacks; they return a cleanup function. State belongs to the caller. This is not a constraint imposed from outside. It is what makes them safe to compose into anything without pulling in unintended reactive dependencies. See [the architecture docs](/docs/editor-architecture/) for the full leaf/composition distinction.
---
## Editor Overlay Primitives
url: https://rafters.studio/docs/editor-primitives-overlays/
# Editor Overlay Primitives.
Anything that pops over the canvas composes from here. Context menus, popovers, tooltips, dialogs: they are not built from scratch each time. They are assembled from seven leaf primitives that each own one part of the problem. Not a floating-UI framework. A layer of focused contracts.
---
## block-context-menu.
`packages/ui/src/primitives/block-context-menu.ts`
Right-click and Shift+F10 both open the context menu for a focused block. Shift+F10 positions the menu at the center of the focused block's bounding rect; right-click uses the pointer coordinates. Either way, `positionMenu` clamps to a 4px gap from every viewport edge so the menu never clips.
The primitive manages its own focus: on open, focus moves to the first `[role="menuitem"]`. Arrow keys, Home, and End navigate; Enter or Space activates. Escape dismisses. Focus is trapped inside via `createFocusTrap` and restored on close. Blocks are identified by `[data-block-id]` attributes; the primitive walks up the DOM from the event target to find the nearest one.
```typescript
const menu = createBlockContextMenu({
container: canvasEl,
menu: menuEl,
onAction: (itemId, blockId) => dispatch({ itemId, blockId }),
});
menu.open(blockId, { x: 120, y: 340 });
```
---
## dismissable-layer.
`packages/ui/src/primitives/dismissable-layer.ts`
One primitive unifying three dismissal signals: pointer down outside, escape key, and focus leaving the layer. Consumers get one `onDismiss` callback instead of wiring three separate listeners.
When `disableOutsidePointerEvents` is true, a `