Rich text / foldkit-richtext-dom
Lower level: the interpreter
Everything above is built from these. Reach for them to embed the editable subtree without the Bundle, or to write another host.
import { mount } from 'foldkit-richtext-dom'
import { attach } from 'foldkit-richtext-dom/events'
import * as RichText from 'foldkit-richtext'
let dom = mount(ownerDocument, content) // render once
host.append(dom.root) // the host element is yours
// Events become intent; your update decides and commits.
const attachment = attach(dom, {
onIntent: command => dispatch({ type: 'Edited', command }),
})
// In the resulting transition, after RichText.run returns a new state:
attachment.sync(next, result.changeSet)
dom = attachment.current()This is the shape, not a copyable module: ownerDocument, content, host, dispatch,
next, and result come from the surrounding application, and next is the EditorState
that transition committed.
mount(ownerDocument, content, renderer?, decorations?)builds the subtree and the identity index it is patched through:data-blockon a block,data-runon a run. It listens to nothing and runs nothing. A mark renders as an element inside its run element, so the text node stays deepest and a selection still maps; a mark no entry renders stays ondata-marks. A node entry renders a node block as its element, nested blocks inside, and a kind no entry renders keeps adiv. The registry is kept on theEditorDom, so every later patch renders the same way. Decorations are drawn as the read-only renderer draws them, and the position mapping reads across the text nodes they split.attach(dom, { onIntent, onSelection?, keymap? })listens forbeforeinput,keydown, composition, copy, cut, and paste, and reports each one it understands as intent. Everything it understands it prevents, so the browser never mutates the DOM behind the document; an event it cannot honor yet is prevented with no intent rather than allowed to drift.onSelectionreports a caret or range inside the editor that the application did not just commit, and nothing while an IME owns the caret. A selection elsewhere on the page is not reported, so the editor keeps its own caret (or selected block) while another field has focus. A keystroke, a composition start, a cut, or a paste reads the live selection first and reports it before its intent, becauseselectionchangeis asynchronous and a click just before would otherwise be missed.keymapadds or overrides chord bindings, checked before the built-in ones. A press on an atom (an image, a divider) selects it as a node selection; the browser is given the range around its element, which reads back as that selection, and the block carriesdata-selected, the only sign of it a stylesheet can reach. From a selected atom an arrow goes to the text beside it.attachment.sync(state, changeSet)patches and restores the selection in one call, but only while the browser's selection is inside the editor: a sync never takes the caret from another editor or an input. While an IME is composing, the latest state waits and is drawn when composition ends, so another person's edit never rewrites the text under the IME.detach()removes the listeners.patch(dom, content, changeSet, decorations?)removes what the change set removed, re-renders what it marked dirty, and places inserted or moved elements in document order.decorationsis the next render's whole set, so a run whose share of it changed is redrawn, and a patch without one clears what was drawn. An element that is no longer what its block renders as (a heading re-leveled toh3, say) is re-rendered rather than kept.repair(dom, content)is recovery, not domain state: it drops what the subtree holds that the document does not, re-renders blocks whose rendering drifted, and returns the sameEditorDomwhen nothing was wrong. Call it after a cancelled IME.positionToRangeandrangeToPositiontranslate between a semanticPositionand a DOMRange. A DOM caret carries no affinity, so mapping back derives it (afterat a run's end,beforeelsewhere) rather than pretending to round-trip it.
Mounting into a view's element
A Foldkit view owns the host element; the interpreter owns what goes inside it. mountInto
renders into the host and records the attachment there, so a patch Command that has only the
element can find it:
import { attachmentIn, mountInto, releaseMount } from 'foldkit-richtext-dom/host'
// The mount: once, when the host element enters the DOM. The registry is optional.
const attachment = mountInto(host, content, { onIntent, onSelection }, renderer)
// The patch Command: after the transition committed, given the host element.
attachmentIn(host)?.sync(state, changeSet)
// Unmount.
releaseMount(host)If the host already holds exactly the subtree mount would build (server markup the browser
parsed, say), mountInto keeps those elements and indexes them. adopt(existing, content,
rendering?, decorations?) is that check alone. It compares the trees ignoring empty text
nodes, which markup cannot carry, and gives an empty run back the text node a caret needs.
Anything else in the host is replaced, so it ends with one subtree either way.
renderEditable(document, renderer?, decorations?) is the same subtree as Html, which is
what a server sends.
attachEditor(host, content, emit, { rendering?, decorate?, placeholder?, lineHint? }) attaches the
translation to Messages to a host element; events({ content }) wraps it as the Mount the
editor's view uses; patchEditor(hostId, state, changeSet) is what the patch Command runs,
reporting whether it patched. A missing host is an editor that went away while the transition
was in flight, not an error.
Pasting HTML
parseHtml(html, { kit, mint }) at foldkit-richtext-dom/html is the clipboard fallback: a
whitelist walk over a DOMParser tree that mints fresh identities. It maps the standard
vocabulary's elements (blockquote, pre with its language from data-language or a
language-… class, hr, img, table/tr/td/th with a row of ths as the header,
s/del, a) and reads a fixed few attributes, each URL through safeUrl. That refuses a
scheme outside http/https/mailto/tel after removing control characters, so
java\tscript: cannot walk past it. A style, an onclick, or a javascript: URL cannot
survive as anything executable, and an element it cannot map keeps its text and reports a
diagnostic.
What it does not do
- It does not run commands or hold domain state. A caller runs the command and hands the result
here;
attachreports intent and stops. - It does not patch a container's items one by one: a container whose item list changed is re-rendered where it stood, so its surviving items are rebuilt rather than kept. Correct, and a follow-up for identity preservation.
- One host id is one editor on the page, so repeated rows of a form cannot each hold one yet.