@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,502 +1,502 @@
|
|
|
1
|
-
// The unified canvas editor (ADR-0024, P3b mode-merge parça 2). It is the ONE
|
|
2
|
-
// owner of the shared core — a single EditorState, a single HistoryEngine, and
|
|
3
|
-
// a single Renderer — and it wires BOTH surfaces onto that core:
|
|
4
|
-
//
|
|
5
|
-
// - the BOX surface: a `CanvasController` (select / drag / resize a box),
|
|
6
|
-
// dispatching through `dispatchBox` — which renders WITHOUT moving the text
|
|
7
|
-
// caret, because a box move must not place a caret;
|
|
8
|
-
// - the TEXT surface: a `createEditingHost` (type into a box), whose own
|
|
9
|
-
// dispatch renders AND restores the caret. It is given the SAME core and
|
|
10
|
-
// renderer, so a text edit and a box move land in ONE model with ONE undo
|
|
11
|
-
// stack — never two divergent copies. That is the whole point of ADR-0024.
|
|
12
|
-
//
|
|
13
|
-
// parça 2 bound the two onto the shared core and stood up the ModeController;
|
|
14
|
-
// parça 3 (this file's `planPointerDown` / `handlePointerDown` /
|
|
15
|
-
// `handleDoubleClick` / `attach`) routes clicks through the mode gate and wires
|
|
16
|
-
// the text host to the real DOM, so a double-click enters text mode and typing
|
|
17
|
-
// edits the box. The empty-box×mode hit-test refinement (ADR-0021) is parça 4.
|
|
18
|
-
//
|
|
19
|
-
// No `window`/`document` global is touched at import time (invariant #5): every
|
|
20
|
-
// DOM reference is injected (`root`, `document`, `readPageMetrics`).
|
|
21
|
-
|
|
22
|
-
import { applyTransaction, createEditorState } from "../state/editor-state.js";
|
|
23
|
-
import { HistoryEngine } from "../history/history.js";
|
|
24
|
-
import { recordForwardDirty, verifyInverseDirty } from "../io/ooxml/export.js";
|
|
25
|
-
import { Renderer } from "../render/renderer.js";
|
|
26
|
-
import { createEditingHost } from "../editor/editing-host.js";
|
|
27
|
-
import { CanvasController } from "./controller.js";
|
|
28
|
-
import { ModeController } from "./mode-controller.js";
|
|
29
|
-
import { topBoxAtPoint } from "./hit-test.js";
|
|
30
|
-
import { pageAtClientPoint, clientToPagePoint } from "./viewport.js";
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* @typedef {object} PointerRoute
|
|
34
|
-
* @property {boolean} preventDefault suppress the browser default (so no native
|
|
35
|
-
* text-selection drag starts under a box move) — box behaviour only.
|
|
36
|
-
* @property {boolean} toController hand the event to the box CanvasController.
|
|
37
|
-
* @property {boolean} exitToBox leave text mode first (a text-mode click that
|
|
38
|
-
* landed outside the active box).
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Decide what a left pointerdown means, given the current mode and what box (if
|
|
43
|
-
* any) is under it (ADR-0024 parça 3 click-routing). PURE — no DOM, no state —
|
|
44
|
-
* so the whole routing table is node-testable in isolation. The `dblclick`
|
|
45
|
-
* listener, not this, is what flips INTO text mode; the empty-box×mode
|
|
46
|
-
* refinement (ADR-0021 filledBeforeEmpty under a double-click) is parça 4;
|
|
47
|
-
* here `hitBoxId` is a plain topmost-box hit.
|
|
48
|
-
*
|
|
49
|
-
* 🔴 There used to be a THIRD branch here — an `isSecondClick` parameter
|
|
50
|
-
* (`event.detail >= 2`) meant to let a double-click's second pointerdown skip
|
|
51
|
-
* `preventDefault` so the native caret would land. It never fired: measured
|
|
52
|
-
* real, `isTrusted` events from an actual double-click (Chrome, this
|
|
53
|
-
* session) —
|
|
54
|
-
* pointerdown detail=0 · mousedown detail=1 · click detail=1
|
|
55
|
-
* pointerdown detail=0 · mousedown detail=2 · click detail=2 · dblclick detail=2
|
|
56
|
-
* — `pointerdown.detail` is `0` on BOTH clicks; only `MouseEvent`s
|
|
57
|
-
* (`mousedown`/`click`/`dblclick`) carry the real click count. So
|
|
58
|
-
* `isSecondClick` was always `false`, this branch was structurally dead, and
|
|
59
|
-
* the caret restoration this comment used to promise never happened —
|
|
60
|
-
* `handleDoubleClick` below now owns that job directly, using `dblclick`,
|
|
61
|
-
* whose `detail` a real browser DOES set correctly. ⚠ Only Chrome measured
|
|
62
|
-
* here — Firefox/Safari's `PointerEvent.detail` is a DOĞRULANMALI, not
|
|
63
|
-
* assumed identical (`docs/browser-matrix.md`).
|
|
64
|
-
*
|
|
65
|
-
* @param {object} at
|
|
66
|
-
* @param {import("./mode-controller.js").Mode} at.mode
|
|
67
|
-
* @param {import("../model/node.js").NodeId | null} at.activeContainerId
|
|
68
|
-
* @param {import("../model/node.js").NodeId | null} at.hitBoxId topmost box under the point, or null
|
|
69
|
-
* @returns {PointerRoute}
|
|
70
|
-
*/
|
|
71
|
-
export function planPointerDown({ mode, activeContainerId, hitBoxId }) {
|
|
72
|
-
// Already in text mode and the click is INSIDE the active box: native caret
|
|
73
|
-
// repositions; stay in text mode, keep the box controller out of it.
|
|
74
|
-
if (mode === "text" && hitBoxId !== null && hitBoxId === activeContainerId) {
|
|
75
|
-
return { preventDefault: false, toController: false, exitToBox: false };
|
|
76
|
-
}
|
|
77
|
-
// Everything else is box behaviour: a box-mode click (both clicks of a
|
|
78
|
-
// double-click included — see the module comment above), or a text-mode
|
|
79
|
-
// click on a DIFFERENT box / empty space (which first leaves text mode).
|
|
80
|
-
// preventDefault then route to the box controller.
|
|
81
|
-
return { preventDefault: true, toController: true, exitToBox: mode === "text" };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* @typedef {object} CanvasEditorOptions
|
|
86
|
-
* @property {any} root the element the renderer mounts into (the box hosts and
|
|
87
|
-
* the per-box contenteditable text hosts both live under it).
|
|
88
|
-
* @property {import("../render/dom.js").DocumentLike} document owner document.
|
|
89
|
-
* @property {import("../model/node.js").Doc} doc the initial document.
|
|
90
|
-
* @property {import("../model/ids.js").IdFactory} ids
|
|
91
|
-
* @property {import("./controller.js").CanvasMetrics} metrics
|
|
92
|
-
* @property {() => import("./viewport.js").PageRect[]} readPageMetrics live page
|
|
93
|
-
* geometry for hit-testing (index 0 is page 1). Injected because it reads the
|
|
94
|
-
* real DOM (`getBoundingClientRect`); a test passes a fixed set of rects.
|
|
95
|
-
* @property {() => import("../selection/controller.js").DomSelectionLike | null} [getDomSelection]
|
|
96
|
-
* @property {(event: {clientX: number, clientY: number}) => {node: any, offset: number} | null} [resolveCaretAtPoint]
|
|
97
|
-
* Real double-click caret placement (parça 3 fix). `handleDoubleClick`
|
|
98
|
-
* ALWAYS reaches a box whose pointerdown already `preventDefault`-ed (every
|
|
99
|
-
* pointerdown does — see `planPointerDown`'s own comment on why
|
|
100
|
-
* `isSecondClick` never worked), so the browser's native "second click
|
|
101
|
-
* places the caret" never happens on its own; this restores it explicitly.
|
|
102
|
-
* Reads real pixel coordinates, so — like `readPageMetrics` — it is
|
|
103
|
-
* injected rather than called directly: a test supplies a fake DOM
|
|
104
|
-
* {node, offset}, the real app resolves one via
|
|
105
|
-
* `document.caretPositionFromPoint`/`caretRangeFromPoint`. Returns a RAW DOM
|
|
106
|
-
* position, not a model one — `handleDoubleClick` only needs to WRITE it
|
|
107
|
-
* into the live `Selection` (`getDomSelection().setBaseAndExtent(...)`);
|
|
108
|
-
* the existing `selectionchange` → `SelectionController.handleSelectionChange`
|
|
109
|
-
* → `domToModel` pipeline (unchanged, already tested) adopts it into
|
|
110
|
-
* `state.selection` exactly as it would a genuine unblocked click — no new
|
|
111
|
-
* model-update path. Omitted → mode still flips correctly, only the caret
|
|
112
|
-
* placement degrades (a SECOND click inside the now-active box still works,
|
|
113
|
-
* via `planPointerDown`'s own `mode==="text" && hitBoxId===activeContainerId`
|
|
114
|
-
* branch).
|
|
115
|
-
* @property {(state: import("../state/editor-state.js").EditorState) => void} [onChange]
|
|
116
|
-
* @property {(plan: import("../render/renderer.js").RenderPlan) => void} [onRender]
|
|
117
|
-
* fires after EVERY projection of the shared model — box dispatch, text
|
|
118
|
-
* dispatch, undo, redo, and the explicit `render()`. The canvas measure loop
|
|
119
|
-
* hangs `observe(getState().doc, plan)` here (ADR-0019 §2.3: the doc it reads
|
|
120
|
-
* is `renderer.lastDoc` at this instant).
|
|
121
|
-
* @property {() => number} [now] the clock stamped onto box transactions so
|
|
122
|
-
* `HistoryEngine` can coalesce a drag's frames. Defaults to a constant 0 (a
|
|
123
|
-
* test drives its own clock); the browser passes `() => performance.now()`.
|
|
124
|
-
*/
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* @param {CanvasEditorOptions} options
|
|
128
|
-
*/
|
|
129
|
-
export function createCanvasEditor({
|
|
130
|
-
root,
|
|
131
|
-
document,
|
|
132
|
-
doc,
|
|
133
|
-
ids,
|
|
134
|
-
metrics,
|
|
135
|
-
readPageMetrics,
|
|
136
|
-
getDomSelection,
|
|
137
|
-
resolveCaretAtPoint,
|
|
138
|
-
onChange,
|
|
139
|
-
onRender,
|
|
140
|
-
now = () => 0,
|
|
141
|
-
}) {
|
|
142
|
-
// The shared core: one state cell, one history. Both surfaces mutate THIS.
|
|
143
|
-
const state = { value: createEditorState(doc) };
|
|
144
|
-
const history = new HistoryEngine();
|
|
145
|
-
// P6 parça (5) / Dilim B: which layout keys a FORWARD setLayout step has
|
|
146
|
-
// named, recorded by `recordForwardDirty` on every forward dispatch and
|
|
147
|
-
// every redo (both re-apply a forward step); `verifyInverseDirty` checks
|
|
148
|
-
// every undo's inverse against it (HIST7 — see export.js's own module
|
|
149
|
-
// comment for why that check, not a same-set check, is the one that does
|
|
150
|
-
// not false-positive). Also exposed for `exportDocx`'s optional
|
|
151
|
-
// historyDirty consistency gate (a different regression: a forgotten
|
|
152
|
-
// wiring). NOT the export dirty source — a taşı-sonra-geri-al still exports
|
|
153
|
-
// byte-identical because that decision stays with dirtyGeometry's value
|
|
154
|
-
// compare.
|
|
155
|
-
/** @type {Map<import("../model/node.js").NodeId, Set<string>>} */
|
|
156
|
-
const historyDirty = new Map();
|
|
157
|
-
/** @type {import("../editor/editing-host.js").EditorCore} */
|
|
158
|
-
const core = {
|
|
159
|
-
getState: () => state.value,
|
|
160
|
-
setState: (next) => {
|
|
161
|
-
state.value = next;
|
|
162
|
-
},
|
|
163
|
-
history,
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
// One renderer, shared by both surfaces. The facade owns its lifecycle
|
|
167
|
-
// (initial paint via render(), teardown via destroy()); the injected host
|
|
168
|
-
// renders into it but never creates or destroys it.
|
|
169
|
-
const renderer = new Renderer(root, document);
|
|
170
|
-
|
|
171
|
-
const mode = new ModeController();
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Project the shared model for a BOX operation: render, then hand the plan to
|
|
175
|
-
* onRender — but do NOT restore the text caret. A box move/resize must never
|
|
176
|
-
* place a caret in a box (the text surface's job, parça 3). This is the box
|
|
177
|
-
* half of ADR-0024's caret split; the text half lives in editing-host's own
|
|
178
|
-
* renderAndRestore.
|
|
179
|
-
* @returns {void}
|
|
180
|
-
*/
|
|
181
|
-
function renderBox() {
|
|
182
|
-
const plan = renderer.render(core.getState().doc);
|
|
183
|
-
if (onRender !== undefined) {
|
|
184
|
-
onRender(plan);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* The box surface's dispatch (given to CanvasController). Stamps the clock so
|
|
190
|
-
* a drag's frames coalesce, folds into the SHARED core, and renders without a
|
|
191
|
-
* caret move.
|
|
192
|
-
* @param {import("../model/transaction.js").Transaction} tr
|
|
193
|
-
* @returns {void}
|
|
194
|
-
*/
|
|
195
|
-
function dispatchBox(tr) {
|
|
196
|
-
const stamped = { ...tr, timestamp: now() };
|
|
197
|
-
history.push(stamped);
|
|
198
|
-
recordForwardDirty(historyDirty, stamped.docBefore, stamped.steps);
|
|
199
|
-
core.setState(applyTransaction(core.getState(), stamped));
|
|
200
|
-
if (onChange !== undefined) {
|
|
201
|
-
onChange(core.getState());
|
|
202
|
-
}
|
|
203
|
-
renderBox();
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const controller = new CanvasController({
|
|
207
|
-
getState: core.getState,
|
|
208
|
-
dispatch: dispatchBox,
|
|
209
|
-
readPageMetrics,
|
|
210
|
-
metrics,
|
|
211
|
-
ids,
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
// The text surface. SAME core, SAME renderer -> one model, one undo stack.
|
|
215
|
-
// getMode lets its SelectionController read the box/text mode (inert until
|
|
216
|
-
// parça 3 routes on it). Deliberately NOT attached this turn: no typing yet.
|
|
217
|
-
const host = createEditingHost({
|
|
218
|
-
root,
|
|
219
|
-
document,
|
|
220
|
-
doc,
|
|
221
|
-
ids,
|
|
222
|
-
getDomSelection,
|
|
223
|
-
onChange,
|
|
224
|
-
onRender,
|
|
225
|
-
core,
|
|
226
|
-
renderer,
|
|
227
|
-
getMode: () => mode.mode,
|
|
228
|
-
// ADR-0027 Ek 47: editing-host.js has no visibility into CanvasController
|
|
229
|
-
// (architectural boundary), so a document swap through `host.loadDocument`
|
|
230
|
-
// needs this hook to also reset the box surface's own selection/gesture/
|
|
231
|
-
// drag state — otherwise a stale box selection id can silently retarget a
|
|
232
|
-
// nudge/delete onto an unrelated box in the new document, measured against
|
|
233
|
-
// real corpus documents.
|
|
234
|
-
//
|
|
235
|
-
// ADR-0027 Ek 49: the hook also receives the freshly-reseeded IdFactory
|
|
236
|
-
// editing-host.js just built for the new document (past every id already
|
|
237
|
-
// used in it) and applies it to `controller.ids` — the SAME object
|
|
238
|
-
// `inputContext.ids` was just mutated to (both were sourced from this
|
|
239
|
-
// function's single `ids` parameter at construction). Without this,
|
|
240
|
-
// `controller`'s paste/gesture id minting would keep using the OLD
|
|
241
|
-
// document's factory, which can hand out ids that collide with real
|
|
242
|
-
// containers in the new document.
|
|
243
|
-
onLoadDocument: (newIds) => {
|
|
244
|
-
controller.reset();
|
|
245
|
-
controller.ids = newIds;
|
|
246
|
-
},
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Undo/redo on the SHARED history, rendered the box way (no caret move) —
|
|
251
|
-
* box-mode is the only surface live this turn. Returns the applied inverse
|
|
252
|
-
* transaction, or null when the stack was empty.
|
|
253
|
-
*
|
|
254
|
-
* `history.undo()`'s own steps are the INVERSE ones (`history.js`: `steps:
|
|
255
|
-
* inverted`) — checked with `verifyInverseDirty`, never recorded as new
|
|
256
|
-
* forward names: an inverse must only confirm keys a forward step already
|
|
257
|
-
* named, never introduce one (HIST7).
|
|
258
|
-
* @returns {import("../model/transaction.js").Transaction | null}
|
|
259
|
-
*/
|
|
260
|
-
function undo() {
|
|
261
|
-
const tr = history.undo(core.getState());
|
|
262
|
-
if (tr === null) {
|
|
263
|
-
return null;
|
|
264
|
-
}
|
|
265
|
-
verifyInverseDirty(historyDirty, tr.docBefore, tr.steps);
|
|
266
|
-
core.setState(applyTransaction(core.getState(), tr));
|
|
267
|
-
if (onChange !== undefined) {
|
|
268
|
-
onChange(core.getState());
|
|
269
|
-
}
|
|
270
|
-
renderBox();
|
|
271
|
-
return tr;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* `history.redo()`'s own steps are the ORIGINAL forward ones re-applied
|
|
276
|
-
* (`history.js`: `steps: entry.steps.slice()`) — recorded the same way a
|
|
277
|
-
* fresh forward dispatch is.
|
|
278
|
-
* @returns {import("../model/transaction.js").Transaction | null}
|
|
279
|
-
*/
|
|
280
|
-
function redo() {
|
|
281
|
-
const tr = history.redo(core.getState());
|
|
282
|
-
if (tr === null) {
|
|
283
|
-
return null;
|
|
284
|
-
}
|
|
285
|
-
recordForwardDirty(historyDirty, tr.docBefore, tr.steps);
|
|
286
|
-
core.setState(applyTransaction(core.getState(), tr));
|
|
287
|
-
if (onChange !== undefined) {
|
|
288
|
-
onChange(core.getState());
|
|
289
|
-
}
|
|
290
|
-
renderBox();
|
|
291
|
-
return tr;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* The topmost box under a pointer event, using live page geometry — the simple
|
|
296
|
-
* hit-test parça 3 routes on (ADR-0024; the empty-box×mode refinement is parça 4).
|
|
297
|
-
* @param {{ clientX: number, clientY: number }} event
|
|
298
|
-
* @returns {import("../model/node.js").NodeId | null}
|
|
299
|
-
*/
|
|
300
|
-
function hitTestBox(event) {
|
|
301
|
-
const pageRects = readPageMetrics();
|
|
302
|
-
const page = pageAtClientPoint(pageRects, event.clientX, event.clientY);
|
|
303
|
-
if (page === null) {
|
|
304
|
-
return null;
|
|
305
|
-
}
|
|
306
|
-
const point = clientToPagePoint(pageRects[page - 1], event.clientX, event.clientY);
|
|
307
|
-
return topBoxAtPoint(core.getState().doc, page, point.x, point.y);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Route a left pointerdown through the mode gate (ADR-0024 parça 3). In box
|
|
312
|
-
* mode it is exactly today's behaviour — preventDefault, then the box
|
|
313
|
-
* controller. In text mode a click inside the active box falls through to the
|
|
314
|
-
* native caret; a click elsewhere leaves text mode and behaves as box. Returns
|
|
315
|
-
* the routing decision (for tests / callers that also want to sync visuals).
|
|
316
|
-
* @param {{ clientX: number, clientY: number, pointerId: number, preventDefault?: () => void }} event
|
|
317
|
-
* @returns {PointerRoute}
|
|
318
|
-
*/
|
|
319
|
-
function handlePointerDown(event) {
|
|
320
|
-
const decision = planPointerDown({
|
|
321
|
-
mode: mode.mode,
|
|
322
|
-
activeContainerId: mode.activeContainerId,
|
|
323
|
-
hitBoxId: hitTestBox(event),
|
|
324
|
-
});
|
|
325
|
-
if (decision.exitToBox) {
|
|
326
|
-
mode.exitToBoxMode();
|
|
327
|
-
}
|
|
328
|
-
if (decision.preventDefault && typeof event.preventDefault === "function") {
|
|
329
|
-
event.preventDefault();
|
|
330
|
-
}
|
|
331
|
-
if (decision.toController) {
|
|
332
|
-
controller.handlePointerDown(event);
|
|
333
|
-
}
|
|
334
|
-
return decision;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* A double-click on a box enters text mode on it (ADR-0024 §1) AND places the
|
|
339
|
-
* caret at the click point. Returns the box entered, or null when the
|
|
340
|
-
* double-click missed every box.
|
|
341
|
-
*
|
|
342
|
-
* 🔴 The caret half is NOT free. `planPointerDown`'s own comment: BOTH
|
|
343
|
-
* pointerdowns of a double-click always `preventDefault` (measured —
|
|
344
|
-
* `PointerEvent.detail` is 0 on both, so the "second click, let it through"
|
|
345
|
-
* branch this function's docstring used to promise never existed in
|
|
346
|
-
* practice), so the browser's native "second click places a caret" never
|
|
347
|
-
* happens on its own. `dblclick` is the one event that reliably fires with a
|
|
348
|
-
* real click count (`detail`) AFTER the fact, so THIS handler restores the
|
|
349
|
-
* caret explicitly, using the SAME point.
|
|
350
|
-
* @param {{ clientX: number, clientY: number }} event
|
|
351
|
-
* @returns {import("../model/node.js").NodeId | null}
|
|
352
|
-
*/
|
|
353
|
-
function handleDoubleClick(event) {
|
|
354
|
-
const hitBoxId = hitTestBox(event);
|
|
355
|
-
if (hitBoxId === null) {
|
|
356
|
-
return null;
|
|
357
|
-
}
|
|
358
|
-
mode.enterTextMode(hitBoxId);
|
|
359
|
-
placeCaretAtPoint(event, hitBoxId);
|
|
360
|
-
return hitBoxId;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Writes a real DOM caret into `hitBoxId`'s contenteditable at `event`'s
|
|
365
|
-
* point, when the capability was injected. Deliberately does NOT touch
|
|
366
|
-
* `state.selection` itself: it focuses the box and calls the native
|
|
367
|
-
* `Selection.setBaseAndExtent`, and the ALREADY-ATTACHED `selectionchange`
|
|
368
|
-
* listener (`SelectionController.handleSelectionChange`, wired by
|
|
369
|
-
* `host.attach`) adopts it into the shared core exactly as it would a
|
|
370
|
-
* genuine unblocked click — one adoption path, not two. A no-op (mode stays
|
|
371
|
-
* flipped, no caret) when `resolveCaretAtPoint`/`getDomSelection` was not
|
|
372
|
-
* injected, or when the point did not resolve to a real DOM position.
|
|
373
|
-
* @param {{ clientX: number, clientY: number }} event
|
|
374
|
-
* @param {import("../model/node.js").NodeId} hitBoxId
|
|
375
|
-
* @returns {void}
|
|
376
|
-
*/
|
|
377
|
-
function placeCaretAtPoint(event, hitBoxId) {
|
|
378
|
-
if (resolveCaretAtPoint === undefined || getDomSelection === undefined) {
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
const domCaret = resolveCaretAtPoint(event);
|
|
382
|
-
if (domCaret === null) {
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
const domSelection = getDomSelection();
|
|
386
|
-
if (domSelection === null) {
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
// Focus BEFORE setting the selection: the reverse order risks a browser
|
|
390
|
-
// collapsing the just-placed selection back to the focused element's start.
|
|
391
|
-
focusContainer(hitBoxId);
|
|
392
|
-
domSelection.setBaseAndExtent(domCaret.node, domCaret.offset, domCaret.node, domCaret.offset);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Puts real DOM focus on one box's contenteditable.
|
|
397
|
-
*
|
|
398
|
-
* A native click would do this as part of its own default action — the very
|
|
399
|
-
* thing `planPointerDown`'s `preventDefault` suppresses — so every path that
|
|
400
|
-
* wants a box focused has to say so explicitly. There is exactly one such
|
|
401
|
-
* expression, here, and two callers: `placeCaretAtPoint` (double-click) and
|
|
402
|
-
* `focusActiveContainer` (ADR-0032's ribbon return).
|
|
403
|
-
*
|
|
404
|
-
* @param {import("../model/node.js").NodeId} containerId
|
|
405
|
-
* @returns {boolean} whether an element was actually focused
|
|
406
|
-
*/
|
|
407
|
-
function focusContainer(containerId) {
|
|
408
|
-
const containerElement = renderer.getContainerElement(containerId);
|
|
409
|
-
if (containerElement === null || typeof containerElement.focus !== "function") {
|
|
410
|
-
return false;
|
|
411
|
-
}
|
|
412
|
-
containerElement.focus({ preventScroll: true });
|
|
413
|
-
return true;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Hands DOM focus back to the box being text-edited (ADR-0032).
|
|
418
|
-
*
|
|
419
|
-
* A ribbon control that TAKES focus — the point-size box, the two indent
|
|
420
|
-
* fields — must give it back when it is done, on Enter and on Escape alike.
|
|
421
|
-
* Until this existed, focus came back only as a SIDE EFFECT: a commit that
|
|
422
|
-
* changed the document dispatched, which rendered, which called
|
|
423
|
-
* `restoreSelection`, whose `setBaseAndExtent` implicitly moves focus into
|
|
424
|
-
* the contenteditable. A commit that changed NOTHING skipped that whole
|
|
425
|
-
* chain, so focus stayed in the ribbon field and the user's next keystrokes
|
|
426
|
-
* went into it silently — measured in a real browser, the box read "11ZQ7".
|
|
427
|
-
*
|
|
428
|
-
* 🔴 Deliberately NOT wired into `restoreSelection` itself. That would make
|
|
429
|
-
* every render steal focus, including the renders that happen WHILE the user
|
|
430
|
-
* is typing in a ribbon field — exactly what the `activeElement` guards in
|
|
431
|
-
* the demo's refresh path exist to avoid. The return is opt-in: the commit
|
|
432
|
-
* handler's explicit second step.
|
|
433
|
-
*
|
|
434
|
-
* A no-op in box mode (no box is being edited, so there is nothing to
|
|
435
|
-
* return focus to) — the caller does not have to check the mode first.
|
|
436
|
-
*
|
|
437
|
-
* @returns {boolean} whether focus was actually moved
|
|
438
|
-
*/
|
|
439
|
-
function focusActiveContainer() {
|
|
440
|
-
const containerId = mode.activeContainerId;
|
|
441
|
-
if (containerId === null) {
|
|
442
|
-
return false;
|
|
443
|
-
}
|
|
444
|
-
return focusContainer(containerId);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
return {
|
|
448
|
-
mode,
|
|
449
|
-
controller,
|
|
450
|
-
host,
|
|
451
|
-
renderer,
|
|
452
|
-
history,
|
|
453
|
-
// P6 parça (5) / Dilim B: hand to exportDocx's optional `historyDirty` param
|
|
454
|
-
// for the consistency gate (see export.js). Read-only from the caller's side
|
|
455
|
-
// in spirit — nothing outside this module writes to it.
|
|
456
|
-
historyDirty,
|
|
457
|
-
getState: core.getState,
|
|
458
|
-
/** The box dispatch, exposed for symmetry / tests. */
|
|
459
|
-
dispatchBox,
|
|
460
|
-
/** The text dispatch (the host's), for driving a text edit through the shared core. */
|
|
461
|
-
dispatchText: host.dispatch,
|
|
462
|
-
undo,
|
|
463
|
-
redo,
|
|
464
|
-
hitTestBox,
|
|
465
|
-
handlePointerDown,
|
|
466
|
-
handleDoubleClick,
|
|
467
|
-
/** ADR-0032: hand DOM focus back to the box being text-edited. */
|
|
468
|
-
focusActiveContainer,
|
|
469
|
-
/**
|
|
470
|
-
* Supplies resolved media (`mediaPartName` -> `data:` URI) for `Doc.images`
|
|
471
|
-
* render (ADR-0020 Karar 6 render half). Call BEFORE the first `render()`,
|
|
472
|
-
* so that render already sets each `<img>`'s `src` — the same ordering
|
|
473
|
-
* `createEditor.setMedia` documents and editor.html follows. Render-only;
|
|
474
|
-
* export never reads it (the `word/media/*` parts are copied verbatim).
|
|
475
|
-
* @param {Map<string, string> | null | undefined} media
|
|
476
|
-
* @returns {void}
|
|
477
|
-
*/
|
|
478
|
-
setMedia(media) {
|
|
479
|
-
renderer.setMedia(media);
|
|
480
|
-
},
|
|
481
|
-
/**
|
|
482
|
-
* Wire the text editing host to the real canvas DOM (beforeinput /
|
|
483
|
-
* composition / selection).
|
|
484
|
-
* @param {any} rootTarget
|
|
485
|
-
* @param {any} documentTarget
|
|
486
|
-
* @returns {void}
|
|
487
|
-
*/
|
|
488
|
-
attach(rootTarget, documentTarget) {
|
|
489
|
-
host.attach(rootTarget, documentTarget);
|
|
490
|
-
},
|
|
491
|
-
/** @returns {void} */
|
|
492
|
-
detach() {
|
|
493
|
-
host.detach();
|
|
494
|
-
},
|
|
495
|
-
/** Explicit first paint (and any forced repaint) — the box way, no caret move. */
|
|
496
|
-
render: renderBox,
|
|
497
|
-
/** @returns {void} */
|
|
498
|
-
destroy() {
|
|
499
|
-
renderer.destroy();
|
|
500
|
-
},
|
|
501
|
-
};
|
|
502
|
-
}
|
|
1
|
+
// The unified canvas editor (ADR-0024, P3b mode-merge parça 2). It is the ONE
|
|
2
|
+
// owner of the shared core — a single EditorState, a single HistoryEngine, and
|
|
3
|
+
// a single Renderer — and it wires BOTH surfaces onto that core:
|
|
4
|
+
//
|
|
5
|
+
// - the BOX surface: a `CanvasController` (select / drag / resize a box),
|
|
6
|
+
// dispatching through `dispatchBox` — which renders WITHOUT moving the text
|
|
7
|
+
// caret, because a box move must not place a caret;
|
|
8
|
+
// - the TEXT surface: a `createEditingHost` (type into a box), whose own
|
|
9
|
+
// dispatch renders AND restores the caret. It is given the SAME core and
|
|
10
|
+
// renderer, so a text edit and a box move land in ONE model with ONE undo
|
|
11
|
+
// stack — never two divergent copies. That is the whole point of ADR-0024.
|
|
12
|
+
//
|
|
13
|
+
// parça 2 bound the two onto the shared core and stood up the ModeController;
|
|
14
|
+
// parça 3 (this file's `planPointerDown` / `handlePointerDown` /
|
|
15
|
+
// `handleDoubleClick` / `attach`) routes clicks through the mode gate and wires
|
|
16
|
+
// the text host to the real DOM, so a double-click enters text mode and typing
|
|
17
|
+
// edits the box. The empty-box×mode hit-test refinement (ADR-0021) is parça 4.
|
|
18
|
+
//
|
|
19
|
+
// No `window`/`document` global is touched at import time (invariant #5): every
|
|
20
|
+
// DOM reference is injected (`root`, `document`, `readPageMetrics`).
|
|
21
|
+
|
|
22
|
+
import { applyTransaction, createEditorState } from "../state/editor-state.js";
|
|
23
|
+
import { HistoryEngine } from "../history/history.js";
|
|
24
|
+
import { recordForwardDirty, verifyInverseDirty } from "../io/ooxml/export.js";
|
|
25
|
+
import { Renderer } from "../render/renderer.js";
|
|
26
|
+
import { createEditingHost } from "../editor/editing-host.js";
|
|
27
|
+
import { CanvasController } from "./controller.js";
|
|
28
|
+
import { ModeController } from "./mode-controller.js";
|
|
29
|
+
import { topBoxAtPoint } from "./hit-test.js";
|
|
30
|
+
import { pageAtClientPoint, clientToPagePoint } from "./viewport.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {object} PointerRoute
|
|
34
|
+
* @property {boolean} preventDefault suppress the browser default (so no native
|
|
35
|
+
* text-selection drag starts under a box move) — box behaviour only.
|
|
36
|
+
* @property {boolean} toController hand the event to the box CanvasController.
|
|
37
|
+
* @property {boolean} exitToBox leave text mode first (a text-mode click that
|
|
38
|
+
* landed outside the active box).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decide what a left pointerdown means, given the current mode and what box (if
|
|
43
|
+
* any) is under it (ADR-0024 parça 3 click-routing). PURE — no DOM, no state —
|
|
44
|
+
* so the whole routing table is node-testable in isolation. The `dblclick`
|
|
45
|
+
* listener, not this, is what flips INTO text mode; the empty-box×mode
|
|
46
|
+
* refinement (ADR-0021 filledBeforeEmpty under a double-click) is parça 4;
|
|
47
|
+
* here `hitBoxId` is a plain topmost-box hit.
|
|
48
|
+
*
|
|
49
|
+
* 🔴 There used to be a THIRD branch here — an `isSecondClick` parameter
|
|
50
|
+
* (`event.detail >= 2`) meant to let a double-click's second pointerdown skip
|
|
51
|
+
* `preventDefault` so the native caret would land. It never fired: measured
|
|
52
|
+
* real, `isTrusted` events from an actual double-click (Chrome, this
|
|
53
|
+
* session) —
|
|
54
|
+
* pointerdown detail=0 · mousedown detail=1 · click detail=1
|
|
55
|
+
* pointerdown detail=0 · mousedown detail=2 · click detail=2 · dblclick detail=2
|
|
56
|
+
* — `pointerdown.detail` is `0` on BOTH clicks; only `MouseEvent`s
|
|
57
|
+
* (`mousedown`/`click`/`dblclick`) carry the real click count. So
|
|
58
|
+
* `isSecondClick` was always `false`, this branch was structurally dead, and
|
|
59
|
+
* the caret restoration this comment used to promise never happened —
|
|
60
|
+
* `handleDoubleClick` below now owns that job directly, using `dblclick`,
|
|
61
|
+
* whose `detail` a real browser DOES set correctly. ⚠ Only Chrome measured
|
|
62
|
+
* here — Firefox/Safari's `PointerEvent.detail` is a DOĞRULANMALI, not
|
|
63
|
+
* assumed identical (`docs/browser-matrix.md`).
|
|
64
|
+
*
|
|
65
|
+
* @param {object} at
|
|
66
|
+
* @param {import("./mode-controller.js").Mode} at.mode
|
|
67
|
+
* @param {import("../model/node.js").NodeId | null} at.activeContainerId
|
|
68
|
+
* @param {import("../model/node.js").NodeId | null} at.hitBoxId topmost box under the point, or null
|
|
69
|
+
* @returns {PointerRoute}
|
|
70
|
+
*/
|
|
71
|
+
export function planPointerDown({ mode, activeContainerId, hitBoxId }) {
|
|
72
|
+
// Already in text mode and the click is INSIDE the active box: native caret
|
|
73
|
+
// repositions; stay in text mode, keep the box controller out of it.
|
|
74
|
+
if (mode === "text" && hitBoxId !== null && hitBoxId === activeContainerId) {
|
|
75
|
+
return { preventDefault: false, toController: false, exitToBox: false };
|
|
76
|
+
}
|
|
77
|
+
// Everything else is box behaviour: a box-mode click (both clicks of a
|
|
78
|
+
// double-click included — see the module comment above), or a text-mode
|
|
79
|
+
// click on a DIFFERENT box / empty space (which first leaves text mode).
|
|
80
|
+
// preventDefault then route to the box controller.
|
|
81
|
+
return { preventDefault: true, toController: true, exitToBox: mode === "text" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @typedef {object} CanvasEditorOptions
|
|
86
|
+
* @property {any} root the element the renderer mounts into (the box hosts and
|
|
87
|
+
* the per-box contenteditable text hosts both live under it).
|
|
88
|
+
* @property {import("../render/dom.js").DocumentLike} document owner document.
|
|
89
|
+
* @property {import("../model/node.js").Doc} doc the initial document.
|
|
90
|
+
* @property {import("../model/ids.js").IdFactory} ids
|
|
91
|
+
* @property {import("./controller.js").CanvasMetrics} metrics
|
|
92
|
+
* @property {() => import("./viewport.js").PageRect[]} readPageMetrics live page
|
|
93
|
+
* geometry for hit-testing (index 0 is page 1). Injected because it reads the
|
|
94
|
+
* real DOM (`getBoundingClientRect`); a test passes a fixed set of rects.
|
|
95
|
+
* @property {() => import("../selection/controller.js").DomSelectionLike | null} [getDomSelection]
|
|
96
|
+
* @property {(event: {clientX: number, clientY: number}) => {node: any, offset: number} | null} [resolveCaretAtPoint]
|
|
97
|
+
* Real double-click caret placement (parça 3 fix). `handleDoubleClick`
|
|
98
|
+
* ALWAYS reaches a box whose pointerdown already `preventDefault`-ed (every
|
|
99
|
+
* pointerdown does — see `planPointerDown`'s own comment on why
|
|
100
|
+
* `isSecondClick` never worked), so the browser's native "second click
|
|
101
|
+
* places the caret" never happens on its own; this restores it explicitly.
|
|
102
|
+
* Reads real pixel coordinates, so — like `readPageMetrics` — it is
|
|
103
|
+
* injected rather than called directly: a test supplies a fake DOM
|
|
104
|
+
* {node, offset}, the real app resolves one via
|
|
105
|
+
* `document.caretPositionFromPoint`/`caretRangeFromPoint`. Returns a RAW DOM
|
|
106
|
+
* position, not a model one — `handleDoubleClick` only needs to WRITE it
|
|
107
|
+
* into the live `Selection` (`getDomSelection().setBaseAndExtent(...)`);
|
|
108
|
+
* the existing `selectionchange` → `SelectionController.handleSelectionChange`
|
|
109
|
+
* → `domToModel` pipeline (unchanged, already tested) adopts it into
|
|
110
|
+
* `state.selection` exactly as it would a genuine unblocked click — no new
|
|
111
|
+
* model-update path. Omitted → mode still flips correctly, only the caret
|
|
112
|
+
* placement degrades (a SECOND click inside the now-active box still works,
|
|
113
|
+
* via `planPointerDown`'s own `mode==="text" && hitBoxId===activeContainerId`
|
|
114
|
+
* branch).
|
|
115
|
+
* @property {(state: import("../state/editor-state.js").EditorState) => void} [onChange]
|
|
116
|
+
* @property {(plan: import("../render/renderer.js").RenderPlan) => void} [onRender]
|
|
117
|
+
* fires after EVERY projection of the shared model — box dispatch, text
|
|
118
|
+
* dispatch, undo, redo, and the explicit `render()`. The canvas measure loop
|
|
119
|
+
* hangs `observe(getState().doc, plan)` here (ADR-0019 §2.3: the doc it reads
|
|
120
|
+
* is `renderer.lastDoc` at this instant).
|
|
121
|
+
* @property {() => number} [now] the clock stamped onto box transactions so
|
|
122
|
+
* `HistoryEngine` can coalesce a drag's frames. Defaults to a constant 0 (a
|
|
123
|
+
* test drives its own clock); the browser passes `() => performance.now()`.
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {CanvasEditorOptions} options
|
|
128
|
+
*/
|
|
129
|
+
export function createCanvasEditor({
|
|
130
|
+
root,
|
|
131
|
+
document,
|
|
132
|
+
doc,
|
|
133
|
+
ids,
|
|
134
|
+
metrics,
|
|
135
|
+
readPageMetrics,
|
|
136
|
+
getDomSelection,
|
|
137
|
+
resolveCaretAtPoint,
|
|
138
|
+
onChange,
|
|
139
|
+
onRender,
|
|
140
|
+
now = () => 0,
|
|
141
|
+
}) {
|
|
142
|
+
// The shared core: one state cell, one history. Both surfaces mutate THIS.
|
|
143
|
+
const state = { value: createEditorState(doc) };
|
|
144
|
+
const history = new HistoryEngine();
|
|
145
|
+
// P6 parça (5) / Dilim B: which layout keys a FORWARD setLayout step has
|
|
146
|
+
// named, recorded by `recordForwardDirty` on every forward dispatch and
|
|
147
|
+
// every redo (both re-apply a forward step); `verifyInverseDirty` checks
|
|
148
|
+
// every undo's inverse against it (HIST7 — see export.js's own module
|
|
149
|
+
// comment for why that check, not a same-set check, is the one that does
|
|
150
|
+
// not false-positive). Also exposed for `exportDocx`'s optional
|
|
151
|
+
// historyDirty consistency gate (a different regression: a forgotten
|
|
152
|
+
// wiring). NOT the export dirty source — a taşı-sonra-geri-al still exports
|
|
153
|
+
// byte-identical because that decision stays with dirtyGeometry's value
|
|
154
|
+
// compare.
|
|
155
|
+
/** @type {Map<import("../model/node.js").NodeId, Set<string>>} */
|
|
156
|
+
const historyDirty = new Map();
|
|
157
|
+
/** @type {import("../editor/editing-host.js").EditorCore} */
|
|
158
|
+
const core = {
|
|
159
|
+
getState: () => state.value,
|
|
160
|
+
setState: (next) => {
|
|
161
|
+
state.value = next;
|
|
162
|
+
},
|
|
163
|
+
history,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// One renderer, shared by both surfaces. The facade owns its lifecycle
|
|
167
|
+
// (initial paint via render(), teardown via destroy()); the injected host
|
|
168
|
+
// renders into it but never creates or destroys it.
|
|
169
|
+
const renderer = new Renderer(root, document);
|
|
170
|
+
|
|
171
|
+
const mode = new ModeController();
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Project the shared model for a BOX operation: render, then hand the plan to
|
|
175
|
+
* onRender — but do NOT restore the text caret. A box move/resize must never
|
|
176
|
+
* place a caret in a box (the text surface's job, parça 3). This is the box
|
|
177
|
+
* half of ADR-0024's caret split; the text half lives in editing-host's own
|
|
178
|
+
* renderAndRestore.
|
|
179
|
+
* @returns {void}
|
|
180
|
+
*/
|
|
181
|
+
function renderBox() {
|
|
182
|
+
const plan = renderer.render(core.getState().doc);
|
|
183
|
+
if (onRender !== undefined) {
|
|
184
|
+
onRender(plan);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The box surface's dispatch (given to CanvasController). Stamps the clock so
|
|
190
|
+
* a drag's frames coalesce, folds into the SHARED core, and renders without a
|
|
191
|
+
* caret move.
|
|
192
|
+
* @param {import("../model/transaction.js").Transaction} tr
|
|
193
|
+
* @returns {void}
|
|
194
|
+
*/
|
|
195
|
+
function dispatchBox(tr) {
|
|
196
|
+
const stamped = { ...tr, timestamp: now() };
|
|
197
|
+
history.push(stamped);
|
|
198
|
+
recordForwardDirty(historyDirty, stamped.docBefore, stamped.steps);
|
|
199
|
+
core.setState(applyTransaction(core.getState(), stamped));
|
|
200
|
+
if (onChange !== undefined) {
|
|
201
|
+
onChange(core.getState());
|
|
202
|
+
}
|
|
203
|
+
renderBox();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const controller = new CanvasController({
|
|
207
|
+
getState: core.getState,
|
|
208
|
+
dispatch: dispatchBox,
|
|
209
|
+
readPageMetrics,
|
|
210
|
+
metrics,
|
|
211
|
+
ids,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// The text surface. SAME core, SAME renderer -> one model, one undo stack.
|
|
215
|
+
// getMode lets its SelectionController read the box/text mode (inert until
|
|
216
|
+
// parça 3 routes on it). Deliberately NOT attached this turn: no typing yet.
|
|
217
|
+
const host = createEditingHost({
|
|
218
|
+
root,
|
|
219
|
+
document,
|
|
220
|
+
doc,
|
|
221
|
+
ids,
|
|
222
|
+
getDomSelection,
|
|
223
|
+
onChange,
|
|
224
|
+
onRender,
|
|
225
|
+
core,
|
|
226
|
+
renderer,
|
|
227
|
+
getMode: () => mode.mode,
|
|
228
|
+
// ADR-0027 Ek 47: editing-host.js has no visibility into CanvasController
|
|
229
|
+
// (architectural boundary), so a document swap through `host.loadDocument`
|
|
230
|
+
// needs this hook to also reset the box surface's own selection/gesture/
|
|
231
|
+
// drag state — otherwise a stale box selection id can silently retarget a
|
|
232
|
+
// nudge/delete onto an unrelated box in the new document, measured against
|
|
233
|
+
// real corpus documents.
|
|
234
|
+
//
|
|
235
|
+
// ADR-0027 Ek 49: the hook also receives the freshly-reseeded IdFactory
|
|
236
|
+
// editing-host.js just built for the new document (past every id already
|
|
237
|
+
// used in it) and applies it to `controller.ids` — the SAME object
|
|
238
|
+
// `inputContext.ids` was just mutated to (both were sourced from this
|
|
239
|
+
// function's single `ids` parameter at construction). Without this,
|
|
240
|
+
// `controller`'s paste/gesture id minting would keep using the OLD
|
|
241
|
+
// document's factory, which can hand out ids that collide with real
|
|
242
|
+
// containers in the new document.
|
|
243
|
+
onLoadDocument: (newIds) => {
|
|
244
|
+
controller.reset();
|
|
245
|
+
controller.ids = newIds;
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Undo/redo on the SHARED history, rendered the box way (no caret move) —
|
|
251
|
+
* box-mode is the only surface live this turn. Returns the applied inverse
|
|
252
|
+
* transaction, or null when the stack was empty.
|
|
253
|
+
*
|
|
254
|
+
* `history.undo()`'s own steps are the INVERSE ones (`history.js`: `steps:
|
|
255
|
+
* inverted`) — checked with `verifyInverseDirty`, never recorded as new
|
|
256
|
+
* forward names: an inverse must only confirm keys a forward step already
|
|
257
|
+
* named, never introduce one (HIST7).
|
|
258
|
+
* @returns {import("../model/transaction.js").Transaction | null}
|
|
259
|
+
*/
|
|
260
|
+
function undo() {
|
|
261
|
+
const tr = history.undo(core.getState());
|
|
262
|
+
if (tr === null) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
verifyInverseDirty(historyDirty, tr.docBefore, tr.steps);
|
|
266
|
+
core.setState(applyTransaction(core.getState(), tr));
|
|
267
|
+
if (onChange !== undefined) {
|
|
268
|
+
onChange(core.getState());
|
|
269
|
+
}
|
|
270
|
+
renderBox();
|
|
271
|
+
return tr;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* `history.redo()`'s own steps are the ORIGINAL forward ones re-applied
|
|
276
|
+
* (`history.js`: `steps: entry.steps.slice()`) — recorded the same way a
|
|
277
|
+
* fresh forward dispatch is.
|
|
278
|
+
* @returns {import("../model/transaction.js").Transaction | null}
|
|
279
|
+
*/
|
|
280
|
+
function redo() {
|
|
281
|
+
const tr = history.redo(core.getState());
|
|
282
|
+
if (tr === null) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
recordForwardDirty(historyDirty, tr.docBefore, tr.steps);
|
|
286
|
+
core.setState(applyTransaction(core.getState(), tr));
|
|
287
|
+
if (onChange !== undefined) {
|
|
288
|
+
onChange(core.getState());
|
|
289
|
+
}
|
|
290
|
+
renderBox();
|
|
291
|
+
return tr;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The topmost box under a pointer event, using live page geometry — the simple
|
|
296
|
+
* hit-test parça 3 routes on (ADR-0024; the empty-box×mode refinement is parça 4).
|
|
297
|
+
* @param {{ clientX: number, clientY: number }} event
|
|
298
|
+
* @returns {import("../model/node.js").NodeId | null}
|
|
299
|
+
*/
|
|
300
|
+
function hitTestBox(event) {
|
|
301
|
+
const pageRects = readPageMetrics();
|
|
302
|
+
const page = pageAtClientPoint(pageRects, event.clientX, event.clientY);
|
|
303
|
+
if (page === null) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
const point = clientToPagePoint(pageRects[page - 1], event.clientX, event.clientY);
|
|
307
|
+
return topBoxAtPoint(core.getState().doc, page, point.x, point.y);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Route a left pointerdown through the mode gate (ADR-0024 parça 3). In box
|
|
312
|
+
* mode it is exactly today's behaviour — preventDefault, then the box
|
|
313
|
+
* controller. In text mode a click inside the active box falls through to the
|
|
314
|
+
* native caret; a click elsewhere leaves text mode and behaves as box. Returns
|
|
315
|
+
* the routing decision (for tests / callers that also want to sync visuals).
|
|
316
|
+
* @param {{ clientX: number, clientY: number, pointerId: number, preventDefault?: () => void }} event
|
|
317
|
+
* @returns {PointerRoute}
|
|
318
|
+
*/
|
|
319
|
+
function handlePointerDown(event) {
|
|
320
|
+
const decision = planPointerDown({
|
|
321
|
+
mode: mode.mode,
|
|
322
|
+
activeContainerId: mode.activeContainerId,
|
|
323
|
+
hitBoxId: hitTestBox(event),
|
|
324
|
+
});
|
|
325
|
+
if (decision.exitToBox) {
|
|
326
|
+
mode.exitToBoxMode();
|
|
327
|
+
}
|
|
328
|
+
if (decision.preventDefault && typeof event.preventDefault === "function") {
|
|
329
|
+
event.preventDefault();
|
|
330
|
+
}
|
|
331
|
+
if (decision.toController) {
|
|
332
|
+
controller.handlePointerDown(event);
|
|
333
|
+
}
|
|
334
|
+
return decision;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* A double-click on a box enters text mode on it (ADR-0024 §1) AND places the
|
|
339
|
+
* caret at the click point. Returns the box entered, or null when the
|
|
340
|
+
* double-click missed every box.
|
|
341
|
+
*
|
|
342
|
+
* 🔴 The caret half is NOT free. `planPointerDown`'s own comment: BOTH
|
|
343
|
+
* pointerdowns of a double-click always `preventDefault` (measured —
|
|
344
|
+
* `PointerEvent.detail` is 0 on both, so the "second click, let it through"
|
|
345
|
+
* branch this function's docstring used to promise never existed in
|
|
346
|
+
* practice), so the browser's native "second click places a caret" never
|
|
347
|
+
* happens on its own. `dblclick` is the one event that reliably fires with a
|
|
348
|
+
* real click count (`detail`) AFTER the fact, so THIS handler restores the
|
|
349
|
+
* caret explicitly, using the SAME point.
|
|
350
|
+
* @param {{ clientX: number, clientY: number }} event
|
|
351
|
+
* @returns {import("../model/node.js").NodeId | null}
|
|
352
|
+
*/
|
|
353
|
+
function handleDoubleClick(event) {
|
|
354
|
+
const hitBoxId = hitTestBox(event);
|
|
355
|
+
if (hitBoxId === null) {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
mode.enterTextMode(hitBoxId);
|
|
359
|
+
placeCaretAtPoint(event, hitBoxId);
|
|
360
|
+
return hitBoxId;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Writes a real DOM caret into `hitBoxId`'s contenteditable at `event`'s
|
|
365
|
+
* point, when the capability was injected. Deliberately does NOT touch
|
|
366
|
+
* `state.selection` itself: it focuses the box and calls the native
|
|
367
|
+
* `Selection.setBaseAndExtent`, and the ALREADY-ATTACHED `selectionchange`
|
|
368
|
+
* listener (`SelectionController.handleSelectionChange`, wired by
|
|
369
|
+
* `host.attach`) adopts it into the shared core exactly as it would a
|
|
370
|
+
* genuine unblocked click — one adoption path, not two. A no-op (mode stays
|
|
371
|
+
* flipped, no caret) when `resolveCaretAtPoint`/`getDomSelection` was not
|
|
372
|
+
* injected, or when the point did not resolve to a real DOM position.
|
|
373
|
+
* @param {{ clientX: number, clientY: number }} event
|
|
374
|
+
* @param {import("../model/node.js").NodeId} hitBoxId
|
|
375
|
+
* @returns {void}
|
|
376
|
+
*/
|
|
377
|
+
function placeCaretAtPoint(event, hitBoxId) {
|
|
378
|
+
if (resolveCaretAtPoint === undefined || getDomSelection === undefined) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const domCaret = resolveCaretAtPoint(event);
|
|
382
|
+
if (domCaret === null) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const domSelection = getDomSelection();
|
|
386
|
+
if (domSelection === null) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// Focus BEFORE setting the selection: the reverse order risks a browser
|
|
390
|
+
// collapsing the just-placed selection back to the focused element's start.
|
|
391
|
+
focusContainer(hitBoxId);
|
|
392
|
+
domSelection.setBaseAndExtent(domCaret.node, domCaret.offset, domCaret.node, domCaret.offset);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Puts real DOM focus on one box's contenteditable.
|
|
397
|
+
*
|
|
398
|
+
* A native click would do this as part of its own default action — the very
|
|
399
|
+
* thing `planPointerDown`'s `preventDefault` suppresses — so every path that
|
|
400
|
+
* wants a box focused has to say so explicitly. There is exactly one such
|
|
401
|
+
* expression, here, and two callers: `placeCaretAtPoint` (double-click) and
|
|
402
|
+
* `focusActiveContainer` (ADR-0032's ribbon return).
|
|
403
|
+
*
|
|
404
|
+
* @param {import("../model/node.js").NodeId} containerId
|
|
405
|
+
* @returns {boolean} whether an element was actually focused
|
|
406
|
+
*/
|
|
407
|
+
function focusContainer(containerId) {
|
|
408
|
+
const containerElement = renderer.getContainerElement(containerId);
|
|
409
|
+
if (containerElement === null || typeof containerElement.focus !== "function") {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
containerElement.focus({ preventScroll: true });
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Hands DOM focus back to the box being text-edited (ADR-0032).
|
|
418
|
+
*
|
|
419
|
+
* A ribbon control that TAKES focus — the point-size box, the two indent
|
|
420
|
+
* fields — must give it back when it is done, on Enter and on Escape alike.
|
|
421
|
+
* Until this existed, focus came back only as a SIDE EFFECT: a commit that
|
|
422
|
+
* changed the document dispatched, which rendered, which called
|
|
423
|
+
* `restoreSelection`, whose `setBaseAndExtent` implicitly moves focus into
|
|
424
|
+
* the contenteditable. A commit that changed NOTHING skipped that whole
|
|
425
|
+
* chain, so focus stayed in the ribbon field and the user's next keystrokes
|
|
426
|
+
* went into it silently — measured in a real browser, the box read "11ZQ7".
|
|
427
|
+
*
|
|
428
|
+
* 🔴 Deliberately NOT wired into `restoreSelection` itself. That would make
|
|
429
|
+
* every render steal focus, including the renders that happen WHILE the user
|
|
430
|
+
* is typing in a ribbon field — exactly what the `activeElement` guards in
|
|
431
|
+
* the demo's refresh path exist to avoid. The return is opt-in: the commit
|
|
432
|
+
* handler's explicit second step.
|
|
433
|
+
*
|
|
434
|
+
* A no-op in box mode (no box is being edited, so there is nothing to
|
|
435
|
+
* return focus to) — the caller does not have to check the mode first.
|
|
436
|
+
*
|
|
437
|
+
* @returns {boolean} whether focus was actually moved
|
|
438
|
+
*/
|
|
439
|
+
function focusActiveContainer() {
|
|
440
|
+
const containerId = mode.activeContainerId;
|
|
441
|
+
if (containerId === null) {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
return focusContainer(containerId);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
mode,
|
|
449
|
+
controller,
|
|
450
|
+
host,
|
|
451
|
+
renderer,
|
|
452
|
+
history,
|
|
453
|
+
// P6 parça (5) / Dilim B: hand to exportDocx's optional `historyDirty` param
|
|
454
|
+
// for the consistency gate (see export.js). Read-only from the caller's side
|
|
455
|
+
// in spirit — nothing outside this module writes to it.
|
|
456
|
+
historyDirty,
|
|
457
|
+
getState: core.getState,
|
|
458
|
+
/** The box dispatch, exposed for symmetry / tests. */
|
|
459
|
+
dispatchBox,
|
|
460
|
+
/** The text dispatch (the host's), for driving a text edit through the shared core. */
|
|
461
|
+
dispatchText: host.dispatch,
|
|
462
|
+
undo,
|
|
463
|
+
redo,
|
|
464
|
+
hitTestBox,
|
|
465
|
+
handlePointerDown,
|
|
466
|
+
handleDoubleClick,
|
|
467
|
+
/** ADR-0032: hand DOM focus back to the box being text-edited. */
|
|
468
|
+
focusActiveContainer,
|
|
469
|
+
/**
|
|
470
|
+
* Supplies resolved media (`mediaPartName` -> `data:` URI) for `Doc.images`
|
|
471
|
+
* render (ADR-0020 Karar 6 render half). Call BEFORE the first `render()`,
|
|
472
|
+
* so that render already sets each `<img>`'s `src` — the same ordering
|
|
473
|
+
* `createEditor.setMedia` documents and editor.html follows. Render-only;
|
|
474
|
+
* export never reads it (the `word/media/*` parts are copied verbatim).
|
|
475
|
+
* @param {Map<string, string> | null | undefined} media
|
|
476
|
+
* @returns {void}
|
|
477
|
+
*/
|
|
478
|
+
setMedia(media) {
|
|
479
|
+
renderer.setMedia(media);
|
|
480
|
+
},
|
|
481
|
+
/**
|
|
482
|
+
* Wire the text editing host to the real canvas DOM (beforeinput /
|
|
483
|
+
* composition / selection).
|
|
484
|
+
* @param {any} rootTarget
|
|
485
|
+
* @param {any} documentTarget
|
|
486
|
+
* @returns {void}
|
|
487
|
+
*/
|
|
488
|
+
attach(rootTarget, documentTarget) {
|
|
489
|
+
host.attach(rootTarget, documentTarget);
|
|
490
|
+
},
|
|
491
|
+
/** @returns {void} */
|
|
492
|
+
detach() {
|
|
493
|
+
host.detach();
|
|
494
|
+
},
|
|
495
|
+
/** Explicit first paint (and any forced repaint) — the box way, no caret move. */
|
|
496
|
+
render: renderBox,
|
|
497
|
+
/** @returns {void} */
|
|
498
|
+
destroy() {
|
|
499
|
+
renderer.destroy();
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|