u2ui 1.5.2 → 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.
Files changed (144) hide show
  1. package/el/ico/ico.js +5 -5
  2. package/el/rte/README.md +53 -13
  3. package/el/rte/manifest.json +19 -4
  4. package/el/rte/rte.css +31 -6
  5. package/el/rte/rte.js +174 -44
  6. package/el/rte0/README.md +37 -0
  7. package/el/rte0/manifest.json +33 -0
  8. package/el/rte0/rte0.css +9 -0
  9. package/el/rte0/rte0.js +56 -0
  10. package/el/system/styler.js +2 -0
  11. package/el/tooltip/tooltip.css +2 -2
  12. package/js/Placer/Placer.js +2 -1
  13. package/js/rte/AGENTS.md +142 -0
  14. package/js/rte/PLAN.md +488 -0
  15. package/js/rte/README.md +319 -25
  16. package/js/rte/blocks.js +7 -0
  17. package/js/rte/breaks.js +6 -0
  18. package/js/rte/classes.js +7 -0
  19. package/js/rte/docs/usage.md +845 -0
  20. package/js/rte/images.js +7 -0
  21. package/js/rte/link.js +8 -0
  22. package/js/rte/playground/README.md +95 -0
  23. package/js/rte/playground/hosts.html +518 -0
  24. package/js/rte/playground/index.html +824 -0
  25. package/js/rte/rte.js +56 -5
  26. package/js/rte/source.js +12 -0
  27. package/js/rte/src/browser/README.md +40 -0
  28. package/js/rte/src/browser/range-rect.js +82 -0
  29. package/js/rte/src/client/README.md +378 -0
  30. package/js/rte/src/client/blocks.js +38 -0
  31. package/js/rte/src/client/breaks.js +212 -0
  32. package/js/rte/src/client/classes.js +48 -0
  33. package/js/rte/src/client/editor.js +461 -0
  34. package/js/rte/src/client/external-input.js +47 -0
  35. package/js/rte/src/client/history.js +13 -0
  36. package/js/rte/src/client/images.js +275 -0
  37. package/js/rte/src/client/link.js +572 -0
  38. package/js/rte/src/client/marks.js +25 -0
  39. package/js/rte/src/client/source.js +182 -0
  40. package/js/rte/src/client/structure.js +27 -0
  41. package/js/rte/src/client/tables.js +162 -0
  42. package/js/rte/src/client/unstyle.js +32 -0
  43. package/js/rte/src/command/README.md +517 -0
  44. package/js/rte/src/command/block-boundary.js +43 -0
  45. package/js/rte/src/command/block-style.js +120 -0
  46. package/js/rte/src/command/commands.js +158 -0
  47. package/js/rte/src/command/delete.js +112 -0
  48. package/js/rte/src/command/edit.js +72 -0
  49. package/js/rte/src/command/element.js +45 -0
  50. package/js/rte/src/command/enter.js +175 -0
  51. package/js/rte/src/command/fragment.js +76 -0
  52. package/js/rte/src/command/insert.js +52 -0
  53. package/js/rte/src/command/list.js +284 -0
  54. package/js/rte/src/command/mark.js +583 -0
  55. package/js/rte/src/command/pending-marks.js +216 -0
  56. package/js/rte/src/command/table.js +207 -0
  57. package/js/rte/src/command/unstyle.js +248 -0
  58. package/js/rte/src/config/README.md +96 -0
  59. package/js/rte/src/config/config.js +127 -0
  60. package/js/rte/src/core/README.md +41 -0
  61. package/js/rte/src/core/core.js +156 -0
  62. package/js/rte/src/history/README.md +83 -0
  63. package/js/rte/src/history/history.js +283 -0
  64. package/js/rte/src/input/README.md +155 -0
  65. package/js/rte/src/input/external-input.js +130 -0
  66. package/js/rte/src/input/input-pipeline.js +414 -0
  67. package/js/rte/src/mark/README.md +219 -0
  68. package/js/rte/src/mark/dom-adapter.js +100 -0
  69. package/js/rte/src/mark/mark.js +133 -0
  70. package/js/rte/src/mark/standard.js +76 -0
  71. package/js/rte/src/model/README.md +71 -0
  72. package/js/rte/src/model/content-model.js +213 -0
  73. package/js/rte/src/model/html/README.md +51 -0
  74. package/js/rte/src/model/html/html-model.js +90 -0
  75. package/js/rte/src/normalize/execute/README.md +33 -0
  76. package/js/rte/src/normalize/execute/repair-executor.js +129 -0
  77. package/js/rte/src/normalize/normalizer/README.md +48 -0
  78. package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
  79. package/js/rte/src/normalize/repair/README.md +70 -0
  80. package/js/rte/src/normalize/repair/repair-planner.js +208 -0
  81. package/js/rte/src/sanitize/README.md +105 -0
  82. package/js/rte/src/sanitize/native.js +43 -0
  83. package/js/rte/src/sanitize/policy.js +202 -0
  84. package/js/rte/src/selection/README.md +39 -0
  85. package/js/rte/src/selection/map/README.md +53 -0
  86. package/js/rte/src/selection/map/point-map.js +307 -0
  87. package/js/rte/src/selection/ownership/README.md +32 -0
  88. package/js/rte/src/selection/ownership/ownership.js +42 -0
  89. package/js/rte/src/selection/point/README.md +34 -0
  90. package/js/rte/src/selection/point/point.js +83 -0
  91. package/js/rte/src/selection/range/README.md +38 -0
  92. package/js/rte/src/selection/range/edit-range.js +223 -0
  93. package/js/rte/src/selection/snapshot.js +64 -0
  94. package/js/rte/src/source/README.md +64 -0
  95. package/js/rte/src/source/source.js +193 -0
  96. package/js/rte/src/surface/README.md +36 -0
  97. package/js/rte/src/surface/content.js +16 -0
  98. package/js/rte/src/surface/surface.js +87 -0
  99. package/js/rte/src/transaction/README.md +35 -0
  100. package/js/rte/src/transaction/transaction.js +56 -0
  101. package/js/rte/src/ui/README.md +255 -0
  102. package/js/rte/src/ui/chrome.js +214 -0
  103. package/js/rte/src/ui/handles.js +179 -0
  104. package/js/rte/src/ui/place.js +25 -0
  105. package/js/rte/src/ui/toolbar.js +283 -0
  106. package/js/rte/src/unstyle/README.md +103 -0
  107. package/js/rte/src/unstyle/unstyle.js +140 -0
  108. package/js/rte/tables.js +7 -0
  109. package/js/rte/unstyle.js +8 -0
  110. package/js/rte0/README.md +37 -0
  111. package/js/rte0/rte.js +7 -0
  112. package/js/{rte → rte0}/src/behavior.js +0 -1
  113. package/js/{rte → rte0}/src/events.js +0 -1
  114. package/js/{rte → rte0}/src/range.js +0 -2
  115. package/js/{rte → rte0}/src/ui.items.js +0 -2
  116. package/js/{rte → rte0}/src/ui.js +0 -1
  117. package/package.json +1 -1
  118. package/u2/Element.js +16 -4
  119. package/u2/enhance.js +2 -1
  120. package/u2/plan-scoped-refactoring.md +133 -304
  121. package/u2/projects.json +18 -2
  122. package/u2/tools/update.repos.json.js +1 -1
  123. package/u2/utils.js +0 -16
  124. package/el/qrcode/package.json +0 -22
  125. /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
  126. /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
  127. /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
  128. /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
  129. /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
  130. /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
  131. /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
  132. /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
  133. /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
  134. /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
  135. /package/js/{rte → rte0}/old/Rte/main.css +0 -0
  136. /package/js/{rte → rte0}/old/old.rte.js +0 -0
  137. /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
  138. /package/js/{rte → rte0}/src/fixes.js +0 -0
  139. /package/js/{rte → rte0}/src/rte.js +0 -0
  140. /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
  141. /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
  142. /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
  143. /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
  144. /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
+ }