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