Rich text / foldkit-richtext

Current semantics

  • Documents contain paragraphs and headings (levels 1–6), each containing text runs. The shipped marks are Bold, Italic, and Code, 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 (see findUnknownMarks) and rides along through text edits. Edit.addMark takes any mark; run is 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 Node block may carry nested blocks in blocks, 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 by inspect and 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 optional parent to 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 with InvalidParent is 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's atom declaration, or, without one, a node holding nothing.
  • InsertText targets one run and inherits that run's marks. When the command carries marks, 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.
  • DeleteText removes a half-open range [from, to) within one run.
  • AddMark sets 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. RemoveMark filters 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.
  • SplitNode splits 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.
  • SplitRun divides 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.
  • JoinNode moves 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.
  • MoveNode reorders 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 optional parent moves it into a node block's nested blocks (and back out), and a parent that cannot hold blocks is refused.
  • RetypeBlock changes 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).
  • SetProps sets 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.
  • InsertNode splices a caller-built block at an explicit index, or into a node block's nested blocks when parent is given; every carried identity must be fresh within the transaction. Positions need no mapping (they address runs, not indexes).
  • DeleteNode removes 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.
  • decodeDocument preserves blocks whose type this version does not implement as Unknown nodes: original type, remaining JSON fields, and no text runs. Unknown nodes are addressable structurally (move, delete) but never text-editable; findUnknownNodes lists 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 in removedNodes with RelocateSteps). 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.
  • SetSelection resolves 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 before affinity on the left and moves after affinity 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.