u2ui 1.5.3 → 1.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/el/ico/ico.js +5 -5
- package/el/rte/README.md +53 -13
- package/el/rte/manifest.json +19 -4
- package/el/rte/rte.css +31 -6
- package/el/rte/rte.js +174 -44
- package/el/rte0/README.md +37 -0
- package/el/rte0/manifest.json +33 -0
- package/el/rte0/rte0.css +9 -0
- package/el/rte0/rte0.js +56 -0
- package/el/system/styler.js +2 -0
- package/el/tooltip/tooltip.css +2 -2
- package/js/Placer/Placer.js +2 -1
- package/js/rte/AGENTS.md +142 -0
- package/js/rte/PLAN.md +488 -0
- package/js/rte/README.md +319 -25
- package/js/rte/blocks.js +7 -0
- package/js/rte/breaks.js +6 -0
- package/js/rte/classes.js +7 -0
- package/js/rte/docs/usage.md +845 -0
- package/js/rte/images.js +7 -0
- package/js/rte/link.js +8 -0
- package/js/rte/playground/README.md +95 -0
- package/js/rte/playground/hosts.html +518 -0
- package/js/rte/playground/index.html +824 -0
- package/js/rte/rte.js +56 -5
- package/js/rte/source.js +12 -0
- package/js/rte/src/browser/README.md +40 -0
- package/js/rte/src/browser/range-rect.js +82 -0
- package/js/rte/src/client/README.md +378 -0
- package/js/rte/src/client/blocks.js +38 -0
- package/js/rte/src/client/breaks.js +212 -0
- package/js/rte/src/client/classes.js +48 -0
- package/js/rte/src/client/editor.js +461 -0
- package/js/rte/src/client/external-input.js +47 -0
- package/js/rte/src/client/history.js +13 -0
- package/js/rte/src/client/images.js +275 -0
- package/js/rte/src/client/link.js +572 -0
- package/js/rte/src/client/marks.js +25 -0
- package/js/rte/src/client/source.js +182 -0
- package/js/rte/src/client/structure.js +27 -0
- package/js/rte/src/client/tables.js +162 -0
- package/js/rte/src/client/unstyle.js +32 -0
- package/js/rte/src/command/README.md +517 -0
- package/js/rte/src/command/block-boundary.js +43 -0
- package/js/rte/src/command/block-style.js +120 -0
- package/js/rte/src/command/commands.js +158 -0
- package/js/rte/src/command/delete.js +112 -0
- package/js/rte/src/command/edit.js +72 -0
- package/js/rte/src/command/element.js +45 -0
- package/js/rte/src/command/enter.js +175 -0
- package/js/rte/src/command/fragment.js +76 -0
- package/js/rte/src/command/insert.js +52 -0
- package/js/rte/src/command/list.js +284 -0
- package/js/rte/src/command/mark.js +583 -0
- package/js/rte/src/command/pending-marks.js +216 -0
- package/js/rte/src/command/table.js +207 -0
- package/js/rte/src/command/unstyle.js +248 -0
- package/js/rte/src/config/README.md +96 -0
- package/js/rte/src/config/config.js +127 -0
- package/js/rte/src/core/README.md +41 -0
- package/js/rte/src/core/core.js +156 -0
- package/js/rte/src/history/README.md +83 -0
- package/js/rte/src/history/history.js +283 -0
- package/js/rte/src/input/README.md +155 -0
- package/js/rte/src/input/external-input.js +130 -0
- package/js/rte/src/input/input-pipeline.js +414 -0
- package/js/rte/src/mark/README.md +219 -0
- package/js/rte/src/mark/dom-adapter.js +100 -0
- package/js/rte/src/mark/mark.js +133 -0
- package/js/rte/src/mark/standard.js +76 -0
- package/js/rte/src/model/README.md +71 -0
- package/js/rte/src/model/content-model.js +213 -0
- package/js/rte/src/model/html/README.md +51 -0
- package/js/rte/src/model/html/html-model.js +90 -0
- package/js/rte/src/normalize/execute/README.md +33 -0
- package/js/rte/src/normalize/execute/repair-executor.js +129 -0
- package/js/rte/src/normalize/normalizer/README.md +48 -0
- package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
- package/js/rte/src/normalize/repair/README.md +70 -0
- package/js/rte/src/normalize/repair/repair-planner.js +208 -0
- package/js/rte/src/sanitize/README.md +105 -0
- package/js/rte/src/sanitize/native.js +43 -0
- package/js/rte/src/sanitize/policy.js +202 -0
- package/js/rte/src/selection/README.md +39 -0
- package/js/rte/src/selection/map/README.md +53 -0
- package/js/rte/src/selection/map/point-map.js +307 -0
- package/js/rte/src/selection/ownership/README.md +32 -0
- package/js/rte/src/selection/ownership/ownership.js +42 -0
- package/js/rte/src/selection/point/README.md +34 -0
- package/js/rte/src/selection/point/point.js +83 -0
- package/js/rte/src/selection/range/README.md +38 -0
- package/js/rte/src/selection/range/edit-range.js +223 -0
- package/js/rte/src/selection/snapshot.js +64 -0
- package/js/rte/src/source/README.md +64 -0
- package/js/rte/src/source/source.js +193 -0
- package/js/rte/src/surface/README.md +36 -0
- package/js/rte/src/surface/content.js +16 -0
- package/js/rte/src/surface/surface.js +87 -0
- package/js/rte/src/transaction/README.md +35 -0
- package/js/rte/src/transaction/transaction.js +56 -0
- package/js/rte/src/ui/README.md +255 -0
- package/js/rte/src/ui/chrome.js +214 -0
- package/js/rte/src/ui/handles.js +179 -0
- package/js/rte/src/ui/place.js +25 -0
- package/js/rte/src/ui/toolbar.js +283 -0
- package/js/rte/src/unstyle/README.md +103 -0
- package/js/rte/src/unstyle/unstyle.js +140 -0
- package/js/rte/tables.js +7 -0
- package/js/rte/unstyle.js +8 -0
- package/js/rte0/README.md +37 -0
- package/js/rte0/rte.js +7 -0
- package/js/{rte → rte0}/src/behavior.js +0 -1
- package/js/{rte → rte0}/src/events.js +0 -1
- package/js/{rte → rte0}/src/range.js +0 -2
- package/js/{rte → rte0}/src/ui.items.js +0 -2
- package/js/{rte → rte0}/src/ui.js +0 -1
- package/package.json +1 -1
- package/u2/Element.js +2 -2
- package/u2/plan-scoped-refactoring.md +133 -303
- package/u2/projects.json +18 -2
- package/u2/tools/update.repos.json.js +1 -1
- package/el/qrcode/package.json +0 -22
- /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
- /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/main.css +0 -0
- /package/js/{rte → rte0}/old/old.rte.js +0 -0
- /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
- /package/js/{rte → rte0}/src/fixes.js +0 -0
- /package/js/{rte → rte0}/src/rte.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
- /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
- /package/js/{rte → rte0}/src/util/indentHtml.js +0 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {Mark, MarkType} from './mark.js';
|
|
2
|
+
|
|
3
|
+
const TRUE = () => true;
|
|
4
|
+
|
|
5
|
+
export class MarkAdapter {
|
|
6
|
+
#type;
|
|
7
|
+
#selector;
|
|
8
|
+
#read;
|
|
9
|
+
#render;
|
|
10
|
+
#write;
|
|
11
|
+
#clear;
|
|
12
|
+
#reuse;
|
|
13
|
+
|
|
14
|
+
constructor(type, {selector, tag, read = TRUE, write, clear, reuse = false, render} = {}) {
|
|
15
|
+
if (!(type instanceof MarkType)) throw new TypeError('A mark adapter requires a mark type');
|
|
16
|
+
if (typeof selector !== 'string' || !selector.trim()) {
|
|
17
|
+
throw new TypeError('A mark adapter requires a non-empty selector');
|
|
18
|
+
}
|
|
19
|
+
if (typeof read !== 'function') throw new TypeError('A mark reader must be a function');
|
|
20
|
+
if (write !== undefined && typeof write !== 'function') throw new TypeError('A mark writer must be a function');
|
|
21
|
+
if (clear !== undefined && typeof clear !== 'function') throw new TypeError('A mark clearer must be a function');
|
|
22
|
+
if (typeof reuse !== 'boolean' && typeof reuse !== 'function') {
|
|
23
|
+
throw new TypeError('Mark reuse must be a boolean or function');
|
|
24
|
+
}
|
|
25
|
+
if (reuse && !write) throw new TypeError('A reusable mark adapter requires write');
|
|
26
|
+
if (render !== undefined && typeof render !== 'function') throw new TypeError('A mark renderer must be a function');
|
|
27
|
+
if (render && tag !== undefined) throw new TypeError('A custom mark renderer cannot be combined with tag');
|
|
28
|
+
if (!render && (typeof tag !== 'string' || !/^[a-z][a-z0-9-]*$/.test(tag))) {
|
|
29
|
+
throw new TypeError('A mark adapter requires a lowercase HTML tag');
|
|
30
|
+
}
|
|
31
|
+
this.#type = type;
|
|
32
|
+
this.#selector = selector.trim();
|
|
33
|
+
this.#read = read;
|
|
34
|
+
this.#write = write;
|
|
35
|
+
this.#clear = clear;
|
|
36
|
+
this.#reuse = reuse;
|
|
37
|
+
this.#render = render || ((document, value) => {
|
|
38
|
+
const element = document.createElement(tag);
|
|
39
|
+
write?.(element, value);
|
|
40
|
+
return element;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get type() { return this.#type; }
|
|
45
|
+
get selector() { return this.#selector; }
|
|
46
|
+
get reusable() { return !!this.#reuse; }
|
|
47
|
+
get removable() { return !!this.#clear; }
|
|
48
|
+
|
|
49
|
+
parse(element) {
|
|
50
|
+
assertElement(element);
|
|
51
|
+
if (!element.matches(this.#selector)) return null;
|
|
52
|
+
const value = this.#read(element);
|
|
53
|
+
return value === undefined ? null : this.#type.create(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
canReuse(element) {
|
|
57
|
+
assertElement(element);
|
|
58
|
+
return this.#reuse === true || !!this.#reuse && !!this.#reuse(element);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
apply(element, mark) {
|
|
62
|
+
this.#assertMark(mark);
|
|
63
|
+
assertElement(element);
|
|
64
|
+
if (!this.#write) throw new TypeError('This mark adapter cannot decorate an existing element');
|
|
65
|
+
this.#write(element, mark.value);
|
|
66
|
+
return element;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
clear(element, mark) {
|
|
70
|
+
this.#assertMark(mark);
|
|
71
|
+
assertElement(element);
|
|
72
|
+
if (!this.#clear) throw new TypeError('This mark adapter cannot remove its formatting');
|
|
73
|
+
const unwrap = this.#clear(element, mark.value) === true;
|
|
74
|
+
for (const name of ['class', 'style']) {
|
|
75
|
+
if (!element.getAttribute(name)?.trim()) element.removeAttribute(name);
|
|
76
|
+
}
|
|
77
|
+
return unwrap || element.localName === 'span' && !element.attributes.length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
render(mark, document) {
|
|
81
|
+
this.#assertMark(mark);
|
|
82
|
+
if (document?.nodeType !== Node.DOCUMENT_NODE) throw new TypeError('Mark rendering requires a document');
|
|
83
|
+
const element = this.#render(document, mark.value);
|
|
84
|
+
assertElement(element);
|
|
85
|
+
if (element.ownerDocument !== document || element.parentNode || element.childNodes.length) {
|
|
86
|
+
throw new TypeError('A rendered mark must be an empty detached element from the requested document');
|
|
87
|
+
}
|
|
88
|
+
return element;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#assertMark(mark) {
|
|
92
|
+
if (!(mark instanceof Mark) || mark.type !== this.#type) {
|
|
93
|
+
throw new TypeError('A mark adapter can only use its own mark type');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function assertElement(element) {
|
|
99
|
+
if (element?.nodeType !== Node.ELEMENT_NODE) throw new TypeError('A mark adapter requires an element');
|
|
100
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const DEFAULT = Symbol('default mark value');
|
|
2
|
+
const EMPTY = Object.freeze([]);
|
|
3
|
+
|
|
4
|
+
export function markSet(marks) {
|
|
5
|
+
assertSet(marks);
|
|
6
|
+
let set = EMPTY;
|
|
7
|
+
for (const mark of marks) set = mark.add(set);
|
|
8
|
+
return set;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class MarkType {
|
|
12
|
+
#name;
|
|
13
|
+
#rank;
|
|
14
|
+
#excludes;
|
|
15
|
+
|
|
16
|
+
constructor(name, {rank = 50, excludes = [name]} = {}) {
|
|
17
|
+
this.#name = nameOf(name, 'A mark type');
|
|
18
|
+
if (!Number.isFinite(rank)) throw new TypeError('A mark rank must be a finite number');
|
|
19
|
+
this.#rank = rank;
|
|
20
|
+
this.#excludes = names(excludes);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get name() { return this.#name; }
|
|
24
|
+
get rank() { return this.#rank; }
|
|
25
|
+
get exclusions() { return this.#excludes; }
|
|
26
|
+
|
|
27
|
+
create(...values) {
|
|
28
|
+
return new Mark(this, values.length ? values[0] : DEFAULT);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
excludes(type) {
|
|
32
|
+
if (!(type instanceof MarkType)) throw new TypeError('A mark type can only exclude another mark type');
|
|
33
|
+
return this.#excludes.includes('*') || this.#excludes.includes(type.name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
remove(marks) {
|
|
37
|
+
assertSet(marks);
|
|
38
|
+
const kept = marks.filter(mark => mark.type !== this);
|
|
39
|
+
return kept.length === marks.length ? marks : Object.freeze(kept);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class Mark {
|
|
44
|
+
#type;
|
|
45
|
+
#value;
|
|
46
|
+
#key;
|
|
47
|
+
|
|
48
|
+
constructor(type, ...values) {
|
|
49
|
+
if (!(type instanceof MarkType)) throw new TypeError('A mark requires a mark type');
|
|
50
|
+
this.#type = type;
|
|
51
|
+
const value = values.length ? values[0] : DEFAULT;
|
|
52
|
+
this.#value = data(value === DEFAULT ? true : value);
|
|
53
|
+
this.#key = JSON.stringify(this.#value);
|
|
54
|
+
Object.freeze(this);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get type() { return this.#type; }
|
|
58
|
+
get value() { return this.#value; }
|
|
59
|
+
|
|
60
|
+
equals(mark) {
|
|
61
|
+
return mark instanceof Mark && this.#type === mark.type && this.#key === mark.#key;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
conflicts(mark) {
|
|
65
|
+
assertMark(mark);
|
|
66
|
+
return this.#type.excludes(mark.type) || mark.type.excludes(this.#type);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
add(marks) {
|
|
70
|
+
assertSet(marks);
|
|
71
|
+
if (marks.some(mark => this.equals(mark))) return marks;
|
|
72
|
+
if (marks.some(mark => !this.#type.excludes(mark.type) && mark.type.excludes(this.#type))) return marks;
|
|
73
|
+
const added = marks.filter(mark => !this.#type.excludes(mark.type));
|
|
74
|
+
const at = added.findIndex(mark => {
|
|
75
|
+
const order = this.#type.rank - mark.type.rank
|
|
76
|
+
|| compare(this.#type.name, mark.type.name)
|
|
77
|
+
|| compare(this.#key, mark.#key);
|
|
78
|
+
return order < 0;
|
|
79
|
+
});
|
|
80
|
+
at < 0 ? added.push(this) : added.splice(at, 0, this);
|
|
81
|
+
return Object.freeze(added);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
remove(marks) {
|
|
85
|
+
assertSet(marks);
|
|
86
|
+
const kept = marks.filter(mark => !this.equals(mark));
|
|
87
|
+
return kept.length === marks.length ? marks : Object.freeze(kept);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function data(value, parents = new Set()) {
|
|
92
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
93
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
94
|
+
if (typeof value !== 'object' || !plain(value)) throw new TypeError('A mark value must be serializable data');
|
|
95
|
+
if (parents.has(value)) throw new TypeError('A mark value cannot be cyclic');
|
|
96
|
+
parents.add(value);
|
|
97
|
+
if (Object.getOwnPropertySymbols(value).length) throw new TypeError('A mark value cannot have symbol keys');
|
|
98
|
+
const copy = Array.isArray(value)
|
|
99
|
+
? Array.from(value, item => data(item, parents))
|
|
100
|
+
: Object.fromEntries(Object.keys(value).sort().map(key => [key, data(value[key], parents)]));
|
|
101
|
+
parents.delete(value);
|
|
102
|
+
return Object.freeze(copy);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function plain(value) {
|
|
106
|
+
if (Array.isArray(value)) return true;
|
|
107
|
+
const prototype = Object.getPrototypeOf(value);
|
|
108
|
+
return prototype === null || prototype?.constructor?.name === 'Object';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function compare(a, b) {
|
|
112
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function names(value) {
|
|
116
|
+
if (!Array.isArray(value)) throw new TypeError('Mark exclusions must be an array of names');
|
|
117
|
+
return Object.freeze([...new Set(value.map(name => name === '*' ? name : nameOf(name, 'A mark exclusion')))]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function nameOf(value, subject) {
|
|
121
|
+
if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${subject} must have a non-empty name`);
|
|
122
|
+
return value.trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function assertSet(marks) {
|
|
126
|
+
if (!Array.isArray(marks) || marks.some(mark => !(mark instanceof Mark))) {
|
|
127
|
+
throw new TypeError('A mark set must be an array of marks');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function assertMark(mark) {
|
|
132
|
+
if (!(mark instanceof Mark)) throw new TypeError('A mark is required');
|
|
133
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {MarkAdapter} from './dom-adapter.js';
|
|
2
|
+
import {MarkType} from './mark.js';
|
|
3
|
+
|
|
4
|
+
const LINK_ATTRIBUTES = ['href', 'target', 'rel', 'title'];
|
|
5
|
+
|
|
6
|
+
export const bold = new MarkType('bold');
|
|
7
|
+
export const italic = new MarkType('italic');
|
|
8
|
+
export const underline = new MarkType('underline');
|
|
9
|
+
export const strike = new MarkType('strike');
|
|
10
|
+
export const code = new MarkType('code');
|
|
11
|
+
export const link = new MarkType('link');
|
|
12
|
+
|
|
13
|
+
export const boldHtml = element(bold, 'strong, b', 'strong');
|
|
14
|
+
export const italicHtml = element(italic, 'em, i', 'em');
|
|
15
|
+
export const underlineHtml = element(underline, 'u', 'u');
|
|
16
|
+
export const strikeHtml = element(strike, 's, strike', 's');
|
|
17
|
+
export const codeHtml = element(code, 'code', 'code');
|
|
18
|
+
export const linkHtml = new MarkAdapter(link, {
|
|
19
|
+
selector: 'a[href]',
|
|
20
|
+
tag: 'a',
|
|
21
|
+
read: readLink,
|
|
22
|
+
write: writeLink,
|
|
23
|
+
clear: clearLink,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// One closed group of content classes as a single mark. Its value is the class
|
|
27
|
+
// name, so a value command drives it and the group stays mutually exclusive; an
|
|
28
|
+
// application that needs independent axes builds a second adapter.
|
|
29
|
+
export function classMark(names, {type = new MarkType('class'), tag = 'span'} = {}) {
|
|
30
|
+
const known = [...new Set(names)];
|
|
31
|
+
if (!known.length || known.some(name => typeof name !== 'string' || !name.trim())) {
|
|
32
|
+
throw new TypeError('A class mark requires class names');
|
|
33
|
+
}
|
|
34
|
+
return new MarkAdapter(type, {
|
|
35
|
+
selector: known.map(name => `.${CSS.escape(name)}`).join(','),
|
|
36
|
+
tag,
|
|
37
|
+
reuse: true,
|
|
38
|
+
read: element => known.find(name => element.classList.contains(name)),
|
|
39
|
+
write: (element, value) => {
|
|
40
|
+
if (!known.includes(value)) throw new RangeError(`Unknown class mark: ${value}`);
|
|
41
|
+
for (const name of known) element.classList.remove(name);
|
|
42
|
+
element.classList.add(value);
|
|
43
|
+
},
|
|
44
|
+
clear(element) {
|
|
45
|
+
for (const name of known) element.classList.remove(name);
|
|
46
|
+
if (!element.classList.length) element.removeAttribute('class');
|
|
47
|
+
return true;
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function element(type, selector, tag) {
|
|
53
|
+
return new MarkAdapter(type, {selector, tag, clear: () => true});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readLink(element) {
|
|
57
|
+
const value = {};
|
|
58
|
+
for (const name of LINK_ATTRIBUTES) {
|
|
59
|
+
if (element.hasAttribute(name)) value[name] = element.getAttribute(name);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeLink(element, value) {
|
|
65
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
66
|
+
|| typeof value.href !== 'string'
|
|
67
|
+
|| Object.keys(value).some(name => !LINK_ATTRIBUTES.includes(name) || typeof value[name] !== 'string')) {
|
|
68
|
+
throw new TypeError('A link mark requires href and optional target, rel, and title strings');
|
|
69
|
+
}
|
|
70
|
+
for (const name of LINK_ATTRIBUTES) if (value[name] !== undefined) element.setAttribute(name, value[name]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clearLink(element) {
|
|
74
|
+
for (const name of LINK_ATTRIBUTES) element.removeAttribute(name);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Content model engine
|
|
2
|
+
|
|
3
|
+
A content model is the editor's rulebook for valid document structure. For
|
|
4
|
+
example, it can say that a `ul` contains `li` elements and that a paragraph may
|
|
5
|
+
contain text but not another block.
|
|
6
|
+
|
|
7
|
+
`content-model.js` stores and evaluates these rules. It only answers whether a
|
|
8
|
+
node is allowed directly inside another node; it never changes the DOM itself.
|
|
9
|
+
The generic engine does not hardcode HTML rules.
|
|
10
|
+
|
|
11
|
+
## Rule shape
|
|
12
|
+
|
|
13
|
+
- `groups`: categories the node belongs to, such as `flow` or `phrasing`.
|
|
14
|
+
- `children`: accepted tag names, `#text`, `*`, or category tokens prefixed by
|
|
15
|
+
`@`.
|
|
16
|
+
- `exclude`: child tags or categories forbidden below this element, including
|
|
17
|
+
through transparent descendants.
|
|
18
|
+
- `block`, `atomic`, and `void`: editor semantics used by traversal and repair.
|
|
19
|
+
- `textBlock`: a paragraph-like block that Enter may split, such as a heading;
|
|
20
|
+
it implies `block`.
|
|
21
|
+
- `mergeable`: a block whose content Backspace may join into a compatible
|
|
22
|
+
preceding sibling. It defaults to `true` for text blocks and remains an
|
|
23
|
+
explicit policy for structural blocks such as list items.
|
|
24
|
+
- `defaultChild`: structural wrapper used when direct content needs repair.
|
|
25
|
+
- `transparent`: inherit the nearest concrete ancestor's child model.
|
|
26
|
+
- `allow(parent, child, model)`: an optional dynamic decision. Returning
|
|
27
|
+
`undefined` falls back to `children`.
|
|
28
|
+
- `elements`: an optional final allowlist for output element names. Parent
|
|
29
|
+
rules remain available as context even when the editing host itself is not
|
|
30
|
+
output content.
|
|
31
|
+
|
|
32
|
+
## Contract
|
|
33
|
+
|
|
34
|
+
- Rules, lists, and models are immutable after construction.
|
|
35
|
+
- Names, categories, and category tokens are ASCII-case-insensitive.
|
|
36
|
+
- Text, unknown elements, and non-content DOM nodes have separate fallback
|
|
37
|
+
rules.
|
|
38
|
+
- Transparent rules require a real DOM context; a detached tag name alone is
|
|
39
|
+
intentionally insufficient.
|
|
40
|
+
- `extend()` creates an isolated model, shallowly merging named rule overrides
|
|
41
|
+
and allowing a rule to be removed with `null`.
|
|
42
|
+
- `allowed(node)` answers the element allowlist independently of its current
|
|
43
|
+
parent; text is still decided by the parent's `children` rule.
|
|
44
|
+
- `withElements(names)` returns and reuses an immutable narrowed model. This is
|
|
45
|
+
the inexpensive bridge from a surface's CSS element policy to cleanup and
|
|
46
|
+
commands.
|
|
47
|
+
- The model describes validity only. It never mutates DOM, chooses a repair,
|
|
48
|
+
crosses editing hosts, or reads browser-computed styles.
|
|
49
|
+
- Element validity is not HTML sanitizing. Attributes, URL schemes, and hostile
|
|
50
|
+
input require a sanitizer policy before insertion.
|
|
51
|
+
|
|
52
|
+
`defaultChild(node)` names the element a container fills itself with — `li` for
|
|
53
|
+
a list, `tr` for a table section. Structural commands use it instead of naming
|
|
54
|
+
tags, so the same command serves any configured list-like structure.
|
|
55
|
+
|
|
56
|
+
## Cost
|
|
57
|
+
|
|
58
|
+
`allows()` is the engine's hottest question — normalization asks it for every
|
|
59
|
+
child of every element — so the model avoids work rather than caching answers.
|
|
60
|
+
Rules are looked up by `localName`, which is already lowercase, so no lookup
|
|
61
|
+
allocates a string. Ancestor exclusion is the one rule that has to walk upwards,
|
|
62
|
+
and the model knows which tokens any rule excludes at all: a child nothing could
|
|
63
|
+
exclude skips that walk entirely, and the answer is remembered per element name.
|
|
64
|
+
|
|
65
|
+
## TODO
|
|
66
|
+
|
|
67
|
+
- Add contextual constraints that depend on sibling order and required child
|
|
68
|
+
sequences.
|
|
69
|
+
- Expose diagnostic reasons without complicating the hot `allows()` path.
|
|
70
|
+
- Add namespaced SVG and MathML integration points.
|
|
71
|
+
- Compile large policies only if profiling shows rule lookup is significant.
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
const EMPTY = rule({});
|
|
2
|
+
|
|
3
|
+
export class ContentModel {
|
|
4
|
+
#rules;
|
|
5
|
+
#fallback;
|
|
6
|
+
#text;
|
|
7
|
+
#elements;
|
|
8
|
+
#elementNames;
|
|
9
|
+
#elementModels = new Map();
|
|
10
|
+
#exclusions;
|
|
11
|
+
#excludable = new Map();
|
|
12
|
+
|
|
13
|
+
constructor({rules = {}, fallback = {}, text = {groups: ['flow', 'phrasing']}, elements = null} = {}) {
|
|
14
|
+
this.#rules = new Map();
|
|
15
|
+
for (const [name, value] of Object.entries(rules)) this.#rules.set(tag(name), rule(value));
|
|
16
|
+
this.#fallback = rule(fallback);
|
|
17
|
+
this.#text = rule(text);
|
|
18
|
+
this.#elementNames = elements === null ? null : tags(elements);
|
|
19
|
+
this.#elements = this.#elementNames && new Set(this.#elementNames);
|
|
20
|
+
// Every token any rule excludes. Ancestor exclusion is the one rule that
|
|
21
|
+
// has to walk, so a child no rule could ever exclude skips the walk.
|
|
22
|
+
const exclusions = new Set();
|
|
23
|
+
for (const item of [...this.#rules.values(), this.#fallback, this.#text]) {
|
|
24
|
+
for (const token of item.exclude) exclusions.add(token);
|
|
25
|
+
}
|
|
26
|
+
this.#exclusions = Object.freeze([...exclusions]);
|
|
27
|
+
Object.freeze(this);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get elements() { return this.#elementNames; }
|
|
31
|
+
|
|
32
|
+
// `localName` is already the lowercase name for HTML elements, so the hot
|
|
33
|
+
// path does no string work at all.
|
|
34
|
+
rule(node) {
|
|
35
|
+
if (typeof node === 'string') return this.#rules.get(tag(node)) || this.#fallback;
|
|
36
|
+
if (node?.nodeType === Node.TEXT_NODE) return this.#text;
|
|
37
|
+
if (node?.nodeType !== Node.ELEMENT_NODE) return EMPTY;
|
|
38
|
+
return this.#rules.get(node.localName) || this.#fallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
groups(node) {
|
|
42
|
+
return this.rule(node).groups;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
is(node, group) {
|
|
46
|
+
if (typeof group !== 'string' || !group.trim()) throw new TypeError('A group name must be a non-empty string');
|
|
47
|
+
return this.rule(node).groups.includes(group.toLowerCase());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Whether any rule could exclude this kind of child. It depends only on the
|
|
51
|
+
// node's name, so the answer is remembered per name and the ancestor walk in
|
|
52
|
+
// `allows()` is skipped for everything nothing excludes.
|
|
53
|
+
excludable(child) {
|
|
54
|
+
if (!this.#exclusions.length) return false;
|
|
55
|
+
const key = child?.nodeType === Node.ELEMENT_NODE ? child.localName
|
|
56
|
+
: child?.nodeType === Node.TEXT_NODE ? '#text' : null;
|
|
57
|
+
if (key === null) return false;
|
|
58
|
+
let found = this.#excludable.get(key);
|
|
59
|
+
if (found === undefined) {
|
|
60
|
+
found = this.#exclusions.some(token => matches(this, token, child));
|
|
61
|
+
this.#excludable.set(key, found);
|
|
62
|
+
}
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
block(node) {
|
|
67
|
+
return this.rule(node).block;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
textBlock(node) {
|
|
71
|
+
return this.rule(node).textBlock;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The element a container fills itself with: `li` for a list, `tr` for a
|
|
75
|
+
// table section. Structural commands use it instead of naming tags.
|
|
76
|
+
defaultChild(node) {
|
|
77
|
+
return this.rule(node).defaultChild;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
mergeable(node) {
|
|
81
|
+
const current = this.rule(node);
|
|
82
|
+
return current.mergeable ?? current.textBlock;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
atomic(node) {
|
|
86
|
+
const current = this.rule(node);
|
|
87
|
+
return current.atomic || current.void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
transparent(node) {
|
|
91
|
+
return this.rule(node).transparent;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
allowed(node) {
|
|
95
|
+
if (typeof node === 'string') return !this.#elements || this.#elements.has(tag(node));
|
|
96
|
+
if (node?.nodeType === Node.TEXT_NODE) return true;
|
|
97
|
+
return node?.nodeType === Node.ELEMENT_NODE && (!this.#elements || this.#elements.has(node.localName));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
allows(parent, child) {
|
|
101
|
+
if (!this.allowed(child)) return false;
|
|
102
|
+
const context = transparentParent(this, parent);
|
|
103
|
+
if (!context) return false;
|
|
104
|
+
if (excluded(this, parent, child)) return false;
|
|
105
|
+
const parentRule = this.rule(context);
|
|
106
|
+
if (parentRule.allow) {
|
|
107
|
+
const allowed = parentRule.allow(context, child, this);
|
|
108
|
+
if (allowed !== undefined) return !!allowed;
|
|
109
|
+
}
|
|
110
|
+
return parentRule.children.some(token => matches(this, token, child));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
withElements(elements) {
|
|
114
|
+
if (elements === null && this.#elementNames === null) return this;
|
|
115
|
+
const names = elements === null ? null : tags(elements);
|
|
116
|
+
if (same(names, this.#elementNames)) return this;
|
|
117
|
+
const key = names === null ? '*' : names.join('\u0000');
|
|
118
|
+
let model = this.#elementModels.get(key);
|
|
119
|
+
if (!model) {
|
|
120
|
+
model = this.extend({elements: names});
|
|
121
|
+
this.#elementModels.set(key, model);
|
|
122
|
+
}
|
|
123
|
+
return model;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
extend({rules = {}, fallback, text, elements} = {}) {
|
|
127
|
+
const merged = Object.fromEntries(this.#rules);
|
|
128
|
+
for (const [name, value] of Object.entries(rules)) {
|
|
129
|
+
const key = tag(name);
|
|
130
|
+
if (value === null) delete merged[key];
|
|
131
|
+
else merged[key] = {...(merged[key] || {}), ...value};
|
|
132
|
+
}
|
|
133
|
+
return new ContentModel({
|
|
134
|
+
rules: merged,
|
|
135
|
+
fallback: fallback ? {...this.#fallback, ...fallback} : this.#fallback,
|
|
136
|
+
text: text ? {...this.#text, ...text} : this.#text,
|
|
137
|
+
elements: elements === undefined ? this.#elementNames : elements,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function rule(value) {
|
|
143
|
+
if (!value || typeof value !== 'object') throw new TypeError('A content rule must be an object');
|
|
144
|
+
const textBlock = !!value.textBlock;
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
groups: list(value.groups),
|
|
147
|
+
children: list(value.children),
|
|
148
|
+
exclude: list(value.exclude),
|
|
149
|
+
block: !!value.block || textBlock,
|
|
150
|
+
textBlock,
|
|
151
|
+
mergeable: value.mergeable == null ? null : !!value.mergeable,
|
|
152
|
+
atomic: !!value.atomic,
|
|
153
|
+
void: !!value.void,
|
|
154
|
+
transparent: !!value.transparent,
|
|
155
|
+
defaultChild: value.defaultChild == null ? null : tag(value.defaultChild),
|
|
156
|
+
allow: callback(value.allow),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function callback(value) {
|
|
161
|
+
if (value == null) return null;
|
|
162
|
+
if (typeof value !== 'function') throw new TypeError('A dynamic content rule must be a function');
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function list(value) {
|
|
167
|
+
if (value === undefined) return Object.freeze([]);
|
|
168
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
|
|
169
|
+
throw new TypeError('Content rule lists must contain strings');
|
|
170
|
+
}
|
|
171
|
+
return Object.freeze([...new Set(value.map(item => item.startsWith('@') ? `@${item.slice(1).toLowerCase()}` : tag(item)))]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function tags(value) {
|
|
175
|
+
if (!Array.isArray(value) || value.some(name => typeof name !== 'string' || !name.trim())) {
|
|
176
|
+
throw new TypeError('Allowed elements must be an array of tag names');
|
|
177
|
+
}
|
|
178
|
+
return Object.freeze([...new Set(value.map(tag))]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function same(left, right) {
|
|
182
|
+
return left === right || !!left && !!right
|
|
183
|
+
&& left.length === right.length && left.every((value, index) => value === right[index]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function transparentParent(model, parent) {
|
|
187
|
+
let context = parent;
|
|
188
|
+
while (context && model.transparent(context)) {
|
|
189
|
+
if (typeof context === 'string') return null;
|
|
190
|
+
context = context.parentElement;
|
|
191
|
+
}
|
|
192
|
+
return context;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function excluded(model, parent, child) {
|
|
196
|
+
if (typeof parent === 'string' || !model.excludable(child)) return false;
|
|
197
|
+
for (let element = parent; element; element = element.parentElement) {
|
|
198
|
+
if (model.rule(element).exclude.some(token => matches(model, token, child))) return true;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function matches(model, token, child) {
|
|
204
|
+
if (token.charCodeAt(0) === 64) return model.rule(child).groups.includes(token.slice(1));
|
|
205
|
+
if (token === '*') return child?.nodeType === Node.ELEMENT_NODE || child?.nodeType === Node.TEXT_NODE;
|
|
206
|
+
if (token === '#text') return child?.nodeType === Node.TEXT_NODE;
|
|
207
|
+
return child?.nodeType === Node.ELEMENT_NODE && child.localName === token;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function tag(name) {
|
|
211
|
+
if (typeof name !== 'string' || !name.trim()) throw new TypeError('A content rule name must be a non-empty string');
|
|
212
|
+
return name.toLowerCase();
|
|
213
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Default HTML content model
|
|
2
|
+
|
|
3
|
+
The default HTML content model is RTE's ready-to-use rulebook for ordinary
|
|
4
|
+
HTML. It knows structures such as paragraphs, headings, lists, links, tables,
|
|
5
|
+
media, and form controls, so an application does not have to describe them all.
|
|
6
|
+
|
|
7
|
+
`html-model.js` supplies these defaults as data for `ContentModel`. Applications
|
|
8
|
+
may extend or replace them; commands and normalizers do not hardcode the rules.
|
|
9
|
+
|
|
10
|
+
## Defaults
|
|
11
|
+
|
|
12
|
+
- Flow containers accept flow content; paragraph and heading-like blocks accept
|
|
13
|
+
phrasing content.
|
|
14
|
+
- Paragraphs, headings, and preformatted blocks are marked as splittable text
|
|
15
|
+
blocks. Structural blocks such as lists, items, tables, and layout `div`s are
|
|
16
|
+
not.
|
|
17
|
+
- Text blocks and list items are mergeable at their leading boundary. Layout
|
|
18
|
+
containers and table structure are not mergeable by default.
|
|
19
|
+
- Lists accept list items, definition lists accept their term/description
|
|
20
|
+
structures, and table sections accept their native structural children.
|
|
21
|
+
Structural children are not misclassified as general flow content.
|
|
22
|
+
- Lists, table sections, and rows declare their default repair child explicitly.
|
|
23
|
+
- Transparent elements such as `a`, `ins`, `del`, and `map` inherit their real
|
|
24
|
+
DOM parent's content restrictions.
|
|
25
|
+
- Interactive descendants are excluded from links and buttons. Nested links
|
|
26
|
+
are rejected through transparent wrappers.
|
|
27
|
+
- Replaced controls, media, line breaks, images, and horizontal rules are
|
|
28
|
+
editor-atomic. HTML void elements are also marked `void`.
|
|
29
|
+
- Script, style, and template nodes are not flow content. Security sanitizing
|
|
30
|
+
remains a separate mandatory stage for untrusted HTML.
|
|
31
|
+
- Unknown/custom elements are transparent flow/phrasing wrappers by default,
|
|
32
|
+
so their permitted children follow the context where they are used.
|
|
33
|
+
|
|
34
|
+
`createHtmlModel(overrides)` returns an isolated extended policy when overrides
|
|
35
|
+
are supplied. Shipped rules are defaults and may be narrowed or replaced by an
|
|
36
|
+
application.
|
|
37
|
+
|
|
38
|
+
## Limits
|
|
39
|
+
|
|
40
|
+
This first policy describes direct parent/child validity and inherited
|
|
41
|
+
transparent content. It does not yet enforce sibling order, required children,
|
|
42
|
+
attribute-dependent categories, or every uncommon HTML element. Those become
|
|
43
|
+
additional constraints without changing the generic model API.
|
|
44
|
+
|
|
45
|
+
## TODO
|
|
46
|
+
|
|
47
|
+
- Cover ruby, media fallback, picture/source order, details/summary order, and
|
|
48
|
+
definition-list grouping precisely.
|
|
49
|
+
- Add attribute-dependent interactive and embedded-content categories.
|
|
50
|
+
- Add SVG and MathML integration rules.
|
|
51
|
+
- Track relevant HTML Standard changes with focused conformance fixtures.
|