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.
- package/el/ico/ico.js +5 -5
- package/el/rte/README.md +53 -13
- package/el/rte/manifest.json +19 -4
- package/el/rte/rte.css +31 -6
- package/el/rte/rte.js +174 -44
- package/el/rte0/README.md +37 -0
- package/el/rte0/manifest.json +33 -0
- package/el/rte0/rte0.css +9 -0
- package/el/rte0/rte0.js +56 -0
- package/el/system/styler.js +2 -0
- package/el/tooltip/tooltip.css +2 -2
- package/js/Placer/Placer.js +2 -1
- package/js/rte/AGENTS.md +142 -0
- package/js/rte/PLAN.md +488 -0
- package/js/rte/README.md +319 -25
- package/js/rte/blocks.js +7 -0
- package/js/rte/breaks.js +6 -0
- package/js/rte/classes.js +7 -0
- package/js/rte/docs/usage.md +845 -0
- package/js/rte/images.js +7 -0
- package/js/rte/link.js +8 -0
- package/js/rte/playground/README.md +95 -0
- package/js/rte/playground/hosts.html +518 -0
- package/js/rte/playground/index.html +824 -0
- package/js/rte/rte.js +56 -5
- package/js/rte/source.js +12 -0
- package/js/rte/src/browser/README.md +40 -0
- package/js/rte/src/browser/range-rect.js +82 -0
- package/js/rte/src/client/README.md +378 -0
- package/js/rte/src/client/blocks.js +38 -0
- package/js/rte/src/client/breaks.js +212 -0
- package/js/rte/src/client/classes.js +48 -0
- package/js/rte/src/client/editor.js +461 -0
- package/js/rte/src/client/external-input.js +47 -0
- package/js/rte/src/client/history.js +13 -0
- package/js/rte/src/client/images.js +275 -0
- package/js/rte/src/client/link.js +572 -0
- package/js/rte/src/client/marks.js +25 -0
- package/js/rte/src/client/source.js +182 -0
- package/js/rte/src/client/structure.js +27 -0
- package/js/rte/src/client/tables.js +162 -0
- package/js/rte/src/client/unstyle.js +32 -0
- package/js/rte/src/command/README.md +517 -0
- package/js/rte/src/command/block-boundary.js +43 -0
- package/js/rte/src/command/block-style.js +120 -0
- package/js/rte/src/command/commands.js +158 -0
- package/js/rte/src/command/delete.js +112 -0
- package/js/rte/src/command/edit.js +72 -0
- package/js/rte/src/command/element.js +45 -0
- package/js/rte/src/command/enter.js +175 -0
- package/js/rte/src/command/fragment.js +76 -0
- package/js/rte/src/command/insert.js +52 -0
- package/js/rte/src/command/list.js +284 -0
- package/js/rte/src/command/mark.js +583 -0
- package/js/rte/src/command/pending-marks.js +216 -0
- package/js/rte/src/command/table.js +207 -0
- package/js/rte/src/command/unstyle.js +248 -0
- package/js/rte/src/config/README.md +96 -0
- package/js/rte/src/config/config.js +127 -0
- package/js/rte/src/core/README.md +41 -0
- package/js/rte/src/core/core.js +156 -0
- package/js/rte/src/history/README.md +83 -0
- package/js/rte/src/history/history.js +283 -0
- package/js/rte/src/input/README.md +155 -0
- package/js/rte/src/input/external-input.js +130 -0
- package/js/rte/src/input/input-pipeline.js +414 -0
- package/js/rte/src/mark/README.md +219 -0
- package/js/rte/src/mark/dom-adapter.js +100 -0
- package/js/rte/src/mark/mark.js +133 -0
- package/js/rte/src/mark/standard.js +76 -0
- package/js/rte/src/model/README.md +71 -0
- package/js/rte/src/model/content-model.js +213 -0
- package/js/rte/src/model/html/README.md +51 -0
- package/js/rte/src/model/html/html-model.js +90 -0
- package/js/rte/src/normalize/execute/README.md +33 -0
- package/js/rte/src/normalize/execute/repair-executor.js +129 -0
- package/js/rte/src/normalize/normalizer/README.md +48 -0
- package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
- package/js/rte/src/normalize/repair/README.md +70 -0
- package/js/rte/src/normalize/repair/repair-planner.js +208 -0
- package/js/rte/src/sanitize/README.md +105 -0
- package/js/rte/src/sanitize/native.js +43 -0
- package/js/rte/src/sanitize/policy.js +202 -0
- package/js/rte/src/selection/README.md +39 -0
- package/js/rte/src/selection/map/README.md +53 -0
- package/js/rte/src/selection/map/point-map.js +307 -0
- package/js/rte/src/selection/ownership/README.md +32 -0
- package/js/rte/src/selection/ownership/ownership.js +42 -0
- package/js/rte/src/selection/point/README.md +34 -0
- package/js/rte/src/selection/point/point.js +83 -0
- package/js/rte/src/selection/range/README.md +38 -0
- package/js/rte/src/selection/range/edit-range.js +223 -0
- package/js/rte/src/selection/snapshot.js +64 -0
- package/js/rte/src/source/README.md +64 -0
- package/js/rte/src/source/source.js +193 -0
- package/js/rte/src/surface/README.md +36 -0
- package/js/rte/src/surface/content.js +16 -0
- package/js/rte/src/surface/surface.js +87 -0
- package/js/rte/src/transaction/README.md +35 -0
- package/js/rte/src/transaction/transaction.js +56 -0
- package/js/rte/src/ui/README.md +255 -0
- package/js/rte/src/ui/chrome.js +214 -0
- package/js/rte/src/ui/handles.js +179 -0
- package/js/rte/src/ui/place.js +25 -0
- package/js/rte/src/ui/toolbar.js +283 -0
- package/js/rte/src/unstyle/README.md +103 -0
- package/js/rte/src/unstyle/unstyle.js +140 -0
- package/js/rte/tables.js +7 -0
- package/js/rte/unstyle.js +8 -0
- package/js/rte0/README.md +37 -0
- package/js/rte0/rte.js +7 -0
- package/js/{rte → rte0}/src/behavior.js +0 -1
- package/js/{rte → rte0}/src/events.js +0 -1
- package/js/{rte → rte0}/src/range.js +0 -2
- package/js/{rte → rte0}/src/ui.items.js +0 -2
- package/js/{rte → rte0}/src/ui.js +0 -1
- package/package.json +1 -1
- package/u2/Element.js +16 -4
- package/u2/enhance.js +2 -1
- package/u2/plan-scoped-refactoring.md +133 -304
- package/u2/projects.json +18 -2
- package/u2/tools/update.repos.json.js +1 -1
- package/u2/utils.js +0 -16
- package/el/qrcode/package.json +0 -22
- /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
- /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/main.css +0 -0
- /package/js/{rte → rte0}/old/old.rte.js +0 -0
- /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
- /package/js/{rte → rte0}/src/fixes.js +0 -0
- /package/js/{rte → rte0}/src/rte.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
- /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
- /package/js/{rte → rte0}/src/util/indentHtml.js +0 -0
|
@@ -0,0 +1,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.
|