@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,433 +1,433 @@
1
- import { isDev } from "../dev.js";
2
- import { marksEqual } from "../model/node.js";
3
- import { positionsEqual, selectionsEqual } from "../model/position.js";
4
- import { applyAll } from "../model/step-apply.js";
5
- import { invertAll } from "../model/step-invert.js";
6
- import { normalizeDoc } from "../model/normalize.js";
7
- import { runsLength } from "../model/runs.js";
8
-
9
- /**
10
- * Dev-only counters, mirroring `renderStats`.
11
- *
12
- * `layoutCollapses` counts **the layout-collapse branch specifically** — not
13
- * "coalescing happened" in general. A broad counter would keep filling from the
14
- * typing-accumulate branch even if the collapse rule were deleted, so the
15
- * counter itself would go vacuous and a test asserting `> 0` would still pass
16
- * (CLAUDE.md's rule about vacuous guard counters).
17
- */
18
- export const historyStats = {
19
- layoutCollapses: 0,
20
- typingAccumulations: 0,
21
- };
22
-
23
- /** @returns {void} */
24
- export function resetHistoryStats() {
25
- historyStats.layoutCollapses = 0;
26
- historyStats.typingAccumulations = 0;
27
- }
28
-
29
- /** How long a pause breaks a run of typing into a separate undo entry. */
30
- export const COALESCE_MS = 500;
31
-
32
- /** Upper bound on entries kept. */
33
- export const DEFAULT_DEPTH_LIMIT = 200;
34
-
35
- /** Upper bound on steps merged into one entry, so a held key is still undoable. */
36
- export const MAX_COALESCED_STEPS = 200;
37
-
38
- /**
39
- * @typedef {object} HistoryEntry
40
- * @property {import("../model/step.js").Step[]} steps forward order, as applied
41
- * @property {import("../model/step.js").Step[]} inverted **application order**:
42
- * `invertAll` already returns the last step's inverse first, so undo applies
43
- * this list front to back without reversing it
44
- * @property {import("../model/position.js").Selection} selectionBefore
45
- * @property {import("../model/position.js").Selection} selectionAfter
46
- * @property {number} timestamp
47
- * @property {import("../model/transaction.js").TransactionOrigin} origin
48
- * @property {number} [gestureId] ADR-0016 §1 / ADR-0018 Karar 3 — see `Transaction.gestureId`'s own doc comment
49
- */
50
-
51
- /**
52
- * Undo/redo built on step inversion — there is no snapshot stack, per
53
- * ARCHITECTURE.md §4.
54
- *
55
- * Inverses are computed at **push time**, because `invert(step, docBefore)` needs
56
- * the document as it was before the step and that document is not retained
57
- * afterwards.
58
- */
59
- export class HistoryEngine {
60
- /**
61
- * @param {{ depthLimit?: number, coalesceMs?: number }} [options]
62
- */
63
- constructor(options = {}) {
64
- /** @type {HistoryEntry[]} */
65
- this.undoStack = [];
66
- /** @type {HistoryEntry[]} */
67
- this.redoStack = [];
68
- this.depthLimit = options.depthLimit ?? DEFAULT_DEPTH_LIMIT;
69
- this.coalesceMs = options.coalesceMs ?? COALESCE_MS;
70
- /** @type {boolean} */
71
- this.coalesceBarrier = false;
72
- }
73
-
74
- /**
75
- * Forces the next transaction to start a fresh undo entry. Intended for blur
76
- * and for a hand-moved caret.
77
- *
78
- * **Not called at composition boundaries, despite what this comment used to
79
- * claim** (ADR-0004, karar 6). A composition commits with `origin: "ime"`,
80
- * and `canCoalesce` only ever merges `input`+`input` or `pointer`+`pointer` —
81
- * so an IME entry can neither absorb the typing before it nor be absorbed by
82
- * the typing after it. The origin already enforces the break; calling this as
83
- * well would be unmeasured defence.
84
- *
85
- * Has **no callers today**. Kept because the blur and caret-move cases are
86
- * real and land with the P1.9 wiring, but nothing depends on it yet.
87
- * @returns {void}
88
- */
89
- breakCoalescing() {
90
- this.coalesceBarrier = true;
91
- }
92
-
93
- /**
94
- * Records a transaction.
95
- *
96
- * Transactions that changed nothing are ignored — a no-op Ctrl+Z is worse than
97
- * no entry at all. `origin: "history"` is ignored too: undo and redo must not
98
- * record themselves.
99
- *
100
- * @param {import("../model/transaction.js").Transaction} tr
101
- * @returns {void}
102
- */
103
- push(tr) {
104
- if (tr.origin === "history" || tr.steps.length === 0) {
105
- return;
106
- }
107
-
108
- this.redoStack.length = 0;
109
-
110
- const previous = this.undoStack[this.undoStack.length - 1];
111
- if (!this.coalesceBarrier && previous !== undefined && this.canCoalesce(previous, tr)) {
112
- if (tr.steps.length > 1) {
113
- // ADR-0018 Karar 3: a multi-step pointer frame (e.g. a multi-box
114
- // drag) COLLAPSES exactly like the single-step case below, just
115
- // replacing the previous entry's TRAILING N steps instead of only
116
- // its last one — `canCoalesce` already confirmed via
117
- // `sameLayoutFrame` that they pairwise match. `inverted` is left
118
- // completely untouched, same reasoning as the single-step COLLAPSE
119
- // comment below: it was computed against the pre-drag document on
120
- // this gesture's FIRST push and stays correct no matter how many
121
- // frames replace `steps` afterward.
122
- previous.steps.splice(previous.steps.length - tr.steps.length, tr.steps.length, ...tr.steps);
123
- if (isDev()) {
124
- historyStats.layoutCollapses += 1;
125
- }
126
- previous.selectionAfter = tr.selectionAfter;
127
- previous.timestamp = tr.timestamp;
128
- return;
129
- }
130
-
131
- const last = previous.steps[previous.steps.length - 1];
132
- const next = tr.steps[0];
133
-
134
- if (
135
- last !== undefined &&
136
- next !== undefined &&
137
- last.type === "setLayout" &&
138
- next.type === "setLayout"
139
- ) {
140
- // COLLAPSE, not accumulate: a drag calls this once per pointer move —
141
- // 200 mousemoves would otherwise mean 200 stored steps and 200 stored
142
- // inverses. Replacing the last forward step with the newest one keeps
143
- // `steps` correct for redo (it always reflects the drag's final
144
- // geometry), while `inverted` is left completely untouched.
145
- //
146
- // That is safe, not accidental: the FIRST push of this drag already
147
- // computed `inverted` against the document as it looked *before the
148
- // drag started* (HistoryEngine.push runs `invertAll` at push time,
149
- // while that pre-drag document is still in hand). Touching `inverted`
150
- // here — the way the accumulate branch below does for typing — would
151
- // make undo land on the second-to-last mousemove frame instead of
152
- // where the drag began, which is the exact bug ADR-0007 calls out.
153
- previous.steps[previous.steps.length - 1] = next;
154
- if (isDev()) {
155
- historyStats.layoutCollapses += 1;
156
- }
157
- } else {
158
- previous.steps.push(...tr.steps);
159
- // The newly appended steps ran last, so their inverses must run *first*.
160
- previous.inverted.unshift(...invertAll(tr.docBefore, tr.steps));
161
- if (isDev()) {
162
- historyStats.typingAccumulations += 1;
163
- }
164
- }
165
-
166
- previous.selectionAfter = tr.selectionAfter;
167
- previous.timestamp = tr.timestamp;
168
- return;
169
- }
170
-
171
- this.coalesceBarrier = false;
172
- this.undoStack.push({
173
- steps: tr.steps.slice(),
174
- inverted: invertAll(tr.docBefore, tr.steps),
175
- selectionBefore: tr.selectionBefore,
176
- selectionAfter: tr.selectionAfter,
177
- timestamp: tr.timestamp,
178
- origin: tr.origin,
179
- gestureId: tr.gestureId,
180
- });
181
-
182
- if (this.undoStack.length > this.depthLimit) {
183
- this.undoStack.shift();
184
- }
185
- }
186
-
187
- /**
188
- * Merge this transaction into the previous entry? Conditions 2–4 are generic;
189
- * which type-specific rule (5a/5b/5c) applies depends on the origin the two
190
- * transactions share — so that "merhaba" is one Ctrl+Z but "merhaba", pause,
191
- * "dünya" is two, and a whole box drag is one Ctrl+Z but a drag, a pause, a
192
- * second drag is two.
193
- *
194
- * @param {HistoryEntry} previous
195
- * @param {import("../model/transaction.js").Transaction} tr
196
- * @returns {boolean}
197
- */
198
- canCoalesce(previous, tr) {
199
- // `gestureId` matching on BOTH sides (never just one — undefined counts
200
- // as "not set", not as a wildcard) means the two transactions belong to
201
- // the SAME open pointer-gesture bracket (ADR-0016 §1, ADR-0018 Karar
202
- // 3). `undefined === undefined` (every transaction built before a
203
- // `CanvasController` exists, this file's own tests included) does NOT
204
- // count as a real match — those fall through to today's exact
205
- // behavior, unchanged.
206
- const pointerGestureMatch =
207
- previous.origin === "pointer" && tr.origin === "pointer" && previous.gestureId === tr.gestureId;
208
- const realGestureMatch = pointerGestureMatch && previous.gestureId !== undefined;
209
-
210
- // 2. Within the idle window. Compared by timestamp, never by a timer, so
211
- // tests stay deterministic. BYPASSED only for a real gesture-bracket
212
- // match: once a controller exists, the bracket's OWN (much larger,
213
- // separately injected) idle-abandon threshold is what decides when
214
- // two frames stop belonging to the same gesture — not this fixed
215
- // 500ms window. This is exactly why a nudge's measured first-repeat
216
- // delay (501-564ms, ADR-0016 §4) can exceed COALESCE_MS without
217
- // splitting: repeats of ONE held key share a single gestureId.
218
- if (!realGestureMatch && tr.timestamp - previous.timestamp >= this.coalesceMs) {
219
- return false;
220
- }
221
- // 3. Bounded, so holding a key down (or a very long drag) still leaves
222
- // usable undo granularity.
223
- if (previous.steps.length + tr.steps.length > MAX_COALESCED_STEPS) {
224
- return false;
225
- }
226
- // 4. The user did not move the caret, or drop the box, in between.
227
- if (!selectionsEqual(previous.selectionAfter, tr.selectionBefore)) {
228
- return false;
229
- }
230
-
231
- // 1 + 5c + gestureId (ADR-0018 Karar 3). A box drag OR a keyboard nudge
232
- // — both origin "pointer": a multi-step frame (a multi-box drag) is
233
- // matched pairwise against the previous entry's own TRAILING N steps
234
- // via `sameLayoutFrame`, not just its last one.
235
- if (previous.origin === "pointer" && tr.origin === "pointer") {
236
- if (!pointerGestureMatch) {
237
- return false;
238
- }
239
- if (tr.steps.length > 1) {
240
- return sameLayoutFrame(previous.steps, tr.steps);
241
- }
242
- const last = previous.steps[previous.steps.length - 1];
243
- const next = tr.steps[0];
244
- if (last === undefined || next === undefined) {
245
- return false;
246
- }
247
- // A box drag: consecutive `SetLayoutStep`s from `commands/box.js`'s
248
- // `moveBox`/`resizeBox`, same container, same patched keys (`{x,y}`
249
- // for a move, `{cx,cy}` for a resize — never mixed, so a move drag
250
- // can never collapse into a resize drag or vice versa). This is what
251
- // `push()` acts on to COLLAPSE rather than accumulate (see the
252
- // comment there for why that is safe).
253
- if (last.type === "setLayout" && next.type === "setLayout") {
254
- return last.containerId === next.containerId && sameKeySet(last.patch, next.patch);
255
- }
256
- return false;
257
- }
258
-
259
- const last = previous.steps[previous.steps.length - 1];
260
- const next = tr.steps[0];
261
- if (tr.steps.length !== 1 || last === undefined || next === undefined) {
262
- return false;
263
- }
264
-
265
- // 1 + 5a/5b. Only ordinary typing coalesces this way — never paste,
266
- // formatting, IME, or a box drag.
267
- if (previous.origin === "input" && tr.origin === "input") {
268
- // 5a. Forward typing: single-character inserts, contiguous, same marks.
269
- if (last.type === "insert" && next.type === "insert") {
270
- return (
271
- isSingleCharacterInsert(last) &&
272
- isSingleCharacterInsert(next) &&
273
- positionsEqual(
274
- { path: last.pos.path, offset: last.pos.offset + runsLength(last.runs) },
275
- next.pos,
276
- ) &&
277
- marksEqual(last.runs[0].marks, next.runs[0].marks)
278
- );
279
- }
280
-
281
- // 5b. A run of backspaces: each deletion ends where the previous began.
282
- if (last.type === "delete" && next.type === "delete") {
283
- return positionsEqual(next.to, last.from);
284
- }
285
-
286
- return false;
287
- }
288
-
289
- return false;
290
- }
291
-
292
- /** @returns {boolean} */
293
- get canUndo() {
294
- return this.undoStack.length > 0;
295
- }
296
-
297
- /** @returns {boolean} */
298
- get canRedo() {
299
- return this.redoStack.length > 0;
300
- }
301
-
302
- /**
303
- * Undoes one entry. Restores the selection too — that is most of what makes
304
- * undo feel correct rather than merely accurate.
305
- * @param {import("../state/editor-state.js").EditorState} state
306
- * @returns {import("../model/transaction.js").Transaction | null}
307
- */
308
- undo(state) {
309
- const entry = this.undoStack.pop();
310
- if (entry === undefined) {
311
- return null;
312
- }
313
-
314
- const inverted = entry.inverted.slice();
315
- const doc = normalizeDoc(applyAll(state.doc, inverted));
316
-
317
- this.redoStack.push(entry);
318
- this.coalesceBarrier = true;
319
-
320
- return {
321
- steps: inverted,
322
- docBefore: state.doc,
323
- doc,
324
- selectionBefore: state.selection,
325
- selectionAfter: entry.selectionBefore,
326
- origin: "history",
327
- timestamp: Date.now(),
328
- storedMarks: null,
329
- };
330
- }
331
-
332
- /**
333
- * @param {import("../state/editor-state.js").EditorState} state
334
- * @returns {import("../model/transaction.js").Transaction | null}
335
- */
336
- redo(state) {
337
- const entry = this.redoStack.pop();
338
- if (entry === undefined) {
339
- return null;
340
- }
341
-
342
- const doc = normalizeDoc(applyAll(state.doc, entry.steps));
343
- this.undoStack.push(entry);
344
- this.coalesceBarrier = true;
345
-
346
- return {
347
- steps: entry.steps.slice(),
348
- docBefore: state.doc,
349
- doc,
350
- selectionBefore: state.selection,
351
- selectionAfter: entry.selectionAfter,
352
- origin: "history",
353
- timestamp: Date.now(),
354
- storedMarks: null,
355
- };
356
- }
357
-
358
- /** @returns {void} */
359
- clear() {
360
- this.undoStack.length = 0;
361
- this.redoStack.length = 0;
362
- this.coalesceBarrier = false;
363
- }
364
- }
365
-
366
- /**
367
- * @param {import("../model/step.js").InsertStep} step
368
- * @returns {boolean}
369
- */
370
- function isSingleCharacterInsert(step) {
371
- return step.runs.length === 1 && [...step.runs[0].text].length === 1;
372
- }
373
-
374
- /**
375
- * Whether two layout patches touch exactly the same set of keys, order
376
- * ignored. Used to keep a move drag (`{x,y}`) from ever collapsing into a
377
- * resize drag (`{cx,cy}`) that happens to follow it within the idle window.
378
- * @param {Partial<import("../model/node.js").BoxLayout>} a
379
- * @param {Partial<import("../model/node.js").BoxLayout>} b
380
- * @returns {boolean}
381
- */
382
- export function sameKeySet(a, b) {
383
- const aKeys = Object.keys(a);
384
- const bKeys = Object.keys(b);
385
- if (aKeys.length !== bKeys.length) {
386
- return false;
387
- }
388
- return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key));
389
- }
390
-
391
- /**
392
- * Whether `steps` (a candidate transaction's forward steps) pairwise match
393
- * `previousSteps`' own TRAILING N steps, on `(containerId, sameKeySet)` —
394
- * ADR-0018 Karar 3's multi-step generalization of the single-step pointer
395
- * check `canCoalesce` already made for one box. Order matters: `steps[i]`
396
- * is compared against `previousSteps[previousSteps.length - steps.length + i]`,
397
- * never re-sorted — a caller that reorders its own entries between frames
398
- * would (correctly) see this reject, since `HistoryEngine` matches frames by
399
- * INDEX, not by a `containerId` lookup (a reordered frame would otherwise
400
- * silently coalesce one box's step into a different box's slot).
401
- *
402
- * Every element on both sides must be a `SetLayoutStep` — this is a
403
- * pointer-drag concept only, never mixed with typing/paste/IME steps.
404
- *
405
- * For `steps.length === 1` this asks EXACTLY the question `canCoalesce`'s
406
- * own single-step branch already asks (same `containerId`, same key set
407
- * against the previous entry's LAST step) — `canCoalesce` does not actually
408
- * route through this function for that case (it keeps its original,
409
- * byte-identical single-step code so HIST2/HIST3's mutations keep their
410
- * exact anchor text), but the EQUIVALENCE is proven as a property in this
411
- * function's own test file, not just argued.
412
- *
413
- * @param {import("../model/step.js").Step[]} previousSteps
414
- * @param {import("../model/step.js").Step[]} steps
415
- * @returns {boolean}
416
- */
417
- export function sameLayoutFrame(previousSteps, steps) {
418
- if (steps.length === 0 || steps.length > previousSteps.length) {
419
- return false;
420
- }
421
- const offset = previousSteps.length - steps.length;
422
- for (let i = 0; i < steps.length; i += 1) {
423
- const prev = previousSteps[offset + i];
424
- const next = steps[i];
425
- if (prev.type !== "setLayout" || next.type !== "setLayout") {
426
- return false;
427
- }
428
- if (prev.containerId !== next.containerId || !sameKeySet(prev.patch, next.patch)) {
429
- return false;
430
- }
431
- }
432
- return true;
433
- }
1
+ import { isDev } from "../dev.js";
2
+ import { marksEqual } from "../model/node.js";
3
+ import { positionsEqual, selectionsEqual } from "../model/position.js";
4
+ import { applyAll } from "../model/step-apply.js";
5
+ import { invertAll } from "../model/step-invert.js";
6
+ import { normalizeDoc } from "../model/normalize.js";
7
+ import { runsLength } from "../model/runs.js";
8
+
9
+ /**
10
+ * Dev-only counters, mirroring `renderStats`.
11
+ *
12
+ * `layoutCollapses` counts **the layout-collapse branch specifically** — not
13
+ * "coalescing happened" in general. A broad counter would keep filling from the
14
+ * typing-accumulate branch even if the collapse rule were deleted, so the
15
+ * counter itself would go vacuous and a test asserting `> 0` would still pass
16
+ * (CLAUDE.md's rule about vacuous guard counters).
17
+ */
18
+ export const historyStats = {
19
+ layoutCollapses: 0,
20
+ typingAccumulations: 0,
21
+ };
22
+
23
+ /** @returns {void} */
24
+ export function resetHistoryStats() {
25
+ historyStats.layoutCollapses = 0;
26
+ historyStats.typingAccumulations = 0;
27
+ }
28
+
29
+ /** How long a pause breaks a run of typing into a separate undo entry. */
30
+ export const COALESCE_MS = 500;
31
+
32
+ /** Upper bound on entries kept. */
33
+ export const DEFAULT_DEPTH_LIMIT = 200;
34
+
35
+ /** Upper bound on steps merged into one entry, so a held key is still undoable. */
36
+ export const MAX_COALESCED_STEPS = 200;
37
+
38
+ /**
39
+ * @typedef {object} HistoryEntry
40
+ * @property {import("../model/step.js").Step[]} steps forward order, as applied
41
+ * @property {import("../model/step.js").Step[]} inverted **application order**:
42
+ * `invertAll` already returns the last step's inverse first, so undo applies
43
+ * this list front to back without reversing it
44
+ * @property {import("../model/position.js").Selection} selectionBefore
45
+ * @property {import("../model/position.js").Selection} selectionAfter
46
+ * @property {number} timestamp
47
+ * @property {import("../model/transaction.js").TransactionOrigin} origin
48
+ * @property {number} [gestureId] ADR-0016 §1 / ADR-0018 Karar 3 — see `Transaction.gestureId`'s own doc comment
49
+ */
50
+
51
+ /**
52
+ * Undo/redo built on step inversion — there is no snapshot stack, per
53
+ * ARCHITECTURE.md §4.
54
+ *
55
+ * Inverses are computed at **push time**, because `invert(step, docBefore)` needs
56
+ * the document as it was before the step and that document is not retained
57
+ * afterwards.
58
+ */
59
+ export class HistoryEngine {
60
+ /**
61
+ * @param {{ depthLimit?: number, coalesceMs?: number }} [options]
62
+ */
63
+ constructor(options = {}) {
64
+ /** @type {HistoryEntry[]} */
65
+ this.undoStack = [];
66
+ /** @type {HistoryEntry[]} */
67
+ this.redoStack = [];
68
+ this.depthLimit = options.depthLimit ?? DEFAULT_DEPTH_LIMIT;
69
+ this.coalesceMs = options.coalesceMs ?? COALESCE_MS;
70
+ /** @type {boolean} */
71
+ this.coalesceBarrier = false;
72
+ }
73
+
74
+ /**
75
+ * Forces the next transaction to start a fresh undo entry. Intended for blur
76
+ * and for a hand-moved caret.
77
+ *
78
+ * **Not called at composition boundaries, despite what this comment used to
79
+ * claim** (ADR-0004, karar 6). A composition commits with `origin: "ime"`,
80
+ * and `canCoalesce` only ever merges `input`+`input` or `pointer`+`pointer` —
81
+ * so an IME entry can neither absorb the typing before it nor be absorbed by
82
+ * the typing after it. The origin already enforces the break; calling this as
83
+ * well would be unmeasured defence.
84
+ *
85
+ * Has **no callers today**. Kept because the blur and caret-move cases are
86
+ * real and land with the P1.9 wiring, but nothing depends on it yet.
87
+ * @returns {void}
88
+ */
89
+ breakCoalescing() {
90
+ this.coalesceBarrier = true;
91
+ }
92
+
93
+ /**
94
+ * Records a transaction.
95
+ *
96
+ * Transactions that changed nothing are ignored — a no-op Ctrl+Z is worse than
97
+ * no entry at all. `origin: "history"` is ignored too: undo and redo must not
98
+ * record themselves.
99
+ *
100
+ * @param {import("../model/transaction.js").Transaction} tr
101
+ * @returns {void}
102
+ */
103
+ push(tr) {
104
+ if (tr.origin === "history" || tr.steps.length === 0) {
105
+ return;
106
+ }
107
+
108
+ this.redoStack.length = 0;
109
+
110
+ const previous = this.undoStack[this.undoStack.length - 1];
111
+ if (!this.coalesceBarrier && previous !== undefined && this.canCoalesce(previous, tr)) {
112
+ if (tr.steps.length > 1) {
113
+ // ADR-0018 Karar 3: a multi-step pointer frame (e.g. a multi-box
114
+ // drag) COLLAPSES exactly like the single-step case below, just
115
+ // replacing the previous entry's TRAILING N steps instead of only
116
+ // its last one — `canCoalesce` already confirmed via
117
+ // `sameLayoutFrame` that they pairwise match. `inverted` is left
118
+ // completely untouched, same reasoning as the single-step COLLAPSE
119
+ // comment below: it was computed against the pre-drag document on
120
+ // this gesture's FIRST push and stays correct no matter how many
121
+ // frames replace `steps` afterward.
122
+ previous.steps.splice(previous.steps.length - tr.steps.length, tr.steps.length, ...tr.steps);
123
+ if (isDev()) {
124
+ historyStats.layoutCollapses += 1;
125
+ }
126
+ previous.selectionAfter = tr.selectionAfter;
127
+ previous.timestamp = tr.timestamp;
128
+ return;
129
+ }
130
+
131
+ const last = previous.steps[previous.steps.length - 1];
132
+ const next = tr.steps[0];
133
+
134
+ if (
135
+ last !== undefined &&
136
+ next !== undefined &&
137
+ last.type === "setLayout" &&
138
+ next.type === "setLayout"
139
+ ) {
140
+ // COLLAPSE, not accumulate: a drag calls this once per pointer move —
141
+ // 200 mousemoves would otherwise mean 200 stored steps and 200 stored
142
+ // inverses. Replacing the last forward step with the newest one keeps
143
+ // `steps` correct for redo (it always reflects the drag's final
144
+ // geometry), while `inverted` is left completely untouched.
145
+ //
146
+ // That is safe, not accidental: the FIRST push of this drag already
147
+ // computed `inverted` against the document as it looked *before the
148
+ // drag started* (HistoryEngine.push runs `invertAll` at push time,
149
+ // while that pre-drag document is still in hand). Touching `inverted`
150
+ // here — the way the accumulate branch below does for typing — would
151
+ // make undo land on the second-to-last mousemove frame instead of
152
+ // where the drag began, which is the exact bug ADR-0007 calls out.
153
+ previous.steps[previous.steps.length - 1] = next;
154
+ if (isDev()) {
155
+ historyStats.layoutCollapses += 1;
156
+ }
157
+ } else {
158
+ previous.steps.push(...tr.steps);
159
+ // The newly appended steps ran last, so their inverses must run *first*.
160
+ previous.inverted.unshift(...invertAll(tr.docBefore, tr.steps));
161
+ if (isDev()) {
162
+ historyStats.typingAccumulations += 1;
163
+ }
164
+ }
165
+
166
+ previous.selectionAfter = tr.selectionAfter;
167
+ previous.timestamp = tr.timestamp;
168
+ return;
169
+ }
170
+
171
+ this.coalesceBarrier = false;
172
+ this.undoStack.push({
173
+ steps: tr.steps.slice(),
174
+ inverted: invertAll(tr.docBefore, tr.steps),
175
+ selectionBefore: tr.selectionBefore,
176
+ selectionAfter: tr.selectionAfter,
177
+ timestamp: tr.timestamp,
178
+ origin: tr.origin,
179
+ gestureId: tr.gestureId,
180
+ });
181
+
182
+ if (this.undoStack.length > this.depthLimit) {
183
+ this.undoStack.shift();
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Merge this transaction into the previous entry? Conditions 2–4 are generic;
189
+ * which type-specific rule (5a/5b/5c) applies depends on the origin the two
190
+ * transactions share — so that "merhaba" is one Ctrl+Z but "merhaba", pause,
191
+ * "dünya" is two, and a whole box drag is one Ctrl+Z but a drag, a pause, a
192
+ * second drag is two.
193
+ *
194
+ * @param {HistoryEntry} previous
195
+ * @param {import("../model/transaction.js").Transaction} tr
196
+ * @returns {boolean}
197
+ */
198
+ canCoalesce(previous, tr) {
199
+ // `gestureId` matching on BOTH sides (never just one — undefined counts
200
+ // as "not set", not as a wildcard) means the two transactions belong to
201
+ // the SAME open pointer-gesture bracket (ADR-0016 §1, ADR-0018 Karar
202
+ // 3). `undefined === undefined` (every transaction built before a
203
+ // `CanvasController` exists, this file's own tests included) does NOT
204
+ // count as a real match — those fall through to today's exact
205
+ // behavior, unchanged.
206
+ const pointerGestureMatch =
207
+ previous.origin === "pointer" && tr.origin === "pointer" && previous.gestureId === tr.gestureId;
208
+ const realGestureMatch = pointerGestureMatch && previous.gestureId !== undefined;
209
+
210
+ // 2. Within the idle window. Compared by timestamp, never by a timer, so
211
+ // tests stay deterministic. BYPASSED only for a real gesture-bracket
212
+ // match: once a controller exists, the bracket's OWN (much larger,
213
+ // separately injected) idle-abandon threshold is what decides when
214
+ // two frames stop belonging to the same gesture — not this fixed
215
+ // 500ms window. This is exactly why a nudge's measured first-repeat
216
+ // delay (501-564ms, ADR-0016 §4) can exceed COALESCE_MS without
217
+ // splitting: repeats of ONE held key share a single gestureId.
218
+ if (!realGestureMatch && tr.timestamp - previous.timestamp >= this.coalesceMs) {
219
+ return false;
220
+ }
221
+ // 3. Bounded, so holding a key down (or a very long drag) still leaves
222
+ // usable undo granularity.
223
+ if (previous.steps.length + tr.steps.length > MAX_COALESCED_STEPS) {
224
+ return false;
225
+ }
226
+ // 4. The user did not move the caret, or drop the box, in between.
227
+ if (!selectionsEqual(previous.selectionAfter, tr.selectionBefore)) {
228
+ return false;
229
+ }
230
+
231
+ // 1 + 5c + gestureId (ADR-0018 Karar 3). A box drag OR a keyboard nudge
232
+ // — both origin "pointer": a multi-step frame (a multi-box drag) is
233
+ // matched pairwise against the previous entry's own TRAILING N steps
234
+ // via `sameLayoutFrame`, not just its last one.
235
+ if (previous.origin === "pointer" && tr.origin === "pointer") {
236
+ if (!pointerGestureMatch) {
237
+ return false;
238
+ }
239
+ if (tr.steps.length > 1) {
240
+ return sameLayoutFrame(previous.steps, tr.steps);
241
+ }
242
+ const last = previous.steps[previous.steps.length - 1];
243
+ const next = tr.steps[0];
244
+ if (last === undefined || next === undefined) {
245
+ return false;
246
+ }
247
+ // A box drag: consecutive `SetLayoutStep`s from `commands/box.js`'s
248
+ // `moveBox`/`resizeBox`, same container, same patched keys (`{x,y}`
249
+ // for a move, `{cx,cy}` for a resize — never mixed, so a move drag
250
+ // can never collapse into a resize drag or vice versa). This is what
251
+ // `push()` acts on to COLLAPSE rather than accumulate (see the
252
+ // comment there for why that is safe).
253
+ if (last.type === "setLayout" && next.type === "setLayout") {
254
+ return last.containerId === next.containerId && sameKeySet(last.patch, next.patch);
255
+ }
256
+ return false;
257
+ }
258
+
259
+ const last = previous.steps[previous.steps.length - 1];
260
+ const next = tr.steps[0];
261
+ if (tr.steps.length !== 1 || last === undefined || next === undefined) {
262
+ return false;
263
+ }
264
+
265
+ // 1 + 5a/5b. Only ordinary typing coalesces this way — never paste,
266
+ // formatting, IME, or a box drag.
267
+ if (previous.origin === "input" && tr.origin === "input") {
268
+ // 5a. Forward typing: single-character inserts, contiguous, same marks.
269
+ if (last.type === "insert" && next.type === "insert") {
270
+ return (
271
+ isSingleCharacterInsert(last) &&
272
+ isSingleCharacterInsert(next) &&
273
+ positionsEqual(
274
+ { path: last.pos.path, offset: last.pos.offset + runsLength(last.runs) },
275
+ next.pos,
276
+ ) &&
277
+ marksEqual(last.runs[0].marks, next.runs[0].marks)
278
+ );
279
+ }
280
+
281
+ // 5b. A run of backspaces: each deletion ends where the previous began.
282
+ if (last.type === "delete" && next.type === "delete") {
283
+ return positionsEqual(next.to, last.from);
284
+ }
285
+
286
+ return false;
287
+ }
288
+
289
+ return false;
290
+ }
291
+
292
+ /** @returns {boolean} */
293
+ get canUndo() {
294
+ return this.undoStack.length > 0;
295
+ }
296
+
297
+ /** @returns {boolean} */
298
+ get canRedo() {
299
+ return this.redoStack.length > 0;
300
+ }
301
+
302
+ /**
303
+ * Undoes one entry. Restores the selection too — that is most of what makes
304
+ * undo feel correct rather than merely accurate.
305
+ * @param {import("../state/editor-state.js").EditorState} state
306
+ * @returns {import("../model/transaction.js").Transaction | null}
307
+ */
308
+ undo(state) {
309
+ const entry = this.undoStack.pop();
310
+ if (entry === undefined) {
311
+ return null;
312
+ }
313
+
314
+ const inverted = entry.inverted.slice();
315
+ const doc = normalizeDoc(applyAll(state.doc, inverted));
316
+
317
+ this.redoStack.push(entry);
318
+ this.coalesceBarrier = true;
319
+
320
+ return {
321
+ steps: inverted,
322
+ docBefore: state.doc,
323
+ doc,
324
+ selectionBefore: state.selection,
325
+ selectionAfter: entry.selectionBefore,
326
+ origin: "history",
327
+ timestamp: Date.now(),
328
+ storedMarks: null,
329
+ };
330
+ }
331
+
332
+ /**
333
+ * @param {import("../state/editor-state.js").EditorState} state
334
+ * @returns {import("../model/transaction.js").Transaction | null}
335
+ */
336
+ redo(state) {
337
+ const entry = this.redoStack.pop();
338
+ if (entry === undefined) {
339
+ return null;
340
+ }
341
+
342
+ const doc = normalizeDoc(applyAll(state.doc, entry.steps));
343
+ this.undoStack.push(entry);
344
+ this.coalesceBarrier = true;
345
+
346
+ return {
347
+ steps: entry.steps.slice(),
348
+ docBefore: state.doc,
349
+ doc,
350
+ selectionBefore: state.selection,
351
+ selectionAfter: entry.selectionAfter,
352
+ origin: "history",
353
+ timestamp: Date.now(),
354
+ storedMarks: null,
355
+ };
356
+ }
357
+
358
+ /** @returns {void} */
359
+ clear() {
360
+ this.undoStack.length = 0;
361
+ this.redoStack.length = 0;
362
+ this.coalesceBarrier = false;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * @param {import("../model/step.js").InsertStep} step
368
+ * @returns {boolean}
369
+ */
370
+ function isSingleCharacterInsert(step) {
371
+ return step.runs.length === 1 && [...step.runs[0].text].length === 1;
372
+ }
373
+
374
+ /**
375
+ * Whether two layout patches touch exactly the same set of keys, order
376
+ * ignored. Used to keep a move drag (`{x,y}`) from ever collapsing into a
377
+ * resize drag (`{cx,cy}`) that happens to follow it within the idle window.
378
+ * @param {Partial<import("../model/node.js").BoxLayout>} a
379
+ * @param {Partial<import("../model/node.js").BoxLayout>} b
380
+ * @returns {boolean}
381
+ */
382
+ export function sameKeySet(a, b) {
383
+ const aKeys = Object.keys(a);
384
+ const bKeys = Object.keys(b);
385
+ if (aKeys.length !== bKeys.length) {
386
+ return false;
387
+ }
388
+ return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key));
389
+ }
390
+
391
+ /**
392
+ * Whether `steps` (a candidate transaction's forward steps) pairwise match
393
+ * `previousSteps`' own TRAILING N steps, on `(containerId, sameKeySet)` —
394
+ * ADR-0018 Karar 3's multi-step generalization of the single-step pointer
395
+ * check `canCoalesce` already made for one box. Order matters: `steps[i]`
396
+ * is compared against `previousSteps[previousSteps.length - steps.length + i]`,
397
+ * never re-sorted — a caller that reorders its own entries between frames
398
+ * would (correctly) see this reject, since `HistoryEngine` matches frames by
399
+ * INDEX, not by a `containerId` lookup (a reordered frame would otherwise
400
+ * silently coalesce one box's step into a different box's slot).
401
+ *
402
+ * Every element on both sides must be a `SetLayoutStep` — this is a
403
+ * pointer-drag concept only, never mixed with typing/paste/IME steps.
404
+ *
405
+ * For `steps.length === 1` this asks EXACTLY the question `canCoalesce`'s
406
+ * own single-step branch already asks (same `containerId`, same key set
407
+ * against the previous entry's LAST step) — `canCoalesce` does not actually
408
+ * route through this function for that case (it keeps its original,
409
+ * byte-identical single-step code so HIST2/HIST3's mutations keep their
410
+ * exact anchor text), but the EQUIVALENCE is proven as a property in this
411
+ * function's own test file, not just argued.
412
+ *
413
+ * @param {import("../model/step.js").Step[]} previousSteps
414
+ * @param {import("../model/step.js").Step[]} steps
415
+ * @returns {boolean}
416
+ */
417
+ export function sameLayoutFrame(previousSteps, steps) {
418
+ if (steps.length === 0 || steps.length > previousSteps.length) {
419
+ return false;
420
+ }
421
+ const offset = previousSteps.length - steps.length;
422
+ for (let i = 0; i < steps.length; i += 1) {
423
+ const prev = previousSteps[offset + i];
424
+ const next = steps[i];
425
+ if (prev.type !== "setLayout" || next.type !== "setLayout") {
426
+ return false;
427
+ }
428
+ if (prev.containerId !== next.containerId || !sameKeySet(prev.patch, next.patch)) {
429
+ return false;
430
+ }
431
+ }
432
+ return true;
433
+ }