Rich text / foldkit-richtext
Commands
Intent is resolved into operations by run, so durable history keeps its
meaning and identity comes from the caller:
let n = 0
const ids = { mint: () => `new-${++n}` }
RichText.run(state, { type: 'InsertText', text: 'hi' }, ids)
RichText.run(state, { type: 'ToggleMark', mark: 'Bold' }, ids)
RichText.run(state, { type: 'SplitBlock' }, ids)
RichText.run(state, { type: 'RetypeBlock', to: { type: 'Heading', level: 2 } }, ids)InsertText, DeleteBackward, DeleteForward, SplitBlock, ToggleMark, SetMark,
ClearMark, SetSelection, Paste, and the block commands below (RetypeBlock,
WrapBlock, ConvertBlock, RetypeContainer, LiftBlock) read the current selection; MoveBlock,
SetProps, InsertBlock and DeleteBlock name their blocks instead. Each resolves to transactions applied as one step, usually one (Enter over
a range in a list item is two: the delete, then the split); the returned ChangeSet and
positionMap describe the effect, and transactions holds what was applied, in order, an
empty one for a command that changed nothing. Nothing mints
identity unless the caller's mint does, and replay applies transactions rather than
commands: folding apply over transactions from the starting state gives the same result.
An action is an ordered list of those commands committed as one step:
RichText.runAction(state, [removeTheMarker, retypeTheBlock], ids)runAction runs the commands in sequence and returns one state, one ChangeSet (the
union of its commands'), and one identity stream. It stops at the first refusal and
returns that command's error; state is a value, so nothing partial escapes. One command
behaves exactly as run does.
An input rule turns what was just typed into such an action:
interface InputRule {
name: string
match: (
textBefore: string,
within: ReadonlyArray<string>,
) => { remove: number; commands: Action; storedMarks?: ReadonlyArray<string> } | undefined
}within is the kinds of the node blocks around the caret's block, innermost first,
so a rule can tell [ ] typed at the start of a list item from the same text at the
top level. applyInputRules takes it as within, defaulting to none, and the editor
fills it in.
applyInputRules(rules, { textBefore, text, insertion }) builds the action — the insertion,
one DeleteBackward per character the rule consumed, then the rule's commands. The deletes
are what let a rule work without a range: # is not in the document until the insertion
that completes it has run, so a range read before that would be the wrong range. A rule that
claims to consume more than the text it was shown throws, because those deletes would run
past the block's start and join it to the one before. textBefore for a selection that is
a range is read from rangeStart(document, range) — the end that comes first, whichever
way the range was made — since that is where what is typed over it lands.
resolveInputRules returns the same action with the matching rule's storedMarks: the marks
the text typed next carries, which an editor keeps, so **bold** closed as typed ends its bold
rather than letting the bold run continue. A delete removes a whole grapheme, so remove counts
graphemes.
RetypeBlock changes the type of the block the selection starts in — Paragraph, or
a Heading at a level — and keeps that block's runs, so identities and the caret
survive. Given a vocabulary that declares a node kind as holding text, it also takes a block
of that kind back out — a CodeBlock to a paragraph — the same way, keeping the block's and
its runs' identities. Any other node block, or preserved content, is refused, because its
content is not runs.
WrapBlock puts that same block inside new containers, listed outermost first —
{ type: 'WrapBlock', containers: [{ kind: 'Quote' }] }, or a List holding a ListItem
— built where the block stood, with identities from mint. The block moves rather than
being copied, so its identity, its runs, and the caret all survive. Given a vocabulary, a
wrap the vocabulary would not hold is refused with UnexpectedChild: the parent must accept
the outermost container, each container must be a kind declared to hold nested blocks, and
each must accept the next. A container's props must decode as its kind declares them, or the
wrap is refused with InvalidInput. With a vocabulary, a wrap whose outer container holds
items (as List holds ListItem) and whose block sits right after a container of that kind
and those props joins it: the block becomes its last item instead of starting a second list,
which Markdown would read back as the same one.
ConvertBlock makes a paragraph or heading a node kind that holds text —
{ type: 'ConvertBlock', to: { kind: 'CodeBlock', props: { language: 'ts' } } } — with its
text and marks. It is a retype: the block and its runs keep their identities, so the
selection, a decoration, a remote cursor, and another replica's edit to that text still
find them. Given a vocabulary, the kind must be declared to hold text and its
parent must accept it (UnexpectedChild), its props must decode as the kind declares them
(InvalidInput), and a kind that forbids marks refuses a block that carries any
(ForbiddenMark).
RetypeContainer retypes the container around that block — the node holding it, such as a
list item — to another node kind where it stands:
{ type: 'RetypeContainer', to: { kind: 'TaskItem', props: { checked: false } } } makes the
item a task without moving it, splitting its list, or touching what it holds, and the
container keeps its identity, so another replica's edit inside it survives. It acts only from
the container's first block, which is where a marker for the container is typed, and refuses
elsewhere, or with no container, with InvalidInput. Given a vocabulary, the new kind must
be declared to hold blocks, hold every block the container holds, and stand where the
container stands (UnexpectedChild), and its props must decode (InvalidInput).
LiftBlock is the inverse of a wrap: it moves the block the selection starts in out of its
container — before it when it was first, after it when it was last, and between the two halves
of a split container when it was in the middle — and deletes a container it leaves empty. With
a vocabulary, it keeps lifting while the new parent does not hold the block's kind, so a list
item's paragraph leaves both the item and the list; without one it takes a single step. It
never leaves a container declared isolating, such as the standard TableCell, and a block
with nothing to leave is refused with InvalidInput. The block keeps its identity, and the
caret with it.
MoveBlock moves a block, with everything it holds, before or after another block:
{ type: 'MoveBlock', node, to: { before: other } } or { after: other }. It addresses blocks
by identity, not by the selection, so a block handle or a key sends it for the block it stands
for; identities are kept, and so is the selection, except a node selection on a container the
move empties and deletes, which moves to the moved block. other can be in any container the block may
move into. With a vocabulary, that container has to hold the block's kind, and the kind has to
stand in it by its within (otherwise UnexpectedChild). The move may not leave an
isolating container such as a table cell, or go inside itself (InvalidParent). A container
the move leaves empty is deleted with it, as a lift deletes one: an item moved out of a
one-item list takes the list too. A block that is not there is InvalidInput, and moving a
block beside itself changes nothing. moveTargets(document, node, nodes?) lists the blocks it
may move beside, in document order, which is what a drag offers.
SetProps sets props on a node block by identity and keeps the ones it does not name:
{ type: 'SetProps', node: item, props: { checked: true } } ticks a task item. Given a
vocabulary, props that decode as the kind declares them must still decode after, or the
command is refused with InvalidInput, as it is for a block that is not a node. Props that
already fail (a key an older version wrote, which cannot be deleted) can still be changed.
InsertBlock puts a block, with everything it holds, beside another:
{ type: 'InsertBlock', block, to: { after: other } }. Its identities are minted afresh, so
the same block value can be inserted twice (a handle's Duplicate sends the block it stands for).
Without to it goes beside the caret's block: before it when that block is empty, so a slash
command typed on a blank line leaves the line below to type on, and after it otherwise. The
caret moves to the start of its first text, when it has any. With a vocabulary, the container
has to hold the kind and the kind has to stand there, as for a move (UnexpectedChild).
DeleteBlock deletes a block and everything it holds: { type: 'DeleteBlock', node }. A
container it leaves empty goes with it, as a move's does. A caret inside it moves to the start of
the text after it, or the end of the text before; a document it would empty gets an empty
paragraph, so there is always somewhere to type.
A table is edited as a grid from the cell the caret is in (tableAt(document, selection) reads
which: the table, its rows, the row and the column). InsertRow and InsertColumn ({ where:
'before' | 'after' }) add a row as wide as the widest, or a cell beside the caret's in every row,
at the end of a row too short for that column; DeleteRow and DeleteColumn remove them, a row a
column empties with it, and the last row or column takes the table. The caret goes to the new
cell, or to its neighbour. Each is one transaction, so one undo step, and each is refused outside
a table (InvalidSelection).
Indent and Outdent are Tab and Shift+Tab. In a kind declared lineBreaks (code) they add or
remove a level of indentation (INDENT, two spaces): at the caret, or at the start of every line
a range covers; Enter there starts the new line as indented as the one it broke. In a table cell
they move the caret to the next or previous cell, and Tab in the last cell adds a row. In a list
item, Tab nests it under the item before it (in the list that item ends with, or a new one of its
own kind), and Shift+Tab moves a nested item out to follow its parent item, or takes a top-level
item out of its list; the first item has nothing to nest under and stays. The innermost wins: code
in a cell indents, a list in a cell nests. Anywhere else they are refused, and takesTab(document, selection, nodes) says so
beforehand, which is how an editor leaves Tab to move focus there.
DeleteBackward at the start of a container's first block used to do nothing, having no
sibling to join. With a vocabulary it now lifts the block, which is how Backspace undoes a
> or - typed at a block's start. Without a vocabulary, in a table cell, and forwards, the
edge still does nothing. At the start of a kind the vocabulary declares as holding text, such
as a CodeBlock, Backspace retypes it to a paragraph instead of joining it to the block
above, which undoes a fence the way a lift undoes a list marker.
SplitBlock at the end of a heading starts a paragraph under it, since what follows a title is
text; in the middle of one, both halves stay headings. Over a selection, the range's end
decides, and one ending past the heading also starts a paragraph.
SplitBlock inside a list item works on the item, with a vocabulary to say what an item is: a
container its parent declares it holds (a List holds ListItems), and not isolating. Enter
splits the block, and the second half, with every block after it in the item, becomes a new
item of the same kind and props right after; the caret lands at its start. Enter in an empty
block that is the item's whole content leaves the list instead, as Backspace does. Over a range
inside an item, the range is deleted first. The new item copies the old one's props, with the
kind's splitProps laid over them when it declares some: the standard TaskItem's
{ checked: false } makes Enter in a done task start an open one. Together they must still
decode by the kind's Props, or the split is refused (InvalidInput), and a heading's rule
holds inside an item too. Without a vocabulary, in a quote, and in a table
cell, Enter splits only the block, as before.
InsertText takes an optional marks. With it, the inserted text carries
exactly that set wherever it lands; without it, the boundary rule decides and the
text inherits the marks of the run it joins. A mark the caller's vocabulary does
not declare is rejected, and so is one whose props its definition does not decode — a
Link named bare, with no href (MarkRegistry.accepts is the check). That is how stored marks stay the application's state:
the caret's format belongs to the caller, and the command layer reads no hidden
cursor state. A collapsed ToggleMark is likewise a no-op — the application
decides what the caret carries and passes it back on the next InsertText.
A toggle can take a link off but cannot change where it points. SetMark puts exactly
the given mark on every run a range covers, replacing the props of a same-named mark,
and ClearMark takes a mark off by name, or, given a mark value, off only the runs carrying
exactly that value (one discussion's highlight, leaving another's inside it). At a caret both act on the mark's extent
around it, so an editor changes or removes the link the caret sits in without selecting
it first; outside a mark they do nothing. markExtent(document, position, name) is that
extent as a read: the mark, and the range of adjacent runs carrying it with the same
props, which is what a link editor shows before it sends either command. linkAt(document,
selection) is the standard vocabulary's reading of it for Link: the href and extent of
the link a selection starts in, or undefined outside one.
marksInRange(document, selection) is the read beside them, for a toolbar's active
button: the marks every run the selection covers carries. A caret reports its run's
marks — an empty run can carry them, which is how a caret holds a format — a range
that straddles a marked run and a plain one reports neither, and a node selection
reports what its whole subtree agrees on.
textBlockAt(document, selection) is the style of the block a selection starts in — a
paragraph, or a heading at its level — in the shape RetypeBlock takes, or undefined for any
other block (a code block) or a node selection, which is what a style picker presses.
count(document) is { words, characters } as a reader counts them: Intl.Segmenter's
word-like segments and graphemes, so an emoji is one character and a word in a script without
spaces still counts. Runs join within a block, so a word whose mark changes midway is one word;
blocks never join.
blocksAt(document, selection) is the chain of blocks a selection starts in, outermost first:
a caret in a list item's paragraph gives the list, the item, and the paragraph, and a node
selection gives the chain to its block. It is what a block handle picks its block from.
isBlank(document) says whether a reader would see nothing — no blocks, or a lone paragraph
or heading with no text — which is when an editor shows its placeholder.
textBefore(document, position) is the read for a menu: the text of the block the
position addresses, up to that position, across its runs. A query is typed into the
block's text between its start and the caret, so it never spans blocks; an offset
past a run's end clamps to it, a negative one reads as none of it, and a position
that resolves to nothing gives an empty string.
textRangeBefore(document, position, length) is its inverse, for a rule that removes
what it matched: the range covering the length characters before the position in its
block, or undefined when they do not all precede it. An offset past the run's end clamps
to it, as textBefore clamps. Endpoints land at run boundaries
at after affinity, where a caret that typed that character would sit.