Rich text / foldkit-richtext
Current semantics
- Documents contain paragraphs and headings (levels 1–6), each containing text
runs. The shipped marks are
Bold,Italic, andCode, and a run carries a mark name at most once. A mark with props is stored as{ name, props }; a name the vocabulary does not define loads verbatim for forward compatibility (seefindUnknownMarks) and rides along through text edits.Edit.addMarktakes any mark;runis what refuses to add one the caller's vocabulary does not declare — the shipped marks by default, or a Kit's through its registry. - Empty documents, empty blocks, and empty text runs are valid. No normalization creates nodes or merges text runs yet.
- A
Nodeblock may carry nested blocks inblocks, and its presence is what says the kind accepts them: a List holds ListItems, a Quote paragraphs. Nested content decodes, round-trips byte-equal, is counted byinspectand the decode limits, survives a deploy that lost its kind, and a selection or reference inside it resolves. Commands reach a run wherever it sits, so typing, grapheme deletion, marks, stored marks, undo, and the clipboard work inside a container — a copy across its children carries the container — and every interpreter renders one, with the HTML importer reading a container back. Structural placement works at depth too: a split keeps its halves in the block's own container, siblings join within theirs, and a move or insert takes an optionalparentto enter or leave a container. A range across containers or kinds of block (a selection from a list into the paragraph after it, or the whole document) is cleared as an editor clears one: the ends lose what it covers, every block wholly between goes, the far end comes up beside the near one and joins it when the two can be one (where the near end's container takes its kind), and containers left empty go. A range that leaves a table cell only loses its text. Typing, Enter or a paste over such a range clears it first. What is still refused withInvalidParentis a parent that cannot hold blocks. - An atom (an image, a divider) has no text for a caret, so it is selected whole.
Backspace at the start of the block after it, or Delete at the end of the block
before it, selects it, taking away the line the caret was on when that line is
empty; a second press deletes it, and the caret lands where it was. Over a node
selection, Enter opens an empty paragraph after the block, and typing replaces
the block with a paragraph of what was typed.
isAtom(nodes, block)is the test: the vocabulary'satomdeclaration, or, without one, a node holding nothing. InsertTexttargets one run and inherits that run's marks. When the command carriesmarks, the inserted span is split out of its run and given exactly that set instead — that is how a caller's stored marks reach the document.DeleteTextremoves a half-open range[from, to)within one run.AddMarksets one run's mark for that name: it appends when the name is absent, replaces the value when its props differ, and no-ops when the mark is already exactly this one.RemoveMarkfilters that name away whatever props it carries. Redundant mark edits preserve state identity, and mark edits dirty the run and its block without emitting position steps.SplitNodesplits one block at a run offset into two: runs before the split stay (trailing runs move right with their ids), the split run keeps its id on the left, and the right remainder takes the caller-supplied run id under a caller-supplied block id of the same block type. Splitting an empty block is rejected; split that via node insertion once it exists. New identities must be fresh within the transaction.SplitRundivides one run at an offset so each side can carry different marks; the left keeps the identity, the right takes the caller-supplied id and inherits the marks. Splitting at 0 is a no-op. A bare split (no subsequent difference) is normalized away by the merge pass, so a split is only meaningful together with an edit that makes the sides differ.JoinNodemoves every run of the removed block into the surviving previous sibling, preserving run identities, marks, and range selections without emitting position steps. No runs merge (that is future normalization's job); the survivor keeps its block type. Node selections on the removed block remap to the survivor. Only adjacent siblings join, and only compatible ones: opaque content, application nodes of a different kind or props, and a container with a run holder are all refused rather than merged lossily. Joining two containers of the same kind concatenates their nested blocks.MoveNodereorders one block to an explicit post-removal index; moving to the same index is a no-op. Run identities and selections are untouched, so no position steps are emitted. An optionalparentmoves it into a node block's nested blocks (and back out), and a parent that cannot hold blocks is refused.RetypeBlockchanges the type of a block whose content is its runs — to a paragraph, a heading at a level, or a node kind with props (RetypeTarget) — keeping its run values and identities. A node block holding nested blocks retypes only to another node kind, keeping them. Same-shape sets are no-ops; retyping such a block to a paragraph or heading, or retyping preserved content, is refused (InvalidRange).SetPropssets the props it names on a node block and keeps the others. Setting a prop to the value it has is a no-op; a text block or preserved content is refused (InvalidRange). Props cannot be deleted, only set.InsertNodesplices a caller-built block at an explicit index, or into a node block's nested blocks whenparentis given; every carried identity must be fresh within the transaction. Positions need no mapping (they address runs, not indexes).DeleteNoderemoves one block and collapses its positions to the nearest surviving run in document order — the first at or after the removed block's place, else the last before it — or clears the selection when no run remains. Node selections on the removed subtree remap to the collapse target. Collapse steps in the position map carry the same rule to external positions.decodeDocumentpreserves blocks whose type this version does not implement asUnknownnodes: original type, remaining JSON fields, and no text runs. Unknown nodes are addressable structurally (move, delete) but never text-editable;findUnknownNodeslists them. Non-JSON payloads, missing ids, unknown top-level fields, and unsupported versions are rejected rather than dropped.- Marks carry boundary expansion (
Bold/Italic:after,Code:none).resolveInsertion(document, position)retargets edge insertions whose marks forbid the edge to the neighbor carrying exactly the remaining marks; mixed edges stay put rather than swapping formatting. At a block's outer edge, the typed text takes its own run with only the marks that extend there. Unknown marks default to expanding both ways so preservation never retargets them away. - Every transaction ends by running its transforms (by default
mergeAdjacentRuns): adjacent same-mark runs within touched blocks merge, the first run keeping its identity and text and later equivalents retiring (reported inremovedNodeswithRelocateSteps). Loading never normalizes, so decoded documents stay verbatim until first edited; empty transactions stay untouched to preserve state identity. - Positions count UTF-16 code units. Low-level edits may split a surrogate pair; grapheme-aware user commands are not implemented.
SetSelectionresolves against the document at that point in the transaction. Ranges retain anchor/focus direction; node selections may target a block or run.- Insertion at a range endpoint keeps
beforeaffinity on the left and movesafteraffinity past the insertion. Deletion collapses covered positions to its start. Other nodes' positions are unchanged.
positionMap records text replacements in sequential coordinates, plus split
relocations ({ node, into, at }): offsets above the split point move to the
new run, the split point itself follows affinity, lower offsets stay. Use
mapPosition(position, result.positionMap) for another position from the original
document. The returned selection is already mapped. ChangeSet names touched
text runs and their blocks, plus insertedNodes/removedNodes and
structureChanged once structural operations exist; it is neither a replication
packet nor proof of a net content change (insert-then-delete can cancel).
selectionChanged compares the final selection with the original. Empty edits
preserve state identity.