u2ui 1.5.3 → 1.5.4
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/el/ico/ico.js +5 -5
- package/el/rte/README.md +53 -13
- package/el/rte/manifest.json +19 -4
- package/el/rte/rte.css +31 -6
- package/el/rte/rte.js +174 -44
- package/el/rte0/README.md +37 -0
- package/el/rte0/manifest.json +33 -0
- package/el/rte0/rte0.css +9 -0
- package/el/rte0/rte0.js +56 -0
- package/el/system/styler.js +2 -0
- package/el/tooltip/tooltip.css +2 -2
- package/js/Placer/Placer.js +2 -1
- package/js/rte/AGENTS.md +142 -0
- package/js/rte/PLAN.md +488 -0
- package/js/rte/README.md +319 -25
- package/js/rte/blocks.js +7 -0
- package/js/rte/breaks.js +6 -0
- package/js/rte/classes.js +7 -0
- package/js/rte/docs/usage.md +845 -0
- package/js/rte/images.js +7 -0
- package/js/rte/link.js +8 -0
- package/js/rte/playground/README.md +95 -0
- package/js/rte/playground/hosts.html +518 -0
- package/js/rte/playground/index.html +824 -0
- package/js/rte/rte.js +56 -5
- package/js/rte/source.js +12 -0
- package/js/rte/src/browser/README.md +40 -0
- package/js/rte/src/browser/range-rect.js +82 -0
- package/js/rte/src/client/README.md +378 -0
- package/js/rte/src/client/blocks.js +38 -0
- package/js/rte/src/client/breaks.js +212 -0
- package/js/rte/src/client/classes.js +48 -0
- package/js/rte/src/client/editor.js +461 -0
- package/js/rte/src/client/external-input.js +47 -0
- package/js/rte/src/client/history.js +13 -0
- package/js/rte/src/client/images.js +275 -0
- package/js/rte/src/client/link.js +572 -0
- package/js/rte/src/client/marks.js +25 -0
- package/js/rte/src/client/source.js +182 -0
- package/js/rte/src/client/structure.js +27 -0
- package/js/rte/src/client/tables.js +162 -0
- package/js/rte/src/client/unstyle.js +32 -0
- package/js/rte/src/command/README.md +517 -0
- package/js/rte/src/command/block-boundary.js +43 -0
- package/js/rte/src/command/block-style.js +120 -0
- package/js/rte/src/command/commands.js +158 -0
- package/js/rte/src/command/delete.js +112 -0
- package/js/rte/src/command/edit.js +72 -0
- package/js/rte/src/command/element.js +45 -0
- package/js/rte/src/command/enter.js +175 -0
- package/js/rte/src/command/fragment.js +76 -0
- package/js/rte/src/command/insert.js +52 -0
- package/js/rte/src/command/list.js +284 -0
- package/js/rte/src/command/mark.js +583 -0
- package/js/rte/src/command/pending-marks.js +216 -0
- package/js/rte/src/command/table.js +207 -0
- package/js/rte/src/command/unstyle.js +248 -0
- package/js/rte/src/config/README.md +96 -0
- package/js/rte/src/config/config.js +127 -0
- package/js/rte/src/core/README.md +41 -0
- package/js/rte/src/core/core.js +156 -0
- package/js/rte/src/history/README.md +83 -0
- package/js/rte/src/history/history.js +283 -0
- package/js/rte/src/input/README.md +155 -0
- package/js/rte/src/input/external-input.js +130 -0
- package/js/rte/src/input/input-pipeline.js +414 -0
- package/js/rte/src/mark/README.md +219 -0
- package/js/rte/src/mark/dom-adapter.js +100 -0
- package/js/rte/src/mark/mark.js +133 -0
- package/js/rte/src/mark/standard.js +76 -0
- package/js/rte/src/model/README.md +71 -0
- package/js/rte/src/model/content-model.js +213 -0
- package/js/rte/src/model/html/README.md +51 -0
- package/js/rte/src/model/html/html-model.js +90 -0
- package/js/rte/src/normalize/execute/README.md +33 -0
- package/js/rte/src/normalize/execute/repair-executor.js +129 -0
- package/js/rte/src/normalize/normalizer/README.md +48 -0
- package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
- package/js/rte/src/normalize/repair/README.md +70 -0
- package/js/rte/src/normalize/repair/repair-planner.js +208 -0
- package/js/rte/src/sanitize/README.md +105 -0
- package/js/rte/src/sanitize/native.js +43 -0
- package/js/rte/src/sanitize/policy.js +202 -0
- package/js/rte/src/selection/README.md +39 -0
- package/js/rte/src/selection/map/README.md +53 -0
- package/js/rte/src/selection/map/point-map.js +307 -0
- package/js/rte/src/selection/ownership/README.md +32 -0
- package/js/rte/src/selection/ownership/ownership.js +42 -0
- package/js/rte/src/selection/point/README.md +34 -0
- package/js/rte/src/selection/point/point.js +83 -0
- package/js/rte/src/selection/range/README.md +38 -0
- package/js/rte/src/selection/range/edit-range.js +223 -0
- package/js/rte/src/selection/snapshot.js +64 -0
- package/js/rte/src/source/README.md +64 -0
- package/js/rte/src/source/source.js +193 -0
- package/js/rte/src/surface/README.md +36 -0
- package/js/rte/src/surface/content.js +16 -0
- package/js/rte/src/surface/surface.js +87 -0
- package/js/rte/src/transaction/README.md +35 -0
- package/js/rte/src/transaction/transaction.js +56 -0
- package/js/rte/src/ui/README.md +255 -0
- package/js/rte/src/ui/chrome.js +214 -0
- package/js/rte/src/ui/handles.js +179 -0
- package/js/rte/src/ui/place.js +25 -0
- package/js/rte/src/ui/toolbar.js +283 -0
- package/js/rte/src/unstyle/README.md +103 -0
- package/js/rte/src/unstyle/unstyle.js +140 -0
- package/js/rte/tables.js +7 -0
- package/js/rte/unstyle.js +8 -0
- package/js/rte0/README.md +37 -0
- package/js/rte0/rte.js +7 -0
- package/js/{rte → rte0}/src/behavior.js +0 -1
- package/js/{rte → rte0}/src/events.js +0 -1
- package/js/{rte → rte0}/src/range.js +0 -2
- package/js/{rte → rte0}/src/ui.items.js +0 -2
- package/js/{rte → rte0}/src/ui.js +0 -1
- package/package.json +1 -1
- package/u2/Element.js +2 -2
- package/u2/plan-scoped-refactoring.md +133 -303
- package/u2/projects.json +18 -2
- package/u2/tools/update.repos.json.js +1 -1
- package/el/qrcode/package.json +0 -22
- /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
- /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/main.css +0 -0
- /package/js/{rte → rte0}/old/old.rte.js +0 -0
- /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
- /package/js/{rte → rte0}/src/fixes.js +0 -0
- /package/js/{rte → rte0}/src/rte.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
- /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
- /package/js/{rte → rte0}/src/util/indentHtml.js +0 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import {SelectionSnapshot} from '../selection/snapshot.js';
|
|
2
|
+
import {contentChildren, replaceContent} from '../surface/content.js';
|
|
3
|
+
|
|
4
|
+
// Snapshot history for one surface. RTE leaves ordinary typing and deletion to
|
|
5
|
+
// the browser, so history cannot be an operation log: it observes every mutation
|
|
6
|
+
// regardless of its origin and stores the resulting content as a cloned fragment
|
|
7
|
+
// with a path-addressed selection that survives content replacement.
|
|
8
|
+
//
|
|
9
|
+
// Transactions provide the grouping boundaries. Ordinary input coalesces into
|
|
10
|
+
// one entry per interval; commands, paste, and drop each become one entry.
|
|
11
|
+
export class History {
|
|
12
|
+
#surface;
|
|
13
|
+
#entries = [];
|
|
14
|
+
#index = -1;
|
|
15
|
+
#limit;
|
|
16
|
+
#coalesce;
|
|
17
|
+
#observer;
|
|
18
|
+
#timer = null;
|
|
19
|
+
#dirty = false;
|
|
20
|
+
#applying = false;
|
|
21
|
+
#controller;
|
|
22
|
+
#commands;
|
|
23
|
+
|
|
24
|
+
constructor(surface, {limit = 100, coalesce = 400} = {}) {
|
|
25
|
+
if (surface?.element?.nodeType !== Node.ELEMENT_NODE || typeof surface?.transact !== 'function') {
|
|
26
|
+
throw new TypeError('History requires an editor surface');
|
|
27
|
+
}
|
|
28
|
+
if (!Number.isInteger(limit) || limit < 2) throw new RangeError('History limit must be an integer of at least 2');
|
|
29
|
+
if (!Number.isFinite(coalesce) || coalesce < 0) {
|
|
30
|
+
throw new RangeError('History coalescing must be a non-negative interval');
|
|
31
|
+
}
|
|
32
|
+
this.#surface = surface;
|
|
33
|
+
this.#limit = limit;
|
|
34
|
+
this.#coalesce = coalesce;
|
|
35
|
+
this.#commands = Object.freeze({
|
|
36
|
+
undo: step('historyUndo', 'ctrl+z', () => this.canUndo, () => this.undo()),
|
|
37
|
+
redo: step('historyRedo', 'ctrl+y ctrl+shift+z', () => this.canRedo, () => this.redo()),
|
|
38
|
+
});
|
|
39
|
+
const root = surface.element;
|
|
40
|
+
const view = root.ownerDocument.defaultView;
|
|
41
|
+
this.#observer = new view.MutationObserver(this.#mutate);
|
|
42
|
+
this.#observer.observe(root, {subtree: true, childList: true, attributes: true, characterData: true});
|
|
43
|
+
this.#controller = new view.AbortController();
|
|
44
|
+
const listen = {signal: this.#controller.signal};
|
|
45
|
+
surface.addEventListener('u2-rte-beforechange', this.#boundary, listen);
|
|
46
|
+
surface.addEventListener('u2-rte-change', this.#boundary, listen);
|
|
47
|
+
surface.addEventListener('u2-rte-selectionchange', this.#selectionChange, listen);
|
|
48
|
+
surface.addEventListener('u2-rte-deactivate', this.#deactivate, listen);
|
|
49
|
+
surface.addEventListener('u2-rte-disconnect', this.#disconnect, listen);
|
|
50
|
+
this.#push(this.#capture());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get surface() { return this.#surface; }
|
|
54
|
+
get commands() { return this.#commands; }
|
|
55
|
+
get length() { return this.#entries.length; }
|
|
56
|
+
get index() { return this.#index; }
|
|
57
|
+
get connected() { return !this.#controller.signal.aborted; }
|
|
58
|
+
get canUndo() { return this.#index > 0 || this.#pull(); }
|
|
59
|
+
get canRedo() { return this.#index < this.#entries.length - 1 && !this.#pull(); }
|
|
60
|
+
|
|
61
|
+
undo() {
|
|
62
|
+
return this.#go(-1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
redo() {
|
|
66
|
+
return this.#go(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Commits the current content as one entry. Unchanged content adds nothing,
|
|
70
|
+
// so callers may flush at any boundary without producing duplicates.
|
|
71
|
+
record() {
|
|
72
|
+
if (this.#applying || !this.#surface.connected) return false;
|
|
73
|
+
const dirty = this.#pull();
|
|
74
|
+
this.#cancel();
|
|
75
|
+
if (!dirty) return false;
|
|
76
|
+
this.#dirty = false;
|
|
77
|
+
this.#push(this.#capture());
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
clear() {
|
|
82
|
+
this.#cancel();
|
|
83
|
+
this.#observer.takeRecords();
|
|
84
|
+
this.#dirty = false;
|
|
85
|
+
this.#entries = [];
|
|
86
|
+
this.#index = -1;
|
|
87
|
+
this.#push(this.#capture());
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
dispose() {
|
|
92
|
+
if (!this.connected) return;
|
|
93
|
+
this.#cancel();
|
|
94
|
+
this.#observer.disconnect();
|
|
95
|
+
this.#controller.abort();
|
|
96
|
+
this.#entries = [];
|
|
97
|
+
this.#index = -1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
[Symbol.dispose]() {
|
|
101
|
+
this.dispose();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#go(step) {
|
|
105
|
+
if (!this.#surface.connected) return false;
|
|
106
|
+
// Uncommitted input becomes its own entry first, so undo leaves the
|
|
107
|
+
// state the user actually sees instead of skipping past it.
|
|
108
|
+
this.record();
|
|
109
|
+
const next = this.#index + step;
|
|
110
|
+
const entry = this.#entries[next];
|
|
111
|
+
if (!entry) return false;
|
|
112
|
+
this.#apply(entry);
|
|
113
|
+
this.#index = next;
|
|
114
|
+
this.#emit();
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#capture() {
|
|
119
|
+
const element = this.#surface.element;
|
|
120
|
+
const fragment = element.ownerDocument.createDocumentFragment();
|
|
121
|
+
for (const node of contentChildren(this.#surface)) fragment.append(node.cloneNode(true));
|
|
122
|
+
return {fragment, selection: this.#selection()};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#apply(entry) {
|
|
126
|
+
this.#applying = true;
|
|
127
|
+
try {
|
|
128
|
+
const clone = entry.fragment.cloneNode(true);
|
|
129
|
+
replaceContent(this.#surface, ...clone.childNodes);
|
|
130
|
+
this.#restore(entry.selection);
|
|
131
|
+
} finally {
|
|
132
|
+
// Restoring is not an edit: its own records never enter history.
|
|
133
|
+
this.#observer.takeRecords();
|
|
134
|
+
this.#dirty = false;
|
|
135
|
+
this.#applying = false;
|
|
136
|
+
}
|
|
137
|
+
this.#surface.capture();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#push(entry) {
|
|
141
|
+
this.#entries.splice(this.#index + 1);
|
|
142
|
+
this.#entries.push(entry);
|
|
143
|
+
if (this.#entries.length > this.#limit) this.#entries.shift();
|
|
144
|
+
this.#index = this.#entries.length - 1;
|
|
145
|
+
this.#emit();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#selection() {
|
|
149
|
+
const root = this.#surface.element;
|
|
150
|
+
const snapshot = SelectionSnapshot.capture(this.#surface.core.selection, root) || this.#surface.selection;
|
|
151
|
+
if (!snapshot?.valid()) return null;
|
|
152
|
+
const range = snapshot.range();
|
|
153
|
+
const start = address(this.#surface, root, range.startContainer, range.startOffset);
|
|
154
|
+
if (!start) return null;
|
|
155
|
+
const end = range.collapsed ? start : address(this.#surface, root, range.endContainer, range.endOffset);
|
|
156
|
+
return end ? {start, end, backward: snapshot.backward} : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#restore(saved) {
|
|
160
|
+
const root = this.#surface.element;
|
|
161
|
+
const start = saved && locate(this.#surface, root, saved.start);
|
|
162
|
+
const end = saved && locate(this.#surface, root, saved.end);
|
|
163
|
+
if (!start || !end) return false;
|
|
164
|
+
const range = root.ownerDocument.createRange();
|
|
165
|
+
range.setStart(start.node, start.offset);
|
|
166
|
+
range.setEnd(end.node, end.offset);
|
|
167
|
+
return new SelectionSnapshot(root, range, saved.backward).restore(this.#surface.core.selection);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#pull() {
|
|
171
|
+
if (this.#edited(this.#observer.takeRecords())) this.#mark();
|
|
172
|
+
return this.#dirty;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The host element belongs to the application, not to the edited content:
|
|
176
|
+
// an entry restores children only, so its own attributes must not record.
|
|
177
|
+
#edited(records) {
|
|
178
|
+
const root = this.#surface.element;
|
|
179
|
+
const core = this.#surface.core;
|
|
180
|
+
return records.some(record => {
|
|
181
|
+
if (core.retains(record.target)) return false;
|
|
182
|
+
if (record.type === 'attributes') return record.target !== root;
|
|
183
|
+
if (record.type !== 'childList') return true;
|
|
184
|
+
const changed = [...record.addedNodes, ...record.removedNodes];
|
|
185
|
+
return !changed.length || changed.some(node => !core.retains(node));
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Continuous input keeps one open interval instead of restarting it, so a
|
|
190
|
+
// long paragraph still becomes several undo steps.
|
|
191
|
+
#mark() {
|
|
192
|
+
this.#dirty = true;
|
|
193
|
+
if (this.#timer === null) this.#timer = setTimeout(this.#flush, this.#coalesce);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#cancel() {
|
|
197
|
+
if (this.#timer === null) return;
|
|
198
|
+
clearTimeout(this.#timer);
|
|
199
|
+
this.#timer = null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#emit() {
|
|
203
|
+
this.#surface.emit('u2-rte-history', {history: this});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#flush = () => {
|
|
207
|
+
this.#timer = null;
|
|
208
|
+
this.record();
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
#mutate = records => {
|
|
212
|
+
if (!this.#applying && this.#edited(records)) this.#mark();
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// A transaction that is not ordinary input is its own undo step: the state
|
|
216
|
+
// before it and the state after it each become one entry.
|
|
217
|
+
#boundary = event => {
|
|
218
|
+
if (!this.#applying && !coalesced(event)) this.record();
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// An entry captured before the surface had a selection adopts the first one
|
|
222
|
+
// it sees, so undoing back to it restores a caret too.
|
|
223
|
+
#selectionChange = () => {
|
|
224
|
+
if (this.#applying || this.#dirty) return;
|
|
225
|
+
const entry = this.#entries[this.#index];
|
|
226
|
+
if (entry && !entry.selection) entry.selection = this.#selection();
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
#deactivate = () => {
|
|
230
|
+
this.record();
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
#disconnect = () => {
|
|
234
|
+
this.dispose();
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Like every other command, a history step acts only where the surface owns the
|
|
239
|
+
// selection. An editor the user has left must not change under a shortcut, and
|
|
240
|
+
// a toolbar that still holds a saved selection passes it as the edit range.
|
|
241
|
+
function step(inputType, shortcut, available, run) {
|
|
242
|
+
return {
|
|
243
|
+
inputTypes: [inputType],
|
|
244
|
+
shortcut,
|
|
245
|
+
transaction: false,
|
|
246
|
+
enabled: edit => !!edit.range && available(),
|
|
247
|
+
run,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function coalesced(event) {
|
|
252
|
+
return event.detail.transaction?.options.trigger === 'input';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// A selection address survives content replacement because it names positions
|
|
256
|
+
// by child index instead of by node identity.
|
|
257
|
+
function address(surface, root, node, offset) {
|
|
258
|
+
const path = [];
|
|
259
|
+
for (let current = node; current !== root; current = current.parentNode) {
|
|
260
|
+
const parent = current?.parentNode;
|
|
261
|
+
if (!parent) return null;
|
|
262
|
+
const index = contentChildren(surface, parent).indexOf(current);
|
|
263
|
+
if (index < 0) return null;
|
|
264
|
+
path.unshift(index);
|
|
265
|
+
}
|
|
266
|
+
return {path, offset: contentOffset(surface, node, offset)};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function locate(surface, root, {path, offset}) {
|
|
270
|
+
let node = root;
|
|
271
|
+
for (const index of path) {
|
|
272
|
+
node = contentChildren(surface, node)[index];
|
|
273
|
+
if (!node) return null;
|
|
274
|
+
}
|
|
275
|
+
const length = typeof node.length === 'number' ? node.length : contentChildren(surface, node).length;
|
|
276
|
+
return {node, offset: Math.min(offset, length)};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function contentOffset(surface, node, offset) {
|
|
280
|
+
if (typeof node.length === 'number') return offset;
|
|
281
|
+
const before = [...node.childNodes].slice(0, offset);
|
|
282
|
+
return before.filter(child => !surface.core.retains(child)).length;
|
|
283
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Input pipeline
|
|
2
|
+
|
|
3
|
+
The input pipeline turns browser editing events—typing, Enter, paste, drop, and
|
|
4
|
+
composition—into editor work. It decides whether the browser may perform an
|
|
5
|
+
action itself or whether an RTE command replaces it, then repairs the small
|
|
6
|
+
part of the document that changed.
|
|
7
|
+
|
|
8
|
+
`input-pipeline.js` installs post-native behavior for one `Surface`.
|
|
9
|
+
`external-input.js` is the optional pre-native boundary for rich HTML from
|
|
10
|
+
paste and drop. Both are explicit and reversible: construct them for a surface
|
|
11
|
+
and call `dispose()` during module cleanup. `[Symbol.dispose]()` exposes each
|
|
12
|
+
to `using`. Disconnecting the surface also disposes them automatically.
|
|
13
|
+
|
|
14
|
+
## Contract
|
|
15
|
+
|
|
16
|
+
- `beforeinput` captures a live target range before the browser mutates DOM;
|
|
17
|
+
the matching `input` performs postcondition normalization afterward.
|
|
18
|
+
- With a command registry, a cancelable `beforeinput` whose `inputType` has a
|
|
19
|
+
registered, available command is prevented and replaced by that command. The
|
|
20
|
+
registry decides availability; the pipeline only decides that native editing
|
|
21
|
+
is not used. The command receives the event's `inputType`, text `data`, and
|
|
22
|
+
target range unchanged. Uncancelable or already prevented events, plain-text
|
|
23
|
+
hosts, and active composition are never routed. This also prevents two
|
|
24
|
+
deliberately independent cores from executing the same bubbling event twice.
|
|
25
|
+
- Plain unmodified Backspace and Delete are checked at `keydown` because
|
|
26
|
+
engines do not reliably expose a usable `beforeinput` at every filler
|
|
27
|
+
boundary. A key is prevented only when its registered structural command is
|
|
28
|
+
available; character deletion, selections, modified keys, and other
|
|
29
|
+
unavailable cases remain native.
|
|
30
|
+
- A registered command shortcut is resolved at `keydown` before anything else,
|
|
31
|
+
and only replaces the key where that command is available. That is what lets
|
|
32
|
+
`Tab` reach the list-nesting command inside a list and go on moving focus
|
|
33
|
+
outside one. Undo and redo arrive this way too: an engine stops reporting
|
|
34
|
+
`historyUndo` and `historyRedo` once its own undo stack no longer matches
|
|
35
|
+
content the editor replaced, so their keys cannot come from `beforeinput`.
|
|
36
|
+
- `u2-rte-command` with a transaction triggers the same cleanup as native
|
|
37
|
+
input, so an editing command executed from a toolbar is normalized like one
|
|
38
|
+
executed from the keyboard. View-only commands report a null transaction and
|
|
39
|
+
are ignored because they changed no editable DOM.
|
|
40
|
+
- `insertFromPaste` and `insertFromDrop` are separate `paste` and `drop`
|
|
41
|
+
triggers. Other input types use `input`; explicit editor commands use
|
|
42
|
+
`command`.
|
|
43
|
+
- Native paste/drop payloads remain browser-owned. Between their matching
|
|
44
|
+
`beforeinput` and `input`, the pipeline records only newly added element roots,
|
|
45
|
+
applies the configured `Unstyle` level to those roots with point mapping, and
|
|
46
|
+
then structurally normalizes the affected neighborhood. Elements present
|
|
47
|
+
before the input retain their presentation even when the browser moves them
|
|
48
|
+
below a newly inserted wrapper. The default level is `styles`; `none` disables
|
|
49
|
+
presentation cleanup.
|
|
50
|
+
- `--u2-rte-clean-on` decides which triggers run. Cleanup level and semantic
|
|
51
|
+
default block are resolved from the surface CSS configuration for every run.
|
|
52
|
+
- Scope starts at the affected block. If that block has an invalid relationship
|
|
53
|
+
to its parent, the parent becomes the scope so the relationship can be
|
|
54
|
+
repaired without cleaning unrelated sibling subtrees.
|
|
55
|
+
- Native IME mutations are never interrupted. Input during composition is
|
|
56
|
+
deferred until the final input or the microtask after `compositionend`.
|
|
57
|
+
- Nested editable hosts are ownership boundaries; their events are ignored.
|
|
58
|
+
- Normalization runs in a surface transaction, maps the current forward or
|
|
59
|
+
backward selection, reports dirty nodes, and emits `u2-rte-normalize`.
|
|
60
|
+
- A collapsed selection maps as one caret point. Two points would drift apart
|
|
61
|
+
when a repair inserts content exactly at the caret boundary.
|
|
62
|
+
- The pipeline never reads clipboard or drag payloads. `inputRange(event,
|
|
63
|
+
surface)` is its public, shared conversion from a native `StaticRange` to an
|
|
64
|
+
owned live `Range`; missing target ranges fall back to the owned selection.
|
|
65
|
+
|
|
66
|
+
## Rich external input
|
|
67
|
+
|
|
68
|
+
The native path above needs no HTML parser or sanitizer adapter because RTE
|
|
69
|
+
never reads the payload or reinserts its string. `ExternalInput` is a separate,
|
|
70
|
+
optional pre-native path for applications that deliberately want to process a
|
|
71
|
+
rich HTML string themselves.
|
|
72
|
+
|
|
73
|
+
`ExternalInput` composes one selected sanitizer, an optional `Unstyle` policy,
|
|
74
|
+
and a registered mapped insertion command:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
const commands = new Commands(surface, {commands: {insertFragment}});
|
|
78
|
+
const external = new ExternalInput(surface, {
|
|
79
|
+
commands,
|
|
80
|
+
sanitizer: new NativeSanitizer(),
|
|
81
|
+
unstyle: defaultUnstyle,
|
|
82
|
+
through: 'styles',
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
For `insertFromPaste` and `insertFromDrop`, the contenteditable Input Events
|
|
87
|
+
contract supplies rich and plain data in `dataTransfer` plus the DOM range that
|
|
88
|
+
would be replaced. The adapter takes over only when `text/html` is present. It
|
|
89
|
+
prevents native insertion first, sanitizes into a detached `DocumentFragment`,
|
|
90
|
+
optionally cleans presentation cumulatively through the selected level, and
|
|
91
|
+
passes the result and exact target range to the registered command. Drop thus
|
|
92
|
+
does not accidentally use a selection left elsewhere.
|
|
93
|
+
|
|
94
|
+
`through` may be a level name, `null`, `none`, or a function receiving
|
|
95
|
+
`{surface, inputType}`. This leaves serializable per-surface policy resolvers to
|
|
96
|
+
the client layer without coupling security code to CSS. An empty sanitized
|
|
97
|
+
fragment changes nothing and does not delete an existing selection.
|
|
98
|
+
|
|
99
|
+
Plain text, quotation paste, uncancelable or already handled events, active
|
|
100
|
+
composition, plain-text hosts, and nested editing hosts stay native. Quotation
|
|
101
|
+
and plain-text line structure need their own contextual transformer rather than
|
|
102
|
+
being guessed by this HTML adapter.
|
|
103
|
+
|
|
104
|
+
`insert(html, {range, inputType})` exposes the same synchronous composition to
|
|
105
|
+
an HTML source dialog or another trusted event adapter. It still always calls
|
|
106
|
+
the configured sanitizer. A direct failure is thrown. A native event failure
|
|
107
|
+
remains fail-closed—the browser insertion stays prevented—and emits
|
|
108
|
+
`u2-rte-error` with `transaction: null` and `phase: 'external-input'`.
|
|
109
|
+
|
|
110
|
+
## What an import goes through
|
|
111
|
+
|
|
112
|
+
A native paste or drop is cleaned in three stages, in this order:
|
|
113
|
+
|
|
114
|
+
1. **What may exist**: the sanitize policy narrows the arrived nodes to the
|
|
115
|
+
allowed attributes and protocols, and then to the importable elements.
|
|
116
|
+
Attributes go first — removing one moves nothing, while narrowing dissolves
|
|
117
|
+
wrappers and would leave nothing left to clean. What the content model
|
|
118
|
+
already rejects is skipped and left to stage 3, which knows that dissolving
|
|
119
|
+
a block into inline content needs a line break to survive.
|
|
120
|
+
`--u2-rte-import-sanitize: none` opts a host out of the whole stage.
|
|
121
|
+
An element the list does not carry but whose meaning a listed one does is
|
|
122
|
+
replaced rather than dropped: `importAliases` maps `b` to `strong`, `i` to
|
|
123
|
+
`em`, and `strike` to `s`, so a strict list costs no emphasis.
|
|
124
|
+
2. **What it should look like**: `Unstyle` removes presentation up to
|
|
125
|
+
`--u2-rte-import-unstyle`, sparing the host's declared content classes.
|
|
126
|
+
3. **How it is built**: the normalizer repairs structure.
|
|
127
|
+
|
|
128
|
+
The order matters. An id removed in the first stage is exactly what lets the
|
|
129
|
+
third dissolve the layout wrapper that carried it.
|
|
130
|
+
|
|
131
|
+
Where to repair is decided before any of it runs: narrowing dissolves the very
|
|
132
|
+
nodes that say where the import landed.
|
|
133
|
+
|
|
134
|
+
## What cleanup covers
|
|
135
|
+
|
|
136
|
+
The caret is not where pasted or dropped content is — it sits at the end of it.
|
|
137
|
+
Deriving the cleanup scope from the caret therefore repaired only its last block
|
|
138
|
+
and left the rest of a pasted document untouched, however broken. The pipeline
|
|
139
|
+
already tracks the nodes that arrived, so the scope is the smallest element
|
|
140
|
+
covering both them and the caret. That tracking is now set up for every cleaned
|
|
141
|
+
paste or drop rather than only when presentation cleanup is configured:
|
|
142
|
+
structural repair needs to know where content landed just as much.
|
|
143
|
+
|
|
144
|
+
## TODO
|
|
145
|
+
|
|
146
|
+
- Keep a native input transaction open from cancellable `beforeinput` through
|
|
147
|
+
the resulting `input` for exact history snapshots.
|
|
148
|
+
- Derive multi-block neighborhoods from all `getTargetRanges()` entries.
|
|
149
|
+
- Route selected-range deletion once its command can preserve structural
|
|
150
|
+
boundaries and selection direction.
|
|
151
|
+
- Add contextual plain-text and quotation import without folding their line and
|
|
152
|
+
block semantics into HTML sanitizing.
|
|
153
|
+
- Verify trusted clipboard and drag interactions in all target engines in
|
|
154
|
+
addition to the deterministic event-contract suite.
|
|
155
|
+
- Group composition and consecutive typing transactions for history.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {inputRange} from './input-pipeline.js';
|
|
2
|
+
import {editingHost, isPlainTextHost} from '../selection/ownership/ownership.js';
|
|
3
|
+
|
|
4
|
+
const INPUTS = new Set(['insertFromPaste', 'insertFromDrop']);
|
|
5
|
+
|
|
6
|
+
// Owns rich external HTML before the browser can insert it. The selected
|
|
7
|
+
// sanitizer, optional presentation cleanup, and mapped insertion command stay
|
|
8
|
+
// replaceable; this class only composes them for native input events.
|
|
9
|
+
export class ExternalInput {
|
|
10
|
+
#surface;
|
|
11
|
+
#root;
|
|
12
|
+
#commands;
|
|
13
|
+
#sanitizer;
|
|
14
|
+
#unstyle;
|
|
15
|
+
#through;
|
|
16
|
+
#command;
|
|
17
|
+
#controller;
|
|
18
|
+
#connected = true;
|
|
19
|
+
|
|
20
|
+
constructor(surface, {
|
|
21
|
+
commands,
|
|
22
|
+
sanitizer,
|
|
23
|
+
unstyle = null,
|
|
24
|
+
through = null,
|
|
25
|
+
command = 'insertFragment',
|
|
26
|
+
} = {}) {
|
|
27
|
+
const root = surface?.element;
|
|
28
|
+
if (root?.nodeType !== Node.ELEMENT_NODE || typeof surface?.emit !== 'function') {
|
|
29
|
+
throw new TypeError('External input requires an editor surface');
|
|
30
|
+
}
|
|
31
|
+
if (typeof commands?.has !== 'function' || typeof commands?.run !== 'function') {
|
|
32
|
+
throw new TypeError('External input requires a command registry');
|
|
33
|
+
}
|
|
34
|
+
if (typeof command !== 'string' || !command.trim() || !commands.has(command)) {
|
|
35
|
+
throw new RangeError('External input requires a registered insertion command');
|
|
36
|
+
}
|
|
37
|
+
if (typeof sanitizer?.sanitize !== 'function') {
|
|
38
|
+
throw new TypeError('External input requires a sanitizer');
|
|
39
|
+
}
|
|
40
|
+
if (unstyle !== null && typeof unstyle?.clean !== 'function') {
|
|
41
|
+
throw new TypeError('External input cleanup requires an Unstyle policy');
|
|
42
|
+
}
|
|
43
|
+
if (through !== null && typeof through !== 'string' && typeof through !== 'function') {
|
|
44
|
+
throw new TypeError('External input cleanup level must be a string, function, or null');
|
|
45
|
+
}
|
|
46
|
+
if (through !== null && !unstyle) {
|
|
47
|
+
throw new TypeError('External input cleanup level requires an Unstyle policy');
|
|
48
|
+
}
|
|
49
|
+
this.#surface = surface;
|
|
50
|
+
this.#root = root;
|
|
51
|
+
this.#commands = commands;
|
|
52
|
+
this.#sanitizer = sanitizer;
|
|
53
|
+
this.#unstyle = unstyle;
|
|
54
|
+
this.#through = through;
|
|
55
|
+
this.#command = command.trim();
|
|
56
|
+
this.#controller = new root.ownerDocument.defaultView.AbortController();
|
|
57
|
+
const listen = {signal: this.#controller.signal};
|
|
58
|
+
root.addEventListener('beforeinput', this.#beforeInput, listen);
|
|
59
|
+
surface.addEventListener('u2-rte-disconnect', this.#disconnect, listen);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get surface() { return this.#surface; }
|
|
63
|
+
get connected() { return this.#connected; }
|
|
64
|
+
|
|
65
|
+
insert(html, {range = null, inputType = ''} = {}) {
|
|
66
|
+
if (!this.#connected) throw new DOMException('External input is disconnected', 'InvalidStateError');
|
|
67
|
+
if (typeof html !== 'string') throw new TypeError('External HTML must be a string');
|
|
68
|
+
const fragment = this.#sanitizer.sanitize(html, {
|
|
69
|
+
document: this.#root.ownerDocument,
|
|
70
|
+
base: this.#root.ownerDocument.baseURI,
|
|
71
|
+
elements: this.#surface.config.elements,
|
|
72
|
+
classes: this.#surface.config.classes.length ? this.#surface.config.classes : null,
|
|
73
|
+
});
|
|
74
|
+
if (fragment?.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
|
|
75
|
+
throw new TypeError('A sanitizer must return a DocumentFragment');
|
|
76
|
+
}
|
|
77
|
+
const through = typeof this.#through === 'function'
|
|
78
|
+
? this.#through(Object.freeze({surface: this.#surface, inputType}))
|
|
79
|
+
: this.#through;
|
|
80
|
+
if (through !== null && through !== 'none') {
|
|
81
|
+
this.#unstyle.clean(fragment, {through, keep: this.#surface.config.classes});
|
|
82
|
+
}
|
|
83
|
+
if (!fragment.childNodes.length) return [];
|
|
84
|
+
return this.#commands.run(this.#command, {fragment, range, inputType});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
dispose() {
|
|
88
|
+
if (!this.#connected) return;
|
|
89
|
+
this.#controller.abort();
|
|
90
|
+
this.#connected = false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
[Symbol.dispose]() {
|
|
94
|
+
this.dispose();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#beforeInput = event => {
|
|
98
|
+
if (!this.#owns(event) || !INPUTS.has(event.inputType) || !event.cancelable
|
|
99
|
+
|| event.defaultPrevented || event.isComposing || isPlainTextHost(this.#root)) return;
|
|
100
|
+
if (!hasHtml(event.dataTransfer)) return;
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
try {
|
|
103
|
+
const html = event.dataTransfer.getData('text/html');
|
|
104
|
+
const range = inputRange(event, this.#surface);
|
|
105
|
+
this.insert(html, {range, inputType: event.inputType});
|
|
106
|
+
} catch (error) {
|
|
107
|
+
this.#surface.emit('u2-rte-error', {
|
|
108
|
+
transaction: null,
|
|
109
|
+
error,
|
|
110
|
+
phase: 'external-input',
|
|
111
|
+
inputType: event.inputType,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
#disconnect = () => {
|
|
117
|
+
this.dispose();
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
#owns(event) {
|
|
121
|
+
const target = event.composedPath()[0];
|
|
122
|
+
return target === this.#root || editingHost(target) === this.#root;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function hasHtml(transfer) {
|
|
127
|
+
if (!transfer || typeof transfer.getData !== 'function') return false;
|
|
128
|
+
const types = [...(transfer.types || [])].map(type => String(type).toLowerCase());
|
|
129
|
+
return types.includes('text/html');
|
|
130
|
+
}
|