@sametalmdr/word-editor 0.2.0-alpha → 0.3.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +108 -70
  2. package/LICENSE +21 -21
  3. package/README.md +286 -247
  4. package/dist/canvas/box-clipboard.js +238 -238
  5. package/dist/canvas/box-selection.js +80 -80
  6. package/dist/canvas/canvas-editor.js +502 -502
  7. package/dist/canvas/controller.js +717 -717
  8. package/dist/canvas/geometry.js +131 -131
  9. package/dist/canvas/gesture.d.ts +0 -1
  10. package/dist/canvas/gesture.js +289 -289
  11. package/dist/canvas/handles.js +212 -212
  12. package/dist/canvas/menu.js +82 -82
  13. package/dist/canvas/mode-controller.js +81 -81
  14. package/dist/canvas/nudge.js +108 -108
  15. package/dist/canvas/overlay.js +149 -149
  16. package/dist/canvas/snap.js +132 -132
  17. package/dist/canvas/viewport.js +112 -112
  18. package/dist/canvas/z-order.js +171 -171
  19. package/dist/commands/alignment.js +150 -150
  20. package/dist/commands/box.js +272 -272
  21. package/dist/commands/clipboard.js +93 -93
  22. package/dist/commands/graphemes.js +112 -112
  23. package/dist/commands/marks.js +153 -153
  24. package/dist/commands/paragraph-props.js +176 -176
  25. package/dist/commands/size.js +368 -368
  26. package/dist/commands/text.js +291 -291
  27. package/dist/docx.d.ts +1 -0
  28. package/dist/docx.js +15 -13
  29. package/dist/editor/editing-host.js +434 -434
  30. package/dist/element.js +41 -41
  31. package/dist/fonts/OFL-Carlito.txt +94 -0
  32. package/dist/fonts/calibri-metrics.d.ts +27 -0
  33. package/dist/fonts/calibri-metrics.js +88 -0
  34. package/dist/history/history.js +433 -433
  35. package/dist/index.js +63 -63
  36. package/dist/input/beforeinput.js +224 -224
  37. package/dist/input/clipboard.js +107 -107
  38. package/dist/input/composition.js +341 -341
  39. package/dist/input/keymap.js +54 -54
  40. package/dist/io/ooxml/custgeom.js +72 -72
  41. package/dist/io/ooxml/drawingml.js +232 -232
  42. package/dist/io/ooxml/edits.js +67 -67
  43. package/dist/io/ooxml/export-splice.js +880 -880
  44. package/dist/io/ooxml/export.js +365 -365
  45. package/dist/io/ooxml/fill.d.ts +93 -8
  46. package/dist/io/ooxml/fill.js +618 -562
  47. package/dist/io/ooxml/fit.d.ts +252 -0
  48. package/dist/io/ooxml/fit.js +598 -0
  49. package/dist/io/ooxml/import-gate.js +200 -200
  50. package/dist/io/ooxml/import.js +691 -691
  51. package/dist/io/ooxml/inflate.js +126 -126
  52. package/dist/io/ooxml/media.js +87 -87
  53. package/dist/io/ooxml/ns.d.ts +2 -0
  54. package/dist/io/ooxml/ns.js +26 -21
  55. package/dist/io/ooxml/numbering.js +290 -290
  56. package/dist/io/ooxml/opc-write.js +171 -171
  57. package/dist/io/ooxml/provenance.js +93 -93
  58. package/dist/io/ooxml/recipe.js +708 -708
  59. package/dist/io/ooxml/relationships.js +59 -59
  60. package/dist/io/ooxml/run-fonts.d.ts +89 -0
  61. package/dist/io/ooxml/run-fonts.js +378 -0
  62. package/dist/io/ooxml/slots.d.ts +4 -0
  63. package/dist/io/ooxml/slots.js +582 -581
  64. package/dist/io/ooxml/styles-resolve.js +257 -257
  65. package/dist/io/ooxml/units.js +43 -43
  66. package/dist/io/ooxml/value.js +120 -120
  67. package/dist/io/ooxml/xml-scan.js +616 -616
  68. package/dist/io/ooxml/zip-container.js +228 -228
  69. package/dist/io/ooxml/zip-reject.js +106 -106
  70. package/dist/measure/placement.js +83 -83
  71. package/dist/measure/report.js +75 -75
  72. package/dist/model/ids.js +70 -70
  73. package/dist/model/normalize.js +146 -146
  74. package/dist/model/position.js +283 -283
  75. package/dist/model/transaction.js +209 -209
  76. package/dist/platform/capabilities.js +25 -25
  77. package/dist/react/index.js +21 -21
  78. package/dist/selection/controller.js +315 -315
  79. package/dist/selection/dom-map.js +334 -334
  80. package/dist/state/editor-state.js +109 -109
  81. package/dist/styles.css +66 -66
  82. package/package.json +94 -93
@@ -1,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
+ }