u2ui 1.5.4 → 1.5.8
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/js/rte/PLAN.md +42 -43
- package/js/rte/README.md +1 -1
- package/js/rte/playground/hosts.html +35 -0
- package/js/rte/playground/index.html +11 -0
- package/js/rte/run.out +17 -0
- package/js/rte/src/browser/interactive.js +17 -0
- package/js/rte/src/client/README.md +13 -6
- package/js/rte/src/client/editor.js +11 -0
- package/js/rte/src/client/images.js +32 -22
- package/js/rte/src/client/link.js +10 -15
- package/js/rte/src/client/tables.js +3 -0
- package/js/rte/src/command/README.md +4 -1
- package/js/rte/src/command/commands.js +4 -3
- package/js/rte/src/command/delete.js +8 -1
- package/js/rte/src/command/edit.js +8 -2
- package/js/rte/src/config/config.js +7 -0
- package/js/rte/src/core/README.md +25 -3
- package/js/rte/src/core/core.js +97 -6
- package/js/rte/src/input/README.md +18 -0
- package/js/rte/src/input/input-pipeline.js +45 -0
- package/js/rte/src/sanitize/README.md +5 -2
- package/js/rte/src/sanitize/policy.js +5 -1
- package/js/rte/src/ui/README.md +27 -6
- package/js/rte/src/ui/caret.js +17 -0
- package/js/rte/src/ui/chrome.js +1 -1
- package/js/rte/src/ui/handles.js +1 -1
- package/js/rte/src/ui/highlight.js +86 -0
- package/js/rte/src/ui/place.js +24 -2
- package/js/rte/src/ui/toolbar.js +16 -41
- package/package.json +1 -1
|
@@ -18,16 +18,38 @@ document structure, or UI behavior.
|
|
|
18
18
|
browser and document-lifecycle differences without duplicate activation.
|
|
19
19
|
- The nearest explicit nested `contenteditable` is an isolation boundary.
|
|
20
20
|
- `sync()` resolves the current selection to one registered surface and captures
|
|
21
|
-
it without scanning or mutating unrelated editor contents.
|
|
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.
|
|
22
26
|
- At most one surface is active per core; active state may outlive DOM focus so
|
|
23
27
|
a roaming UI can safely receive focus.
|
|
24
28
|
- `retain(element)` marks editor-owned UI in the same document. Focus inside it
|
|
25
29
|
keeps the active surface, `retains(node)` identifies its composed subtree,
|
|
26
30
|
and `release(element)` removes the mark. This also distinguishes UI from
|
|
27
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.
|
|
28
47
|
- Focus that lands anywhere else — not a surface, not retained UI — ends the
|
|
29
|
-
session, so everything drawn for it goes at once.
|
|
30
|
-
|
|
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.
|
|
31
53
|
- `dispose()` removes listeners and disconnects every surface. It is
|
|
32
54
|
idempotent; `[Symbol.dispose]()` exposes the same teardown to `using`.
|
|
33
55
|
|
package/js/rte/src/core/core.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {enabled} from '../config/config.js';
|
|
2
|
+
import {interactiveAround} from '../browser/interactive.js';
|
|
2
3
|
import {editingHost, isEditableHost, isEditingBoundary} from '../selection/ownership/ownership.js';
|
|
3
4
|
import {Surface} from '../surface/surface.js';
|
|
4
5
|
|
|
@@ -10,6 +11,9 @@ export class Rte extends EventTarget {
|
|
|
10
11
|
#elements = new WeakMap();
|
|
11
12
|
#retained = new Set();
|
|
12
13
|
#active = null;
|
|
14
|
+
#unfocused = false;
|
|
15
|
+
#pointer = null;
|
|
16
|
+
#handed = false;
|
|
13
17
|
#controller;
|
|
14
18
|
|
|
15
19
|
constructor(root = document, options = {}) {
|
|
@@ -24,6 +28,15 @@ export class Rte extends EventTarget {
|
|
|
24
28
|
const listen = {capture: true, signal: this.#controller.signal};
|
|
25
29
|
root.addEventListener('focus', this.#focus, listen);
|
|
26
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);
|
|
27
40
|
this.#document.addEventListener('selectionchange', this.#selectionChange, {signal: this.#controller.signal});
|
|
28
41
|
}
|
|
29
42
|
|
|
@@ -108,6 +121,12 @@ export class Rte extends EventTarget {
|
|
|
108
121
|
this.activate(null);
|
|
109
122
|
return null;
|
|
110
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;
|
|
111
130
|
this.activate(surface);
|
|
112
131
|
return surface;
|
|
113
132
|
}
|
|
@@ -125,26 +144,98 @@ export class Rte extends EventTarget {
|
|
|
125
144
|
}
|
|
126
145
|
|
|
127
146
|
#focus = event => {
|
|
128
|
-
|
|
129
|
-
|
|
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 =>
|
|
130
153
|
node?.nodeType === Node.ELEMENT_NODE && isEditingBoundary(node)
|
|
131
154
|
);
|
|
132
155
|
// Focus that went somewhere else entirely ends the session: retained UI
|
|
133
156
|
// is already excluded above, so what is left is not the editor's, and
|
|
134
157
|
// everything drawn for it has to go with it.
|
|
135
|
-
if (!editable) {
|
|
158
|
+
if (!editable || !isEditableHost(editable)) {
|
|
159
|
+
this.#unfocused = true;
|
|
136
160
|
this.activate(null);
|
|
137
161
|
return;
|
|
138
162
|
}
|
|
139
|
-
|
|
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;
|
|
140
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
|
+
}
|
|
141
187
|
return;
|
|
142
188
|
}
|
|
143
|
-
|
|
144
|
-
if (!surface && this.#options.auto && enabled(editable)) surface = this.add(editable);
|
|
189
|
+
this.#unfocused = !surface;
|
|
145
190
|
this.activate(surface || null);
|
|
146
191
|
};
|
|
147
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
|
+
|
|
148
239
|
#selectionChange = () => {
|
|
149
240
|
this.sync();
|
|
150
241
|
};
|
|
@@ -63,6 +63,24 @@ to `using`. Disconnecting the surface also disposes them automatically.
|
|
|
63
63
|
surface)` is its public, shared conversion from a native `StaticRange` to an
|
|
64
64
|
owned live `Range`; missing target ranges fall back to the owned selection.
|
|
65
65
|
|
|
66
|
+
## Keys a host answers itself
|
|
67
|
+
|
|
68
|
+
A button answers Space and Enter by activating itself, whatever it holds: the
|
|
69
|
+
text inside never sees them and no `beforeinput` arrives at all. In that host,
|
|
70
|
+
and only there, the pipeline turns the key into the input it should have been —
|
|
71
|
+
`insertParagraph` for Enter, and for Space the `insert` command with a space of
|
|
72
|
+
its own — a non-breaking one where a plain space would collapse away, which is
|
|
73
|
+
what the browser itself types there. Everywhere else ordinary typing stays the
|
|
74
|
+
browser's own.
|
|
75
|
+
|
|
76
|
+
## Pointing at an atomic element
|
|
77
|
+
|
|
78
|
+
An atomic element is addressable only as the selection: engines disagree about
|
|
79
|
+
whether pointing at one selects it, and a caret an engine leaves inside one —
|
|
80
|
+
clicking a rule does that in some — reaches nothing at all. A click on one
|
|
81
|
+
selects it, which is what makes a frame, a command, or a delete apply to it. The
|
|
82
|
+
host itself is left alone: a surface is not a thing inside itself.
|
|
83
|
+
|
|
66
84
|
## Rich external input
|
|
67
85
|
|
|
68
86
|
The native path above needs no HTML parser or sanitizer adapter because RTE
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {htmlModel} from '../model/html/html-model.js';
|
|
2
2
|
import {narrow} from '../command/edit.js';
|
|
3
|
+
import {activatingAround} from '../browser/interactive.js';
|
|
3
4
|
import {Normalizer} from '../normalize/normalizer/normalizer.js';
|
|
4
5
|
import {PointMap} from '../selection/map/point-map.js';
|
|
5
6
|
import {EditRange} from '../selection/range/edit-range.js';
|
|
@@ -71,6 +72,7 @@ export class InputPipeline {
|
|
|
71
72
|
root.addEventListener('compositionend', this.#compositionEnd, listen);
|
|
72
73
|
root.addEventListener('paste', this.#paste, listen);
|
|
73
74
|
root.addEventListener('drop', this.#drop, listen);
|
|
75
|
+
root.addEventListener('click', this.#click, listen);
|
|
74
76
|
surface.addEventListener('u2-rte-command', this.#command, listen);
|
|
75
77
|
surface.addEventListener('u2-rte-disconnect', this.#disconnect, listen);
|
|
76
78
|
}
|
|
@@ -198,6 +200,7 @@ export class InputPipeline {
|
|
|
198
200
|
if (this.#invoke(event, shortcut, {range: selectionRange(this.#surface)})) return;
|
|
199
201
|
}
|
|
200
202
|
if (event.altKey) return;
|
|
203
|
+
if ((event.key === 'Enter' || event.key === ' ') && this.#activation(event)) return;
|
|
201
204
|
const inputType = keyInput(event);
|
|
202
205
|
if (!inputType) return;
|
|
203
206
|
this.#surface.capture();
|
|
@@ -208,6 +211,19 @@ export class InputPipeline {
|
|
|
208
211
|
});
|
|
209
212
|
};
|
|
210
213
|
|
|
214
|
+
// In a button, Enter and Space belong to the button: the text inside never
|
|
215
|
+
// sees them and no `beforeinput` arrives, so the editor puts in what the key
|
|
216
|
+
// meant. Everywhere else the browser's own input is left alone.
|
|
217
|
+
#activation(event) {
|
|
218
|
+
if (event.ctrlKey || event.metaKey || !activatingAround(this.#root)) return false;
|
|
219
|
+
this.#surface.capture();
|
|
220
|
+
const range = selectionRange(this.#surface);
|
|
221
|
+
if (event.key === 'Enter') return this.#route(event, {inputType: 'insertParagraph', data: null, range});
|
|
222
|
+
const fragment = this.#root.ownerDocument.createDocumentFragment();
|
|
223
|
+
fragment.append(space(range));
|
|
224
|
+
return this.#invoke(event, 'insert', {fragment, range});
|
|
225
|
+
}
|
|
226
|
+
|
|
211
227
|
#input = event => {
|
|
212
228
|
if (!this.#owns(event)) return;
|
|
213
229
|
const pending = this.#pending;
|
|
@@ -245,6 +261,25 @@ export class InputPipeline {
|
|
|
245
261
|
});
|
|
246
262
|
};
|
|
247
263
|
|
|
264
|
+
// An atomic element is addressable only as the selection: engines disagree
|
|
265
|
+
// about whether pointing at one selects it, and a caret one leaves inside it
|
|
266
|
+
// reaches nothing at all. The host itself is left alone — a surface is not a
|
|
267
|
+
// thing inside itself.
|
|
268
|
+
#click = event => {
|
|
269
|
+
const target = event.composedPath()[0];
|
|
270
|
+
if (target === this.#root || !this.#owns(event) || target?.nodeType !== Node.ELEMENT_NODE) return;
|
|
271
|
+
// The base model, not the narrowed one: what an element is does not
|
|
272
|
+
// depend on which elements a host allows, and narrowing costs a computed
|
|
273
|
+
// style on a path every click in the text goes through.
|
|
274
|
+
if (!this.#model.atomic(target)) return;
|
|
275
|
+
const range = this.#root.ownerDocument.createRange();
|
|
276
|
+
range.selectNode(target);
|
|
277
|
+
const selection = this.#surface.core.selection;
|
|
278
|
+
selection.removeAllRanges();
|
|
279
|
+
selection.addRange(range);
|
|
280
|
+
this.#surface.capture();
|
|
281
|
+
};
|
|
282
|
+
|
|
248
283
|
#paste = event => {
|
|
249
284
|
if (this.#owns(event)) this.#rememberSource('paste');
|
|
250
285
|
};
|
|
@@ -323,6 +358,16 @@ export class InputPipeline {
|
|
|
323
358
|
}
|
|
324
359
|
}
|
|
325
360
|
|
|
361
|
+
// A plain space collapses away where there is nothing after it, or where another
|
|
362
|
+
// space already is — so there the browser types a non-breaking one, and so does
|
|
363
|
+
// this. Anywhere else a space is a space.
|
|
364
|
+
function space(range) {
|
|
365
|
+
const node = range?.startContainer;
|
|
366
|
+
if (node?.nodeType !== Node.TEXT_NODE) return '\u00a0';
|
|
367
|
+
const before = range.startOffset ? node.data[range.startOffset - 1] : '';
|
|
368
|
+
return before.trim() && range.startOffset < node.length ? ' ' : '\u00a0';
|
|
369
|
+
}
|
|
370
|
+
|
|
326
371
|
function keyInput(event) {
|
|
327
372
|
if (event.ctrlKey || event.metaKey || event.shiftKey) return null;
|
|
328
373
|
return DELETE_KEYS.get(event.key) || null;
|
|
@@ -16,8 +16,11 @@ repairs editable document shape.
|
|
|
16
16
|
- `comments` and `dataAttributes`: explicit booleans, both false by default.
|
|
17
17
|
|
|
18
18
|
The supplied `sanitizePolicy` accepts the `document` element preset, ordinary
|
|
19
|
-
editor metadata, and conservative web URLs.
|
|
20
|
-
|
|
19
|
+
editor metadata, and conservative web URLs. An image may carry a `data:` URL,
|
|
20
|
+
because an image executes nothing — not even an SVG one, which browsers draw in
|
|
21
|
+
a script-free context; in a navigating attribute the same protocol is a page, so
|
|
22
|
+
links and citations keep it out. Inline styles, event attributes, comments, and
|
|
23
|
+
data attributes are not enabled by default.
|
|
21
24
|
Relative URLs use the explicit `relative` protocol token. Known URL attributes
|
|
22
25
|
without a matching protocol rule are removed rather than accepted implicitly.
|
|
23
26
|
|
|
@@ -19,7 +19,11 @@ const DEFAULT_ATTRIBUTES = Object.freeze({
|
|
|
19
19
|
const DEFAULT_PROTOCOLS = Object.freeze({
|
|
20
20
|
a: Object.freeze({href: Object.freeze(['http', 'https', 'mailto', 'relative', 'tel'])}),
|
|
21
21
|
blockquote: Object.freeze({cite: Object.freeze(['http', 'https', 'relative'])}),
|
|
22
|
-
|
|
22
|
+
// `data:` belongs to an image and to nothing else here: an image executes
|
|
23
|
+
// nothing, not even an SVG one, which browsers draw in a script-free
|
|
24
|
+
// context. In a navigating attribute the same protocol is a page, so `a`
|
|
25
|
+
// and the citations keep it out.
|
|
26
|
+
img: Object.freeze({src: Object.freeze(['data', 'http', 'https', 'relative'])}),
|
|
23
27
|
q: Object.freeze({cite: Object.freeze(['http', 'https', 'relative'])}),
|
|
24
28
|
});
|
|
25
29
|
|
package/js/rte/src/ui/README.md
CHANGED
|
@@ -218,11 +218,22 @@ while a wide one loses a great deal.
|
|
|
218
218
|
|
|
219
219
|
## Focus across the boundary
|
|
220
220
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
the toolbar
|
|
224
|
-
|
|
225
|
-
|
|
221
|
+
Whether focus leaving the editor ends the session is the core's decision, not
|
|
222
|
+
the toolbar's: it knows every surface and every retained element, so one rule
|
|
223
|
+
covers the toolbar, the contextual UI in the chrome, and anything a host
|
|
224
|
+
retains. The toolbar shows and hides with the active surface and does not watch
|
|
225
|
+
focus itself.
|
|
226
|
+
|
|
227
|
+
## The selection while a form has the focus
|
|
228
|
+
|
|
229
|
+
A browser paints a selection only where the focus is, so a form of the editor's
|
|
230
|
+
own makes the text it is about to change stop looking selected. `highlight.js`
|
|
231
|
+
draws the surface's saved selection in its place, through the CSS custom
|
|
232
|
+
highlight API, and stops the moment the text has the focus again — the browser's
|
|
233
|
+
own selection is never painted over. The rule goes into the tree that holds the
|
|
234
|
+
text, because a highlight is painted on the range's own nodes, and sits in the
|
|
235
|
+
`u2-rte` layer, so `::highlight(u2-rte-selection)` in a page's own stylesheet
|
|
236
|
+
replaces it. Engines without the API simply show nothing extra.
|
|
226
237
|
|
|
227
238
|
## Placement
|
|
228
239
|
|
|
@@ -232,12 +243,22 @@ keeps the element inside the viewport, and falls to the other side when the
|
|
|
232
243
|
preferred one does not fit. The roaming toolbar centres above the selection; the
|
|
233
244
|
link form aligns to its start and prefers below.
|
|
234
245
|
|
|
246
|
+
Two panels can answer for the same thing — an image that is also a link brings
|
|
247
|
+
its frame, its name and its address — so a panel goes under whatever is already
|
|
248
|
+
drawn where it wants to be, in the order the chrome holds them. `panelGap` is
|
|
249
|
+
the one distance they all keep from what they hang off, and from each other.
|
|
250
|
+
|
|
251
|
+
`caretAfter(surface, element)` is its counterpart at the end: the one way a
|
|
252
|
+
contextual UI hands the caret back, after the element it was about to change
|
|
253
|
+
rather than inside it. An element that is no longer there leaves the surface
|
|
254
|
+
with the selection it had.
|
|
255
|
+
|
|
235
256
|
A select with no usable choice hides like a button whose command is unavailable:
|
|
236
257
|
a control that cannot be used is not shown.
|
|
237
258
|
|
|
238
259
|
Pointing anywhere at the toolbar never moves the editor's selection, including
|
|
239
260
|
at a control that currently has nothing to run — otherwise clicking a disabled
|
|
240
|
-
button would blur the surface and
|
|
261
|
+
button would blur the surface and end the editing session. Fields keep their own
|
|
241
262
|
pointer behaviour so they can be opened and typed in.
|
|
242
263
|
|
|
243
264
|
Keys are not the toolbar's concern: a command declares its own shortcut and the
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The other half of `place`: where a contextual UI hands the caret back when it
|
|
2
|
+
// is done. After the element it was about to change, never inside it — whoever
|
|
3
|
+
// just named an image or made a link wants to keep writing, without it. The
|
|
4
|
+
// surface takes the focus first, so what it captures is a selection it owns.
|
|
5
|
+
//
|
|
6
|
+
// An element that is no longer there leaves the surface with what it had.
|
|
7
|
+
export function caretAfter(surface, element) {
|
|
8
|
+
surface.element.focus();
|
|
9
|
+
if (!element?.isConnected) return surface.restore();
|
|
10
|
+
const range = surface.element.ownerDocument.createRange();
|
|
11
|
+
range.setStartAfter(element);
|
|
12
|
+
range.collapse(true);
|
|
13
|
+
const selection = surface.core.selection;
|
|
14
|
+
selection.removeAllRanges();
|
|
15
|
+
selection.addRange(range);
|
|
16
|
+
return !!surface.capture();
|
|
17
|
+
}
|
package/js/rte/src/ui/chrome.js
CHANGED
|
@@ -196,7 +196,7 @@ export class Chrome {
|
|
|
196
196
|
// The closest boundary is the one that owns the target's current top-layer
|
|
197
197
|
// context. Walking through shadow hosts keeps the rule identical for document
|
|
198
198
|
// and ShadowRoot editors.
|
|
199
|
-
|
|
199
|
+
function topLayer(target) {
|
|
200
200
|
for (let element = target; element?.nodeType === Node.ELEMENT_NODE; element = composedParent(element)) {
|
|
201
201
|
for (const selector of [':fullscreen', ':modal', ':popover-open']) {
|
|
202
202
|
try {
|
package/js/rte/src/ui/handles.js
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const NAME = 'u2-rte-selection';
|
|
2
|
+
// System colours, so what stands in for the selection looks like one. In a
|
|
3
|
+
// layer, so a page that wants its own answer simply writes an ordinary rule.
|
|
4
|
+
const STYLE = `@layer u2-rte {
|
|
5
|
+
::highlight(${NAME}) { background-color: Highlight; color: HighlightText; }
|
|
6
|
+
}`;
|
|
7
|
+
|
|
8
|
+
// A browser paints a selection only where the focus is, so the moment a form of
|
|
9
|
+
// the editor's own takes the caret, the text it is about to change stops looking
|
|
10
|
+
// selected. The surface's saved selection is drawn in its place: the range the
|
|
11
|
+
// commands act on is the range the eye follows.
|
|
12
|
+
//
|
|
13
|
+
// It is the editor's, not one form's: a link address, an image's name and
|
|
14
|
+
// anything else that takes focus all leave the same gap behind them.
|
|
15
|
+
export class SelectionHighlight {
|
|
16
|
+
#core;
|
|
17
|
+
#document;
|
|
18
|
+
#style = null;
|
|
19
|
+
#controller;
|
|
20
|
+
#connected = true;
|
|
21
|
+
|
|
22
|
+
constructor(core) {
|
|
23
|
+
if (!core?.root || typeof core?.addEventListener !== 'function') {
|
|
24
|
+
throw new TypeError('A selection highlight requires an editor core');
|
|
25
|
+
}
|
|
26
|
+
const root = core.root;
|
|
27
|
+
this.#core = core;
|
|
28
|
+
this.#document = root.nodeType === Node.DOCUMENT_NODE ? root : root.ownerDocument;
|
|
29
|
+
this.#controller = new this.#document.defaultView.AbortController();
|
|
30
|
+
const listen = {capture: true, signal: this.#controller.signal};
|
|
31
|
+
// Surface events bubble out composed, so one listener covers every
|
|
32
|
+
// surface the core owns, whichever of them is being edited.
|
|
33
|
+
for (const type of ['focusin', 'focusout', 'u2-rte-selectionchange', 'u2-rte-change']) {
|
|
34
|
+
root.addEventListener(type, this.#update, listen);
|
|
35
|
+
}
|
|
36
|
+
core.addEventListener('u2-rte-activechange', this.#update, {signal: this.#controller.signal});
|
|
37
|
+
this.#update();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get connected() { return this.#connected; }
|
|
41
|
+
|
|
42
|
+
refresh() {
|
|
43
|
+
const surface = this.#core.active;
|
|
44
|
+
const selection = surface?.selection;
|
|
45
|
+
const highlights = this.#document.defaultView.CSS?.highlights;
|
|
46
|
+
if (!highlights) return false;
|
|
47
|
+
// Not while the text itself has the focus: the browser is already
|
|
48
|
+
// painting that selection, and painting it twice can only disagree.
|
|
49
|
+
// `activeElement` of the core's own root: in a shadow tree the document
|
|
50
|
+
// reports the host, which says nothing about what is focused inside it.
|
|
51
|
+
if (!selection || selection.collapsed || surface.element.contains(this.#core.root.activeElement)) {
|
|
52
|
+
highlights.delete(NAME);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
this.#ensure();
|
|
56
|
+
highlights.set(NAME, new this.#document.defaultView.Highlight(selection.range()));
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
dispose() {
|
|
61
|
+
if (!this.#connected) return;
|
|
62
|
+
this.#controller.abort();
|
|
63
|
+
this.#document.defaultView.CSS?.highlights?.delete(NAME);
|
|
64
|
+
this.#style?.remove();
|
|
65
|
+
this.#style = null;
|
|
66
|
+
this.#connected = false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
[Symbol.dispose]() {
|
|
70
|
+
this.dispose();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The rule belongs to the tree that holds the text, not to the editor's own
|
|
74
|
+
// shadow root: a highlight is painted on the range's own nodes.
|
|
75
|
+
#ensure() {
|
|
76
|
+
if (this.#style) return;
|
|
77
|
+
const root = this.#core.root;
|
|
78
|
+
const style = this.#document.createElement('style');
|
|
79
|
+
style.dataset.u2RteHighlight = '';
|
|
80
|
+
style.textContent = STYLE;
|
|
81
|
+
(root.nodeType === Node.DOCUMENT_NODE ? this.#document.head || this.#document.documentElement : root).append(style);
|
|
82
|
+
this.#style = style;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#update = () => this.refresh();
|
|
86
|
+
}
|
package/js/rte/src/ui/place.js
CHANGED
|
@@ -6,6 +6,11 @@ import {rangeRect} from '../browser/range-rect.js';
|
|
|
6
6
|
//
|
|
7
7
|
// A UI that belongs to one element rather than to the selection passes that
|
|
8
8
|
// element's rect as `on`: what it points at should not shift with the caret.
|
|
9
|
+
// One distance for every panel that hangs off an element rather than off the
|
|
10
|
+
// selection: the handles on a frame's edge reach about this far, and two panels
|
|
11
|
+
// answering for the same element must not sit at two different heights.
|
|
12
|
+
export const panelGap = 16;
|
|
13
|
+
|
|
9
14
|
export function place(element, surface, {align = 'center', prefer = 'above', gap = 28, on = null} = {}) {
|
|
10
15
|
const range = on ? null : surface.selection?.range();
|
|
11
16
|
if (!on && !range) return false;
|
|
@@ -19,7 +24,24 @@ export function place(element, surface, {align = 'center', prefer = 'above', gap
|
|
|
19
24
|
const top = prefer === 'above'
|
|
20
25
|
? (fits ? above : Math.min(view.innerHeight - box.height - gap, below))
|
|
21
26
|
: (fits ? below : Math.max(gap, above));
|
|
22
|
-
|
|
23
|
-
element.style.
|
|
27
|
+
const left = Math.max(gap, Math.min(view.innerWidth - box.width - gap, start));
|
|
28
|
+
element.style.left = `${left}px`;
|
|
29
|
+
element.style.top = `${clear(element, {top, left, width: box.width, height: box.height}, gap)}px`;
|
|
24
30
|
return true;
|
|
25
31
|
}
|
|
32
|
+
|
|
33
|
+
// Two panels can answer for the same thing — an image that is also a link — and
|
|
34
|
+
// the second must not sit on the first. What is already drawn keeps its spot and
|
|
35
|
+
// the newcomer goes under it, in the order the chrome holds them, so the same
|
|
36
|
+
// two always stack the same way round.
|
|
37
|
+
function clear(element, box, gap) {
|
|
38
|
+
let top = box.top;
|
|
39
|
+
for (const panel of element.getRootNode().querySelectorAll?.('.panel') ?? []) {
|
|
40
|
+
if (panel === element || panel.hidden) continue;
|
|
41
|
+
const other = panel.getBoundingClientRect();
|
|
42
|
+
const misses = other.bottom <= top || other.top >= top + box.height
|
|
43
|
+
|| other.right <= box.left || other.left >= box.left + box.width;
|
|
44
|
+
if (!misses) top = other.bottom + gap;
|
|
45
|
+
}
|
|
46
|
+
return top;
|
|
47
|
+
}
|