foldkit-richtext-dom 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +448 -166
  2. package/dist/decorations-Ar6URa7n.mjs +57 -0
  3. package/dist/decorations-Ar6URa7n.mjs.map +1 -0
  4. package/dist/editor-C76tfEyV.d.mts +1016 -0
  5. package/dist/editor-C76tfEyV.d.mts.map +1 -0
  6. package/dist/editor-bundle-dnjyuk1P.d.mts +8576 -0
  7. package/dist/editor-bundle-dnjyuk1P.d.mts.map +1 -0
  8. package/dist/editor-bundle.d.mts +2 -7535
  9. package/dist/editor-bundle.mjs +352 -81
  10. package/dist/editor-bundle.mjs.map +1 -1
  11. package/dist/editor.d.mts +2 -849
  12. package/dist/editor.mjs +176 -9
  13. package/dist/editor.mjs.map +1 -1
  14. package/dist/{events-BTw9SJ7r.d.mts → events-Dasls6a_.d.mts} +31 -5
  15. package/dist/events-Dasls6a_.d.mts.map +1 -0
  16. package/dist/events.d.mts +2 -2
  17. package/dist/events.mjs +84 -14
  18. package/dist/events.mjs.map +1 -1
  19. package/dist/host-1dtxuKIA.d.mts +86 -0
  20. package/dist/host-1dtxuKIA.d.mts.map +1 -0
  21. package/dist/host.d.mts +2 -25
  22. package/dist/host.mjs +78 -6
  23. package/dist/host.mjs.map +1 -1
  24. package/dist/html.d.mts +1 -1
  25. package/dist/html.d.mts.map +1 -1
  26. package/dist/html.mjs +150 -20
  27. package/dist/html.mjs.map +1 -1
  28. package/dist/index-DDV_JesZ.d.mts +57 -0
  29. package/dist/index-DDV_JesZ.d.mts.map +1 -0
  30. package/dist/index.d.mts +2 -2
  31. package/dist/index.mjs +249 -99
  32. package/dist/index.mjs.map +1 -1
  33. package/dist/input.d.mts +936 -0
  34. package/dist/input.d.mts.map +1 -0
  35. package/dist/input.mjs +89 -0
  36. package/dist/input.mjs.map +1 -0
  37. package/dist/toolbar.d.mts +39 -0
  38. package/dist/toolbar.d.mts.map +1 -1
  39. package/dist/toolbar.mjs +325 -1
  40. package/dist/toolbar.mjs.map +1 -1
  41. package/dist/view.d.mts +14 -2
  42. package/dist/view.d.mts.map +1 -1
  43. package/dist/view.mjs +90 -19
  44. package/dist/view.mjs.map +1 -1
  45. package/package.json +26 -5
  46. package/dist/editor-bundle.d.mts.map +0 -1
  47. package/dist/editor.d.mts.map +0 -1
  48. package/dist/events-BTw9SJ7r.d.mts.map +0 -1
  49. package/dist/host.d.mts.map +0 -1
  50. package/dist/index-6QEOehXh.d.mts +0 -38
  51. package/dist/index-6QEOehXh.d.mts.map +0 -1
package/README.md CHANGED
@@ -1,26 +1,40 @@
1
1
  # foldkit-richtext-dom
2
2
 
3
- The DOM adapter for a [`foldkit-richtext`](../richtext) editable subtree.
3
+ The browser half of a [`foldkit-richtext`](../richtext) editor: it owns the
4
+ `contenteditable` subtree, and gives an application an editor it places like any other
5
+ Foldkit Bundle.
4
6
 
5
- A rich-text editor cannot let a virtual DOM own its `contenteditable` subtree:
6
- the browser mutates that subtree directly for typing, selection, IME, and
7
- clipboard, and rebuilding it on every keystroke fights that native behavior. This
8
- package owns the subtree instead. It renders a semantic `Document` into real DOM
9
- once, patches only the nodes a `ChangeSet` names, and translates the browser
10
- events it understands into `foldkit-richtext` commands.
7
+ A rich-text editor cannot let a virtual DOM own its `contenteditable` subtree: the browser
8
+ mutates that subtree directly for typing, selection, IME, and the clipboard, and rebuilding
9
+ it on every keystroke fights that native behavior. This package owns the subtree instead. It
10
+ renders a semantic `Document` into real DOM once, patches only the nodes a `ChangeSet` names,
11
+ and turns the browser events it understands into Messages.
11
12
 
12
- ## Ownership
13
+ ## Who owns what
13
14
 
14
15
  ```text
15
- foldkit-richtext owns the semantics: Document, commands, transactions
16
- foldkit-richtext-dom owns the contenteditable subtree and its events (this package)
17
- foldkit / application owns everything around it: host, toolbar, menus, status
16
+ foldkit-richtext the semantics: Document, commands, transactions, validation
17
+ foldkit-richtext-dom the contenteditable subtree and its events (this package)
18
+ your application the document in its Model, and everything around the editor
19
+ foldkit-mixins-richtext that everything, drawn as styleable views: toolbars, menus, handle
18
20
  ```
19
21
 
20
- Only `Document`, `ChangeSet`, and `Selection` values, commands, and intents cross
21
- the boundary. Nothing inside the subtree is read as truth; the semantic document
22
- stays authoritative, and the DOM is consulted only to map a browser selection back
23
- to a position.
22
+ The document is never read back from the DOM. It lives in the application's Model, and the
23
+ DOM is consulted only to map a browser selection to a position. Only `Document`, `ChangeSet`,
24
+ and `Selection` values, commands, and Messages cross the boundary.
25
+
26
+ ## The loop
27
+
28
+ ```text
29
+ browser event → Message → update runs the command → Model holds the new document
30
+ → patch Command → DOM patched to match
31
+ ```
32
+
33
+ An edit is a transition like any other: `update` decides, the Model commits, and rendering
34
+ follows through a Command that patches what the `ChangeSet` names. Nothing edits the DOM
35
+ first and reports later; an event the adapter cannot honor is prevented, not let through.
36
+
37
+ ## Install
24
38
 
25
39
  This package is **early**: 0.x, and its API may change between minor versions.
26
40
 
@@ -28,20 +42,341 @@ This package is **early**: 0.x, and its API may change between minor versions.
28
42
  pnpm add foldkit-richtext-dom foldkit-richtext foldkit-bundle foldkit effect
29
43
  ```
30
44
 
31
- ## Entries
45
+ ## Sixty seconds: an editor on a page
46
+
47
+ ```ts
48
+ import { Bundle } from 'foldkit-bundle'
49
+ import * as RichText from 'foldkit-richtext'
50
+ import * as Runtime from 'foldkit/runtime'
51
+ import { editorAt, Model, type ParentMessage } from 'foldkit-richtext-dom/editor-bundle'
52
+
53
+ // One editor, placed at the host id its view renders, with what it draws and allows.
54
+ const body = editorAt('body', {
55
+ rendering: RichText.standardRendering,
56
+ vocabulary: {
57
+ marks: RichText.markRegistry(RichText.standardMarks),
58
+ nodes: RichText.nodeRegistry(RichText.standardNodes),
59
+ },
60
+ placeholder: 'Write something…',
61
+ })
62
+ const app = Bundle.assemble<Model, ParentMessage>()([body])
63
+
64
+ const document = RichText.decodeDocument({
65
+ version: 1,
66
+ children: [
67
+ { type: 'Paragraph', id: 'p', children: [{ type: 'Text', id: 't', text: 'Hello', marks: [] }] },
68
+ ],
69
+ })
70
+
71
+ Runtime.embed(
72
+ Runtime.makeElement({
73
+ Model,
74
+ container: window.document.getElementById('app')!,
75
+ init: () => app.initial({ document }),
76
+ update: app.update(),
77
+ view: (model, h) => h.main([], [body.view(model, h)]),
78
+ }),
79
+ )
80
+ ```
81
+
82
+ What each part does, and does not do:
83
+
84
+ - **`editorAt(hostId, placement)`** records, under `hostId`, how this editor renders, which
85
+ marks and kinds its edits may use, and what a blank document shows. It performs no I/O and
86
+ renders nothing yet. The placement is recorded rather than passed as an arg because it holds
87
+ functions and schemas, which a Bundle's schema-decoded args cannot carry.
88
+ - **`Model`** is `{ document, editor }`: the document, and the editor's interaction state
89
+ beside it (selection, stored marks, undo history, the slash menu's highlight). The document
90
+ is the parent's; the editor reads it through its Link and never keeps a copy.
91
+ - **`body.view`** renders one element, `<foldkit-richtext id="body">`, and mounts the
92
+ interpreter into it once. Everything inside belongs to this package.
93
+ - **An edit** arrives as a Message, `update` runs the command against the vocabulary, the new
94
+ document lands in `model.document` in the same transition, and a patch Command then updates
95
+ the DOM. An edit the vocabulary forbids is refused and changes nothing.
96
+
97
+ ## Configuring a placement
98
+
99
+ `editorAt(hostId, placement)` takes, each optional:
100
+
101
+ - **`rendering`**: how marks and node kinds become elements (§121); the default renders the
102
+ core's own names. A mark renders *inside* its run element, so the run stays outermost and a
103
+ selection still maps.
104
+ - **`vocabulary`**: `{ marks, nodes }` registries. The editor refuses an edit a declaration
105
+ forbids, rather than only letting `validate` report it later (§125).
106
+ - **`inputRules`**: rules applied to what is typed, so a marker turns into a block change as it
107
+ is completed (§128), such as `foldkit-richtext-markdown`'s `markdownInputRules`. A rule the
108
+ vocabulary refuses does not cost the keystroke: the text is inserted on its own.
109
+ - **`decorate`**: `document => DecorationSet`, drawn over the document on the mount and every
110
+ patch (§129), code highlighting for one. It sees the document only. A highlight derived
111
+ from application state, such as a search query or other people's carets, goes through
112
+ the `overlay(hostId, decorations)` Command instead. It draws them after the placement's
113
+ own and leaves the caret where it is. Its positions name the document shown, so set it
114
+ again when that changes; placing the host id again starts it with none.
115
+ - **`placeholder`**: what a blank document shows (`RichText.isBlank`). It is put on the block as
116
+ `data-placeholder`, and the root, a `role="textbox"`, carries it as `aria-placeholder`.
117
+ Drawing it is the stylesheet's, so the text never enters the content:
118
+
119
+ ```css
120
+ [data-placeholder]::before {
121
+ content: attr(data-placeholder);
122
+ float: left;
123
+ height: 0;
124
+ pointer-events: none;
125
+ color: GrayText;
126
+ }
127
+ ```
128
+
129
+ - **`serverRendered`**: whether a render is the server's; see "On a server-rendered page".
130
+
131
+ Each has a record at `foldkit-richtext-dom/host` (`placeRendering`/`renderingFor`,
132
+ `placeVocabulary`/`vocabularyFor`, and so on) that `editorAt` writes. It is kept for the host
133
+ id, not for one mount, so a host that unmounts and mounts again renders the same way. One host
134
+ id is one editor on the page: the patch Command finds its host by id.
135
+
136
+ The assembled parent's `update` handles every editor Message. Build them with `edited` and the
137
+ helpers beside it: `typed`, `pressed`, `toggled`, `applied`, `cleared`, `retyped`, `wrapped`,
138
+ `converted`, `lifted`, `moved`, `selected`, `undone`, `redone`, and `patched`.
139
+
140
+ ### When the document changes from outside
141
+
142
+ A document the parent puts in the Model any other way than an edit (an entry opened, a
143
+ revision restored, a form's `fill` or `Reset`) gets a fresh host. The host's Mount reads its
144
+ document once, so the view keys the host by the document it shows: each committed edit hands
145
+ its key on to the next document, and a document that did not come from an edit here has none
146
+ yet. The first document a host id shows is drawn without a key, as the server draws it.
147
+
148
+ So commit the document an edit returns as it is. A parent that puts its own copy in the Model
149
+ after each edit (normalized, say) gets a fresh host, and a lost caret, on every keystroke.
150
+
151
+ When the new document continues the old one (another replica's change arriving, or the
152
+ document a collaborative edit projects to), return `patchTo(hostId, previous, next)` from the
153
+ same transition that puts it in the Model. It hands the host's key on from `previous` to
154
+ `next.document`, which have to be the very objects the view rendered and will render, and
155
+ patches the editor from the latest state it holds (what it has drawn, or a state waiting for an
156
+ IME to finish) to `next`, so the host and every run the change
157
+ did not touch keep their elements. `next.selection` becomes the browser selection, so pass
158
+ the caret carried across the change (`Replicated.resolve` of an anchored one), or the caret
159
+ is lost. A replacement that changes nothing drawn, as an exchange that only confirms edits
160
+ already shown does, leaves the DOM and its selection alone. `replaceChangeSet(previous, next)` is the patch, in a transaction's terms: the runs
161
+ whose text or marks differ and the blocks holding them, blocks whose runs moved in or out or
162
+ changed order, blocks whose own fields changed, the
163
+ nodes that came and went, and whether any block moved. An `Edited` OutMessage also carries
164
+ the `transactions` the edit applied, which is what `RichText.Replicated.translate` reads.
165
+
166
+ ## As a form control
167
+
168
+ `foldkit-richtext-dom/input` puts the editor in a `foldkit-form` form: a key whose value is a
169
+ `RichText.Document`. It is the one entry that imports `foldkit-form`, an optional peer.
170
+
171
+ ```ts
172
+ import { Form } from 'foldkit-form'
173
+ import { richTextInput } from 'foldkit-richtext-dom/input'
174
+
175
+ // PostInput is an `Entity.input(...)` whose `body` field is a `RichText.Document`.
176
+ const PostForm = Form.make('PostForm', PostInput, {
177
+ inputs: { body: richTextInput('post-body', { placeholder: 'Write the post…' }) },
178
+ })
179
+ ```
180
+
181
+ Here there is no parent to hold the document: a form key has nothing to report an edit to,
182
+ and `Input.bundle` refuses a Bundle with an OutMessage. So `EditorInput` is the same editor
183
+ holding its document in its own Model, with each committed edit and undo folded back in. The
184
+ form owns that Model as the key's draft; the document is the key's value, validated,
185
+ submitted, and reported by `authoredChanged` like any other.
186
+
187
+ - **Placement:** `richTextInput(hostId, placement)` places what `editorAt` places, under that
188
+ host id. Since one host id is one editor, a key repeated in a form's rows cannot be a
189
+ rich-text control yet.
190
+ - **Nothing entered:** a new record starts on one empty paragraph, which is where a caret can
191
+ go. A blank document reads as no value, so a required key refuses it.
192
+ - **Filling:** `fill` shows a given document with a history of its own and no selection, since
193
+ the caret was in runs the given document does not have.
194
+ - **Resuming:** `settled`, for a stored draft shown again, keeps the caret, the stored marks,
195
+ and the history, clears only the slash menu's highlight, and takes this placement's host id
196
+ in case the draft was stored under another.
197
+
198
+ ## Chrome around the editor
199
+
200
+ The editor renders its host and nothing else (§29). A toolbar, a menu, a handle: each is the
201
+ application's chrome, drawn beside the host from the Model, sending the editor's Messages.
202
+ This package ships the plain versions below; `foldkit-mixins-richtext` draws the same chrome
203
+ as slot views an application restyles.
204
+
205
+ ### The marks toolbar
206
+
207
+ `marksToolbar({ state, toMessage, marks? })` returns a view, `(h) => Html`, of one button per
208
+ mark, dispatching the Message a chord would. `state` is what the editor projects (`document`,
209
+ `selection`, `storedMarks`), and `marks` defaults to the three the package ships. A mark is
210
+ active when the caret carries it, or, with no stored format, when every run the selection
211
+ covers does. `markActive(state, mark)` is that rule alone, for a renderer drawing its own
212
+ buttons.
213
+
214
+ ### The floating toolbar
215
+
216
+ The same buttons, in an element `selectionAnchor` places over the selection:
217
+
218
+ ```ts
219
+ import * as RichText from 'foldkit-richtext'
220
+ import { marksToolbar, selectionAnchor } from 'foldkit-richtext-dom/toolbar'
221
+
222
+ RichText.coversText(model.editor.selection)
223
+ ? h.div(
224
+ [h.OnMount(selectionAnchor({ hostId: 'body', gap: 8 }))],
225
+ [
226
+ marksToolbar({
227
+ state: { ...model.editor, document: model.document },
228
+ toMessage: mark => edited(Message.ToggledMark({ mark })),
229
+ })(h),
230
+ ],
231
+ )
232
+ : h.empty
233
+ ```
234
+
235
+ Whether it is drawn is the view's, from the Model: `RichText.coversText` is a range that is not
236
+ a caret. The Mount only moves it. It places the element `gap` pixels above the page's
237
+ selection, centred and kept inside the window's width, or below when there is no room above,
238
+ and again on `selectionchange`, any scroll, and resize; a selection outside the host is
239
+ ignored. It writes `position: fixed`, `top`, `left`, and `data-placement` (`top` or `bottom`)
240
+ on the element, so a style there must leave those alone.
241
+
242
+ ### The block handle: anchor and drag
243
+
244
+ `blockAnchor({ hostId, node, gap })` places an element `gap` pixels left of block `node`, level
245
+ with its top edge and never past the window's left edge. It places it again when the editor's
246
+ subtree changes (a patch can move a block without the page scrolling), on scroll, and on
247
+ resize, and it finds a host drawn after it, or drawn again in the same parent.
248
+
249
+ `blockDrag({ hostId, node })` drags block `node` by the element it is mounted on, to any block
250
+ `RichText.moveTargets` allows under the placed vocabulary, so an item can go into another list
251
+ but not to the top level:
252
+
253
+ - The pointer picks the nearest edge of those blocks: before one at its top, after one at its
254
+ bottom. Where a container's edge meets its first or last block's, the block's is picked while
255
+ the pointer is inside it, and the container's once the pointer is past it.
256
+ - A line, `[data-richtext-drop]`, is fixed at that edge, outside the editable subtree, for a
257
+ stylesheet to draw. The place is read again on a scroll and on release, where the blocks are
258
+ then, and release sends `MovedBlock` there.
259
+ - Escape, a cancelled pointer, a move with the button up (a release the page never heard, over
260
+ a frame say), or a place where the block already is ends it with nothing sent.
261
+ - Only the pointer that pressed steers or ends it. The page is listened to only during a drag,
262
+ in the capture phase, so a handler that stops a release does not hide it, and the element
263
+ gets `touch-action: none` while mounted, or a touch would pan the page instead.
264
+
265
+ Both are Mounts, and a Mount reads its args once, when its element is inserted. Key the element
266
+ by the block (`h.Key(node)`) when the block it stands for can change.
267
+
268
+ ### The slash menu
269
+
270
+ The editor owns the slash menu's vocabulary, because an entry is one of its own Messages and
271
+ the Bundle resolves Enter by handling the chosen entry as that Message (§123):
272
+
273
+ ```ts
274
+ import { slashEntries, slashMenu, slashQuery } from 'foldkit-richtext-dom/editor'
275
+
276
+ slashQuery('see /head') // 'head': opens at a block's start or after whitespace
277
+ slashMenu(slashEntries, 'see /head', 0)?.highlighted?.label // 'Heading 1'
278
+ ```
279
+
280
+ - **`slashEntries`** leads with the text blocks a caret can become (`Paragraph`,
281
+ `Heading 1`–`3`), then the standard containers (`Quote`, `Bulleted list`, `Numbered list`,
282
+ `Code block`, whose id is `code-block` because `code` is the Code mark's), then the marks.
283
+ Each has a stable `id`, a `label`, the words a query may also match, and its Message.
284
+ - **`slashMenu(entries, textBefore, index)`** is the one value a view and `update` share:
285
+ whether a query is open, what matches, and what Enter would send. A stale index falls back to
286
+ the first match. `textBefore` is a read (`RichText.textBefore(document, position)`), not
287
+ stored state, so there is no switch that opens or closes the menu.
288
+ - **`EditorState.menuIndex`** is the one thing a menu owns. The application moves it (with
289
+ `slashMove` from `foldkit-mixins-richtext`), and on `Entered` the Bundle treats the
290
+ highlighted entry as the Message a click would send. Choosing an entry also removes the
291
+ query typed for it, as one action (§124 §5): one transition and one undo step.
292
+
293
+ `matchingEntries` filters, and it, `slashQuery`, and `slashMenu` work over any entries carrying
294
+ a `message`, so an application's own catalogue reuses them.
295
+
296
+ ## Showing a document: the read-only renderer
297
+
298
+ `foldkit-richtext-dom/view` renders a document as ordinary Foldkit `Html` through `inertHtml`:
299
+ it dispatches nothing and owns no DOM, so it serves a visitor's page, a preview, or an email as
300
+ it is.
301
+
302
+ ```ts
303
+ const html = renderDocument(document) // a div of block elements
304
+ renderBlocks(slice.blocks) // one element per block
305
+ renderDocument(document, renderer) // a declared Link as a real <a href>
306
+ renderDocument(document, renderer, decorations) // a decoration overlaid on the runs it covers
307
+ ```
308
+
309
+ It takes the same `rendering(...)` registry as the HTML serializer and the editor (§121), so a
310
+ declared mark or kind becomes the same element everywhere, custom elements included. Without
311
+ one, blocks become `p`/`h1`–`h6`, marks nest as `strong`/`em`/`code` in the serializer's order,
312
+ an undeclared mark rides on a `span` with `data-marks`, and an unknown block renders as an
313
+ inert `div data-unknown="Type"` placeholder. A tag Foldkit cannot build is reported, never
314
+ swapped for another element.
315
+
316
+ A decoration set (§64, §126) is projected with `RichText.decorationsIn`: each covered piece of
317
+ a run becomes a `span[data-decoration=<kind>]` with the run's marks inside it, which is also how
318
+ the editor draws one, so one stylesheet serves both. Each string field of the decoration's
319
+ `data` whose name is lowercase letters, digits, and hyphens is drawn as
320
+ `data-decoration-<name>` on that span (`data: { name: 'Ada' }` is `data-decoration-name="Ada"`,
321
+ for a stylesheet's `attr()`); any other field is not drawn. A decoration that starts or ends in
322
+ an empty run, such as another person's caret on an empty line, is drawn as an empty span in it,
323
+ where the core's `decorationsIn` has no text to cover. `renderBlocks` takes no set, since a
324
+ slice's positions cannot be resolved without its document. Text holding a NUL, which markup
325
+ cannot carry, is written as the U+FFFD a parser would make of it.
326
+
327
+ `h.DataAttribute` prefixes `data-` itself, so it takes the bare name
328
+ (`DataAttribute('unknown', …)` is `data-unknown`).
329
+
330
+ ## On a server-rendered page
331
+
332
+ The editor's host is a custom element, `<foldkit-richtext>`, drawn `display: block` by an
333
+ inline style (so a stylesheet's `display` needs `!important` to override it). Hydration leaves
334
+ a custom element's contents alone, so markup the server sent there survives until the editor
335
+ mounts, and the editor adopts it rather than drawing the document again. The server sends it
336
+ when the placement says the render is the server's:
337
+
338
+ ```ts
339
+ import { SSR } from 'foldkit-ssr'
340
+
341
+ editorAt('body', { rendering: RichText.standardRendering, serverRendered: SSR.serving })
342
+ ```
343
+
344
+ While `serverRendered()` is true, the host holds `renderEditable` of the document, with the
345
+ placement's rendering and decorations: exactly what `mount` would build. Without the option the
346
+ host is empty until the editor mounts. The browser's Model must hold the same document the
347
+ server rendered, or the markup is not what `mount` would build and is replaced.
348
+
349
+ ## The editor's Messages
350
+
351
+ `foldkit-richtext-dom/editor` holds the vocabulary an editor's `update` handles. `Message`'s
352
+ cases (each built as `Message.Typed({ text })` and so on, not exported one by one):
32
353
 
33
354
  ```text
34
- foldkit-richtext-dom the interpreter: mount, patch, repair, position mapping
35
- foldkit-richtext-dom/host mountInto, attachmentIn, releaseMount, placeRendering, renderingFor
36
- foldkit-richtext-dom/events attach, intentFor, selection read and restore
37
- foldkit-richtext-dom/html parseHtml
38
- foldkit-richtext-dom/view renderDocument, renderBlocks
39
- foldkit-richtext-dom/toolbar marksToolbar
40
- foldkit-richtext-dom/editor Message, toMessage, attachEditor, events, patchEditor
41
- foldkit-richtext-dom/editor-bundle Editor, editorAt, application, update, the Messages
355
+ Typed, Backspace, DeletedForward, Entered, ToggledMark, AppliedMark, ClearedMark,
356
+ RetypedBlock, WrappedBlock, ConvertedBlock, LiftedBlock, MovedBlock, Selected, Pasted, Undone,
357
+ Redone, Patched
42
358
  ```
43
359
 
44
- ## The loop
360
+ The adapter reports what the browser did as commands, a caret, and history chords, and
361
+ `toMessage` turns each into a Message. It refuses what the vocabulary cannot carry rather than
362
+ dropping a detail: an insertion carrying marks, or a mark value with props, comes back
363
+ `undefined`. The rest are the application's to send, because no browser event means them:
364
+
365
+ - `RetypedBlock`, `WrappedBlock` (`{ containers }`), `ConvertedBlock` (`{ to }`), and
366
+ `LiftedBlock` for the core's block commands;
367
+ - `MovedBlock` (`{ node, to }`, `to` a `RichText.Beside`), which a block handle sends and which
368
+ needs no selection;
369
+ - `AppliedMark` (`{ mark }`, a name or a `{ name, props }` value) and `ClearedMark` (`{ mark }`)
370
+ for `SetMark` and `ClearMark`: what a link editor sends, which at a caret changes or removes
371
+ the whole link the caret is in.
372
+
373
+ `Patched` is the patch Command's own completion, because a Foldkit Command must return a
374
+ Message.
375
+
376
+ ## Lower level: the interpreter
377
+
378
+ Everything above is built from these. Reach for them to embed the editable subtree without
379
+ the Bundle, or to write another host.
45
380
 
46
381
  ```ts
47
382
  import { mount } from 'foldkit-richtext-dom'
@@ -61,55 +396,51 @@ attachment.sync(next, result.changeSet)
61
396
  dom = attachment.current()
62
397
  ```
63
398
 
64
- This is the shape, not a copyable module: `ownerDocument`, `content`, `host`,
65
- `dispatch`, `next`, and `result` come from the surrounding application, and `next`
66
- is the `EditorState` that transition committed.
67
-
68
- Read the calls literally:
69
-
70
- - `mount(ownerDocument, content, renderer?)` builds the subtree and the identity
71
- index it is patched through: `data-block` on a block, `data-run` on a run. It
72
- listens to nothing and runs nothing. A renderer (§121) nests each mark as an
73
- element *inside* its run element, exactly as the read-only renderer nests them,
74
- so the run element stays outermost and the text node stays deepest, and a
75
- selection still maps; a mark name no entry renders stays on `data-marks`. A node
76
- entry renders a node block as its element — attributes included, its nested blocks
77
- inside — and `data-block` stays the interpreter's identity; a kind no entry renders
78
- keeps a `div`. The registry is kept on the `EditorDom`, and every later patch uses
79
- that one, so a run or a container cannot come back rendered differently.
80
- - `attach(dom, { onIntent })` listens for `beforeinput`, `keydown`, composition,
81
- copy, cut, and paste, and reports each one it understands as intent. Everything
82
- it understands it `preventDefault`s, so the browser never mutates the DOM behind
83
- the document; an event it cannot honor yet is prevented with no intent rather
84
- than allowed to drift. `onSelection` reports a caret or range the application
85
- did not just commit, so the editor can follow it; a position it did commit is
86
- not reported back, and neither is anything while an IME owns the caret.
87
- `attachment.sync(state, changeSet)` does the patch and selection restore below
88
- in one call, and `detach()` removes the listeners. `keymap` adds or overrides
89
- chord bindings, checked before the built-in chords.
90
- - `patch(dom, content, changeSet)` removes the identities the change set removed,
91
- re-renders the ones it marked dirty, and places inserted or moved elements in
92
- document order. Every other element is left alone.
93
- - `repair(dom, content)` is recovery, not domain state (§31): it drops anything the
94
- subtree holds that the document does not, re-renders blocks whose rendered text
95
- drifted, and returns the same `EditorDom` when nothing was wrong. Call it after a
96
- cancelled IME.
97
- - `positionToRange` and `rangeToPosition` translate between a semantic `Position`
98
- and a DOM `Range`. A DOM caret carries no affinity, so mapping back derives it
99
- (`after` at a run's end, `before` elsewhere) rather than pretending to
100
- round-trip it.
101
- - `parseHtml(html, { kit, mint })` at `foldkit-richtext-dom/html` is the clipboard
102
- fallback: a whitelist walk over a `DOMParser` tree that mints fresh identities,
103
- so a pasted `style`, `href`, or `onclick` can never survive as anything
104
- executable.
105
-
106
- ## Mounting into a view
107
-
108
- A Foldkit view owns the host element; the interpreter owns what goes inside it.
109
- `mountInto` renders into the host and records the attachment there, so a patch
110
- Command that has only the element can find it (§118). It takes the same
111
- `rendering(...)` registry as `mount`, so the subtree a *view* mounts can carry a
112
- declared mark's props too:
399
+ This is the shape, not a copyable module: `ownerDocument`, `content`, `host`, `dispatch`,
400
+ `next`, and `result` come from the surrounding application, and `next` is the `EditorState`
401
+ that transition committed.
402
+
403
+ - **`mount(ownerDocument, content, renderer?, decorations?)`** builds the subtree and the
404
+ identity index it is patched through: `data-block` on a block, `data-run` on a run. It
405
+ listens to nothing and runs nothing. A mark renders as an element inside its run element, so
406
+ the text node stays deepest and a selection still maps; a mark no entry renders stays on
407
+ `data-marks`. A node entry renders a node block as its element, nested blocks inside, and a
408
+ kind no entry renders keeps a `div`. The registry is kept on the `EditorDom`, so every later
409
+ patch renders the same way. Decorations are drawn as the read-only renderer draws them, and
410
+ the position mapping reads across the text nodes they split.
411
+ - **`attach(dom, { onIntent, onSelection?, keymap? })`** listens for `beforeinput`, `keydown`,
412
+ composition, copy, cut, and paste, and reports each one it understands as intent. Everything
413
+ it understands it prevents, so the browser never mutates the DOM behind the document; an
414
+ event it cannot honor yet is prevented with no intent rather than allowed to drift.
415
+ `onSelection` reports a caret or range inside the editor that the application did not just
416
+ commit, and nothing while an IME owns the caret. A selection elsewhere on the page is not
417
+ reported, so the editor keeps its own caret (or selected block) while another field has
418
+ focus. A keystroke, a composition start, a cut, or a paste reads the live selection first and
419
+ reports it before its intent, because `selectionchange` is asynchronous and a click just
420
+ before would otherwise be missed. `keymap` adds or overrides chord bindings, checked
421
+ before the built-in ones. `attachment.sync(state, changeSet)` patches and restores the
422
+ selection in one call, but only while the browser's selection is inside the editor: a sync
423
+ never takes the caret from another editor or an input. While an IME is composing, the latest
424
+ state waits and is drawn when composition ends, so another person's edit never rewrites the
425
+ text under the IME.
426
+ `detach()` removes the listeners.
427
+ - **`patch(dom, content, changeSet, decorations?)`** removes what the change set removed,
428
+ re-renders what it marked dirty, and places inserted or moved elements in document order.
429
+ `decorations` is the next render's whole set, so a run whose share of it changed is redrawn,
430
+ and a patch without one clears what was drawn. An element that is no longer what its block
431
+ renders as (a heading re-leveled to `h3`, say) is re-rendered rather than kept.
432
+ - **`repair(dom, content)`** is recovery, not domain state (§31): it drops what the subtree
433
+ holds that the document does not, re-renders blocks whose rendering drifted, and returns the
434
+ same `EditorDom` when nothing was wrong. Call it after a cancelled IME.
435
+ - **`positionToRange` and `rangeToPosition`** translate between a semantic `Position` and a DOM
436
+ `Range`. A DOM caret carries no affinity, so mapping back derives it (`after` at a run's end,
437
+ `before` elsewhere) rather than pretending to round-trip it.
438
+
439
+ ### Mounting into a view's element
440
+
441
+ A Foldkit view owns the host element; the interpreter owns what goes inside it. `mountInto`
442
+ renders into the host and records the attachment there, so a patch Command that has only the
443
+ element can find it (§118):
113
444
 
114
445
  ```ts
115
446
  import { attachmentIn, mountInto, releaseMount } from 'foldkit-richtext-dom/host'
@@ -124,104 +455,55 @@ attachmentIn(host)?.sync(state, changeSet)
124
455
  releaseMount(host)
125
456
  ```
126
457
 
127
- ## The editor's Messages
458
+ If the host already holds exactly the subtree `mount` would build (server markup the browser
459
+ parsed, say), `mountInto` keeps those elements and indexes them. `adopt(existing, content,
460
+ rendering?, decorations?)` is that check alone. It compares the trees ignoring empty text
461
+ nodes, which markup cannot carry, and gives an empty run back the text node a caret needs.
462
+ Anything else in the host is replaced, so it ends with one subtree either way.
463
+ `renderEditable(document, renderer?, decorations?)` is the same subtree as `Html`, which is
464
+ what a server sends.
465
+
466
+ `attachEditor(host, content, emit, { rendering?, decorate?, placeholder? })` attaches the
467
+ translation to Messages to a host element; `events({ content })` wraps it as the Mount the
468
+ editor's view uses; `patchEditor(hostId, state, changeSet)` is what the patch Command runs,
469
+ reporting whether it patched. A missing host is an editor that went away while the transition
470
+ was in flight, not an error.
471
+
472
+ ### Pasting HTML
473
+
474
+ `parseHtml(html, { kit, mint })` at `foldkit-richtext-dom/html` is the clipboard fallback: a
475
+ whitelist walk over a `DOMParser` tree that mints fresh identities. It maps the standard
476
+ vocabulary's elements (`blockquote`, `pre` with its language from `data-language` or a
477
+ `language-…` class, `hr`, `img`, `table`/`tr`/`td`/`th` with a row of `th`s as the header,
478
+ `s`/`del`, `a`) and reads a fixed few attributes, each URL through `safeUrl`. That refuses a
479
+ scheme outside http/https/mailto/tel *after* removing control characters, so
480
+ `java\tscript:` cannot walk past it. A `style`, an `onclick`, or a `javascript:` URL cannot
481
+ survive as anything executable, and an element it cannot map keeps its text and reports a
482
+ diagnostic.
128
483
 
129
- `editor` is the vocabulary an editor's `update` handles, and the mount that
130
- produces it (§118). The adapter reports what happened as commands, a caret, and a
131
- history chord; `toMessage` turns each into a Message:
484
+ ## What it does not do
132
485
 
133
- ```ts
134
- const Message = defineMessageUnion({
135
- Typed, Backspace, DeletedForward, Entered, ToggledMark,
136
- Selected, Pasted, Undone, Redone, Patched,
137
- })
138
- ```
486
+ - It does not run commands or hold domain state. A caller runs the command and hands the result
487
+ here; `attach` reports intent and stops.
488
+ - It does not patch a container's items one by one: a container whose item list changed is
489
+ re-rendered where it stood, so its surviving items are rebuilt rather than kept. Correct, and
490
+ a follow-up for identity preservation.
491
+ - One host id is one editor on the page, so repeated rows of a form cannot each hold one yet.
139
492
 
140
- `toMessage(command)` refuses what the vocabulary cannot carry rather than dropping
141
- a detail: the adapter reports only plain insertions and toggles by name, so an
142
- insertion carrying marks and a mark value with props come back `undefined` — the
143
- vocabulary has no shape for them yet, and silently losing the marks would be
144
- worse. `attachEditor(host, content, emit, renderer?)` attaches the translation to a host
145
- element and reports each Message; `events({ content })` wraps the same thing in a
146
- `Mount.defineStream`, so a view renders a host element whose mount produces these
147
- Messages and releases the subtree when the element goes. `patchEditor(hostId,
148
- state, changeSet)` is what the patch Command runs: it finds the element by id,
149
- syncs the attachment it holds, and reports whether it patched — a missing host is
150
- an editor that went away while the transition was in flight, not an error.
151
- `Patched` is that Command's own completion, because a Foldkit Command must return
152
- a Message.
153
-
154
- ## The read-only renderer
155
-
156
- `view` renders a document or a slice as ordinary Foldkit `Html` through
157
- `inertHtml`, so it dispatches nothing and owns no DOM — the counterpart to the
158
- editable adapter, not a second editor:
493
+ ## Reference
159
494
 
160
- ```ts
161
- const html = renderDocument(document) // a div of block elements
162
- renderBlocks(slice.blocks) // one element per block
163
- renderDocument(document, renderer) // a declared Link as a real <a href>
495
+ ```text
496
+ foldkit-richtext-dom the interpreter: mount, adopt, patch, repair, position mapping
497
+ foldkit-richtext-dom/editor-bundle Editor, editorAt, application, update, the Message helpers
498
+ foldkit-richtext-dom/input richTextInput, EditorInput: the editor as a foldkit-form key
499
+ foldkit-richtext-dom/toolbar marksToolbar, markActive, selectionAnchor, blockAnchor, blockDrag
500
+ foldkit-richtext-dom/editor Message, toMessage, attachEditor, events, patchEditor, slash menu
501
+ foldkit-richtext-dom/view renderDocument, renderBlocks, renderEditable
502
+ foldkit-richtext-dom/host mountInto, attachmentIn, releaseMount, the place*/…For records
503
+ foldkit-richtext-dom/events attach, intentFor, selection read and restore
504
+ foldkit-richtext-dom/html parseHtml
164
505
  ```
165
506
 
166
- Both take the same `rendering(...)` registry as the HTML serializer (§121), so a
167
- declared mark or node kind becomes its element here too. Without one, blocks become
168
- `p`/`h1`–`h6`, marks nest as `strong`/`em`/`code` in the same order the serializer
169
- uses, unknown marks ride on a `span` with `data-marks`, and unknown blocks render as
170
- an inert `div data-unknown="Type"` placeholder.
171
-
172
- Foldkit types one builder per tag name and publishes no builder for an arbitrary tag,
173
- so a renderer tag outside the tags Foldkit can build — a custom element's, say — is
174
- *reported* rather than swapped for another element. The serializer and the adapter
175
- accept any tag; that asymmetry is Foldkit's, not the registry's.
176
-
177
- One caveat worth knowing: `h.DataAttribute` prefixes `data-` itself, so it takes the
178
- bare name (`DataAttribute('unknown', …)` → `data-unknown`).
179
-
180
- ## The editor Bundle
181
-
182
- `editor-bundle` is the editor as a Bundle whose authoritative document may live in
183
- the parent (§27). `Editor` is the Bundle; `EditorState` and `EditorView` are the
184
- state the parent owns beside the document; `editorAt(hostId, renderer?)` places one
185
- editor and binds it to the host element its view renders. A `renderer` is placed for
186
- that host id rather than passed as an arg, because a registry holds functions and the
187
- Bundle's args are schema-decoded (§122): `placeRendering` and `renderingFor` at
188
- `foldkit-richtext-dom/host` are the same record the editor's mount reads, and a
189
- placement without one renders with the default. The Link's `read` projects the
190
- parent's document in, and `write` keeps only the editor fields, so the child never
191
- stores a document copy; `onOut` commits the returned state in the same parent
192
- transition. `application` and `update` are the assembled parent, and `edited` /
193
- `typed` / `pressed` / `toggled` / `selected` / `undone` / `redone` / `patched`
194
- build the Messages it dispatches. Every accepted edit returns a `RichText.patch`
195
- Command carrying the `ChangeSet`, which is how rendering follows the commit instead
196
- of sharing it.
197
-
198
- ## The marks toolbar
199
-
200
- `marksToolbar({ state, toMessage, marks? })` returns a view — `(h) => Html` — of one
201
- button per mark, dispatching the same Message a chord does. It is the application's
202
- chrome: §29 gives the editor the subtree and the application everything around it,
203
- so the application places this beside the editor's host rather than the Bundle
204
- rendering it. `state` is what the editor projects (`document`, `selection`,
205
- `storedMarks`), and `marks` defaults to the three the package ships. A mark is
206
- active when the caret carries it, or — with no stored format — when every run the
207
- selection covers does. `markActive(state, mark)` is that rule on its own, for a
208
- renderer that draws its own buttons.
209
-
210
- ## What it does not do
211
-
212
- - It does not run commands or resolve domain state. A caller runs `RichText.run`
213
- and hands the result here; `attach` reports intent and stops.
214
- - A registry placed for a host id is forgotten when that host releases, but a
215
- placement that never mounts never releases, so the map is only as bounded as
216
- placements are (§122). It is a per-id record the view's author writes, not a
217
- service locator.
218
- - It does not patch nested structural changes item by item: a container whose item
219
- list changed is re-rendered where it stood, so its surviving items are rebuilt
220
- rather than patched individually. Correct, and a follow-up for identity
221
- preservation.
222
-
223
- ## Checks
224
-
225
507
  ```bash
226
508
  pnpm --filter foldkit-richtext-dom typecheck
227
509
  pnpm vitest run packages/richtext-dom/test