@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,289 +1,289 @@
1
- // The pointer-gesture bracket: idle -> open -> sealed(reason) (ADR-0016 §1,
2
- // measured in two engines). No src/, DOM, or history import — pure data in,
3
- // data out, same discipline as every other file in this directory.
4
- //
5
- // ⚠ SENTETİK DİZİLER KANIT DEĞİL (test/input/composition.test.js's own
6
- // header, restated here for the same reason): this file's own tests drive
7
- // hand-written event sequences through these functions. That proves the
8
- // state machine behaves correctly GIVEN that sequence. It does NOT prove a
9
- // real browser produces that sequence — the real evidence is
10
- // docs/adr/0016-gesture-bracket-and-accumulative-invert.md's G1-G10
11
- // measurements, and ADR-0018 §10.2 is explicit that multi-selection
12
- // dragging (Ö5) never reached the probe at all.
13
- //
14
- // MEASURED RULES this module encodes (ADR-0016, two engines — Chrome +
15
- // Firefox):
16
- // - `pointerup` closes the bracket, but MAY NOT ARRIVE (Chrome 6/17,
17
- // Firefox 1/4 — right-click mid-drag). The one reliable backup is a
18
- // NEW `pointerdown` on the SAME `pointerId` (G2) — pointerId's own
19
- // VALUE is engine-dependent (Chrome always 1, Firefox always 0), so
20
- // this file only ever compares two observed values for EQUALITY, never
21
- // against a hardcoded constant.
22
- // - `lostpointercapture` is NOT a seal signal (G3): 5/6 orphaned gestures
23
- // never got it at all, and the one that did got it mid-gesture without
24
- // closing.
25
- // - `focusout`/`blur` are NOT seal signals (G4/G6): 20/20 focusout events
26
- // fired with `hasFocus: true` still true, and 3/4 visibilitychange
27
- // events during an open gesture still closed normally via pointerup.
28
- // - Capture loss is not gesture end, only a NOTE (G5) — Blink-specific
29
- // (14/14 Firefox gestures never showed it at all).
30
- // - Sealing is IDEMPOTENT / order-tolerant: G10 (Firefox) found a key's
31
- // own delayed signal can arrive out of the order a naive implementation
32
- // would assume — nudge.js's `keyUpSealsOpenBracket` carries the same
33
- // discipline for the keyboard bracket.
34
-
35
- import { movePatch, resizePatch } from "./resize.js";
36
-
37
- /**
38
- * @typedef {"closed" | "abandoned:superseded" | "abandoned:timeout"} SealReason
39
- *
40
- * Terk (abandon) kapanıştan (closed) AYIRT EDİLEBİLİR tutulur (ADR-0016 §1):
41
- * a mutation that let an abandoned gesture read back as "closed" would make
42
- * "the bracket abandons cleanly" impossible to assert — it would only ever
43
- * be TOLERATED, never verified.
44
- */
45
-
46
- /**
47
- * @typedef {object} Gesture
48
- * @property {number} gestureId minted by the caller's IdFactory, monotonic, never reused after sealing
49
- * @property {number} pointerId compared by EQUALITY only — never a hardcoded engine constant
50
- * @property {number} lastEventAt timestamp of the most recent event this gesture's clock cares about
51
- * @property {boolean} paused true while the page is hidden/unfocused — `isIdleAbandoned` does not advance the clock
52
- * @property {boolean} captureLostDuringGesture note only, never a seal (path 4)
53
- * @property {boolean} pageHiddenDuringGesture note only, never a seal (path 5)
54
- */
55
-
56
- /**
57
- * @typedef {object} GestureEvent
58
- * @property {"pointerdown" | "pointerup" | "pointercancel" | "pointermove" | "lostpointercapture" | "visibilitychange" | "blur" | "focus"} type
59
- * @property {number} [pointerId] absent for visibilitychange/blur/focus
60
- * @property {number} timestamp
61
- * @property {boolean} [hidden] visibilitychange only: `document.hidden`
62
- * @property {number} [gestureId] pointerdown only: pre-minted id for the gesture this event WOULD open
63
- */
64
-
65
- /**
66
- * @typedef {object} GesturePlan
67
- * @property {Gesture | null} gesture the gesture state AFTER this event; `null` once idle
68
- * @property {SealReason | null} sealReason what happened to the PREVIOUS (pre-event) gesture this call, if anything
69
- */
70
-
71
- /**
72
- * Whether `event`, against the CURRENTLY open `gesture`, closes or
73
- * supersedes it (ADR-0016 §1 paths 1 and 2 only — path 3's timeout is
74
- * time-based, not event-based, and lives in `isIdleAbandoned`; paths 4 and
75
- * 5 never seal at all, they only leave a note, and are handled in
76
- * `planGesture`). A different pointer's event never affects this gesture —
77
- * multi-touch is out of scope, but the check costs nothing to make correct.
78
- *
79
- * @param {Gesture} gesture
80
- * @param {GestureEvent} event
81
- * @returns {SealReason | null}
82
- */
83
- export function sealReasonFor(gesture, event) {
84
- if (event.pointerId !== undefined && event.pointerId !== gesture.pointerId) {
85
- return null;
86
- }
87
- if (event.type === "pointerup" || event.type === "pointercancel") {
88
- return "closed";
89
- }
90
- if (event.type === "pointerdown") {
91
- return "abandoned:superseded";
92
- }
93
- return null;
94
- }
95
-
96
- /**
97
- * @param {number} idleAbandonMs
98
- * @returns {void}
99
- */
100
- export function requireIdleAbandonMs(idleAbandonMs) {
101
- if (typeof idleAbandonMs !== "number" || !Number.isFinite(idleAbandonMs)) {
102
- throw new TypeError(
103
- `isIdleAbandoned: idleAbandonMs must be a finite number, got ${JSON.stringify(idleAbandonMs)}`,
104
- );
105
- }
106
- }
107
-
108
- /**
109
- * Path 3 (ADR-0016 §1): silence since the gesture's OWN last event, not the
110
- * gesture's total duration — measured (`docs/adr/0016...md`'s DOĞRULANMALI
111
- * #1) as the only metric that actually separates closed gestures from
112
- * orphaned ones (total duration overlaps completely: closed 94-10325ms,
113
- * orphaned 461-10323ms). `idleAbandonMs` is injected with NO default — the
114
- * measured 1456.8ms is a FLOOR (the longest legitimate pause seen, n=31),
115
- * never a ceiling a chosen value could safely equal.
116
- *
117
- * While the page is hidden (`gesture.paused`), the clock does not advance
118
- * at all (ADR-0016 §1 path 5) — this function returns `false` unconditionally.
119
- *
120
- * @param {Gesture} gesture
121
- * @param {number} now
122
- * @param {number} idleAbandonMs
123
- * @returns {boolean}
124
- */
125
- export function isIdleAbandoned(gesture, now, idleAbandonMs) {
126
- requireIdleAbandonMs(idleAbandonMs);
127
- if (gesture.paused) {
128
- return false;
129
- }
130
- return now - gesture.lastEventAt >= idleAbandonMs;
131
- }
132
-
133
- /**
134
- * @param {GestureEvent} event a "pointerdown"
135
- * @returns {Gesture}
136
- */
137
- function openGesture(event) {
138
- return {
139
- gestureId: /** @type {number} */ (event.gestureId),
140
- pointerId: /** @type {number} */ (event.pointerId),
141
- lastEventAt: event.timestamp,
142
- paused: false,
143
- captureLostDuringGesture: false,
144
- pageHiddenDuringGesture: false,
145
- };
146
- }
147
-
148
- /**
149
- * @param {Gesture} gesture
150
- * @param {GestureEvent} event
151
- * @returns {boolean}
152
- */
153
- function belongsToGesture(gesture, event) {
154
- return event.pointerId === undefined || event.pointerId === gesture.pointerId;
155
- }
156
-
157
- /**
158
- * The full bracket state transition for one event. `gesture` is the state
159
- * BEFORE `event`; the returned `GesturePlan.gesture` is the state AFTER.
160
- *
161
- * - Idle + `pointerdown` -> opens (uses `event.gestureId`, pre-minted by
162
- * the caller's `IdFactory` — this function never mints one itself).
163
- * - `sealReasonFor` decides "closed" (bracket fully ends) and
164
- * "abandoned:superseded" (bracket ends AND a new one opens in the same
165
- * call, since the superseding pointerdown IS the next gesture's start).
166
- * - `lostpointercapture` and `visibilitychange`/`blur` never seal — they
167
- * only set a note (`captureLostDuringGesture`/`pageHiddenDuringGesture`).
168
- * `visibilitychange`(hidden)/`blur` ALSO pause the idle clock
169
- * (ADR-0016 §1 path 5); becoming visible/focused again resumes it and
170
- * resets `lastEventAt` to the resume moment — otherwise the entire
171
- * hidden duration would count as "silence since the last event" the
172
- * instant the page came back, defeating the point of pausing.
173
- * - A `pointermove`/`lostpointercapture` from a DIFFERENT pointer than the
174
- * one this gesture is tracking is a pure no-op.
175
- *
176
- * @param {Gesture | null} gesture
177
- * @param {GestureEvent} event
178
- * @returns {GesturePlan}
179
- */
180
- export function planGesture(gesture, event) {
181
- if (gesture === null) {
182
- if (event.type === "pointerdown") {
183
- return { gesture: openGesture(event), sealReason: null };
184
- }
185
- return { gesture: null, sealReason: null };
186
- }
187
-
188
- const reason = sealReasonFor(gesture, event);
189
- if (reason === "closed") {
190
- return { gesture: null, sealReason: "closed" };
191
- }
192
- if (reason === "abandoned:superseded") {
193
- return { gesture: openGesture(event), sealReason: "abandoned:superseded" };
194
- }
195
-
196
- switch (event.type) {
197
- case "pointermove":
198
- return belongsToGesture(gesture, event)
199
- ? { gesture: { ...gesture, lastEventAt: event.timestamp }, sealReason: null }
200
- : { gesture, sealReason: null };
201
- case "lostpointercapture":
202
- return belongsToGesture(gesture, event)
203
- ? { gesture: { ...gesture, captureLostDuringGesture: true }, sealReason: null }
204
- : { gesture, sealReason: null };
205
- case "visibilitychange":
206
- return event.hidden
207
- ? { gesture: { ...gesture, pageHiddenDuringGesture: true, paused: true }, sealReason: null }
208
- : { gesture: { ...gesture, paused: false, lastEventAt: event.timestamp }, sealReason: null };
209
- case "blur":
210
- return { gesture: { ...gesture, pageHiddenDuringGesture: true, paused: true }, sealReason: null };
211
- case "focus":
212
- return { gesture: { ...gesture, paused: false, lastEventAt: event.timestamp }, sealReason: null };
213
- default:
214
- return { gesture, sealReason: null };
215
- }
216
- }
217
-
218
- /**
219
- * @typedef {object} DragEntry
220
- * @property {import("../model/node.js").NodeId} containerId
221
- * @property {import("./geometry.js").Rect} anchor the box's own layout at gesture start
222
- * @property {(typeof import("./handles.js").ALL_HANDLES)[number] | "body"} handle "body" for a plain move
223
- */
224
-
225
- /**
226
- * The layout patch for every dragged box, for ONE frame of an open gesture
227
- * — the array `transformBoxes`/`transformBox` (`src/commands/box.js`)
228
- * expects. `entries`' order is FROZEN for the whole gesture by the caller
229
- * (ADR-0018 Karar 3): `HistoryEngine` matches frames by INDEX, so a
230
- * reordered `entries` array would silently coalesce one box's step into a
231
- * different box's slot. This function does not enforce that — it is the
232
- * caller's contract, the same way `resizePatch`/`movePatch` trust their own
233
- * caller for `handle`'s validity.
234
- *
235
- * @param {DragEntry[]} entries
236
- * @param {number} dx
237
- * @param {number} dy
238
- * @param {import("./resize.js").ResizeLimits} limits
239
- * @returns {import("../commands/box.js").BoxTransformEntry[]}
240
- */
241
- export function framePatches(entries, dx, dy, limits) {
242
- return entries.map((entry) => ({
243
- containerId: entry.containerId,
244
- patch:
245
- entry.handle === "body"
246
- ? movePatch(entry.anchor, dx, dy)
247
- : resizePatch(entry.anchor, entry.handle, dx, dy, limits),
248
- }));
249
- }
250
-
251
- /**
252
- * Whether two frames (the OUTPUT of `framePatches`, or any array shaped the
253
- * same way) would produce coalescible steps — the CANVAS-layer twin of
254
- * `HistoryEngine`'s `sameLayoutFrame` (`src/history/history.js`), asked
255
- * before a `Step` ever exists. Deliberately NOT implemented by importing
256
- * `sameLayoutFrame` — this file has no history import (this directory's own
257
- * "No src/, DOM, or history import" rule), and the two questions are asked
258
- * by different consumers for different reasons, the same "two related
259
- * tables, two files" split `resizeKeys`/`handleEdges` already established.
260
- *
261
- * @param {import("../commands/box.js").BoxTransformEntry[]} previousPatches
262
- * @param {import("../commands/box.js").BoxTransformEntry[]} patches
263
- * @returns {boolean}
264
- */
265
- export function framePatchesMatchKeys(previousPatches, patches) {
266
- if (previousPatches.length !== patches.length) {
267
- return false;
268
- }
269
- return previousPatches.every((previous, i) => {
270
- const current = patches[i];
271
- return previous.containerId === current.containerId && sameKeySet(previous.patch, current.patch);
272
- });
273
- }
274
-
275
- /**
276
- * Local to this file on purpose — see `framePatchesMatchKeys`'s own comment
277
- * for why this is not imported from `history.js`.
278
- * @param {Partial<import("../model/node.js").BoxLayout>} a
279
- * @param {Partial<import("../model/node.js").BoxLayout>} b
280
- * @returns {boolean}
281
- */
282
- function sameKeySet(a, b) {
283
- const aKeys = Object.keys(a);
284
- const bKeys = Object.keys(b);
285
- if (aKeys.length !== bKeys.length) {
286
- return false;
287
- }
288
- return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key));
289
- }
1
+ // The pointer-gesture bracket: idle -> open -> sealed(reason) (ADR-0016 §1,
2
+ // measured in two engines). No src/, DOM, or history import — pure data in,
3
+ // data out, same discipline as every other file in this directory.
4
+ //
5
+ // ⚠ SENTETİK DİZİLER KANIT DEĞİL (test/input/composition.test.js's own
6
+ // header, restated here for the same reason): this file's own tests drive
7
+ // hand-written event sequences through these functions. That proves the
8
+ // state machine behaves correctly GIVEN that sequence. It does NOT prove a
9
+ // real browser produces that sequence — the real evidence is
10
+ // docs/adr/0016-gesture-bracket-and-accumulative-invert.md's G1-G10
11
+ // measurements, and ADR-0018 §10.2 is explicit that multi-selection
12
+ // dragging (Ö5) never reached the probe at all.
13
+ //
14
+ // MEASURED RULES this module encodes (ADR-0016, two engines — Chrome +
15
+ // Firefox):
16
+ // - `pointerup` closes the bracket, but MAY NOT ARRIVE (Chrome 6/17,
17
+ // Firefox 1/4 — right-click mid-drag). The one reliable backup is a
18
+ // NEW `pointerdown` on the SAME `pointerId` (G2) — pointerId's own
19
+ // VALUE is engine-dependent (Chrome always 1, Firefox always 0), so
20
+ // this file only ever compares two observed values for EQUALITY, never
21
+ // against a hardcoded constant.
22
+ // - `lostpointercapture` is NOT a seal signal (G3): 5/6 orphaned gestures
23
+ // never got it at all, and the one that did got it mid-gesture without
24
+ // closing.
25
+ // - `focusout`/`blur` are NOT seal signals (G4/G6): 20/20 focusout events
26
+ // fired with `hasFocus: true` still true, and 3/4 visibilitychange
27
+ // events during an open gesture still closed normally via pointerup.
28
+ // - Capture loss is not gesture end, only a NOTE (G5) — Blink-specific
29
+ // (14/14 Firefox gestures never showed it at all).
30
+ // - Sealing is IDEMPOTENT / order-tolerant: G10 (Firefox) found a key's
31
+ // own delayed signal can arrive out of the order a naive implementation
32
+ // would assume — nudge.js's `keyUpSealsOpenBracket` carries the same
33
+ // discipline for the keyboard bracket.
34
+
35
+ import { movePatch, resizePatch } from "./resize.js";
36
+
37
+ /**
38
+ * @typedef {"closed" | "abandoned:superseded" | "abandoned:timeout"} SealReason
39
+ *
40
+ * Terk (abandon) kapanıştan (closed) AYIRT EDİLEBİLİR tutulur (ADR-0016 §1):
41
+ * a mutation that let an abandoned gesture read back as "closed" would make
42
+ * "the bracket abandons cleanly" impossible to assert — it would only ever
43
+ * be TOLERATED, never verified.
44
+ */
45
+
46
+ /**
47
+ * @typedef {object} Gesture
48
+ * @property {number} gestureId minted by the caller's IdFactory, monotonic, never reused after sealing
49
+ * @property {number} pointerId compared by EQUALITY only — never a hardcoded engine constant
50
+ * @property {number} lastEventAt timestamp of the most recent event this gesture's clock cares about
51
+ * @property {boolean} paused true while the page is hidden/unfocused — `isIdleAbandoned` does not advance the clock
52
+ * @property {boolean} captureLostDuringGesture note only, never a seal (path 4)
53
+ * @property {boolean} pageHiddenDuringGesture note only, never a seal (path 5)
54
+ */
55
+
56
+ /**
57
+ * @typedef {object} GestureEvent
58
+ * @property {"pointerdown" | "pointerup" | "pointercancel" | "pointermove" | "lostpointercapture" | "visibilitychange" | "blur" | "focus"} type
59
+ * @property {number} [pointerId] absent for visibilitychange/blur/focus
60
+ * @property {number} timestamp
61
+ * @property {boolean} [hidden] visibilitychange only: `document.hidden`
62
+ * @property {number} [gestureId] pointerdown only: pre-minted id for the gesture this event WOULD open
63
+ */
64
+
65
+ /**
66
+ * @typedef {object} GesturePlan
67
+ * @property {Gesture | null} gesture the gesture state AFTER this event; `null` once idle
68
+ * @property {SealReason | null} sealReason what happened to the PREVIOUS (pre-event) gesture this call, if anything
69
+ */
70
+
71
+ /**
72
+ * Whether `event`, against the CURRENTLY open `gesture`, closes or
73
+ * supersedes it (ADR-0016 §1 paths 1 and 2 only — path 3's timeout is
74
+ * time-based, not event-based, and lives in `isIdleAbandoned`; paths 4 and
75
+ * 5 never seal at all, they only leave a note, and are handled in
76
+ * `planGesture`). A different pointer's event never affects this gesture —
77
+ * multi-touch is out of scope, but the check costs nothing to make correct.
78
+ *
79
+ * @param {Gesture} gesture
80
+ * @param {GestureEvent} event
81
+ * @returns {SealReason | null}
82
+ */
83
+ export function sealReasonFor(gesture, event) {
84
+ if (event.pointerId !== undefined && event.pointerId !== gesture.pointerId) {
85
+ return null;
86
+ }
87
+ if (event.type === "pointerup" || event.type === "pointercancel") {
88
+ return "closed";
89
+ }
90
+ if (event.type === "pointerdown") {
91
+ return "abandoned:superseded";
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * @param {number} idleAbandonMs
98
+ * @returns {void}
99
+ */
100
+ export function requireIdleAbandonMs(idleAbandonMs) {
101
+ if (typeof idleAbandonMs !== "number" || !Number.isFinite(idleAbandonMs)) {
102
+ throw new TypeError(
103
+ `isIdleAbandoned: idleAbandonMs must be a finite number, got ${JSON.stringify(idleAbandonMs)}`,
104
+ );
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Path 3 (ADR-0016 §1): silence since the gesture's OWN last event, not the
110
+ * gesture's total duration — measured (`docs/adr/0016...md`'s DOĞRULANMALI
111
+ * #1) as the only metric that actually separates closed gestures from
112
+ * orphaned ones (total duration overlaps completely: closed 94-10325ms,
113
+ * orphaned 461-10323ms). `idleAbandonMs` is injected with NO default — the
114
+ * measured 1456.8ms is a FLOOR (the longest legitimate pause seen, n=31),
115
+ * never a ceiling a chosen value could safely equal.
116
+ *
117
+ * While the page is hidden (`gesture.paused`), the clock does not advance
118
+ * at all (ADR-0016 §1 path 5) — this function returns `false` unconditionally.
119
+ *
120
+ * @param {Gesture} gesture
121
+ * @param {number} now
122
+ * @param {number} idleAbandonMs
123
+ * @returns {boolean}
124
+ */
125
+ export function isIdleAbandoned(gesture, now, idleAbandonMs) {
126
+ requireIdleAbandonMs(idleAbandonMs);
127
+ if (gesture.paused) {
128
+ return false;
129
+ }
130
+ return now - gesture.lastEventAt >= idleAbandonMs;
131
+ }
132
+
133
+ /**
134
+ * @param {GestureEvent} event a "pointerdown"
135
+ * @returns {Gesture}
136
+ */
137
+ function openGesture(event) {
138
+ return {
139
+ gestureId: /** @type {number} */ (event.gestureId),
140
+ pointerId: /** @type {number} */ (event.pointerId),
141
+ lastEventAt: event.timestamp,
142
+ paused: false,
143
+ captureLostDuringGesture: false,
144
+ pageHiddenDuringGesture: false,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * @param {Gesture} gesture
150
+ * @param {GestureEvent} event
151
+ * @returns {boolean}
152
+ */
153
+ function belongsToGesture(gesture, event) {
154
+ return event.pointerId === undefined || event.pointerId === gesture.pointerId;
155
+ }
156
+
157
+ /**
158
+ * The full bracket state transition for one event. `gesture` is the state
159
+ * BEFORE `event`; the returned `GesturePlan.gesture` is the state AFTER.
160
+ *
161
+ * - Idle + `pointerdown` -> opens (uses `event.gestureId`, pre-minted by
162
+ * the caller's `IdFactory` — this function never mints one itself).
163
+ * - `sealReasonFor` decides "closed" (bracket fully ends) and
164
+ * "abandoned:superseded" (bracket ends AND a new one opens in the same
165
+ * call, since the superseding pointerdown IS the next gesture's start).
166
+ * - `lostpointercapture` and `visibilitychange`/`blur` never seal — they
167
+ * only set a note (`captureLostDuringGesture`/`pageHiddenDuringGesture`).
168
+ * `visibilitychange`(hidden)/`blur` ALSO pause the idle clock
169
+ * (ADR-0016 §1 path 5); becoming visible/focused again resumes it and
170
+ * resets `lastEventAt` to the resume moment — otherwise the entire
171
+ * hidden duration would count as "silence since the last event" the
172
+ * instant the page came back, defeating the point of pausing.
173
+ * - A `pointermove`/`lostpointercapture` from a DIFFERENT pointer than the
174
+ * one this gesture is tracking is a pure no-op.
175
+ *
176
+ * @param {Gesture | null} gesture
177
+ * @param {GestureEvent} event
178
+ * @returns {GesturePlan}
179
+ */
180
+ export function planGesture(gesture, event) {
181
+ if (gesture === null) {
182
+ if (event.type === "pointerdown") {
183
+ return { gesture: openGesture(event), sealReason: null };
184
+ }
185
+ return { gesture: null, sealReason: null };
186
+ }
187
+
188
+ const reason = sealReasonFor(gesture, event);
189
+ if (reason === "closed") {
190
+ return { gesture: null, sealReason: "closed" };
191
+ }
192
+ if (reason === "abandoned:superseded") {
193
+ return { gesture: openGesture(event), sealReason: "abandoned:superseded" };
194
+ }
195
+
196
+ switch (event.type) {
197
+ case "pointermove":
198
+ return belongsToGesture(gesture, event)
199
+ ? { gesture: { ...gesture, lastEventAt: event.timestamp }, sealReason: null }
200
+ : { gesture, sealReason: null };
201
+ case "lostpointercapture":
202
+ return belongsToGesture(gesture, event)
203
+ ? { gesture: { ...gesture, captureLostDuringGesture: true }, sealReason: null }
204
+ : { gesture, sealReason: null };
205
+ case "visibilitychange":
206
+ return event.hidden
207
+ ? { gesture: { ...gesture, pageHiddenDuringGesture: true, paused: true }, sealReason: null }
208
+ : { gesture: { ...gesture, paused: false, lastEventAt: event.timestamp }, sealReason: null };
209
+ case "blur":
210
+ return { gesture: { ...gesture, pageHiddenDuringGesture: true, paused: true }, sealReason: null };
211
+ case "focus":
212
+ return { gesture: { ...gesture, paused: false, lastEventAt: event.timestamp }, sealReason: null };
213
+ default:
214
+ return { gesture, sealReason: null };
215
+ }
216
+ }
217
+
218
+ /**
219
+ * @typedef {object} DragEntry
220
+ * @property {import("../model/node.js").NodeId} containerId
221
+ * @property {import("./geometry.js").Rect} anchor the box's own layout at gesture start
222
+ * @property {(typeof import("./handles.js").ALL_HANDLES)[number] | "body"} handle "body" for a plain move
223
+ */
224
+
225
+ /**
226
+ * The layout patch for every dragged box, for ONE frame of an open gesture
227
+ * — the array `transformBoxes`/`transformBox` (`src/commands/box.js`)
228
+ * expects. `entries`' order is FROZEN for the whole gesture by the caller
229
+ * (ADR-0018 Karar 3): `HistoryEngine` matches frames by INDEX, so a
230
+ * reordered `entries` array would silently coalesce one box's step into a
231
+ * different box's slot. This function does not enforce that — it is the
232
+ * caller's contract, the same way `resizePatch`/`movePatch` trust their own
233
+ * caller for `handle`'s validity.
234
+ *
235
+ * @param {DragEntry[]} entries
236
+ * @param {number} dx
237
+ * @param {number} dy
238
+ * @param {import("./resize.js").ResizeLimits} limits
239
+ * @returns {import("../commands/box.js").BoxTransformEntry[]}
240
+ */
241
+ export function framePatches(entries, dx, dy, limits) {
242
+ return entries.map((entry) => ({
243
+ containerId: entry.containerId,
244
+ patch:
245
+ entry.handle === "body"
246
+ ? movePatch(entry.anchor, dx, dy)
247
+ : resizePatch(entry.anchor, entry.handle, dx, dy, limits),
248
+ }));
249
+ }
250
+
251
+ /**
252
+ * Whether two frames (the OUTPUT of `framePatches`, or any array shaped the
253
+ * same way) would produce coalescible steps — the CANVAS-layer twin of
254
+ * `HistoryEngine`'s `sameLayoutFrame` (`src/history/history.js`), asked
255
+ * before a `Step` ever exists. Deliberately NOT implemented by importing
256
+ * `sameLayoutFrame` — this file has no history import (this directory's own
257
+ * "No src/, DOM, or history import" rule), and the two questions are asked
258
+ * by different consumers for different reasons, the same "two related
259
+ * tables, two files" split `resizeKeys`/`handleEdges` already established.
260
+ *
261
+ * @param {import("../commands/box.js").BoxTransformEntry[]} previousPatches
262
+ * @param {import("../commands/box.js").BoxTransformEntry[]} patches
263
+ * @returns {boolean}
264
+ */
265
+ export function framePatchesMatchKeys(previousPatches, patches) {
266
+ if (previousPatches.length !== patches.length) {
267
+ return false;
268
+ }
269
+ return previousPatches.every((previous, i) => {
270
+ const current = patches[i];
271
+ return previous.containerId === current.containerId && sameKeySet(previous.patch, current.patch);
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Local to this file on purpose — see `framePatchesMatchKeys`'s own comment
277
+ * for why this is not imported from `history.js`.
278
+ * @param {Partial<import("../model/node.js").BoxLayout>} a
279
+ * @param {Partial<import("../model/node.js").BoxLayout>} b
280
+ * @returns {boolean}
281
+ */
282
+ function sameKeySet(a, b) {
283
+ const aKeys = Object.keys(a);
284
+ const bKeys = Object.keys(b);
285
+ if (aKeys.length !== bKeys.length) {
286
+ return false;
287
+ }
288
+ return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key));
289
+ }