foldkit-richtext-dom 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Glenn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,228 @@
1
+ # foldkit-richtext-dom
2
+
3
+ The DOM adapter for a [`foldkit-richtext`](../richtext) editable subtree.
4
+
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.
11
+
12
+ ## Ownership
13
+
14
+ ```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
18
+ ```
19
+
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.
24
+
25
+ This package is **early**: 0.x, and its API may change between minor versions.
26
+
27
+ ```bash
28
+ pnpm add foldkit-richtext-dom foldkit-richtext foldkit-bundle foldkit effect
29
+ ```
30
+
31
+ ## Entries
32
+
33
+ ```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
42
+ ```
43
+
44
+ ## The loop
45
+
46
+ ```ts
47
+ import { mount } from 'foldkit-richtext-dom'
48
+ import { attach } from 'foldkit-richtext-dom/events'
49
+ import * as RichText from 'foldkit-richtext'
50
+
51
+ let dom = mount(ownerDocument, content) // render once
52
+ host.append(dom.root) // the host element is yours
53
+
54
+ // Events become intent; your update decides and commits.
55
+ const attachment = attach(dom, {
56
+ onIntent: command => dispatch({ type: 'Edited', command }),
57
+ })
58
+
59
+ // In the resulting transition, after RichText.run returns a new state:
60
+ attachment.sync(next, result.changeSet)
61
+ dom = attachment.current()
62
+ ```
63
+
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:
113
+
114
+ ```ts
115
+ import { attachmentIn, mountInto, releaseMount } from 'foldkit-richtext-dom/host'
116
+
117
+ // The mount: once, when the host element enters the DOM. The registry is optional.
118
+ const attachment = mountInto(host, content, { onIntent, onSelection }, renderer)
119
+
120
+ // The patch Command: after the transition committed, given the host element.
121
+ attachmentIn(host)?.sync(state, changeSet)
122
+
123
+ // Unmount.
124
+ releaseMount(host)
125
+ ```
126
+
127
+ ## The editor's Messages
128
+
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:
132
+
133
+ ```ts
134
+ const Message = defineMessageUnion({
135
+ Typed, Backspace, DeletedForward, Entered, ToggledMark,
136
+ Selected, Pasted, Undone, Redone, Patched,
137
+ })
138
+ ```
139
+
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:
159
+
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>
164
+ ```
165
+
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
+ ```bash
226
+ pnpm --filter foldkit-richtext-dom typecheck
227
+ pnpm vitest run packages/richtext-dom/test
228
+ ```