@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
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
|
+
}
|