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.

ts
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-block on a block, data-run on 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 on data-marks. A node entry renders a node block as its element, nested blocks inside, and a kind no entry renders keeps a div. The registry is kept on the EditorDom, 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 for beforeinput, 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. onSelection reports 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, because selectionchange is asynchronous and a click just before would otherwise be missed. keymap adds 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 carries data-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. decorations is 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 to h3, 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 same EditorDom when nothing was wrong. Call it after a cancelled IME.
  • positionToRange and rangeToPosition translate between a semantic Position and a DOM Range. A DOM caret carries no affinity, so mapping back derives it (after at a run's end, before elsewhere) 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:

ts
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; attach reports 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.