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 +21 -0
- package/README.md +228 -0
- package/dist/editor-bundle.d.mts +7535 -0
- package/dist/editor-bundle.d.mts.map +1 -0
- package/dist/editor-bundle.mjs +243 -0
- package/dist/editor-bundle.mjs.map +1 -0
- package/dist/editor.d.mts +849 -0
- package/dist/editor.d.mts.map +1 -0
- package/dist/editor.mjs +95 -0
- package/dist/editor.mjs.map +1 -0
- package/dist/events-BTw9SJ7r.d.mts +77 -0
- package/dist/events-BTw9SJ7r.d.mts.map +1 -0
- package/dist/events.d.mts +2 -0
- package/dist/events.mjs +305 -0
- package/dist/events.mjs.map +1 -0
- package/dist/host.d.mts +25 -0
- package/dist/host.d.mts.map +1 -0
- package/dist/host.mjs +52 -0
- package/dist/host.mjs.map +1 -0
- package/dist/html.d.mts +19 -0
- package/dist/html.d.mts.map +1 -0
- package/dist/html.mjs +234 -0
- package/dist/html.mjs.map +1 -0
- package/dist/index-6QEOehXh.d.mts +38 -0
- package/dist/index-6QEOehXh.d.mts.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +304 -0
- package/dist/index.mjs.map +1 -0
- package/dist/toolbar.d.mts +26 -0
- package/dist/toolbar.d.mts.map +1 -0
- package/dist/toolbar.mjs +23 -0
- package/dist/toolbar.mjs.map +1 -0
- package/dist/view.d.mts +9 -0
- package/dist/view.d.mts.map +1 -0
- package/dist/view.mjs +62 -0
- package/dist/view.mjs.map +1 -0
- package/package.json +89 -0
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
|
+
```
|