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.
@@ -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. Retaining is therefore how a
30
- UI that takes focus stays alive; `Toolbar` retains its element itself.
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
 
@@ -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
- if (event.composedPath().some(node => this.#retained.has(node))) return;
129
- const editable = event.composedPath().find(node =>
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
- if (!isEditableHost(editable)) {
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
- let surface = this.#elements.get(editable);
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. Inline styles, event attributes,
20
- `data:` URLs, comments, and data attributes are not enabled by default.
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
- img: Object.freeze({src: Object.freeze(['http', 'https', 'relative'])}),
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
 
@@ -218,11 +218,22 @@ while a wide one loses a great deal.
218
218
 
219
219
  ## Focus across the boundary
220
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.
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 dismiss the toolbar. Fields keep their own
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
+ }
@@ -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
- export function topLayer(target) {
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 {
@@ -1,4 +1,4 @@
1
- export const handleStyle = `
1
+ const handleStyle = `
2
2
  .handles > .frame {
3
3
  border: 1px solid Highlight;
4
4
  box-sizing: border-box;
@@ -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
+ }
@@ -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
- element.style.left = `${Math.max(gap, Math.min(view.innerWidth - box.width - gap, start))}px`;
23
- element.style.top = `${top}px`;
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
+ }