@sametalmdr/word-editor 0.2.0-alpha → 0.3.0-alpha

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 (82) hide show
  1. package/CHANGELOG.md +108 -70
  2. package/LICENSE +21 -21
  3. package/README.md +286 -247
  4. package/dist/canvas/box-clipboard.js +238 -238
  5. package/dist/canvas/box-selection.js +80 -80
  6. package/dist/canvas/canvas-editor.js +502 -502
  7. package/dist/canvas/controller.js +717 -717
  8. package/dist/canvas/geometry.js +131 -131
  9. package/dist/canvas/gesture.d.ts +0 -1
  10. package/dist/canvas/gesture.js +289 -289
  11. package/dist/canvas/handles.js +212 -212
  12. package/dist/canvas/menu.js +82 -82
  13. package/dist/canvas/mode-controller.js +81 -81
  14. package/dist/canvas/nudge.js +108 -108
  15. package/dist/canvas/overlay.js +149 -149
  16. package/dist/canvas/snap.js +132 -132
  17. package/dist/canvas/viewport.js +112 -112
  18. package/dist/canvas/z-order.js +171 -171
  19. package/dist/commands/alignment.js +150 -150
  20. package/dist/commands/box.js +272 -272
  21. package/dist/commands/clipboard.js +93 -93
  22. package/dist/commands/graphemes.js +112 -112
  23. package/dist/commands/marks.js +153 -153
  24. package/dist/commands/paragraph-props.js +176 -176
  25. package/dist/commands/size.js +368 -368
  26. package/dist/commands/text.js +291 -291
  27. package/dist/docx.d.ts +1 -0
  28. package/dist/docx.js +15 -13
  29. package/dist/editor/editing-host.js +434 -434
  30. package/dist/element.js +41 -41
  31. package/dist/fonts/OFL-Carlito.txt +94 -0
  32. package/dist/fonts/calibri-metrics.d.ts +27 -0
  33. package/dist/fonts/calibri-metrics.js +88 -0
  34. package/dist/history/history.js +433 -433
  35. package/dist/index.js +63 -63
  36. package/dist/input/beforeinput.js +224 -224
  37. package/dist/input/clipboard.js +107 -107
  38. package/dist/input/composition.js +341 -341
  39. package/dist/input/keymap.js +54 -54
  40. package/dist/io/ooxml/custgeom.js +72 -72
  41. package/dist/io/ooxml/drawingml.js +232 -232
  42. package/dist/io/ooxml/edits.js +67 -67
  43. package/dist/io/ooxml/export-splice.js +880 -880
  44. package/dist/io/ooxml/export.js +365 -365
  45. package/dist/io/ooxml/fill.d.ts +93 -8
  46. package/dist/io/ooxml/fill.js +618 -562
  47. package/dist/io/ooxml/fit.d.ts +252 -0
  48. package/dist/io/ooxml/fit.js +598 -0
  49. package/dist/io/ooxml/import-gate.js +200 -200
  50. package/dist/io/ooxml/import.js +691 -691
  51. package/dist/io/ooxml/inflate.js +126 -126
  52. package/dist/io/ooxml/media.js +87 -87
  53. package/dist/io/ooxml/ns.d.ts +2 -0
  54. package/dist/io/ooxml/ns.js +26 -21
  55. package/dist/io/ooxml/numbering.js +290 -290
  56. package/dist/io/ooxml/opc-write.js +171 -171
  57. package/dist/io/ooxml/provenance.js +93 -93
  58. package/dist/io/ooxml/recipe.js +708 -708
  59. package/dist/io/ooxml/relationships.js +59 -59
  60. package/dist/io/ooxml/run-fonts.d.ts +89 -0
  61. package/dist/io/ooxml/run-fonts.js +378 -0
  62. package/dist/io/ooxml/slots.d.ts +4 -0
  63. package/dist/io/ooxml/slots.js +582 -581
  64. package/dist/io/ooxml/styles-resolve.js +257 -257
  65. package/dist/io/ooxml/units.js +43 -43
  66. package/dist/io/ooxml/value.js +120 -120
  67. package/dist/io/ooxml/xml-scan.js +616 -616
  68. package/dist/io/ooxml/zip-container.js +228 -228
  69. package/dist/io/ooxml/zip-reject.js +106 -106
  70. package/dist/measure/placement.js +83 -83
  71. package/dist/measure/report.js +75 -75
  72. package/dist/model/ids.js +70 -70
  73. package/dist/model/normalize.js +146 -146
  74. package/dist/model/position.js +283 -283
  75. package/dist/model/transaction.js +209 -209
  76. package/dist/platform/capabilities.js +25 -25
  77. package/dist/react/index.js +21 -21
  78. package/dist/selection/controller.js +315 -315
  79. package/dist/selection/dom-map.js +334 -334
  80. package/dist/state/editor-state.js +109 -109
  81. package/dist/styles.css +66 -66
  82. package/package.json +94 -93
@@ -1,334 +1,334 @@
1
- import { createPosition, paragraphIdOf } from "../model/position.js";
2
-
3
- /**
4
- * Two-way mapping between DOM selection endpoints and model positions.
5
- *
6
- * Everything here works on a **minimal node interface** — `nodeType`, `data`,
7
- * `childNodes`, `parentNode`, `dataset` — and never calls a DOM method. Real
8
- * elements satisfy it, and so do plain object trees, which is what the tests
9
- * use. It also keeps our DOM surface small enough to reason about.
10
- *
11
- * @typedef {object} MappableNode
12
- * @property {number} nodeType
13
- * @property {string} [data] text content, for text nodes
14
- * @property {ArrayLike<MappableNode>} [childNodes]
15
- * @property {MappableNode | null} [parentNode]
16
- * @property {Record<string, string | undefined>} [dataset]
17
- */
18
-
19
- /**
20
- * A DOM selection endpoint: a container plus an offset that means "character
21
- * offset" in a text node and "index among children" in an element.
22
- * @typedef {object} DomPoint
23
- * @property {MappableNode} node
24
- * @property {number} offset
25
- */
26
-
27
- const TEXT_NODE = 3;
28
-
29
- /**
30
- * The dataset key carrying a paragraph's node id (`data-we-id` in markup).
31
- * The renderer writes it; this module is the only thing that reads it.
32
- */
33
- export const PARAGRAPH_ID_KEY = "weId";
34
-
35
- /**
36
- * The dataset key carrying a container's node id (`data-we-container-id`).
37
- * A model position needs both halves of its path, and the container half is not
38
- * derivable from the paragraph element alone.
39
- */
40
- export const CONTAINER_ID_KEY = "weContainerId";
41
-
42
- /**
43
- * @param {MappableNode} node
44
- * @returns {boolean}
45
- */
46
- function isText(node) {
47
- return node.nodeType === TEXT_NODE;
48
- }
49
-
50
- /**
51
- * @param {MappableNode} node
52
- * @returns {string}
53
- */
54
- function textOf(node) {
55
- return node.data ?? "";
56
- }
57
-
58
- /**
59
- * @param {MappableNode} node
60
- * @returns {ArrayLike<MappableNode>}
61
- */
62
- function childrenOf(node) {
63
- return node.childNodes ?? [];
64
- }
65
-
66
- /**
67
- * The paragraph element a node lives in, found by walking up to the nearest
68
- * ancestor carrying a paragraph id.
69
- *
70
- * Walking `parentNode` rather than using `closest()` is deliberate: during
71
- * composition the browser inserts text nodes we never created, and this has to
72
- * work from those too.
73
- *
74
- * @param {MappableNode | null | undefined} node
75
- * @returns {MappableNode | null}
76
- */
77
- export function paragraphElementOf(node) {
78
- let current = node ?? null;
79
- while (current !== null && current !== undefined) {
80
- if (current.dataset?.[PARAGRAPH_ID_KEY] !== undefined) {
81
- return current;
82
- }
83
- current = current.parentNode ?? null;
84
- }
85
- return null;
86
- }
87
-
88
- /**
89
- * @param {MappableNode} paragraphElement
90
- * @returns {number | null}
91
- */
92
- export function paragraphIdOfElement(paragraphElement) {
93
- const raw = paragraphElement.dataset?.[PARAGRAPH_ID_KEY];
94
- if (raw === undefined) {
95
- return null;
96
- }
97
- const id = Number(raw);
98
- return Number.isFinite(id) ? id : null;
99
- }
100
-
101
- /**
102
- * The container element a node lives in — the box, in canvas terms. Same
103
- * upward walk as `paragraphElementOf`, and for the same reason: it has to work
104
- * from nodes the browser created during composition.
105
- * @param {MappableNode | null | undefined} node
106
- * @returns {MappableNode | null}
107
- */
108
- export function containerElementOf(node) {
109
- let current = node ?? null;
110
- while (current !== null && current !== undefined) {
111
- if (current.dataset?.[CONTAINER_ID_KEY] !== undefined) {
112
- return current;
113
- }
114
- current = current.parentNode ?? null;
115
- }
116
- return null;
117
- }
118
-
119
- /**
120
- * @param {MappableNode} containerElement
121
- * @returns {number | null}
122
- */
123
- export function containerIdOfElement(containerElement) {
124
- const raw = containerElement.dataset?.[CONTAINER_ID_KEY];
125
- if (raw === undefined) {
126
- return null;
127
- }
128
- const id = Number(raw);
129
- return Number.isFinite(id) ? id : null;
130
- }
131
-
132
- /**
133
- * Every text node under `node`, in document order.
134
- * @param {MappableNode} node
135
- * @returns {MappableNode[]}
136
- */
137
- export function textNodesOf(node) {
138
- /** @type {MappableNode[]} */
139
- const out = [];
140
- /** @param {MappableNode} current */
141
- function walk(current) {
142
- if (isText(current)) {
143
- out.push(current);
144
- return;
145
- }
146
- const children = childrenOf(current);
147
- for (let i = 0; i < children.length; i += 1) {
148
- walk(children[i]);
149
- }
150
- }
151
- walk(node);
152
- return out;
153
- }
154
-
155
- /**
156
- * Total text length under a node.
157
- * @param {MappableNode} node
158
- * @returns {number}
159
- */
160
- function textLengthOf(node) {
161
- if (isText(node)) {
162
- return textOf(node).length;
163
- }
164
- let total = 0;
165
- const children = childrenOf(node);
166
- for (let i = 0; i < children.length; i += 1) {
167
- total += textLengthOf(children[i]);
168
- }
169
- return total;
170
- }
171
-
172
- /**
173
- * Character offset of a DOM boundary point within `paragraphElement`.
174
- *
175
- * The walk accumulates *text length* in document order, which is why stray nodes
176
- * the browser inserted do not throw it off: the model's offsets are defined over
177
- * the same characters.
178
- *
179
- * @param {MappableNode} paragraphElement
180
- * @param {MappableNode} container
181
- * @param {number} containerOffset
182
- * @returns {number | null} null when `container` is not inside the paragraph
183
- */
184
- export function boundaryOffset(paragraphElement, container, containerOffset) {
185
- let acc = 0;
186
- let found = false;
187
-
188
- /** @param {MappableNode} node */
189
- function visit(node) {
190
- if (found) {
191
- return;
192
- }
193
-
194
- if (node === container) {
195
- if (isText(node)) {
196
- acc += clamp(containerOffset, 0, textOf(node).length);
197
- } else {
198
- // In an element the offset counts children, so everything in the first
199
- // `containerOffset` of them lies before the boundary.
200
- const children = childrenOf(node);
201
- const upTo = clamp(containerOffset, 0, children.length);
202
- for (let i = 0; i < upTo; i += 1) {
203
- acc += textLengthOf(children[i]);
204
- }
205
- }
206
- found = true;
207
- return;
208
- }
209
-
210
- if (isText(node)) {
211
- acc += textOf(node).length;
212
- return;
213
- }
214
-
215
- const children = childrenOf(node);
216
- for (let i = 0; i < children.length && !found; i += 1) {
217
- visit(children[i]);
218
- }
219
- }
220
-
221
- visit(paragraphElement);
222
- return found ? acc : null;
223
- }
224
-
225
- /**
226
- * DOM selection endpoint to model position.
227
- * @param {MappableNode} node
228
- * @param {number} domOffset
229
- * @returns {import("../model/position.js").Position | null} null when the point
230
- * is outside the editor
231
- */
232
- export function domToModel(node, domOffset) {
233
- const paragraphElement = paragraphElementOf(node);
234
- if (paragraphElement === null) {
235
- return null;
236
- }
237
-
238
- const id = paragraphIdOfElement(paragraphElement);
239
- if (id === null) {
240
- return null;
241
- }
242
-
243
- // A paragraph outside any container is not addressable — the model has no
244
- // one-level path any more, so refusing here is better than inventing an id.
245
- const containerElement = containerElementOf(paragraphElement);
246
- if (containerElement === null) {
247
- return null;
248
- }
249
- const containerId = containerIdOfElement(containerElement);
250
- if (containerId === null) {
251
- return null;
252
- }
253
-
254
- const offset = boundaryOffset(paragraphElement, node, domOffset);
255
- if (offset === null) {
256
- return null;
257
- }
258
-
259
- return createPosition(containerId, id, offset);
260
- }
261
-
262
- /**
263
- * Model position to DOM selection endpoint.
264
- *
265
- * **Left-biased at a run seam**, matching `resolve()` in `model/position.js`: an
266
- * offset that is both the end of one text node and the start of the next
267
- * resolves to the end of the first. Both directions must agree on this or the
268
- * caret drifts by a character — `domToModel` accumulates the same way, so they
269
- * do.
270
- *
271
- * An empty paragraph has no text nodes at all (the renderer emits a filler
272
- * `<br>`), so the endpoint becomes the paragraph element itself at offset 0 —
273
- * which `domToModel` maps straight back to 0.
274
- *
275
- * @param {import("../model/position.js").Position} pos
276
- * @param {(id: import("../model/node.js").NodeId) => MappableNode | null} lookup
277
- * @returns {DomPoint | null}
278
- */
279
- export function modelToDom(pos, lookup) {
280
- const paragraphElement = lookup(paragraphIdOf(pos));
281
- if (paragraphElement === null || paragraphElement === undefined) {
282
- return null;
283
- }
284
-
285
- let acc = 0;
286
- for (const textNode of textNodesOf(paragraphElement)) {
287
- const length = textOf(textNode).length;
288
- if (pos.offset <= acc + length) {
289
- return { node: textNode, offset: pos.offset - acc };
290
- }
291
- acc += length;
292
- }
293
-
294
- return { node: paragraphElement, offset: 0 };
295
- }
296
-
297
- /**
298
- * Looks paragraph elements up by scanning a root. The renderer keeps a map for
299
- * this; use the scan as a fallback, or in tests.
300
- * @param {MappableNode} root
301
- * @returns {(id: import("../model/node.js").NodeId) => MappableNode | null}
302
- */
303
- export function createParagraphLookup(root) {
304
- return (id) => {
305
- /** @type {MappableNode | null} */
306
- let found = null;
307
- /** @param {MappableNode} node */
308
- function walk(node) {
309
- if (found !== null) {
310
- return;
311
- }
312
- if (paragraphIdOfElement(node) === id) {
313
- found = node;
314
- return;
315
- }
316
- const children = childrenOf(node);
317
- for (let i = 0; i < children.length && found === null; i += 1) {
318
- walk(children[i]);
319
- }
320
- }
321
- walk(root);
322
- return found;
323
- };
324
- }
325
-
326
- /**
327
- * @param {number} value
328
- * @param {number} min
329
- * @param {number} max
330
- * @returns {number}
331
- */
332
- function clamp(value, min, max) {
333
- return Math.max(min, Math.min(value, max));
334
- }
1
+ import { createPosition, paragraphIdOf } from "../model/position.js";
2
+
3
+ /**
4
+ * Two-way mapping between DOM selection endpoints and model positions.
5
+ *
6
+ * Everything here works on a **minimal node interface** — `nodeType`, `data`,
7
+ * `childNodes`, `parentNode`, `dataset` — and never calls a DOM method. Real
8
+ * elements satisfy it, and so do plain object trees, which is what the tests
9
+ * use. It also keeps our DOM surface small enough to reason about.
10
+ *
11
+ * @typedef {object} MappableNode
12
+ * @property {number} nodeType
13
+ * @property {string} [data] text content, for text nodes
14
+ * @property {ArrayLike<MappableNode>} [childNodes]
15
+ * @property {MappableNode | null} [parentNode]
16
+ * @property {Record<string, string | undefined>} [dataset]
17
+ */
18
+
19
+ /**
20
+ * A DOM selection endpoint: a container plus an offset that means "character
21
+ * offset" in a text node and "index among children" in an element.
22
+ * @typedef {object} DomPoint
23
+ * @property {MappableNode} node
24
+ * @property {number} offset
25
+ */
26
+
27
+ const TEXT_NODE = 3;
28
+
29
+ /**
30
+ * The dataset key carrying a paragraph's node id (`data-we-id` in markup).
31
+ * The renderer writes it; this module is the only thing that reads it.
32
+ */
33
+ export const PARAGRAPH_ID_KEY = "weId";
34
+
35
+ /**
36
+ * The dataset key carrying a container's node id (`data-we-container-id`).
37
+ * A model position needs both halves of its path, and the container half is not
38
+ * derivable from the paragraph element alone.
39
+ */
40
+ export const CONTAINER_ID_KEY = "weContainerId";
41
+
42
+ /**
43
+ * @param {MappableNode} node
44
+ * @returns {boolean}
45
+ */
46
+ function isText(node) {
47
+ return node.nodeType === TEXT_NODE;
48
+ }
49
+
50
+ /**
51
+ * @param {MappableNode} node
52
+ * @returns {string}
53
+ */
54
+ function textOf(node) {
55
+ return node.data ?? "";
56
+ }
57
+
58
+ /**
59
+ * @param {MappableNode} node
60
+ * @returns {ArrayLike<MappableNode>}
61
+ */
62
+ function childrenOf(node) {
63
+ return node.childNodes ?? [];
64
+ }
65
+
66
+ /**
67
+ * The paragraph element a node lives in, found by walking up to the nearest
68
+ * ancestor carrying a paragraph id.
69
+ *
70
+ * Walking `parentNode` rather than using `closest()` is deliberate: during
71
+ * composition the browser inserts text nodes we never created, and this has to
72
+ * work from those too.
73
+ *
74
+ * @param {MappableNode | null | undefined} node
75
+ * @returns {MappableNode | null}
76
+ */
77
+ export function paragraphElementOf(node) {
78
+ let current = node ?? null;
79
+ while (current !== null && current !== undefined) {
80
+ if (current.dataset?.[PARAGRAPH_ID_KEY] !== undefined) {
81
+ return current;
82
+ }
83
+ current = current.parentNode ?? null;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * @param {MappableNode} paragraphElement
90
+ * @returns {number | null}
91
+ */
92
+ export function paragraphIdOfElement(paragraphElement) {
93
+ const raw = paragraphElement.dataset?.[PARAGRAPH_ID_KEY];
94
+ if (raw === undefined) {
95
+ return null;
96
+ }
97
+ const id = Number(raw);
98
+ return Number.isFinite(id) ? id : null;
99
+ }
100
+
101
+ /**
102
+ * The container element a node lives in — the box, in canvas terms. Same
103
+ * upward walk as `paragraphElementOf`, and for the same reason: it has to work
104
+ * from nodes the browser created during composition.
105
+ * @param {MappableNode | null | undefined} node
106
+ * @returns {MappableNode | null}
107
+ */
108
+ export function containerElementOf(node) {
109
+ let current = node ?? null;
110
+ while (current !== null && current !== undefined) {
111
+ if (current.dataset?.[CONTAINER_ID_KEY] !== undefined) {
112
+ return current;
113
+ }
114
+ current = current.parentNode ?? null;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * @param {MappableNode} containerElement
121
+ * @returns {number | null}
122
+ */
123
+ export function containerIdOfElement(containerElement) {
124
+ const raw = containerElement.dataset?.[CONTAINER_ID_KEY];
125
+ if (raw === undefined) {
126
+ return null;
127
+ }
128
+ const id = Number(raw);
129
+ return Number.isFinite(id) ? id : null;
130
+ }
131
+
132
+ /**
133
+ * Every text node under `node`, in document order.
134
+ * @param {MappableNode} node
135
+ * @returns {MappableNode[]}
136
+ */
137
+ export function textNodesOf(node) {
138
+ /** @type {MappableNode[]} */
139
+ const out = [];
140
+ /** @param {MappableNode} current */
141
+ function walk(current) {
142
+ if (isText(current)) {
143
+ out.push(current);
144
+ return;
145
+ }
146
+ const children = childrenOf(current);
147
+ for (let i = 0; i < children.length; i += 1) {
148
+ walk(children[i]);
149
+ }
150
+ }
151
+ walk(node);
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * Total text length under a node.
157
+ * @param {MappableNode} node
158
+ * @returns {number}
159
+ */
160
+ function textLengthOf(node) {
161
+ if (isText(node)) {
162
+ return textOf(node).length;
163
+ }
164
+ let total = 0;
165
+ const children = childrenOf(node);
166
+ for (let i = 0; i < children.length; i += 1) {
167
+ total += textLengthOf(children[i]);
168
+ }
169
+ return total;
170
+ }
171
+
172
+ /**
173
+ * Character offset of a DOM boundary point within `paragraphElement`.
174
+ *
175
+ * The walk accumulates *text length* in document order, which is why stray nodes
176
+ * the browser inserted do not throw it off: the model's offsets are defined over
177
+ * the same characters.
178
+ *
179
+ * @param {MappableNode} paragraphElement
180
+ * @param {MappableNode} container
181
+ * @param {number} containerOffset
182
+ * @returns {number | null} null when `container` is not inside the paragraph
183
+ */
184
+ export function boundaryOffset(paragraphElement, container, containerOffset) {
185
+ let acc = 0;
186
+ let found = false;
187
+
188
+ /** @param {MappableNode} node */
189
+ function visit(node) {
190
+ if (found) {
191
+ return;
192
+ }
193
+
194
+ if (node === container) {
195
+ if (isText(node)) {
196
+ acc += clamp(containerOffset, 0, textOf(node).length);
197
+ } else {
198
+ // In an element the offset counts children, so everything in the first
199
+ // `containerOffset` of them lies before the boundary.
200
+ const children = childrenOf(node);
201
+ const upTo = clamp(containerOffset, 0, children.length);
202
+ for (let i = 0; i < upTo; i += 1) {
203
+ acc += textLengthOf(children[i]);
204
+ }
205
+ }
206
+ found = true;
207
+ return;
208
+ }
209
+
210
+ if (isText(node)) {
211
+ acc += textOf(node).length;
212
+ return;
213
+ }
214
+
215
+ const children = childrenOf(node);
216
+ for (let i = 0; i < children.length && !found; i += 1) {
217
+ visit(children[i]);
218
+ }
219
+ }
220
+
221
+ visit(paragraphElement);
222
+ return found ? acc : null;
223
+ }
224
+
225
+ /**
226
+ * DOM selection endpoint to model position.
227
+ * @param {MappableNode} node
228
+ * @param {number} domOffset
229
+ * @returns {import("../model/position.js").Position | null} null when the point
230
+ * is outside the editor
231
+ */
232
+ export function domToModel(node, domOffset) {
233
+ const paragraphElement = paragraphElementOf(node);
234
+ if (paragraphElement === null) {
235
+ return null;
236
+ }
237
+
238
+ const id = paragraphIdOfElement(paragraphElement);
239
+ if (id === null) {
240
+ return null;
241
+ }
242
+
243
+ // A paragraph outside any container is not addressable — the model has no
244
+ // one-level path any more, so refusing here is better than inventing an id.
245
+ const containerElement = containerElementOf(paragraphElement);
246
+ if (containerElement === null) {
247
+ return null;
248
+ }
249
+ const containerId = containerIdOfElement(containerElement);
250
+ if (containerId === null) {
251
+ return null;
252
+ }
253
+
254
+ const offset = boundaryOffset(paragraphElement, node, domOffset);
255
+ if (offset === null) {
256
+ return null;
257
+ }
258
+
259
+ return createPosition(containerId, id, offset);
260
+ }
261
+
262
+ /**
263
+ * Model position to DOM selection endpoint.
264
+ *
265
+ * **Left-biased at a run seam**, matching `resolve()` in `model/position.js`: an
266
+ * offset that is both the end of one text node and the start of the next
267
+ * resolves to the end of the first. Both directions must agree on this or the
268
+ * caret drifts by a character — `domToModel` accumulates the same way, so they
269
+ * do.
270
+ *
271
+ * An empty paragraph has no text nodes at all (the renderer emits a filler
272
+ * `<br>`), so the endpoint becomes the paragraph element itself at offset 0 —
273
+ * which `domToModel` maps straight back to 0.
274
+ *
275
+ * @param {import("../model/position.js").Position} pos
276
+ * @param {(id: import("../model/node.js").NodeId) => MappableNode | null} lookup
277
+ * @returns {DomPoint | null}
278
+ */
279
+ export function modelToDom(pos, lookup) {
280
+ const paragraphElement = lookup(paragraphIdOf(pos));
281
+ if (paragraphElement === null || paragraphElement === undefined) {
282
+ return null;
283
+ }
284
+
285
+ let acc = 0;
286
+ for (const textNode of textNodesOf(paragraphElement)) {
287
+ const length = textOf(textNode).length;
288
+ if (pos.offset <= acc + length) {
289
+ return { node: textNode, offset: pos.offset - acc };
290
+ }
291
+ acc += length;
292
+ }
293
+
294
+ return { node: paragraphElement, offset: 0 };
295
+ }
296
+
297
+ /**
298
+ * Looks paragraph elements up by scanning a root. The renderer keeps a map for
299
+ * this; use the scan as a fallback, or in tests.
300
+ * @param {MappableNode} root
301
+ * @returns {(id: import("../model/node.js").NodeId) => MappableNode | null}
302
+ */
303
+ export function createParagraphLookup(root) {
304
+ return (id) => {
305
+ /** @type {MappableNode | null} */
306
+ let found = null;
307
+ /** @param {MappableNode} node */
308
+ function walk(node) {
309
+ if (found !== null) {
310
+ return;
311
+ }
312
+ if (paragraphIdOfElement(node) === id) {
313
+ found = node;
314
+ return;
315
+ }
316
+ const children = childrenOf(node);
317
+ for (let i = 0; i < children.length && found === null; i += 1) {
318
+ walk(children[i]);
319
+ }
320
+ }
321
+ walk(root);
322
+ return found;
323
+ };
324
+ }
325
+
326
+ /**
327
+ * @param {number} value
328
+ * @param {number} min
329
+ * @param {number} max
330
+ * @returns {number}
331
+ */
332
+ function clamp(value, min, max) {
333
+ return Math.max(min, Math.min(value, max));
334
+ }