@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,341 +1,341 @@
|
|
|
1
|
-
import { isDev } from "../dev.js";
|
|
2
|
-
import { paragraphById, paragraphText } from "../model/node.js";
|
|
3
|
-
import {
|
|
4
|
-
containerIdOf,
|
|
5
|
-
createPosition,
|
|
6
|
-
createSelection,
|
|
7
|
-
isSingleParagraph,
|
|
8
|
-
paragraphIdOf,
|
|
9
|
-
selectionRange,
|
|
10
|
-
} from "../model/position.js";
|
|
11
|
-
import { createTransaction } from "../model/transaction.js";
|
|
12
|
-
import { textNodesOf } from "../selection/dom-map.js";
|
|
13
|
-
import { deleteSelection, insertText } from "../commands/text.js";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* P1.9a — IME composition. Design: `docs/adr/0004-input-and-ime-strategy.md`.
|
|
17
|
-
* This module converts that design into code; it does not add to it. Where a
|
|
18
|
-
* decision is not made by the ADR (the abandon-path commit policy, §3), this
|
|
19
|
-
* module takes the undecided value as an injected parameter rather than
|
|
20
|
-
* choosing one — see `onAbandon` below.
|
|
21
|
-
*
|
|
22
|
-
* Two freezes are this module's job (ADR-0004 Karar 2, F1/F3): no step is ever
|
|
23
|
-
* applied while composing, and any other model-mutating path seals the
|
|
24
|
-
* bracket first. The third freeze, F2 ("no `Renderer.render()` while
|
|
25
|
-
* composing"), is NOT this module's job — it belongs to the host's dispatch
|
|
26
|
-
* loop, which this module has no reference to (its context is exactly
|
|
27
|
-
* `{getState, dispatch}`, matching `input/beforeinput.js`'s
|
|
28
|
-
* `BeforeInputContext`). For the same reason, "re-render from the model"
|
|
29
|
-
* after a refusal (ADR-0004 §2) is also a host responsibility: a refusal
|
|
30
|
-
* seal never changes `state.doc` (by design — F1 held), so a plain
|
|
31
|
-
* `render(state.doc)` call is a structural no-op against `Renderer`'s
|
|
32
|
-
* identity fast path (`render/renderer.js`). The host is expected to force
|
|
33
|
-
* one render whenever `isComposing` transitions from `true` to `false`,
|
|
34
|
-
* regardless of doc identity.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* @typedef {object} CompositionSnapshot
|
|
39
|
-
* @property {import("../model/node.js").NodeId} containerId
|
|
40
|
-
* @property {import("../model/node.js").NodeId} paragraphId
|
|
41
|
-
* @property {number} fromOffset
|
|
42
|
-
* @property {number} toOffset
|
|
43
|
-
* @property {string} modelTextAtStart
|
|
44
|
-
* @property {import("../selection/dom-map.js").MappableNode} paragraphElement
|
|
45
|
-
*/
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Why a composing bracket closed.
|
|
49
|
-
* @typedef {"committed" | "abandoned:blur" | "abandoned:superseded" | "abandoned:preempted"} SealTrigger
|
|
50
|
-
*/
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Reads a paragraph's live text straight from its text nodes — never
|
|
54
|
-
* `innerHTML`, never interpreting element structure (invariant #7: whatever
|
|
55
|
-
* markup the IME left behind is just characters). Mirrors `textNodesOf`'s own
|
|
56
|
-
* "walks `parentNode`, not `closest()`" reasoning: this has to work on nodes
|
|
57
|
-
* the browser inserted during composition, which the model never created.
|
|
58
|
-
* @param {import("../selection/dom-map.js").MappableNode} paragraphElement
|
|
59
|
-
* @returns {string}
|
|
60
|
-
*/
|
|
61
|
-
export function readParagraphText(paragraphElement) {
|
|
62
|
-
return textNodesOf(paragraphElement)
|
|
63
|
-
.map((node) => node.data ?? "")
|
|
64
|
-
.join("");
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* @typedef {{ ok: true, composed: string } | { ok: false, reason: string }} VerifyResult
|
|
69
|
-
*/
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* The self-verifying read (ADR-0004 §2): four gates, and a failure of any one
|
|
73
|
-
* refuses rather than guesses. `currentModelText` is `null` when the
|
|
74
|
-
* paragraph the snapshot pointed at can no longer be resolved at all.
|
|
75
|
-
* @param {CompositionSnapshot} snapshot
|
|
76
|
-
* @param {string} domText
|
|
77
|
-
* @param {string | null} currentModelText
|
|
78
|
-
* @returns {VerifyResult}
|
|
79
|
-
*/
|
|
80
|
-
export function verifyAndExtract(snapshot, domText, currentModelText) {
|
|
81
|
-
// KAPI 1 (F3 check): the model must not have moved out from under us.
|
|
82
|
-
if (currentModelText === null || currentModelText !== snapshot.modelTextAtStart) {
|
|
83
|
-
return { ok: false, reason: "model-changed" };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const prefix = snapshot.modelTextAtStart.slice(0, snapshot.fromOffset);
|
|
87
|
-
const suffix = snapshot.modelTextAtStart.slice(snapshot.toOffset);
|
|
88
|
-
|
|
89
|
-
// KAPI 2 (boundary)
|
|
90
|
-
if (!domText.startsWith(prefix)) {
|
|
91
|
-
return { ok: false, reason: "prefix-mismatch" };
|
|
92
|
-
}
|
|
93
|
-
// KAPI 3 (boundary)
|
|
94
|
-
if (!domText.endsWith(suffix)) {
|
|
95
|
-
return { ok: false, reason: "suffix-mismatch" };
|
|
96
|
-
}
|
|
97
|
-
// KAPI 4 (length) — guards the slice below from going negative when prefix
|
|
98
|
-
// and suffix overlap (a shorter domText than the untouched boundaries need).
|
|
99
|
-
if (domText.length < prefix.length + suffix.length) {
|
|
100
|
-
return { ok: false, reason: "length-mismatch" };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return { ok: true, composed: domText.slice(prefix.length, domText.length - suffix.length) };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* @typedef {object} SealPlan
|
|
108
|
-
* @property {import("../model/transaction.js").Transaction | null} transaction
|
|
109
|
-
* @property {boolean} restoreSelection
|
|
110
|
-
* @property {import("../model/position.js").Selection | undefined} selectionOverride
|
|
111
|
-
* @property {string | undefined} refusedReason
|
|
112
|
-
*/
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Decides what a seal does, without touching `getState`/`dispatch` itself —
|
|
116
|
-
* this is the pure half `CompositionController` drives. Takes `onAbandon` as
|
|
117
|
-
* a parameter rather than reading a field, so the mechanism ("both values
|
|
118
|
-
* honoured") is testable independently of any chosen policy (ADR-0004 §3,
|
|
119
|
-
* DOĞRULANMALI #8 — the policy itself is undecided, deferred to P1.9b).
|
|
120
|
-
*
|
|
121
|
-
* @param {import("../state/editor-state.js").EditorState} state
|
|
122
|
-
* @param {CompositionSnapshot} snapshot
|
|
123
|
-
* @param {string} domText
|
|
124
|
-
* @param {SealTrigger} reason
|
|
125
|
-
* @param {"commit" | "discard"} onAbandon
|
|
126
|
-
* @returns {SealPlan}
|
|
127
|
-
*/
|
|
128
|
-
export function planSeal(state, snapshot, domText, reason, onAbandon) {
|
|
129
|
-
const attemptsCommit = reason === "committed" || onAbandon === "commit";
|
|
130
|
-
|
|
131
|
-
if (!attemptsCommit) {
|
|
132
|
-
return { transaction: null, restoreSelection: false, selectionOverride: undefined, refusedReason: undefined };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Below this point, an abandon-path attempt (onAbandon === "commit") is
|
|
136
|
-
// treated exactly like Yol 1's real `compositionend`: if verification
|
|
137
|
-
// fails, it falls through to Yol 5's refusal semantics regardless of which
|
|
138
|
-
// of the two triggered the attempt. Refusal is the narrower, purely
|
|
139
|
-
// technical condition (the DOM no longer proves what changed) and takes
|
|
140
|
-
// precedence over the wider abandon-policy choice — a policy decision
|
|
141
|
-
// should never override a safety check.
|
|
142
|
-
|
|
143
|
-
const para = paragraphById(state.doc, snapshot.paragraphId);
|
|
144
|
-
const currentModelText = para === null ? null : paragraphText(para);
|
|
145
|
-
const verified = verifyAndExtract(snapshot, domText, currentModelText);
|
|
146
|
-
|
|
147
|
-
const composingSelection = createSelection(
|
|
148
|
-
createPosition(snapshot.containerId, snapshot.paragraphId, snapshot.fromOffset),
|
|
149
|
-
createPosition(snapshot.containerId, snapshot.paragraphId, snapshot.toOffset),
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
if (!verified.ok) {
|
|
153
|
-
// Yol 5: a refused (or unsupported) composition never happened, as far as
|
|
154
|
-
// the model is concerned (F1 held it untouched) — so caret and selection
|
|
155
|
-
// return to exactly where they stood at `compositionstart`. If the
|
|
156
|
-
// paragraph itself is gone, there is nowhere sensible to put a caret;
|
|
157
|
-
// `state.selection` is left exactly as it is.
|
|
158
|
-
return {
|
|
159
|
-
transaction: null,
|
|
160
|
-
restoreSelection: para !== null,
|
|
161
|
-
selectionOverride: para !== null ? composingSelection : undefined,
|
|
162
|
-
refusedReason: verified.reason,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const pseudoState = { doc: state.doc, selection: composingSelection, storedMarks: state.storedMarks };
|
|
167
|
-
|
|
168
|
-
let transaction = null;
|
|
169
|
-
if (verified.composed !== "") {
|
|
170
|
-
// Reuses `insertText` rather than building the step directly: this is
|
|
171
|
-
// exactly what carries ADR-0017's left-biased `marksAt` (and, riding
|
|
172
|
-
// along inside `marks`, `rPrRef`) into the composed text "for free".
|
|
173
|
-
transaction = insertText(pseudoState, verified.composed, "ime");
|
|
174
|
-
} else if (snapshot.fromOffset !== snapshot.toOffset) {
|
|
175
|
-
transaction = deleteSelection(pseudoState, "ime");
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return { transaction, restoreSelection: false, selectionOverride: undefined, refusedReason: undefined };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* @typedef {object} CompositionContext
|
|
183
|
-
* @property {() => import("../state/editor-state.js").EditorState} getState
|
|
184
|
-
* @property {(tr: import("../model/transaction.js").Transaction) => void} dispatch
|
|
185
|
-
*/
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Tracks one IME composing bracket. Two rest states, `idle` and `composing`;
|
|
189
|
-
* `sealed(reason)` (ADR-0004 §1) is a transition, not a third rest state —
|
|
190
|
-
* carrying `reason` forward is what lets "closed" and "abandoned" stay
|
|
191
|
-
* distinguishable (ADR-0016 §1's `sealReason` reasoning, reused).
|
|
192
|
-
*
|
|
193
|
-
* Sealing is idempotent: once a bracket is sealed, a second seal call is a
|
|
194
|
-
* no-op. Event order across the closing paths is not guaranteed (ADR-0016
|
|
195
|
-
* G10 — measured on a different pair of events, same class of problem), so
|
|
196
|
-
* whichever seal arrives first wins and the second finds nothing open.
|
|
197
|
-
*/
|
|
198
|
-
export class CompositionController {
|
|
199
|
-
/**
|
|
200
|
-
* @param {object} options
|
|
201
|
-
* @param {() => import("../state/editor-state.js").EditorState} options.getState
|
|
202
|
-
* @param {(tr: import("../model/transaction.js").Transaction) => void} options.dispatch
|
|
203
|
-
* @param {(id: import("../model/node.js").NodeId) => import("../selection/dom-map.js").MappableNode | null} options.lookupParagraph
|
|
204
|
-
* @param {"commit" | "discard"} options.onAbandon
|
|
205
|
-
* Not chosen by P1.9a (ADR-0004 §3) — the caller supplies it.
|
|
206
|
-
*/
|
|
207
|
-
constructor({ getState, dispatch, lookupParagraph, onAbandon }) {
|
|
208
|
-
this._getState = getState;
|
|
209
|
-
this._dispatch = dispatch;
|
|
210
|
-
this._lookupParagraph = lookupParagraph;
|
|
211
|
-
this._onAbandon = onAbandon;
|
|
212
|
-
|
|
213
|
-
/** @type {"idle" | "composing"} */
|
|
214
|
-
this.phase = "idle";
|
|
215
|
-
/** @type {CompositionSnapshot | null} */
|
|
216
|
-
this._snapshot = null;
|
|
217
|
-
/** @type {string | null} */
|
|
218
|
-
this._lastSealReason = null;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** @returns {boolean} */
|
|
222
|
-
get isComposing() {
|
|
223
|
-
return this.phase === "composing";
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** The reason (or refusal) the most recent seal closed with, if any. */
|
|
227
|
-
get lastSealReason() {
|
|
228
|
-
return this._lastSealReason;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* `compositionstart`. Rejects multi-paragraph composing ranges outright
|
|
233
|
-
* (ADR-0004 §2 — untested arithmetic, `isSingleContainer` already makes the
|
|
234
|
-
* cross-container case impossible) rather than tracking them and refusing
|
|
235
|
-
* later.
|
|
236
|
-
* @returns {void}
|
|
237
|
-
*/
|
|
238
|
-
handleCompositionStart() {
|
|
239
|
-
if (this.phase === "composing") {
|
|
240
|
-
this._seal("abandoned:superseded");
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const state = this._getState();
|
|
244
|
-
const { selection } = state;
|
|
245
|
-
|
|
246
|
-
if (!isSingleParagraph(selection)) {
|
|
247
|
-
if (isDev()) {
|
|
248
|
-
// eslint-disable-next-line no-console
|
|
249
|
-
console.warn("word-editor: composition started over a multi-paragraph selection — unsupported-range, not tracked");
|
|
250
|
-
}
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const range = selectionRange(state.doc, selection);
|
|
255
|
-
if (range === null) {
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
const paragraphId = paragraphIdOf(range.from);
|
|
259
|
-
const para = paragraphById(state.doc, paragraphId);
|
|
260
|
-
const paragraphElement = this._lookupParagraph(paragraphId);
|
|
261
|
-
if (para === null || paragraphElement === null) {
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
this._snapshot = {
|
|
266
|
-
containerId: containerIdOf(range.from),
|
|
267
|
-
paragraphId,
|
|
268
|
-
fromOffset: range.from.offset,
|
|
269
|
-
toOffset: range.to.offset,
|
|
270
|
-
modelTextAtStart: paragraphText(para),
|
|
271
|
-
paragraphElement,
|
|
272
|
-
};
|
|
273
|
-
this.phase = "composing";
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* `compositionupdate` deliberately does nothing (ADR-0004 §1, F1): no
|
|
278
|
-
* transition, no transaction. Its absence would say nothing; this explicit
|
|
279
|
-
* no-op is the guarded decision itself.
|
|
280
|
-
* @returns {void}
|
|
281
|
-
*/
|
|
282
|
-
handleCompositionUpdate() {
|
|
283
|
-
// Intentionally empty — see docstring.
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** `compositionend` — Yol 1. @returns {void} */
|
|
287
|
-
handleCompositionEnd() {
|
|
288
|
-
this._seal("committed");
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** `focusout` on the composing box — Yol 2. @returns {void} */
|
|
292
|
-
handleFocusOut() {
|
|
293
|
-
this._seal("abandoned:blur");
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* F3: called by the host before any other path is about to mutate the
|
|
298
|
-
* model — Yol 4. A no-op when nothing is composing.
|
|
299
|
-
* @returns {void}
|
|
300
|
-
*/
|
|
301
|
-
sealForExternalMutation() {
|
|
302
|
-
this._seal("abandoned:preempted");
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* @param {SealTrigger} reason
|
|
307
|
-
* @returns {void}
|
|
308
|
-
*/
|
|
309
|
-
_seal(reason) {
|
|
310
|
-
if (this.phase !== "composing" || this._snapshot === null) {
|
|
311
|
-
return; // idempotent: already sealed, or never opened
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const snapshot = this._snapshot;
|
|
315
|
-
const state = this._getState();
|
|
316
|
-
const domText = readParagraphText(snapshot.paragraphElement);
|
|
317
|
-
const plan = planSeal(state, snapshot, domText, reason, this._onAbandon);
|
|
318
|
-
|
|
319
|
-
if (plan.transaction !== null) {
|
|
320
|
-
this._dispatch(plan.transaction);
|
|
321
|
-
} else if (plan.restoreSelection && plan.selectionOverride !== undefined) {
|
|
322
|
-
const tr = createTransaction(state.doc, state.selection, "ime")
|
|
323
|
-
.setSelection(plan.selectionOverride)
|
|
324
|
-
.build();
|
|
325
|
-
this._dispatch(tr);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
if (isDev() && reason !== "committed") {
|
|
329
|
-
// eslint-disable-next-line no-console
|
|
330
|
-
console.warn(`word-editor: composition ${reason} — commit policy for this path is undecided (P1.9b)`);
|
|
331
|
-
}
|
|
332
|
-
if (isDev() && plan.refusedReason !== undefined) {
|
|
333
|
-
// eslint-disable-next-line no-console
|
|
334
|
-
console.warn(`word-editor: composition commit refused (${plan.refusedReason})`);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
this.phase = "idle";
|
|
338
|
-
this._lastSealReason = plan.refusedReason ?? reason;
|
|
339
|
-
this._snapshot = null;
|
|
340
|
-
}
|
|
341
|
-
}
|
|
1
|
+
import { isDev } from "../dev.js";
|
|
2
|
+
import { paragraphById, paragraphText } from "../model/node.js";
|
|
3
|
+
import {
|
|
4
|
+
containerIdOf,
|
|
5
|
+
createPosition,
|
|
6
|
+
createSelection,
|
|
7
|
+
isSingleParagraph,
|
|
8
|
+
paragraphIdOf,
|
|
9
|
+
selectionRange,
|
|
10
|
+
} from "../model/position.js";
|
|
11
|
+
import { createTransaction } from "../model/transaction.js";
|
|
12
|
+
import { textNodesOf } from "../selection/dom-map.js";
|
|
13
|
+
import { deleteSelection, insertText } from "../commands/text.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* P1.9a — IME composition. Design: `docs/adr/0004-input-and-ime-strategy.md`.
|
|
17
|
+
* This module converts that design into code; it does not add to it. Where a
|
|
18
|
+
* decision is not made by the ADR (the abandon-path commit policy, §3), this
|
|
19
|
+
* module takes the undecided value as an injected parameter rather than
|
|
20
|
+
* choosing one — see `onAbandon` below.
|
|
21
|
+
*
|
|
22
|
+
* Two freezes are this module's job (ADR-0004 Karar 2, F1/F3): no step is ever
|
|
23
|
+
* applied while composing, and any other model-mutating path seals the
|
|
24
|
+
* bracket first. The third freeze, F2 ("no `Renderer.render()` while
|
|
25
|
+
* composing"), is NOT this module's job — it belongs to the host's dispatch
|
|
26
|
+
* loop, which this module has no reference to (its context is exactly
|
|
27
|
+
* `{getState, dispatch}`, matching `input/beforeinput.js`'s
|
|
28
|
+
* `BeforeInputContext`). For the same reason, "re-render from the model"
|
|
29
|
+
* after a refusal (ADR-0004 §2) is also a host responsibility: a refusal
|
|
30
|
+
* seal never changes `state.doc` (by design — F1 held), so a plain
|
|
31
|
+
* `render(state.doc)` call is a structural no-op against `Renderer`'s
|
|
32
|
+
* identity fast path (`render/renderer.js`). The host is expected to force
|
|
33
|
+
* one render whenever `isComposing` transitions from `true` to `false`,
|
|
34
|
+
* regardless of doc identity.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {object} CompositionSnapshot
|
|
39
|
+
* @property {import("../model/node.js").NodeId} containerId
|
|
40
|
+
* @property {import("../model/node.js").NodeId} paragraphId
|
|
41
|
+
* @property {number} fromOffset
|
|
42
|
+
* @property {number} toOffset
|
|
43
|
+
* @property {string} modelTextAtStart
|
|
44
|
+
* @property {import("../selection/dom-map.js").MappableNode} paragraphElement
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Why a composing bracket closed.
|
|
49
|
+
* @typedef {"committed" | "abandoned:blur" | "abandoned:superseded" | "abandoned:preempted"} SealTrigger
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reads a paragraph's live text straight from its text nodes — never
|
|
54
|
+
* `innerHTML`, never interpreting element structure (invariant #7: whatever
|
|
55
|
+
* markup the IME left behind is just characters). Mirrors `textNodesOf`'s own
|
|
56
|
+
* "walks `parentNode`, not `closest()`" reasoning: this has to work on nodes
|
|
57
|
+
* the browser inserted during composition, which the model never created.
|
|
58
|
+
* @param {import("../selection/dom-map.js").MappableNode} paragraphElement
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function readParagraphText(paragraphElement) {
|
|
62
|
+
return textNodesOf(paragraphElement)
|
|
63
|
+
.map((node) => node.data ?? "")
|
|
64
|
+
.join("");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {{ ok: true, composed: string } | { ok: false, reason: string }} VerifyResult
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The self-verifying read (ADR-0004 §2): four gates, and a failure of any one
|
|
73
|
+
* refuses rather than guesses. `currentModelText` is `null` when the
|
|
74
|
+
* paragraph the snapshot pointed at can no longer be resolved at all.
|
|
75
|
+
* @param {CompositionSnapshot} snapshot
|
|
76
|
+
* @param {string} domText
|
|
77
|
+
* @param {string | null} currentModelText
|
|
78
|
+
* @returns {VerifyResult}
|
|
79
|
+
*/
|
|
80
|
+
export function verifyAndExtract(snapshot, domText, currentModelText) {
|
|
81
|
+
// KAPI 1 (F3 check): the model must not have moved out from under us.
|
|
82
|
+
if (currentModelText === null || currentModelText !== snapshot.modelTextAtStart) {
|
|
83
|
+
return { ok: false, reason: "model-changed" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const prefix = snapshot.modelTextAtStart.slice(0, snapshot.fromOffset);
|
|
87
|
+
const suffix = snapshot.modelTextAtStart.slice(snapshot.toOffset);
|
|
88
|
+
|
|
89
|
+
// KAPI 2 (boundary)
|
|
90
|
+
if (!domText.startsWith(prefix)) {
|
|
91
|
+
return { ok: false, reason: "prefix-mismatch" };
|
|
92
|
+
}
|
|
93
|
+
// KAPI 3 (boundary)
|
|
94
|
+
if (!domText.endsWith(suffix)) {
|
|
95
|
+
return { ok: false, reason: "suffix-mismatch" };
|
|
96
|
+
}
|
|
97
|
+
// KAPI 4 (length) — guards the slice below from going negative when prefix
|
|
98
|
+
// and suffix overlap (a shorter domText than the untouched boundaries need).
|
|
99
|
+
if (domText.length < prefix.length + suffix.length) {
|
|
100
|
+
return { ok: false, reason: "length-mismatch" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { ok: true, composed: domText.slice(prefix.length, domText.length - suffix.length) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @typedef {object} SealPlan
|
|
108
|
+
* @property {import("../model/transaction.js").Transaction | null} transaction
|
|
109
|
+
* @property {boolean} restoreSelection
|
|
110
|
+
* @property {import("../model/position.js").Selection | undefined} selectionOverride
|
|
111
|
+
* @property {string | undefined} refusedReason
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Decides what a seal does, without touching `getState`/`dispatch` itself —
|
|
116
|
+
* this is the pure half `CompositionController` drives. Takes `onAbandon` as
|
|
117
|
+
* a parameter rather than reading a field, so the mechanism ("both values
|
|
118
|
+
* honoured") is testable independently of any chosen policy (ADR-0004 §3,
|
|
119
|
+
* DOĞRULANMALI #8 — the policy itself is undecided, deferred to P1.9b).
|
|
120
|
+
*
|
|
121
|
+
* @param {import("../state/editor-state.js").EditorState} state
|
|
122
|
+
* @param {CompositionSnapshot} snapshot
|
|
123
|
+
* @param {string} domText
|
|
124
|
+
* @param {SealTrigger} reason
|
|
125
|
+
* @param {"commit" | "discard"} onAbandon
|
|
126
|
+
* @returns {SealPlan}
|
|
127
|
+
*/
|
|
128
|
+
export function planSeal(state, snapshot, domText, reason, onAbandon) {
|
|
129
|
+
const attemptsCommit = reason === "committed" || onAbandon === "commit";
|
|
130
|
+
|
|
131
|
+
if (!attemptsCommit) {
|
|
132
|
+
return { transaction: null, restoreSelection: false, selectionOverride: undefined, refusedReason: undefined };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Below this point, an abandon-path attempt (onAbandon === "commit") is
|
|
136
|
+
// treated exactly like Yol 1's real `compositionend`: if verification
|
|
137
|
+
// fails, it falls through to Yol 5's refusal semantics regardless of which
|
|
138
|
+
// of the two triggered the attempt. Refusal is the narrower, purely
|
|
139
|
+
// technical condition (the DOM no longer proves what changed) and takes
|
|
140
|
+
// precedence over the wider abandon-policy choice — a policy decision
|
|
141
|
+
// should never override a safety check.
|
|
142
|
+
|
|
143
|
+
const para = paragraphById(state.doc, snapshot.paragraphId);
|
|
144
|
+
const currentModelText = para === null ? null : paragraphText(para);
|
|
145
|
+
const verified = verifyAndExtract(snapshot, domText, currentModelText);
|
|
146
|
+
|
|
147
|
+
const composingSelection = createSelection(
|
|
148
|
+
createPosition(snapshot.containerId, snapshot.paragraphId, snapshot.fromOffset),
|
|
149
|
+
createPosition(snapshot.containerId, snapshot.paragraphId, snapshot.toOffset),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (!verified.ok) {
|
|
153
|
+
// Yol 5: a refused (or unsupported) composition never happened, as far as
|
|
154
|
+
// the model is concerned (F1 held it untouched) — so caret and selection
|
|
155
|
+
// return to exactly where they stood at `compositionstart`. If the
|
|
156
|
+
// paragraph itself is gone, there is nowhere sensible to put a caret;
|
|
157
|
+
// `state.selection` is left exactly as it is.
|
|
158
|
+
return {
|
|
159
|
+
transaction: null,
|
|
160
|
+
restoreSelection: para !== null,
|
|
161
|
+
selectionOverride: para !== null ? composingSelection : undefined,
|
|
162
|
+
refusedReason: verified.reason,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const pseudoState = { doc: state.doc, selection: composingSelection, storedMarks: state.storedMarks };
|
|
167
|
+
|
|
168
|
+
let transaction = null;
|
|
169
|
+
if (verified.composed !== "") {
|
|
170
|
+
// Reuses `insertText` rather than building the step directly: this is
|
|
171
|
+
// exactly what carries ADR-0017's left-biased `marksAt` (and, riding
|
|
172
|
+
// along inside `marks`, `rPrRef`) into the composed text "for free".
|
|
173
|
+
transaction = insertText(pseudoState, verified.composed, "ime");
|
|
174
|
+
} else if (snapshot.fromOffset !== snapshot.toOffset) {
|
|
175
|
+
transaction = deleteSelection(pseudoState, "ime");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { transaction, restoreSelection: false, selectionOverride: undefined, refusedReason: undefined };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @typedef {object} CompositionContext
|
|
183
|
+
* @property {() => import("../state/editor-state.js").EditorState} getState
|
|
184
|
+
* @property {(tr: import("../model/transaction.js").Transaction) => void} dispatch
|
|
185
|
+
*/
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Tracks one IME composing bracket. Two rest states, `idle` and `composing`;
|
|
189
|
+
* `sealed(reason)` (ADR-0004 §1) is a transition, not a third rest state —
|
|
190
|
+
* carrying `reason` forward is what lets "closed" and "abandoned" stay
|
|
191
|
+
* distinguishable (ADR-0016 §1's `sealReason` reasoning, reused).
|
|
192
|
+
*
|
|
193
|
+
* Sealing is idempotent: once a bracket is sealed, a second seal call is a
|
|
194
|
+
* no-op. Event order across the closing paths is not guaranteed (ADR-0016
|
|
195
|
+
* G10 — measured on a different pair of events, same class of problem), so
|
|
196
|
+
* whichever seal arrives first wins and the second finds nothing open.
|
|
197
|
+
*/
|
|
198
|
+
export class CompositionController {
|
|
199
|
+
/**
|
|
200
|
+
* @param {object} options
|
|
201
|
+
* @param {() => import("../state/editor-state.js").EditorState} options.getState
|
|
202
|
+
* @param {(tr: import("../model/transaction.js").Transaction) => void} options.dispatch
|
|
203
|
+
* @param {(id: import("../model/node.js").NodeId) => import("../selection/dom-map.js").MappableNode | null} options.lookupParagraph
|
|
204
|
+
* @param {"commit" | "discard"} options.onAbandon
|
|
205
|
+
* Not chosen by P1.9a (ADR-0004 §3) — the caller supplies it.
|
|
206
|
+
*/
|
|
207
|
+
constructor({ getState, dispatch, lookupParagraph, onAbandon }) {
|
|
208
|
+
this._getState = getState;
|
|
209
|
+
this._dispatch = dispatch;
|
|
210
|
+
this._lookupParagraph = lookupParagraph;
|
|
211
|
+
this._onAbandon = onAbandon;
|
|
212
|
+
|
|
213
|
+
/** @type {"idle" | "composing"} */
|
|
214
|
+
this.phase = "idle";
|
|
215
|
+
/** @type {CompositionSnapshot | null} */
|
|
216
|
+
this._snapshot = null;
|
|
217
|
+
/** @type {string | null} */
|
|
218
|
+
this._lastSealReason = null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @returns {boolean} */
|
|
222
|
+
get isComposing() {
|
|
223
|
+
return this.phase === "composing";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The reason (or refusal) the most recent seal closed with, if any. */
|
|
227
|
+
get lastSealReason() {
|
|
228
|
+
return this._lastSealReason;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* `compositionstart`. Rejects multi-paragraph composing ranges outright
|
|
233
|
+
* (ADR-0004 §2 — untested arithmetic, `isSingleContainer` already makes the
|
|
234
|
+
* cross-container case impossible) rather than tracking them and refusing
|
|
235
|
+
* later.
|
|
236
|
+
* @returns {void}
|
|
237
|
+
*/
|
|
238
|
+
handleCompositionStart() {
|
|
239
|
+
if (this.phase === "composing") {
|
|
240
|
+
this._seal("abandoned:superseded");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const state = this._getState();
|
|
244
|
+
const { selection } = state;
|
|
245
|
+
|
|
246
|
+
if (!isSingleParagraph(selection)) {
|
|
247
|
+
if (isDev()) {
|
|
248
|
+
// eslint-disable-next-line no-console
|
|
249
|
+
console.warn("word-editor: composition started over a multi-paragraph selection — unsupported-range, not tracked");
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const range = selectionRange(state.doc, selection);
|
|
255
|
+
if (range === null) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const paragraphId = paragraphIdOf(range.from);
|
|
259
|
+
const para = paragraphById(state.doc, paragraphId);
|
|
260
|
+
const paragraphElement = this._lookupParagraph(paragraphId);
|
|
261
|
+
if (para === null || paragraphElement === null) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
this._snapshot = {
|
|
266
|
+
containerId: containerIdOf(range.from),
|
|
267
|
+
paragraphId,
|
|
268
|
+
fromOffset: range.from.offset,
|
|
269
|
+
toOffset: range.to.offset,
|
|
270
|
+
modelTextAtStart: paragraphText(para),
|
|
271
|
+
paragraphElement,
|
|
272
|
+
};
|
|
273
|
+
this.phase = "composing";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* `compositionupdate` deliberately does nothing (ADR-0004 §1, F1): no
|
|
278
|
+
* transition, no transaction. Its absence would say nothing; this explicit
|
|
279
|
+
* no-op is the guarded decision itself.
|
|
280
|
+
* @returns {void}
|
|
281
|
+
*/
|
|
282
|
+
handleCompositionUpdate() {
|
|
283
|
+
// Intentionally empty — see docstring.
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** `compositionend` — Yol 1. @returns {void} */
|
|
287
|
+
handleCompositionEnd() {
|
|
288
|
+
this._seal("committed");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** `focusout` on the composing box — Yol 2. @returns {void} */
|
|
292
|
+
handleFocusOut() {
|
|
293
|
+
this._seal("abandoned:blur");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* F3: called by the host before any other path is about to mutate the
|
|
298
|
+
* model — Yol 4. A no-op when nothing is composing.
|
|
299
|
+
* @returns {void}
|
|
300
|
+
*/
|
|
301
|
+
sealForExternalMutation() {
|
|
302
|
+
this._seal("abandoned:preempted");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* @param {SealTrigger} reason
|
|
307
|
+
* @returns {void}
|
|
308
|
+
*/
|
|
309
|
+
_seal(reason) {
|
|
310
|
+
if (this.phase !== "composing" || this._snapshot === null) {
|
|
311
|
+
return; // idempotent: already sealed, or never opened
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const snapshot = this._snapshot;
|
|
315
|
+
const state = this._getState();
|
|
316
|
+
const domText = readParagraphText(snapshot.paragraphElement);
|
|
317
|
+
const plan = planSeal(state, snapshot, domText, reason, this._onAbandon);
|
|
318
|
+
|
|
319
|
+
if (plan.transaction !== null) {
|
|
320
|
+
this._dispatch(plan.transaction);
|
|
321
|
+
} else if (plan.restoreSelection && plan.selectionOverride !== undefined) {
|
|
322
|
+
const tr = createTransaction(state.doc, state.selection, "ime")
|
|
323
|
+
.setSelection(plan.selectionOverride)
|
|
324
|
+
.build();
|
|
325
|
+
this._dispatch(tr);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (isDev() && reason !== "committed") {
|
|
329
|
+
// eslint-disable-next-line no-console
|
|
330
|
+
console.warn(`word-editor: composition ${reason} — commit policy for this path is undecided (P1.9b)`);
|
|
331
|
+
}
|
|
332
|
+
if (isDev() && plan.refusedReason !== undefined) {
|
|
333
|
+
// eslint-disable-next-line no-console
|
|
334
|
+
console.warn(`word-editor: composition commit refused (${plan.refusedReason})`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
this.phase = "idle";
|
|
338
|
+
this._lastSealReason = plan.refusedReason ?? reason;
|
|
339
|
+
this._snapshot = null;
|
|
340
|
+
}
|
|
341
|
+
}
|