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.
- package/README.md +448 -166
- package/dist/decorations-Ar6URa7n.mjs +57 -0
- package/dist/decorations-Ar6URa7n.mjs.map +1 -0
- package/dist/editor-C76tfEyV.d.mts +1016 -0
- package/dist/editor-C76tfEyV.d.mts.map +1 -0
- package/dist/editor-bundle-dnjyuk1P.d.mts +8576 -0
- package/dist/editor-bundle-dnjyuk1P.d.mts.map +1 -0
- package/dist/editor-bundle.d.mts +2 -7535
- package/dist/editor-bundle.mjs +352 -81
- package/dist/editor-bundle.mjs.map +1 -1
- package/dist/editor.d.mts +2 -849
- package/dist/editor.mjs +176 -9
- package/dist/editor.mjs.map +1 -1
- package/dist/{events-BTw9SJ7r.d.mts → events-Dasls6a_.d.mts} +31 -5
- package/dist/events-Dasls6a_.d.mts.map +1 -0
- package/dist/events.d.mts +2 -2
- package/dist/events.mjs +84 -14
- package/dist/events.mjs.map +1 -1
- package/dist/host-1dtxuKIA.d.mts +86 -0
- package/dist/host-1dtxuKIA.d.mts.map +1 -0
- package/dist/host.d.mts +2 -25
- package/dist/host.mjs +78 -6
- package/dist/host.mjs.map +1 -1
- package/dist/html.d.mts +1 -1
- package/dist/html.d.mts.map +1 -1
- package/dist/html.mjs +150 -20
- package/dist/html.mjs.map +1 -1
- package/dist/index-DDV_JesZ.d.mts +57 -0
- package/dist/index-DDV_JesZ.d.mts.map +1 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +249 -99
- package/dist/index.mjs.map +1 -1
- package/dist/input.d.mts +936 -0
- package/dist/input.d.mts.map +1 -0
- package/dist/input.mjs +89 -0
- package/dist/input.mjs.map +1 -0
- package/dist/toolbar.d.mts +39 -0
- package/dist/toolbar.d.mts.map +1 -1
- package/dist/toolbar.mjs +325 -1
- package/dist/toolbar.mjs.map +1 -1
- package/dist/view.d.mts +14 -2
- package/dist/view.d.mts.map +1 -1
- package/dist/view.mjs +90 -19
- package/dist/view.mjs.map +1 -1
- package/package.json +26 -5
- package/dist/editor-bundle.d.mts.map +0 -1
- package/dist/editor.d.mts.map +0 -1
- package/dist/events-BTw9SJ7r.d.mts.map +0 -1
- package/dist/host.d.mts.map +0 -1
- package/dist/index-6QEOehXh.d.mts +0 -38
- 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
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
##
|
|
13
|
+
## Who owns what
|
|
13
14
|
|
|
14
15
|
```text
|
|
15
|
-
foldkit-richtext
|
|
16
|
-
foldkit-richtext-dom
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|