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,96 @@
1
+ # Configuration
2
+
3
+ `config.js` resolves inheritable CSS custom properties for one editable host.
4
+ Absent values and `auto` use semantic defaults derived from the host tag.
5
+
6
+ ## Contract
7
+
8
+ - `enabled(host)` detects the opt-in property `--u2-rte`.
9
+ - `hostDefaults(host)` returns a shared immutable default for block and Enter
10
+ behavior.
11
+ - `config(host)` returns an immutable snapshot of the current computed settings.
12
+ - CSS is read on demand, so class changes and media/container queries can alter
13
+ behavior without recreating a surface.
14
+ - Unknown enum values fall back safely instead of becoming implicit modes.
15
+ - A block value is used only when it is a usable lowercase tag name, so a typo,
16
+ quoted value, or value list cannot reach `createElement()`.
17
+
18
+ Current properties:
19
+
20
+ | Property | Values | Default |
21
+ | --- | --- | --- |
22
+ | `--u2-rte` | any truthy token | disabled |
23
+ | `--u2-rte-block` | tag name, `none`, `auto` | host-specific |
24
+ | `--u2-rte-enter` | `break`, `block`, `item`, `row`, `cell`, `auto` | host-specific |
25
+ | `--u2-rte-cleanup` | `none`, `minimal`, `structural`, `canonical` | `structural` |
26
+ | `--u2-rte-clean-on` | space- or comma-separated triggers | `input paste drop command` |
27
+ | `--u2-rte-elements` | tag list, `@basic`, `@article`, `@document`, `all` | `all` |
28
+ | `--u2-rte-import-unstyle` | `none` or an installed Unstyle level | `styles` |
29
+ | `--u2-rte-ui` | `none`, `roaming`, `static` | `roaming` |
30
+ | `--u2-rte-toolbar` | control names separated by spaces or commas | every represented control |
31
+ | `--u2-rte-toolbar-when` | `always`, `selection` | `always` |
32
+
33
+ `--u2-rte-enter` is consumed by the `enter` command: `break` inserts a line
34
+ break, every other value names the element Enter splits. `--u2-rte-ui: none`
35
+ hides a roaming `Toolbar`; `static` is reserved for the future static binding.
36
+ The convention client in `rte.js` currently represents only `bold`, so additional
37
+ toolbar names stay invisible until an optional module supplies both command and
38
+ control.
39
+ See [`../command/README.md`](../command/README.md) and
40
+ [`../ui/README.md`](../ui/README.md).
41
+
42
+ Semantic block defaults preserve native container shape: generic block hosts
43
+ such as `div` use `p`, lists use `li`, and table structure uses `tbody`, `tr`,
44
+ and `td`. A directly editable `li`, `caption`, `th`, or `td` keeps phrasing
45
+ content unwrapped and Enter inserts `br`. Any host can override this, including
46
+ opting those containers back into paragraphs with `--u2-rte-block: p` and
47
+ `--u2-rte-enter: block`.
48
+
49
+ Functions, command implementations, and policy objects are intentionally not
50
+ encoded as CSS strings. They enter through module registration.
51
+
52
+ `--u2-rte-elements` is a structural output policy shared by cleanup and
53
+ commands. An explicit list is application-specific; the frozen `basic`,
54
+ `article`, and `document` presets are exported as `elementPresets` and selected
55
+ in CSS with an `@` prefix:
56
+
57
+ ```css
58
+ .article-editor {
59
+ --u2-rte-elements: @article;
60
+ }
61
+ ```
62
+
63
+ An unknown preset or malformed list resolves to an empty allowlist rather than
64
+ silently broadening content. `all` retains the complete supplied content model.
65
+ The list controls elements only; it is deliberately not an attribute or URL
66
+ sanitizer.
67
+
68
+ `--u2-rte-import-unstyle` controls presentation cleanup for native paste and
69
+ drop. The default removes classes and inline styles only from elements added by
70
+ that input; `none` keeps native presentation. A custom `Unstyle` policy may
71
+ provide other level names.
72
+
73
+ `--u2-rte-classes` lists the class names the host treats as content. It is one
74
+ declaration with three consumers: the style control offers exactly those names,
75
+ the sanitizer keeps only those from external HTML, and presentation cleanup
76
+ leaves them and their wrappers alone. A host that declares none behaves exactly
77
+ as before.
78
+
79
+ `--u2-rte-import-elements` is what may *arrive*, a narrower question than
80
+ `--u2-rte-elements`, which is what a host tolerates in content it already owns.
81
+ It defaults to the `@content` preset: headings, text, lists, tables, media, and
82
+ the text-level semantics that carry meaning — no layout, no embeds, no forms.
83
+ The host list still bounds it; neither can widen the sanitize policy.
84
+
85
+ `--u2-rte-import-sanitize` switches that whole stage off with `none`, leaving
86
+ what the browser inserted. Which attributes and protocols are acceptable stays
87
+ in JavaScript with the policy — that is a security decision, not a presentation
88
+ one, and not something CSS should be carrying.
89
+
90
+ ## TODO
91
+
92
+ - Add named sanitize-policy references when applications need CSS selection
93
+ among several registered JavaScript policies.
94
+ - Decide whether typed `@property` declarations improve debugging without
95
+ restricting inheritance or future values.
96
+ - Test container-query and adopted-style-sheet changes in integration suites.
@@ -0,0 +1,134 @@
1
+ const INLINE_HOSTS = new Set([
2
+ 'A', 'ABBR', 'B', 'BUTTON', 'CODE', 'EM', 'H1', 'H2', 'H3', 'H4', 'H5',
3
+ 'H6', 'I', 'KBD', 'LABEL', 'P', 'PRE', 'Q', 'SAMP', 'SMALL', 'SPAN',
4
+ 'STRONG', 'SUB', 'SUP', 'TIME', 'U', 'VAR',
5
+ ]);
6
+ const UNWRAPPED_HOSTS = new Set(['LI', 'CAPTION', 'TH', 'TD']);
7
+ const CLEANUP = new Set(['none', 'minimal', 'structural', 'canonical']);
8
+ const ENTER = new Set(['break', 'block', 'item', 'row', 'cell']);
9
+ const UI = new Set(['none', 'roaming', 'static']);
10
+ const IMPORT_SANITIZE = new Set(['policy', 'none']);
11
+ const TAG = /^[a-z][a-z\d-]*$/;
12
+ const CLASS = /^-?[_a-zA-Z][\w-]*$/;
13
+ const FALSE = new Set(['0', 'false', 'none', 'off']);
14
+ const DEFAULT_CLEAN_ON = Object.freeze(['input', 'paste', 'drop', 'command']);
15
+ const EMPTY_ELEMENTS = Object.freeze([]);
16
+ export const elementPresets = Object.freeze({
17
+ basic: elements('p ul ol li a strong b em i code br'),
18
+ // What a content field is about: headings, text, lists, tables, media and
19
+ // the text-level semantics that carry meaning. No layout, no embeds, no
20
+ // forms. This is the import default: what may arrive is a narrower question
21
+ // than what a host tolerates in content it owns.
22
+ content: elements(`
23
+ p h1 h2 h3 h4 h5 h6 blockquote pre ul ol li table caption thead tbody
24
+ tfoot tr th td hr img a strong em code span br
25
+ `),
26
+ article: elements('p h1 h2 h3 h4 h5 h6 blockquote pre ul ol li hr a strong b em i u s del ins code kbd mark q small sub sup span br'),
27
+ document: elements('p h1 h2 h3 h4 h5 h6 blockquote pre ul ol li dl dt dd hr figure figcaption img table caption thead tbody tfoot tr th td a strong b em i u s del ins code kbd mark q small sub sup span br'),
28
+ });
29
+ const DEFAULTS = Object.freeze({
30
+ block: Object.freeze({block: 'p', enter: 'block'}),
31
+ text: Object.freeze({block: null, enter: 'break'}),
32
+ list: Object.freeze({block: 'li', enter: 'item'}),
33
+ table: Object.freeze({block: 'tbody', enter: 'row'}),
34
+ row: Object.freeze({block: 'tr', enter: 'row'}),
35
+ cell: Object.freeze({block: 'td', enter: 'cell'}),
36
+ });
37
+
38
+ export function enabled(host) {
39
+ const value = getComputedStyle(host).getPropertyValue('--u2-rte').trim().toLowerCase();
40
+ return value !== '' && !FALSE.has(value);
41
+ }
42
+
43
+ export function hostDefaults(host) {
44
+ const tag = host.tagName;
45
+ if (UNWRAPPED_HOSTS.has(tag)) return DEFAULTS.text;
46
+ if (tag === 'UL' || tag === 'OL') return DEFAULTS.list;
47
+ if (tag === 'TABLE') return DEFAULTS.table;
48
+ if (tag === 'THEAD' || tag === 'TBODY' || tag === 'TFOOT') return DEFAULTS.row;
49
+ if (tag === 'TR') return DEFAULTS.cell;
50
+ if (INLINE_HOSTS.has(tag)) return DEFAULTS.text;
51
+ return DEFAULTS.block;
52
+ }
53
+
54
+ // Only the setting the model narrowing wants. The whole configuration parses
55
+ // eleven properties, and reading a computed style is the expensive part of a
56
+ // path every keystroke and every availability check goes through.
57
+ export function configuredElements(host) {
58
+ return allowedElements(getComputedStyle(host), 'elements', null);
59
+ }
60
+
61
+ export function config(host) {
62
+ const style = getComputedStyle(host);
63
+ const defaults = hostDefaults(host);
64
+ const cleanOn = value(style, 'clean-on');
65
+ return Object.freeze({
66
+ block: tag(style, 'block', defaults.block),
67
+ enter: choice(style, 'enter', ENTER, defaults.enter),
68
+ cleanup: choice(style, 'cleanup', CLEANUP, 'structural'),
69
+ cleanOn: Object.freeze(cleanOn ? cleanOn.split(/[\s,]+/).filter(Boolean) : [...DEFAULT_CLEAN_ON]),
70
+ elements: allowedElements(style, 'elements', null),
71
+ importElements: allowedElements(style, 'import-elements', elementPresets.content),
72
+ classes: classNames(style),
73
+ // Foreign presentation through the class rung: pasted markup keeps no
74
+ // styles, no presentational attributes, and no undeclared classes.
75
+ importSanitize: choice(style, 'import-sanitize', IMPORT_SANITIZE, 'policy'),
76
+ importUnstyle: value(style, 'import-unstyle') || 'classes',
77
+ ui: choice(style, 'ui', UI, 'roaming'),
78
+ inlineUi: inlineNames(style),
79
+ });
80
+ }
81
+
82
+ // Which contextual UIs a field draws at its content — `table image link`. Unset
83
+ // means every one the loaded modules bring, `none` means the field stays plain
84
+ // while keeping the commands. The same modules then serve a body of text and a
85
+ // bare teaser field without a second editor.
86
+ export function inlineUi(config, name) {
87
+ return !config.inlineUi || config.inlineUi.includes(name);
88
+ }
89
+
90
+ function inlineNames(style) {
91
+ const result = value(style, 'inline-ui').toLowerCase();
92
+ if (!result) return null;
93
+ if (result === 'none') return EMPTY_ELEMENTS;
94
+ return Object.freeze([...new Set(result.split(/[\s,]+/).filter(Boolean))]);
95
+ }
96
+
97
+ function value(style, name) {
98
+ const value = style.getPropertyValue(`--u2-rte-${name}`).trim();
99
+ return value === 'auto' ? '' : value;
100
+ }
101
+
102
+ function tag(style, name, fallback) {
103
+ const result = value(style, name).toLowerCase();
104
+ if (result === 'none') return null;
105
+ return TAG.test(result) ? result : fallback;
106
+ }
107
+
108
+ function choice(style, name, choices, fallback) {
109
+ const result = value(style, name);
110
+ return choices.has(result) ? result : fallback;
111
+ }
112
+
113
+ // The class names this host treats as content. One declaration serves the style
114
+ // control, the sanitizer, and presentation cleanup, so a class the host knows is
115
+ // never offered without being allowed, or cleaned away as foreign.
116
+ function classNames(style) {
117
+ const result = value(style, 'classes');
118
+ if (!result) return EMPTY_ELEMENTS;
119
+ const names = result.split(/[\s,]+/).filter(Boolean);
120
+ return names.every(name => CLASS.test(name)) ? Object.freeze([...new Set(names)]) : EMPTY_ELEMENTS;
121
+ }
122
+
123
+ function allowedElements(style, name, fallback) {
124
+ const result = value(style, name).toLowerCase();
125
+ if (!result) return fallback;
126
+ if (result === 'all') return null;
127
+ if (result.startsWith('@')) return elementPresets[result.slice(1)] || EMPTY_ELEMENTS;
128
+ const names = result.split(/[\s,]+/).filter(Boolean);
129
+ return names.length && names.every(name => TAG.test(name)) ? Object.freeze([...new Set(names)]) : EMPTY_ELEMENTS;
130
+ }
131
+
132
+ function elements(value) {
133
+ return Object.freeze(value.trim().split(/\s+/));
134
+ }
@@ -0,0 +1,63 @@
1
+ # Core
2
+
3
+ The core is the shared coordinator for all editable elements in one document or
4
+ ShadowRoot. It listens for focus and selection changes once, knows which editor
5
+ is active, and routes work to that editor. It does not decide formatting,
6
+ document structure, or UI behavior.
7
+
8
+ `core.js` implements this coordinator and registers each editable element as a
9
+ `Surface`.
10
+
11
+ ## Contract
12
+
13
+ - One core may coordinate any number of explicit editable surfaces.
14
+ - `add()` is idempotent and rejects non-editable or foreign-root elements.
15
+ - With `auto` enabled, focusing an element opted in through `--u2-rte` registers
16
+ it lazily. Explicit registration does not require that CSS property.
17
+ - Capture-phase `focus` and `focusin` share one idempotent route to tolerate
18
+ browser and document-lifecycle differences without duplicate activation.
19
+ - The nearest explicit nested `contenteditable` is an isolation boundary.
20
+ - `sync()` resolves the current selection to one registered surface and captures
21
+ it without scanning or mutating unrelated editor contents. A selection is not
22
+ a session: once focus has left the editor, only focus starts one again.
23
+ Engines leave a selection inside an editable that nobody focused — clicking
24
+ beside one does that — and a toolbar over a caret the keyboard cannot reach is
25
+ worse than no toolbar. The surface still keeps what it captured.
26
+ - At most one surface is active per core; active state may outlive DOM focus so
27
+ a roaming UI can safely receive focus.
28
+ - `retain(element)` marks editor-owned UI in the same document. Focus inside it
29
+ keeps the active surface, `retains(node)` identifies its composed subtree,
30
+ and `release(element)` removes the mark. This also distinguishes UI from
31
+ content when a native top-layer boundary requires both to share one host.
32
+ - A link, a button, or a label around an editing host is answered by the element,
33
+ not by the text: the link is dragged rather than giving the text a caret and
34
+ followed when the press ends, and the label hands the press to the control it
35
+ names. Both are suppressed for a press that belongs to
36
+ the text, and the link is left undraggable — nobody drags a link wrapped
37
+ around editable content on purpose.
38
+ - The right button never moves the selection inside a surface: its menu is about
39
+ what is selected, and aiming the menu somewhere else is what would make it
40
+ useless.
41
+ - A press decides which surface a focus belongs to. Engines hand the focus to
42
+ the nearest editable when a press lands beside one, which an inline host in
43
+ running text collects a whole line of; a focus whose press did not land in the
44
+ surface is refused and given back. A press answers for every focus until it is
45
+ released — one drag can hand the focus back more than once — and a key taking
46
+ over ends it too, so a keyboard reaching a surface is never mistaken for it.
47
+ - Focus that lands anywhere else — not a surface, not retained UI — ends the
48
+ session, so everything drawn for it goes at once. So does focus that lands
49
+ nowhere: clicking a plain paragraph focuses nothing, and no focus event
50
+ follows to say so, which is what `focusout` is listened to for. Retaining is
51
+ therefore how a UI that takes focus stays alive; `Toolbar` retains its element
52
+ itself.
53
+ - `dispose()` removes listeners and disconnects every surface. It is
54
+ idempotent; `[Symbol.dispose]()` exposes the same teardown to `using`.
55
+
56
+ Separate cores are supported for iframes, ShadowRoots, tests, and intentionally
57
+ isolated environments. The public entry point creates one default document core.
58
+
59
+ ## TODO
60
+
61
+ - Observe removed auto-registered hosts without watching their contents.
62
+ - Complete cross-browser ShadowRoot selection routing.
63
+ - Add module lifecycle registration at the core boundary.
@@ -0,0 +1,247 @@
1
+ import {enabled} from '../config/config.js';
2
+ import {interactiveAround} from '../browser/interactive.js';
3
+ import {editingHost, isEditableHost, isEditingBoundary} from '../selection/ownership/ownership.js';
4
+ import {Surface} from '../surface/surface.js';
5
+
6
+ export class Rte extends EventTarget {
7
+ #root;
8
+ #document;
9
+ #options;
10
+ #surfaces = new Set();
11
+ #elements = new WeakMap();
12
+ #retained = new Set();
13
+ #active = null;
14
+ #unfocused = false;
15
+ #pointer = null;
16
+ #handed = false;
17
+ #controller;
18
+
19
+ constructor(root = document, options = {}) {
20
+ super();
21
+ const isDocument = root?.nodeType === Node.DOCUMENT_NODE;
22
+ const isShadow = root?.nodeType === Node.DOCUMENT_FRAGMENT_NODE && root.host;
23
+ if (!isDocument && !isShadow) throw new TypeError('RTE root must be a Document or ShadowRoot');
24
+ this.#root = root;
25
+ this.#document = isDocument ? root : root.ownerDocument;
26
+ this.#options = Object.freeze({auto: options.auto !== false});
27
+ this.#controller = new this.#document.defaultView.AbortController();
28
+ const listen = {capture: true, signal: this.#controller.signal};
29
+ root.addEventListener('focus', this.#focus, listen);
30
+ root.addEventListener('focusin', this.#focus, listen);
31
+ root.addEventListener('focusout', this.#focusOut, listen);
32
+ // A press answers for every focus until it is over: one drag can hand the
33
+ // focus back more than once. A key taking over ends it too, so what is
34
+ // left of a release that never arrived cannot answer for the keyboard.
35
+ root.addEventListener('pointerdown', this.#pointerDown, listen);
36
+ for (const type of ['pointerup', 'pointercancel', 'keydown']) {
37
+ root.addEventListener(type, this.#pointerDone, listen);
38
+ }
39
+ root.addEventListener('click', this.#click, listen);
40
+ this.#document.addEventListener('selectionchange', this.#selectionChange, {signal: this.#controller.signal});
41
+ }
42
+
43
+ get root() { return this.#root; }
44
+ get options() { return this.#options; }
45
+ get active() { return this.#active; }
46
+ get surfaces() { return [...this.#surfaces]; }
47
+ get selection() { return this.#root.getSelection?.() || this.#document.getSelection(); }
48
+
49
+ // Retained UI may receive focus without deactivating the current surface.
50
+ // When it temporarily has to live inside a top-layer editing host, content
51
+ // readers also use this boundary to leave it out of the document.
52
+ retain(element) {
53
+ if (element?.nodeType !== Node.ELEMENT_NODE) throw new TypeError('Retained UI must be an element');
54
+ if (element.ownerDocument !== this.#document) throw new RangeError('Retained UI must belong to the core document');
55
+ this.#retained.add(element);
56
+ return element;
57
+ }
58
+
59
+ release(element) {
60
+ return this.#retained.delete(element);
61
+ }
62
+
63
+ retains(node) {
64
+ for (let current = node; current;) {
65
+ if (this.#retained.has(current)) return true;
66
+ const root = current.getRootNode?.();
67
+ current = current.parentNode || root?.host || null;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ add(element, options) {
73
+ if (!isEditableHost(element)) {
74
+ throw new TypeError('RTE surfaces must be explicit contenteditable elements');
75
+ }
76
+ if (element.getRootNode() !== this.#root) throw new RangeError('RTE surface must belong to the core root');
77
+ const current = this.#elements.get(element);
78
+ if (current) return current;
79
+ const surface = new Surface(this, element, options);
80
+ this.#elements.set(element, surface);
81
+ this.#surfaces.add(surface);
82
+ this.#emit('u2-rte-add', {surface});
83
+ return surface;
84
+ }
85
+
86
+ get(element) {
87
+ return this.#elements.get(element) || null;
88
+ }
89
+
90
+ delete(target) {
91
+ const surface = target instanceof Surface ? target : this.#elements.get(target);
92
+ if (!surface || !this.#surfaces.has(surface)) return false;
93
+ if (this.#active === surface) this.activate(null);
94
+ this.#surfaces.delete(surface);
95
+ this.#elements.delete(surface.element);
96
+ surface.disconnect();
97
+ this.#emit('u2-rte-delete', {surface});
98
+ return true;
99
+ }
100
+
101
+ activate(surface) {
102
+ if (surface && !this.#surfaces.has(surface)) throw new RangeError('Active surface must belong to this core');
103
+ if (surface === this.#active) return surface;
104
+ this.#active?.activate(false);
105
+ this.#active = surface;
106
+ surface?.activate();
107
+ this.#emit('u2-rte-activechange', {surface});
108
+ return surface;
109
+ }
110
+
111
+ sync() {
112
+ const selection = this.selection;
113
+ const editable = editingHost(selection?.anchorNode);
114
+ if (!editable) return null;
115
+ const surface = this.#elements.get(editable);
116
+ if (!surface || !selection.focusNode || !surface.element.contains(selection.focusNode)) {
117
+ this.activate(null);
118
+ return null;
119
+ }
120
+ if (!surface.capture(selection)) {
121
+ this.activate(null);
122
+ return null;
123
+ }
124
+ // A selection is not a session: once the focus has left the editor, only
125
+ // focus brings it back. Engines leave a selection inside an editable that
126
+ // nobody focused — clicking beside one does that — and a toolbar over a
127
+ // caret the keyboard cannot reach is worse than no toolbar. What the
128
+ // surface captured, it keeps either way.
129
+ if (this.#unfocused && this.#active !== surface) return null;
130
+ this.activate(surface);
131
+ return surface;
132
+ }
133
+
134
+ dispose() {
135
+ if (this.#controller.signal.aborted) return;
136
+ this.#controller.abort();
137
+ for (const surface of [...this.#surfaces]) this.delete(surface);
138
+ this.#retained.clear();
139
+ this.#emit('u2-rte-dispose');
140
+ }
141
+
142
+ [Symbol.dispose]() {
143
+ this.dispose();
144
+ }
145
+
146
+ #focus = event => {
147
+ const path = event.composedPath();
148
+ if (path.some(node => this.#retained.has(node))) {
149
+ this.#unfocused = false;
150
+ return;
151
+ }
152
+ const editable = path.find(node =>
153
+ node?.nodeType === Node.ELEMENT_NODE && isEditingBoundary(node)
154
+ );
155
+ // Focus that went somewhere else entirely ends the session: retained UI
156
+ // is already excluded above, so what is left is not the editor's, and
157
+ // everything drawn for it has to go with it.
158
+ if (!editable || !isEditableHost(editable)) {
159
+ this.#unfocused = true;
160
+ this.activate(null);
161
+ return;
162
+ }
163
+ let surface = this.#elements.get(editable);
164
+ if (!surface && this.#options.auto && enabled(editable)) surface = this.add(editable);
165
+ // An engine gives the focus to the nearest editable when a press lands
166
+ // beside one: an inline host collects its whole line. A press that did
167
+ // not land in this surface is nobody starting to edit it, and the caret
168
+ // it would leave behind is one no one asked for.
169
+ //
170
+ // Taking it back is the one thing here that reaches into the document:
171
+ // the element really is focused for a moment, so the page sees a
172
+ // focus/blur pair it did not ask for, and anything listening for either
173
+ // on that element — a host's own handler, a css `:focus` rule — sees it
174
+ // too. Leaving it focused instead is worse: a caret that types into a
175
+ // surface with no session behind it.
176
+ if (surface && this.#pointer && !this.#pointer.includes(surface.element)) {
177
+ this.#unfocused = true;
178
+ this.activate(null);
179
+ // Handing the focus back once per press. An engine that answers a
180
+ // blur by focusing again — the selection is still in there, after
181
+ // all — would otherwise trade focus with the editor for as long as
182
+ // the button is down, and the session stays refused either way.
183
+ if (!this.#handed) {
184
+ this.#handed = true;
185
+ editable.blur();
186
+ }
187
+ return;
188
+ }
189
+ this.#unfocused = !surface;
190
+ this.activate(surface || null);
191
+ };
192
+
193
+ // Focus falling back to the document is the one way a session ends without
194
+ // a focus event to end it: nothing is focused, so nothing announces it.
195
+ // A related target inside a surface or in retained UI is `#focus`'s to
196
+ // decide — deactivating here would end a session that is only moving.
197
+ #focusOut = event => {
198
+ if (this.#owns(event.relatedTarget)) return;
199
+ this.#unfocused = true;
200
+ this.activate(null);
201
+ };
202
+
203
+ // The editor's world: one of its surfaces, or UI it retains.
204
+ #owns(node) {
205
+ return !!node && (this.retains(node) || this.#elements.has(editingHost(node)));
206
+ }
207
+
208
+ #pointerDown = event => {
209
+ const path = event.composedPath();
210
+ this.#pointer = path;
211
+ this.#handed = false;
212
+ const editing = this.#editing(path[0]);
213
+ // The right button opens a menu about what is selected; moving the
214
+ // selection to what it was aimed at is what makes the menu useless.
215
+ if (event.button === 2 && editing) event.preventDefault();
216
+ // The drag has to be off before this very press is acted on, which is
217
+ // what a capturing listener is early enough for. It stays off: a link
218
+ // wrapping editable content is not one anybody drags on purpose.
219
+ const around = interactiveAround(editing);
220
+ if (around) around.draggable = false;
221
+ };
222
+
223
+ // The press belonged to the text, and so does its release.
224
+ #click = event => {
225
+ if (interactiveAround(this.#editing(event.composedPath()[0]))) event.preventDefault();
226
+ };
227
+
228
+ // The editing host a node sits in, if this core is the editor of it.
229
+ #editing(node) {
230
+ const host = editingHost(node);
231
+ if (!host) return null;
232
+ return this.#elements.has(host) || this.#options.auto && enabled(host) ? host : null;
233
+ }
234
+
235
+ #pointerDone = () => {
236
+ this.#pointer = null;
237
+ };
238
+
239
+ #selectionChange = () => {
240
+ this.sync();
241
+ };
242
+
243
+ #emit(type, detail = {}) {
244
+ const Event = this.#document.defaultView.CustomEvent;
245
+ return this.dispatchEvent(new Event(type, {detail: {core: this, ...detail}}));
246
+ }
247
+ }
@@ -0,0 +1,83 @@
1
+ # History
2
+
3
+ Undo and redo for one surface.
4
+
5
+ RTE deliberately leaves ordinary typing and deletion to the browser, so history
6
+ cannot be an operation log: it would only ever see the edits the engine itself
7
+ performs. `history.js` therefore records *states*. A `MutationObserver` reports
8
+ every change regardless of its origin — native input, a command, paste, drop, or
9
+ an unrelated application script — and an entry stores the resulting content as a
10
+ cloned `DocumentFragment` with a path-addressed selection.
11
+
12
+ Because entries are complete states, undo never has to reverse a mutation it did
13
+ not understand. That is what makes a mixed native/engine editing model undoable
14
+ at all.
15
+
16
+ ## Contract
17
+
18
+ - `new History(surface, {limit = 100, coalesce = 400})` starts with the current
19
+ content as its baseline entry and observes the surface from that moment.
20
+ - `record()` commits the current content as one entry and reports whether it
21
+ added one. Unchanged content adds nothing, so any boundary may flush safely.
22
+ - `undo()` and `redo()` move one entry and report whether they moved. Both
23
+ commit uncommitted input first, so undo leaves the state the user sees.
24
+ - `canUndo` and `canRedo` account for uncommitted input: pending input is
25
+ undoable, and it invalidates a redo branch before it is recorded.
26
+ - `commands` is the frozen `{undo, redo}` pair for a `Commands` registry. They
27
+ claim the `historyUndo` and `historyRedo` input types and run without a
28
+ transaction, because an entry replaces the content and restores the selection
29
+ itself. Like every other command they require the surface to own the edit's
30
+ range, so a shortcut never reaches an editor the user has left; a toolbar
31
+ that still holds a saved selection passes it and keeps working.
32
+ - The methods are unconditional: `undo()` and `redo()` are the programmatic API
33
+ and do not consult the selection.
34
+ - `clear()` discards every entry and restarts from the current content.
35
+ - Recording a new state drops the redo branch. Exceeding `limit` drops the
36
+ oldest entry.
37
+ - `u2-rte-history` reports every entry and every move on the surface.
38
+ - `dispose()` stops observing and releases every entry. A disconnected surface
39
+ disposes its history.
40
+ - Core-retained UI mutations are not edits. Snapshots omit those subtrees and
41
+ undo/redo preserves them, including when shared chrome is temporarily mounted
42
+ directly in a top-layer editing host.
43
+
44
+ ## Grouping
45
+
46
+ Transactions supply the boundaries, so grouping needs no separate timer policy:
47
+
48
+ - A transaction with the `input` trigger is ordinary typing and coalesces.
49
+ - Every other transaction — `command`, `paste`, `drop` — records one entry
50
+ before and one after itself, so a command is always its own undo step.
51
+ - Native input that never reaches a transaction coalesces through the observer.
52
+ - Deactivating the surface flushes.
53
+
54
+ Coalescing keeps one open interval instead of restarting it on every keystroke,
55
+ so continuous typing still becomes several undo steps rather than one.
56
+
57
+ ## Selection
58
+
59
+ `SelectionSnapshot` holds live nodes and cannot survive replaced content. An
60
+ entry therefore stores each boundary as a child-index path from the surface
61
+ element plus an offset, and rebuilds a `SelectionSnapshot` on restore so
62
+ backward selections keep their direction. An entry captured before the surface
63
+ had a selection adopts the first one it sees, so undoing back to the baseline
64
+ restores a caret too.
65
+
66
+ ## Browser considerations
67
+
68
+ - Ctrl/Command+Z and +Y are routed from `keydown` by the input pipeline, not
69
+ from `beforeinput`. A browser stops reporting `historyUndo` once its own undo
70
+ stack no longer matches content the engine replaced. The `inputTypes` claim
71
+ remains so that a menu- or gesture-triggered undo is captured as well.
72
+ - Restoring content takes the observer's own records so an applied entry can
73
+ never record itself.
74
+
75
+ ## TODO
76
+
77
+ - Expose the coalescing interval and an off switch as `--u2-rte-history`.
78
+ - Store diffs instead of whole fragments once the transaction layer records
79
+ reversible operations, and keep this state model as the fallback for
80
+ mutations the engine does not own.
81
+ - Group composition deliberately instead of relying on its mutations alone.
82
+ - Verify entry cost on large documents and decide whether `limit` should be
83
+ measured in bytes rather than entries.