@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,717 +1,717 @@
1
- // CanvasController — the one place every pure canvas module gets wired to
2
- // real events and a real document (ADR-0018 Karar 9). Together with
3
- // `overlay.js`, this is the ONLY DOM-touching pair in `src/canvas/`; every
4
- // other file in this directory is pure data in, data out, and this file's
5
- // whole job is to feed them real input and turn their answers into
6
- // `Transaction`s.
7
- //
8
- // DI pattern mirrors `SelectionController`'s `{root, lookupParagraph,
9
- // onAdopt}` shape; the state boundary is the same `{getState, dispatch}`
10
- // pair `BeforeInputContext`/`CompositionContext` already use — this
11
- // controller adds `readPageMetrics`, `metrics`, and `ids` on top, the same
12
- // way `BeforeInputContext` adds `history`/`ids` to the base pair.
13
- //
14
- // 🔴 STATE NEVER TOUCHES EditorState (§9.2, a measured trap): `applyTransaction`
15
- // (state/editor-state.js:83) and `withSelection` (:97) return a FRESH object
16
- // literal, not a spread of the incoming state — any field this controller
17
- // tried to add there would be silently dropped on the next transaction.
18
- // Box selection, the open gesture, and the nudge bracket all live on THIS
19
- // class's own instance instead, the same place `SelectionController.isComposing`
20
- // and the whole of `CompositionController` already live (ADR-0004).
21
- //
22
- // 🔴 THE CLOCK IS HOST-DRIVEN: `tick(now)` is the only place time enters this
23
- // class. No `setTimeout`/`setInterval` anywhere below — `HistoryEngine.canCoalesce`
24
- // already made this call (timestamps compared, never a timer) so tests stay
25
- // deterministic; this class holds to the same discipline for gesture/nudge
26
- // idle-abandon.
27
- //
28
- // PageMetrics are read at the boundary, ONCE per need, via the injected
29
- // `readPageMetrics()` — never `getBoundingClientRect`/`offsetLeft`/
30
- // `elementFromPoint` (invariant #7: the browser's box model is a MEASUREMENT
31
- // INPUT here, never the data model). Hit-testing, snap, z-order, and the
32
- // menu's own stack list are all computed from `doc.layout` (`hit-test.js`),
33
- // not from the DOM.
34
- //
35
- // Nudge keeps `origin: "pointer"` even though the device is a keyboard —
36
- // ADR-0018 §9's own naming-debt note: a new `"gesture"` origin would touch
37
- // every HIST mutation's `find` string for zero behavioral gain.
38
-
39
- import { layoutOf } from "../model/node.js";
40
- import { insertContainerStep, removeContainerStep } from "../model/step.js";
41
- import { createTransaction } from "../model/transaction.js";
42
- import { isDev } from "../dev.js";
43
-
44
- import { pageAtClientPoint, clientToPagePoint } from "./viewport.js";
45
- import { rectOfLayout, unionRect } from "./geometry.js";
46
- import { topBoxAtPoint } from "./hit-test.js";
47
- import { handleAtPoint, affordancesFor } from "./handles.js";
48
- import { movePatch } from "./resize.js";
49
- import { snapTargets, snapAdjustment } from "./snap.js";
50
- import { zOrderPlan } from "./z-order.js";
51
- import { planGesture, framePatches, isIdleAbandoned } from "./gesture.js";
52
- import { nudgeTransition, keyUpSealsOpenBracket, nudgeDelta } from "./nudge.js";
53
- import { toggleSelection, boxesInMarquee, anchorBounds } from "./box-selection.js";
54
- import { serializeBoxes, payloadToText, pasteBoxes } from "./box-clipboard.js";
55
- import { contextMenuModel } from "./menu.js";
56
- import { transformBoxes, setBoxZOrder } from "../commands/box.js";
57
-
58
- /**
59
- * Every unmeasured constant this layer needs, in ONE injected object — none
60
- * has a default (ADR-0018 §9.0: "ölçülmemiş sabitlerin hiçbirinin varsayılanı
61
- * yok"). `crampedCx`/`crampedCy` are required here too even though an
62
- * earlier listing of this shape omitted them: `handles.js`'s own
63
- * `requireMetrics` demands them for `affordancesFor`/`handleRects`/
64
- * `handleAtPoint`, all three of which this controller calls, so any
65
- * `CanvasMetrics` this class accepts has to carry them or those calls throw
66
- * the moment a resize handle is actually queried.
67
- * @typedef {object} CanvasMetrics
68
- * @property {number} handleSize
69
- * @property {number} handleSlop
70
- * @property {number} crampedCx
71
- * @property {number} crampedCy
72
- * @property {number} snapThreshold
73
- * @property {number} minBoxCx
74
- * @property {number} minBoxCy
75
- * @property {number} nudgeStep
76
- * @property {number} pasteOffset
77
- * @property {number} idleAbandonMs
78
- * @property {number} minZ
79
- */
80
-
81
- const REQUIRED_METRICS_KEYS = /** @type {const} */ ([
82
- "handleSize",
83
- "handleSlop",
84
- "crampedCx",
85
- "crampedCy",
86
- "snapThreshold",
87
- "minBoxCx",
88
- "minBoxCy",
89
- "nudgeStep",
90
- "pasteOffset",
91
- "idleAbandonMs",
92
- "minZ",
93
- ]);
94
-
95
- /**
96
- * @param {CanvasMetrics} metrics
97
- * @returns {void}
98
- */
99
- export function requireMetrics(metrics) {
100
- for (const key of REQUIRED_METRICS_KEYS) {
101
- if (typeof metrics?.[key] !== "number" || !Number.isFinite(metrics[key])) {
102
- throw new TypeError(
103
- `CanvasMetrics.${key} is required and must be a finite number, got ${JSON.stringify(metrics?.[key])}`,
104
- );
105
- }
106
- }
107
- }
108
-
109
- const ARROW_KEYS = /** @type {const} */ (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
110
-
111
- /**
112
- * @typedef {object} CanvasControllerContext
113
- * @property {() => import("../state/editor-state.js").EditorState} getState
114
- * @property {(tr: import("../model/transaction.js").Transaction) => void} dispatch
115
- * @property {() => import("./viewport.js").PageRect[]} readPageMetrics index 0 is page 1
116
- * @property {CanvasMetrics} metrics
117
- * @property {import("../model/ids.js").IdFactory} ids
118
- */
119
-
120
- export class CanvasController {
121
- /** @param {CanvasControllerContext} context */
122
- constructor({ getState, dispatch, readPageMetrics, metrics, ids }) {
123
- requireMetrics(metrics);
124
- this.getState = getState;
125
- this.dispatch = dispatch;
126
- this.readPageMetrics = readPageMetrics;
127
- this.metrics = metrics;
128
- this.ids = ids;
129
-
130
- /** @type {import("../model/node.js").NodeId[]} */
131
- this.selection = [];
132
- /** @type {number} the page a paste targets — set by the consumer as the user navigates */
133
- this.activePage = 1;
134
- /** @type {import("../model/node.js").NodeId} the page an active gesture targets */
135
- this._page = 1;
136
- /** @type {import("./box-clipboard.js").BoxPayload | null} lives HERE, never on EditorState (§7.1) */
137
- this.clipboard = null;
138
- /** @type {import("./gesture.js").Gesture | null} */
139
- this.gesture = null;
140
- /** @type {import("./nudge.js").NudgeBracket | null} */
141
- this.nudgeBracket = null;
142
- /** @type {import("./snap.js").SnapGuide[]} */
143
- this.guides = [];
144
- /** @type {import("./menu.js").MenuItem[] | null} */
145
- this.menu = null;
146
-
147
- /** @type {"move" | "resize" | "marquee" | null} */
148
- this._dragKind = null;
149
- /** @type {import("./gesture.js").DragEntry[] | null} */
150
- this._dragEntries = null;
151
- /** @type {{x: number, y: number} | null} */
152
- this._anchorPoint = null;
153
- /** @type {import("./geometry.js").Rect | null} */
154
- this._anchorBounds = null;
155
- /** @type {import("./viewport.js").PageRect | null} */
156
- this._pageRect = null;
157
- /** @type {import("../model/node.js").NodeId[]} */
158
- this._marqueeBaseSelection = [];
159
- this._marqueeAdditive = false;
160
-
161
- this._now = 0;
162
-
163
- /** @type {Record<string, (event: any) => void> | undefined} */
164
- this._rootListeners = undefined;
165
- /** @type {any} */
166
- this._root = undefined;
167
- /** @type {Record<string, () => void> | undefined} */
168
- this._documentListeners = undefined;
169
- /** @type {any} */
170
- this._documentTarget = undefined;
171
- }
172
-
173
- /**
174
- * The host's own clock, advanced explicitly — never a real timer (see this
175
- * file's header). Seals an open gesture/nudge bracket that has gone idle
176
- * past `metrics.idleAbandonMs` (ADR-0016 §1 path 3 / §4).
177
- * @param {number} now
178
- * @returns {void}
179
- */
180
- tick(now) {
181
- this._now = now;
182
- if (this.gesture !== null && isIdleAbandoned(this.gesture, now, this.metrics.idleAbandonMs)) {
183
- this.gesture = null;
184
- this._dragKind = null;
185
- this._dragEntries = null;
186
- this.guides = [];
187
- }
188
- // `NudgeBracket` carries no `paused` field, but `isIdleAbandoned` treats
189
- // an absent `paused` as `false` — the same generic reuse `nudge.js`'s
190
- // own header describes for this exact function.
191
- if (
192
- this.nudgeBracket !== null &&
193
- isIdleAbandoned(/** @type {any} */ (this.nudgeBracket), now, this.metrics.idleAbandonMs)
194
- ) {
195
- this.nudgeBracket = null;
196
- }
197
- }
198
-
199
- /**
200
- * Forgets everything about the previous document's selection and any
201
- * in-progress interaction — used on a shared-core document swap
202
- * (ADR-0024's `canvas-editor.js`, via `editing-host.js`'s `onLoadDocument`
203
- * hook, ADR-0027 Ek 47). Without this, a stale `this.selection` id can
204
- * survive the swap and, if the new document happens to reuse that SAME
205
- * small integer for an unrelated container (real corpus documents share
206
- * dozens of ids across separate imports — `import.js`'s per-call id
207
- * restart), silently retarget an action onto a box the user never
208
- * selected in the CURRENT document. Measured against real corpus
209
- * documents (AA01.docx -> KK13.docx, a real shared container id): a
210
- * keyboard nudge with an ENTIRELY absent stale id throws inside
211
- * `movePatch` (neither it nor `rectOfLayout` null-checks its argument);
212
- * with a PARTIALLY colliding stale id, the same nudge silently moved a
213
- * real KK13 box the user never touched, and `deleteSelection()`
214
- * separately, silently DELETED one — no error, no warning, either time.
215
- *
216
- * `getState`/`dispatch`/`readPageMetrics`/`metrics`/`ids` (constructor
217
- * dependencies) and `_root`/`_rootListeners`/`_documentTarget`/
218
- * `_documentListeners` (wiring handles set by `attach()`) are NOT
219
- * touched — those describe the mount point and injected collaborators,
220
- * not the loaded document, and stay valid across a document swap.
221
- * `_now` is the host's own clock, also unrelated to which document is
222
- * loaded.
223
- * @returns {void}
224
- */
225
- reset() {
226
- this.selection = [];
227
- this.activePage = 1;
228
- this._page = 1;
229
- this.clipboard = null;
230
- this.gesture = null;
231
- this.nudgeBracket = null;
232
- this.guides = [];
233
- this.menu = null;
234
- this._dragKind = null;
235
- this._dragEntries = null;
236
- this._anchorPoint = null;
237
- this._anchorBounds = null;
238
- this._pageRect = null;
239
- this._marqueeBaseSelection = [];
240
- this._marqueeAdditive = false;
241
- }
242
-
243
- // --- Pointer gesture: select / drag / resize / marquee ------------------
244
-
245
- /**
246
- * @typedef {object} PointerEventLike
247
- * @property {number} pointerId
248
- * @property {number} clientX
249
- * @property {number} clientY
250
- * @property {boolean} [shiftKey]
251
- * @property {() => void} [preventDefault]
252
- */
253
-
254
- /** @param {PointerEventLike} event @returns {void} */
255
- handlePointerDown(event) {
256
- const pageRects = this.readPageMetrics();
257
- const page = pageAtClientPoint(pageRects, event.clientX, event.clientY);
258
- if (page === null) {
259
- return;
260
- }
261
- const pageRect = pageRects[page - 1];
262
- const point = clientToPagePoint(pageRect, event.clientX, event.clientY);
263
-
264
- const plan = planGesture(this.gesture, {
265
- type: "pointerdown",
266
- pointerId: event.pointerId,
267
- timestamp: this._now,
268
- gestureId: this.ids.next(),
269
- });
270
- this.gesture = plan.gesture;
271
- this._page = page;
272
- this._pageRect = pageRect;
273
- this._anchorPoint = point;
274
-
275
- const state = this.getState();
276
-
277
- if (this.selection.length === 1) {
278
- const layout = layoutOf(state.doc, this.selection[0]);
279
- if (layout !== null && layout.page === page) {
280
- const handle = handleAtPoint(layout, this.metrics, point.x, point.y);
281
- if (handle !== null) {
282
- this._dragKind = "resize";
283
- this._dragEntries = [
284
- {
285
- containerId: this.selection[0],
286
- anchor: rectOfLayout(layout),
287
- handle: /** @type {import("./gesture.js").DragEntry["handle"]} */ (handle),
288
- },
289
- ];
290
- return;
291
- }
292
- }
293
- }
294
-
295
- const hitId = topBoxAtPoint(state.doc, page, point.x, point.y);
296
- if (hitId === null) {
297
- this._dragKind = "marquee";
298
- this._marqueeAdditive = event.shiftKey === true;
299
- this._marqueeBaseSelection = this._marqueeAdditive ? this.selection : [];
300
- return;
301
- }
302
-
303
- if (event.shiftKey === true) {
304
- this.selection = toggleSelection(this.selection, hitId);
305
- } else if (!this.selection.includes(hitId)) {
306
- this.selection = [hitId];
307
- }
308
-
309
- // `locked` withholds BOTH the resize handle (already true above, since
310
- // `handleAtPoint` never offers one for a locked box) AND the body-move
311
- // affordance (`BoxLayout.locked`'s own doc comment, `handles.js`'s
312
- // `affordancesFor`) — selecting a locked box is still allowed (so it can
313
- // be inspected or reached via the context menu), only dragging it is not.
314
- const hitLayout = layoutOf(state.doc, hitId);
315
- if (hitLayout !== null && !affordancesFor(hitLayout, this.metrics).move) {
316
- return;
317
- }
318
-
319
- this._dragKind = "move";
320
- this._dragEntries = this.selection.map((id) => ({
321
- containerId: id,
322
- anchor: rectOfLayout(/** @type {import("../model/node.js").BoxLayout} */ (layoutOf(state.doc, id))),
323
- handle: /** @type {"body"} */ ("body"),
324
- }));
325
- this._anchorBounds = anchorBounds(state.doc, this.selection);
326
- }
327
-
328
- /** @param {PointerEventLike} event @returns {void} */
329
- handlePointerMove(event) {
330
- if (this.gesture === null || this._pageRect === null || this._anchorPoint === null) {
331
- return;
332
- }
333
- const plan = planGesture(this.gesture, {
334
- type: "pointermove",
335
- pointerId: event.pointerId,
336
- timestamp: this._now,
337
- });
338
- this.gesture = plan.gesture;
339
- if (this.gesture === null) {
340
- return;
341
- }
342
-
343
- const point = clientToPagePoint(this._pageRect, event.clientX, event.clientY);
344
- let dx = point.x - this._anchorPoint.x;
345
- let dy = point.y - this._anchorPoint.y;
346
-
347
- if (this._dragKind === "marquee") {
348
- const rect = { x: this._anchorPoint.x, y: this._anchorPoint.y, cx: dx, cy: dy };
349
- const inMarquee = boxesInMarquee(this.getState().doc, this._page, rect);
350
- this.selection = this._marqueeAdditive
351
- ? [...new Set([...this._marqueeBaseSelection, ...inMarquee])]
352
- : inMarquee;
353
- return;
354
- }
355
-
356
- if (this._dragEntries === null) {
357
- return;
358
- }
359
-
360
- this.guides = [];
361
- // Snap only applies to a MOVE gesture (ADR-0018 Karar 6): resizing has
362
- // its own per-edge key-set story, deferred to P3b-b.
363
- if (this._dragKind === "move" && this._anchorBounds !== null) {
364
- const movedOrigin = movePatch(this._anchorBounds, dx, dy);
365
- const proposedRect = {
366
- x: movedOrigin.x,
367
- y: movedOrigin.y,
368
- cx: this._anchorBounds.cx,
369
- cy: this._anchorBounds.cy,
370
- };
371
- const targets = snapTargets(this.getState().doc, this._page, this.selection);
372
- const adjustment = snapAdjustment(proposedRect, targets, this.metrics.snapThreshold);
373
- dx += adjustment.dx;
374
- dy += adjustment.dy;
375
- this.guides = adjustment.guides;
376
- }
377
-
378
- const limits = { minCx: this.metrics.minBoxCx, minCy: this.metrics.minBoxCy };
379
- const patches = framePatches(this._dragEntries, dx, dy, limits);
380
- const state = this.getState();
381
- const tr = transformBoxes(state, patches);
382
- if (tr !== null) {
383
- this.dispatch({ .../** @type {any} */ (tr), gestureId: this.gesture.gestureId });
384
- }
385
- }
386
-
387
- /** @param {PointerEventLike} event @returns {void} */
388
- handlePointerUp(event) {
389
- if (this.gesture === null) {
390
- return;
391
- }
392
- const plan = planGesture(this.gesture, {
393
- type: "pointerup",
394
- pointerId: event.pointerId,
395
- timestamp: this._now,
396
- });
397
- this.gesture = plan.gesture;
398
- this._dragKind = null;
399
- this._dragEntries = null;
400
- this._anchorBounds = null;
401
- this.guides = [];
402
- }
403
-
404
- /** @param {{pointerId: number}} event @returns {void} */
405
- handleLostPointerCapture(event) {
406
- if (this.gesture === null) {
407
- return;
408
- }
409
- const plan = planGesture(this.gesture, {
410
- type: "lostpointercapture",
411
- pointerId: event.pointerId,
412
- timestamp: this._now,
413
- });
414
- this.gesture = plan.gesture;
415
- }
416
-
417
- /** @param {{hidden?: boolean}} documentTarget @returns {void} */
418
- handleVisibilityChange(documentTarget) {
419
- if (this.gesture === null) {
420
- return;
421
- }
422
- const plan = planGesture(this.gesture, {
423
- type: "visibilitychange",
424
- hidden: documentTarget.hidden === true,
425
- timestamp: this._now,
426
- });
427
- this.gesture = plan.gesture;
428
- }
429
-
430
- /** @returns {void} */
431
- handleBlur() {
432
- if (this.gesture === null) {
433
- return;
434
- }
435
- const plan = planGesture(this.gesture, { type: "blur", timestamp: this._now });
436
- this.gesture = plan.gesture;
437
- }
438
-
439
- /** @returns {void} */
440
- handleFocus() {
441
- if (this.gesture === null) {
442
- return;
443
- }
444
- const plan = planGesture(this.gesture, { type: "focus", timestamp: this._now });
445
- this.gesture = plan.gesture;
446
- }
447
-
448
- // --- Keyboard nudge -------------------------------------------------------
449
-
450
- /** @param {{key: string, repeat?: boolean, preventDefault?: () => void}} event @returns {void} */
451
- handleKeyDown(event) {
452
- if (!/** @type {readonly string[]} */ (ARROW_KEYS).includes(event.key) || this.selection.length === 0) {
453
- return;
454
- }
455
- event.preventDefault?.();
456
-
457
- const plan = nudgeTransition(this.nudgeBracket, {
458
- type: "keydown",
459
- key: event.key,
460
- repeat: event.repeat === true,
461
- timestamp: this._now,
462
- gestureId: this.ids.next(),
463
- });
464
- this.nudgeBracket = plan.bracket;
465
-
466
- const { dx, dy } = nudgeDelta(event.key, this.metrics.nudgeStep);
467
- const state = this.getState();
468
- const patches = this.selection.map((id) => ({
469
- containerId: id,
470
- patch: movePatch(/** @type {import("../model/node.js").BoxLayout} */ (layoutOf(state.doc, id)), dx, dy),
471
- }));
472
- // origin stays "pointer" (see this file's header) — transformBoxes always
473
- // builds it that way.
474
- const tr = transformBoxes(state, patches);
475
- if (tr !== null) {
476
- this.dispatch({ .../** @type {any} */ (tr), gestureId: plan.bracket.gestureId });
477
- }
478
- }
479
-
480
- /** @param {{key: string}} event @returns {void} */
481
- handleKeyUp(event) {
482
- if (keyUpSealsOpenBracket(this.nudgeBracket, { type: "keyup", key: event.key, timestamp: this._now })) {
483
- this.nudgeBracket = null;
484
- }
485
- }
486
-
487
- // --- Box clipboard ---------------------------------------------------------
488
-
489
- /** @typedef {{preventDefault: () => void, clipboardData?: {setData: (type: string, value: string) => void} | null}} ClipboardEventLike */
490
-
491
- /** @param {ClipboardEventLike} event @returns {void} */
492
- handleCopy(event) {
493
- if (this.selection.length === 0) {
494
- return;
495
- }
496
- const state = this.getState();
497
- const payload = serializeBoxes(state.doc, this.selection);
498
- if (payload === null) {
499
- return;
500
- }
501
- event.preventDefault();
502
- event.clipboardData?.setData("text/plain", payloadToText(payload));
503
- this.clipboard = payload;
504
- }
505
-
506
- /** @param {ClipboardEventLike} event @returns {void} */
507
- handleCut(event) {
508
- if (this.selection.length === 0) {
509
- return;
510
- }
511
- const state = this.getState();
512
- if (this.selection.length >= state.doc.content.length) {
513
- // Refused ENTIRELY — no partial cut, nothing written to the clipboard
514
- // (ADR-0018 §7.5): a half-finished cut would be worse than none.
515
- if (isDev()) {
516
- console.warn("word-editor: cut refused — would empty the document");
517
- }
518
- return;
519
- }
520
- const payload = serializeBoxes(state.doc, this.selection);
521
- if (payload === null) {
522
- return;
523
- }
524
- event.preventDefault();
525
- event.clipboardData?.setData("text/plain", payloadToText(payload));
526
- this.clipboard = payload;
527
-
528
- const tr = createTransaction(state.doc, state.selection, "format");
529
- for (const id of this.selection) {
530
- tr.step(removeContainerStep(id));
531
- }
532
- if (tr.changesDoc) {
533
- this.dispatch(tr.build());
534
- }
535
- this.selection = [];
536
- }
537
-
538
- /** @param {ClipboardEventLike} event @returns {void} */
539
- handlePaste(event) {
540
- if (this.clipboard === null) {
541
- return;
542
- }
543
- const pageRects = this.readPageMetrics();
544
- const pageRect = pageRects[this.activePage - 1];
545
- if (pageRect === undefined) {
546
- return;
547
- }
548
- const scale = pageRect.scale;
549
- const pasted = pasteBoxes(
550
- this.clipboard,
551
- this.ids,
552
- { page: this.activePage, width: pageRect.width / scale, height: pageRect.height / scale },
553
- this.metrics.pasteOffset,
554
- );
555
- if (pasted.length === 0) {
556
- return;
557
- }
558
-
559
- const state = this.getState();
560
- const tr = createTransaction(state.doc, state.selection, "format");
561
- for (const { container, layout } of pasted) {
562
- tr.step(insertContainerStep(tr.doc.content.length, container, layout));
563
- }
564
- if (!tr.changesDoc) {
565
- return;
566
- }
567
- event.preventDefault();
568
- this.dispatch(tr.build());
569
- this.selection = pasted.map((p) => p.container.id);
570
- }
571
-
572
- /**
573
- * Removes the current selection. Unlike `handleCut`, a selection that
574
- * would empty the document is not refused wholesale — it keeps the LAST
575
- * remaining box and warns in dev (ADR-0018 §5.3): the command layer's own
576
- * `deleteBox` already refuses a document's last container one at a time,
577
- * this generalizes that same refusal to a batch.
578
- * @returns {void}
579
- */
580
- deleteSelection() {
581
- const state = this.getState();
582
- if (this.selection.length === 0) {
583
- return;
584
- }
585
- const wouldEmpty = this.selection.length >= state.doc.content.length;
586
- const lastId = state.doc.content[state.doc.content.length - 1].id;
587
- const idsToDelete = wouldEmpty ? this.selection.filter((id) => id !== lastId) : this.selection;
588
- if (idsToDelete.length === 0) {
589
- return;
590
- }
591
- if (wouldEmpty && isDev()) {
592
- console.warn("word-editor: delete kept the document's last box");
593
- }
594
-
595
- const tr = createTransaction(state.doc, state.selection, "format");
596
- for (const id of idsToDelete) {
597
- tr.step(removeContainerStep(id));
598
- }
599
- if (tr.changesDoc) {
600
- this.dispatch(tr.build());
601
- }
602
- this.selection = [];
603
- }
604
-
605
- // --- Context menu -----------------------------------------------------------
606
-
607
- /** @param {{clientX: number, clientY: number, preventDefault: () => void}} event @returns {void} */
608
- handleContextMenu(event) {
609
- const pageRects = this.readPageMetrics();
610
- const page = pageAtClientPoint(pageRects, event.clientX, event.clientY);
611
- if (page === null) {
612
- this.menu = null;
613
- return;
614
- }
615
- event.preventDefault();
616
- const point = clientToPagePoint(pageRects[page - 1], event.clientX, event.clientY);
617
- this.menu = contextMenuModel(this.getState().doc, page, point, this.selection);
618
- }
619
-
620
- /**
621
- * Executes one of `contextMenuModel`'s own action ids.
622
- * @param {import("./menu.js").MenuAction["id"]} actionId
623
- * @returns {void}
624
- */
625
- runMenuAction(actionId) {
626
- switch (actionId) {
627
- case "delete":
628
- this.deleteSelection();
629
- return;
630
- case "front":
631
- case "back":
632
- case "forward":
633
- case "backward": {
634
- if (this.selection.length === 0) {
635
- return;
636
- }
637
- const state = this.getState();
638
- const layout = layoutOf(state.doc, this.selection[0]);
639
- if (layout === null) {
640
- return;
641
- }
642
- const entries = zOrderPlan(state.doc, layout.page, this.selection, actionId, this.metrics.minZ);
643
- if (entries.length === 0) {
644
- return;
645
- }
646
- const tr = setBoxZOrder(state, entries);
647
- if (tr !== null) {
648
- this.dispatch(tr);
649
- }
650
- return;
651
- }
652
- default:
653
- return;
654
- }
655
- }
656
-
657
- // --- Wiring -----------------------------------------------------------------
658
- //
659
- // Every handler above is a plain method a test calls directly with a
660
- // hand-built fake event, the same convention `handleBeforeInput`/
661
- // `handlePaste` already use (`src/input/`) — `attach`/`detach` are ONLY the
662
- // thin real-DOM wiring on top, mirroring `SelectionController.attach`'s own
663
- // "takes the event target as a parameter, never a stored global" shape.
664
-
665
- /**
666
- * @param {{addEventListener: (type: string, listener: (event: any) => void) => void, removeEventListener: (type: string, listener: (event: any) => void) => void}} root
667
- * @param {{addEventListener: (type: string, listener: (event: any) => void) => void, removeEventListener: (type: string, listener: (event: any) => void) => void, hidden?: boolean}} documentTarget
668
- * @returns {void}
669
- */
670
- attach(root, documentTarget) {
671
- this._rootListeners = {
672
- pointerdown: (/** @type {any} */ e) => this.handlePointerDown(e),
673
- pointermove: (/** @type {any} */ e) => this.handlePointerMove(e),
674
- pointerup: (/** @type {any} */ e) => this.handlePointerUp(e),
675
- pointercancel: (/** @type {any} */ e) => this.handlePointerUp(e),
676
- lostpointercapture: (/** @type {any} */ e) => this.handleLostPointerCapture(e),
677
- contextmenu: (/** @type {any} */ e) => this.handleContextMenu(e),
678
- keydown: (/** @type {any} */ e) => this.handleKeyDown(e),
679
- keyup: (/** @type {any} */ e) => this.handleKeyUp(e),
680
- copy: (/** @type {any} */ e) => this.handleCopy(e),
681
- cut: (/** @type {any} */ e) => this.handleCut(e),
682
- paste: (/** @type {any} */ e) => this.handlePaste(e),
683
- };
684
- for (const [type, listener] of Object.entries(this._rootListeners)) {
685
- root.addEventListener(type, listener);
686
- }
687
- this._root = root;
688
-
689
- this._documentListeners = {
690
- visibilitychange: () => this.handleVisibilityChange(documentTarget),
691
- blur: () => this.handleBlur(),
692
- focus: () => this.handleFocus(),
693
- };
694
- for (const [type, listener] of Object.entries(this._documentListeners)) {
695
- documentTarget.addEventListener(type, listener);
696
- }
697
- this._documentTarget = documentTarget;
698
- }
699
-
700
- /** @returns {void} */
701
- detach() {
702
- if (this._root !== undefined && this._rootListeners !== undefined) {
703
- for (const [type, listener] of Object.entries(this._rootListeners)) {
704
- this._root.removeEventListener(type, listener);
705
- }
706
- this._root = undefined;
707
- this._rootListeners = undefined;
708
- }
709
- if (this._documentTarget !== undefined && this._documentListeners !== undefined) {
710
- for (const [type, listener] of Object.entries(this._documentListeners)) {
711
- this._documentTarget.removeEventListener(type, listener);
712
- }
713
- this._documentTarget = undefined;
714
- this._documentListeners = undefined;
715
- }
716
- }
717
- }
1
+ // CanvasController — the one place every pure canvas module gets wired to
2
+ // real events and a real document (ADR-0018 Karar 9). Together with
3
+ // `overlay.js`, this is the ONLY DOM-touching pair in `src/canvas/`; every
4
+ // other file in this directory is pure data in, data out, and this file's
5
+ // whole job is to feed them real input and turn their answers into
6
+ // `Transaction`s.
7
+ //
8
+ // DI pattern mirrors `SelectionController`'s `{root, lookupParagraph,
9
+ // onAdopt}` shape; the state boundary is the same `{getState, dispatch}`
10
+ // pair `BeforeInputContext`/`CompositionContext` already use — this
11
+ // controller adds `readPageMetrics`, `metrics`, and `ids` on top, the same
12
+ // way `BeforeInputContext` adds `history`/`ids` to the base pair.
13
+ //
14
+ // 🔴 STATE NEVER TOUCHES EditorState (§9.2, a measured trap): `applyTransaction`
15
+ // (state/editor-state.js:83) and `withSelection` (:97) return a FRESH object
16
+ // literal, not a spread of the incoming state — any field this controller
17
+ // tried to add there would be silently dropped on the next transaction.
18
+ // Box selection, the open gesture, and the nudge bracket all live on THIS
19
+ // class's own instance instead, the same place `SelectionController.isComposing`
20
+ // and the whole of `CompositionController` already live (ADR-0004).
21
+ //
22
+ // 🔴 THE CLOCK IS HOST-DRIVEN: `tick(now)` is the only place time enters this
23
+ // class. No `setTimeout`/`setInterval` anywhere below — `HistoryEngine.canCoalesce`
24
+ // already made this call (timestamps compared, never a timer) so tests stay
25
+ // deterministic; this class holds to the same discipline for gesture/nudge
26
+ // idle-abandon.
27
+ //
28
+ // PageMetrics are read at the boundary, ONCE per need, via the injected
29
+ // `readPageMetrics()` — never `getBoundingClientRect`/`offsetLeft`/
30
+ // `elementFromPoint` (invariant #7: the browser's box model is a MEASUREMENT
31
+ // INPUT here, never the data model). Hit-testing, snap, z-order, and the
32
+ // menu's own stack list are all computed from `doc.layout` (`hit-test.js`),
33
+ // not from the DOM.
34
+ //
35
+ // Nudge keeps `origin: "pointer"` even though the device is a keyboard —
36
+ // ADR-0018 §9's own naming-debt note: a new `"gesture"` origin would touch
37
+ // every HIST mutation's `find` string for zero behavioral gain.
38
+
39
+ import { layoutOf } from "../model/node.js";
40
+ import { insertContainerStep, removeContainerStep } from "../model/step.js";
41
+ import { createTransaction } from "../model/transaction.js";
42
+ import { isDev } from "../dev.js";
43
+
44
+ import { pageAtClientPoint, clientToPagePoint } from "./viewport.js";
45
+ import { rectOfLayout, unionRect } from "./geometry.js";
46
+ import { topBoxAtPoint } from "./hit-test.js";
47
+ import { handleAtPoint, affordancesFor } from "./handles.js";
48
+ import { movePatch } from "./resize.js";
49
+ import { snapTargets, snapAdjustment } from "./snap.js";
50
+ import { zOrderPlan } from "./z-order.js";
51
+ import { planGesture, framePatches, isIdleAbandoned } from "./gesture.js";
52
+ import { nudgeTransition, keyUpSealsOpenBracket, nudgeDelta } from "./nudge.js";
53
+ import { toggleSelection, boxesInMarquee, anchorBounds } from "./box-selection.js";
54
+ import { serializeBoxes, payloadToText, pasteBoxes } from "./box-clipboard.js";
55
+ import { contextMenuModel } from "./menu.js";
56
+ import { transformBoxes, setBoxZOrder } from "../commands/box.js";
57
+
58
+ /**
59
+ * Every unmeasured constant this layer needs, in ONE injected object — none
60
+ * has a default (ADR-0018 §9.0: "ölçülmemiş sabitlerin hiçbirinin varsayılanı
61
+ * yok"). `crampedCx`/`crampedCy` are required here too even though an
62
+ * earlier listing of this shape omitted them: `handles.js`'s own
63
+ * `requireMetrics` demands them for `affordancesFor`/`handleRects`/
64
+ * `handleAtPoint`, all three of which this controller calls, so any
65
+ * `CanvasMetrics` this class accepts has to carry them or those calls throw
66
+ * the moment a resize handle is actually queried.
67
+ * @typedef {object} CanvasMetrics
68
+ * @property {number} handleSize
69
+ * @property {number} handleSlop
70
+ * @property {number} crampedCx
71
+ * @property {number} crampedCy
72
+ * @property {number} snapThreshold
73
+ * @property {number} minBoxCx
74
+ * @property {number} minBoxCy
75
+ * @property {number} nudgeStep
76
+ * @property {number} pasteOffset
77
+ * @property {number} idleAbandonMs
78
+ * @property {number} minZ
79
+ */
80
+
81
+ const REQUIRED_METRICS_KEYS = /** @type {const} */ ([
82
+ "handleSize",
83
+ "handleSlop",
84
+ "crampedCx",
85
+ "crampedCy",
86
+ "snapThreshold",
87
+ "minBoxCx",
88
+ "minBoxCy",
89
+ "nudgeStep",
90
+ "pasteOffset",
91
+ "idleAbandonMs",
92
+ "minZ",
93
+ ]);
94
+
95
+ /**
96
+ * @param {CanvasMetrics} metrics
97
+ * @returns {void}
98
+ */
99
+ export function requireMetrics(metrics) {
100
+ for (const key of REQUIRED_METRICS_KEYS) {
101
+ if (typeof metrics?.[key] !== "number" || !Number.isFinite(metrics[key])) {
102
+ throw new TypeError(
103
+ `CanvasMetrics.${key} is required and must be a finite number, got ${JSON.stringify(metrics?.[key])}`,
104
+ );
105
+ }
106
+ }
107
+ }
108
+
109
+ const ARROW_KEYS = /** @type {const} */ (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
110
+
111
+ /**
112
+ * @typedef {object} CanvasControllerContext
113
+ * @property {() => import("../state/editor-state.js").EditorState} getState
114
+ * @property {(tr: import("../model/transaction.js").Transaction) => void} dispatch
115
+ * @property {() => import("./viewport.js").PageRect[]} readPageMetrics index 0 is page 1
116
+ * @property {CanvasMetrics} metrics
117
+ * @property {import("../model/ids.js").IdFactory} ids
118
+ */
119
+
120
+ export class CanvasController {
121
+ /** @param {CanvasControllerContext} context */
122
+ constructor({ getState, dispatch, readPageMetrics, metrics, ids }) {
123
+ requireMetrics(metrics);
124
+ this.getState = getState;
125
+ this.dispatch = dispatch;
126
+ this.readPageMetrics = readPageMetrics;
127
+ this.metrics = metrics;
128
+ this.ids = ids;
129
+
130
+ /** @type {import("../model/node.js").NodeId[]} */
131
+ this.selection = [];
132
+ /** @type {number} the page a paste targets — set by the consumer as the user navigates */
133
+ this.activePage = 1;
134
+ /** @type {import("../model/node.js").NodeId} the page an active gesture targets */
135
+ this._page = 1;
136
+ /** @type {import("./box-clipboard.js").BoxPayload | null} lives HERE, never on EditorState (§7.1) */
137
+ this.clipboard = null;
138
+ /** @type {import("./gesture.js").Gesture | null} */
139
+ this.gesture = null;
140
+ /** @type {import("./nudge.js").NudgeBracket | null} */
141
+ this.nudgeBracket = null;
142
+ /** @type {import("./snap.js").SnapGuide[]} */
143
+ this.guides = [];
144
+ /** @type {import("./menu.js").MenuItem[] | null} */
145
+ this.menu = null;
146
+
147
+ /** @type {"move" | "resize" | "marquee" | null} */
148
+ this._dragKind = null;
149
+ /** @type {import("./gesture.js").DragEntry[] | null} */
150
+ this._dragEntries = null;
151
+ /** @type {{x: number, y: number} | null} */
152
+ this._anchorPoint = null;
153
+ /** @type {import("./geometry.js").Rect | null} */
154
+ this._anchorBounds = null;
155
+ /** @type {import("./viewport.js").PageRect | null} */
156
+ this._pageRect = null;
157
+ /** @type {import("../model/node.js").NodeId[]} */
158
+ this._marqueeBaseSelection = [];
159
+ this._marqueeAdditive = false;
160
+
161
+ this._now = 0;
162
+
163
+ /** @type {Record<string, (event: any) => void> | undefined} */
164
+ this._rootListeners = undefined;
165
+ /** @type {any} */
166
+ this._root = undefined;
167
+ /** @type {Record<string, () => void> | undefined} */
168
+ this._documentListeners = undefined;
169
+ /** @type {any} */
170
+ this._documentTarget = undefined;
171
+ }
172
+
173
+ /**
174
+ * The host's own clock, advanced explicitly — never a real timer (see this
175
+ * file's header). Seals an open gesture/nudge bracket that has gone idle
176
+ * past `metrics.idleAbandonMs` (ADR-0016 §1 path 3 / §4).
177
+ * @param {number} now
178
+ * @returns {void}
179
+ */
180
+ tick(now) {
181
+ this._now = now;
182
+ if (this.gesture !== null && isIdleAbandoned(this.gesture, now, this.metrics.idleAbandonMs)) {
183
+ this.gesture = null;
184
+ this._dragKind = null;
185
+ this._dragEntries = null;
186
+ this.guides = [];
187
+ }
188
+ // `NudgeBracket` carries no `paused` field, but `isIdleAbandoned` treats
189
+ // an absent `paused` as `false` — the same generic reuse `nudge.js`'s
190
+ // own header describes for this exact function.
191
+ if (
192
+ this.nudgeBracket !== null &&
193
+ isIdleAbandoned(/** @type {any} */ (this.nudgeBracket), now, this.metrics.idleAbandonMs)
194
+ ) {
195
+ this.nudgeBracket = null;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Forgets everything about the previous document's selection and any
201
+ * in-progress interaction — used on a shared-core document swap
202
+ * (ADR-0024's `canvas-editor.js`, via `editing-host.js`'s `onLoadDocument`
203
+ * hook, ADR-0027 Ek 47). Without this, a stale `this.selection` id can
204
+ * survive the swap and, if the new document happens to reuse that SAME
205
+ * small integer for an unrelated container (real corpus documents share
206
+ * dozens of ids across separate imports — `import.js`'s per-call id
207
+ * restart), silently retarget an action onto a box the user never
208
+ * selected in the CURRENT document. Measured against real corpus
209
+ * documents (AA01.docx -> KK13.docx, a real shared container id): a
210
+ * keyboard nudge with an ENTIRELY absent stale id throws inside
211
+ * `movePatch` (neither it nor `rectOfLayout` null-checks its argument);
212
+ * with a PARTIALLY colliding stale id, the same nudge silently moved a
213
+ * real KK13 box the user never touched, and `deleteSelection()`
214
+ * separately, silently DELETED one — no error, no warning, either time.
215
+ *
216
+ * `getState`/`dispatch`/`readPageMetrics`/`metrics`/`ids` (constructor
217
+ * dependencies) and `_root`/`_rootListeners`/`_documentTarget`/
218
+ * `_documentListeners` (wiring handles set by `attach()`) are NOT
219
+ * touched — those describe the mount point and injected collaborators,
220
+ * not the loaded document, and stay valid across a document swap.
221
+ * `_now` is the host's own clock, also unrelated to which document is
222
+ * loaded.
223
+ * @returns {void}
224
+ */
225
+ reset() {
226
+ this.selection = [];
227
+ this.activePage = 1;
228
+ this._page = 1;
229
+ this.clipboard = null;
230
+ this.gesture = null;
231
+ this.nudgeBracket = null;
232
+ this.guides = [];
233
+ this.menu = null;
234
+ this._dragKind = null;
235
+ this._dragEntries = null;
236
+ this._anchorPoint = null;
237
+ this._anchorBounds = null;
238
+ this._pageRect = null;
239
+ this._marqueeBaseSelection = [];
240
+ this._marqueeAdditive = false;
241
+ }
242
+
243
+ // --- Pointer gesture: select / drag / resize / marquee ------------------
244
+
245
+ /**
246
+ * @typedef {object} PointerEventLike
247
+ * @property {number} pointerId
248
+ * @property {number} clientX
249
+ * @property {number} clientY
250
+ * @property {boolean} [shiftKey]
251
+ * @property {() => void} [preventDefault]
252
+ */
253
+
254
+ /** @param {PointerEventLike} event @returns {void} */
255
+ handlePointerDown(event) {
256
+ const pageRects = this.readPageMetrics();
257
+ const page = pageAtClientPoint(pageRects, event.clientX, event.clientY);
258
+ if (page === null) {
259
+ return;
260
+ }
261
+ const pageRect = pageRects[page - 1];
262
+ const point = clientToPagePoint(pageRect, event.clientX, event.clientY);
263
+
264
+ const plan = planGesture(this.gesture, {
265
+ type: "pointerdown",
266
+ pointerId: event.pointerId,
267
+ timestamp: this._now,
268
+ gestureId: this.ids.next(),
269
+ });
270
+ this.gesture = plan.gesture;
271
+ this._page = page;
272
+ this._pageRect = pageRect;
273
+ this._anchorPoint = point;
274
+
275
+ const state = this.getState();
276
+
277
+ if (this.selection.length === 1) {
278
+ const layout = layoutOf(state.doc, this.selection[0]);
279
+ if (layout !== null && layout.page === page) {
280
+ const handle = handleAtPoint(layout, this.metrics, point.x, point.y);
281
+ if (handle !== null) {
282
+ this._dragKind = "resize";
283
+ this._dragEntries = [
284
+ {
285
+ containerId: this.selection[0],
286
+ anchor: rectOfLayout(layout),
287
+ handle: /** @type {import("./gesture.js").DragEntry["handle"]} */ (handle),
288
+ },
289
+ ];
290
+ return;
291
+ }
292
+ }
293
+ }
294
+
295
+ const hitId = topBoxAtPoint(state.doc, page, point.x, point.y);
296
+ if (hitId === null) {
297
+ this._dragKind = "marquee";
298
+ this._marqueeAdditive = event.shiftKey === true;
299
+ this._marqueeBaseSelection = this._marqueeAdditive ? this.selection : [];
300
+ return;
301
+ }
302
+
303
+ if (event.shiftKey === true) {
304
+ this.selection = toggleSelection(this.selection, hitId);
305
+ } else if (!this.selection.includes(hitId)) {
306
+ this.selection = [hitId];
307
+ }
308
+
309
+ // `locked` withholds BOTH the resize handle (already true above, since
310
+ // `handleAtPoint` never offers one for a locked box) AND the body-move
311
+ // affordance (`BoxLayout.locked`'s own doc comment, `handles.js`'s
312
+ // `affordancesFor`) — selecting a locked box is still allowed (so it can
313
+ // be inspected or reached via the context menu), only dragging it is not.
314
+ const hitLayout = layoutOf(state.doc, hitId);
315
+ if (hitLayout !== null && !affordancesFor(hitLayout, this.metrics).move) {
316
+ return;
317
+ }
318
+
319
+ this._dragKind = "move";
320
+ this._dragEntries = this.selection.map((id) => ({
321
+ containerId: id,
322
+ anchor: rectOfLayout(/** @type {import("../model/node.js").BoxLayout} */ (layoutOf(state.doc, id))),
323
+ handle: /** @type {"body"} */ ("body"),
324
+ }));
325
+ this._anchorBounds = anchorBounds(state.doc, this.selection);
326
+ }
327
+
328
+ /** @param {PointerEventLike} event @returns {void} */
329
+ handlePointerMove(event) {
330
+ if (this.gesture === null || this._pageRect === null || this._anchorPoint === null) {
331
+ return;
332
+ }
333
+ const plan = planGesture(this.gesture, {
334
+ type: "pointermove",
335
+ pointerId: event.pointerId,
336
+ timestamp: this._now,
337
+ });
338
+ this.gesture = plan.gesture;
339
+ if (this.gesture === null) {
340
+ return;
341
+ }
342
+
343
+ const point = clientToPagePoint(this._pageRect, event.clientX, event.clientY);
344
+ let dx = point.x - this._anchorPoint.x;
345
+ let dy = point.y - this._anchorPoint.y;
346
+
347
+ if (this._dragKind === "marquee") {
348
+ const rect = { x: this._anchorPoint.x, y: this._anchorPoint.y, cx: dx, cy: dy };
349
+ const inMarquee = boxesInMarquee(this.getState().doc, this._page, rect);
350
+ this.selection = this._marqueeAdditive
351
+ ? [...new Set([...this._marqueeBaseSelection, ...inMarquee])]
352
+ : inMarquee;
353
+ return;
354
+ }
355
+
356
+ if (this._dragEntries === null) {
357
+ return;
358
+ }
359
+
360
+ this.guides = [];
361
+ // Snap only applies to a MOVE gesture (ADR-0018 Karar 6): resizing has
362
+ // its own per-edge key-set story, deferred to P3b-b.
363
+ if (this._dragKind === "move" && this._anchorBounds !== null) {
364
+ const movedOrigin = movePatch(this._anchorBounds, dx, dy);
365
+ const proposedRect = {
366
+ x: movedOrigin.x,
367
+ y: movedOrigin.y,
368
+ cx: this._anchorBounds.cx,
369
+ cy: this._anchorBounds.cy,
370
+ };
371
+ const targets = snapTargets(this.getState().doc, this._page, this.selection);
372
+ const adjustment = snapAdjustment(proposedRect, targets, this.metrics.snapThreshold);
373
+ dx += adjustment.dx;
374
+ dy += adjustment.dy;
375
+ this.guides = adjustment.guides;
376
+ }
377
+
378
+ const limits = { minCx: this.metrics.minBoxCx, minCy: this.metrics.minBoxCy };
379
+ const patches = framePatches(this._dragEntries, dx, dy, limits);
380
+ const state = this.getState();
381
+ const tr = transformBoxes(state, patches);
382
+ if (tr !== null) {
383
+ this.dispatch({ .../** @type {any} */ (tr), gestureId: this.gesture.gestureId });
384
+ }
385
+ }
386
+
387
+ /** @param {PointerEventLike} event @returns {void} */
388
+ handlePointerUp(event) {
389
+ if (this.gesture === null) {
390
+ return;
391
+ }
392
+ const plan = planGesture(this.gesture, {
393
+ type: "pointerup",
394
+ pointerId: event.pointerId,
395
+ timestamp: this._now,
396
+ });
397
+ this.gesture = plan.gesture;
398
+ this._dragKind = null;
399
+ this._dragEntries = null;
400
+ this._anchorBounds = null;
401
+ this.guides = [];
402
+ }
403
+
404
+ /** @param {{pointerId: number}} event @returns {void} */
405
+ handleLostPointerCapture(event) {
406
+ if (this.gesture === null) {
407
+ return;
408
+ }
409
+ const plan = planGesture(this.gesture, {
410
+ type: "lostpointercapture",
411
+ pointerId: event.pointerId,
412
+ timestamp: this._now,
413
+ });
414
+ this.gesture = plan.gesture;
415
+ }
416
+
417
+ /** @param {{hidden?: boolean}} documentTarget @returns {void} */
418
+ handleVisibilityChange(documentTarget) {
419
+ if (this.gesture === null) {
420
+ return;
421
+ }
422
+ const plan = planGesture(this.gesture, {
423
+ type: "visibilitychange",
424
+ hidden: documentTarget.hidden === true,
425
+ timestamp: this._now,
426
+ });
427
+ this.gesture = plan.gesture;
428
+ }
429
+
430
+ /** @returns {void} */
431
+ handleBlur() {
432
+ if (this.gesture === null) {
433
+ return;
434
+ }
435
+ const plan = planGesture(this.gesture, { type: "blur", timestamp: this._now });
436
+ this.gesture = plan.gesture;
437
+ }
438
+
439
+ /** @returns {void} */
440
+ handleFocus() {
441
+ if (this.gesture === null) {
442
+ return;
443
+ }
444
+ const plan = planGesture(this.gesture, { type: "focus", timestamp: this._now });
445
+ this.gesture = plan.gesture;
446
+ }
447
+
448
+ // --- Keyboard nudge -------------------------------------------------------
449
+
450
+ /** @param {{key: string, repeat?: boolean, preventDefault?: () => void}} event @returns {void} */
451
+ handleKeyDown(event) {
452
+ if (!/** @type {readonly string[]} */ (ARROW_KEYS).includes(event.key) || this.selection.length === 0) {
453
+ return;
454
+ }
455
+ event.preventDefault?.();
456
+
457
+ const plan = nudgeTransition(this.nudgeBracket, {
458
+ type: "keydown",
459
+ key: event.key,
460
+ repeat: event.repeat === true,
461
+ timestamp: this._now,
462
+ gestureId: this.ids.next(),
463
+ });
464
+ this.nudgeBracket = plan.bracket;
465
+
466
+ const { dx, dy } = nudgeDelta(event.key, this.metrics.nudgeStep);
467
+ const state = this.getState();
468
+ const patches = this.selection.map((id) => ({
469
+ containerId: id,
470
+ patch: movePatch(/** @type {import("../model/node.js").BoxLayout} */ (layoutOf(state.doc, id)), dx, dy),
471
+ }));
472
+ // origin stays "pointer" (see this file's header) — transformBoxes always
473
+ // builds it that way.
474
+ const tr = transformBoxes(state, patches);
475
+ if (tr !== null) {
476
+ this.dispatch({ .../** @type {any} */ (tr), gestureId: plan.bracket.gestureId });
477
+ }
478
+ }
479
+
480
+ /** @param {{key: string}} event @returns {void} */
481
+ handleKeyUp(event) {
482
+ if (keyUpSealsOpenBracket(this.nudgeBracket, { type: "keyup", key: event.key, timestamp: this._now })) {
483
+ this.nudgeBracket = null;
484
+ }
485
+ }
486
+
487
+ // --- Box clipboard ---------------------------------------------------------
488
+
489
+ /** @typedef {{preventDefault: () => void, clipboardData?: {setData: (type: string, value: string) => void} | null}} ClipboardEventLike */
490
+
491
+ /** @param {ClipboardEventLike} event @returns {void} */
492
+ handleCopy(event) {
493
+ if (this.selection.length === 0) {
494
+ return;
495
+ }
496
+ const state = this.getState();
497
+ const payload = serializeBoxes(state.doc, this.selection);
498
+ if (payload === null) {
499
+ return;
500
+ }
501
+ event.preventDefault();
502
+ event.clipboardData?.setData("text/plain", payloadToText(payload));
503
+ this.clipboard = payload;
504
+ }
505
+
506
+ /** @param {ClipboardEventLike} event @returns {void} */
507
+ handleCut(event) {
508
+ if (this.selection.length === 0) {
509
+ return;
510
+ }
511
+ const state = this.getState();
512
+ if (this.selection.length >= state.doc.content.length) {
513
+ // Refused ENTIRELY — no partial cut, nothing written to the clipboard
514
+ // (ADR-0018 §7.5): a half-finished cut would be worse than none.
515
+ if (isDev()) {
516
+ console.warn("word-editor: cut refused — would empty the document");
517
+ }
518
+ return;
519
+ }
520
+ const payload = serializeBoxes(state.doc, this.selection);
521
+ if (payload === null) {
522
+ return;
523
+ }
524
+ event.preventDefault();
525
+ event.clipboardData?.setData("text/plain", payloadToText(payload));
526
+ this.clipboard = payload;
527
+
528
+ const tr = createTransaction(state.doc, state.selection, "format");
529
+ for (const id of this.selection) {
530
+ tr.step(removeContainerStep(id));
531
+ }
532
+ if (tr.changesDoc) {
533
+ this.dispatch(tr.build());
534
+ }
535
+ this.selection = [];
536
+ }
537
+
538
+ /** @param {ClipboardEventLike} event @returns {void} */
539
+ handlePaste(event) {
540
+ if (this.clipboard === null) {
541
+ return;
542
+ }
543
+ const pageRects = this.readPageMetrics();
544
+ const pageRect = pageRects[this.activePage - 1];
545
+ if (pageRect === undefined) {
546
+ return;
547
+ }
548
+ const scale = pageRect.scale;
549
+ const pasted = pasteBoxes(
550
+ this.clipboard,
551
+ this.ids,
552
+ { page: this.activePage, width: pageRect.width / scale, height: pageRect.height / scale },
553
+ this.metrics.pasteOffset,
554
+ );
555
+ if (pasted.length === 0) {
556
+ return;
557
+ }
558
+
559
+ const state = this.getState();
560
+ const tr = createTransaction(state.doc, state.selection, "format");
561
+ for (const { container, layout } of pasted) {
562
+ tr.step(insertContainerStep(tr.doc.content.length, container, layout));
563
+ }
564
+ if (!tr.changesDoc) {
565
+ return;
566
+ }
567
+ event.preventDefault();
568
+ this.dispatch(tr.build());
569
+ this.selection = pasted.map((p) => p.container.id);
570
+ }
571
+
572
+ /**
573
+ * Removes the current selection. Unlike `handleCut`, a selection that
574
+ * would empty the document is not refused wholesale — it keeps the LAST
575
+ * remaining box and warns in dev (ADR-0018 §5.3): the command layer's own
576
+ * `deleteBox` already refuses a document's last container one at a time,
577
+ * this generalizes that same refusal to a batch.
578
+ * @returns {void}
579
+ */
580
+ deleteSelection() {
581
+ const state = this.getState();
582
+ if (this.selection.length === 0) {
583
+ return;
584
+ }
585
+ const wouldEmpty = this.selection.length >= state.doc.content.length;
586
+ const lastId = state.doc.content[state.doc.content.length - 1].id;
587
+ const idsToDelete = wouldEmpty ? this.selection.filter((id) => id !== lastId) : this.selection;
588
+ if (idsToDelete.length === 0) {
589
+ return;
590
+ }
591
+ if (wouldEmpty && isDev()) {
592
+ console.warn("word-editor: delete kept the document's last box");
593
+ }
594
+
595
+ const tr = createTransaction(state.doc, state.selection, "format");
596
+ for (const id of idsToDelete) {
597
+ tr.step(removeContainerStep(id));
598
+ }
599
+ if (tr.changesDoc) {
600
+ this.dispatch(tr.build());
601
+ }
602
+ this.selection = [];
603
+ }
604
+
605
+ // --- Context menu -----------------------------------------------------------
606
+
607
+ /** @param {{clientX: number, clientY: number, preventDefault: () => void}} event @returns {void} */
608
+ handleContextMenu(event) {
609
+ const pageRects = this.readPageMetrics();
610
+ const page = pageAtClientPoint(pageRects, event.clientX, event.clientY);
611
+ if (page === null) {
612
+ this.menu = null;
613
+ return;
614
+ }
615
+ event.preventDefault();
616
+ const point = clientToPagePoint(pageRects[page - 1], event.clientX, event.clientY);
617
+ this.menu = contextMenuModel(this.getState().doc, page, point, this.selection);
618
+ }
619
+
620
+ /**
621
+ * Executes one of `contextMenuModel`'s own action ids.
622
+ * @param {import("./menu.js").MenuAction["id"]} actionId
623
+ * @returns {void}
624
+ */
625
+ runMenuAction(actionId) {
626
+ switch (actionId) {
627
+ case "delete":
628
+ this.deleteSelection();
629
+ return;
630
+ case "front":
631
+ case "back":
632
+ case "forward":
633
+ case "backward": {
634
+ if (this.selection.length === 0) {
635
+ return;
636
+ }
637
+ const state = this.getState();
638
+ const layout = layoutOf(state.doc, this.selection[0]);
639
+ if (layout === null) {
640
+ return;
641
+ }
642
+ const entries = zOrderPlan(state.doc, layout.page, this.selection, actionId, this.metrics.minZ);
643
+ if (entries.length === 0) {
644
+ return;
645
+ }
646
+ const tr = setBoxZOrder(state, entries);
647
+ if (tr !== null) {
648
+ this.dispatch(tr);
649
+ }
650
+ return;
651
+ }
652
+ default:
653
+ return;
654
+ }
655
+ }
656
+
657
+ // --- Wiring -----------------------------------------------------------------
658
+ //
659
+ // Every handler above is a plain method a test calls directly with a
660
+ // hand-built fake event, the same convention `handleBeforeInput`/
661
+ // `handlePaste` already use (`src/input/`) — `attach`/`detach` are ONLY the
662
+ // thin real-DOM wiring on top, mirroring `SelectionController.attach`'s own
663
+ // "takes the event target as a parameter, never a stored global" shape.
664
+
665
+ /**
666
+ * @param {{addEventListener: (type: string, listener: (event: any) => void) => void, removeEventListener: (type: string, listener: (event: any) => void) => void}} root
667
+ * @param {{addEventListener: (type: string, listener: (event: any) => void) => void, removeEventListener: (type: string, listener: (event: any) => void) => void, hidden?: boolean}} documentTarget
668
+ * @returns {void}
669
+ */
670
+ attach(root, documentTarget) {
671
+ this._rootListeners = {
672
+ pointerdown: (/** @type {any} */ e) => this.handlePointerDown(e),
673
+ pointermove: (/** @type {any} */ e) => this.handlePointerMove(e),
674
+ pointerup: (/** @type {any} */ e) => this.handlePointerUp(e),
675
+ pointercancel: (/** @type {any} */ e) => this.handlePointerUp(e),
676
+ lostpointercapture: (/** @type {any} */ e) => this.handleLostPointerCapture(e),
677
+ contextmenu: (/** @type {any} */ e) => this.handleContextMenu(e),
678
+ keydown: (/** @type {any} */ e) => this.handleKeyDown(e),
679
+ keyup: (/** @type {any} */ e) => this.handleKeyUp(e),
680
+ copy: (/** @type {any} */ e) => this.handleCopy(e),
681
+ cut: (/** @type {any} */ e) => this.handleCut(e),
682
+ paste: (/** @type {any} */ e) => this.handlePaste(e),
683
+ };
684
+ for (const [type, listener] of Object.entries(this._rootListeners)) {
685
+ root.addEventListener(type, listener);
686
+ }
687
+ this._root = root;
688
+
689
+ this._documentListeners = {
690
+ visibilitychange: () => this.handleVisibilityChange(documentTarget),
691
+ blur: () => this.handleBlur(),
692
+ focus: () => this.handleFocus(),
693
+ };
694
+ for (const [type, listener] of Object.entries(this._documentListeners)) {
695
+ documentTarget.addEventListener(type, listener);
696
+ }
697
+ this._documentTarget = documentTarget;
698
+ }
699
+
700
+ /** @returns {void} */
701
+ detach() {
702
+ if (this._root !== undefined && this._rootListeners !== undefined) {
703
+ for (const [type, listener] of Object.entries(this._rootListeners)) {
704
+ this._root.removeEventListener(type, listener);
705
+ }
706
+ this._root = undefined;
707
+ this._rootListeners = undefined;
708
+ }
709
+ if (this._documentTarget !== undefined && this._documentListeners !== undefined) {
710
+ for (const [type, listener] of Object.entries(this._documentListeners)) {
711
+ this._documentTarget.removeEventListener(type, listener);
712
+ }
713
+ this._documentTarget = undefined;
714
+ this._documentListeners = undefined;
715
+ }
716
+ }
717
+ }