u2ui 1.5.2 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/el/ico/ico.js +5 -5
  2. package/el/rte/README.md +53 -13
  3. package/el/rte/manifest.json +19 -4
  4. package/el/rte/rte.css +31 -6
  5. package/el/rte/rte.js +174 -44
  6. package/el/rte0/README.md +37 -0
  7. package/el/rte0/manifest.json +33 -0
  8. package/el/rte0/rte0.css +9 -0
  9. package/el/rte0/rte0.js +56 -0
  10. package/el/system/styler.js +2 -0
  11. package/el/tooltip/tooltip.css +2 -2
  12. package/js/Placer/Placer.js +2 -1
  13. package/js/rte/AGENTS.md +142 -0
  14. package/js/rte/PLAN.md +488 -0
  15. package/js/rte/README.md +319 -25
  16. package/js/rte/blocks.js +7 -0
  17. package/js/rte/breaks.js +6 -0
  18. package/js/rte/classes.js +7 -0
  19. package/js/rte/docs/usage.md +845 -0
  20. package/js/rte/images.js +7 -0
  21. package/js/rte/link.js +8 -0
  22. package/js/rte/playground/README.md +95 -0
  23. package/js/rte/playground/hosts.html +518 -0
  24. package/js/rte/playground/index.html +824 -0
  25. package/js/rte/rte.js +56 -5
  26. package/js/rte/source.js +12 -0
  27. package/js/rte/src/browser/README.md +40 -0
  28. package/js/rte/src/browser/range-rect.js +82 -0
  29. package/js/rte/src/client/README.md +378 -0
  30. package/js/rte/src/client/blocks.js +38 -0
  31. package/js/rte/src/client/breaks.js +212 -0
  32. package/js/rte/src/client/classes.js +48 -0
  33. package/js/rte/src/client/editor.js +461 -0
  34. package/js/rte/src/client/external-input.js +47 -0
  35. package/js/rte/src/client/history.js +13 -0
  36. package/js/rte/src/client/images.js +275 -0
  37. package/js/rte/src/client/link.js +572 -0
  38. package/js/rte/src/client/marks.js +25 -0
  39. package/js/rte/src/client/source.js +182 -0
  40. package/js/rte/src/client/structure.js +27 -0
  41. package/js/rte/src/client/tables.js +162 -0
  42. package/js/rte/src/client/unstyle.js +32 -0
  43. package/js/rte/src/command/README.md +517 -0
  44. package/js/rte/src/command/block-boundary.js +43 -0
  45. package/js/rte/src/command/block-style.js +120 -0
  46. package/js/rte/src/command/commands.js +158 -0
  47. package/js/rte/src/command/delete.js +112 -0
  48. package/js/rte/src/command/edit.js +72 -0
  49. package/js/rte/src/command/element.js +45 -0
  50. package/js/rte/src/command/enter.js +175 -0
  51. package/js/rte/src/command/fragment.js +76 -0
  52. package/js/rte/src/command/insert.js +52 -0
  53. package/js/rte/src/command/list.js +284 -0
  54. package/js/rte/src/command/mark.js +583 -0
  55. package/js/rte/src/command/pending-marks.js +216 -0
  56. package/js/rte/src/command/table.js +207 -0
  57. package/js/rte/src/command/unstyle.js +248 -0
  58. package/js/rte/src/config/README.md +96 -0
  59. package/js/rte/src/config/config.js +127 -0
  60. package/js/rte/src/core/README.md +41 -0
  61. package/js/rte/src/core/core.js +156 -0
  62. package/js/rte/src/history/README.md +83 -0
  63. package/js/rte/src/history/history.js +283 -0
  64. package/js/rte/src/input/README.md +155 -0
  65. package/js/rte/src/input/external-input.js +130 -0
  66. package/js/rte/src/input/input-pipeline.js +414 -0
  67. package/js/rte/src/mark/README.md +219 -0
  68. package/js/rte/src/mark/dom-adapter.js +100 -0
  69. package/js/rte/src/mark/mark.js +133 -0
  70. package/js/rte/src/mark/standard.js +76 -0
  71. package/js/rte/src/model/README.md +71 -0
  72. package/js/rte/src/model/content-model.js +213 -0
  73. package/js/rte/src/model/html/README.md +51 -0
  74. package/js/rte/src/model/html/html-model.js +90 -0
  75. package/js/rte/src/normalize/execute/README.md +33 -0
  76. package/js/rte/src/normalize/execute/repair-executor.js +129 -0
  77. package/js/rte/src/normalize/normalizer/README.md +48 -0
  78. package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
  79. package/js/rte/src/normalize/repair/README.md +70 -0
  80. package/js/rte/src/normalize/repair/repair-planner.js +208 -0
  81. package/js/rte/src/sanitize/README.md +105 -0
  82. package/js/rte/src/sanitize/native.js +43 -0
  83. package/js/rte/src/sanitize/policy.js +202 -0
  84. package/js/rte/src/selection/README.md +39 -0
  85. package/js/rte/src/selection/map/README.md +53 -0
  86. package/js/rte/src/selection/map/point-map.js +307 -0
  87. package/js/rte/src/selection/ownership/README.md +32 -0
  88. package/js/rte/src/selection/ownership/ownership.js +42 -0
  89. package/js/rte/src/selection/point/README.md +34 -0
  90. package/js/rte/src/selection/point/point.js +83 -0
  91. package/js/rte/src/selection/range/README.md +38 -0
  92. package/js/rte/src/selection/range/edit-range.js +223 -0
  93. package/js/rte/src/selection/snapshot.js +64 -0
  94. package/js/rte/src/source/README.md +64 -0
  95. package/js/rte/src/source/source.js +193 -0
  96. package/js/rte/src/surface/README.md +36 -0
  97. package/js/rte/src/surface/content.js +16 -0
  98. package/js/rte/src/surface/surface.js +87 -0
  99. package/js/rte/src/transaction/README.md +35 -0
  100. package/js/rte/src/transaction/transaction.js +56 -0
  101. package/js/rte/src/ui/README.md +255 -0
  102. package/js/rte/src/ui/chrome.js +214 -0
  103. package/js/rte/src/ui/handles.js +179 -0
  104. package/js/rte/src/ui/place.js +25 -0
  105. package/js/rte/src/ui/toolbar.js +283 -0
  106. package/js/rte/src/unstyle/README.md +103 -0
  107. package/js/rte/src/unstyle/unstyle.js +140 -0
  108. package/js/rte/tables.js +7 -0
  109. package/js/rte/unstyle.js +8 -0
  110. package/js/rte0/README.md +37 -0
  111. package/js/rte0/rte.js +7 -0
  112. package/js/{rte → rte0}/src/behavior.js +0 -1
  113. package/js/{rte → rte0}/src/events.js +0 -1
  114. package/js/{rte → rte0}/src/range.js +0 -2
  115. package/js/{rte → rte0}/src/ui.items.js +0 -2
  116. package/js/{rte → rte0}/src/ui.js +0 -1
  117. package/package.json +1 -1
  118. package/u2/Element.js +16 -4
  119. package/u2/enhance.js +2 -1
  120. package/u2/plan-scoped-refactoring.md +133 -304
  121. package/u2/projects.json +18 -2
  122. package/u2/tools/update.repos.json.js +1 -1
  123. package/u2/utils.js +0 -16
  124. package/el/qrcode/package.json +0 -22
  125. /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
  126. /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
  127. /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
  128. /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
  129. /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
  130. /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
  131. /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
  132. /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
  133. /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
  134. /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
  135. /package/js/{rte → rte0}/old/Rte/main.css +0 -0
  136. /package/js/{rte → rte0}/old/old.rte.js +0 -0
  137. /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
  138. /package/js/{rte → rte0}/src/fixes.js +0 -0
  139. /package/js/{rte → rte0}/src/rte.js +0 -0
  140. /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
  141. /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
  142. /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
  143. /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
  144. /package/js/{rte → rte0}/src/util/indentHtml.js +0 -0
@@ -0,0 +1,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.