u2ui 1.5.3 → 1.5.7

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 (146) 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 +487 -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 +553 -0
  24. package/js/rte/playground/index.html +835 -0
  25. package/js/rte/rte.js +56 -5
  26. package/js/rte/run.out +17 -0
  27. package/js/rte/source.js +12 -0
  28. package/js/rte/src/browser/README.md +40 -0
  29. package/js/rte/src/browser/interactive.js +17 -0
  30. package/js/rte/src/browser/range-rect.js +82 -0
  31. package/js/rte/src/client/README.md +385 -0
  32. package/js/rte/src/client/blocks.js +38 -0
  33. package/js/rte/src/client/breaks.js +212 -0
  34. package/js/rte/src/client/classes.js +48 -0
  35. package/js/rte/src/client/editor.js +472 -0
  36. package/js/rte/src/client/external-input.js +47 -0
  37. package/js/rte/src/client/history.js +13 -0
  38. package/js/rte/src/client/images.js +285 -0
  39. package/js/rte/src/client/link.js +567 -0
  40. package/js/rte/src/client/marks.js +25 -0
  41. package/js/rte/src/client/source.js +182 -0
  42. package/js/rte/src/client/structure.js +27 -0
  43. package/js/rte/src/client/tables.js +165 -0
  44. package/js/rte/src/client/unstyle.js +32 -0
  45. package/js/rte/src/command/README.md +520 -0
  46. package/js/rte/src/command/block-boundary.js +43 -0
  47. package/js/rte/src/command/block-style.js +120 -0
  48. package/js/rte/src/command/commands.js +159 -0
  49. package/js/rte/src/command/delete.js +119 -0
  50. package/js/rte/src/command/edit.js +78 -0
  51. package/js/rte/src/command/element.js +45 -0
  52. package/js/rte/src/command/enter.js +175 -0
  53. package/js/rte/src/command/fragment.js +76 -0
  54. package/js/rte/src/command/insert.js +52 -0
  55. package/js/rte/src/command/list.js +284 -0
  56. package/js/rte/src/command/mark.js +583 -0
  57. package/js/rte/src/command/pending-marks.js +216 -0
  58. package/js/rte/src/command/table.js +207 -0
  59. package/js/rte/src/command/unstyle.js +248 -0
  60. package/js/rte/src/config/README.md +96 -0
  61. package/js/rte/src/config/config.js +134 -0
  62. package/js/rte/src/core/README.md +63 -0
  63. package/js/rte/src/core/core.js +247 -0
  64. package/js/rte/src/history/README.md +83 -0
  65. package/js/rte/src/history/history.js +283 -0
  66. package/js/rte/src/input/README.md +173 -0
  67. package/js/rte/src/input/external-input.js +130 -0
  68. package/js/rte/src/input/input-pipeline.js +459 -0
  69. package/js/rte/src/mark/README.md +219 -0
  70. package/js/rte/src/mark/dom-adapter.js +100 -0
  71. package/js/rte/src/mark/mark.js +133 -0
  72. package/js/rte/src/mark/standard.js +76 -0
  73. package/js/rte/src/model/README.md +71 -0
  74. package/js/rte/src/model/content-model.js +213 -0
  75. package/js/rte/src/model/html/README.md +51 -0
  76. package/js/rte/src/model/html/html-model.js +90 -0
  77. package/js/rte/src/normalize/execute/README.md +33 -0
  78. package/js/rte/src/normalize/execute/repair-executor.js +129 -0
  79. package/js/rte/src/normalize/normalizer/README.md +48 -0
  80. package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
  81. package/js/rte/src/normalize/repair/README.md +70 -0
  82. package/js/rte/src/normalize/repair/repair-planner.js +208 -0
  83. package/js/rte/src/sanitize/README.md +108 -0
  84. package/js/rte/src/sanitize/native.js +43 -0
  85. package/js/rte/src/sanitize/policy.js +206 -0
  86. package/js/rte/src/selection/README.md +39 -0
  87. package/js/rte/src/selection/map/README.md +53 -0
  88. package/js/rte/src/selection/map/point-map.js +307 -0
  89. package/js/rte/src/selection/ownership/README.md +32 -0
  90. package/js/rte/src/selection/ownership/ownership.js +42 -0
  91. package/js/rte/src/selection/point/README.md +34 -0
  92. package/js/rte/src/selection/point/point.js +83 -0
  93. package/js/rte/src/selection/range/README.md +38 -0
  94. package/js/rte/src/selection/range/edit-range.js +223 -0
  95. package/js/rte/src/selection/snapshot.js +64 -0
  96. package/js/rte/src/source/README.md +64 -0
  97. package/js/rte/src/source/source.js +193 -0
  98. package/js/rte/src/surface/README.md +36 -0
  99. package/js/rte/src/surface/content.js +16 -0
  100. package/js/rte/src/surface/surface.js +87 -0
  101. package/js/rte/src/transaction/README.md +35 -0
  102. package/js/rte/src/transaction/transaction.js +56 -0
  103. package/js/rte/src/ui/README.md +271 -0
  104. package/js/rte/src/ui/caret.js +17 -0
  105. package/js/rte/src/ui/chrome.js +214 -0
  106. package/js/rte/src/ui/handles.js +179 -0
  107. package/js/rte/src/ui/highlight.js +86 -0
  108. package/js/rte/src/ui/place.js +25 -0
  109. package/js/rte/src/ui/toolbar.js +258 -0
  110. package/js/rte/src/unstyle/README.md +103 -0
  111. package/js/rte/src/unstyle/unstyle.js +140 -0
  112. package/js/rte/tables.js +7 -0
  113. package/js/rte/unstyle.js +8 -0
  114. package/js/rte0/README.md +37 -0
  115. package/js/rte0/rte.js +7 -0
  116. package/js/{rte → rte0}/src/behavior.js +0 -1
  117. package/js/{rte → rte0}/src/events.js +0 -1
  118. package/js/{rte → rte0}/src/range.js +0 -2
  119. package/js/{rte → rte0}/src/ui.items.js +0 -2
  120. package/js/{rte → rte0}/src/ui.js +0 -1
  121. package/package.json +1 -1
  122. package/u2/Element.js +2 -2
  123. package/u2/plan-scoped-refactoring.md +133 -303
  124. package/u2/projects.json +18 -2
  125. package/u2/tools/update.repos.json.js +1 -1
  126. package/el/qrcode/package.json +0 -22
  127. /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
  128. /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
  129. /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
  130. /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
  131. /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
  132. /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
  133. /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
  134. /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
  135. /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
  136. /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
  137. /package/js/{rte → rte0}/old/Rte/main.css +0 -0
  138. /package/js/{rte → rte0}/old/old.rte.js +0 -0
  139. /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
  140. /package/js/{rte → rte0}/src/fixes.js +0 -0
  141. /package/js/{rte → rte0}/src/rte.js +0 -0
  142. /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
  143. /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
  144. /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
  145. /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
  146. /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,173 @@
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
+ ## Keys a host answers itself
67
+
68
+ A button answers Space and Enter by activating itself, whatever it holds: the
69
+ text inside never sees them and no `beforeinput` arrives at all. In that host,
70
+ and only there, the pipeline turns the key into the input it should have been —
71
+ `insertParagraph` for Enter, and for Space the `insert` command with a space of
72
+ its own — a non-breaking one where a plain space would collapse away, which is
73
+ what the browser itself types there. Everywhere else ordinary typing stays the
74
+ browser's own.
75
+
76
+ ## Pointing at an atomic element
77
+
78
+ An atomic element is addressable only as the selection: engines disagree about
79
+ whether pointing at one selects it, and a caret an engine leaves inside one —
80
+ clicking a rule does that in some — reaches nothing at all. A click on one
81
+ selects it, which is what makes a frame, a command, or a delete apply to it. The
82
+ host itself is left alone: a surface is not a thing inside itself.
83
+
84
+ ## Rich external input
85
+
86
+ The native path above needs no HTML parser or sanitizer adapter because RTE
87
+ never reads the payload or reinserts its string. `ExternalInput` is a separate,
88
+ optional pre-native path for applications that deliberately want to process a
89
+ rich HTML string themselves.
90
+
91
+ `ExternalInput` composes one selected sanitizer, an optional `Unstyle` policy,
92
+ and a registered mapped insertion command:
93
+
94
+ ```js
95
+ const commands = new Commands(surface, {commands: {insertFragment}});
96
+ const external = new ExternalInput(surface, {
97
+ commands,
98
+ sanitizer: new NativeSanitizer(),
99
+ unstyle: defaultUnstyle,
100
+ through: 'styles',
101
+ });
102
+ ```
103
+
104
+ For `insertFromPaste` and `insertFromDrop`, the contenteditable Input Events
105
+ contract supplies rich and plain data in `dataTransfer` plus the DOM range that
106
+ would be replaced. The adapter takes over only when `text/html` is present. It
107
+ prevents native insertion first, sanitizes into a detached `DocumentFragment`,
108
+ optionally cleans presentation cumulatively through the selected level, and
109
+ passes the result and exact target range to the registered command. Drop thus
110
+ does not accidentally use a selection left elsewhere.
111
+
112
+ `through` may be a level name, `null`, `none`, or a function receiving
113
+ `{surface, inputType}`. This leaves serializable per-surface policy resolvers to
114
+ the client layer without coupling security code to CSS. An empty sanitized
115
+ fragment changes nothing and does not delete an existing selection.
116
+
117
+ Plain text, quotation paste, uncancelable or already handled events, active
118
+ composition, plain-text hosts, and nested editing hosts stay native. Quotation
119
+ and plain-text line structure need their own contextual transformer rather than
120
+ being guessed by this HTML adapter.
121
+
122
+ `insert(html, {range, inputType})` exposes the same synchronous composition to
123
+ an HTML source dialog or another trusted event adapter. It still always calls
124
+ the configured sanitizer. A direct failure is thrown. A native event failure
125
+ remains fail-closed—the browser insertion stays prevented—and emits
126
+ `u2-rte-error` with `transaction: null` and `phase: 'external-input'`.
127
+
128
+ ## What an import goes through
129
+
130
+ A native paste or drop is cleaned in three stages, in this order:
131
+
132
+ 1. **What may exist**: the sanitize policy narrows the arrived nodes to the
133
+ allowed attributes and protocols, and then to the importable elements.
134
+ Attributes go first — removing one moves nothing, while narrowing dissolves
135
+ wrappers and would leave nothing left to clean. What the content model
136
+ already rejects is skipped and left to stage 3, which knows that dissolving
137
+ a block into inline content needs a line break to survive.
138
+ `--u2-rte-import-sanitize: none` opts a host out of the whole stage.
139
+ An element the list does not carry but whose meaning a listed one does is
140
+ replaced rather than dropped: `importAliases` maps `b` to `strong`, `i` to
141
+ `em`, and `strike` to `s`, so a strict list costs no emphasis.
142
+ 2. **What it should look like**: `Unstyle` removes presentation up to
143
+ `--u2-rte-import-unstyle`, sparing the host's declared content classes.
144
+ 3. **How it is built**: the normalizer repairs structure.
145
+
146
+ The order matters. An id removed in the first stage is exactly what lets the
147
+ third dissolve the layout wrapper that carried it.
148
+
149
+ Where to repair is decided before any of it runs: narrowing dissolves the very
150
+ nodes that say where the import landed.
151
+
152
+ ## What cleanup covers
153
+
154
+ The caret is not where pasted or dropped content is — it sits at the end of it.
155
+ Deriving the cleanup scope from the caret therefore repaired only its last block
156
+ and left the rest of a pasted document untouched, however broken. The pipeline
157
+ already tracks the nodes that arrived, so the scope is the smallest element
158
+ covering both them and the caret. That tracking is now set up for every cleaned
159
+ paste or drop rather than only when presentation cleanup is configured:
160
+ structural repair needs to know where content landed just as much.
161
+
162
+ ## TODO
163
+
164
+ - Keep a native input transaction open from cancellable `beforeinput` through
165
+ the resulting `input` for exact history snapshots.
166
+ - Derive multi-block neighborhoods from all `getTargetRanges()` entries.
167
+ - Route selected-range deletion once its command can preserve structural
168
+ boundaries and selection direction.
169
+ - Add contextual plain-text and quotation import without folding their line and
170
+ block semantics into HTML sanitizing.
171
+ - Verify trusted clipboard and drag interactions in all target engines in
172
+ addition to the deterministic event-contract suite.
173
+ - 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
+ }