@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,341 +1,341 @@
1
- import { isDev } from "../dev.js";
2
- import { paragraphById, paragraphText } from "../model/node.js";
3
- import {
4
- containerIdOf,
5
- createPosition,
6
- createSelection,
7
- isSingleParagraph,
8
- paragraphIdOf,
9
- selectionRange,
10
- } from "../model/position.js";
11
- import { createTransaction } from "../model/transaction.js";
12
- import { textNodesOf } from "../selection/dom-map.js";
13
- import { deleteSelection, insertText } from "../commands/text.js";
14
-
15
- /**
16
- * P1.9a — IME composition. Design: `docs/adr/0004-input-and-ime-strategy.md`.
17
- * This module converts that design into code; it does not add to it. Where a
18
- * decision is not made by the ADR (the abandon-path commit policy, §3), this
19
- * module takes the undecided value as an injected parameter rather than
20
- * choosing one — see `onAbandon` below.
21
- *
22
- * Two freezes are this module's job (ADR-0004 Karar 2, F1/F3): no step is ever
23
- * applied while composing, and any other model-mutating path seals the
24
- * bracket first. The third freeze, F2 ("no `Renderer.render()` while
25
- * composing"), is NOT this module's job — it belongs to the host's dispatch
26
- * loop, which this module has no reference to (its context is exactly
27
- * `{getState, dispatch}`, matching `input/beforeinput.js`'s
28
- * `BeforeInputContext`). For the same reason, "re-render from the model"
29
- * after a refusal (ADR-0004 §2) is also a host responsibility: a refusal
30
- * seal never changes `state.doc` (by design — F1 held), so a plain
31
- * `render(state.doc)` call is a structural no-op against `Renderer`'s
32
- * identity fast path (`render/renderer.js`). The host is expected to force
33
- * one render whenever `isComposing` transitions from `true` to `false`,
34
- * regardless of doc identity.
35
- */
36
-
37
- /**
38
- * @typedef {object} CompositionSnapshot
39
- * @property {import("../model/node.js").NodeId} containerId
40
- * @property {import("../model/node.js").NodeId} paragraphId
41
- * @property {number} fromOffset
42
- * @property {number} toOffset
43
- * @property {string} modelTextAtStart
44
- * @property {import("../selection/dom-map.js").MappableNode} paragraphElement
45
- */
46
-
47
- /**
48
- * Why a composing bracket closed.
49
- * @typedef {"committed" | "abandoned:blur" | "abandoned:superseded" | "abandoned:preempted"} SealTrigger
50
- */
51
-
52
- /**
53
- * Reads a paragraph's live text straight from its text nodes — never
54
- * `innerHTML`, never interpreting element structure (invariant #7: whatever
55
- * markup the IME left behind is just characters). Mirrors `textNodesOf`'s own
56
- * "walks `parentNode`, not `closest()`" reasoning: this has to work on nodes
57
- * the browser inserted during composition, which the model never created.
58
- * @param {import("../selection/dom-map.js").MappableNode} paragraphElement
59
- * @returns {string}
60
- */
61
- export function readParagraphText(paragraphElement) {
62
- return textNodesOf(paragraphElement)
63
- .map((node) => node.data ?? "")
64
- .join("");
65
- }
66
-
67
- /**
68
- * @typedef {{ ok: true, composed: string } | { ok: false, reason: string }} VerifyResult
69
- */
70
-
71
- /**
72
- * The self-verifying read (ADR-0004 §2): four gates, and a failure of any one
73
- * refuses rather than guesses. `currentModelText` is `null` when the
74
- * paragraph the snapshot pointed at can no longer be resolved at all.
75
- * @param {CompositionSnapshot} snapshot
76
- * @param {string} domText
77
- * @param {string | null} currentModelText
78
- * @returns {VerifyResult}
79
- */
80
- export function verifyAndExtract(snapshot, domText, currentModelText) {
81
- // KAPI 1 (F3 check): the model must not have moved out from under us.
82
- if (currentModelText === null || currentModelText !== snapshot.modelTextAtStart) {
83
- return { ok: false, reason: "model-changed" };
84
- }
85
-
86
- const prefix = snapshot.modelTextAtStart.slice(0, snapshot.fromOffset);
87
- const suffix = snapshot.modelTextAtStart.slice(snapshot.toOffset);
88
-
89
- // KAPI 2 (boundary)
90
- if (!domText.startsWith(prefix)) {
91
- return { ok: false, reason: "prefix-mismatch" };
92
- }
93
- // KAPI 3 (boundary)
94
- if (!domText.endsWith(suffix)) {
95
- return { ok: false, reason: "suffix-mismatch" };
96
- }
97
- // KAPI 4 (length) — guards the slice below from going negative when prefix
98
- // and suffix overlap (a shorter domText than the untouched boundaries need).
99
- if (domText.length < prefix.length + suffix.length) {
100
- return { ok: false, reason: "length-mismatch" };
101
- }
102
-
103
- return { ok: true, composed: domText.slice(prefix.length, domText.length - suffix.length) };
104
- }
105
-
106
- /**
107
- * @typedef {object} SealPlan
108
- * @property {import("../model/transaction.js").Transaction | null} transaction
109
- * @property {boolean} restoreSelection
110
- * @property {import("../model/position.js").Selection | undefined} selectionOverride
111
- * @property {string | undefined} refusedReason
112
- */
113
-
114
- /**
115
- * Decides what a seal does, without touching `getState`/`dispatch` itself —
116
- * this is the pure half `CompositionController` drives. Takes `onAbandon` as
117
- * a parameter rather than reading a field, so the mechanism ("both values
118
- * honoured") is testable independently of any chosen policy (ADR-0004 §3,
119
- * DOĞRULANMALI #8 — the policy itself is undecided, deferred to P1.9b).
120
- *
121
- * @param {import("../state/editor-state.js").EditorState} state
122
- * @param {CompositionSnapshot} snapshot
123
- * @param {string} domText
124
- * @param {SealTrigger} reason
125
- * @param {"commit" | "discard"} onAbandon
126
- * @returns {SealPlan}
127
- */
128
- export function planSeal(state, snapshot, domText, reason, onAbandon) {
129
- const attemptsCommit = reason === "committed" || onAbandon === "commit";
130
-
131
- if (!attemptsCommit) {
132
- return { transaction: null, restoreSelection: false, selectionOverride: undefined, refusedReason: undefined };
133
- }
134
-
135
- // Below this point, an abandon-path attempt (onAbandon === "commit") is
136
- // treated exactly like Yol 1's real `compositionend`: if verification
137
- // fails, it falls through to Yol 5's refusal semantics regardless of which
138
- // of the two triggered the attempt. Refusal is the narrower, purely
139
- // technical condition (the DOM no longer proves what changed) and takes
140
- // precedence over the wider abandon-policy choice — a policy decision
141
- // should never override a safety check.
142
-
143
- const para = paragraphById(state.doc, snapshot.paragraphId);
144
- const currentModelText = para === null ? null : paragraphText(para);
145
- const verified = verifyAndExtract(snapshot, domText, currentModelText);
146
-
147
- const composingSelection = createSelection(
148
- createPosition(snapshot.containerId, snapshot.paragraphId, snapshot.fromOffset),
149
- createPosition(snapshot.containerId, snapshot.paragraphId, snapshot.toOffset),
150
- );
151
-
152
- if (!verified.ok) {
153
- // Yol 5: a refused (or unsupported) composition never happened, as far as
154
- // the model is concerned (F1 held it untouched) — so caret and selection
155
- // return to exactly where they stood at `compositionstart`. If the
156
- // paragraph itself is gone, there is nowhere sensible to put a caret;
157
- // `state.selection` is left exactly as it is.
158
- return {
159
- transaction: null,
160
- restoreSelection: para !== null,
161
- selectionOverride: para !== null ? composingSelection : undefined,
162
- refusedReason: verified.reason,
163
- };
164
- }
165
-
166
- const pseudoState = { doc: state.doc, selection: composingSelection, storedMarks: state.storedMarks };
167
-
168
- let transaction = null;
169
- if (verified.composed !== "") {
170
- // Reuses `insertText` rather than building the step directly: this is
171
- // exactly what carries ADR-0017's left-biased `marksAt` (and, riding
172
- // along inside `marks`, `rPrRef`) into the composed text "for free".
173
- transaction = insertText(pseudoState, verified.composed, "ime");
174
- } else if (snapshot.fromOffset !== snapshot.toOffset) {
175
- transaction = deleteSelection(pseudoState, "ime");
176
- }
177
-
178
- return { transaction, restoreSelection: false, selectionOverride: undefined, refusedReason: undefined };
179
- }
180
-
181
- /**
182
- * @typedef {object} CompositionContext
183
- * @property {() => import("../state/editor-state.js").EditorState} getState
184
- * @property {(tr: import("../model/transaction.js").Transaction) => void} dispatch
185
- */
186
-
187
- /**
188
- * Tracks one IME composing bracket. Two rest states, `idle` and `composing`;
189
- * `sealed(reason)` (ADR-0004 §1) is a transition, not a third rest state —
190
- * carrying `reason` forward is what lets "closed" and "abandoned" stay
191
- * distinguishable (ADR-0016 §1's `sealReason` reasoning, reused).
192
- *
193
- * Sealing is idempotent: once a bracket is sealed, a second seal call is a
194
- * no-op. Event order across the closing paths is not guaranteed (ADR-0016
195
- * G10 — measured on a different pair of events, same class of problem), so
196
- * whichever seal arrives first wins and the second finds nothing open.
197
- */
198
- export class CompositionController {
199
- /**
200
- * @param {object} options
201
- * @param {() => import("../state/editor-state.js").EditorState} options.getState
202
- * @param {(tr: import("../model/transaction.js").Transaction) => void} options.dispatch
203
- * @param {(id: import("../model/node.js").NodeId) => import("../selection/dom-map.js").MappableNode | null} options.lookupParagraph
204
- * @param {"commit" | "discard"} options.onAbandon
205
- * Not chosen by P1.9a (ADR-0004 §3) — the caller supplies it.
206
- */
207
- constructor({ getState, dispatch, lookupParagraph, onAbandon }) {
208
- this._getState = getState;
209
- this._dispatch = dispatch;
210
- this._lookupParagraph = lookupParagraph;
211
- this._onAbandon = onAbandon;
212
-
213
- /** @type {"idle" | "composing"} */
214
- this.phase = "idle";
215
- /** @type {CompositionSnapshot | null} */
216
- this._snapshot = null;
217
- /** @type {string | null} */
218
- this._lastSealReason = null;
219
- }
220
-
221
- /** @returns {boolean} */
222
- get isComposing() {
223
- return this.phase === "composing";
224
- }
225
-
226
- /** The reason (or refusal) the most recent seal closed with, if any. */
227
- get lastSealReason() {
228
- return this._lastSealReason;
229
- }
230
-
231
- /**
232
- * `compositionstart`. Rejects multi-paragraph composing ranges outright
233
- * (ADR-0004 §2 — untested arithmetic, `isSingleContainer` already makes the
234
- * cross-container case impossible) rather than tracking them and refusing
235
- * later.
236
- * @returns {void}
237
- */
238
- handleCompositionStart() {
239
- if (this.phase === "composing") {
240
- this._seal("abandoned:superseded");
241
- }
242
-
243
- const state = this._getState();
244
- const { selection } = state;
245
-
246
- if (!isSingleParagraph(selection)) {
247
- if (isDev()) {
248
- // eslint-disable-next-line no-console
249
- console.warn("word-editor: composition started over a multi-paragraph selection — unsupported-range, not tracked");
250
- }
251
- return;
252
- }
253
-
254
- const range = selectionRange(state.doc, selection);
255
- if (range === null) {
256
- return;
257
- }
258
- const paragraphId = paragraphIdOf(range.from);
259
- const para = paragraphById(state.doc, paragraphId);
260
- const paragraphElement = this._lookupParagraph(paragraphId);
261
- if (para === null || paragraphElement === null) {
262
- return;
263
- }
264
-
265
- this._snapshot = {
266
- containerId: containerIdOf(range.from),
267
- paragraphId,
268
- fromOffset: range.from.offset,
269
- toOffset: range.to.offset,
270
- modelTextAtStart: paragraphText(para),
271
- paragraphElement,
272
- };
273
- this.phase = "composing";
274
- }
275
-
276
- /**
277
- * `compositionupdate` deliberately does nothing (ADR-0004 §1, F1): no
278
- * transition, no transaction. Its absence would say nothing; this explicit
279
- * no-op is the guarded decision itself.
280
- * @returns {void}
281
- */
282
- handleCompositionUpdate() {
283
- // Intentionally empty — see docstring.
284
- }
285
-
286
- /** `compositionend` — Yol 1. @returns {void} */
287
- handleCompositionEnd() {
288
- this._seal("committed");
289
- }
290
-
291
- /** `focusout` on the composing box — Yol 2. @returns {void} */
292
- handleFocusOut() {
293
- this._seal("abandoned:blur");
294
- }
295
-
296
- /**
297
- * F3: called by the host before any other path is about to mutate the
298
- * model — Yol 4. A no-op when nothing is composing.
299
- * @returns {void}
300
- */
301
- sealForExternalMutation() {
302
- this._seal("abandoned:preempted");
303
- }
304
-
305
- /**
306
- * @param {SealTrigger} reason
307
- * @returns {void}
308
- */
309
- _seal(reason) {
310
- if (this.phase !== "composing" || this._snapshot === null) {
311
- return; // idempotent: already sealed, or never opened
312
- }
313
-
314
- const snapshot = this._snapshot;
315
- const state = this._getState();
316
- const domText = readParagraphText(snapshot.paragraphElement);
317
- const plan = planSeal(state, snapshot, domText, reason, this._onAbandon);
318
-
319
- if (plan.transaction !== null) {
320
- this._dispatch(plan.transaction);
321
- } else if (plan.restoreSelection && plan.selectionOverride !== undefined) {
322
- const tr = createTransaction(state.doc, state.selection, "ime")
323
- .setSelection(plan.selectionOverride)
324
- .build();
325
- this._dispatch(tr);
326
- }
327
-
328
- if (isDev() && reason !== "committed") {
329
- // eslint-disable-next-line no-console
330
- console.warn(`word-editor: composition ${reason} — commit policy for this path is undecided (P1.9b)`);
331
- }
332
- if (isDev() && plan.refusedReason !== undefined) {
333
- // eslint-disable-next-line no-console
334
- console.warn(`word-editor: composition commit refused (${plan.refusedReason})`);
335
- }
336
-
337
- this.phase = "idle";
338
- this._lastSealReason = plan.refusedReason ?? reason;
339
- this._snapshot = null;
340
- }
341
- }
1
+ import { isDev } from "../dev.js";
2
+ import { paragraphById, paragraphText } from "../model/node.js";
3
+ import {
4
+ containerIdOf,
5
+ createPosition,
6
+ createSelection,
7
+ isSingleParagraph,
8
+ paragraphIdOf,
9
+ selectionRange,
10
+ } from "../model/position.js";
11
+ import { createTransaction } from "../model/transaction.js";
12
+ import { textNodesOf } from "../selection/dom-map.js";
13
+ import { deleteSelection, insertText } from "../commands/text.js";
14
+
15
+ /**
16
+ * P1.9a — IME composition. Design: `docs/adr/0004-input-and-ime-strategy.md`.
17
+ * This module converts that design into code; it does not add to it. Where a
18
+ * decision is not made by the ADR (the abandon-path commit policy, §3), this
19
+ * module takes the undecided value as an injected parameter rather than
20
+ * choosing one — see `onAbandon` below.
21
+ *
22
+ * Two freezes are this module's job (ADR-0004 Karar 2, F1/F3): no step is ever
23
+ * applied while composing, and any other model-mutating path seals the
24
+ * bracket first. The third freeze, F2 ("no `Renderer.render()` while
25
+ * composing"), is NOT this module's job — it belongs to the host's dispatch
26
+ * loop, which this module has no reference to (its context is exactly
27
+ * `{getState, dispatch}`, matching `input/beforeinput.js`'s
28
+ * `BeforeInputContext`). For the same reason, "re-render from the model"
29
+ * after a refusal (ADR-0004 §2) is also a host responsibility: a refusal
30
+ * seal never changes `state.doc` (by design — F1 held), so a plain
31
+ * `render(state.doc)` call is a structural no-op against `Renderer`'s
32
+ * identity fast path (`render/renderer.js`). The host is expected to force
33
+ * one render whenever `isComposing` transitions from `true` to `false`,
34
+ * regardless of doc identity.
35
+ */
36
+
37
+ /**
38
+ * @typedef {object} CompositionSnapshot
39
+ * @property {import("../model/node.js").NodeId} containerId
40
+ * @property {import("../model/node.js").NodeId} paragraphId
41
+ * @property {number} fromOffset
42
+ * @property {number} toOffset
43
+ * @property {string} modelTextAtStart
44
+ * @property {import("../selection/dom-map.js").MappableNode} paragraphElement
45
+ */
46
+
47
+ /**
48
+ * Why a composing bracket closed.
49
+ * @typedef {"committed" | "abandoned:blur" | "abandoned:superseded" | "abandoned:preempted"} SealTrigger
50
+ */
51
+
52
+ /**
53
+ * Reads a paragraph's live text straight from its text nodes — never
54
+ * `innerHTML`, never interpreting element structure (invariant #7: whatever
55
+ * markup the IME left behind is just characters). Mirrors `textNodesOf`'s own
56
+ * "walks `parentNode`, not `closest()`" reasoning: this has to work on nodes
57
+ * the browser inserted during composition, which the model never created.
58
+ * @param {import("../selection/dom-map.js").MappableNode} paragraphElement
59
+ * @returns {string}
60
+ */
61
+ export function readParagraphText(paragraphElement) {
62
+ return textNodesOf(paragraphElement)
63
+ .map((node) => node.data ?? "")
64
+ .join("");
65
+ }
66
+
67
+ /**
68
+ * @typedef {{ ok: true, composed: string } | { ok: false, reason: string }} VerifyResult
69
+ */
70
+
71
+ /**
72
+ * The self-verifying read (ADR-0004 §2): four gates, and a failure of any one
73
+ * refuses rather than guesses. `currentModelText` is `null` when the
74
+ * paragraph the snapshot pointed at can no longer be resolved at all.
75
+ * @param {CompositionSnapshot} snapshot
76
+ * @param {string} domText
77
+ * @param {string | null} currentModelText
78
+ * @returns {VerifyResult}
79
+ */
80
+ export function verifyAndExtract(snapshot, domText, currentModelText) {
81
+ // KAPI 1 (F3 check): the model must not have moved out from under us.
82
+ if (currentModelText === null || currentModelText !== snapshot.modelTextAtStart) {
83
+ return { ok: false, reason: "model-changed" };
84
+ }
85
+
86
+ const prefix = snapshot.modelTextAtStart.slice(0, snapshot.fromOffset);
87
+ const suffix = snapshot.modelTextAtStart.slice(snapshot.toOffset);
88
+
89
+ // KAPI 2 (boundary)
90
+ if (!domText.startsWith(prefix)) {
91
+ return { ok: false, reason: "prefix-mismatch" };
92
+ }
93
+ // KAPI 3 (boundary)
94
+ if (!domText.endsWith(suffix)) {
95
+ return { ok: false, reason: "suffix-mismatch" };
96
+ }
97
+ // KAPI 4 (length) — guards the slice below from going negative when prefix
98
+ // and suffix overlap (a shorter domText than the untouched boundaries need).
99
+ if (domText.length < prefix.length + suffix.length) {
100
+ return { ok: false, reason: "length-mismatch" };
101
+ }
102
+
103
+ return { ok: true, composed: domText.slice(prefix.length, domText.length - suffix.length) };
104
+ }
105
+
106
+ /**
107
+ * @typedef {object} SealPlan
108
+ * @property {import("../model/transaction.js").Transaction | null} transaction
109
+ * @property {boolean} restoreSelection
110
+ * @property {import("../model/position.js").Selection | undefined} selectionOverride
111
+ * @property {string | undefined} refusedReason
112
+ */
113
+
114
+ /**
115
+ * Decides what a seal does, without touching `getState`/`dispatch` itself —
116
+ * this is the pure half `CompositionController` drives. Takes `onAbandon` as
117
+ * a parameter rather than reading a field, so the mechanism ("both values
118
+ * honoured") is testable independently of any chosen policy (ADR-0004 §3,
119
+ * DOĞRULANMALI #8 — the policy itself is undecided, deferred to P1.9b).
120
+ *
121
+ * @param {import("../state/editor-state.js").EditorState} state
122
+ * @param {CompositionSnapshot} snapshot
123
+ * @param {string} domText
124
+ * @param {SealTrigger} reason
125
+ * @param {"commit" | "discard"} onAbandon
126
+ * @returns {SealPlan}
127
+ */
128
+ export function planSeal(state, snapshot, domText, reason, onAbandon) {
129
+ const attemptsCommit = reason === "committed" || onAbandon === "commit";
130
+
131
+ if (!attemptsCommit) {
132
+ return { transaction: null, restoreSelection: false, selectionOverride: undefined, refusedReason: undefined };
133
+ }
134
+
135
+ // Below this point, an abandon-path attempt (onAbandon === "commit") is
136
+ // treated exactly like Yol 1's real `compositionend`: if verification
137
+ // fails, it falls through to Yol 5's refusal semantics regardless of which
138
+ // of the two triggered the attempt. Refusal is the narrower, purely
139
+ // technical condition (the DOM no longer proves what changed) and takes
140
+ // precedence over the wider abandon-policy choice — a policy decision
141
+ // should never override a safety check.
142
+
143
+ const para = paragraphById(state.doc, snapshot.paragraphId);
144
+ const currentModelText = para === null ? null : paragraphText(para);
145
+ const verified = verifyAndExtract(snapshot, domText, currentModelText);
146
+
147
+ const composingSelection = createSelection(
148
+ createPosition(snapshot.containerId, snapshot.paragraphId, snapshot.fromOffset),
149
+ createPosition(snapshot.containerId, snapshot.paragraphId, snapshot.toOffset),
150
+ );
151
+
152
+ if (!verified.ok) {
153
+ // Yol 5: a refused (or unsupported) composition never happened, as far as
154
+ // the model is concerned (F1 held it untouched) — so caret and selection
155
+ // return to exactly where they stood at `compositionstart`. If the
156
+ // paragraph itself is gone, there is nowhere sensible to put a caret;
157
+ // `state.selection` is left exactly as it is.
158
+ return {
159
+ transaction: null,
160
+ restoreSelection: para !== null,
161
+ selectionOverride: para !== null ? composingSelection : undefined,
162
+ refusedReason: verified.reason,
163
+ };
164
+ }
165
+
166
+ const pseudoState = { doc: state.doc, selection: composingSelection, storedMarks: state.storedMarks };
167
+
168
+ let transaction = null;
169
+ if (verified.composed !== "") {
170
+ // Reuses `insertText` rather than building the step directly: this is
171
+ // exactly what carries ADR-0017's left-biased `marksAt` (and, riding
172
+ // along inside `marks`, `rPrRef`) into the composed text "for free".
173
+ transaction = insertText(pseudoState, verified.composed, "ime");
174
+ } else if (snapshot.fromOffset !== snapshot.toOffset) {
175
+ transaction = deleteSelection(pseudoState, "ime");
176
+ }
177
+
178
+ return { transaction, restoreSelection: false, selectionOverride: undefined, refusedReason: undefined };
179
+ }
180
+
181
+ /**
182
+ * @typedef {object} CompositionContext
183
+ * @property {() => import("../state/editor-state.js").EditorState} getState
184
+ * @property {(tr: import("../model/transaction.js").Transaction) => void} dispatch
185
+ */
186
+
187
+ /**
188
+ * Tracks one IME composing bracket. Two rest states, `idle` and `composing`;
189
+ * `sealed(reason)` (ADR-0004 §1) is a transition, not a third rest state —
190
+ * carrying `reason` forward is what lets "closed" and "abandoned" stay
191
+ * distinguishable (ADR-0016 §1's `sealReason` reasoning, reused).
192
+ *
193
+ * Sealing is idempotent: once a bracket is sealed, a second seal call is a
194
+ * no-op. Event order across the closing paths is not guaranteed (ADR-0016
195
+ * G10 — measured on a different pair of events, same class of problem), so
196
+ * whichever seal arrives first wins and the second finds nothing open.
197
+ */
198
+ export class CompositionController {
199
+ /**
200
+ * @param {object} options
201
+ * @param {() => import("../state/editor-state.js").EditorState} options.getState
202
+ * @param {(tr: import("../model/transaction.js").Transaction) => void} options.dispatch
203
+ * @param {(id: import("../model/node.js").NodeId) => import("../selection/dom-map.js").MappableNode | null} options.lookupParagraph
204
+ * @param {"commit" | "discard"} options.onAbandon
205
+ * Not chosen by P1.9a (ADR-0004 §3) — the caller supplies it.
206
+ */
207
+ constructor({ getState, dispatch, lookupParagraph, onAbandon }) {
208
+ this._getState = getState;
209
+ this._dispatch = dispatch;
210
+ this._lookupParagraph = lookupParagraph;
211
+ this._onAbandon = onAbandon;
212
+
213
+ /** @type {"idle" | "composing"} */
214
+ this.phase = "idle";
215
+ /** @type {CompositionSnapshot | null} */
216
+ this._snapshot = null;
217
+ /** @type {string | null} */
218
+ this._lastSealReason = null;
219
+ }
220
+
221
+ /** @returns {boolean} */
222
+ get isComposing() {
223
+ return this.phase === "composing";
224
+ }
225
+
226
+ /** The reason (or refusal) the most recent seal closed with, if any. */
227
+ get lastSealReason() {
228
+ return this._lastSealReason;
229
+ }
230
+
231
+ /**
232
+ * `compositionstart`. Rejects multi-paragraph composing ranges outright
233
+ * (ADR-0004 §2 — untested arithmetic, `isSingleContainer` already makes the
234
+ * cross-container case impossible) rather than tracking them and refusing
235
+ * later.
236
+ * @returns {void}
237
+ */
238
+ handleCompositionStart() {
239
+ if (this.phase === "composing") {
240
+ this._seal("abandoned:superseded");
241
+ }
242
+
243
+ const state = this._getState();
244
+ const { selection } = state;
245
+
246
+ if (!isSingleParagraph(selection)) {
247
+ if (isDev()) {
248
+ // eslint-disable-next-line no-console
249
+ console.warn("word-editor: composition started over a multi-paragraph selection — unsupported-range, not tracked");
250
+ }
251
+ return;
252
+ }
253
+
254
+ const range = selectionRange(state.doc, selection);
255
+ if (range === null) {
256
+ return;
257
+ }
258
+ const paragraphId = paragraphIdOf(range.from);
259
+ const para = paragraphById(state.doc, paragraphId);
260
+ const paragraphElement = this._lookupParagraph(paragraphId);
261
+ if (para === null || paragraphElement === null) {
262
+ return;
263
+ }
264
+
265
+ this._snapshot = {
266
+ containerId: containerIdOf(range.from),
267
+ paragraphId,
268
+ fromOffset: range.from.offset,
269
+ toOffset: range.to.offset,
270
+ modelTextAtStart: paragraphText(para),
271
+ paragraphElement,
272
+ };
273
+ this.phase = "composing";
274
+ }
275
+
276
+ /**
277
+ * `compositionupdate` deliberately does nothing (ADR-0004 §1, F1): no
278
+ * transition, no transaction. Its absence would say nothing; this explicit
279
+ * no-op is the guarded decision itself.
280
+ * @returns {void}
281
+ */
282
+ handleCompositionUpdate() {
283
+ // Intentionally empty — see docstring.
284
+ }
285
+
286
+ /** `compositionend` — Yol 1. @returns {void} */
287
+ handleCompositionEnd() {
288
+ this._seal("committed");
289
+ }
290
+
291
+ /** `focusout` on the composing box — Yol 2. @returns {void} */
292
+ handleFocusOut() {
293
+ this._seal("abandoned:blur");
294
+ }
295
+
296
+ /**
297
+ * F3: called by the host before any other path is about to mutate the
298
+ * model — Yol 4. A no-op when nothing is composing.
299
+ * @returns {void}
300
+ */
301
+ sealForExternalMutation() {
302
+ this._seal("abandoned:preempted");
303
+ }
304
+
305
+ /**
306
+ * @param {SealTrigger} reason
307
+ * @returns {void}
308
+ */
309
+ _seal(reason) {
310
+ if (this.phase !== "composing" || this._snapshot === null) {
311
+ return; // idempotent: already sealed, or never opened
312
+ }
313
+
314
+ const snapshot = this._snapshot;
315
+ const state = this._getState();
316
+ const domText = readParagraphText(snapshot.paragraphElement);
317
+ const plan = planSeal(state, snapshot, domText, reason, this._onAbandon);
318
+
319
+ if (plan.transaction !== null) {
320
+ this._dispatch(plan.transaction);
321
+ } else if (plan.restoreSelection && plan.selectionOverride !== undefined) {
322
+ const tr = createTransaction(state.doc, state.selection, "ime")
323
+ .setSelection(plan.selectionOverride)
324
+ .build();
325
+ this._dispatch(tr);
326
+ }
327
+
328
+ if (isDev() && reason !== "committed") {
329
+ // eslint-disable-next-line no-console
330
+ console.warn(`word-editor: composition ${reason} — commit policy for this path is undecided (P1.9b)`);
331
+ }
332
+ if (isDev() && plan.refusedReason !== undefined) {
333
+ // eslint-disable-next-line no-console
334
+ console.warn(`word-editor: composition commit refused (${plan.refusedReason})`);
335
+ }
336
+
337
+ this.phase = "idle";
338
+ this._lastSealReason = plan.refusedReason ?? reason;
339
+ this._snapshot = null;
340
+ }
341
+ }