@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
package/dist/index.js CHANGED
@@ -1,63 +1,63 @@
1
- import { createEditingHost } from "./editor/editing-host.js";
2
- import { createIdFactory } from "./model/ids.js";
3
- import { createEmptyDoc } from "./state/editor-state.js";
4
-
5
- /**
6
- * @typedef {object} EditorOptions
7
- * @property {HTMLElement} container the element the editor mounts into. Its
8
- * `ownerDocument` supplies the DOM factory and the live selection — no global
9
- * `window`/`document` is read (invariant #5).
10
- * @property {import("./model/node.js").Doc} [doc] an initial document (e.g.
11
- * from `importDocx`). Omitted → a one-empty-paragraph document.
12
- * @property {(state: import("./state/editor-state.js").EditorState) => void} [onChange]
13
- */
14
-
15
- /**
16
- * @typedef {object} Editor
17
- * @property {() => void} destroy
18
- * @property {(doc: import("./model/node.js").Doc) => void} loadDocument
19
- * @property {(media: Map<string, string> | null | undefined) => void} setMedia
20
- * @property {() => import("./state/editor-state.js").EditorState} getState
21
- */
22
-
23
- /**
24
- * Creates a framework-agnostic, per-box-contenteditable editor mounted into
25
- * `container` (ADR-0022). This is the EditorFacade of ARCHITECTURE §4: it
26
- * assembles the already-written editing modules (via `createEditingHost`) and
27
- * wires them to the real DOM with event delegation, then hands back a small
28
- * imperative handle. No `window`/`document`/`navigator` is touched at import
29
- * time — only here, and only through `container.ownerDocument` (invariant #5).
30
- * @param {EditorOptions} options
31
- * @returns {Editor}
32
- */
33
- export function createEditor(options) {
34
- const { container, doc, onChange } = options;
35
- const document = container.ownerDocument;
36
-
37
- const ids = createIdFactory();
38
- const host = createEditingHost({
39
- root: container,
40
- document,
41
- doc: doc ?? createEmptyDoc(ids),
42
- ids,
43
- getDomSelection: () => document.getSelection(),
44
- onChange,
45
- });
46
-
47
- // Delegation (ADR-0022 D2): input events on the container root, selectionchange
48
- // on the document — never per-box.
49
- host.attach(container, document);
50
-
51
- return {
52
- getState: host.getState,
53
- loadDocument(nextDoc) {
54
- host.loadDocument(nextDoc);
55
- },
56
- setMedia(media) {
57
- host.setMedia(media);
58
- },
59
- destroy() {
60
- host.detach();
61
- },
62
- };
63
- }
1
+ import { createEditingHost } from "./editor/editing-host.js";
2
+ import { createIdFactory } from "./model/ids.js";
3
+ import { createEmptyDoc } from "./state/editor-state.js";
4
+
5
+ /**
6
+ * @typedef {object} EditorOptions
7
+ * @property {HTMLElement} container the element the editor mounts into. Its
8
+ * `ownerDocument` supplies the DOM factory and the live selection — no global
9
+ * `window`/`document` is read (invariant #5).
10
+ * @property {import("./model/node.js").Doc} [doc] an initial document (e.g.
11
+ * from `importDocx`). Omitted → a one-empty-paragraph document.
12
+ * @property {(state: import("./state/editor-state.js").EditorState) => void} [onChange]
13
+ */
14
+
15
+ /**
16
+ * @typedef {object} Editor
17
+ * @property {() => void} destroy
18
+ * @property {(doc: import("./model/node.js").Doc) => void} loadDocument
19
+ * @property {(media: Map<string, string> | null | undefined) => void} setMedia
20
+ * @property {() => import("./state/editor-state.js").EditorState} getState
21
+ */
22
+
23
+ /**
24
+ * Creates a framework-agnostic, per-box-contenteditable editor mounted into
25
+ * `container` (ADR-0022). This is the EditorFacade of ARCHITECTURE §4: it
26
+ * assembles the already-written editing modules (via `createEditingHost`) and
27
+ * wires them to the real DOM with event delegation, then hands back a small
28
+ * imperative handle. No `window`/`document`/`navigator` is touched at import
29
+ * time — only here, and only through `container.ownerDocument` (invariant #5).
30
+ * @param {EditorOptions} options
31
+ * @returns {Editor}
32
+ */
33
+ export function createEditor(options) {
34
+ const { container, doc, onChange } = options;
35
+ const document = container.ownerDocument;
36
+
37
+ const ids = createIdFactory();
38
+ const host = createEditingHost({
39
+ root: container,
40
+ document,
41
+ doc: doc ?? createEmptyDoc(ids),
42
+ ids,
43
+ getDomSelection: () => document.getSelection(),
44
+ onChange,
45
+ });
46
+
47
+ // Delegation (ADR-0022 D2): input events on the container root, selectionchange
48
+ // on the document — never per-box.
49
+ host.attach(container, document);
50
+
51
+ return {
52
+ getState: host.getState,
53
+ loadDocument(nextDoc) {
54
+ host.loadDocument(nextDoc);
55
+ },
56
+ setMedia(media) {
57
+ host.setMedia(media);
58
+ },
59
+ destroy() {
60
+ host.detach();
61
+ },
62
+ };
63
+ }
@@ -1,224 +1,224 @@
1
- import { isDev } from "../dev.js";
2
- import { createSelection, paragraphIdOf } from "../model/position.js";
3
- import { domToModel } from "../selection/dom-map.js";
4
- import {
5
- deleteBackward,
6
- deleteExplicitRange,
7
- deleteForward,
8
- deleteSelection,
9
- insertText,
10
- splitParagraph,
11
- } from "../commands/text.js";
12
- import { toggleMark } from "../commands/marks.js";
13
-
14
- /**
15
- * `beforeinput` → command dispatch. ARCHITECTURE.md §3.3: one
16
- * `contenteditable="true"` surface, `beforeinput` caught and
17
- * `preventDefault()`-ed, no exceptions but composition.
18
- *
19
- * **Default rule: unconditional `preventDefault()`, then dispatch.** An
20
- * inputType outside our scope is not left to the browser either — if it were,
21
- * the DOM would drift from the model, which is the one thing this whole
22
- * architecture exists to prevent (ARCHITECTURE.md §2). It gets `preventDefault()`
23
- * and nothing else.
24
- *
25
- * @typedef {object} StaticRangeLike
26
- * @property {import("../selection/dom-map.js").MappableNode} startContainer
27
- * @property {number} startOffset
28
- * @property {import("../selection/dom-map.js").MappableNode} endContainer
29
- * @property {number} endOffset
30
- */
31
-
32
- /**
33
- * The minimal surface of a real `InputEvent` this module needs.
34
- * @typedef {object} InputEventLike
35
- * @property {string} inputType
36
- * @property {string | null} [data]
37
- * @property {() => void} preventDefault
38
- * @property {() => StaticRangeLike[]} [getTargetRanges]
39
- */
40
-
41
- /**
42
- * @typedef {object} BeforeInputContext
43
- * @property {() => import("../state/editor-state.js").EditorState} getState
44
- * @property {(tr: import("../model/transaction.js").Transaction) => void} dispatch
45
- * @property {import("../history/history.js").HistoryEngine} history
46
- * @property {import("../model/ids.js").IdFactory} ids
47
- */
48
-
49
- /**
50
- * `insertCompositionText` / `insertFromComposition` are the one exception to
51
- * "always preventDefault": doing so breaks IME in Chrome mid-composition.
52
- * This module does not touch the model for these at all.
53
- *
54
- * `input/composition.js` (P1.9a, ADR-0004) owns this path; the early return
55
- * below is what keeps it free for `CompositionController` to drive via the
56
- * real `compositionstart`/`compositionupdate`/`compositionend` events, which
57
- * this module never listens for.
58
- */
59
- const COMPOSITION_INPUT_TYPES = new Set(["insertCompositionText", "insertFromComposition"]);
60
-
61
- /** Sentinel: a recognised inputType that is out of scope for P1. Distinguishes "known no-op" from "never heard of it" — only the latter warns. */
62
- const NOOP = Symbol("noop");
63
-
64
- /**
65
- * Reads `getTargetRanges()` and translates its first range to a model
66
- * selection — but only when both endpoints resolve and land in the **same**
67
- * paragraph. `DeleteStep` cannot span paragraphs (commands/text.js §2.5), so a
68
- * range that does is not a smaller problem to solve here — it is ignored, and
69
- * the caller falls back to `deleteBackward`/`deleteForward`, whose own
70
- * paragraph-merge handling is exactly the multi-paragraph case anyway.
71
- * @param {InputEventLike} event
72
- * @returns {import("../model/position.js").Selection | null}
73
- */
74
- function resolveTargetRange(event) {
75
- const ranges = event.getTargetRanges?.() ?? [];
76
- if (ranges.length === 0) {
77
- return null;
78
- }
79
-
80
- const range = ranges[0];
81
- const from = domToModel(range.startContainer, range.startOffset);
82
- const to = domToModel(range.endContainer, range.endOffset);
83
- if (from === null || to === null) {
84
- return null;
85
- }
86
- if (paragraphIdOf(from) !== paragraphIdOf(to)) {
87
- return null;
88
- }
89
-
90
- return createSelection(from, to);
91
- }
92
-
93
- /**
94
- * Builds a delete handler that prefers the browser's own range and falls back
95
- * to one of our grapheme-aware commands when `getTargetRanges()` is absent,
96
- * unresolvable, or spans paragraphs.
97
- *
98
- * `warnOnFallback` marks the fallback as a **scope cut** rather than the
99
- * intended behaviour. For `deleteContentBackward`/`Forward` and `deleteContent`,
100
- * falling back to a single grapheme (or the current selection) *is* the
101
- * designed behaviour — that is exactly the "getTargetRanges() → Intl.Segmenter
102
- * → code unit" chain the plan specifies for character-level deletion. But for
103
- * the word/line types (`deleteWordBackward`, `deleteSoftLineForward`, …), the
104
- * user asked to delete a whole word or line; silently deleting one grapheme
105
- * instead is a real degradation, not the intended path, and would otherwise
106
- * pass with no visible trace. ⚠ doğrulanmalı — see docs/browser-matrix.md.
107
- *
108
- * @param {(state: import("../state/editor-state.js").EditorState) => import("../model/transaction.js").Transaction | null} fallback
109
- * @param {{ warnOnFallback?: boolean }} [options]
110
- * @returns {(event: InputEventLike, context: BeforeInputContext) => import("../model/transaction.js").Transaction | null}
111
- */
112
- function deleteWithFallback(fallback, options = {}) {
113
- const warnOnFallback = options.warnOnFallback === true;
114
- return (event, context) => {
115
- const state = context.getState();
116
- const range = resolveTargetRange(event);
117
- if (range !== null) {
118
- return deleteExplicitRange(state, range);
119
- }
120
- if (warnOnFallback && isDev()) {
121
- // eslint-disable-next-line no-console -- intentional DEV-only diagnostic
122
- console.warn(
123
- `word-editor: no usable getTargetRanges() for "${event.inputType}" — ` +
124
- `falling back to a single grapheme instead of the requested word/line`,
125
- );
126
- }
127
- return fallback(state);
128
- };
129
- }
130
-
131
- /** @type {Record<string, ((event: InputEventLike, context: BeforeInputContext) => import("../model/transaction.js").Transaction | null) | typeof NOOP>} */
132
- const HANDLERS = {
133
- insertText: (event, context) => insertText(context.getState(), event.data ?? ""),
134
-
135
- insertParagraph: (event, context) => splitParagraph(context.getState(), context.ids),
136
- // No soft line break in P1 — Shift+Enter behaves exactly like Enter, which is
137
- // predictable rather than silently dropped.
138
- insertLineBreak: (event, context) => splitParagraph(context.getState(), context.ids),
139
-
140
- // The paste event, not beforeinput, is the reliable source of clipboard data
141
- // (input/clipboard.js, task 16) — these exist here only to be prevented.
142
- insertFromPaste: NOOP,
143
- insertFromPasteAsQuotation: NOOP,
144
-
145
- deleteContentBackward: deleteWithFallback(deleteBackward),
146
- deleteContentForward: deleteWithFallback(deleteForward),
147
- deleteContent: deleteWithFallback(deleteSelection),
148
- deleteWordBackward: deleteWithFallback(deleteBackward, { warnOnFallback: true }),
149
- deleteWordForward: deleteWithFallback(deleteForward, { warnOnFallback: true }),
150
- deleteSoftLineBackward: deleteWithFallback(deleteBackward, { warnOnFallback: true }),
151
- deleteSoftLineForward: deleteWithFallback(deleteForward, { warnOnFallback: true }),
152
- deleteHardLineBackward: deleteWithFallback(deleteBackward, { warnOnFallback: true }),
153
- deleteHardLineForward: deleteWithFallback(deleteForward, { warnOnFallback: true }),
154
- deleteEntireSoftLine: deleteWithFallback(deleteSelection, { warnOnFallback: true }),
155
-
156
- // The deletion happens here; writing the cut text to the clipboard is
157
- // input/clipboard.js's `cut` handler's job (task 16).
158
- deleteByCut: (event, context) => deleteSelection(context.getState(), "input"),
159
-
160
- historyUndo: (event, context) => context.history.undo(context.getState()),
161
- historyRedo: (event, context) => context.history.redo(context.getState()),
162
-
163
- formatBold: (event, context) => toggleMark(context.getState(), "bold"),
164
- formatItalic: (event, context) => toggleMark(context.getState(), "italic"),
165
- formatUnderline: (event, context) => toggleMark(context.getState(), "underline"),
166
-
167
- insertReplacementText: NOOP, // spellcheck="false" in P1 — see ADR-0004
168
- insertFromDrop: NOOP,
169
- deleteByDrag: NOOP,
170
-
171
- // Out of scope for P1 (ARCHITECTURE.md §9 / the plan's explicit non-goals).
172
- insertOrderedList: NOOP,
173
- insertUnorderedList: NOOP,
174
- insertHorizontalRule: NOOP,
175
- insertLink: NOOP,
176
- formatStrikeThrough: NOOP,
177
- formatSuperscript: NOOP,
178
- formatSubscript: NOOP,
179
- formatJustifyCenter: NOOP,
180
- formatJustifyFull: NOOP,
181
- formatJustifyLeft: NOOP,
182
- formatJustifyRight: NOOP,
183
- formatIndent: NOOP,
184
- formatOutdent: NOOP,
185
- formatRemove: NOOP,
186
- formatSetBlockTextDirection: NOOP,
187
- formatBackColor: NOOP,
188
- formatFontColor: NOOP,
189
- formatFontName: NOOP,
190
- };
191
-
192
- /**
193
- * The `beforeinput` listener. Call with the real event (or a test double
194
- * satisfying `InputEventLike`) and the pieces of editor state it needs to
195
- * reach.
196
- * @param {InputEventLike} event
197
- * @param {BeforeInputContext} context
198
- * @returns {void}
199
- */
200
- export function handleBeforeInput(event, context) {
201
- if (COMPOSITION_INPUT_TYPES.has(event.inputType)) {
202
- return; // input/composition.js owns this path; touch nothing here.
203
- }
204
-
205
- event.preventDefault();
206
-
207
- const handler = HANDLERS[event.inputType];
208
-
209
- if (handler === undefined) {
210
- if (isDev()) {
211
- // eslint-disable-next-line no-console -- intentional DEV-only diagnostic
212
- console.warn(`word-editor: unhandled beforeinput inputType "${event.inputType}"`);
213
- }
214
- return;
215
- }
216
- if (handler === NOOP) {
217
- return;
218
- }
219
-
220
- const tr = handler(event, context);
221
- if (tr !== null) {
222
- context.dispatch(tr);
223
- }
224
- }
1
+ import { isDev } from "../dev.js";
2
+ import { createSelection, paragraphIdOf } from "../model/position.js";
3
+ import { domToModel } from "../selection/dom-map.js";
4
+ import {
5
+ deleteBackward,
6
+ deleteExplicitRange,
7
+ deleteForward,
8
+ deleteSelection,
9
+ insertText,
10
+ splitParagraph,
11
+ } from "../commands/text.js";
12
+ import { toggleMark } from "../commands/marks.js";
13
+
14
+ /**
15
+ * `beforeinput` → command dispatch. ARCHITECTURE.md §3.3: one
16
+ * `contenteditable="true"` surface, `beforeinput` caught and
17
+ * `preventDefault()`-ed, no exceptions but composition.
18
+ *
19
+ * **Default rule: unconditional `preventDefault()`, then dispatch.** An
20
+ * inputType outside our scope is not left to the browser either — if it were,
21
+ * the DOM would drift from the model, which is the one thing this whole
22
+ * architecture exists to prevent (ARCHITECTURE.md §2). It gets `preventDefault()`
23
+ * and nothing else.
24
+ *
25
+ * @typedef {object} StaticRangeLike
26
+ * @property {import("../selection/dom-map.js").MappableNode} startContainer
27
+ * @property {number} startOffset
28
+ * @property {import("../selection/dom-map.js").MappableNode} endContainer
29
+ * @property {number} endOffset
30
+ */
31
+
32
+ /**
33
+ * The minimal surface of a real `InputEvent` this module needs.
34
+ * @typedef {object} InputEventLike
35
+ * @property {string} inputType
36
+ * @property {string | null} [data]
37
+ * @property {() => void} preventDefault
38
+ * @property {() => StaticRangeLike[]} [getTargetRanges]
39
+ */
40
+
41
+ /**
42
+ * @typedef {object} BeforeInputContext
43
+ * @property {() => import("../state/editor-state.js").EditorState} getState
44
+ * @property {(tr: import("../model/transaction.js").Transaction) => void} dispatch
45
+ * @property {import("../history/history.js").HistoryEngine} history
46
+ * @property {import("../model/ids.js").IdFactory} ids
47
+ */
48
+
49
+ /**
50
+ * `insertCompositionText` / `insertFromComposition` are the one exception to
51
+ * "always preventDefault": doing so breaks IME in Chrome mid-composition.
52
+ * This module does not touch the model for these at all.
53
+ *
54
+ * `input/composition.js` (P1.9a, ADR-0004) owns this path; the early return
55
+ * below is what keeps it free for `CompositionController` to drive via the
56
+ * real `compositionstart`/`compositionupdate`/`compositionend` events, which
57
+ * this module never listens for.
58
+ */
59
+ const COMPOSITION_INPUT_TYPES = new Set(["insertCompositionText", "insertFromComposition"]);
60
+
61
+ /** Sentinel: a recognised inputType that is out of scope for P1. Distinguishes "known no-op" from "never heard of it" — only the latter warns. */
62
+ const NOOP = Symbol("noop");
63
+
64
+ /**
65
+ * Reads `getTargetRanges()` and translates its first range to a model
66
+ * selection — but only when both endpoints resolve and land in the **same**
67
+ * paragraph. `DeleteStep` cannot span paragraphs (commands/text.js §2.5), so a
68
+ * range that does is not a smaller problem to solve here — it is ignored, and
69
+ * the caller falls back to `deleteBackward`/`deleteForward`, whose own
70
+ * paragraph-merge handling is exactly the multi-paragraph case anyway.
71
+ * @param {InputEventLike} event
72
+ * @returns {import("../model/position.js").Selection | null}
73
+ */
74
+ function resolveTargetRange(event) {
75
+ const ranges = event.getTargetRanges?.() ?? [];
76
+ if (ranges.length === 0) {
77
+ return null;
78
+ }
79
+
80
+ const range = ranges[0];
81
+ const from = domToModel(range.startContainer, range.startOffset);
82
+ const to = domToModel(range.endContainer, range.endOffset);
83
+ if (from === null || to === null) {
84
+ return null;
85
+ }
86
+ if (paragraphIdOf(from) !== paragraphIdOf(to)) {
87
+ return null;
88
+ }
89
+
90
+ return createSelection(from, to);
91
+ }
92
+
93
+ /**
94
+ * Builds a delete handler that prefers the browser's own range and falls back
95
+ * to one of our grapheme-aware commands when `getTargetRanges()` is absent,
96
+ * unresolvable, or spans paragraphs.
97
+ *
98
+ * `warnOnFallback` marks the fallback as a **scope cut** rather than the
99
+ * intended behaviour. For `deleteContentBackward`/`Forward` and `deleteContent`,
100
+ * falling back to a single grapheme (or the current selection) *is* the
101
+ * designed behaviour — that is exactly the "getTargetRanges() → Intl.Segmenter
102
+ * → code unit" chain the plan specifies for character-level deletion. But for
103
+ * the word/line types (`deleteWordBackward`, `deleteSoftLineForward`, …), the
104
+ * user asked to delete a whole word or line; silently deleting one grapheme
105
+ * instead is a real degradation, not the intended path, and would otherwise
106
+ * pass with no visible trace. ⚠ doğrulanmalı — see docs/browser-matrix.md.
107
+ *
108
+ * @param {(state: import("../state/editor-state.js").EditorState) => import("../model/transaction.js").Transaction | null} fallback
109
+ * @param {{ warnOnFallback?: boolean }} [options]
110
+ * @returns {(event: InputEventLike, context: BeforeInputContext) => import("../model/transaction.js").Transaction | null}
111
+ */
112
+ function deleteWithFallback(fallback, options = {}) {
113
+ const warnOnFallback = options.warnOnFallback === true;
114
+ return (event, context) => {
115
+ const state = context.getState();
116
+ const range = resolveTargetRange(event);
117
+ if (range !== null) {
118
+ return deleteExplicitRange(state, range);
119
+ }
120
+ if (warnOnFallback && isDev()) {
121
+ // eslint-disable-next-line no-console -- intentional DEV-only diagnostic
122
+ console.warn(
123
+ `word-editor: no usable getTargetRanges() for "${event.inputType}" — ` +
124
+ `falling back to a single grapheme instead of the requested word/line`,
125
+ );
126
+ }
127
+ return fallback(state);
128
+ };
129
+ }
130
+
131
+ /** @type {Record<string, ((event: InputEventLike, context: BeforeInputContext) => import("../model/transaction.js").Transaction | null) | typeof NOOP>} */
132
+ const HANDLERS = {
133
+ insertText: (event, context) => insertText(context.getState(), event.data ?? ""),
134
+
135
+ insertParagraph: (event, context) => splitParagraph(context.getState(), context.ids),
136
+ // No soft line break in P1 — Shift+Enter behaves exactly like Enter, which is
137
+ // predictable rather than silently dropped.
138
+ insertLineBreak: (event, context) => splitParagraph(context.getState(), context.ids),
139
+
140
+ // The paste event, not beforeinput, is the reliable source of clipboard data
141
+ // (input/clipboard.js, task 16) — these exist here only to be prevented.
142
+ insertFromPaste: NOOP,
143
+ insertFromPasteAsQuotation: NOOP,
144
+
145
+ deleteContentBackward: deleteWithFallback(deleteBackward),
146
+ deleteContentForward: deleteWithFallback(deleteForward),
147
+ deleteContent: deleteWithFallback(deleteSelection),
148
+ deleteWordBackward: deleteWithFallback(deleteBackward, { warnOnFallback: true }),
149
+ deleteWordForward: deleteWithFallback(deleteForward, { warnOnFallback: true }),
150
+ deleteSoftLineBackward: deleteWithFallback(deleteBackward, { warnOnFallback: true }),
151
+ deleteSoftLineForward: deleteWithFallback(deleteForward, { warnOnFallback: true }),
152
+ deleteHardLineBackward: deleteWithFallback(deleteBackward, { warnOnFallback: true }),
153
+ deleteHardLineForward: deleteWithFallback(deleteForward, { warnOnFallback: true }),
154
+ deleteEntireSoftLine: deleteWithFallback(deleteSelection, { warnOnFallback: true }),
155
+
156
+ // The deletion happens here; writing the cut text to the clipboard is
157
+ // input/clipboard.js's `cut` handler's job (task 16).
158
+ deleteByCut: (event, context) => deleteSelection(context.getState(), "input"),
159
+
160
+ historyUndo: (event, context) => context.history.undo(context.getState()),
161
+ historyRedo: (event, context) => context.history.redo(context.getState()),
162
+
163
+ formatBold: (event, context) => toggleMark(context.getState(), "bold"),
164
+ formatItalic: (event, context) => toggleMark(context.getState(), "italic"),
165
+ formatUnderline: (event, context) => toggleMark(context.getState(), "underline"),
166
+
167
+ insertReplacementText: NOOP, // spellcheck="false" in P1 — see ADR-0004
168
+ insertFromDrop: NOOP,
169
+ deleteByDrag: NOOP,
170
+
171
+ // Out of scope for P1 (ARCHITECTURE.md §9 / the plan's explicit non-goals).
172
+ insertOrderedList: NOOP,
173
+ insertUnorderedList: NOOP,
174
+ insertHorizontalRule: NOOP,
175
+ insertLink: NOOP,
176
+ formatStrikeThrough: NOOP,
177
+ formatSuperscript: NOOP,
178
+ formatSubscript: NOOP,
179
+ formatJustifyCenter: NOOP,
180
+ formatJustifyFull: NOOP,
181
+ formatJustifyLeft: NOOP,
182
+ formatJustifyRight: NOOP,
183
+ formatIndent: NOOP,
184
+ formatOutdent: NOOP,
185
+ formatRemove: NOOP,
186
+ formatSetBlockTextDirection: NOOP,
187
+ formatBackColor: NOOP,
188
+ formatFontColor: NOOP,
189
+ formatFontName: NOOP,
190
+ };
191
+
192
+ /**
193
+ * The `beforeinput` listener. Call with the real event (or a test double
194
+ * satisfying `InputEventLike`) and the pieces of editor state it needs to
195
+ * reach.
196
+ * @param {InputEventLike} event
197
+ * @param {BeforeInputContext} context
198
+ * @returns {void}
199
+ */
200
+ export function handleBeforeInput(event, context) {
201
+ if (COMPOSITION_INPUT_TYPES.has(event.inputType)) {
202
+ return; // input/composition.js owns this path; touch nothing here.
203
+ }
204
+
205
+ event.preventDefault();
206
+
207
+ const handler = HANDLERS[event.inputType];
208
+
209
+ if (handler === undefined) {
210
+ if (isDev()) {
211
+ // eslint-disable-next-line no-console -- intentional DEV-only diagnostic
212
+ console.warn(`word-editor: unhandled beforeinput inputType "${event.inputType}"`);
213
+ }
214
+ return;
215
+ }
216
+ if (handler === NOOP) {
217
+ return;
218
+ }
219
+
220
+ const tr = handler(event, context);
221
+ if (tr !== null) {
222
+ context.dispatch(tr);
223
+ }
224
+ }