u2ui 1.5.3 → 1.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +487 -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 +553 -0
- package/js/rte/playground/index.html +835 -0
- package/js/rte/rte.js +56 -5
- package/js/rte/run.out +17 -0
- package/js/rte/source.js +12 -0
- package/js/rte/src/browser/README.md +40 -0
- package/js/rte/src/browser/interactive.js +17 -0
- package/js/rte/src/browser/range-rect.js +82 -0
- package/js/rte/src/client/README.md +385 -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 +472 -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 +285 -0
- package/js/rte/src/client/link.js +567 -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 +165 -0
- package/js/rte/src/client/unstyle.js +32 -0
- package/js/rte/src/command/README.md +520 -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 +159 -0
- package/js/rte/src/command/delete.js +119 -0
- package/js/rte/src/command/edit.js +78 -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 +134 -0
- package/js/rte/src/core/README.md +63 -0
- package/js/rte/src/core/core.js +247 -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 +173 -0
- package/js/rte/src/input/external-input.js +130 -0
- package/js/rte/src/input/input-pipeline.js +459 -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 +108 -0
- package/js/rte/src/sanitize/native.js +43 -0
- package/js/rte/src/sanitize/policy.js +206 -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 +271 -0
- package/js/rte/src/ui/caret.js +17 -0
- package/js/rte/src/ui/chrome.js +214 -0
- package/js/rte/src/ui/handles.js +179 -0
- package/js/rte/src/ui/highlight.js +86 -0
- package/js/rte/src/ui/place.js +25 -0
- package/js/rte/src/ui/toolbar.js +258 -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,90 @@
|
|
|
1
|
+
import {ContentModel} from '../content-model.js';
|
|
2
|
+
|
|
3
|
+
const PHRASING = words(`
|
|
4
|
+
abbr b bdi bdo cite code data dfn em i kbd mark q ruby s samp small span
|
|
5
|
+
strong sub sup time u var
|
|
6
|
+
`);
|
|
7
|
+
const FLOW_BLOCKS = words(`
|
|
8
|
+
address article aside blockquote details dialog div fieldset figure footer
|
|
9
|
+
form header hgroup main nav search section
|
|
10
|
+
`);
|
|
11
|
+
const RULES = {};
|
|
12
|
+
|
|
13
|
+
for (const name of PHRASING) RULES[name] = content(['flow', 'phrasing'], ['@phrasing']);
|
|
14
|
+
for (const name of FLOW_BLOCKS) RULES[name] = content(['flow'], ['@flow'], {block: true});
|
|
15
|
+
|
|
16
|
+
for (const name of words('h1 h2 h3 h4 h5 h6 p pre')) {
|
|
17
|
+
RULES[name] = content(['flow'], ['@phrasing'], {textBlock: true});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Object.assign(RULES, {
|
|
21
|
+
a: content(['flow', 'phrasing', 'interactive'], [], {transparent: true, exclude: ['a', '@interactive']}),
|
|
22
|
+
del: content(['flow', 'phrasing'], [], {transparent: true}),
|
|
23
|
+
ins: content(['flow', 'phrasing'], [], {transparent: true}),
|
|
24
|
+
map: content(['flow', 'phrasing'], [], {transparent: true}),
|
|
25
|
+
button: content(['flow', 'phrasing', 'interactive'], ['@phrasing'], {atomic: true, exclude: ['@interactive']}),
|
|
26
|
+
label: content(['flow', 'phrasing', 'interactive'], ['@phrasing'], {exclude: ['label']}),
|
|
27
|
+
textarea: content(['flow', 'phrasing', 'interactive'], [], {atomic: true}),
|
|
28
|
+
select: content(['flow', 'phrasing', 'interactive'], ['option', 'optgroup', 'hr'], {atomic: true}),
|
|
29
|
+
option: content([], ['#text']),
|
|
30
|
+
optgroup: content([], ['option']),
|
|
31
|
+
body: content([], ['@flow'], {block: true}),
|
|
32
|
+
form: content(['flow'], ['@flow'], {block: true, exclude: ['form']}),
|
|
33
|
+
details: content(['flow'], ['summary', '@flow'], {block: true}),
|
|
34
|
+
fieldset: content(['flow'], ['legend', '@flow'], {block: true}),
|
|
35
|
+
figure: content(['flow'], ['figcaption', '@flow'], {block: true}),
|
|
36
|
+
summary: content([], ['@phrasing'], {block: true}),
|
|
37
|
+
legend: content([], ['@phrasing'], {block: true}),
|
|
38
|
+
figcaption: content([], ['@phrasing'], {block: true}),
|
|
39
|
+
ul: content(['flow'], ['li'], {block: true, defaultChild: 'li'}),
|
|
40
|
+
ol: content(['flow'], ['li'], {block: true, defaultChild: 'li'}),
|
|
41
|
+
menu: content(['flow'], ['li'], {block: true, defaultChild: 'li'}),
|
|
42
|
+
li: content([], ['@flow'], {block: true, mergeable: true}),
|
|
43
|
+
dl: content(['flow'], ['dt', 'dd', 'div'], {block: true}),
|
|
44
|
+
dt: content([], ['@flow'], {block: true}),
|
|
45
|
+
dd: content([], ['@flow'], {block: true}),
|
|
46
|
+
table: content(['flow'], ['caption', 'colgroup', 'thead', 'tbody', 'tfoot', 'tr'], {block: true, defaultChild: 'tbody'}),
|
|
47
|
+
caption: content([], ['@flow'], {block: true}),
|
|
48
|
+
colgroup: content([], ['col']),
|
|
49
|
+
thead: content([], ['tr'], {block: true, defaultChild: 'tr'}),
|
|
50
|
+
tbody: content([], ['tr'], {block: true, defaultChild: 'tr'}),
|
|
51
|
+
tfoot: content([], ['tr'], {block: true, defaultChild: 'tr'}),
|
|
52
|
+
tr: content([], ['th', 'td'], {block: true, defaultChild: 'td'}),
|
|
53
|
+
th: content([], ['@flow'], {block: true}),
|
|
54
|
+
td: content([], ['@flow'], {block: true}),
|
|
55
|
+
picture: content(['flow', 'phrasing'], ['source', 'img']),
|
|
56
|
+
audio: content(['flow', 'phrasing', 'interactive'], ['source', 'track', '@flow'], {atomic: true}),
|
|
57
|
+
video: content(['flow', 'phrasing', 'interactive'], ['source', 'track', '@flow'], {atomic: true}),
|
|
58
|
+
object: content(['flow', 'phrasing', 'interactive'], ['@flow'], {atomic: true}),
|
|
59
|
+
iframe: content(['flow', 'phrasing', 'interactive'], [], {atomic: true}),
|
|
60
|
+
canvas: content(['flow', 'phrasing'], ['@phrasing'], {atomic: true}),
|
|
61
|
+
br: content(['flow', 'phrasing'], [], {atomic: true, void: true}),
|
|
62
|
+
wbr: content(['flow', 'phrasing'], [], {atomic: true, void: true}),
|
|
63
|
+
img: content(['flow', 'phrasing'], [], {atomic: true, void: true}),
|
|
64
|
+
input: content(['flow', 'phrasing', 'interactive'], [], {atomic: true, void: true}),
|
|
65
|
+
hr: content(['flow'], [], {block: true, atomic: true, void: true}),
|
|
66
|
+
source: content([], [], {atomic: true, void: true}),
|
|
67
|
+
track: content([], [], {atomic: true, void: true}),
|
|
68
|
+
col: content([], [], {atomic: true, void: true}),
|
|
69
|
+
script: content([], [], {atomic: true}),
|
|
70
|
+
style: content([], [], {atomic: true}),
|
|
71
|
+
template: content([], [], {atomic: true}),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const htmlModel = new ContentModel({
|
|
75
|
+
rules: RULES,
|
|
76
|
+
fallback: {groups: ['flow', 'phrasing'], transparent: true},
|
|
77
|
+
text: {groups: ['flow', 'phrasing']},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export function createHtmlModel(overrides) {
|
|
81
|
+
return overrides ? htmlModel.extend(overrides) : htmlModel;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function content(groups, children, options = {}) {
|
|
85
|
+
return {groups, children, ...options};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function words(value) {
|
|
89
|
+
return value.trim().split(/\s+/);
|
|
90
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Repair execution
|
|
2
|
+
|
|
3
|
+
A repair action describes one concrete structural change, such as wrapping
|
|
4
|
+
loose text in a paragraph, unwrapping an invalid element, or moving a child to
|
|
5
|
+
a valid parent. The repair executor performs exactly that change while keeping
|
|
6
|
+
tracked caret and selection positions meaningful.
|
|
7
|
+
|
|
8
|
+
`repair-executor.js` contains only this mechanical DOM work. `RepairPlanner`
|
|
9
|
+
decides which action is appropriate; `PointMap` keeps positions aligned.
|
|
10
|
+
|
|
11
|
+
## Contract
|
|
12
|
+
|
|
13
|
+
- Passive `keep`, `boundary`, and `reject` actions never mutate DOM.
|
|
14
|
+
- `wrap` supports a contiguous node group, allowing the normalizer to create
|
|
15
|
+
one paragraph around an inline run rather than one paragraph per node.
|
|
16
|
+
- `convert`, `unwrap`, `remove`, and `lift` use mapped operations so registered
|
|
17
|
+
points retain their logical positions.
|
|
18
|
+
- Unwrapping a block into phrasing content inserts `<br>` only where adjacent
|
|
19
|
+
visible content would otherwise lose a block boundary.
|
|
20
|
+
- Lifting splits each containing wrapper after the lifted node, preserving the
|
|
21
|
+
document order of content before and after it. Cloned wrappers retain
|
|
22
|
+
meaningful attributes except duplicate-prone `id`.
|
|
23
|
+
- An optional transaction receives only connected changed nodes through
|
|
24
|
+
`touch()`.
|
|
25
|
+
- Unknown actions and invalid roots, child relations, maps, or lift targets fail
|
|
26
|
+
explicitly before unrelated content is changed.
|
|
27
|
+
|
|
28
|
+
## TODO
|
|
29
|
+
|
|
30
|
+
- Preserve element-specific state that `cloneNode(false)` cannot copy.
|
|
31
|
+
- Let content policy provide the line-separator node instead of assuming `<br>`.
|
|
32
|
+
- Record reversible operation descriptions for history.
|
|
33
|
+
- Add namespace-aware replacement and wrapper creation.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {indexOf} from '../../selection/point/point.js';
|
|
2
|
+
import {PointMap} from '../../selection/map/point-map.js';
|
|
3
|
+
|
|
4
|
+
const PASSIVE = new Set(['keep', 'boundary', 'reject']);
|
|
5
|
+
|
|
6
|
+
export class RepairExecutor {
|
|
7
|
+
#root;
|
|
8
|
+
#map;
|
|
9
|
+
#transaction;
|
|
10
|
+
|
|
11
|
+
constructor(root, {map = new PointMap(), transaction = null} = {}) {
|
|
12
|
+
if (root?.nodeType !== Node.ELEMENT_NODE) throw new TypeError('A repair executor requires an element root');
|
|
13
|
+
if (!map?.wrap || !map?.get) throw new TypeError('A repair executor requires a point map');
|
|
14
|
+
if (transaction !== null && typeof transaction?.touch !== 'function') {
|
|
15
|
+
throw new TypeError('A repair transaction must support touch()');
|
|
16
|
+
}
|
|
17
|
+
this.#root = root;
|
|
18
|
+
this.#map = map;
|
|
19
|
+
this.#transaction = transaction;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get root() { return this.#root; }
|
|
23
|
+
get map() { return this.#map; }
|
|
24
|
+
get transaction() { return this.#transaction; }
|
|
25
|
+
|
|
26
|
+
apply(plan, parent, child) {
|
|
27
|
+
if (!plan?.type) throw new TypeError('A repair plan requires a type');
|
|
28
|
+
if (child?.parentNode !== parent || parent !== this.#root && !this.#root.contains(parent)) {
|
|
29
|
+
throw new RangeError('Repair execution requires a direct child inside the root');
|
|
30
|
+
}
|
|
31
|
+
if (PASSIVE.has(plan.type)) return false;
|
|
32
|
+
if (plan.type === 'wrap') {
|
|
33
|
+
this.wrap([child], plan.tag);
|
|
34
|
+
} else if (plan.type === 'convert') {
|
|
35
|
+
const replacement = this.#element(plan.tag);
|
|
36
|
+
this.#map.replaceWrapper(child, replacement);
|
|
37
|
+
this.#touch(parent, replacement);
|
|
38
|
+
} else if (plan.type === 'unwrap') {
|
|
39
|
+
this.#unwrap(child, !!plan.breaks);
|
|
40
|
+
} else if (plan.type === 'lift') {
|
|
41
|
+
this.#lift(child, plan.target);
|
|
42
|
+
} else if (plan.type === 'remove') {
|
|
43
|
+
this.#map.remove(child);
|
|
44
|
+
this.#touch(parent);
|
|
45
|
+
} else {
|
|
46
|
+
throw new TypeError(`Unknown repair action: ${plan.type}`);
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
wrap(nodes, tag) {
|
|
52
|
+
nodes = [...nodes];
|
|
53
|
+
if (!nodes.length) throw new RangeError('A repair wrapper requires content');
|
|
54
|
+
const parent = nodes[0].parentNode;
|
|
55
|
+
const wrapper = this.#element(tag);
|
|
56
|
+
this.#map.wrap(nodes, wrapper);
|
|
57
|
+
this.#touch(parent, wrapper);
|
|
58
|
+
return wrapper;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#unwrap(wrapper, breaks) {
|
|
62
|
+
const parent = wrapper.parentNode;
|
|
63
|
+
if (breaks) {
|
|
64
|
+
const previous = sibling(wrapper, 'previousSibling');
|
|
65
|
+
const next = sibling(wrapper, 'nextSibling');
|
|
66
|
+
const before = previous && !isBreak(previous) && !isBreak(first(wrapper));
|
|
67
|
+
const after = next && !isBreak(next) && !isBreak(last(wrapper));
|
|
68
|
+
if (before) this.#map.insert(parent, indexOf(wrapper), this.#element('br'));
|
|
69
|
+
if (after) this.#map.insert(parent, indexOf(wrapper) + 1, this.#element('br'));
|
|
70
|
+
}
|
|
71
|
+
this.#map.unwrap(wrapper);
|
|
72
|
+
this.#touch(parent);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#lift(node, target) {
|
|
76
|
+
if ((target !== this.#root && !this.#root.contains(target)) || !target?.contains(node)) {
|
|
77
|
+
throw new RangeError('Lift target must be an ancestor inside the repair root');
|
|
78
|
+
}
|
|
79
|
+
while (node.parentNode !== target) {
|
|
80
|
+
const parent = node.parentNode;
|
|
81
|
+
const grand = parent.parentNode;
|
|
82
|
+
const offset = indexOf(parent) + 1;
|
|
83
|
+
let after = null;
|
|
84
|
+
if (node.nextSibling) {
|
|
85
|
+
after = parent.cloneNode(false);
|
|
86
|
+
after.removeAttribute?.('id');
|
|
87
|
+
this.#map.insert(grand, offset, after);
|
|
88
|
+
while (node.nextSibling) this.#map.move(node.nextSibling, after, after.childNodes.length);
|
|
89
|
+
}
|
|
90
|
+
this.#map.move(node, grand, offset);
|
|
91
|
+
this.#touch(parent, grand, after);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#element(tag) {
|
|
96
|
+
if (typeof tag !== 'string' || !tag) throw new TypeError('A repair element requires a tag name');
|
|
97
|
+
return this.#root.ownerDocument.createElement(tag);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#touch(...nodes) {
|
|
101
|
+
if (!this.#transaction) return;
|
|
102
|
+
for (const node of nodes) if (node && (node === this.#root || this.#root.contains(node))) this.#transaction.touch(node);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function first(node) {
|
|
107
|
+
while (node?.firstChild) node = node.firstChild;
|
|
108
|
+
return node;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function last(node) {
|
|
112
|
+
while (node?.lastChild) node = node.lastChild;
|
|
113
|
+
return node;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function visible(node) {
|
|
117
|
+
return !!node && node.nodeType !== Node.COMMENT_NODE
|
|
118
|
+
&& (node.nodeType !== Node.TEXT_NODE || !!node.data.trim());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sibling(node, direction) {
|
|
122
|
+
do node = node[direction]; while (node && !visible(node));
|
|
123
|
+
return node;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isBreak(node) {
|
|
127
|
+
return node?.nodeType === Node.ELEMENT_NODE && node.localName === 'br';
|
|
128
|
+
}
|
|
129
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Scoped normalization
|
|
2
|
+
|
|
3
|
+
Normalization brings editable DOM into the valid, predictable shape required by
|
|
4
|
+
the content model. For example, it can turn loose text in a block editor into a
|
|
5
|
+
paragraph or place stray list content inside an `li`.
|
|
6
|
+
|
|
7
|
+
`normalizer.js` asks the planner what is wrong and the executor to fix it. It
|
|
8
|
+
works only in the requested part of one editor, preserves tracked positions,
|
|
9
|
+
and repeats until that part needs no further repair.
|
|
10
|
+
|
|
11
|
+
## Contract
|
|
12
|
+
|
|
13
|
+
- `normalize()` accepts an element scope, logical points, and an optional
|
|
14
|
+
transaction. The default scope is the complete root.
|
|
15
|
+
- `step()` executes at most the next single operation through the identical
|
|
16
|
+
traversal, grouping, mapping, and scope logic used by `normalize()`.
|
|
17
|
+
- Descendant atomic elements and explicit nested editing boundaries are never
|
|
18
|
+
traversed. A nested editing host cannot itself be selected as a foreign scope.
|
|
19
|
+
- Adjacent text and inline nodes with the same wrapper plan become one block.
|
|
20
|
+
- Every executed action is returned in order. Unresolved `reject` plans are
|
|
21
|
+
reported separately and never hidden or treated as success.
|
|
22
|
+
- `stable` states whether the requested scope has reached a fixed point; a
|
|
23
|
+
successful `step()` remains deliberately unstable until checked again.
|
|
24
|
+
- The returned `PointMap` maps all supplied points after normalization; callers
|
|
25
|
+
decide whether and how to restore a browser selection.
|
|
26
|
+
- Normalization repeats to a fixed point, making a second run a no-op. A hard
|
|
27
|
+
operation limit exposes non-converging custom policies instead of looping.
|
|
28
|
+
- An optional transaction receives executor dirty nodes. The normalizer does
|
|
29
|
+
not create an implicit transaction because fragments and playgrounds also use
|
|
30
|
+
it independently.
|
|
31
|
+
|
|
32
|
+
## Cost
|
|
33
|
+
|
|
34
|
+
An unchanged document is the common case and must be close to free. Two guards
|
|
35
|
+
used to make it linear in tree depth per node instead: the executor re-validated
|
|
36
|
+
the parent against the root for every child, including the ones needing nothing,
|
|
37
|
+
and the pass loop did the same for every element the walk had just collected.
|
|
38
|
+
Both are now paid only where they can matter — a passive plan never reaches the
|
|
39
|
+
executor, and a collected parent is re-checked only after a repair in that same
|
|
40
|
+
pass could have detached it.
|
|
41
|
+
|
|
42
|
+
## TODO
|
|
43
|
+
|
|
44
|
+
- Reduce executor touches into minimal stable ancestor scopes.
|
|
45
|
+
- Expand a local scope only when lift or split crosses its current boundary.
|
|
46
|
+
- Add canonical sibling merging and empty metadata cleanup.
|
|
47
|
+
- Restore mapped `EditRange` snapshots through a Surface integration module.
|
|
48
|
+
- Add generated convergence and idempotence cases for custom policies.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {RepairExecutor} from '../execute/repair-executor.js';
|
|
2
|
+
import {RepairPlanner} from '../repair/repair-planner.js';
|
|
3
|
+
import {PointMap} from '../../selection/map/point-map.js';
|
|
4
|
+
import {isEditingBoundary} from '../../selection/ownership/ownership.js';
|
|
5
|
+
|
|
6
|
+
const PASSIVE = new Set(['keep', 'boundary', 'reject']);
|
|
7
|
+
|
|
8
|
+
export class Normalizer {
|
|
9
|
+
#root;
|
|
10
|
+
#planner;
|
|
11
|
+
#limit;
|
|
12
|
+
|
|
13
|
+
constructor(root, options = {}) {
|
|
14
|
+
const {planner = null, limit = 10000, ...policy} = options;
|
|
15
|
+
if (root?.nodeType !== Node.ELEMENT_NODE) throw new TypeError('A normalizer requires an element root');
|
|
16
|
+
if (planner !== null && (typeof planner?.plan !== 'function' || planner.root !== root)) {
|
|
17
|
+
throw new TypeError('A custom repair planner must belong to the normalizer root');
|
|
18
|
+
}
|
|
19
|
+
if (!Number.isInteger(limit) || limit < 1) throw new RangeError('Normalization limit must be a positive integer');
|
|
20
|
+
this.#root = root;
|
|
21
|
+
this.#planner = planner || new RepairPlanner(root, policy);
|
|
22
|
+
this.#limit = limit;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get root() { return this.#root; }
|
|
26
|
+
get planner() { return this.#planner; }
|
|
27
|
+
get limit() { return this.#limit; }
|
|
28
|
+
|
|
29
|
+
normalize(options) {
|
|
30
|
+
return this.#run(options, false);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
step(options) {
|
|
34
|
+
return this.#run(options, true);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#run({scope = this.#root, points = [], transaction = null} = {}, single) {
|
|
38
|
+
if (scope?.nodeType !== Node.ELEMENT_NODE || scope !== this.#root && !this.#root.contains(scope)) {
|
|
39
|
+
throw new RangeError('Normalization scope must be an element inside the root');
|
|
40
|
+
}
|
|
41
|
+
if (scope !== this.#root && isEditingBoundary(scope)) {
|
|
42
|
+
throw new RangeError('Normalization scope cannot cross into a nested editing boundary');
|
|
43
|
+
}
|
|
44
|
+
const map = new PointMap(points);
|
|
45
|
+
const executor = new RepairExecutor(this.#root, {map, transaction});
|
|
46
|
+
const actions = [];
|
|
47
|
+
const issues = new Map();
|
|
48
|
+
let passes = 0;
|
|
49
|
+
let stable = false;
|
|
50
|
+
outer: while (true) {
|
|
51
|
+
let changed = false;
|
|
52
|
+
passes++;
|
|
53
|
+
// The walk just collected these, so only a repair in this same pass
|
|
54
|
+
// can have detached one. Until that happens the check is a tree walk
|
|
55
|
+
// per element for nothing.
|
|
56
|
+
const collected = actions.length;
|
|
57
|
+
for (const parent of parents(scope, this.#planner.model)) {
|
|
58
|
+
if (actions.length !== collected && parent !== this.#root && !this.#root.contains(parent)) continue;
|
|
59
|
+
const result = this.#normalizeParent(parent, executor, actions, issues, single);
|
|
60
|
+
if (result.changed) changed = true;
|
|
61
|
+
if (result.stopped) break outer;
|
|
62
|
+
}
|
|
63
|
+
if (!changed) {
|
|
64
|
+
stable = true;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const unresolved = [...issues.values()].filter(issue => this.#root.contains(issue.node));
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
changed: actions.length > 0,
|
|
71
|
+
stable,
|
|
72
|
+
passes,
|
|
73
|
+
actions: Object.freeze(actions),
|
|
74
|
+
issues: Object.freeze(unresolved),
|
|
75
|
+
map,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#normalizeParent(parent, executor, actions, issues, single) {
|
|
80
|
+
let changed = false;
|
|
81
|
+
for (let child = parent.firstChild; child;) {
|
|
82
|
+
const plan = this.#planner.plan(parent, child);
|
|
83
|
+
if (plan.type === 'reject') issues.set(child, Object.freeze({node: child, parent, plan}));
|
|
84
|
+
else if (issues.size) issues.delete(child);
|
|
85
|
+
// Most children need nothing. Asking the executor to confirm that
|
|
86
|
+
// would re-validate the parent against the root for every one of
|
|
87
|
+
// them, which is a tree walk per node in an unchanged document.
|
|
88
|
+
if (PASSIVE.has(plan.type)) {
|
|
89
|
+
child = child.nextSibling;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (plan.type === 'wrap') {
|
|
93
|
+
const nodes = [child];
|
|
94
|
+
let next = child.nextSibling;
|
|
95
|
+
while (next && !this.#planner.model.block(child)) {
|
|
96
|
+
const nextPlan = this.#planner.plan(parent, next);
|
|
97
|
+
if (nextPlan.type !== 'wrap' || nextPlan.tag !== plan.tag || this.#planner.model.block(next)) break;
|
|
98
|
+
nodes.push(next);
|
|
99
|
+
next = next.nextSibling;
|
|
100
|
+
}
|
|
101
|
+
this.#assertLimit(actions);
|
|
102
|
+
const wrapper = executor.wrap(nodes, plan.tag);
|
|
103
|
+
actions.push(record(plan, parent, wrapper, {count: nodes.length}));
|
|
104
|
+
changed = true;
|
|
105
|
+
if (single) return {changed, stopped: true};
|
|
106
|
+
child = next;
|
|
107
|
+
} else {
|
|
108
|
+
const next = child.nextSibling;
|
|
109
|
+
this.#assertLimit(actions);
|
|
110
|
+
if (executor.apply(plan, parent, child)) {
|
|
111
|
+
actions.push(record(plan, parent, child));
|
|
112
|
+
changed = true;
|
|
113
|
+
if (single) return {changed, stopped: true};
|
|
114
|
+
child = next?.parentNode === parent ? next : null;
|
|
115
|
+
} else {
|
|
116
|
+
child = next;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return {changed, stopped: false};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#assertLimit(actions) {
|
|
124
|
+
if (actions.length >= this.#limit) throw new RangeError('Normalization did not converge within its operation limit');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parents(scope, model) {
|
|
129
|
+
const result = [];
|
|
130
|
+
const visit = element => {
|
|
131
|
+
if (element !== scope && (isEditingBoundary(element) || model.atomic(element))) return;
|
|
132
|
+
for (const child of element.children) visit(child);
|
|
133
|
+
result.push(element);
|
|
134
|
+
};
|
|
135
|
+
visit(scope);
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function record(plan, parent, node, detail = {}) {
|
|
140
|
+
return Object.freeze({type: plan.type, parent, node, ...detail, ...plan});
|
|
141
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Repair planning
|
|
2
|
+
|
|
3
|
+
The repair planner looks at one parent and one direct child and describes how an
|
|
4
|
+
invalid relationship should be fixed. Text directly in a `ul`, for example,
|
|
5
|
+
may need an `li` wrapper. A valid paragraph inside a `div` needs no action.
|
|
6
|
+
|
|
7
|
+
`repair-planner.js` only makes this decision. It returns an immutable action
|
|
8
|
+
without changing nodes, selections, or transaction state.
|
|
9
|
+
|
|
10
|
+
## Actions
|
|
11
|
+
|
|
12
|
+
- `keep`: the relationship is valid or cleanup is disabled.
|
|
13
|
+
- `boundary`: the child is an explicit nested editable and must not be crossed.
|
|
14
|
+
- `wrap`: place the child in the returned structural tag.
|
|
15
|
+
- `convert`: replace a neutral generic block with the configured default block.
|
|
16
|
+
- `unwrap`: remove a neutral wrapper while preserving its children;
|
|
17
|
+
`breaks` records whether adjacent phrasing content needs explicit line breaks.
|
|
18
|
+
- `lift`: move the child to the nearest permitted ancestor inside the root.
|
|
19
|
+
- `remove`: discard only ignorable whitespace/comments or an empty neutral,
|
|
20
|
+
non-atomic invalid wrapper.
|
|
21
|
+
- `reject`: no lossless default repair is known; execution must leave the node
|
|
22
|
+
untouched or delegate to an application policy.
|
|
23
|
+
|
|
24
|
+
## Contract
|
|
25
|
+
|
|
26
|
+
- `none` never plans changes. `minimal` repairs invalid relationships only.
|
|
27
|
+
`structural` and `canonical` additionally shape direct root content.
|
|
28
|
+
- A neutral generic block — one carrying no attributes — is redundant at any
|
|
29
|
+
depth, not only at the root. It becomes the block its context expects, or
|
|
30
|
+
dissolves into its parent. This is deliberately not a model decision: nested
|
|
31
|
+
`div`s are perfectly valid HTML, which is exactly why validity alone never
|
|
32
|
+
removes them, and why legacy documents arrive full of them. An attribute makes
|
|
33
|
+
a wrapper deliberate and it stays.
|
|
34
|
+
- Loose inline content standing beside blocks in a generic container gets the
|
|
35
|
+
default block, for the same reason: the root already did this, and depth was
|
|
36
|
+
never the argument. Containers that carry their own meaning — list items,
|
|
37
|
+
cells, quotes — keep loose text as it is.
|
|
38
|
+
- `canonical` goes one step beyond valid and also dissolves a generic *inline*
|
|
39
|
+
wrapper that carries nothing — a bare `<span>` — and removes an inline element
|
|
40
|
+
with neither attributes nor content, whatever its name. A semantic element
|
|
41
|
+
with content is never noise, however bare it is.
|
|
42
|
+
- The configured root block wraps direct phrasing content, converts neutral
|
|
43
|
+
generic blocks containing phrasing content, and removes neutral generic
|
|
44
|
+
wrappers around root blocks mixed with content that can be shaped at the
|
|
45
|
+
root. A nested `<div>` followed by text can therefore become two sibling
|
|
46
|
+
paragraphs through repeated small repairs.
|
|
47
|
+
- Root shaping requires a root block the root itself permits, and a generic
|
|
48
|
+
block that already is the root block is never converted into itself. Both
|
|
49
|
+
keep root repairs convergent instead of undoing each other.
|
|
50
|
+
- Parent rules may provide `defaultChild` for structures such as lists, table
|
|
51
|
+
sections, and rows.
|
|
52
|
+
- Attributes make a wrapper meaningful. Such an element is lifted when a safe
|
|
53
|
+
ancestor exists and is never silently unwrapped or converted.
|
|
54
|
+
- Explicit nested `contenteditable` elements and atomic elements are never
|
|
55
|
+
removed by generic empty-node logic.
|
|
56
|
+
- Cached detached elements make repeated category checks allocation-free after
|
|
57
|
+
the first use; they are inspection prototypes, never inserted into the DOM.
|
|
58
|
+
|
|
59
|
+
The separate `RepairExecutor` applies these actions through `PointMap`. The
|
|
60
|
+
scoped `Normalizer` groups compatible plans, repeats them to a fixed point,
|
|
61
|
+
preserves registered points, and reports dirty scopes to an optional
|
|
62
|
+
transaction.
|
|
63
|
+
|
|
64
|
+
## TODO
|
|
65
|
+
|
|
66
|
+
- Add policy hooks for application-specific generic and meaningful wrappers.
|
|
67
|
+
- Make line-boundary planning configurable for non-HTML output policies.
|
|
68
|
+
- Distinguish whitespace that is author content from formatting whitespace.
|
|
69
|
+
- Add sibling-sequence repairs once the content model exposes ordered rules.
|
|
70
|
+
- Define safe fallback policies for `reject` without hiding content loss.
|