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.
- package/el/ico/ico.js +5 -5
- package/el/rte/README.md +53 -13
- package/el/rte/manifest.json +19 -4
- package/el/rte/rte.css +31 -6
- package/el/rte/rte.js +174 -44
- package/el/rte0/README.md +37 -0
- package/el/rte0/manifest.json +33 -0
- package/el/rte0/rte0.css +9 -0
- package/el/rte0/rte0.js +56 -0
- package/el/system/styler.js +2 -0
- package/el/tooltip/tooltip.css +2 -2
- package/js/Placer/Placer.js +2 -1
- package/js/rte/AGENTS.md +142 -0
- package/js/rte/PLAN.md +488 -0
- package/js/rte/README.md +319 -25
- package/js/rte/blocks.js +7 -0
- package/js/rte/breaks.js +6 -0
- package/js/rte/classes.js +7 -0
- package/js/rte/docs/usage.md +845 -0
- package/js/rte/images.js +7 -0
- package/js/rte/link.js +8 -0
- package/js/rte/playground/README.md +95 -0
- package/js/rte/playground/hosts.html +518 -0
- package/js/rte/playground/index.html +824 -0
- package/js/rte/rte.js +56 -5
- package/js/rte/source.js +12 -0
- package/js/rte/src/browser/README.md +40 -0
- package/js/rte/src/browser/range-rect.js +82 -0
- package/js/rte/src/client/README.md +378 -0
- package/js/rte/src/client/blocks.js +38 -0
- package/js/rte/src/client/breaks.js +212 -0
- package/js/rte/src/client/classes.js +48 -0
- package/js/rte/src/client/editor.js +461 -0
- package/js/rte/src/client/external-input.js +47 -0
- package/js/rte/src/client/history.js +13 -0
- package/js/rte/src/client/images.js +275 -0
- package/js/rte/src/client/link.js +572 -0
- package/js/rte/src/client/marks.js +25 -0
- package/js/rte/src/client/source.js +182 -0
- package/js/rte/src/client/structure.js +27 -0
- package/js/rte/src/client/tables.js +162 -0
- package/js/rte/src/client/unstyle.js +32 -0
- package/js/rte/src/command/README.md +517 -0
- package/js/rte/src/command/block-boundary.js +43 -0
- package/js/rte/src/command/block-style.js +120 -0
- package/js/rte/src/command/commands.js +158 -0
- package/js/rte/src/command/delete.js +112 -0
- package/js/rte/src/command/edit.js +72 -0
- package/js/rte/src/command/element.js +45 -0
- package/js/rte/src/command/enter.js +175 -0
- package/js/rte/src/command/fragment.js +76 -0
- package/js/rte/src/command/insert.js +52 -0
- package/js/rte/src/command/list.js +284 -0
- package/js/rte/src/command/mark.js +583 -0
- package/js/rte/src/command/pending-marks.js +216 -0
- package/js/rte/src/command/table.js +207 -0
- package/js/rte/src/command/unstyle.js +248 -0
- package/js/rte/src/config/README.md +96 -0
- package/js/rte/src/config/config.js +127 -0
- package/js/rte/src/core/README.md +41 -0
- package/js/rte/src/core/core.js +156 -0
- package/js/rte/src/history/README.md +83 -0
- package/js/rte/src/history/history.js +283 -0
- package/js/rte/src/input/README.md +155 -0
- package/js/rte/src/input/external-input.js +130 -0
- package/js/rte/src/input/input-pipeline.js +414 -0
- package/js/rte/src/mark/README.md +219 -0
- package/js/rte/src/mark/dom-adapter.js +100 -0
- package/js/rte/src/mark/mark.js +133 -0
- package/js/rte/src/mark/standard.js +76 -0
- package/js/rte/src/model/README.md +71 -0
- package/js/rte/src/model/content-model.js +213 -0
- package/js/rte/src/model/html/README.md +51 -0
- package/js/rte/src/model/html/html-model.js +90 -0
- package/js/rte/src/normalize/execute/README.md +33 -0
- package/js/rte/src/normalize/execute/repair-executor.js +129 -0
- package/js/rte/src/normalize/normalizer/README.md +48 -0
- package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
- package/js/rte/src/normalize/repair/README.md +70 -0
- package/js/rte/src/normalize/repair/repair-planner.js +208 -0
- package/js/rte/src/sanitize/README.md +105 -0
- package/js/rte/src/sanitize/native.js +43 -0
- package/js/rte/src/sanitize/policy.js +202 -0
- package/js/rte/src/selection/README.md +39 -0
- package/js/rte/src/selection/map/README.md +53 -0
- package/js/rte/src/selection/map/point-map.js +307 -0
- package/js/rte/src/selection/ownership/README.md +32 -0
- package/js/rte/src/selection/ownership/ownership.js +42 -0
- package/js/rte/src/selection/point/README.md +34 -0
- package/js/rte/src/selection/point/point.js +83 -0
- package/js/rte/src/selection/range/README.md +38 -0
- package/js/rte/src/selection/range/edit-range.js +223 -0
- package/js/rte/src/selection/snapshot.js +64 -0
- package/js/rte/src/source/README.md +64 -0
- package/js/rte/src/source/source.js +193 -0
- package/js/rte/src/surface/README.md +36 -0
- package/js/rte/src/surface/content.js +16 -0
- package/js/rte/src/surface/surface.js +87 -0
- package/js/rte/src/transaction/README.md +35 -0
- package/js/rte/src/transaction/transaction.js +56 -0
- package/js/rte/src/ui/README.md +255 -0
- package/js/rte/src/ui/chrome.js +214 -0
- package/js/rte/src/ui/handles.js +179 -0
- package/js/rte/src/ui/place.js +25 -0
- package/js/rte/src/ui/toolbar.js +283 -0
- package/js/rte/src/unstyle/README.md +103 -0
- package/js/rte/src/unstyle/unstyle.js +140 -0
- package/js/rte/tables.js +7 -0
- package/js/rte/unstyle.js +8 -0
- package/js/rte0/README.md +37 -0
- package/js/rte0/rte.js +7 -0
- package/js/{rte → rte0}/src/behavior.js +0 -1
- package/js/{rte → rte0}/src/events.js +0 -1
- package/js/{rte → rte0}/src/range.js +0 -2
- package/js/{rte → rte0}/src/ui.items.js +0 -2
- package/js/{rte → rte0}/src/ui.js +0 -1
- package/package.json +1 -1
- package/u2/Element.js +2 -2
- package/u2/plan-scoped-refactoring.md +133 -303
- package/u2/projects.json +18 -2
- package/u2/tools/update.repos.json.js +1 -1
- package/el/qrcode/package.json +0 -22
- /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
- /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/main.css +0 -0
- /package/js/{rte → rte0}/old/old.rte.js +0 -0
- /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
- /package/js/{rte → rte0}/src/fixes.js +0 -0
- /package/js/{rte → rte0}/src/rte.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
- /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
- /package/js/{rte → rte0}/src/util/indentHtml.js +0 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Transactions
|
|
2
|
+
|
|
3
|
+
A transaction groups one complete editor change, from the selection before it
|
|
4
|
+
starts to the selection and affected nodes after it finishes. For example, an
|
|
5
|
+
Enter command and the structural cleanup it triggers should be observed as one
|
|
6
|
+
change rather than unrelated DOM mutations.
|
|
7
|
+
|
|
8
|
+
`transaction.js` provides this synchronous boundary for commands, input
|
|
9
|
+
handling, normalization, events, and history.
|
|
10
|
+
|
|
11
|
+
## Contract
|
|
12
|
+
|
|
13
|
+
- A transaction runs synchronously and at most once.
|
|
14
|
+
- A live selection inside the surface is captured before change hooks execute;
|
|
15
|
+
only a selection the surface does not own is replaced by its saved snapshot.
|
|
16
|
+
- `u2-rte-beforechange` is cancelable on both the surface object and DOM host.
|
|
17
|
+
- `touch(node)` records affected nodes and rejects nodes outside the surface.
|
|
18
|
+
- Nested operations share the surface's current transaction.
|
|
19
|
+
- A successful transaction captures its resulting selection and emits
|
|
20
|
+
`u2-rte-change`; a thrown error emits `u2-rte-error` and is rethrown.
|
|
21
|
+
- `selectionBefore` and `selectionAfter` expose read-only snapshots.
|
|
22
|
+
- Transactions report state but do not pretend to roll back arbitrary DOM
|
|
23
|
+
mutations. Undo is provided by the state-based history layer, which uses the
|
|
24
|
+
`trigger` option to decide what belongs to one step.
|
|
25
|
+
|
|
26
|
+
Synchronous execution is intentional: selection, `beforeinput`, browser undo,
|
|
27
|
+
and DOM mutation order become ambiguous across an asynchronous gap.
|
|
28
|
+
|
|
29
|
+
## TODO
|
|
30
|
+
|
|
31
|
+
- Reduce touched nodes into minimal dirty scopes for normalization.
|
|
32
|
+
- Record reversible DOM operations so history can store diffs instead of
|
|
33
|
+
whole content states.
|
|
34
|
+
- Add transaction metadata compatible with Input Events `inputType` values.
|
|
35
|
+
- Define composition transaction grouping without delaying DOM ownership.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export class Transaction {
|
|
2
|
+
#surface;
|
|
3
|
+
#options;
|
|
4
|
+
#dirty = new Set();
|
|
5
|
+
#state = 'new';
|
|
6
|
+
#selectionBefore = null;
|
|
7
|
+
#selectionAfter = null;
|
|
8
|
+
|
|
9
|
+
constructor(surface, options = {}) {
|
|
10
|
+
this.#surface = surface;
|
|
11
|
+
this.#options = Object.freeze({...options});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get surface() { return this.#surface; }
|
|
15
|
+
get options() { return this.#options; }
|
|
16
|
+
get state() { return this.#state; }
|
|
17
|
+
get dirty() { return [...this.#dirty]; }
|
|
18
|
+
get selectionBefore() { return this.#selectionBefore; }
|
|
19
|
+
get selectionAfter() { return this.#selectionAfter; }
|
|
20
|
+
|
|
21
|
+
touch(node) {
|
|
22
|
+
if (node !== this.#surface.element && !this.#surface.element.contains(node)) {
|
|
23
|
+
throw new RangeError('Dirty nodes must belong to the transaction surface');
|
|
24
|
+
}
|
|
25
|
+
this.#dirty.add(node);
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
run(change) {
|
|
30
|
+
if (this.#state !== 'new') throw new DOMException('A transaction can run only once', 'InvalidStateError');
|
|
31
|
+
// A live selection inside the surface is newer than the saved one; only
|
|
32
|
+
// a selection the surface does not own is replaced by its snapshot.
|
|
33
|
+
if (!this.#surface.capture()) this.#surface.restore();
|
|
34
|
+
this.#selectionBefore = this.#surface.selection;
|
|
35
|
+
if (!this.#surface.emit('u2-rte-beforechange', {transaction: this}, {cancelable: true})) {
|
|
36
|
+
this.#state = 'canceled';
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.#state = 'running';
|
|
40
|
+
try {
|
|
41
|
+
const result = change(this);
|
|
42
|
+
if (result?.then) throw new TypeError('Editor transactions must be synchronous');
|
|
43
|
+
this.#selectionAfter = this.#surface.capture();
|
|
44
|
+
if (!this.#selectionAfter && this.#selectionBefore?.restore()) {
|
|
45
|
+
this.#selectionAfter = this.#surface.capture();
|
|
46
|
+
}
|
|
47
|
+
this.#state = 'committed';
|
|
48
|
+
this.#surface.emit('u2-rte-change', {transaction: this});
|
|
49
|
+
return result;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
this.#state = 'failed';
|
|
52
|
+
this.#surface.emit('u2-rte-error', {transaction: this, error});
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# Toolbar UI
|
|
2
|
+
|
|
3
|
+
`toolbar.js` binds an application's toolbar markup to the command registry of
|
|
4
|
+
the currently active surface. It provides the roaming behavior; it does not
|
|
5
|
+
create buttons, inject styles, choose icons, or define editor commands.
|
|
6
|
+
|
|
7
|
+
The useful interaction model comes from the original `../rte0` toolbar: one
|
|
8
|
+
shared toolbar follows the active editor, its items reflect the current
|
|
9
|
+
selection, pointer interaction keeps the editor selection, and keyboard
|
|
10
|
+
shortcuts invoke the same actions. RTE replaces the old global item registry,
|
|
11
|
+
`execCommand()`, delayed focus repair, and automatic style injection with the
|
|
12
|
+
existing core, surface, and command contracts.
|
|
13
|
+
|
|
14
|
+
## Contract
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
const toolbar = new Toolbar(core, element, {
|
|
18
|
+
commands: surface => commandsBySurface.get(surface),
|
|
19
|
+
place: (element, surface) => placeNearSelection(element, surface.selection),
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- `core` supplies the active surface and owns the single root-level lifecycle.
|
|
24
|
+
- `element` is application-owned markup. Descendants with `data-command` are
|
|
25
|
+
action/toggle items; `select[data-command-value]` binds one value-bearing
|
|
26
|
+
command. No icon system or CSS is required. If the element has
|
|
27
|
+
`popover="manual"`, visibility also opens and closes it in the browser top
|
|
28
|
+
layer without changing focus.
|
|
29
|
+
- `commands(surface)` resolves the registry belonging to that surface. Returning
|
|
30
|
+
`null` hides the toolbar, which permits lazy surface setup without coupling a
|
|
31
|
+
registry to `Surface`.
|
|
32
|
+
- `place(element, surface)` is optional presentation policy called after every
|
|
33
|
+
visible refresh. Positioning remains replaceable and outside command logic.
|
|
34
|
+
- `refresh()` reads command availability and state immediately and returns
|
|
35
|
+
whether the toolbar is visible.
|
|
36
|
+
- `dispose()` removes all core, surface, DOM, and shortcut listeners. Core
|
|
37
|
+
disposal also disposes the toolbar.
|
|
38
|
+
|
|
39
|
+
The toolbar listens only to active-surface changes, selection changes, committed
|
|
40
|
+
changes, disconnect, and root focus transitions. Switching surfaces aborts the
|
|
41
|
+
old surface listeners. It does not observe DOM or poll state.
|
|
42
|
+
|
|
43
|
+
## Items
|
|
44
|
+
|
|
45
|
+
```html
|
|
46
|
+
<div class="toolbar" aria-label="Text formatting">
|
|
47
|
+
<button type="button" data-command="bold" data-state data-shortcut="b">Bold</button>
|
|
48
|
+
<button type="button" data-command="italic" data-state data-shortcut="i">Italic</button>
|
|
49
|
+
</div>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
An item is visible only when the active registry contains its command. Its
|
|
53
|
+
`disabled`/`aria-disabled` state follows `commands.enabled()`. On a `data-state`
|
|
54
|
+
item, boolean and `'mixed'` command states become `aria-pressed`, so toggle
|
|
55
|
+
buttons need no separate adapter. Action buttons omit `data-state` and never
|
|
56
|
+
pretend to be toggles merely because their command can report state.
|
|
57
|
+
`data-control` may give a button a CSS-facing name different from its command,
|
|
58
|
+
just as it does for a value control. This lets `--u2-rte-toolbar: breaks` expose
|
|
59
|
+
a button whose command is `showBreaks`.
|
|
60
|
+
|
|
61
|
+
`data-shortcut="b"` means Ctrl+B or Command+B while keyboard input belongs to
|
|
62
|
+
the active surface. Shortcuts with Shift or Alt are deliberately not inferred;
|
|
63
|
+
future keymap policy can generalize that syntax without changing commands.
|
|
64
|
+
|
|
65
|
+
An editor may select the shared toolbar's available items with an inherited CSS
|
|
66
|
+
property:
|
|
67
|
+
|
|
68
|
+
```css
|
|
69
|
+
.compact-editor { --u2-rte-toolbar: bold italic; }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
An absent or empty property exposes every registered item represented in the
|
|
73
|
+
markup. `--u2-rte-ui: none` hides this roaming toolbar. A future static binding
|
|
74
|
+
can consume the same item contract without changing the core or registry.
|
|
75
|
+
|
|
76
|
+
By default the toolbar is useful at both a caret and a selected range. An
|
|
77
|
+
editor that wants contextual formatting controls only while text is visibly
|
|
78
|
+
selected can opt in without constructing a different toolbar:
|
|
79
|
+
|
|
80
|
+
```css
|
|
81
|
+
.selection-toolbar { --u2-rte-toolbar-when: selection; }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`selection` means a valid, non-collapsed saved selection. A missing selection
|
|
85
|
+
or caret hides the toolbar. `always`, an absent value, and unknown future values
|
|
86
|
+
retain the default behavior.
|
|
87
|
+
|
|
88
|
+
### Command values
|
|
89
|
+
|
|
90
|
+
A select can represent a mutually exclusive group of commands:
|
|
91
|
+
|
|
92
|
+
```html
|
|
93
|
+
<select data-command-value="blockStyle" data-control="block" aria-label="Block style">
|
|
94
|
+
<option value="">Block style</option>
|
|
95
|
+
<option value="paragraph">Paragraph</option>
|
|
96
|
+
<option value="h1">Heading 1</option>
|
|
97
|
+
</select>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`data-command-value` names one command, while `data-control` is the name exposed
|
|
101
|
+
to `--u2-rte-toolbar`. Option values become `edit.value`. The command's string
|
|
102
|
+
state selects the matching option; mixed, null, unknown, or conflicting state
|
|
103
|
+
selects the empty placeholder. A change restores the saved editor selection and
|
|
104
|
+
runs the command through the same transaction path as a button. Availability
|
|
105
|
+
is queried with each option's value, so unsupported values are hidden and
|
|
106
|
+
disabled; state is queried once per refresh. The adapter attaches no listener
|
|
107
|
+
per option or surface.
|
|
108
|
+
|
|
109
|
+
## Invariants
|
|
110
|
+
|
|
111
|
+
- UI focus never deactivates the current surface or becomes editor selection.
|
|
112
|
+
- Focus moving between the active surface and its toolbar keeps the toolbar
|
|
113
|
+
open. Leaving both hides it; later selection/change notifications do not
|
|
114
|
+
reopen it until focus returns.
|
|
115
|
+
- Every action runs through `Commands`; UI code never mutates editable DOM.
|
|
116
|
+
- One toolbar can serve arbitrarily many surfaces and resolve different command
|
|
117
|
+
sets for each one.
|
|
118
|
+
- Markup, labels, localization, icons, layout, and placement are application
|
|
119
|
+
policy and add no engine-side resources when no toolbar is constructed.
|
|
120
|
+
|
|
121
|
+
## Chrome
|
|
122
|
+
|
|
123
|
+
`Chrome` is one shadow root per editor for everything the editor draws: its
|
|
124
|
+
toolbar, its contextual handles, its forms and dialogs. It carries the top layer.
|
|
125
|
+
|
|
126
|
+
`part(key, css, tag)` gives a piece of chrome its node and its stylesheet under
|
|
127
|
+
one name: the key becomes the element's `id`, so the css is written `#link input`
|
|
128
|
+
and nothing is named twice. Inside a root shared by nothing else an id is as
|
|
129
|
+
unique as an attribute, and a second claim on the same key is an error rather
|
|
130
|
+
than a silent second element. `style(key, css)` registers a stylesheet alone,
|
|
131
|
+
once, for a piece that builds its own node.
|
|
132
|
+
|
|
133
|
+
What every piece shares is one skin, `.panel` — blur, rounding, shadow, border —
|
|
134
|
+
because to the eye the editor's chrome is one thing and not five. A piece brings
|
|
135
|
+
only its own layout.
|
|
136
|
+
|
|
137
|
+
Sizes inside are `em`, against a text size stated once on the host. Page-relative
|
|
138
|
+
units would let a site's root font resize the editor's furniture by accident, and
|
|
139
|
+
a variable plus `calc()` would only be `em` with arithmetic bolted on. The size
|
|
140
|
+
is still meant to be changed, just not by accident: `--u2-rte-ui-size` (default
|
|
141
|
+
`14px`) sets it, and reaches the chrome because `all: initial` does not touch
|
|
142
|
+
custom properties and the host inherits them from the page.
|
|
143
|
+
|
|
144
|
+
`follow(element)` keeps that one chrome inside the closest currently active
|
|
145
|
+
native top-layer boundary: a modal dialog, open popover, or fullscreen element.
|
|
146
|
+
This is a DOM relationship rather than a `z-index` trick, because modal
|
|
147
|
+
inertness and fullscreen rendering admit only descendants. Dialog `open`
|
|
148
|
+
mutations, popover `toggle`, and `fullscreenchange` update the mount; closing a
|
|
149
|
+
boundary returns the chrome to its original `Document` or `ShadowRoot`.
|
|
150
|
+
The host is an explicit `contenteditable=false` boundary. A convention client
|
|
151
|
+
also retains it in the core so UI focus and temporary direct-host mounting do
|
|
152
|
+
not become editor state or content.
|
|
153
|
+
|
|
154
|
+
An editor is chrome inside someone else's document and has to survive their
|
|
155
|
+
`button {}` rule. One encapsulated root means the page's CSS reaches none of it,
|
|
156
|
+
none of its styles leak out, and the application sees a single element rather
|
|
157
|
+
than one per piece of UI. Its host is `all: initial`, so the page's typography
|
|
158
|
+
does not shift it either; custom properties are excluded from `all` and still
|
|
159
|
+
inherit, so configuration is unaffected.
|
|
160
|
+
|
|
161
|
+
There is deliberately no `::part` surface. Exposing parts would make the
|
|
162
|
+
internal structure of the chrome a public contract, and it is not needed:
|
|
163
|
+
an application that wants different chrome builds its own on the commands, which
|
|
164
|
+
is the supported path and always was. `Toolbar` already binds application-owned
|
|
165
|
+
markup for exactly that reason.
|
|
166
|
+
|
|
167
|
+
## Handles
|
|
168
|
+
|
|
169
|
+
`Handles` is a set of buttons placed around something, with an optional frame
|
|
170
|
+
drawn behind them. It owns no editor concept: a caller says where each handle
|
|
171
|
+
goes and what pressing one means, so anything with a rectangle can use it —
|
|
172
|
+
inside this engine or outside it.
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
const handles = new Handles(chrome.root, {
|
|
176
|
+
name: 'tables',
|
|
177
|
+
handles: [{name: 'rowAfter', label: 'Row below', text: '+'}],
|
|
178
|
+
action: name => …,
|
|
179
|
+
press: name => …,
|
|
180
|
+
});
|
|
181
|
+
handles.show().place('rowAfter', x, y).disable('rowAfter', false);
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Given a shadow root it places itself inside it and registers its stylesheet
|
|
185
|
+
there. Given a document it makes an encapsulated root of its own, so it works
|
|
186
|
+
just as well outside an editor.
|
|
187
|
+
|
|
188
|
+
Both its listeners are on the containing root rather than the host: an event
|
|
189
|
+
that is not composed never leaves the shadow tree it happened in. Pressing a
|
|
190
|
+
handle prevents its default, because pointing at editor chrome must never move
|
|
191
|
+
the selection it acts on.
|
|
192
|
+
|
|
193
|
+
The glyph inside a handle is centred with `text-box: trim-both cap alphabetic`,
|
|
194
|
+
which trims the font's leading so a bare `+` or `×` sits on its optical centre
|
|
195
|
+
rather than its metric one. Where that is unsupported the flex centring alone
|
|
196
|
+
still applies.
|
|
197
|
+
|
|
198
|
+
## Present or available
|
|
199
|
+
|
|
200
|
+
Two different questions, answered two different ways:
|
|
201
|
+
|
|
202
|
+
- **Presence** says what this editor offers at all. A control is absent when its
|
|
203
|
+
command is not registered on the surface, when `--u2-rte-toolbar` does not
|
|
204
|
+
list it, or when its choices are not configured — a style select with no
|
|
205
|
+
`--u2-rte-classes` has nothing to be.
|
|
206
|
+
- **Availability** says what the current selection allows. A control that exists
|
|
207
|
+
but cannot act is disabled, never hidden.
|
|
208
|
+
|
|
209
|
+
Keeping the two apart is what makes a toolbar usable: its shape follows the
|
|
210
|
+
configuration and stays put while the caret moves, so a control is always found
|
|
211
|
+
in the same place. A toolbar whose buttons appeared and vanished with every
|
|
212
|
+
selection change would move its targets out from under the pointer.
|
|
213
|
+
|
|
214
|
+
`--u2-rte-toolbar-unavailable: hide` trades that away deliberately, for a
|
|
215
|
+
toolbar that only ever shows what it can do. It is the host's call, not the
|
|
216
|
+
default: a compact toolbar of two or three controls loses little by moving,
|
|
217
|
+
while a wide one loses a great deal.
|
|
218
|
+
|
|
219
|
+
## Focus across the boundary
|
|
220
|
+
|
|
221
|
+
A `focusout` listener outside the toolbar's shadow tree sees its related target
|
|
222
|
+
retargeted to that tree's host. Focus moving from the surface into a control of
|
|
223
|
+
the toolbar's own therefore looks like focus leaving the editor, and dismissing
|
|
224
|
+
on it would take the toolbar away in the middle of the click that opened a
|
|
225
|
+
select. Ownership is decided against the tree's host as well as its contents.
|
|
226
|
+
|
|
227
|
+
## Placement
|
|
228
|
+
|
|
229
|
+
`place(element, surface, {align, prefer, gap})` is the one placement policy for every
|
|
230
|
+
contextual UI. It anchors on the surface's saved selection through `rangeRect`,
|
|
231
|
+
keeps the element inside the viewport, and falls to the other side when the
|
|
232
|
+
preferred one does not fit. The roaming toolbar centres above the selection; the
|
|
233
|
+
link form aligns to its start and prefers below.
|
|
234
|
+
|
|
235
|
+
A select with no usable choice hides like a button whose command is unavailable:
|
|
236
|
+
a control that cannot be used is not shown.
|
|
237
|
+
|
|
238
|
+
Pointing anywhere at the toolbar never moves the editor's selection, including
|
|
239
|
+
at a control that currently has nothing to run — otherwise clicking a disabled
|
|
240
|
+
button would blur the surface and dismiss the toolbar. Fields keep their own
|
|
241
|
+
pointer behaviour so they can be opened and typed in.
|
|
242
|
+
|
|
243
|
+
Keys are not the toolbar's concern: a command declares its own shortcut and the
|
|
244
|
+
input pipeline resolves it, so a key works whether or not a control for it is on
|
|
245
|
+
screen. `data-shortcut` remains as the hint shown in a control's title.
|
|
246
|
+
|
|
247
|
+
## TODO
|
|
248
|
+
|
|
249
|
+
- Add a static-surface binding using the same item state rules.
|
|
250
|
+
- Define richer shortcut descriptors and conflict resolution with application
|
|
251
|
+
keymaps.
|
|
252
|
+
- Bind menus and application-owned custom controls; command-valued selects are
|
|
253
|
+
implemented.
|
|
254
|
+
- Verify top-layer Popover focus, caret, viewport, writing-mode, and ShadowRoot
|
|
255
|
+
behavior in all target browsers; use Dialog only for modal extension UI.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// One shadow root per editor for everything the editor draws: its toolbar, its
|
|
2
|
+
// contextual handles, its forms and dialogs.
|
|
3
|
+
//
|
|
4
|
+
// An editor is chrome inside someone else's document. Sharing one encapsulated
|
|
5
|
+
// root means the page's own CSS cannot reach any of it, none of its styles can
|
|
6
|
+
// leak out, and the application sees a single element rather than one per piece
|
|
7
|
+
// of UI. Each piece owns its own layout; what they share is one skin, `.panel`,
|
|
8
|
+
// because to the eye the editor's chrome is one thing and not five.
|
|
9
|
+
export class Chrome {
|
|
10
|
+
#host;
|
|
11
|
+
#root;
|
|
12
|
+
#home;
|
|
13
|
+
#context;
|
|
14
|
+
#document;
|
|
15
|
+
#target = null;
|
|
16
|
+
#styles = new Set();
|
|
17
|
+
#controller;
|
|
18
|
+
#observer;
|
|
19
|
+
#drawing;
|
|
20
|
+
#connected = true;
|
|
21
|
+
|
|
22
|
+
constructor(root, {name = 'chrome'} = {}) {
|
|
23
|
+
const isDocument = root?.nodeType === Node.DOCUMENT_NODE;
|
|
24
|
+
const isShadow = root?.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
|
|
25
|
+
if (!isDocument && !isShadow) throw new TypeError('Editor chrome requires a document or shadow root');
|
|
26
|
+
const document = isDocument ? root : root.ownerDocument;
|
|
27
|
+
this.#context = root;
|
|
28
|
+
this.#document = document;
|
|
29
|
+
this.#host = document.createElement('div');
|
|
30
|
+
this.#host.dataset.u2RteChrome = name;
|
|
31
|
+
this.#host.contentEditable = 'false';
|
|
32
|
+
this.#root = this.#host.attachShadow({mode: 'open'});
|
|
33
|
+
const style = document.createElement('style');
|
|
34
|
+
// The host is inert ground: only what a piece of UI places inside it
|
|
35
|
+
// takes the pointer.
|
|
36
|
+
//
|
|
37
|
+
// Important, because a normal declaration in a shadow tree loses to the
|
|
38
|
+
// outer document for the host element: a page rule as ordinary as
|
|
39
|
+
// `[popover] { border: solid }` would otherwise draw a frame around the
|
|
40
|
+
// whole editor. Important declarations reverse that order, so this is
|
|
41
|
+
// what makes the host unreachable rather than merely encapsulated.
|
|
42
|
+
style.textContent = `
|
|
43
|
+
:host {
|
|
44
|
+
all: initial !important;
|
|
45
|
+
/* The chrome's own text size lives here, and everything inside measures
|
|
46
|
+
itself in em against it — one knob, no arithmetic. Page-relative units
|
|
47
|
+
would instead let a site's root font resize the editor's furniture by
|
|
48
|
+
accident. Important like the rest of the reset, because a rule in the
|
|
49
|
+
outer document outranks one from this shadow tree; the size stays settable
|
|
50
|
+
all the same, because "all" does not touch custom properties and the host
|
|
51
|
+
inherits them from the page. */
|
|
52
|
+
font: var(--u2-rte-ui-size, 14px)/1.2 system-ui, sans-serif !important;
|
|
53
|
+
inset: 0 !important;
|
|
54
|
+
pointer-events: none !important;
|
|
55
|
+
position: fixed !important;
|
|
56
|
+
}
|
|
57
|
+
/* A part states its own display, and an id beats any rule that would hide it —
|
|
58
|
+
so hiding says so outright, once, for everything drawn in here. */
|
|
59
|
+
[hidden] { display: none !important; }
|
|
60
|
+
.panel {
|
|
61
|
+
backdrop-filter: blur(.57em);
|
|
62
|
+
background: color-mix(in srgb, Canvas 92%, transparent);
|
|
63
|
+
border: 1px solid color-mix(in srgb, CanvasText 24%, transparent);
|
|
64
|
+
border-radius: .51em;
|
|
65
|
+
box-shadow: 0 .34em 1.14em #0006;
|
|
66
|
+
color: CanvasText;
|
|
67
|
+
inset: auto;
|
|
68
|
+
margin: 0;
|
|
69
|
+
padding: .23em;
|
|
70
|
+
pointer-events: auto;
|
|
71
|
+
position: fixed;
|
|
72
|
+
z-index: 2147483647;
|
|
73
|
+
|
|
74
|
+
button, input, select { color: inherit; font: inherit; }
|
|
75
|
+
button, select { background: transparent; border: 0; }
|
|
76
|
+
:disabled { opacity: .4; }
|
|
77
|
+
}`;
|
|
78
|
+
this.#root.append(style);
|
|
79
|
+
this.#home = isDocument ? document.body || document.documentElement : root;
|
|
80
|
+
this.#home.append(this.#host);
|
|
81
|
+
if (typeof this.#host.showPopover === 'function') {
|
|
82
|
+
this.#host.popover = 'manual';
|
|
83
|
+
this.#host.showPopover();
|
|
84
|
+
}
|
|
85
|
+
this.#controller = new document.defaultView.AbortController();
|
|
86
|
+
const listen = {capture: true, signal: this.#controller.signal};
|
|
87
|
+
for (const type of ['toggle', 'fullscreenchange']) {
|
|
88
|
+
document.addEventListener(type, this.#topLayerChange, listen);
|
|
89
|
+
if (!isDocument) root.addEventListener(type, this.#topLayerChange, listen);
|
|
90
|
+
}
|
|
91
|
+
// Dialogs expose their open state synchronously as an attribute, but
|
|
92
|
+
// current engines do not all dispatch their newer toggle event yet.
|
|
93
|
+
this.#observer = new document.defaultView.MutationObserver(this.#topLayerChange);
|
|
94
|
+
// Whatever the editor draws is the most recent thing the user triggered,
|
|
95
|
+
// so it belongs in front of anything the page put in the top layer before.
|
|
96
|
+
this.#drawing = new document.defaultView.MutationObserver(records => {
|
|
97
|
+
if (records.some(record => !record.target.hidden)) this.raise();
|
|
98
|
+
});
|
|
99
|
+
this.#drawing.observe(this.#root, {subtree: true, attributes: true, attributeFilter: ['hidden']});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get element() { return this.#host; }
|
|
103
|
+
get root() { return this.#root; }
|
|
104
|
+
get target() { return this.#target; }
|
|
105
|
+
get connected() { return this.#connected; }
|
|
106
|
+
|
|
107
|
+
// Follows the target only across native top-layer boundaries. In ordinary
|
|
108
|
+
// content the chrome stays in its original document or shadow root.
|
|
109
|
+
follow(target = null) {
|
|
110
|
+
if (target !== null && target?.nodeType !== Node.ELEMENT_NODE) {
|
|
111
|
+
throw new TypeError('Editor chrome can follow only an element or null');
|
|
112
|
+
}
|
|
113
|
+
if (target && (target.ownerDocument !== this.#document || target.getRootNode() !== this.#context)) {
|
|
114
|
+
throw new RangeError('Editor chrome target must belong to its root');
|
|
115
|
+
}
|
|
116
|
+
this.#target = target;
|
|
117
|
+
this.#observer.disconnect();
|
|
118
|
+
for (let element = target; element?.nodeType === Node.ELEMENT_NODE; element = composedParent(element)) {
|
|
119
|
+
this.#observer.observe(element, {attributes: true, attributeFilter: ['open']});
|
|
120
|
+
}
|
|
121
|
+
this.#place();
|
|
122
|
+
return this;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Registers one stylesheet under a key, once, however often it is offered.
|
|
126
|
+
style(key, css) {
|
|
127
|
+
if (!this.#connected || this.#styles.has(key)) return this;
|
|
128
|
+
this.#styles.add(key);
|
|
129
|
+
const style = this.#document.createElement('style');
|
|
130
|
+
style.id = `${key}-style`;
|
|
131
|
+
style.textContent = css;
|
|
132
|
+
this.#root.append(style);
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// One node and one stylesheet under one name. The key is the element's id, so
|
|
137
|
+
// a piece of chrome writes `#link input` and is never named twice; inside an
|
|
138
|
+
// encapsulated root shared by nothing else, that is as unique as it gets.
|
|
139
|
+
part(key, css = '', tag = 'div') {
|
|
140
|
+
if (this.#root.getElementById(key)) throw new RangeError(`Editor chrome already draws: ${key}`);
|
|
141
|
+
const element = this.#document.createElement(tag);
|
|
142
|
+
element.id = key;
|
|
143
|
+
if (css) this.style(key, css);
|
|
144
|
+
this.#root.append(element);
|
|
145
|
+
return element;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
dispose() {
|
|
149
|
+
if (!this.#connected) return;
|
|
150
|
+
this.#controller.abort();
|
|
151
|
+
this.#observer.disconnect();
|
|
152
|
+
this.#drawing.disconnect();
|
|
153
|
+
this.#host.remove();
|
|
154
|
+
// Emptying the root as well: a detached shadow tree still answers
|
|
155
|
+
// queries, and a disposed editor must have nothing left to find.
|
|
156
|
+
this.#root.replaceChildren();
|
|
157
|
+
this.#styles.clear();
|
|
158
|
+
this.#target = null;
|
|
159
|
+
this.#connected = false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
[Symbol.dispose]() {
|
|
163
|
+
this.dispose();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Back to the front of the top layer, which is ordered by when each member
|
|
167
|
+
// was shown. Two manual popovers that never close — an editor's chrome and a
|
|
168
|
+
// host application's own menu — would otherwise keep the order they happened
|
|
169
|
+
// to be created in, whatever is being used right now.
|
|
170
|
+
raise() {
|
|
171
|
+
if (!this.#connected || typeof this.#host.showPopover !== 'function') return false;
|
|
172
|
+
if (!this.#host.matches(':popover-open')) return this.#place();
|
|
173
|
+
this.#host.hidePopover();
|
|
174
|
+
this.#host.showPopover();
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#place() {
|
|
179
|
+
if (!this.#connected) return false;
|
|
180
|
+
const parent = topLayer(this.#target) || this.#home;
|
|
181
|
+
if (parent === this.#host.parentNode) {
|
|
182
|
+
if (typeof this.#host.showPopover === 'function' && !this.#host.matches(':popover-open')) {
|
|
183
|
+
this.#host.showPopover();
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
if (this.#host.matches(':popover-open')) this.#host.hidePopover();
|
|
188
|
+
parent.append(this.#host);
|
|
189
|
+
if (typeof this.#host.showPopover === 'function') this.#host.showPopover();
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#topLayerChange = () => this.#place();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The closest boundary is the one that owns the target's current top-layer
|
|
197
|
+
// context. Walking through shadow hosts keeps the rule identical for document
|
|
198
|
+
// and ShadowRoot editors.
|
|
199
|
+
export function topLayer(target) {
|
|
200
|
+
for (let element = target; element?.nodeType === Node.ELEMENT_NODE; element = composedParent(element)) {
|
|
201
|
+
for (const selector of [':fullscreen', ':modal', ':popover-open']) {
|
|
202
|
+
try {
|
|
203
|
+
if (element.matches(selector)) return element;
|
|
204
|
+
} catch {
|
|
205
|
+
// Unsupported top-layer states simply cannot be active.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function composedParent(element) {
|
|
213
|
+
return element.parentElement || element.getRootNode()?.host || null;
|
|
214
|
+
}
|