@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.
- package/CHANGELOG.md +108 -70
- package/LICENSE +21 -21
- package/README.md +286 -247
- package/dist/canvas/box-clipboard.js +238 -238
- package/dist/canvas/box-selection.js +80 -80
- package/dist/canvas/canvas-editor.js +502 -502
- package/dist/canvas/controller.js +717 -717
- package/dist/canvas/geometry.js +131 -131
- package/dist/canvas/gesture.d.ts +0 -1
- package/dist/canvas/gesture.js +289 -289
- package/dist/canvas/handles.js +212 -212
- package/dist/canvas/menu.js +82 -82
- package/dist/canvas/mode-controller.js +81 -81
- package/dist/canvas/nudge.js +108 -108
- package/dist/canvas/overlay.js +149 -149
- package/dist/canvas/snap.js +132 -132
- package/dist/canvas/viewport.js +112 -112
- package/dist/canvas/z-order.js +171 -171
- package/dist/commands/alignment.js +150 -150
- package/dist/commands/box.js +272 -272
- package/dist/commands/clipboard.js +93 -93
- package/dist/commands/graphemes.js +112 -112
- package/dist/commands/marks.js +153 -153
- package/dist/commands/paragraph-props.js +176 -176
- package/dist/commands/size.js +368 -368
- package/dist/commands/text.js +291 -291
- package/dist/docx.d.ts +1 -0
- package/dist/docx.js +15 -13
- package/dist/editor/editing-host.js +434 -434
- package/dist/element.js +41 -41
- package/dist/fonts/OFL-Carlito.txt +94 -0
- package/dist/fonts/calibri-metrics.d.ts +27 -0
- package/dist/fonts/calibri-metrics.js +88 -0
- package/dist/history/history.js +433 -433
- package/dist/index.js +63 -63
- package/dist/input/beforeinput.js +224 -224
- package/dist/input/clipboard.js +107 -107
- package/dist/input/composition.js +341 -341
- package/dist/input/keymap.js +54 -54
- package/dist/io/ooxml/custgeom.js +72 -72
- package/dist/io/ooxml/drawingml.js +232 -232
- package/dist/io/ooxml/edits.js +67 -67
- package/dist/io/ooxml/export-splice.js +880 -880
- package/dist/io/ooxml/export.js +365 -365
- package/dist/io/ooxml/fill.d.ts +93 -8
- package/dist/io/ooxml/fill.js +618 -562
- package/dist/io/ooxml/fit.d.ts +252 -0
- package/dist/io/ooxml/fit.js +598 -0
- package/dist/io/ooxml/import-gate.js +200 -200
- package/dist/io/ooxml/import.js +691 -691
- package/dist/io/ooxml/inflate.js +126 -126
- package/dist/io/ooxml/media.js +87 -87
- package/dist/io/ooxml/ns.d.ts +2 -0
- package/dist/io/ooxml/ns.js +26 -21
- package/dist/io/ooxml/numbering.js +290 -290
- package/dist/io/ooxml/opc-write.js +171 -171
- package/dist/io/ooxml/provenance.js +93 -93
- package/dist/io/ooxml/recipe.js +708 -708
- package/dist/io/ooxml/relationships.js +59 -59
- package/dist/io/ooxml/run-fonts.d.ts +89 -0
- package/dist/io/ooxml/run-fonts.js +378 -0
- package/dist/io/ooxml/slots.d.ts +4 -0
- package/dist/io/ooxml/slots.js +582 -581
- package/dist/io/ooxml/styles-resolve.js +257 -257
- package/dist/io/ooxml/units.js +43 -43
- package/dist/io/ooxml/value.js +120 -120
- package/dist/io/ooxml/xml-scan.js +616 -616
- package/dist/io/ooxml/zip-container.js +228 -228
- package/dist/io/ooxml/zip-reject.js +106 -106
- package/dist/measure/placement.js +83 -83
- package/dist/measure/report.js +75 -75
- package/dist/model/ids.js +70 -70
- package/dist/model/normalize.js +146 -146
- package/dist/model/position.js +283 -283
- package/dist/model/transaction.js +209 -209
- package/dist/platform/capabilities.js +25 -25
- package/dist/react/index.js +21 -21
- package/dist/selection/controller.js +315 -315
- package/dist/selection/dom-map.js +334 -334
- package/dist/state/editor-state.js +109 -109
- package/dist/styles.css +66 -66
- 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
|
+
}
|