@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.
- package/CHANGELOG.md +108 -70
- package/LICENSE +21 -21
- package/README.md +286 -247
- package/dist/canvas/box-clipboard.js +238 -238
- package/dist/canvas/box-selection.js +80 -80
- package/dist/canvas/canvas-editor.js +502 -502
- package/dist/canvas/controller.js +717 -717
- package/dist/canvas/geometry.js +131 -131
- package/dist/canvas/gesture.d.ts +0 -1
- package/dist/canvas/gesture.js +289 -289
- package/dist/canvas/handles.js +212 -212
- package/dist/canvas/menu.js +82 -82
- package/dist/canvas/mode-controller.js +81 -81
- package/dist/canvas/nudge.js +108 -108
- package/dist/canvas/overlay.js +149 -149
- package/dist/canvas/snap.js +132 -132
- package/dist/canvas/viewport.js +112 -112
- package/dist/canvas/z-order.js +171 -171
- package/dist/commands/alignment.js +150 -150
- package/dist/commands/box.js +272 -272
- package/dist/commands/clipboard.js +93 -93
- package/dist/commands/graphemes.js +112 -112
- package/dist/commands/marks.js +153 -153
- package/dist/commands/paragraph-props.js +176 -176
- package/dist/commands/size.js +368 -368
- package/dist/commands/text.js +291 -291
- package/dist/docx.d.ts +1 -0
- package/dist/docx.js +15 -13
- package/dist/editor/editing-host.js +434 -434
- package/dist/element.js +41 -41
- package/dist/fonts/OFL-Carlito.txt +94 -0
- package/dist/fonts/calibri-metrics.d.ts +27 -0
- package/dist/fonts/calibri-metrics.js +88 -0
- package/dist/history/history.js +433 -433
- package/dist/index.js +63 -63
- package/dist/input/beforeinput.js +224 -224
- package/dist/input/clipboard.js +107 -107
- package/dist/input/composition.js +341 -341
- package/dist/input/keymap.js +54 -54
- package/dist/io/ooxml/custgeom.js +72 -72
- package/dist/io/ooxml/drawingml.js +232 -232
- package/dist/io/ooxml/edits.js +67 -67
- package/dist/io/ooxml/export-splice.js +880 -880
- package/dist/io/ooxml/export.js +365 -365
- package/dist/io/ooxml/fill.d.ts +93 -8
- package/dist/io/ooxml/fill.js +618 -562
- package/dist/io/ooxml/fit.d.ts +252 -0
- package/dist/io/ooxml/fit.js +598 -0
- package/dist/io/ooxml/import-gate.js +200 -200
- package/dist/io/ooxml/import.js +691 -691
- package/dist/io/ooxml/inflate.js +126 -126
- package/dist/io/ooxml/media.js +87 -87
- package/dist/io/ooxml/ns.d.ts +2 -0
- package/dist/io/ooxml/ns.js +26 -21
- package/dist/io/ooxml/numbering.js +290 -290
- package/dist/io/ooxml/opc-write.js +171 -171
- package/dist/io/ooxml/provenance.js +93 -93
- package/dist/io/ooxml/recipe.js +708 -708
- package/dist/io/ooxml/relationships.js +59 -59
- package/dist/io/ooxml/run-fonts.d.ts +89 -0
- package/dist/io/ooxml/run-fonts.js +378 -0
- package/dist/io/ooxml/slots.d.ts +4 -0
- package/dist/io/ooxml/slots.js +582 -581
- package/dist/io/ooxml/styles-resolve.js +257 -257
- package/dist/io/ooxml/units.js +43 -43
- package/dist/io/ooxml/value.js +120 -120
- package/dist/io/ooxml/xml-scan.js +616 -616
- package/dist/io/ooxml/zip-container.js +228 -228
- package/dist/io/ooxml/zip-reject.js +106 -106
- package/dist/measure/placement.js +83 -83
- package/dist/measure/report.js +75 -75
- package/dist/model/ids.js +70 -70
- package/dist/model/normalize.js +146 -146
- package/dist/model/position.js +283 -283
- package/dist/model/transaction.js +209 -209
- package/dist/platform/capabilities.js +25 -25
- package/dist/react/index.js +21 -21
- package/dist/selection/controller.js +315 -315
- package/dist/selection/dom-map.js +334 -334
- package/dist/state/editor-state.js +109 -109
- package/dist/styles.css +66 -66
- 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
|
+
}
|