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,414 @@
1
+ import {htmlModel} from '../model/html/html-model.js';
2
+ import {narrow} from '../command/edit.js';
3
+ import {Normalizer} from '../normalize/normalizer/normalizer.js';
4
+ import {PointMap} from '../selection/map/point-map.js';
5
+ import {EditRange} from '../selection/range/edit-range.js';
6
+ import {editingHost, isPlainTextHost} from '../selection/ownership/ownership.js';
7
+ import {Point} from '../selection/point/point.js';
8
+ import {SelectionSnapshot} from '../selection/snapshot.js';
9
+ import {defaultUnstyle} from '../unstyle/unstyle.js';
10
+ import {sanitizePolicy} from '../sanitize/policy.js';
11
+
12
+ const TRIGGERS = new Set(['input', 'paste', 'drop', 'command']);
13
+ const PASTE = new Set(['insertFromPaste', 'insertFromPasteAsQuotation']);
14
+ // An element a strict import list does not carry, but whose meaning a listed one
15
+ // does. Dropping `<b>` would lose the emphasis; `<strong>` keeps it, and the
16
+ // bold mark already treats the two as the same thing.
17
+ export const importAliases = Object.freeze({b: 'strong', i: 'em', strike: 's'});
18
+
19
+ const DELETE_KEYS = new Map([
20
+ ['Backspace', 'deleteContentBackward'],
21
+ ['Delete', 'deleteContentForward'],
22
+ ]);
23
+
24
+ export class InputPipeline {
25
+ #surface;
26
+ #root;
27
+ #model;
28
+ #commands;
29
+ #unstyle;
30
+ #sanitize;
31
+ #aliases;
32
+ #controller;
33
+ #pending = null;
34
+ #source = null;
35
+ #deferred = null;
36
+ #composing = false;
37
+ #connected = true;
38
+
39
+ constructor(surface, {model = htmlModel, commands = null, unstyle = defaultUnstyle,
40
+ sanitize = sanitizePolicy, aliases = importAliases} = {}) {
41
+ const root = surface?.element;
42
+ if (root?.nodeType !== Node.ELEMENT_NODE || typeof surface?.transact !== 'function') {
43
+ throw new TypeError('An input pipeline requires an editor surface');
44
+ }
45
+ if (typeof model?.block !== 'function' || typeof model?.allows !== 'function'
46
+ || typeof model?.allowed !== 'function') {
47
+ throw new TypeError('An input pipeline requires a content model');
48
+ }
49
+ if (commands !== null && (typeof commands?.input !== 'function' || typeof commands?.run !== 'function')) {
50
+ throw new TypeError('An input pipeline requires a command registry');
51
+ }
52
+ if (unstyle !== null && typeof unstyle?.clean !== 'function') {
53
+ throw new TypeError('An input pipeline requires an Unstyle policy or null');
54
+ }
55
+ if (sanitize !== null && typeof sanitize?.clean !== 'function') {
56
+ throw new TypeError('An input pipeline requires a sanitize policy or null');
57
+ }
58
+ this.#surface = surface;
59
+ this.#root = root;
60
+ this.#model = model;
61
+ this.#commands = commands;
62
+ this.#unstyle = unstyle;
63
+ this.#sanitize = sanitize;
64
+ this.#aliases = aliases;
65
+ this.#controller = new root.ownerDocument.defaultView.AbortController();
66
+ const listen = {signal: this.#controller.signal};
67
+ root.addEventListener('beforeinput', this.#beforeInput, listen);
68
+ root.addEventListener('keydown', this.#keyDown, listen);
69
+ root.addEventListener('input', this.#input, listen);
70
+ root.addEventListener('compositionstart', this.#compositionStart, listen);
71
+ root.addEventListener('compositionend', this.#compositionEnd, listen);
72
+ root.addEventListener('paste', this.#paste, listen);
73
+ root.addEventListener('drop', this.#drop, listen);
74
+ surface.addEventListener('u2-rte-command', this.#command, listen);
75
+ surface.addEventListener('u2-rte-disconnect', this.#disconnect, listen);
76
+ }
77
+
78
+ get surface() { return this.#surface; }
79
+ get root() { return this.#root; }
80
+ get commands() { return this.#commands; }
81
+ get connected() { return this.#connected; }
82
+ get composing() { return this.#composing; }
83
+
84
+ normalize(trigger = 'command', {scope = null, range = null, inputType = '', imported = null} = {}) {
85
+ if (!this.#connected) throw new DOMException('The input pipeline is disconnected', 'InvalidStateError');
86
+ if (!TRIGGERS.has(trigger)) throw new TypeError('Unknown normalization trigger');
87
+ const settings = this.#surface.config;
88
+ if (!settings.cleanOn.includes(trigger)) return null;
89
+
90
+ const model = this.#commands?.model || narrow(this.#model, settings.elements);
91
+ const normalizer = new Normalizer(this.#root, {
92
+ model,
93
+ block: settings.block,
94
+ level: settings.cleanup,
95
+ });
96
+ const snapshot = this.#surface.capture();
97
+ const initial = snapshot ? rangePoints(snapshot.range()) : [];
98
+ const result = this.#surface.transact(transaction => {
99
+ let points = initial;
100
+ // Where to repair is decided before anything moves: narrowing
101
+ // dissolves the very nodes that say where the import landed.
102
+ const reach = () => points.length
103
+ ? EditRange.fromPoints(points[0], points.at(-1), this.#root).range()
104
+ : range || selectionRange(this.#surface);
105
+ let target = scope || normalizationScope(affected(reach(), imported, this.#root), this.#root, normalizer);
106
+ // What may exist at all is decided before what it should look like.
107
+ // A native paste is the one import the browser inserts itself, so
108
+ // this is where the policy reaches it: elements first, because
109
+ // unwrapping moves nodes, then attributes.
110
+ if (this.#sanitize && settings.importSanitize === 'policy' && imported?.roots.length) {
111
+ // Attributes first: removing them moves nothing, while narrowing
112
+ // dissolves wrappers and would leave nothing left to clean.
113
+ for (const root of imported.roots) {
114
+ this.#sanitize.clean(root, {
115
+ preserve: imported.preserve,
116
+ base: this.#root.ownerDocument.baseURI,
117
+ classes: settings.classes.length ? settings.classes : null,
118
+ });
119
+ }
120
+ const map = new PointMap(points);
121
+ for (const root of imported.roots) {
122
+ this.#sanitize.narrow(root, {
123
+ map,
124
+ preserve: imported.preserve,
125
+ elements: importable(settings),
126
+ alias: this.#aliases,
127
+ // What the model already rejects is structural repair's
128
+ // job: it dissolves a block without joining two lines.
129
+ skip: element => !model.allows(element.parentNode, element),
130
+ });
131
+ }
132
+ points = points.map(point => map.get(point));
133
+ }
134
+ const unstyled = [];
135
+ if (this.#unstyle && settings.importUnstyle !== 'none' && imported?.roots.length) {
136
+ const map = new PointMap(points);
137
+ for (const root of imported.roots) {
138
+ unstyled.push(...this.#unstyle.clean(root, {
139
+ through: settings.importUnstyle,
140
+ map,
141
+ transaction,
142
+ preserve: imported.preserve,
143
+ keep: settings.classes,
144
+ }));
145
+ }
146
+ points = points.map(point => map.get(point));
147
+ }
148
+ if (target !== this.#root && !this.#root.contains(target)) target = this.#root;
149
+ const normalized = normalizer.normalize({scope: target, points, transaction});
150
+ if (snapshot) EditRange.fromPoints(normalized.map.get(points[0]), normalized.map.get(points.at(-1)), this.#root)
151
+ .select(this.#surface.core.selection, snapshot.backward);
152
+ return unstyled.length
153
+ ? Object.freeze({...normalized, unstyled: Object.freeze(unstyled)})
154
+ : normalized;
155
+ }, {trigger, inputType});
156
+ if (result) this.#surface.emit('u2-rte-normalize', {trigger, inputType, result});
157
+ return result || null;
158
+ }
159
+
160
+ dispose() {
161
+ if (!this.#connected) return;
162
+ this.#controller.abort();
163
+ this.#clearPending();
164
+ this.#source = null;
165
+ this.#deferred = null;
166
+ this.#composing = false;
167
+ this.#connected = false;
168
+ }
169
+
170
+ [Symbol.dispose]() {
171
+ this.dispose();
172
+ }
173
+
174
+ #beforeInput = event => {
175
+ if (!this.#owns(event)) return;
176
+ this.#surface.capture();
177
+ if (event.defaultPrevented) return;
178
+ if (this.#route(event)) return;
179
+ this.#clearPending();
180
+ const pending = {
181
+ inputType: event.inputType || '',
182
+ range: inputRange(event, this.#surface),
183
+ trigger: this.#source || inputTrigger(event.inputType),
184
+ };
185
+ this.#observe(pending);
186
+ this.#pending = pending;
187
+ this.#source = null;
188
+ queueMicrotask(() => {
189
+ if (this.#pending === pending) this.#clearPending();
190
+ });
191
+ };
192
+
193
+ #keyDown = event => {
194
+ if (!this.#owns(event)) return;
195
+ const shortcut = this.#commands?.shortcut(event);
196
+ if (shortcut) {
197
+ this.#surface.capture();
198
+ if (this.#invoke(event, shortcut, {range: selectionRange(this.#surface)})) return;
199
+ }
200
+ if (event.altKey) return;
201
+ const inputType = keyInput(event);
202
+ if (!inputType) return;
203
+ this.#surface.capture();
204
+ this.#route(event, {
205
+ inputType,
206
+ data: null,
207
+ range: selectionRange(this.#surface),
208
+ });
209
+ };
210
+
211
+ #input = event => {
212
+ if (!this.#owns(event)) return;
213
+ const pending = this.#pending;
214
+ const inputType = event.inputType || pending?.inputType || '';
215
+ const job = {
216
+ inputType,
217
+ range: pending?.range || inputRange(event, this.#surface),
218
+ trigger: pending?.trigger || this.#source || inputTrigger(inputType),
219
+ imported: takeImports(pending, this.#root),
220
+ };
221
+ this.#clearPending();
222
+ this.#source = null;
223
+ if (this.#composing || event.isComposing) {
224
+ this.#deferred = job;
225
+ return;
226
+ }
227
+ this.#deferred = null;
228
+ this.normalize(job.trigger, job);
229
+ };
230
+
231
+ #compositionStart = event => {
232
+ if (!this.#owns(event)) return;
233
+ this.#composing = true;
234
+ this.#deferred = null;
235
+ };
236
+
237
+ #compositionEnd = event => {
238
+ if (!this.#owns(event)) return;
239
+ this.#composing = false;
240
+ queueMicrotask(() => {
241
+ if (!this.#connected || this.#composing || !this.#deferred) return;
242
+ const job = this.#deferred;
243
+ this.#deferred = null;
244
+ this.normalize(job.trigger, job);
245
+ });
246
+ };
247
+
248
+ #paste = event => {
249
+ if (this.#owns(event)) this.#rememberSource('paste');
250
+ };
251
+
252
+ #drop = event => {
253
+ if (this.#owns(event)) this.#rememberSource('drop');
254
+ };
255
+
256
+ #command = event => {
257
+ if (!event.detail.transaction) return;
258
+ this.normalize('command', {inputType: event.detail.inputType || ''});
259
+ };
260
+
261
+ #disconnect = () => {
262
+ this.dispose();
263
+ };
264
+
265
+ // Native editing that cannot be interoperable is prevented and replaced by
266
+ // the registered command. Everything else keeps its native behavior and is
267
+ // repaired afterwards.
268
+ #route(event, detail = {
269
+ inputType: event.inputType,
270
+ data: event.data,
271
+ range: inputRange(event, this.#surface),
272
+ }) {
273
+ const name = this.#commands?.input(detail.inputType);
274
+ return !!name && this.#invoke(event, name, detail);
275
+ }
276
+
277
+ // A command replaces the native event only where it is actually available;
278
+ // otherwise the key keeps its own meaning, which is what lets Tab go on
279
+ // moving focus outside a list.
280
+ #invoke(event, name, detail) {
281
+ if (!event.cancelable || event.defaultPrevented || this.#composing
282
+ || event.isComposing || isPlainTextHost(this.#root)) return false;
283
+ if (!this.#commands.enabled(name, detail)) return false;
284
+ event.preventDefault();
285
+ this.#clearPending();
286
+ this.#source = null;
287
+ this.#commands.run(name, detail);
288
+ return true;
289
+ }
290
+
291
+ #rememberSource(trigger) {
292
+ this.#source = trigger;
293
+ queueMicrotask(() => {
294
+ if (this.#source === trigger) this.#source = null;
295
+ });
296
+ }
297
+
298
+ // What arrived is tracked for every cleaned paste or drop, not only when
299
+ // presentation cleanup is configured: structural repair needs to know where
300
+ // the content landed just as much.
301
+ #observe(pending) {
302
+ const settings = this.#surface.config;
303
+ if (!['paste', 'drop'].includes(pending.trigger) || !settings.cleanOn.includes(pending.trigger)) return;
304
+ const records = [];
305
+ const Observer = this.#root.ownerDocument.defaultView.MutationObserver;
306
+ const observer = new Observer(next => records.push(...next));
307
+ observer.observe(this.#root, {childList: true, subtree: true});
308
+ pending.import = {
309
+ observer,
310
+ records,
311
+ preserve: new Set(this.#root.querySelectorAll('*')),
312
+ };
313
+ }
314
+
315
+ #clearPending() {
316
+ this.#pending?.import?.observer.disconnect();
317
+ this.#pending = null;
318
+ }
319
+
320
+ #owns(event) {
321
+ const target = event.composedPath()[0];
322
+ return target === this.#root || editingHost(target) === this.#root;
323
+ }
324
+ }
325
+
326
+ function keyInput(event) {
327
+ if (event.ctrlKey || event.metaKey || event.shiftKey) return null;
328
+ return DELETE_KEYS.get(event.key) || null;
329
+ }
330
+
331
+ export function inputTrigger(inputType = '') {
332
+ if (PASTE.has(inputType)) return 'paste';
333
+ if (inputType === 'insertFromDrop') return 'drop';
334
+ return 'input';
335
+ }
336
+
337
+ // Pasted or dropped content is not where the caret ends up — the caret sits at
338
+ // the end of it. Cleanup has to cover what actually arrived, or a pasted
339
+ // document is only ever repaired in its last block.
340
+ function affected(range, imported, root) {
341
+ let common = range?.commonAncestorContainer || null;
342
+ for (const node of imported?.roots || []) {
343
+ if (!root.contains(node)) continue;
344
+ common = common ? shared(common, node, root) : node;
345
+ }
346
+ return common;
347
+ }
348
+
349
+ function shared(node, other, root) {
350
+ let current = node;
351
+ while (current && current !== root && current !== other && !current.contains(other)) {
352
+ current = current.parentNode;
353
+ }
354
+ return current || root;
355
+ }
356
+
357
+ // What may arrive is the import policy bounded by what the host tolerates at
358
+ // all; either alone may be open.
359
+ function importable(settings) {
360
+ if (settings.importElements === null) return settings.elements;
361
+ if (settings.elements === null) return settings.importElements;
362
+ return settings.importElements.filter(name => settings.elements.includes(name));
363
+ }
364
+
365
+ function normalizationScope(node, root, normalizer) {
366
+ let element = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
367
+ if (!element || (element !== root && !root.contains(element))) return root;
368
+ while (element !== root && !normalizer.planner.model.block(element)) element = element.parentElement;
369
+ if (element === root) return root;
370
+ const parent = element.parentElement;
371
+ return normalizer.planner.plan(parent, element).type === 'keep' ? element : parent;
372
+ }
373
+
374
+ export function inputRange(event, surface) {
375
+ const target = event.getTargetRanges?.()[0];
376
+ if (!target) return selectionRange(surface);
377
+ try {
378
+ const range = surface.element.ownerDocument.createRange();
379
+ range.setStart(target.startContainer, target.startOffset);
380
+ range.setEnd(target.endContainer, target.endOffset);
381
+ return surface.element.contains(range.commonAncestorContainer) ? range : null;
382
+ } catch {
383
+ return null;
384
+ }
385
+ }
386
+
387
+ function selectionRange(surface) {
388
+ return SelectionSnapshot.capture(surface.core.selection, surface.element)?.range() || null;
389
+ }
390
+
391
+ function rangePoints(range) {
392
+ const start = Point.fromRange(range, 'start');
393
+ return range.collapsed ? [start] : [start, Point.fromRange(range, 'end')];
394
+ }
395
+
396
+ function takeImports(pending, root) {
397
+ if (!pending?.import) return null;
398
+ const {observer, records, preserve} = pending.import;
399
+ records.push(...observer.takeRecords());
400
+ const roots = importRoots(records.flatMap(record => [...record.addedNodes]), root, preserve);
401
+ return roots.length ? {roots, preserve} : null;
402
+ }
403
+
404
+ function importRoots(nodes, root, preserve) {
405
+ const elements = new Set();
406
+ for (const node of nodes) {
407
+ if (node.nodeType !== Node.ELEMENT_NODE || !root.contains(node)) continue;
408
+ if (!preserve.has(node)) elements.add(node);
409
+ for (const child of node.querySelectorAll('*')) if (!preserve.has(child)) elements.add(child);
410
+ }
411
+ const added = [...elements];
412
+ return added.filter(node => !added.some(parent => parent !== node && parent.contains(node)));
413
+ }
414
+
@@ -0,0 +1,219 @@
1
+ # Text formatting marks
2
+
3
+ A mark is formatting attached to text. Bold is a mark. A link is a mark with a
4
+ URL. A text color is a mark with a color value. Several marks can apply to the
5
+ same text, for example bold plus a link.
6
+
7
+ `mark.js` represents this formatting as immutable data. It answers the small,
8
+ shared questions that every formatting command has:
9
+
10
+ - Are two marks the same?
11
+ - May they exist together?
12
+ - Does a new mark replace an old one?
13
+ - In which stable order are several marks kept?
14
+
15
+ `dom-adapter.js` decides whether a mark is represented by `<strong>`, a class,
16
+ an attribute, a style, or something custom. `standard.js` provides the compact
17
+ default HTML policies for bold, italic, underline, strike, code, and links. The
18
+ range commands apply or remove those representations on selected text.
19
+
20
+ ## Public contract
21
+
22
+ `MarkType` describes one kind of formatting. `bold`, `link`, and `color` are
23
+ different types. One type may have several concrete values: `color` can be
24
+ `red` or `blue`, and `link` can contain different URLs.
25
+
26
+ `new MarkType(name, options)` accepts:
27
+
28
+ - `rank` controls the stable order when several marks apply. Lower ranks come
29
+ first. The default is `50`.
30
+ - `excludes` names the types removed when this type is added. By default a type
31
+ replaces its own previous value, so blue replaces red. `[]` allows several
32
+ values of that type at once; `['*']` replaces every mark.
33
+ - `create(value)` snapshots serializable value data and returns a `Mark`. The
34
+ default value is `true`, which suits boolean marks such as bold.
35
+ - `remove(marks)` removes every mark of exactly that type.
36
+
37
+ A `Mark` is one concrete piece of formatting, such as `bold: true`,
38
+ `color: "red"`, or `link: {href: "/docs"}`. A mark array describes all
39
+ formatting at one text position.
40
+
41
+ - `equals(mark)` compares type identity and canonical value data.
42
+ - `conflicts(mark)` reports exclusion in either direction.
43
+ - `add(marks)` returns a new frozen array in stable order. Adding the same mark
44
+ changes nothing. A new mark removes types it excludes; an existing mark may
45
+ block it through a one-way exclusion.
46
+ - `remove(marks)` removes only that exact type and value.
47
+
48
+ `markSet(marks)` folds a complete array through those same rules and returns its
49
+ frozen canonical set. Equivalent values collapse, one-way exclusions always
50
+ favor the excluding type, and the later value wins when two marks exclude each
51
+ other.
52
+
53
+ Values may contain strings, finite numbers, booleans, `null`, arrays, and plain
54
+ objects. Objects are copied with sorted keys and all copied containers are
55
+ frozen. The same link therefore compares equal even when its object keys were
56
+ written in a different order. Functions, DOM nodes, cyclic data, and other
57
+ stateful objects are not mark values.
58
+
59
+ ```js
60
+ const bold = new MarkType('bold', {rank: 10});
61
+ const color = new MarkType('color');
62
+
63
+ let marks = bold.create().add([]);
64
+ marks = color.create('red').add(marks);
65
+ marks = color.create('blue').add(marks); // replaces red
66
+
67
+ // marks now means: bold text with the color blue
68
+ ```
69
+
70
+ ## DOM adapter
71
+
72
+ A `MarkAdapter` translates between formatting data and HTML. Reading recognizes
73
+ existing elements; rendering creates the empty canonical wrapper used for bare
74
+ or partially selected text.
75
+
76
+ ```js
77
+ const boldHtml = new MarkAdapter(bold, {
78
+ selector: 'strong, b', // both existing forms mean bold
79
+ tag: 'strong', // new bold text uses <strong>
80
+ });
81
+
82
+ boldHtml.parse(document.querySelector('b')); // a bold Mark
83
+ boldHtml.render(bold.create(), document); // an empty <strong>
84
+ ```
85
+
86
+ `selector` selects possible source elements. `read(element)` returns their mark
87
+ value or `undefined` when a selected element does not represent the mark.
88
+ `tag` plus `write(element, value)` creates the canonical wrapper. `write` can
89
+ also decorate a reusable existing element. `clear(element, value)` removes only
90
+ this mark and leaves its other classes or attributes alone. Returning `true`
91
+ additionally asks the command to remove the representation wrapper. A bare
92
+ wrapper is unwrapped; remaining attributes are retained on a neutral `span`. A
93
+ custom `render(document, value)` may replace `tag` for custom elements or other
94
+ application policies.
95
+
96
+ The same contract handles semantic elements, classes, HTML attributes, and CSS
97
+ declarations because their `read`, `write`, and `clear` functions differ while
98
+ the mark value and command logic stay the same. Rendered wrappers must be empty,
99
+ detached, and owned by the requested document.
100
+
101
+ `reuse` is `false` by default. Set it to `true` to allow every suitable,
102
+ fully-selected phrasing element, or provide a function to restrict reuse
103
+ further. The range command still excludes blocks, atomic elements, the editing
104
+ host, and nested editable boundaries.
105
+
106
+ ```js
107
+ const x = new MarkType('x');
108
+ const xHtml = new MarkAdapter(x, {
109
+ selector: '.x',
110
+ tag: 'span',
111
+ reuse: true,
112
+ write: element => element.classList.add('x'),
113
+ clear: element => element.classList.remove('x'),
114
+ });
115
+ ```
116
+
117
+ With that adapter, selecting `llo` in `hello` creates
118
+ `he<span class="x">llo</span>`. Selecting a complete `<b>dear</b>` reuses it as
119
+ `<b class="x">dear</b>`. Removal keeps the `<b>` and only unwraps a `span` when
120
+ clearing the mark leaves it without attributes.
121
+
122
+ ## Standard HTML marks
123
+
124
+ ```js
125
+ import {bold, boldHtml, italicHtml, link, linkHtml} from './rte.js';
126
+
127
+ boldHtml.parse(document.querySelector('b')); // bold.create()
128
+ boldHtml.render(bold.create(), document); // <strong>
129
+ italicHtml.render(italicHtml.type.create(), document); // <em>
130
+ linkHtml.render(link.create({href: '/docs'}), document); // <a href="/docs">
131
+ ```
132
+
133
+ Each exported type has one replaceable default adapter:
134
+
135
+ | Type | Accepted HTML | Canonical HTML |
136
+ | --- | --- | --- |
137
+ | `bold` | `strong`, `b` | `strong` |
138
+ | `italic` | `em`, `i` | `em` |
139
+ | `underline` | `u` | `u` |
140
+ | `strike` | `s`, `strike` | `s` |
141
+ | `code` | `code` | `code` |
142
+ | `link` | `a[href]` | `a` |
143
+
144
+ Boolean marks use the default value `true`. A link uses
145
+ `{href, target?, rel?, title?}` with string values; other keys are rejected so
146
+ distinct mark values cannot silently render as the same HTML. The adapter does
147
+ not impose a URL-scheme policy. Applications must validate command input, while
148
+ external HTML remains the sanitizer's responsibility.
149
+
150
+ Removing a bare semantic alias unwraps it. Unrelated attributes survive on a
151
+ neutral `span`; replacing part of an attributed link therefore neither loses
152
+ those attributes nor nests anchors.
153
+
154
+ Applying a mark joins adjacent canonical wrappers produced by that adapter, so
155
+ two neighboring `<span class="x">` elements become one. A wrapper carrying
156
+ additional classes, attributes, or tag semantics is preserved: the adapter
157
+ cannot assume that those mean the same thing as its mark.
158
+
159
+ Canonical wrappers are also normalized across nested runs. Redundant nested
160
+ copies are removed. A complete set orders single-child canonical wrappers by
161
+ the set's stable rank, then repeatedly joins siblings exposed by that ordering.
162
+ For example, opposite `bold/color` nesting on neighboring runs becomes one
163
+ `bold` wrapper containing one `color` wrapper. Reordering happens only when the
164
+ content model accepts both resulting relationships. A wrapper with additional
165
+ attributes is meaningful and remains a boundary.
166
+
167
+ Range commands expose `true`, `false`, or `'mixed'` state for selected editable
168
+ text. At a caret they report whether its structural DOM position is inside the
169
+ mark; a boundary outside a wrapper stays outside. Atomic elements and nested
170
+ editable hosts are ignored because the same commands cannot format their
171
+ contents. Toggle removes a fully active mark and applies it across inactive or
172
+ mixed selections.
173
+
174
+ `setMarks(adapters)` creates one value-bearing command for a closed adapter
175
+ universe. Running it with `{value: marks}` replaces exactly those configured
176
+ marks over the selection: absent values are removed, supplied values are
177
+ canonicalized with `markSet()`, and the resulting set is applied in stable rank
178
+ order within one mapped operation. Marks owned by unlisted adapters remain
179
+ untouched. Every configured adapter needs a clear policy, and one adapter owns
180
+ each type.
181
+
182
+ Its state is the current canonical mark array, `'mixed'` when selected text has
183
+ different sets, or `null` when no editable text contributes. At a caret it
184
+ returns the structural array but, like the individual range commands, stays
185
+ disabled.
186
+
187
+ `PendingMarks` can extend those commands at a caret. It stores only an explicit
188
+ override for one surface and replaces the next `insertText`; afterward the DOM
189
+ it created carries the mark and native input resumes. IME input remains native
190
+ throughout composition; the composed range receives the same override only
191
+ after `compositionend`.
192
+
193
+ ## Invariants
194
+
195
+ - Type identity decides equality and removal. Type names deliberately connect
196
+ exclusion rules.
197
+ - Set operations never mutate their source and preserve its identity for a
198
+ no-op.
199
+ - Equivalent non-conflicting inputs produce the same canonical set order.
200
+ - Exclusion is directional policy; conflict reporting is symmetric.
201
+ - Complete sets resolve policy conflicts before mutating the DOM.
202
+ - Canonical DOM order follows canonical set order without crossing meaningful,
203
+ atomic, or nested-editable boundaries.
204
+ - The algebra contains no editor, document, selection, or module-global mutable
205
+ state. DOM mutation belongs to the adapter and range commands.
206
+ - Ready-made types and adapters are immutable shared defaults; applications may
207
+ replace them with their own policy.
208
+
209
+ ## Browser considerations
210
+
211
+ The algebra uses standard JavaScript data. Adapters and range commands use the
212
+ standard DOM, `Range`, and `Selection` APIs without an engine-specific branch.
213
+ The shared real-browser suite covers boundary splitting, multi-block ranges,
214
+ backward selections, atomic elements, nested editors, reuse, and cleanup.
215
+
216
+ ## TODO
217
+
218
+ - Add a default link-control module only after URL entry, validation, and UI
219
+ ownership have an explicit application-facing contract.