u2ui 1.5.3 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) 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 +2 -2
  119. package/u2/plan-scoped-refactoring.md +133 -303
  120. package/u2/projects.json +18 -2
  121. package/u2/tools/update.repos.json.js +1 -1
  122. package/el/qrcode/package.json +0 -22
  123. /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
  124. /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
  125. /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
  126. /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
  127. /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
  128. /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
  129. /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
  130. /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
  131. /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
  132. /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
  133. /package/js/{rte → rte0}/old/Rte/main.css +0 -0
  134. /package/js/{rte → rte0}/old/old.rte.js +0 -0
  135. /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
  136. /package/js/{rte → rte0}/src/fixes.js +0 -0
  137. /package/js/{rte → rte0}/src/rte.js +0 -0
  138. /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
  139. /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
  140. /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
  141. /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
  142. /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,127 @@
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
+ export function config(host) {
55
+ const style = getComputedStyle(host);
56
+ const defaults = hostDefaults(host);
57
+ const cleanOn = value(style, 'clean-on');
58
+ return Object.freeze({
59
+ block: tag(style, 'block', defaults.block),
60
+ enter: choice(style, 'enter', ENTER, defaults.enter),
61
+ cleanup: choice(style, 'cleanup', CLEANUP, 'structural'),
62
+ cleanOn: Object.freeze(cleanOn ? cleanOn.split(/[\s,]+/).filter(Boolean) : [...DEFAULT_CLEAN_ON]),
63
+ elements: allowedElements(style, 'elements', null),
64
+ importElements: allowedElements(style, 'import-elements', elementPresets.content),
65
+ classes: classNames(style),
66
+ // Foreign presentation through the class rung: pasted markup keeps no
67
+ // styles, no presentational attributes, and no undeclared classes.
68
+ importSanitize: choice(style, 'import-sanitize', IMPORT_SANITIZE, 'policy'),
69
+ importUnstyle: value(style, 'import-unstyle') || 'classes',
70
+ ui: choice(style, 'ui', UI, 'roaming'),
71
+ inlineUi: inlineNames(style),
72
+ });
73
+ }
74
+
75
+ // Which contextual UIs a field draws at its content — `table image link`. Unset
76
+ // means every one the loaded modules bring, `none` means the field stays plain
77
+ // while keeping the commands. The same modules then serve a body of text and a
78
+ // bare teaser field without a second editor.
79
+ export function inlineUi(config, name) {
80
+ return !config.inlineUi || config.inlineUi.includes(name);
81
+ }
82
+
83
+ function inlineNames(style) {
84
+ const result = value(style, 'inline-ui').toLowerCase();
85
+ if (!result) return null;
86
+ if (result === 'none') return EMPTY_ELEMENTS;
87
+ return Object.freeze([...new Set(result.split(/[\s,]+/).filter(Boolean))]);
88
+ }
89
+
90
+ function value(style, name) {
91
+ const value = style.getPropertyValue(`--u2-rte-${name}`).trim();
92
+ return value === 'auto' ? '' : value;
93
+ }
94
+
95
+ function tag(style, name, fallback) {
96
+ const result = value(style, name).toLowerCase();
97
+ if (result === 'none') return null;
98
+ return TAG.test(result) ? result : fallback;
99
+ }
100
+
101
+ function choice(style, name, choices, fallback) {
102
+ const result = value(style, name);
103
+ return choices.has(result) ? result : fallback;
104
+ }
105
+
106
+ // The class names this host treats as content. One declaration serves the style
107
+ // control, the sanitizer, and presentation cleanup, so a class the host knows is
108
+ // never offered without being allowed, or cleaned away as foreign.
109
+ function classNames(style) {
110
+ const result = value(style, 'classes');
111
+ if (!result) return EMPTY_ELEMENTS;
112
+ const names = result.split(/[\s,]+/).filter(Boolean);
113
+ return names.every(name => CLASS.test(name)) ? Object.freeze([...new Set(names)]) : EMPTY_ELEMENTS;
114
+ }
115
+
116
+ function allowedElements(style, name, fallback) {
117
+ const result = value(style, name).toLowerCase();
118
+ if (!result) return fallback;
119
+ if (result === 'all') return null;
120
+ if (result.startsWith('@')) return elementPresets[result.slice(1)] || EMPTY_ELEMENTS;
121
+ const names = result.split(/[\s,]+/).filter(Boolean);
122
+ return names.length && names.every(name => TAG.test(name)) ? Object.freeze([...new Set(names)]) : EMPTY_ELEMENTS;
123
+ }
124
+
125
+ function elements(value) {
126
+ return Object.freeze(value.trim().split(/\s+/));
127
+ }
@@ -0,0 +1,41 @@
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.
22
+ - At most one surface is active per core; active state may outlive DOM focus so
23
+ a roaming UI can safely receive focus.
24
+ - `retain(element)` marks editor-owned UI in the same document. Focus inside it
25
+ keeps the active surface, `retains(node)` identifies its composed subtree,
26
+ and `release(element)` removes the mark. This also distinguishes UI from
27
+ content when a native top-layer boundary requires both to share one host.
28
+ - Focus that lands anywhere else — not a surface, not retained UI — ends the
29
+ session, so everything drawn for it goes at once. Retaining is therefore how a
30
+ UI that takes focus stays alive; `Toolbar` retains its element itself.
31
+ - `dispose()` removes listeners and disconnects every surface. It is
32
+ idempotent; `[Symbol.dispose]()` exposes the same teardown to `using`.
33
+
34
+ Separate cores are supported for iframes, ShadowRoots, tests, and intentionally
35
+ isolated environments. The public entry point creates one default document core.
36
+
37
+ ## TODO
38
+
39
+ - Observe removed auto-registered hosts without watching their contents.
40
+ - Complete cross-browser ShadowRoot selection routing.
41
+ - Add module lifecycle registration at the core boundary.
@@ -0,0 +1,156 @@
1
+ import {enabled} from '../config/config.js';
2
+ import {editingHost, isEditableHost, isEditingBoundary} from '../selection/ownership/ownership.js';
3
+ import {Surface} from '../surface/surface.js';
4
+
5
+ export class Rte extends EventTarget {
6
+ #root;
7
+ #document;
8
+ #options;
9
+ #surfaces = new Set();
10
+ #elements = new WeakMap();
11
+ #retained = new Set();
12
+ #active = null;
13
+ #controller;
14
+
15
+ constructor(root = document, options = {}) {
16
+ super();
17
+ const isDocument = root?.nodeType === Node.DOCUMENT_NODE;
18
+ const isShadow = root?.nodeType === Node.DOCUMENT_FRAGMENT_NODE && root.host;
19
+ if (!isDocument && !isShadow) throw new TypeError('RTE root must be a Document or ShadowRoot');
20
+ this.#root = root;
21
+ this.#document = isDocument ? root : root.ownerDocument;
22
+ this.#options = Object.freeze({auto: options.auto !== false});
23
+ this.#controller = new this.#document.defaultView.AbortController();
24
+ const listen = {capture: true, signal: this.#controller.signal};
25
+ root.addEventListener('focus', this.#focus, listen);
26
+ root.addEventListener('focusin', this.#focus, listen);
27
+ this.#document.addEventListener('selectionchange', this.#selectionChange, {signal: this.#controller.signal});
28
+ }
29
+
30
+ get root() { return this.#root; }
31
+ get options() { return this.#options; }
32
+ get active() { return this.#active; }
33
+ get surfaces() { return [...this.#surfaces]; }
34
+ get selection() { return this.#root.getSelection?.() || this.#document.getSelection(); }
35
+
36
+ // Retained UI may receive focus without deactivating the current surface.
37
+ // When it temporarily has to live inside a top-layer editing host, content
38
+ // readers also use this boundary to leave it out of the document.
39
+ retain(element) {
40
+ if (element?.nodeType !== Node.ELEMENT_NODE) throw new TypeError('Retained UI must be an element');
41
+ if (element.ownerDocument !== this.#document) throw new RangeError('Retained UI must belong to the core document');
42
+ this.#retained.add(element);
43
+ return element;
44
+ }
45
+
46
+ release(element) {
47
+ return this.#retained.delete(element);
48
+ }
49
+
50
+ retains(node) {
51
+ for (let current = node; current;) {
52
+ if (this.#retained.has(current)) return true;
53
+ const root = current.getRootNode?.();
54
+ current = current.parentNode || root?.host || null;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ add(element, options) {
60
+ if (!isEditableHost(element)) {
61
+ throw new TypeError('RTE surfaces must be explicit contenteditable elements');
62
+ }
63
+ if (element.getRootNode() !== this.#root) throw new RangeError('RTE surface must belong to the core root');
64
+ const current = this.#elements.get(element);
65
+ if (current) return current;
66
+ const surface = new Surface(this, element, options);
67
+ this.#elements.set(element, surface);
68
+ this.#surfaces.add(surface);
69
+ this.#emit('u2-rte-add', {surface});
70
+ return surface;
71
+ }
72
+
73
+ get(element) {
74
+ return this.#elements.get(element) || null;
75
+ }
76
+
77
+ delete(target) {
78
+ const surface = target instanceof Surface ? target : this.#elements.get(target);
79
+ if (!surface || !this.#surfaces.has(surface)) return false;
80
+ if (this.#active === surface) this.activate(null);
81
+ this.#surfaces.delete(surface);
82
+ this.#elements.delete(surface.element);
83
+ surface.disconnect();
84
+ this.#emit('u2-rte-delete', {surface});
85
+ return true;
86
+ }
87
+
88
+ activate(surface) {
89
+ if (surface && !this.#surfaces.has(surface)) throw new RangeError('Active surface must belong to this core');
90
+ if (surface === this.#active) return surface;
91
+ this.#active?.activate(false);
92
+ this.#active = surface;
93
+ surface?.activate();
94
+ this.#emit('u2-rte-activechange', {surface});
95
+ return surface;
96
+ }
97
+
98
+ sync() {
99
+ const selection = this.selection;
100
+ const editable = editingHost(selection?.anchorNode);
101
+ if (!editable) return null;
102
+ const surface = this.#elements.get(editable);
103
+ if (!surface || !selection.focusNode || !surface.element.contains(selection.focusNode)) {
104
+ this.activate(null);
105
+ return null;
106
+ }
107
+ if (!surface.capture(selection)) {
108
+ this.activate(null);
109
+ return null;
110
+ }
111
+ this.activate(surface);
112
+ return surface;
113
+ }
114
+
115
+ dispose() {
116
+ if (this.#controller.signal.aborted) return;
117
+ this.#controller.abort();
118
+ for (const surface of [...this.#surfaces]) this.delete(surface);
119
+ this.#retained.clear();
120
+ this.#emit('u2-rte-dispose');
121
+ }
122
+
123
+ [Symbol.dispose]() {
124
+ this.dispose();
125
+ }
126
+
127
+ #focus = event => {
128
+ if (event.composedPath().some(node => this.#retained.has(node))) return;
129
+ const editable = event.composedPath().find(node =>
130
+ node?.nodeType === Node.ELEMENT_NODE && isEditingBoundary(node)
131
+ );
132
+ // Focus that went somewhere else entirely ends the session: retained UI
133
+ // is already excluded above, so what is left is not the editor's, and
134
+ // everything drawn for it has to go with it.
135
+ if (!editable) {
136
+ this.activate(null);
137
+ return;
138
+ }
139
+ if (!isEditableHost(editable)) {
140
+ this.activate(null);
141
+ return;
142
+ }
143
+ let surface = this.#elements.get(editable);
144
+ if (!surface && this.#options.auto && enabled(editable)) surface = this.add(editable);
145
+ this.activate(surface || null);
146
+ };
147
+
148
+ #selectionChange = () => {
149
+ this.sync();
150
+ };
151
+
152
+ #emit(type, detail = {}) {
153
+ const Event = this.#document.defaultView.CustomEvent;
154
+ return this.dispatchEvent(new Event(type, {detail: {core: this, ...detail}}));
155
+ }
156
+ }
@@ -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.