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.
Files changed (146) 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 +487 -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 +553 -0
  24. package/js/rte/playground/index.html +835 -0
  25. package/js/rte/rte.js +56 -5
  26. package/js/rte/run.out +17 -0
  27. package/js/rte/source.js +12 -0
  28. package/js/rte/src/browser/README.md +40 -0
  29. package/js/rte/src/browser/interactive.js +17 -0
  30. package/js/rte/src/browser/range-rect.js +82 -0
  31. package/js/rte/src/client/README.md +385 -0
  32. package/js/rte/src/client/blocks.js +38 -0
  33. package/js/rte/src/client/breaks.js +212 -0
  34. package/js/rte/src/client/classes.js +48 -0
  35. package/js/rte/src/client/editor.js +472 -0
  36. package/js/rte/src/client/external-input.js +47 -0
  37. package/js/rte/src/client/history.js +13 -0
  38. package/js/rte/src/client/images.js +285 -0
  39. package/js/rte/src/client/link.js +567 -0
  40. package/js/rte/src/client/marks.js +25 -0
  41. package/js/rte/src/client/source.js +182 -0
  42. package/js/rte/src/client/structure.js +27 -0
  43. package/js/rte/src/client/tables.js +165 -0
  44. package/js/rte/src/client/unstyle.js +32 -0
  45. package/js/rte/src/command/README.md +520 -0
  46. package/js/rte/src/command/block-boundary.js +43 -0
  47. package/js/rte/src/command/block-style.js +120 -0
  48. package/js/rte/src/command/commands.js +159 -0
  49. package/js/rte/src/command/delete.js +119 -0
  50. package/js/rte/src/command/edit.js +78 -0
  51. package/js/rte/src/command/element.js +45 -0
  52. package/js/rte/src/command/enter.js +175 -0
  53. package/js/rte/src/command/fragment.js +76 -0
  54. package/js/rte/src/command/insert.js +52 -0
  55. package/js/rte/src/command/list.js +284 -0
  56. package/js/rte/src/command/mark.js +583 -0
  57. package/js/rte/src/command/pending-marks.js +216 -0
  58. package/js/rte/src/command/table.js +207 -0
  59. package/js/rte/src/command/unstyle.js +248 -0
  60. package/js/rte/src/config/README.md +96 -0
  61. package/js/rte/src/config/config.js +134 -0
  62. package/js/rte/src/core/README.md +63 -0
  63. package/js/rte/src/core/core.js +247 -0
  64. package/js/rte/src/history/README.md +83 -0
  65. package/js/rte/src/history/history.js +283 -0
  66. package/js/rte/src/input/README.md +173 -0
  67. package/js/rte/src/input/external-input.js +130 -0
  68. package/js/rte/src/input/input-pipeline.js +459 -0
  69. package/js/rte/src/mark/README.md +219 -0
  70. package/js/rte/src/mark/dom-adapter.js +100 -0
  71. package/js/rte/src/mark/mark.js +133 -0
  72. package/js/rte/src/mark/standard.js +76 -0
  73. package/js/rte/src/model/README.md +71 -0
  74. package/js/rte/src/model/content-model.js +213 -0
  75. package/js/rte/src/model/html/README.md +51 -0
  76. package/js/rte/src/model/html/html-model.js +90 -0
  77. package/js/rte/src/normalize/execute/README.md +33 -0
  78. package/js/rte/src/normalize/execute/repair-executor.js +129 -0
  79. package/js/rte/src/normalize/normalizer/README.md +48 -0
  80. package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
  81. package/js/rte/src/normalize/repair/README.md +70 -0
  82. package/js/rte/src/normalize/repair/repair-planner.js +208 -0
  83. package/js/rte/src/sanitize/README.md +108 -0
  84. package/js/rte/src/sanitize/native.js +43 -0
  85. package/js/rte/src/sanitize/policy.js +206 -0
  86. package/js/rte/src/selection/README.md +39 -0
  87. package/js/rte/src/selection/map/README.md +53 -0
  88. package/js/rte/src/selection/map/point-map.js +307 -0
  89. package/js/rte/src/selection/ownership/README.md +32 -0
  90. package/js/rte/src/selection/ownership/ownership.js +42 -0
  91. package/js/rte/src/selection/point/README.md +34 -0
  92. package/js/rte/src/selection/point/point.js +83 -0
  93. package/js/rte/src/selection/range/README.md +38 -0
  94. package/js/rte/src/selection/range/edit-range.js +223 -0
  95. package/js/rte/src/selection/snapshot.js +64 -0
  96. package/js/rte/src/source/README.md +64 -0
  97. package/js/rte/src/source/source.js +193 -0
  98. package/js/rte/src/surface/README.md +36 -0
  99. package/js/rte/src/surface/content.js +16 -0
  100. package/js/rte/src/surface/surface.js +87 -0
  101. package/js/rte/src/transaction/README.md +35 -0
  102. package/js/rte/src/transaction/transaction.js +56 -0
  103. package/js/rte/src/ui/README.md +271 -0
  104. package/js/rte/src/ui/caret.js +17 -0
  105. package/js/rte/src/ui/chrome.js +214 -0
  106. package/js/rte/src/ui/handles.js +179 -0
  107. package/js/rte/src/ui/highlight.js +86 -0
  108. package/js/rte/src/ui/place.js +25 -0
  109. package/js/rte/src/ui/toolbar.js +258 -0
  110. package/js/rte/src/unstyle/README.md +103 -0
  111. package/js/rte/src/unstyle/unstyle.js +140 -0
  112. package/js/rte/tables.js +7 -0
  113. package/js/rte/unstyle.js +8 -0
  114. package/js/rte0/README.md +37 -0
  115. package/js/rte0/rte.js +7 -0
  116. package/js/{rte → rte0}/src/behavior.js +0 -1
  117. package/js/{rte → rte0}/src/events.js +0 -1
  118. package/js/{rte → rte0}/src/range.js +0 -2
  119. package/js/{rte → rte0}/src/ui.items.js +0 -2
  120. package/js/{rte → rte0}/src/ui.js +0 -1
  121. package/package.json +1 -1
  122. package/u2/Element.js +2 -2
  123. package/u2/plan-scoped-refactoring.md +133 -303
  124. package/u2/projects.json +18 -2
  125. package/u2/tools/update.repos.json.js +1 -1
  126. package/el/qrcode/package.json +0 -22
  127. /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
  128. /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
  129. /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
  130. /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
  131. /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
  132. /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
  133. /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
  134. /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
  135. /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
  136. /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
  137. /package/js/{rte → rte0}/old/Rte/main.css +0 -0
  138. /package/js/{rte → rte0}/old/old.rte.js +0 -0
  139. /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
  140. /package/js/{rte → rte0}/src/fixes.js +0 -0
  141. /package/js/{rte → rte0}/src/rte.js +0 -0
  142. /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
  143. /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
  144. /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
  145. /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
  146. /package/js/{rte → rte0}/src/util/indentHtml.js +0 -0
@@ -0,0 +1,223 @@
1
+ import {belongsTo, elementOf, isEditingBoundary, selectionOf} from '../ownership/ownership.js';
2
+ import {Point} from '../point/point.js';
3
+
4
+ export class EditRange {
5
+ #root;
6
+ #range;
7
+
8
+ constructor(root, range) {
9
+ if (!root?.hasAttribute?.('contenteditable')) throw new TypeError('An edit range requires an explicit editable root');
10
+ if (!range?.cloneRange) throw new TypeError('An edit range requires a native Range');
11
+ if (!belongsTo(range.startContainer, root) || !belongsTo(range.endContainer, root)) {
12
+ throw new RangeError('Range boundaries must belong to the editable root');
13
+ }
14
+ this.#root = root;
15
+ this.#range = range.cloneRange();
16
+ }
17
+
18
+ static fromSelection(selection, root) {
19
+ return selection?.rangeCount ? this.fromRange(selection.getRangeAt(0), root) : null;
20
+ }
21
+
22
+ static fromRange(range, root) {
23
+ try {
24
+ return new this(root, range);
25
+ } catch (error) {
26
+ if (error instanceof RangeError) return null;
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ static fromPoints(start, end, root) {
32
+ if (!(start instanceof Point) || !(end instanceof Point)) throw new TypeError('Edit range boundaries must be points');
33
+ if (start.compare(end) > 0) throw new RangeError('Edit range start must not follow its end');
34
+ const range = root.ownerDocument.createRange();
35
+ range.setStart(start.node, start.offset);
36
+ range.setEnd(end.node, end.offset);
37
+ return new this(root, range);
38
+ }
39
+
40
+ get root() { return this.#root; }
41
+ get collapsed() { return this.#range.collapsed; }
42
+ get text() { return this.#range.toString(); }
43
+ get commonAncestor() { return this.#range.commonAncestorContainer; }
44
+ get start() { return Point.fromRange(this.#range, 'start'); }
45
+ get end() { return Point.fromRange(this.#range, 'end'); }
46
+
47
+ range() {
48
+ return this.#range.cloneRange();
49
+ }
50
+
51
+ clone() {
52
+ return new this.constructor(this.#root, this.#range);
53
+ }
54
+
55
+ setStart(point) {
56
+ this.#assertPoint(point);
57
+ if (point.compare(this.end) > 0) throw new RangeError('Range start must not follow its end');
58
+ this.#range.setStart(point.node, point.offset);
59
+ return this;
60
+ }
61
+
62
+ setEnd(point) {
63
+ this.#assertPoint(point);
64
+ if (this.start.compare(point) > 0) throw new RangeError('Range end must not precede its start');
65
+ this.#range.setEnd(point.node, point.offset);
66
+ return this;
67
+ }
68
+
69
+ collapse(edge = 'start') {
70
+ if (edge !== 'start' && edge !== 'end') throw new TypeError('Collapse edge must be start or end');
71
+ this.#range.collapse(edge === 'start');
72
+ return this;
73
+ }
74
+
75
+ select(selection = selectionOf(this.#root), backward = false) {
76
+ if (!selection) return false;
77
+ const range = this.#range;
78
+ if (selection.setBaseAndExtent) {
79
+ selection.setBaseAndExtent(
80
+ backward ? range.endContainer : range.startContainer,
81
+ backward ? range.endOffset : range.startOffset,
82
+ backward ? range.startContainer : range.endContainer,
83
+ backward ? range.startOffset : range.endOffset,
84
+ );
85
+ } else {
86
+ selection.removeAllRanges();
87
+ selection.addRange(range.cloneRange());
88
+ }
89
+ return true;
90
+ }
91
+
92
+ splitTextBoundaries() {
93
+ if (this.#range.collapsed) return this;
94
+ let node = this.#range.endContainer;
95
+ let offset = this.#range.endOffset;
96
+ if (node.nodeType === Node.TEXT_NODE && offset > 0 && offset < node.length) {
97
+ node.splitText(offset);
98
+ this.#range.setEnd(node, offset);
99
+ }
100
+ node = this.#range.startContainer;
101
+ offset = this.#range.startOffset;
102
+ if (node.nodeType === Node.TEXT_NODE && offset > 0 && offset < node.length) {
103
+ const selected = node.splitText(offset);
104
+ this.#range.setStart(selected, 0);
105
+ }
106
+ return this;
107
+ }
108
+
109
+ intersects(node) {
110
+ if (!belongsTo(node, this.#root)) return false;
111
+ try {
112
+ return this.#range.intersectsNode(node);
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ contains(node) {
119
+ if (!this.intersects(node)) return false;
120
+ if (node.nodeType === Node.TEXT_NODE) {
121
+ if (node === this.#range.startContainer && this.#range.startOffset !== 0) return false;
122
+ if (node === this.#range.endContainer && this.#range.endOffset !== node.length) return false;
123
+ return true;
124
+ }
125
+ const range = node.ownerDocument.createRange();
126
+ range.selectNode(node);
127
+ return this.#range.compareBoundaryPoints(0, range) <= 0
128
+ && this.#range.compareBoundaryPoints(2, range) >= 0;
129
+ }
130
+
131
+ textNodes() {
132
+ if (this.#range.collapsed) return [];
133
+ const document = this.#root.ownerDocument;
134
+ const filter = document.defaultView.NodeFilter;
135
+ const walker = document.createTreeWalker(this.#root, filter.SHOW_ELEMENT | filter.SHOW_TEXT, {
136
+ acceptNode: node => {
137
+ if (node !== this.#root && isEditingBoundary(node)) return filter.FILTER_REJECT;
138
+ if (node.nodeType !== Node.TEXT_NODE) return filter.FILTER_SKIP;
139
+ return selectedText(this.#range, node) ? filter.FILTER_ACCEPT : filter.FILTER_REJECT;
140
+ },
141
+ });
142
+ const nodes = [];
143
+ while (walker.nextNode()) nodes.push(walker.currentNode);
144
+ return nodes;
145
+ }
146
+
147
+ blocks(match) {
148
+ if (typeof match !== 'function') throw new TypeError('Block matching requires a predicate');
149
+ if (this.#range.collapsed) {
150
+ const block = closest(this.#range.startContainer, this.#root, match);
151
+ return block ? [block] : [];
152
+ }
153
+ const document = this.#root.ownerDocument;
154
+ const filter = document.defaultView.NodeFilter;
155
+ const start = Point.fromRange(this.#range, 'start');
156
+ const end = Point.fromRange(this.#range, 'end');
157
+ const walker = document.createTreeWalker(this.#root, filter.SHOW_ELEMENT | filter.SHOW_TEXT, {
158
+ acceptNode: node => {
159
+ if (node !== this.#root && isEditingBoundary(node)) return filter.FILTER_REJECT;
160
+ if (node.childNodes.length) return filter.FILTER_SKIP;
161
+ if (node.nodeType === Node.TEXT_NODE) {
162
+ return selectedText(this.#range, node) ? filter.FILTER_ACCEPT : filter.FILTER_REJECT;
163
+ }
164
+ return overlaps(start, end, node) ? filter.FILTER_ACCEPT : filter.FILTER_REJECT;
165
+ },
166
+ });
167
+ const blocks = new Set();
168
+ while (walker.nextNode()) {
169
+ const block = closest(walker.currentNode, this.#root, match);
170
+ if (block) blocks.add(block);
171
+ }
172
+ return [...blocks].sort(treeOrder);
173
+ }
174
+
175
+ roots() {
176
+ if (this.#range.collapsed) return [];
177
+ const nodes = [];
178
+ const visit = parent => {
179
+ for (const node of parent.childNodes) {
180
+ if (isEditingBoundary(node)) continue;
181
+ if (!this.intersects(node)) continue;
182
+ if (this.contains(node)) nodes.push(node);
183
+ else if (node.childNodes.length) visit(node);
184
+ }
185
+ };
186
+ const common = this.#range.commonAncestorContainer;
187
+ visit(common.nodeType === Node.TEXT_NODE ? common.parentNode : common);
188
+ return nodes;
189
+ }
190
+
191
+ #assertPoint(point) {
192
+ if (!(point instanceof Point)) throw new TypeError('Range boundaries must be points');
193
+ if (!belongsTo(point.node, this.#root)) throw new RangeError('Point must belong to the editable root');
194
+ }
195
+ }
196
+
197
+ function selectedText(range, node) {
198
+ if (!node.length || !range.intersectsNode(node)) return false;
199
+ if (range.startContainer === node && range.endContainer === node) return range.startOffset < range.endOffset;
200
+ if (range.startContainer === node && range.startOffset === node.length) return false;
201
+ if (range.endContainer === node && range.endOffset === 0) return false;
202
+ return true;
203
+ }
204
+
205
+ function closest(node, root, match) {
206
+ let element = elementOf(node);
207
+ while (element && element !== root) {
208
+ if (match(element)) return element;
209
+ element = element.parentElement;
210
+ }
211
+ return null;
212
+ }
213
+
214
+ function treeOrder(node, other) {
215
+ return node.compareDocumentPosition(other) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
216
+ }
217
+
218
+ function overlaps(start, end, node) {
219
+ const nodeRange = node.ownerDocument.createRange();
220
+ nodeRange.selectNode(node);
221
+ return start.compare(Point.fromRange(nodeRange, 'end')) < 0
222
+ && end.compare(Point.fromRange(nodeRange, 'start')) > 0;
223
+ }
@@ -0,0 +1,64 @@
1
+ import {belongsTo, selectionOf} from './ownership/ownership.js';
2
+
3
+ export class SelectionSnapshot {
4
+ #root;
5
+ #range;
6
+ #backward;
7
+
8
+ constructor(root, range, backward = false) {
9
+ this.#root = root;
10
+ this.#range = range.cloneRange();
11
+ this.#backward = backward && !range.collapsed;
12
+ }
13
+
14
+ static capture(selection, root) {
15
+ if (!selection?.rangeCount || !selection.anchorNode || !selection.focusNode) return null;
16
+ if (!belongsTo(selection.anchorNode, root) || !belongsTo(selection.focusNode, root)) return null;
17
+ const range = selection.getRangeAt(0);
18
+ const backward = !range.collapsed
19
+ && selection.anchorNode === range.endContainer
20
+ && selection.anchorOffset === range.endOffset;
21
+ return new this(root, range, backward);
22
+ }
23
+
24
+ get root() { return this.#root; }
25
+ get collapsed() { return this.#range.collapsed; }
26
+ get backward() { return this.#backward; }
27
+ get text() { return this.#range.toString(); }
28
+
29
+ range() {
30
+ return this.#range.cloneRange();
31
+ }
32
+
33
+ equals(snapshot) {
34
+ if (!(snapshot instanceof SelectionSnapshot)) return false;
35
+ const range = snapshot.#range;
36
+ return this.#root === snapshot.#root
37
+ && this.#backward === snapshot.#backward
38
+ && this.#range.startContainer === range.startContainer
39
+ && this.#range.startOffset === range.startOffset
40
+ && this.#range.endContainer === range.endContainer
41
+ && this.#range.endOffset === range.endOffset;
42
+ }
43
+
44
+ valid() {
45
+ return belongsTo(this.#range.startContainer, this.#root)
46
+ && belongsTo(this.#range.endContainer, this.#root);
47
+ }
48
+
49
+ restore(selection = selectionOf(this.#root)) {
50
+ if (!selection || !this.valid()) return false;
51
+ const range = this.#range;
52
+ if (selection.setBaseAndExtent) {
53
+ const anchorNode = this.#backward ? range.endContainer : range.startContainer;
54
+ const anchorOffset = this.#backward ? range.endOffset : range.startOffset;
55
+ const focusNode = this.#backward ? range.startContainer : range.endContainer;
56
+ const focusOffset = this.#backward ? range.startOffset : range.endOffset;
57
+ selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
58
+ } else {
59
+ selection.removeAllRanges();
60
+ selection.addRange(range.cloneRange());
61
+ }
62
+ return true;
63
+ }
64
+ }
@@ -0,0 +1,64 @@
1
+ # Source
2
+
3
+ Reads and writes one surface's content as HTML text, so an application can offer
4
+ direct source editing without giving up the engine's guarantees.
5
+
6
+ `source.js` provides the whole responsibility; the dialog that shows the text is
7
+ UI and belongs to a client module.
8
+
9
+ ## Contract
10
+
11
+ - `new Source(surface, {sanitizer, indent})` needs nothing but a surface. The
12
+ default sanitizer is a `NativeSanitizer`; `indent` must be whitespace.
13
+ - `read()` returns `{html, start, end}`. The offsets point into `html` and
14
+ locate the current selection, or are null when the surface does not own one.
15
+ Reading never mutates the surface.
16
+ - `write(html)` replaces the whole content and returns the inserted nodes. It
17
+ runs as one transaction with the `source` command trigger, so it becomes one
18
+ undo step, and it leaves the caret at the start of the new content.
19
+ - Writing rejects anything but a string.
20
+ - Core-retained UI is omitted from HTML and survives replacement. A direct
21
+ modal, popover, or fullscreen editing host therefore never exposes its
22
+ temporarily nested editor chrome as source content.
23
+
24
+ ## Reading
25
+
26
+ The writer walks the live DOM instead of post-processing a serialized string, so
27
+ it cannot invent markup, and it reports where DOM boundaries land in its output
28
+ as it goes — no marker nodes are inserted into the content being read. Escaping
29
+ is accounted for, so an offset inside `a &amp; b` is exact.
30
+
31
+ A level is broken into one node per line only when nothing on it can be affected
32
+ by added whitespace: every child is a block element, a comment, or whitespace
33
+ that is already there. Inline content therefore always stays on one line, and
34
+ the whitespace formatting adds is never significant.
35
+
36
+ ## Writing
37
+
38
+ Source text is external input even when the same user just read it out, so it
39
+ always goes through the configured sanitizer, which parses through
40
+ `Element.setHTML()`. Nothing is ever assigned to `innerHTML`. The surface's
41
+ `--u2-rte-elements` narrows the sanitizer further, so a host cannot be filled
42
+ with elements it does not allow.
43
+
44
+ The line breaks reading added are removed again before insertion: a whitespace
45
+ node between block siblings is formatting, not content. This is the exact
46
+ inverse of the rule reading used, so reading and writing the same text is a
47
+ round trip.
48
+
49
+ ## Browser considerations
50
+
51
+ - `Element.setHTML()` is required. `NativeSanitizer.supported()` reports whether
52
+ it exists; there is deliberately no unsafe fallback parser.
53
+
54
+ ## TODO
55
+
56
+ - Let non-interactive consumers replace content without changing selection or
57
+ focus. Initializing a form adapter must not move the caret into its surface;
58
+ the current `write()` always restores a selection at the start.
59
+ - Map the dialog's own selection back into the DOM after a write, the way
60
+ reading maps the other direction.
61
+ - Serialize through a canonical serializer once one exists, so attribute order
62
+ and boolean attributes are stable across engines.
63
+ - Report where a write's sanitizer removed content, so a source view can tell
64
+ the user what was dropped instead of silently applying less than was typed.
@@ -0,0 +1,193 @@
1
+ import {htmlModel} from '../model/html/html-model.js';
2
+ import {narrow} from '../command/edit.js';
3
+ import {NativeSanitizer} from '../sanitize/native.js';
4
+ import {SelectionSnapshot} from '../selection/snapshot.js';
5
+ import {replaceContent} from '../surface/content.js';
6
+
7
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
8
+
9
+ // Reads and writes one surface's content as HTML text.
10
+ //
11
+ // Reading serializes the live DOM and reports where the current selection falls
12
+ // in that text, so a source view can open exactly where the caret is. It walks
13
+ // the DOM instead of post-processing a string, so it cannot invent markup, and
14
+ // it never mutates the surface to place markers.
15
+ //
16
+ // Writing is the only path that turns text back into content, and it always
17
+ // goes through the configured sanitizer: source text is external input even
18
+ // when the same user just read it out.
19
+ export class Source {
20
+ #surface;
21
+ #sanitizer;
22
+ #indent;
23
+
24
+ constructor(surface, {sanitizer = new NativeSanitizer(), indent = ' '} = {}) {
25
+ if (surface?.element?.nodeType !== Node.ELEMENT_NODE || typeof surface?.transact !== 'function') {
26
+ throw new TypeError('A source view requires an editor surface');
27
+ }
28
+ if (typeof sanitizer?.sanitize !== 'function') throw new TypeError('A source view requires a sanitizer');
29
+ if (typeof indent !== 'string' || indent.trim()) throw new TypeError('Source indentation must be whitespace');
30
+ this.#surface = surface;
31
+ this.#sanitizer = sanitizer;
32
+ this.#indent = indent;
33
+ }
34
+
35
+ get surface() { return this.#surface; }
36
+ get sanitizer() { return this.#sanitizer; }
37
+
38
+ // `{html, start, end}`. The offsets point into `html`, or are null when the
39
+ // surface does not currently own a selection.
40
+ read() {
41
+ const root = this.#surface.element;
42
+ const snapshot = SelectionSnapshot.capture(this.#surface.core.selection, root) || this.#surface.selection;
43
+ const range = snapshot?.valid() ? snapshot.range() : null;
44
+ const marks = range ? [
45
+ {node: range.startContainer, offset: range.startOffset},
46
+ {node: range.endContainer, offset: range.endOffset},
47
+ ] : [];
48
+ const writer = new Writer(this.#model(), this.#indent, marks, node => this.#surface.core.retains(node));
49
+ writer.children(root, 0);
50
+ // The outermost separated level wraps the whole document; that first and
51
+ // last break are framing, not content.
52
+ const lead = writer.text.startsWith('\n') ? 1 : 0;
53
+ const html = writer.text.slice(lead).replace(/\n$/, '');
54
+ const clamp = value => value === null ? null : Math.min(Math.max(value - lead, 0), html.length);
55
+ const [start = null, end = null] = writer.offsets;
56
+ return {html, start: clamp(start), end: clamp(end)};
57
+ }
58
+
59
+ // Replaces the whole content and returns the inserted nodes.
60
+ write(html) {
61
+ if (typeof html !== 'string') throw new TypeError('Source must be written as a string');
62
+ const root = this.#surface.element;
63
+ const settings = this.#surface.config;
64
+ const fragment = this.#sanitizer.sanitize(html, {
65
+ document: root.ownerDocument,
66
+ elements: settings.elements ?? undefined,
67
+ classes: settings.classes.length ? settings.classes : null,
68
+ });
69
+ // The breaks between blocks are what reading added for legibility; they
70
+ // are not content and must not return as text nodes.
71
+ prune(fragment, this.#model());
72
+ const nodes = [...fragment.childNodes];
73
+ this.#surface.transact(transaction => {
74
+ replaceContent(this.#surface, fragment);
75
+ transaction.touch(root);
76
+ const range = root.ownerDocument.createRange();
77
+ range.setStart(root, 0);
78
+ range.collapse(true);
79
+ new SelectionSnapshot(root, range).restore(this.#surface.core.selection);
80
+ }, {trigger: 'command', command: 'source'});
81
+ return nodes;
82
+ }
83
+
84
+ #model() {
85
+ return narrow(htmlModel, this.#surface.config.elements);
86
+ }
87
+ }
88
+
89
+ // One indented pass over the DOM that also reports where given DOM boundaries
90
+ // land in its output. A level gets one element per line only where every child
91
+ // is a block, so the whitespace this adds is never significant.
92
+ class Writer {
93
+ #model;
94
+ #indent;
95
+ #marks;
96
+ #skip;
97
+ text = '';
98
+ offsets;
99
+
100
+ constructor(model, indent, marks, skip) {
101
+ this.#model = model;
102
+ this.#indent = indent;
103
+ this.#marks = marks;
104
+ this.#skip = skip;
105
+ this.offsets = marks.map(() => null);
106
+ }
107
+
108
+ children(parent, depth) {
109
+ const lines = separated(parent, this.#model, this.#skip);
110
+ const nodes = [...parent.childNodes];
111
+ for (const [index, child] of nodes.entries()) {
112
+ this.#at(parent, index);
113
+ if (this.#skip(child)) continue;
114
+ if (lines && blank(child)) continue;
115
+ if (lines) this.#line(depth);
116
+ this.node(child, depth);
117
+ }
118
+ this.#at(parent, nodes.length);
119
+ if (lines) this.#line(depth - 1);
120
+ }
121
+
122
+ node(node, depth) {
123
+ if (node.nodeType === Node.TEXT_NODE) return this.#characters(node);
124
+ if (node.nodeType === Node.COMMENT_NODE) {
125
+ this.text += `<!--${node.data}-->`;
126
+ return;
127
+ }
128
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
129
+ const name = node.localName;
130
+ this.text += `<${name}${attributes(node)}>`;
131
+ if (VOID.has(name)) return;
132
+ this.children(node, depth + 1);
133
+ this.text += `</${name}>`;
134
+ }
135
+
136
+ #characters(node) {
137
+ for (const [index, mark] of this.#marks.entries()) {
138
+ if (this.offsets[index] === null && mark.node === node) {
139
+ this.offsets[index] = this.text.length + escape(node.data.slice(0, mark.offset)).length;
140
+ }
141
+ }
142
+ this.text += escape(node.data);
143
+ }
144
+
145
+ #at(parent, index) {
146
+ for (const [position, mark] of this.#marks.entries()) {
147
+ if (this.offsets[position] === null && mark.node === parent && mark.offset === index) {
148
+ this.offsets[position] = this.text.length;
149
+ }
150
+ }
151
+ }
152
+
153
+ #line(depth) {
154
+ this.text += '\n' + this.#indent.repeat(Math.max(depth, 0));
155
+ }
156
+ }
157
+
158
+ // A level is broken into lines only where nothing on it can be affected by
159
+ // added whitespace: block elements, comments, and whitespace already there.
160
+ function separated(parent, model, skip = () => false) {
161
+ const nodes = [...parent.childNodes].filter(node => !skip(node));
162
+ return nodes.some(node => standalone(node, model))
163
+ && nodes.every(node => blank(node) || standalone(node, model));
164
+ }
165
+
166
+ function standalone(node, model) {
167
+ return node.nodeType === Node.COMMENT_NODE
168
+ || node.nodeType === Node.ELEMENT_NODE && model.block(node);
169
+ }
170
+
171
+ function blank(node) {
172
+ return node.nodeType === Node.TEXT_NODE && !node.data.trim();
173
+ }
174
+
175
+ function prune(parent, model) {
176
+ for (const child of parent.childNodes) {
177
+ if (child.nodeType === Node.ELEMENT_NODE) prune(child, model);
178
+ }
179
+ if (!separated(parent, model)) return;
180
+ for (const child of [...parent.childNodes]) if (blank(child)) child.remove();
181
+ }
182
+
183
+ function attributes(element) {
184
+ let result = '';
185
+ for (const attribute of element.attributes) {
186
+ result += ` ${attribute.name}="${escape(attribute.value).replaceAll('"', '&quot;')}"`;
187
+ }
188
+ return result;
189
+ }
190
+
191
+ function escape(value) {
192
+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
193
+ }
@@ -0,0 +1,36 @@
1
+ # Editable surfaces
2
+
3
+ `surface.js` represents one explicit `contenteditable` host registered with a
4
+ core. It owns host-specific configuration, saved selection, active state, and
5
+ the current transaction while the core owns document-level routing.
6
+
7
+ ## Contract
8
+
9
+ - CSS configuration is resolved dynamically through `surface.config`.
10
+ - `capture()` accepts only selections belonging to this editable host.
11
+ - Identical captures reuse the current snapshot and do not emit duplicate
12
+ selection events.
13
+ - `restore()` restores the last valid selection, including its direction.
14
+ - `transact()` creates one atomic transaction; nested calls reuse it.
15
+ - Lifecycle, selection, and change events reach both the `Surface` EventTarget
16
+ and the DOM host as bubbling, composed `u2-rte-*` events. The DOM host is
17
+ notified first, so a listener there observes an event before the modules that
18
+ react to it, and `u2-rte-change` still arrives last as the completed change.
19
+ - `dispose()` unregisters through the owning core; `[Symbol.dispose]()` exposes
20
+ it to `using`, and `disconnect()` is idempotent.
21
+ - No mutable state is stored globally or shared accidentally between surfaces.
22
+
23
+ `content.js` supplies the internal content boundary used by whole-document
24
+ consumers. Direct children retained by the core are editor UI, not surface
25
+ content: replacement preserves them and traversal leaves them out. This matters
26
+ only while native modal, popover, or fullscreen isolation temporarily requires
27
+ the shared chrome to be a descendant of the editable host.
28
+
29
+ The surface gains schema, normalization, pending marks, history, and composition
30
+ state through modules rather than new globals.
31
+
32
+ ## TODO
33
+
34
+ - Add typed module state slots without central registration boilerplate.
35
+ - Coordinate IME composition and native input with transaction grouping.
36
+ - Decide how disabled/read-only transitions affect saved selections and UIs.
@@ -0,0 +1,16 @@
1
+ // Editor UI normally lives outside a surface. Native top-layer isolation is
2
+ // the exception: a modal dialog, popover, or fullscreen host can only keep UI
3
+ // interactive when that UI is its flat-tree descendant. Retained nodes are
4
+ // therefore DOM children but never document content.
5
+ export function contentChildren(surface, parent = surface.element) {
6
+ return [...parent.childNodes].filter(node => !surface.core.retains(node));
7
+ }
8
+
9
+ export function replaceContent(surface, ...nodes) {
10
+ const root = surface.element;
11
+ const retained = [...root.childNodes].filter(node => surface.core.retains(node));
12
+ for (const node of [...root.childNodes]) if (!surface.core.retains(node)) node.remove();
13
+ const fragment = root.ownerDocument.createDocumentFragment();
14
+ fragment.append(...nodes);
15
+ root.insertBefore(fragment, retained[0] || null);
16
+ }
@@ -0,0 +1,87 @@
1
+ import {config} from '../config/config.js';
2
+ import {SelectionSnapshot} from '../selection/snapshot.js';
3
+ import {Transaction} from '../transaction/transaction.js';
4
+
5
+ export class Surface extends EventTarget {
6
+ #core;
7
+ #element;
8
+ #options;
9
+ #selection = null;
10
+ #transaction = null;
11
+ #active = false;
12
+ #connected = true;
13
+
14
+ constructor(core, element, options = {}) {
15
+ super();
16
+ this.#core = core;
17
+ this.#element = element;
18
+ this.#options = Object.freeze({...options});
19
+ }
20
+
21
+ get core() { return this.#core; }
22
+ get element() { return this.#element; }
23
+ get options() { return this.#options; }
24
+ get config() { return config(this.#element); }
25
+ get selection() { return this.#selection; }
26
+ get transaction() { return this.#transaction; }
27
+ get active() { return this.#active; }
28
+ get connected() { return this.#connected; }
29
+
30
+ capture(selection = this.#core.selection) {
31
+ const snapshot = SelectionSnapshot.capture(selection, this.#element);
32
+ if (!snapshot) return null;
33
+ if (this.#selection?.equals(snapshot)) return this.#selection;
34
+ this.#selection = snapshot;
35
+ this.emit('u2-rte-selectionchange', {selection: snapshot});
36
+ return snapshot;
37
+ }
38
+
39
+ restore() {
40
+ return this.#selection?.restore(this.#core.selection) || false;
41
+ }
42
+
43
+ transact(change, options) {
44
+ if (!this.#connected) throw new DOMException('The surface is disconnected', 'InvalidStateError');
45
+ if (this.#transaction) return change(this.#transaction);
46
+ const transaction = new Transaction(this, options);
47
+ this.#transaction = transaction;
48
+ try {
49
+ return transaction.run(change);
50
+ } finally {
51
+ this.#transaction = null;
52
+ }
53
+ }
54
+
55
+ activate(active = true) {
56
+ if (this.#active === active) return this;
57
+ this.#active = active;
58
+ this.emit(active ? 'u2-rte-activate' : 'u2-rte-deactivate');
59
+ return this;
60
+ }
61
+
62
+ // The DOM host is notified first: modules listening on the surface act on
63
+ // an event, so observers would otherwise see the consequence before it.
64
+ emit(type, detail = {}, options = {}) {
65
+ const init = {detail: {surface: this, ...detail}, cancelable: options.cancelable};
66
+ const Event = this.#element.ownerDocument.defaultView.CustomEvent;
67
+ const dom = this.#element.dispatchEvent(new Event(type, {...init, bubbles: true, composed: true}));
68
+ const local = this.dispatchEvent(new Event(type, init));
69
+ return local && dom;
70
+ }
71
+
72
+ dispose() {
73
+ this.#core.delete(this);
74
+ }
75
+
76
+ [Symbol.dispose]() {
77
+ this.dispose();
78
+ }
79
+
80
+ disconnect() {
81
+ if (!this.#connected) return;
82
+ this.activate(false);
83
+ this.#connected = false;
84
+ this.#selection = null;
85
+ this.emit('u2-rte-disconnect');
86
+ }
87
+ }