@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,434 +1,434 @@
1
- // Editing-assembly — wires the already-written editing modules into one live,
2
- // per-box-contenteditable editor (ADR-0022). PURE COMPOSITION: no new engine,
3
- // no module-API change. Every piece here already exists and is unit-tested on
4
- // its own; this file only holds them together and owns the dispatch loop the
5
- // modules' own docstrings say the *host* must own (F2 render suppression while
6
- // composing, the forced render on composing→idle, and the history-push origin
7
- // guard).
8
- //
9
- // Handlers are plain methods a test calls directly with a fake event — the same
10
- // convention `handleBeforeInput`/`handlePaste`/`CanvasController` already use.
11
- // `attach`/`detach` are ONLY the thin real-DOM wiring on top, and they use
12
- // EVENT DELEGATION (ADR-0022 D2): ONE `beforeinput`/`composition*`/`focusout`
13
- // listener on the container root (these events bubble from every per-box host),
14
- // and `selectionchange` on `document` — the browser fires selectionchange only
15
- // there, never on the editing host (ADR-0014 DOĞRULANMALI #1). No per-box
16
- // listeners: R001's 1,344 hosts (§8 risk #9) would otherwise mean 1,344×N
17
- // listeners for zero semantic gain — delegation is one listener, and ADR-0014's
18
- // per-box decision is about the `contenteditable` ATTRIBUTE, not listener count.
19
- //
20
- // This module touches no `window`/`document` global at import time (invariant
21
- // #5): every DOM reference is injected (`root`, `document`, `getDomSelection`).
22
-
23
- import { applyTransaction, createEditorState, withSelection } from "../state/editor-state.js";
24
- import { createIdFactory, maxNodeId } from "../model/ids.js";
25
- import { HistoryEngine } from "../history/history.js";
26
- import { Renderer } from "../render/renderer.js";
27
- import { handleBeforeInput } from "../input/beforeinput.js";
28
- import { handleKeyDown } from "../input/keymap.js";
29
- import { handleCopy, handleCut, handlePaste } from "../input/clipboard.js";
30
- import { CompositionController } from "../input/composition.js";
31
- import { SelectionController } from "../selection/controller.js";
32
- import { selectionsEqual } from "../model/position.js";
33
-
34
- /**
35
- * @typedef {object} EditingHostOptions
36
- * @property {any} root the container element the renderer mounts into AND the
37
- * delegated input listeners attach to.
38
- * @property {import("../render/dom.js").DocumentLike} document owner document.
39
- * @property {import("../model/node.js").Doc} doc the initial document.
40
- * @property {import("../model/ids.js").IdFactory} ids
41
- * @property {() => import("../selection/controller.js").DomSelectionLike | null} [getDomSelection]
42
- * Reads the live DOM selection (`document.getSelection()` in a browser).
43
- * Defaults to `document.getSelection?.()` — a fake DocumentLike returns null,
44
- * which simply skips the model→DOM caret restore in unit tests.
45
- * @property {(state: import("../state/editor-state.js").EditorState) => void} [onChange]
46
- * fires after EVERY state change this host makes — a dispatch (typing,
47
- * formatting, undo/redo through the browser's own historyUndo) AND a bare
48
- * caret move adopted from the browser's `selectionchange` (`onAdopt`
49
- * below). A UI that mirrors selection-derived state (which toolbar button
50
- * is lit, what an indent field reads) must hang off THIS, never off
51
- * `document`'s own `selectionchange` — that event races this host's own
52
- * listener and, if the UI's listener happens to be registered first, reads
53
- * the state one adoption behind (measured, SESSION-LOG Ek 64).
54
- * @property {(plan: import("../render/renderer.js").RenderPlan) => void} [onRender]
55
- * fires once per model→DOM projection (`renderAndRestore`), with that render's
56
- * own PLAN. A test seam for asserting F2 render-suppression while composing,
57
- * and the canvas measure loop's `observe(doc, plan)` hook (ADR-0024). Production
58
- * text-only callers omit it. NOT fired by the initial render.
59
- * @property {"commit" | "discard"} [onAbandon] TODO(P1.9b, ADR-0004): the
60
- * commit-vs-discard policy for an ABANDONED composition is undecided until
61
- * real-IME hardware measurement (ADR-0004 Karar 4, Taslak). This host passes
62
- * a PROVISIONAL "discard" default; the permanent value is P1.9b's to set.
63
- * @property {EditorCore} [core] a SHARED state+history core (ADR-0024 parça 2,
64
- * the unified canvas editor). When present, this host's text-editing path
65
- * folds into the SAME state and undo stack that the box `CanvasController`
66
- * mutates — one model, one history. Omitted (editor.html) -> the host owns
67
- * its own, and behaves exactly as before.
68
- * @property {import("../render/renderer.js").Renderer} [renderer] a SHARED
69
- * renderer (same ADR-0024 seam). When injected, the OWNER drives the initial
70
- * paint and the render lifecycle; the host renders into it but never creates
71
- * or destroys it. Omitted -> the host makes its own and paints once here.
72
- * @property {() => import("../canvas/mode-controller.js").Mode} [getMode] reads
73
- * the box/text mode for the shared canvas surface (ADR-0024). Passed straight
74
- * to SelectionController; undefined -> SelectionController defaults to "text"
75
- * (exactly today's editor.html behaviour — a text-only surface).
76
- * @property {(newIds: import("../model/ids.js").IdFactory) => void} [onLoadDocument]
77
- * called at the START of `loadDocument`'s reset (ADR-0027), before renderer/
78
- * selection/history are cleared, with the freshly-reseeded IdFactory this
79
- * host is about to switch `inputContext.ids` to (ADR-0027 Ek 49 — reseeded
80
- * past every id already in the NEW document, so property-mutating `ids` to
81
- * this same factory keeps `CanvasController.ids` in sync). This module
82
- * deliberately does not import `CanvasController` (architectural boundary
83
- * between the text-editing assembly and the canvas layer) — on the shared-core
84
- * seam (ADR-0024, `canvas-editor.js`) the box surface has its OWN selection/
85
- * gesture/drag state that ADR-0027's renderer/selection/history reset does not
86
- * reach, and a stale box selection surviving a document swap can silently
87
- * retarget an action onto an unrelated box the user never selected in the NEW
88
- * document (measured against real corpus documents, ADR-0027 Ek 47: both a
89
- * crash — `movePatch`/`rectOfLayout` do not null-check a missing layout — and
90
- * a SILENT wrong move/delete when the stale id happens to collide with a real
91
- * container in the new document). `canvas-editor.js` supplies `(newIds) => {
92
- * controller.reset(); controller.ids = newIds; }` here; a text-only caller
93
- * (editor.html) omits it.
94
- */
95
-
96
- /**
97
- * A shared state+history container (ADR-0024). Injecting one makes the box
98
- * surface and the text surface operate on ONE model with ONE undo stack.
99
- * @typedef {object} EditorCore
100
- * @property {() => import("../state/editor-state.js").EditorState} getState
101
- * @property {(next: import("../state/editor-state.js").EditorState) => void} setState
102
- * @property {import("../history/history.js").HistoryEngine} history
103
- */
104
-
105
- /**
106
- * @param {EditingHostOptions} options
107
- */
108
- export function createEditingHost({ root, document, doc, ids, getDomSelection, onChange, onRender, onAbandon = "discard", core: injectedCore, renderer: injectedRenderer, getMode, onLoadDocument }) {
109
- // ONE shared core (state + history) OR our own. The default path (no
110
- // injection) is byte-for-byte the old behaviour: a private `let state` and a
111
- // fresh HistoryEngine, just held behind the core's accessors.
112
- const ownState = { value: createEditorState(doc) };
113
- const core = injectedCore ?? {
114
- getState: () => ownState.value,
115
- setState: (next) => {
116
- ownState.value = next;
117
- },
118
- history: new HistoryEngine(),
119
- };
120
- const history = core.history;
121
-
122
- // Injected renderer -> the owner (canvas-editor facade) drives the initial
123
- // paint and owns destroy(); we only render INTO it. Our own -> paint once now.
124
- const renderer = injectedRenderer ?? new Renderer(root, document);
125
- if (injectedRenderer === undefined) {
126
- renderer.render(core.getState().doc);
127
- }
128
-
129
- // ONE lookup instance feeds every consumer (SelectionController,
130
- // CompositionController, and modelToDom via restoreSelection). It reads the
131
- // renderer's live element map on each call, so it stays valid across renders.
132
- const lookupParagraph = renderer.paragraphLookup();
133
-
134
- const readDomSelection =
135
- getDomSelection ??
136
- (() => (typeof (/** @type {any} */ (document).getSelection) === "function" ? /** @type {any} */ (document).getSelection() : null));
137
-
138
- const getState = core.getState;
139
-
140
- /**
141
- * The host dispatch loop (ADR-0022 D1/U2). Folds a transaction into state and
142
- * projects it, with two host-owned rules the modules cannot enforce alone:
143
- * - O1: `history.push` is guarded on `tr.origin` — an undo/redo transaction
144
- * (origin "history") is already the RESULT of popping the stack; pushing
145
- * it back would double-count. Mirrors `applyTransaction`'s own
146
- * `tr.origin === "history"` branch.
147
- * - F2: no `Renderer.render()` while composing. During a composition seal
148
- * the commit transaction flows through here while `isComposing` is still
149
- * true (composition.js sets phase idle AFTER dispatching); the forced
150
- * render happens once the handler returns — see `handleCompositionEnd`.
151
- * @param {import("../model/transaction.js").Transaction} tr
152
- * @returns {void}
153
- */
154
- function dispatch(tr) {
155
- if (tr.origin !== "history") {
156
- history.push(tr);
157
- }
158
- core.setState(applyTransaction(core.getState(), tr));
159
- if (onChange !== undefined) {
160
- onChange(core.getState());
161
- }
162
- if (!composition.isComposing) {
163
- renderAndRestore();
164
- }
165
- }
166
-
167
- /**
168
- * Projects the current model and restores the caret from it. The render
169
- * PLAN is handed to `onRender` so a consumer (the canvas measure loop) can
170
- * run `observe(doc, plan)` right after the render, for the SAME doc — the
171
- * one call site MeasureController's ordering gate accepts (ADR-0019 §2.3).
172
- * @returns {void}
173
- */
174
- function renderAndRestore() {
175
- const plan = renderer.render(core.getState().doc);
176
- const domSelection = readDomSelection();
177
- if (domSelection !== null && domSelection !== undefined) {
178
- selection.restoreSelection(core.getState().selection, domSelection);
179
- }
180
- if (onRender !== undefined) {
181
- onRender(plan);
182
- }
183
- }
184
-
185
- const composition = new CompositionController({
186
- getState,
187
- dispatch,
188
- lookupParagraph,
189
- onAbandon, // provisional; see TODO(P1.9b, ADR-0004) above
190
- });
191
-
192
- // SelectionController is the one module that does not take {getState,
193
- // dispatch} — it emits a bare Selection through onAdopt and is driven
194
- // imperatively. The host adapts it (ADR-0022 D3/U4): onAdopt updates the
195
- // model selection ONLY (no history, no render — the browser already applied
196
- // it); restoreSelection is called after every render; and the IME flag is
197
- // bridged in `handleComposition*`.
198
- const selection = new SelectionController({
199
- root,
200
- lookupParagraph,
201
- onAdopt: (adopted) => {
202
- const before = core.getState();
203
- // 🔴 An adoption that reports the caret the model ALREADY has must not
204
- // discard a pending `storedMarks`. Nothing moved — the browser is
205
- // re-announcing the same position, which happens whenever focus leaves
206
- // and returns (a toolbar field, ADR-0031's size box) — and dropping the
207
- // pending format there silently cancels it.
208
- //
209
- // Measured before writing this (real isTrusted, AA04 in app.html): with
210
- // a collapsed caret, typing 20 into the size box and pressing Enter set
211
- // storedMarks and the box correctly read 20 — then focus returned to the
212
- // container, this handler ran, storedMarks was cleared, and the next
213
- // typed character came out at the OLD 11pt while the box fell back to 11.
214
- // ADR-0031's own DOĞRULANMALI #3.
215
- //
216
- // A genuine caret MOVE still clears it, which is the rule that matters:
217
- // a pending size belongs to the position it was set at.
218
- core.setState(
219
- withSelection(before, adopted, { keepStoredMarks: selectionsEqual(before.selection, adopted) }),
220
- );
221
- // 🔴 `onChange` fires here for the same reason it fires in `dispatch`:
222
- // the state genuinely CHANGED. It used to be omitted — "no history, no
223
- // render" (the browser already applied the caret) was read as "nothing
224
- // to announce", but `onChange` is a state notification, not a render
225
- // one. The omission made `onChange` quietly untrue for the single most
226
- // common state change there is, a caret move, and pushed every consumer
227
- // into subscribing to the raw `selectionchange` event instead — where
228
- // it races THIS handler and reads the state one adoption behind
229
- // (measured, SESSION-LOG Ek 64). Still no history push and no render:
230
- // those really are unnecessary, and adding them would be wrong.
231
- if (onChange !== undefined) {
232
- onChange(core.getState());
233
- }
234
- },
235
- getMode,
236
- });
237
-
238
- /**
239
- * Completes a composition seal (compositionend / focusout): bridges the IME
240
- * flag back onto SelectionController, and — on the composing→idle transition
241
- * only — forces exactly one render, even when the committed doc is
242
- * identity-equal (a refusal seal), which is F2's other half (composition.js
243
- * suppressed the render during the seal's own dispatch).
244
- * @param {boolean} wasComposing
245
- * @returns {void}
246
- */
247
- function afterSeal(wasComposing) {
248
- selection.setComposing(composition.isComposing);
249
- if (wasComposing && !composition.isComposing) {
250
- renderAndRestore();
251
- }
252
- }
253
-
254
- /** The context {getState, dispatch, ids, history} beforeinput/clipboard read. */
255
- const inputContext = { getState, dispatch, ids, history };
256
-
257
- const host = {
258
- getState,
259
- dispatch,
260
- // The shared instances, exposed so the canvas-editor facade (ADR-0024) can
261
- // build the box CanvasController + measure loop on the SAME renderer/history
262
- // this host edits through. With no injection these are simply this host's own.
263
- renderer,
264
- history,
265
- /** Force one render + caret restore from the model. @returns {void} */
266
- render() {
267
- renderAndRestore();
268
- },
269
-
270
- /** @param {import("../input/beforeinput.js").InputEventLike} event @returns {void} */
271
- handleBeforeInput(event) {
272
- handleBeforeInput(event, inputContext);
273
- },
274
- /** @param {any} event @returns {void} */
275
- handleKeyDown(event) {
276
- handleKeyDown(event);
277
- },
278
- /** @param {any} event @returns {void} */
279
- handleCopy(event) {
280
- handleCopy(event, inputContext);
281
- },
282
- /** @param {any} event @returns {void} */
283
- handleCut(event) {
284
- handleCut(event, inputContext);
285
- },
286
- /** @param {any} event @returns {void} */
287
- handlePaste(event) {
288
- if (composition.isComposing) {
289
- composition.sealForExternalMutation();
290
- }
291
- handlePaste(event, inputContext);
292
- },
293
-
294
- handleCompositionStart() {
295
- composition.handleCompositionStart();
296
- selection.setComposing(composition.isComposing);
297
- },
298
- handleCompositionUpdate() {
299
- composition.handleCompositionUpdate();
300
- },
301
- handleCompositionEnd() {
302
- const wasComposing = composition.isComposing;
303
- composition.handleCompositionEnd();
304
- afterSeal(wasComposing);
305
- },
306
- handleFocusOut() {
307
- const wasComposing = composition.isComposing;
308
- composition.handleFocusOut();
309
- afterSeal(wasComposing);
310
- },
311
-
312
- /**
313
- * Replace the whole document (DOCX import / JSON load). Seals any open
314
- * composition first (F3, the one genuinely external mutation path here),
315
- * then resets renderer, selection and history state in full (ADR-0027)
316
- * before projecting the new document — unconditionally, even when `core`/
317
- * `renderer` are injected/shared (ADR-0027's shared-core decision: the id
318
- * collision this guards against hits an injected box surface just as much
319
- * as the standalone text host, so there is no safe "leave it" branch).
320
- *
321
- * Also reseeds the shared `ids` factory past every id already used in
322
- * `nextDoc` (ADR-0027 Ek 49). Without this, a factory minted for the
323
- * PREVIOUS document keeps handing out ids from its own old counter, which
324
- * can already be in use in `nextDoc` — `pasteBoxes`/`splitParagraph` then
325
- * mint a NodeId that collides with a real node in the new document
326
- * (measured: paste throws in dev / silently no-ops in production; a
327
- * paragraph split has no such production-safe fallback and can give two
328
- * paragraphs the same id). The factory is reseeded by mutating
329
- * `inputContext.ids`'s PROPERTY, not by reassigning the local `ids`
330
- * closure variable — `inputContext` is a plain object built once at
331
- * construction, so a bare reassignment would not propagate, but every
332
- * consumer (`beforeinput.js`/`clipboard.js` handlers) reads
333
- * `context.ids` fresh on each call, so the property mutation does.
334
- *
335
- * `onLoadDocument` (if supplied) runs FIRST, before any of this host's own
336
- * resets, and receives the same new factory — the shared-core injector
337
- * uses it to both reset the box surface's own selection/gesture/drag
338
- * state (which this host has no visibility into) AND apply the same
339
- * reseeded factory to `CanvasController.ids` (same object, same fix).
340
- * @param {import("../model/node.js").Doc} nextDoc
341
- * @returns {void}
342
- */
343
- loadDocument(nextDoc) {
344
- if (composition.isComposing) {
345
- composition.sealForExternalMutation();
346
- selection.setComposing(false);
347
- }
348
- const newIds = createIdFactory(maxNodeId(nextDoc) + 1);
349
- if (onLoadDocument !== undefined) {
350
- onLoadDocument(newIds);
351
- }
352
- renderer.reset();
353
- selection.reset();
354
- history.clear();
355
- inputContext.ids = newIds;
356
- core.setState(createEditorState(nextDoc));
357
- renderAndRestore();
358
- if (onChange !== undefined) {
359
- onChange(core.getState());
360
- }
361
- },
362
-
363
- /**
364
- * Supplies resolved media (`mediaPartName` -> `data:` URI) for `Doc.images`
365
- * render (ADR-0020 Karar 6 render half). Call BEFORE `loadDocument` so the
366
- * first render sets each `<img>`'s `src`. Render-only; does not touch export.
367
- * @param {Map<string, string> | null | undefined} media
368
- * @returns {void}
369
- */
370
- setMedia(media) {
371
- renderer.setMedia(media);
372
- },
373
-
374
- // Exposed for tests to assert against; not part of the public facade.
375
- _composition: composition,
376
- _selection: selection,
377
-
378
- // Live wiring handles, set by `attach`, cleared by `detach`.
379
- /** @type {any} */
380
- _rootTarget: undefined,
381
- /** @type {Record<string, (event: any) => void> | undefined} */
382
- _rootListeners: undefined,
383
- /** @type {any} */
384
- _documentTarget: undefined,
385
-
386
- /**
387
- * Thin real-DOM wiring. `rootTarget` gets ONE delegated listener per input
388
- * event (they bubble from the per-box hosts); `documentTarget` gets the
389
- * selectionchange listener (the only place the browser fires it).
390
- * @param {any} rootTarget
391
- * @param {any} documentTarget
392
- * @returns {void}
393
- */
394
- attach(rootTarget, documentTarget) {
395
- /** @type {Record<string, (event: any) => void>} */
396
- const rootListeners = {
397
- beforeinput: (e) => this.handleBeforeInput(e),
398
- keydown: (e) => this.handleKeyDown(e),
399
- compositionstart: () => this.handleCompositionStart(),
400
- compositionupdate: () => this.handleCompositionUpdate(),
401
- compositionend: () => this.handleCompositionEnd(),
402
- focusout: () => this.handleFocusOut(),
403
- copy: (e) => this.handleCopy(e),
404
- cut: (e) => this.handleCut(e),
405
- paste: (e) => this.handlePaste(e),
406
- };
407
- for (const [type, listener] of Object.entries(rootListeners)) {
408
- rootTarget.addEventListener(type, listener);
409
- }
410
- this._rootTarget = rootTarget;
411
- this._rootListeners = rootListeners;
412
-
413
- selection.attach(documentTarget, readDomSelection);
414
- this._documentTarget = documentTarget;
415
- },
416
-
417
- /** @returns {void} */
418
- detach() {
419
- if (this._rootTarget !== undefined && this._rootListeners !== undefined) {
420
- for (const [type, listener] of Object.entries(this._rootListeners)) {
421
- this._rootTarget.removeEventListener(type, listener);
422
- }
423
- this._rootTarget = undefined;
424
- this._rootListeners = undefined;
425
- }
426
- if (this._documentTarget !== undefined) {
427
- selection.detach(this._documentTarget);
428
- this._documentTarget = undefined;
429
- }
430
- },
431
- };
432
-
433
- return host;
434
- }
1
+ // Editing-assembly — wires the already-written editing modules into one live,
2
+ // per-box-contenteditable editor (ADR-0022). PURE COMPOSITION: no new engine,
3
+ // no module-API change. Every piece here already exists and is unit-tested on
4
+ // its own; this file only holds them together and owns the dispatch loop the
5
+ // modules' own docstrings say the *host* must own (F2 render suppression while
6
+ // composing, the forced render on composing→idle, and the history-push origin
7
+ // guard).
8
+ //
9
+ // Handlers are plain methods a test calls directly with a fake event — the same
10
+ // convention `handleBeforeInput`/`handlePaste`/`CanvasController` already use.
11
+ // `attach`/`detach` are ONLY the thin real-DOM wiring on top, and they use
12
+ // EVENT DELEGATION (ADR-0022 D2): ONE `beforeinput`/`composition*`/`focusout`
13
+ // listener on the container root (these events bubble from every per-box host),
14
+ // and `selectionchange` on `document` — the browser fires selectionchange only
15
+ // there, never on the editing host (ADR-0014 DOĞRULANMALI #1). No per-box
16
+ // listeners: R001's 1,344 hosts (§8 risk #9) would otherwise mean 1,344×N
17
+ // listeners for zero semantic gain — delegation is one listener, and ADR-0014's
18
+ // per-box decision is about the `contenteditable` ATTRIBUTE, not listener count.
19
+ //
20
+ // This module touches no `window`/`document` global at import time (invariant
21
+ // #5): every DOM reference is injected (`root`, `document`, `getDomSelection`).
22
+
23
+ import { applyTransaction, createEditorState, withSelection } from "../state/editor-state.js";
24
+ import { createIdFactory, maxNodeId } from "../model/ids.js";
25
+ import { HistoryEngine } from "../history/history.js";
26
+ import { Renderer } from "../render/renderer.js";
27
+ import { handleBeforeInput } from "../input/beforeinput.js";
28
+ import { handleKeyDown } from "../input/keymap.js";
29
+ import { handleCopy, handleCut, handlePaste } from "../input/clipboard.js";
30
+ import { CompositionController } from "../input/composition.js";
31
+ import { SelectionController } from "../selection/controller.js";
32
+ import { selectionsEqual } from "../model/position.js";
33
+
34
+ /**
35
+ * @typedef {object} EditingHostOptions
36
+ * @property {any} root the container element the renderer mounts into AND the
37
+ * delegated input listeners attach to.
38
+ * @property {import("../render/dom.js").DocumentLike} document owner document.
39
+ * @property {import("../model/node.js").Doc} doc the initial document.
40
+ * @property {import("../model/ids.js").IdFactory} ids
41
+ * @property {() => import("../selection/controller.js").DomSelectionLike | null} [getDomSelection]
42
+ * Reads the live DOM selection (`document.getSelection()` in a browser).
43
+ * Defaults to `document.getSelection?.()` — a fake DocumentLike returns null,
44
+ * which simply skips the model→DOM caret restore in unit tests.
45
+ * @property {(state: import("../state/editor-state.js").EditorState) => void} [onChange]
46
+ * fires after EVERY state change this host makes — a dispatch (typing,
47
+ * formatting, undo/redo through the browser's own historyUndo) AND a bare
48
+ * caret move adopted from the browser's `selectionchange` (`onAdopt`
49
+ * below). A UI that mirrors selection-derived state (which toolbar button
50
+ * is lit, what an indent field reads) must hang off THIS, never off
51
+ * `document`'s own `selectionchange` — that event races this host's own
52
+ * listener and, if the UI's listener happens to be registered first, reads
53
+ * the state one adoption behind (measured, SESSION-LOG Ek 64).
54
+ * @property {(plan: import("../render/renderer.js").RenderPlan) => void} [onRender]
55
+ * fires once per model→DOM projection (`renderAndRestore`), with that render's
56
+ * own PLAN. A test seam for asserting F2 render-suppression while composing,
57
+ * and the canvas measure loop's `observe(doc, plan)` hook (ADR-0024). Production
58
+ * text-only callers omit it. NOT fired by the initial render.
59
+ * @property {"commit" | "discard"} [onAbandon] TODO(P1.9b, ADR-0004): the
60
+ * commit-vs-discard policy for an ABANDONED composition is undecided until
61
+ * real-IME hardware measurement (ADR-0004 Karar 4, Taslak). This host passes
62
+ * a PROVISIONAL "discard" default; the permanent value is P1.9b's to set.
63
+ * @property {EditorCore} [core] a SHARED state+history core (ADR-0024 parça 2,
64
+ * the unified canvas editor). When present, this host's text-editing path
65
+ * folds into the SAME state and undo stack that the box `CanvasController`
66
+ * mutates — one model, one history. Omitted (editor.html) -> the host owns
67
+ * its own, and behaves exactly as before.
68
+ * @property {import("../render/renderer.js").Renderer} [renderer] a SHARED
69
+ * renderer (same ADR-0024 seam). When injected, the OWNER drives the initial
70
+ * paint and the render lifecycle; the host renders into it but never creates
71
+ * or destroys it. Omitted -> the host makes its own and paints once here.
72
+ * @property {() => import("../canvas/mode-controller.js").Mode} [getMode] reads
73
+ * the box/text mode for the shared canvas surface (ADR-0024). Passed straight
74
+ * to SelectionController; undefined -> SelectionController defaults to "text"
75
+ * (exactly today's editor.html behaviour — a text-only surface).
76
+ * @property {(newIds: import("../model/ids.js").IdFactory) => void} [onLoadDocument]
77
+ * called at the START of `loadDocument`'s reset (ADR-0027), before renderer/
78
+ * selection/history are cleared, with the freshly-reseeded IdFactory this
79
+ * host is about to switch `inputContext.ids` to (ADR-0027 Ek 49 — reseeded
80
+ * past every id already in the NEW document, so property-mutating `ids` to
81
+ * this same factory keeps `CanvasController.ids` in sync). This module
82
+ * deliberately does not import `CanvasController` (architectural boundary
83
+ * between the text-editing assembly and the canvas layer) — on the shared-core
84
+ * seam (ADR-0024, `canvas-editor.js`) the box surface has its OWN selection/
85
+ * gesture/drag state that ADR-0027's renderer/selection/history reset does not
86
+ * reach, and a stale box selection surviving a document swap can silently
87
+ * retarget an action onto an unrelated box the user never selected in the NEW
88
+ * document (measured against real corpus documents, ADR-0027 Ek 47: both a
89
+ * crash — `movePatch`/`rectOfLayout` do not null-check a missing layout — and
90
+ * a SILENT wrong move/delete when the stale id happens to collide with a real
91
+ * container in the new document). `canvas-editor.js` supplies `(newIds) => {
92
+ * controller.reset(); controller.ids = newIds; }` here; a text-only caller
93
+ * (editor.html) omits it.
94
+ */
95
+
96
+ /**
97
+ * A shared state+history container (ADR-0024). Injecting one makes the box
98
+ * surface and the text surface operate on ONE model with ONE undo stack.
99
+ * @typedef {object} EditorCore
100
+ * @property {() => import("../state/editor-state.js").EditorState} getState
101
+ * @property {(next: import("../state/editor-state.js").EditorState) => void} setState
102
+ * @property {import("../history/history.js").HistoryEngine} history
103
+ */
104
+
105
+ /**
106
+ * @param {EditingHostOptions} options
107
+ */
108
+ export function createEditingHost({ root, document, doc, ids, getDomSelection, onChange, onRender, onAbandon = "discard", core: injectedCore, renderer: injectedRenderer, getMode, onLoadDocument }) {
109
+ // ONE shared core (state + history) OR our own. The default path (no
110
+ // injection) is byte-for-byte the old behaviour: a private `let state` and a
111
+ // fresh HistoryEngine, just held behind the core's accessors.
112
+ const ownState = { value: createEditorState(doc) };
113
+ const core = injectedCore ?? {
114
+ getState: () => ownState.value,
115
+ setState: (next) => {
116
+ ownState.value = next;
117
+ },
118
+ history: new HistoryEngine(),
119
+ };
120
+ const history = core.history;
121
+
122
+ // Injected renderer -> the owner (canvas-editor facade) drives the initial
123
+ // paint and owns destroy(); we only render INTO it. Our own -> paint once now.
124
+ const renderer = injectedRenderer ?? new Renderer(root, document);
125
+ if (injectedRenderer === undefined) {
126
+ renderer.render(core.getState().doc);
127
+ }
128
+
129
+ // ONE lookup instance feeds every consumer (SelectionController,
130
+ // CompositionController, and modelToDom via restoreSelection). It reads the
131
+ // renderer's live element map on each call, so it stays valid across renders.
132
+ const lookupParagraph = renderer.paragraphLookup();
133
+
134
+ const readDomSelection =
135
+ getDomSelection ??
136
+ (() => (typeof (/** @type {any} */ (document).getSelection) === "function" ? /** @type {any} */ (document).getSelection() : null));
137
+
138
+ const getState = core.getState;
139
+
140
+ /**
141
+ * The host dispatch loop (ADR-0022 D1/U2). Folds a transaction into state and
142
+ * projects it, with two host-owned rules the modules cannot enforce alone:
143
+ * - O1: `history.push` is guarded on `tr.origin` — an undo/redo transaction
144
+ * (origin "history") is already the RESULT of popping the stack; pushing
145
+ * it back would double-count. Mirrors `applyTransaction`'s own
146
+ * `tr.origin === "history"` branch.
147
+ * - F2: no `Renderer.render()` while composing. During a composition seal
148
+ * the commit transaction flows through here while `isComposing` is still
149
+ * true (composition.js sets phase idle AFTER dispatching); the forced
150
+ * render happens once the handler returns — see `handleCompositionEnd`.
151
+ * @param {import("../model/transaction.js").Transaction} tr
152
+ * @returns {void}
153
+ */
154
+ function dispatch(tr) {
155
+ if (tr.origin !== "history") {
156
+ history.push(tr);
157
+ }
158
+ core.setState(applyTransaction(core.getState(), tr));
159
+ if (onChange !== undefined) {
160
+ onChange(core.getState());
161
+ }
162
+ if (!composition.isComposing) {
163
+ renderAndRestore();
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Projects the current model and restores the caret from it. The render
169
+ * PLAN is handed to `onRender` so a consumer (the canvas measure loop) can
170
+ * run `observe(doc, plan)` right after the render, for the SAME doc — the
171
+ * one call site MeasureController's ordering gate accepts (ADR-0019 §2.3).
172
+ * @returns {void}
173
+ */
174
+ function renderAndRestore() {
175
+ const plan = renderer.render(core.getState().doc);
176
+ const domSelection = readDomSelection();
177
+ if (domSelection !== null && domSelection !== undefined) {
178
+ selection.restoreSelection(core.getState().selection, domSelection);
179
+ }
180
+ if (onRender !== undefined) {
181
+ onRender(plan);
182
+ }
183
+ }
184
+
185
+ const composition = new CompositionController({
186
+ getState,
187
+ dispatch,
188
+ lookupParagraph,
189
+ onAbandon, // provisional; see TODO(P1.9b, ADR-0004) above
190
+ });
191
+
192
+ // SelectionController is the one module that does not take {getState,
193
+ // dispatch} — it emits a bare Selection through onAdopt and is driven
194
+ // imperatively. The host adapts it (ADR-0022 D3/U4): onAdopt updates the
195
+ // model selection ONLY (no history, no render — the browser already applied
196
+ // it); restoreSelection is called after every render; and the IME flag is
197
+ // bridged in `handleComposition*`.
198
+ const selection = new SelectionController({
199
+ root,
200
+ lookupParagraph,
201
+ onAdopt: (adopted) => {
202
+ const before = core.getState();
203
+ // 🔴 An adoption that reports the caret the model ALREADY has must not
204
+ // discard a pending `storedMarks`. Nothing moved — the browser is
205
+ // re-announcing the same position, which happens whenever focus leaves
206
+ // and returns (a toolbar field, ADR-0031's size box) — and dropping the
207
+ // pending format there silently cancels it.
208
+ //
209
+ // Measured before writing this (real isTrusted, AA04 in app.html): with
210
+ // a collapsed caret, typing 20 into the size box and pressing Enter set
211
+ // storedMarks and the box correctly read 20 — then focus returned to the
212
+ // container, this handler ran, storedMarks was cleared, and the next
213
+ // typed character came out at the OLD 11pt while the box fell back to 11.
214
+ // ADR-0031's own DOĞRULANMALI #3.
215
+ //
216
+ // A genuine caret MOVE still clears it, which is the rule that matters:
217
+ // a pending size belongs to the position it was set at.
218
+ core.setState(
219
+ withSelection(before, adopted, { keepStoredMarks: selectionsEqual(before.selection, adopted) }),
220
+ );
221
+ // 🔴 `onChange` fires here for the same reason it fires in `dispatch`:
222
+ // the state genuinely CHANGED. It used to be omitted — "no history, no
223
+ // render" (the browser already applied the caret) was read as "nothing
224
+ // to announce", but `onChange` is a state notification, not a render
225
+ // one. The omission made `onChange` quietly untrue for the single most
226
+ // common state change there is, a caret move, and pushed every consumer
227
+ // into subscribing to the raw `selectionchange` event instead — where
228
+ // it races THIS handler and reads the state one adoption behind
229
+ // (measured, SESSION-LOG Ek 64). Still no history push and no render:
230
+ // those really are unnecessary, and adding them would be wrong.
231
+ if (onChange !== undefined) {
232
+ onChange(core.getState());
233
+ }
234
+ },
235
+ getMode,
236
+ });
237
+
238
+ /**
239
+ * Completes a composition seal (compositionend / focusout): bridges the IME
240
+ * flag back onto SelectionController, and — on the composing→idle transition
241
+ * only — forces exactly one render, even when the committed doc is
242
+ * identity-equal (a refusal seal), which is F2's other half (composition.js
243
+ * suppressed the render during the seal's own dispatch).
244
+ * @param {boolean} wasComposing
245
+ * @returns {void}
246
+ */
247
+ function afterSeal(wasComposing) {
248
+ selection.setComposing(composition.isComposing);
249
+ if (wasComposing && !composition.isComposing) {
250
+ renderAndRestore();
251
+ }
252
+ }
253
+
254
+ /** The context {getState, dispatch, ids, history} beforeinput/clipboard read. */
255
+ const inputContext = { getState, dispatch, ids, history };
256
+
257
+ const host = {
258
+ getState,
259
+ dispatch,
260
+ // The shared instances, exposed so the canvas-editor facade (ADR-0024) can
261
+ // build the box CanvasController + measure loop on the SAME renderer/history
262
+ // this host edits through. With no injection these are simply this host's own.
263
+ renderer,
264
+ history,
265
+ /** Force one render + caret restore from the model. @returns {void} */
266
+ render() {
267
+ renderAndRestore();
268
+ },
269
+
270
+ /** @param {import("../input/beforeinput.js").InputEventLike} event @returns {void} */
271
+ handleBeforeInput(event) {
272
+ handleBeforeInput(event, inputContext);
273
+ },
274
+ /** @param {any} event @returns {void} */
275
+ handleKeyDown(event) {
276
+ handleKeyDown(event);
277
+ },
278
+ /** @param {any} event @returns {void} */
279
+ handleCopy(event) {
280
+ handleCopy(event, inputContext);
281
+ },
282
+ /** @param {any} event @returns {void} */
283
+ handleCut(event) {
284
+ handleCut(event, inputContext);
285
+ },
286
+ /** @param {any} event @returns {void} */
287
+ handlePaste(event) {
288
+ if (composition.isComposing) {
289
+ composition.sealForExternalMutation();
290
+ }
291
+ handlePaste(event, inputContext);
292
+ },
293
+
294
+ handleCompositionStart() {
295
+ composition.handleCompositionStart();
296
+ selection.setComposing(composition.isComposing);
297
+ },
298
+ handleCompositionUpdate() {
299
+ composition.handleCompositionUpdate();
300
+ },
301
+ handleCompositionEnd() {
302
+ const wasComposing = composition.isComposing;
303
+ composition.handleCompositionEnd();
304
+ afterSeal(wasComposing);
305
+ },
306
+ handleFocusOut() {
307
+ const wasComposing = composition.isComposing;
308
+ composition.handleFocusOut();
309
+ afterSeal(wasComposing);
310
+ },
311
+
312
+ /**
313
+ * Replace the whole document (DOCX import / JSON load). Seals any open
314
+ * composition first (F3, the one genuinely external mutation path here),
315
+ * then resets renderer, selection and history state in full (ADR-0027)
316
+ * before projecting the new document — unconditionally, even when `core`/
317
+ * `renderer` are injected/shared (ADR-0027's shared-core decision: the id
318
+ * collision this guards against hits an injected box surface just as much
319
+ * as the standalone text host, so there is no safe "leave it" branch).
320
+ *
321
+ * Also reseeds the shared `ids` factory past every id already used in
322
+ * `nextDoc` (ADR-0027 Ek 49). Without this, a factory minted for the
323
+ * PREVIOUS document keeps handing out ids from its own old counter, which
324
+ * can already be in use in `nextDoc` — `pasteBoxes`/`splitParagraph` then
325
+ * mint a NodeId that collides with a real node in the new document
326
+ * (measured: paste throws in dev / silently no-ops in production; a
327
+ * paragraph split has no such production-safe fallback and can give two
328
+ * paragraphs the same id). The factory is reseeded by mutating
329
+ * `inputContext.ids`'s PROPERTY, not by reassigning the local `ids`
330
+ * closure variable — `inputContext` is a plain object built once at
331
+ * construction, so a bare reassignment would not propagate, but every
332
+ * consumer (`beforeinput.js`/`clipboard.js` handlers) reads
333
+ * `context.ids` fresh on each call, so the property mutation does.
334
+ *
335
+ * `onLoadDocument` (if supplied) runs FIRST, before any of this host's own
336
+ * resets, and receives the same new factory — the shared-core injector
337
+ * uses it to both reset the box surface's own selection/gesture/drag
338
+ * state (which this host has no visibility into) AND apply the same
339
+ * reseeded factory to `CanvasController.ids` (same object, same fix).
340
+ * @param {import("../model/node.js").Doc} nextDoc
341
+ * @returns {void}
342
+ */
343
+ loadDocument(nextDoc) {
344
+ if (composition.isComposing) {
345
+ composition.sealForExternalMutation();
346
+ selection.setComposing(false);
347
+ }
348
+ const newIds = createIdFactory(maxNodeId(nextDoc) + 1);
349
+ if (onLoadDocument !== undefined) {
350
+ onLoadDocument(newIds);
351
+ }
352
+ renderer.reset();
353
+ selection.reset();
354
+ history.clear();
355
+ inputContext.ids = newIds;
356
+ core.setState(createEditorState(nextDoc));
357
+ renderAndRestore();
358
+ if (onChange !== undefined) {
359
+ onChange(core.getState());
360
+ }
361
+ },
362
+
363
+ /**
364
+ * Supplies resolved media (`mediaPartName` -> `data:` URI) for `Doc.images`
365
+ * render (ADR-0020 Karar 6 render half). Call BEFORE `loadDocument` so the
366
+ * first render sets each `<img>`'s `src`. Render-only; does not touch export.
367
+ * @param {Map<string, string> | null | undefined} media
368
+ * @returns {void}
369
+ */
370
+ setMedia(media) {
371
+ renderer.setMedia(media);
372
+ },
373
+
374
+ // Exposed for tests to assert against; not part of the public facade.
375
+ _composition: composition,
376
+ _selection: selection,
377
+
378
+ // Live wiring handles, set by `attach`, cleared by `detach`.
379
+ /** @type {any} */
380
+ _rootTarget: undefined,
381
+ /** @type {Record<string, (event: any) => void> | undefined} */
382
+ _rootListeners: undefined,
383
+ /** @type {any} */
384
+ _documentTarget: undefined,
385
+
386
+ /**
387
+ * Thin real-DOM wiring. `rootTarget` gets ONE delegated listener per input
388
+ * event (they bubble from the per-box hosts); `documentTarget` gets the
389
+ * selectionchange listener (the only place the browser fires it).
390
+ * @param {any} rootTarget
391
+ * @param {any} documentTarget
392
+ * @returns {void}
393
+ */
394
+ attach(rootTarget, documentTarget) {
395
+ /** @type {Record<string, (event: any) => void>} */
396
+ const rootListeners = {
397
+ beforeinput: (e) => this.handleBeforeInput(e),
398
+ keydown: (e) => this.handleKeyDown(e),
399
+ compositionstart: () => this.handleCompositionStart(),
400
+ compositionupdate: () => this.handleCompositionUpdate(),
401
+ compositionend: () => this.handleCompositionEnd(),
402
+ focusout: () => this.handleFocusOut(),
403
+ copy: (e) => this.handleCopy(e),
404
+ cut: (e) => this.handleCut(e),
405
+ paste: (e) => this.handlePaste(e),
406
+ };
407
+ for (const [type, listener] of Object.entries(rootListeners)) {
408
+ rootTarget.addEventListener(type, listener);
409
+ }
410
+ this._rootTarget = rootTarget;
411
+ this._rootListeners = rootListeners;
412
+
413
+ selection.attach(documentTarget, readDomSelection);
414
+ this._documentTarget = documentTarget;
415
+ },
416
+
417
+ /** @returns {void} */
418
+ detach() {
419
+ if (this._rootTarget !== undefined && this._rootListeners !== undefined) {
420
+ for (const [type, listener] of Object.entries(this._rootListeners)) {
421
+ this._rootTarget.removeEventListener(type, listener);
422
+ }
423
+ this._rootTarget = undefined;
424
+ this._rootListeners = undefined;
425
+ }
426
+ if (this._documentTarget !== undefined) {
427
+ selection.detach(this._documentTarget);
428
+ this._documentTarget = undefined;
429
+ }
430
+ },
431
+ };
432
+
433
+ return host;
434
+ }