@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,315 +1,315 @@
|
|
|
1
|
-
import { domToModel, modelToDom } from "./dom-map.js";
|
|
2
|
-
import { positionsEqual } from "../model/position.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Bridges DOM selection and model selection in both directions.
|
|
6
|
-
*
|
|
7
|
-
* The DOM direction is filtered through four stages, in order (plan §3):
|
|
8
|
-
*
|
|
9
|
-
* 1. outside the editor entirely → ignore
|
|
10
|
-
* 2. it is an echo of our own write → ignore
|
|
11
|
-
* 3. an IME composition is in progress → ignore
|
|
12
|
-
* 4. otherwise → **adopt**: translate both endpoints
|
|
13
|
-
* and hand the result to the caller, without ever calling the renderer.
|
|
14
|
-
*
|
|
15
|
-
* A DOM selection change is either something we caused or something the user
|
|
16
|
-
* caused. Only the second kind should change anything — a click updates the
|
|
17
|
-
* model's notion of where the cursor is, but it must not "apply" the way a
|
|
18
|
-
* transaction does, because there is no document change to make. That
|
|
19
|
-
* distinction is why `onAdopt` exists instead of just returning a value: it
|
|
20
|
-
* marks the boundary past which `storedMarks` gets cleared (rule 2, decided by
|
|
21
|
-
* `state/editor-state.js`'s `withSelection`) — a fired `onAdopt` means a real
|
|
22
|
-
* cursor move, our own programmatic restores never reach it.
|
|
23
|
-
*
|
|
24
|
-
* @typedef {object} DomSelectionPoint
|
|
25
|
-
* @property {import("./dom-map.js").MappableNode} node
|
|
26
|
-
* @property {number} offset
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* The minimal surface of `window.Selection` this module needs: four fields to
|
|
31
|
-
* read the current selection, one method to set it. A fake satisfying this is
|
|
32
|
-
* enough to test the whole class without a real DOM.
|
|
33
|
-
* @typedef {object} DomSelectionLike
|
|
34
|
-
* @property {import("./dom-map.js").MappableNode | null} anchorNode
|
|
35
|
-
* @property {number} anchorOffset
|
|
36
|
-
* @property {import("./dom-map.js").MappableNode | null} focusNode
|
|
37
|
-
* @property {number} focusOffset
|
|
38
|
-
* @property {(anchorNode: any, anchorOffset: number, focusNode: any, focusOffset: number) => void} setBaseAndExtent
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* @param {DomSelectionLike} sel
|
|
43
|
-
* @returns {DomSelectionPoint & { focusNode: any, focusOffset: number } | null}
|
|
44
|
-
*/
|
|
45
|
-
function snapshot(sel) {
|
|
46
|
-
if (sel.anchorNode === null || sel.focusNode === null) {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
return {
|
|
50
|
-
node: sel.anchorNode,
|
|
51
|
-
offset: sel.anchorOffset,
|
|
52
|
-
focusNode: sel.focusNode,
|
|
53
|
-
focusOffset: sel.focusOffset,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* @param {ReturnType<typeof snapshot>} a
|
|
59
|
-
* @param {ReturnType<typeof snapshot>} b
|
|
60
|
-
* @returns {boolean}
|
|
61
|
-
*/
|
|
62
|
-
function snapshotsEqual(a, b) {
|
|
63
|
-
if (a === null || b === null) {
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
return (
|
|
67
|
-
a.node === b.node &&
|
|
68
|
-
a.offset === b.offset &&
|
|
69
|
-
a.focusNode === b.focusNode &&
|
|
70
|
-
a.focusOffset === b.focusOffset
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Walks `parentNode` rather than calling `.contains()`, so the same logic works
|
|
76
|
-
* on the plain object trees the tests use and on real DOM nodes alike — the
|
|
77
|
-
* same reasoning as `paragraphElementOf` in `dom-map.js`.
|
|
78
|
-
* @param {import("./dom-map.js").MappableNode} root
|
|
79
|
-
* @param {import("./dom-map.js").MappableNode | null} node
|
|
80
|
-
* @returns {boolean}
|
|
81
|
-
*/
|
|
82
|
-
function isInside(root, node) {
|
|
83
|
-
let current = node;
|
|
84
|
-
while (current !== null && current !== undefined) {
|
|
85
|
-
if (current === root) {
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
88
|
-
current = current.parentNode ?? null;
|
|
89
|
-
}
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export class SelectionController {
|
|
94
|
-
/**
|
|
95
|
-
* @param {object} options
|
|
96
|
-
* @param {import("./dom-map.js").MappableNode} options.root
|
|
97
|
-
* @param {(id: import("../model/node.js").NodeId) => import("./dom-map.js").MappableNode | null} options.lookupParagraph
|
|
98
|
-
* @param {(selection: import("../model/position.js").Selection) => void} options.onAdopt
|
|
99
|
-
* Called only for a real, user-driven selection change — never for our own
|
|
100
|
-
* programmatic restores.
|
|
101
|
-
* @param {() => import("../canvas/mode-controller.js").Mode} [options.getMode]
|
|
102
|
-
* Reads the current box/text mode (ADR-0024, P3b mode-merge). This turn it
|
|
103
|
-
* is the READ connection only — no behaviour depends on it yet (the
|
|
104
|
-
* click-router that routes a box-mode click away from the caret is parça 3).
|
|
105
|
-
* Defaults to always-"text", which is exactly today's editor.html behaviour
|
|
106
|
-
* (no mode controller -> the surface is only ever a text editor).
|
|
107
|
-
*/
|
|
108
|
-
constructor({ root, lookupParagraph, onAdopt, getMode = () => "text" }) {
|
|
109
|
-
this.root = root;
|
|
110
|
-
this.lookupParagraph = lookupParagraph;
|
|
111
|
-
this.onAdopt = onAdopt;
|
|
112
|
-
this.getMode = getMode;
|
|
113
|
-
|
|
114
|
-
/** @type {import("../model/position.js").Selection | null} */
|
|
115
|
-
this.selection = null;
|
|
116
|
-
/** @type {ReturnType<typeof snapshot>} */
|
|
117
|
-
this.expectedDomSelection = null;
|
|
118
|
-
/** @type {boolean} */
|
|
119
|
-
this.isComposing = false;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* The current box/text mode, read from the mode controller (ADR-0024). The
|
|
124
|
-
* READ connection only in P3b parça 1 — parça 3 is where this gates routing.
|
|
125
|
-
* @returns {import("../canvas/mode-controller.js").Mode}
|
|
126
|
-
*/
|
|
127
|
-
get mode() {
|
|
128
|
-
return this.getMode();
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* @param {boolean} value
|
|
133
|
-
* @returns {void}
|
|
134
|
-
*/
|
|
135
|
-
setComposing(value) {
|
|
136
|
-
this.isComposing = value;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Forgets everything about the previous document's selection — used when
|
|
141
|
-
* `loadDocument` swaps in an unrelated document on the SAME live instance
|
|
142
|
-
* (ADR-0027). Without this, `this.selection`/`expectedDomSelection` keep
|
|
143
|
-
* referencing the OLD document's node ids; `renderAndRestore`'s
|
|
144
|
-
* `restoreSelection` call is skipped whenever nothing is focused right
|
|
145
|
-
* after a fresh load (`readDomSelection()` returns null), so the stale
|
|
146
|
-
* value would otherwise survive silently — not crash-visible the way the
|
|
147
|
-
* renderer's id collision was, just wrong.
|
|
148
|
-
* @returns {void}
|
|
149
|
-
*/
|
|
150
|
-
reset() {
|
|
151
|
-
this.selection = null;
|
|
152
|
-
this.expectedDomSelection = null;
|
|
153
|
-
this.isComposing = false;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Writes a model selection into the DOM and records it as ours, so the
|
|
158
|
-
* `selectionchange` it triggers is recognised and ignored rather than adopted.
|
|
159
|
-
*
|
|
160
|
-
* Skips the actual DOM write when the browser's selection already matches —
|
|
161
|
-
* cheap to check, and avoids a pointless `selectionchange`/scroll (plan §5).
|
|
162
|
-
* `this.selection` is still updated either way: as far as the controller is
|
|
163
|
-
* concerned, the model selection is now this value regardless of whether the
|
|
164
|
-
* DOM needed to catch up.
|
|
165
|
-
*
|
|
166
|
-
* The expectation flag is cleared on a microtask, not synchronously.
|
|
167
|
-
* `selectionchange` fires asynchronously in some browsers, so clearing it
|
|
168
|
-
* immediately after the synchronous `setBaseAndExtent` call risks the
|
|
169
|
-
* eventual event arriving after the flag is already gone and being
|
|
170
|
-
* misread as a user change. See ADR-0004 (input/IME strategy) for the
|
|
171
|
-
* browser-specific timing this works around.
|
|
172
|
-
*
|
|
173
|
-
* @param {import("../model/position.js").Selection} selection
|
|
174
|
-
* @param {DomSelectionLike} domSelection
|
|
175
|
-
* @returns {boolean} whether a DOM write actually happened
|
|
176
|
-
*/
|
|
177
|
-
restoreSelection(selection, domSelection) {
|
|
178
|
-
this.selection = selection;
|
|
179
|
-
|
|
180
|
-
const anchor = modelToDom(selection.anchor, this.lookupParagraph);
|
|
181
|
-
const head = modelToDom(selection.head, this.lookupParagraph);
|
|
182
|
-
if (anchor === null || head === null) {
|
|
183
|
-
return false;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const target = {
|
|
187
|
-
node: anchor.node,
|
|
188
|
-
offset: anchor.offset,
|
|
189
|
-
focusNode: head.node,
|
|
190
|
-
focusOffset: head.offset,
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
if (snapshotsEqual(snapshot(domSelection), target)) {
|
|
194
|
-
return false;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
this.expectedDomSelection = target;
|
|
198
|
-
domSelection.setBaseAndExtent(anchor.node, anchor.offset, head.node, head.offset);
|
|
199
|
-
|
|
200
|
-
queueMicrotask(() => {
|
|
201
|
-
this.expectedDomSelection = null;
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
return true;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* The `selectionchange` handler's logic, decoupled from the real event so it
|
|
209
|
-
* can be driven directly in tests and by `attach()` alike.
|
|
210
|
-
* @param {DomSelectionLike} domSelection
|
|
211
|
-
* @returns {void}
|
|
212
|
-
*/
|
|
213
|
-
handleSelectionChange(domSelection) {
|
|
214
|
-
const current = snapshot(domSelection);
|
|
215
|
-
if (current === null || !isInside(this.root, current.node)) {
|
|
216
|
-
return; // stage 1: outside the editor
|
|
217
|
-
}
|
|
218
|
-
if (this.isEcho(current)) {
|
|
219
|
-
return; // stage 2: an echo of our own write
|
|
220
|
-
}
|
|
221
|
-
if (this.isComposing) {
|
|
222
|
-
return; // stage 3: IME owns the DOM right now
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// stage 4: adopt — a real, user-driven change.
|
|
226
|
-
const anchorPos = domToModel(current.node, current.offset);
|
|
227
|
-
const headPos = domToModel(current.focusNode, current.focusOffset);
|
|
228
|
-
if (anchorPos === null || headPos === null) {
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
this.selection = { anchor: anchorPos, head: headPos };
|
|
233
|
-
this.onAdopt(this.selection);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Whether `current` is what we last asked the browser to set.
|
|
238
|
-
*
|
|
239
|
-
* The exact `{node, offset}` match is the common case and is checked first,
|
|
240
|
-
* with no `domToModel` calls at all. It is not the only case: `setBaseAndExtent`
|
|
241
|
-
* can come back normalized to a different but equivalent point — most likely
|
|
242
|
-
* when we target an empty paragraph's element container (offset 0, no text
|
|
243
|
-
* node to land on) and the browser reports the filler `<br>` instead of the
|
|
244
|
-
* `<p>`, or vice versa. Both describe the same character position, so without
|
|
245
|
-
* this fallback that echo would be misread as a real cursor move and clear
|
|
246
|
-
* `storedMarks` for no reason (rule 2 in `state/editor-state.js`).
|
|
247
|
-
*
|
|
248
|
-
* Only runs when there is something to compare against — the overwhelming
|
|
249
|
-
* majority of `selectionchange` events are genuine user moves with
|
|
250
|
-
* `expectedDomSelection` already `null`, and those never pay for a
|
|
251
|
-
* `domToModel` call here at all.
|
|
252
|
-
*
|
|
253
|
-
* ⚠ doğrulanmalı: exact normalization behaviour is browser-specific — see
|
|
254
|
-
* `docs/browser-matrix.md`.
|
|
255
|
-
*
|
|
256
|
-
* @param {Exclude<ReturnType<typeof snapshot>, null>} current
|
|
257
|
-
* @returns {boolean}
|
|
258
|
-
*/
|
|
259
|
-
isEcho(current) {
|
|
260
|
-
const expected = this.expectedDomSelection;
|
|
261
|
-
if (expected === null) {
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
264
|
-
if (snapshotsEqual(current, expected)) {
|
|
265
|
-
return true;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const currentAnchor = domToModel(current.node, current.offset);
|
|
269
|
-
const currentHead = domToModel(current.focusNode, current.focusOffset);
|
|
270
|
-
const expectedAnchor = domToModel(expected.node, expected.offset);
|
|
271
|
-
const expectedHead = domToModel(expected.focusNode, expected.focusOffset);
|
|
272
|
-
|
|
273
|
-
if (
|
|
274
|
-
currentAnchor === null ||
|
|
275
|
-
currentHead === null ||
|
|
276
|
-
expectedAnchor === null ||
|
|
277
|
-
expectedHead === null
|
|
278
|
-
) {
|
|
279
|
-
return false;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
return positionsEqual(currentAnchor, expectedAnchor) && positionsEqual(currentHead, expectedHead);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Wires the real event. `document` — not the root element — because
|
|
287
|
-
* `selectionchange` only fires there (plan §3). Takes the target rather than
|
|
288
|
-
* reading a module-level global, so this stays SSR-safe and testable with a
|
|
289
|
-
* fake event target.
|
|
290
|
-
* @param {{ addEventListener: (type: string, listener: () => void) => void }} documentTarget
|
|
291
|
-
* @param {() => DomSelectionLike | null} getDomSelection
|
|
292
|
-
* @returns {void}
|
|
293
|
-
*/
|
|
294
|
-
attach(documentTarget, getDomSelection) {
|
|
295
|
-
/** @type {() => void} */
|
|
296
|
-
this._listener = () => {
|
|
297
|
-
const sel = getDomSelection();
|
|
298
|
-
if (sel !== null) {
|
|
299
|
-
this.handleSelectionChange(sel);
|
|
300
|
-
}
|
|
301
|
-
};
|
|
302
|
-
documentTarget.addEventListener("selectionchange", this._listener);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* @param {{ removeEventListener: (type: string, listener: () => void) => void }} documentTarget
|
|
307
|
-
* @returns {void}
|
|
308
|
-
*/
|
|
309
|
-
detach(documentTarget) {
|
|
310
|
-
if (this._listener !== undefined) {
|
|
311
|
-
documentTarget.removeEventListener("selectionchange", this._listener);
|
|
312
|
-
this._listener = undefined;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
}
|
|
1
|
+
import { domToModel, modelToDom } from "./dom-map.js";
|
|
2
|
+
import { positionsEqual } from "../model/position.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bridges DOM selection and model selection in both directions.
|
|
6
|
+
*
|
|
7
|
+
* The DOM direction is filtered through four stages, in order (plan §3):
|
|
8
|
+
*
|
|
9
|
+
* 1. outside the editor entirely → ignore
|
|
10
|
+
* 2. it is an echo of our own write → ignore
|
|
11
|
+
* 3. an IME composition is in progress → ignore
|
|
12
|
+
* 4. otherwise → **adopt**: translate both endpoints
|
|
13
|
+
* and hand the result to the caller, without ever calling the renderer.
|
|
14
|
+
*
|
|
15
|
+
* A DOM selection change is either something we caused or something the user
|
|
16
|
+
* caused. Only the second kind should change anything — a click updates the
|
|
17
|
+
* model's notion of where the cursor is, but it must not "apply" the way a
|
|
18
|
+
* transaction does, because there is no document change to make. That
|
|
19
|
+
* distinction is why `onAdopt` exists instead of just returning a value: it
|
|
20
|
+
* marks the boundary past which `storedMarks` gets cleared (rule 2, decided by
|
|
21
|
+
* `state/editor-state.js`'s `withSelection`) — a fired `onAdopt` means a real
|
|
22
|
+
* cursor move, our own programmatic restores never reach it.
|
|
23
|
+
*
|
|
24
|
+
* @typedef {object} DomSelectionPoint
|
|
25
|
+
* @property {import("./dom-map.js").MappableNode} node
|
|
26
|
+
* @property {number} offset
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The minimal surface of `window.Selection` this module needs: four fields to
|
|
31
|
+
* read the current selection, one method to set it. A fake satisfying this is
|
|
32
|
+
* enough to test the whole class without a real DOM.
|
|
33
|
+
* @typedef {object} DomSelectionLike
|
|
34
|
+
* @property {import("./dom-map.js").MappableNode | null} anchorNode
|
|
35
|
+
* @property {number} anchorOffset
|
|
36
|
+
* @property {import("./dom-map.js").MappableNode | null} focusNode
|
|
37
|
+
* @property {number} focusOffset
|
|
38
|
+
* @property {(anchorNode: any, anchorOffset: number, focusNode: any, focusOffset: number) => void} setBaseAndExtent
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {DomSelectionLike} sel
|
|
43
|
+
* @returns {DomSelectionPoint & { focusNode: any, focusOffset: number } | null}
|
|
44
|
+
*/
|
|
45
|
+
function snapshot(sel) {
|
|
46
|
+
if (sel.anchorNode === null || sel.focusNode === null) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
node: sel.anchorNode,
|
|
51
|
+
offset: sel.anchorOffset,
|
|
52
|
+
focusNode: sel.focusNode,
|
|
53
|
+
focusOffset: sel.focusOffset,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {ReturnType<typeof snapshot>} a
|
|
59
|
+
* @param {ReturnType<typeof snapshot>} b
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
function snapshotsEqual(a, b) {
|
|
63
|
+
if (a === null || b === null) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return (
|
|
67
|
+
a.node === b.node &&
|
|
68
|
+
a.offset === b.offset &&
|
|
69
|
+
a.focusNode === b.focusNode &&
|
|
70
|
+
a.focusOffset === b.focusOffset
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Walks `parentNode` rather than calling `.contains()`, so the same logic works
|
|
76
|
+
* on the plain object trees the tests use and on real DOM nodes alike — the
|
|
77
|
+
* same reasoning as `paragraphElementOf` in `dom-map.js`.
|
|
78
|
+
* @param {import("./dom-map.js").MappableNode} root
|
|
79
|
+
* @param {import("./dom-map.js").MappableNode | null} node
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
function isInside(root, node) {
|
|
83
|
+
let current = node;
|
|
84
|
+
while (current !== null && current !== undefined) {
|
|
85
|
+
if (current === root) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
current = current.parentNode ?? null;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class SelectionController {
|
|
94
|
+
/**
|
|
95
|
+
* @param {object} options
|
|
96
|
+
* @param {import("./dom-map.js").MappableNode} options.root
|
|
97
|
+
* @param {(id: import("../model/node.js").NodeId) => import("./dom-map.js").MappableNode | null} options.lookupParagraph
|
|
98
|
+
* @param {(selection: import("../model/position.js").Selection) => void} options.onAdopt
|
|
99
|
+
* Called only for a real, user-driven selection change — never for our own
|
|
100
|
+
* programmatic restores.
|
|
101
|
+
* @param {() => import("../canvas/mode-controller.js").Mode} [options.getMode]
|
|
102
|
+
* Reads the current box/text mode (ADR-0024, P3b mode-merge). This turn it
|
|
103
|
+
* is the READ connection only — no behaviour depends on it yet (the
|
|
104
|
+
* click-router that routes a box-mode click away from the caret is parça 3).
|
|
105
|
+
* Defaults to always-"text", which is exactly today's editor.html behaviour
|
|
106
|
+
* (no mode controller -> the surface is only ever a text editor).
|
|
107
|
+
*/
|
|
108
|
+
constructor({ root, lookupParagraph, onAdopt, getMode = () => "text" }) {
|
|
109
|
+
this.root = root;
|
|
110
|
+
this.lookupParagraph = lookupParagraph;
|
|
111
|
+
this.onAdopt = onAdopt;
|
|
112
|
+
this.getMode = getMode;
|
|
113
|
+
|
|
114
|
+
/** @type {import("../model/position.js").Selection | null} */
|
|
115
|
+
this.selection = null;
|
|
116
|
+
/** @type {ReturnType<typeof snapshot>} */
|
|
117
|
+
this.expectedDomSelection = null;
|
|
118
|
+
/** @type {boolean} */
|
|
119
|
+
this.isComposing = false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The current box/text mode, read from the mode controller (ADR-0024). The
|
|
124
|
+
* READ connection only in P3b parça 1 — parça 3 is where this gates routing.
|
|
125
|
+
* @returns {import("../canvas/mode-controller.js").Mode}
|
|
126
|
+
*/
|
|
127
|
+
get mode() {
|
|
128
|
+
return this.getMode();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {boolean} value
|
|
133
|
+
* @returns {void}
|
|
134
|
+
*/
|
|
135
|
+
setComposing(value) {
|
|
136
|
+
this.isComposing = value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Forgets everything about the previous document's selection — used when
|
|
141
|
+
* `loadDocument` swaps in an unrelated document on the SAME live instance
|
|
142
|
+
* (ADR-0027). Without this, `this.selection`/`expectedDomSelection` keep
|
|
143
|
+
* referencing the OLD document's node ids; `renderAndRestore`'s
|
|
144
|
+
* `restoreSelection` call is skipped whenever nothing is focused right
|
|
145
|
+
* after a fresh load (`readDomSelection()` returns null), so the stale
|
|
146
|
+
* value would otherwise survive silently — not crash-visible the way the
|
|
147
|
+
* renderer's id collision was, just wrong.
|
|
148
|
+
* @returns {void}
|
|
149
|
+
*/
|
|
150
|
+
reset() {
|
|
151
|
+
this.selection = null;
|
|
152
|
+
this.expectedDomSelection = null;
|
|
153
|
+
this.isComposing = false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Writes a model selection into the DOM and records it as ours, so the
|
|
158
|
+
* `selectionchange` it triggers is recognised and ignored rather than adopted.
|
|
159
|
+
*
|
|
160
|
+
* Skips the actual DOM write when the browser's selection already matches —
|
|
161
|
+
* cheap to check, and avoids a pointless `selectionchange`/scroll (plan §5).
|
|
162
|
+
* `this.selection` is still updated either way: as far as the controller is
|
|
163
|
+
* concerned, the model selection is now this value regardless of whether the
|
|
164
|
+
* DOM needed to catch up.
|
|
165
|
+
*
|
|
166
|
+
* The expectation flag is cleared on a microtask, not synchronously.
|
|
167
|
+
* `selectionchange` fires asynchronously in some browsers, so clearing it
|
|
168
|
+
* immediately after the synchronous `setBaseAndExtent` call risks the
|
|
169
|
+
* eventual event arriving after the flag is already gone and being
|
|
170
|
+
* misread as a user change. See ADR-0004 (input/IME strategy) for the
|
|
171
|
+
* browser-specific timing this works around.
|
|
172
|
+
*
|
|
173
|
+
* @param {import("../model/position.js").Selection} selection
|
|
174
|
+
* @param {DomSelectionLike} domSelection
|
|
175
|
+
* @returns {boolean} whether a DOM write actually happened
|
|
176
|
+
*/
|
|
177
|
+
restoreSelection(selection, domSelection) {
|
|
178
|
+
this.selection = selection;
|
|
179
|
+
|
|
180
|
+
const anchor = modelToDom(selection.anchor, this.lookupParagraph);
|
|
181
|
+
const head = modelToDom(selection.head, this.lookupParagraph);
|
|
182
|
+
if (anchor === null || head === null) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const target = {
|
|
187
|
+
node: anchor.node,
|
|
188
|
+
offset: anchor.offset,
|
|
189
|
+
focusNode: head.node,
|
|
190
|
+
focusOffset: head.offset,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
if (snapshotsEqual(snapshot(domSelection), target)) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.expectedDomSelection = target;
|
|
198
|
+
domSelection.setBaseAndExtent(anchor.node, anchor.offset, head.node, head.offset);
|
|
199
|
+
|
|
200
|
+
queueMicrotask(() => {
|
|
201
|
+
this.expectedDomSelection = null;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The `selectionchange` handler's logic, decoupled from the real event so it
|
|
209
|
+
* can be driven directly in tests and by `attach()` alike.
|
|
210
|
+
* @param {DomSelectionLike} domSelection
|
|
211
|
+
* @returns {void}
|
|
212
|
+
*/
|
|
213
|
+
handleSelectionChange(domSelection) {
|
|
214
|
+
const current = snapshot(domSelection);
|
|
215
|
+
if (current === null || !isInside(this.root, current.node)) {
|
|
216
|
+
return; // stage 1: outside the editor
|
|
217
|
+
}
|
|
218
|
+
if (this.isEcho(current)) {
|
|
219
|
+
return; // stage 2: an echo of our own write
|
|
220
|
+
}
|
|
221
|
+
if (this.isComposing) {
|
|
222
|
+
return; // stage 3: IME owns the DOM right now
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// stage 4: adopt — a real, user-driven change.
|
|
226
|
+
const anchorPos = domToModel(current.node, current.offset);
|
|
227
|
+
const headPos = domToModel(current.focusNode, current.focusOffset);
|
|
228
|
+
if (anchorPos === null || headPos === null) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
this.selection = { anchor: anchorPos, head: headPos };
|
|
233
|
+
this.onAdopt(this.selection);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Whether `current` is what we last asked the browser to set.
|
|
238
|
+
*
|
|
239
|
+
* The exact `{node, offset}` match is the common case and is checked first,
|
|
240
|
+
* with no `domToModel` calls at all. It is not the only case: `setBaseAndExtent`
|
|
241
|
+
* can come back normalized to a different but equivalent point — most likely
|
|
242
|
+
* when we target an empty paragraph's element container (offset 0, no text
|
|
243
|
+
* node to land on) and the browser reports the filler `<br>` instead of the
|
|
244
|
+
* `<p>`, or vice versa. Both describe the same character position, so without
|
|
245
|
+
* this fallback that echo would be misread as a real cursor move and clear
|
|
246
|
+
* `storedMarks` for no reason (rule 2 in `state/editor-state.js`).
|
|
247
|
+
*
|
|
248
|
+
* Only runs when there is something to compare against — the overwhelming
|
|
249
|
+
* majority of `selectionchange` events are genuine user moves with
|
|
250
|
+
* `expectedDomSelection` already `null`, and those never pay for a
|
|
251
|
+
* `domToModel` call here at all.
|
|
252
|
+
*
|
|
253
|
+
* ⚠ doğrulanmalı: exact normalization behaviour is browser-specific — see
|
|
254
|
+
* `docs/browser-matrix.md`.
|
|
255
|
+
*
|
|
256
|
+
* @param {Exclude<ReturnType<typeof snapshot>, null>} current
|
|
257
|
+
* @returns {boolean}
|
|
258
|
+
*/
|
|
259
|
+
isEcho(current) {
|
|
260
|
+
const expected = this.expectedDomSelection;
|
|
261
|
+
if (expected === null) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
if (snapshotsEqual(current, expected)) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const currentAnchor = domToModel(current.node, current.offset);
|
|
269
|
+
const currentHead = domToModel(current.focusNode, current.focusOffset);
|
|
270
|
+
const expectedAnchor = domToModel(expected.node, expected.offset);
|
|
271
|
+
const expectedHead = domToModel(expected.focusNode, expected.focusOffset);
|
|
272
|
+
|
|
273
|
+
if (
|
|
274
|
+
currentAnchor === null ||
|
|
275
|
+
currentHead === null ||
|
|
276
|
+
expectedAnchor === null ||
|
|
277
|
+
expectedHead === null
|
|
278
|
+
) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return positionsEqual(currentAnchor, expectedAnchor) && positionsEqual(currentHead, expectedHead);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Wires the real event. `document` — not the root element — because
|
|
287
|
+
* `selectionchange` only fires there (plan §3). Takes the target rather than
|
|
288
|
+
* reading a module-level global, so this stays SSR-safe and testable with a
|
|
289
|
+
* fake event target.
|
|
290
|
+
* @param {{ addEventListener: (type: string, listener: () => void) => void }} documentTarget
|
|
291
|
+
* @param {() => DomSelectionLike | null} getDomSelection
|
|
292
|
+
* @returns {void}
|
|
293
|
+
*/
|
|
294
|
+
attach(documentTarget, getDomSelection) {
|
|
295
|
+
/** @type {() => void} */
|
|
296
|
+
this._listener = () => {
|
|
297
|
+
const sel = getDomSelection();
|
|
298
|
+
if (sel !== null) {
|
|
299
|
+
this.handleSelectionChange(sel);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
documentTarget.addEventListener("selectionchange", this._listener);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* @param {{ removeEventListener: (type: string, listener: () => void) => void }} documentTarget
|
|
307
|
+
* @returns {void}
|
|
308
|
+
*/
|
|
309
|
+
detach(documentTarget) {
|
|
310
|
+
if (this._listener !== undefined) {
|
|
311
|
+
documentTarget.removeEventListener("selectionchange", this._listener);
|
|
312
|
+
this._listener = undefined;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|