@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,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
+ }