u2ui 1.5.8 → 1.5.10

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.
@@ -94,10 +94,12 @@ arrow keys move through it, Enter takes the current entry, Escape closes the lis
94
94
  before it closes the form, and a pointer does the same. One question is in flight
95
95
  at a time, and a late answer to a word that has since changed is dropped.
96
96
 
97
- Contextual UI goes with the editing session and comes back with it: a module
98
- draws on `u2-rte-activate` as well as on selection and content changes, because
99
- returning to the field a caret was already in is no selection change and would
100
- otherwise leave its handles or its form behind.
97
+ Contextual UI goes with the editing session and comes back with it. `follows()`
98
+ owns that lifecycle for every module that draws one: selection, content, and the
99
+ session's return, because coming back to the selection a module was already
100
+ showing for is no selection change and would otherwise leave its handles or its
101
+ form behind. A module that has more of its own to hear ends it with the same
102
+ signal, the way the table handles listen for typing.
101
103
 
102
104
  Everything this client draws goes into one `Chrome`: its toolbar, the contextual
103
105
  handles, the link form, the source dialog. A module receives it as `chrome` in
@@ -0,0 +1,22 @@
1
+ // When a contextual UI is drawn, for every module that draws one. It follows the
2
+ // selection and the content of one surface, comes back with the session and goes
3
+ // with it — coming back to the selection it was already showing for is no
4
+ // selection change, so only the session's return says it belongs on screen again.
5
+ //
6
+ // What to draw is the module's own business; this only says when.
7
+ export function follows(surface, draw, close) {
8
+ const controller = new surface.element.ownerDocument.defaultView.AbortController();
9
+ const listen = {signal: controller.signal};
10
+ for (const type of ['u2-rte-selectionchange', 'u2-rte-change', 'u2-rte-activate']) {
11
+ surface.addEventListener(type, draw, listen);
12
+ }
13
+ surface.addEventListener('u2-rte-deactivate', close, listen);
14
+ return {
15
+ // A module with more of its own to hear ends it with the same signal.
16
+ signal: controller.signal,
17
+ dispose() {
18
+ controller.abort();
19
+ close();
20
+ },
21
+ };
22
+ }
@@ -1,6 +1,7 @@
1
1
  import {elementAttributes, selectedElement} from '../command/element.js';
2
2
  import {inlineUi} from '../config/config.js';
3
3
  import {caretAfter} from '../ui/caret.js';
4
+ import {follows} from './contextual.js';
4
5
  import {Handles} from '../ui/handles.js';
5
6
  import {panelGap, place} from '../ui/place.js';
6
7
 
@@ -93,18 +94,9 @@ export function imageTools({selector = 'img', minimum = 16} = {}) {
93
94
  const state = editors.get(editor);
94
95
  const view = state?.views.get(surface);
95
96
  if (!view) throw new DOMException('The image surface is not set up', 'InvalidStateError');
96
- const controller = new state.document.defaultView.AbortController();
97
- const listen = {signal: controller.signal};
98
- const sync = () => track(state, view, match);
99
- surface.addEventListener('u2-rte-selectionchange', sync, listen);
100
- surface.addEventListener('u2-rte-change', sync, listen);
101
- // What a session's end takes away, its beginning brings back: coming
102
- // back to the same selection is no selection change to hear about.
103
- surface.addEventListener('u2-rte-activate', sync, listen);
104
- surface.addEventListener('u2-rte-deactivate', () => close(state, surface), listen);
97
+ const following = follows(surface, () => track(state, view, match), () => close(state, surface));
105
98
  return {dispose() {
106
- controller.abort();
107
- close(state, surface);
99
+ following.dispose();
108
100
  state.views.delete(surface);
109
101
  }};
110
102
  },
@@ -192,8 +184,12 @@ function position(state, box = null) {
192
184
  for (const handle of HANDLES) {
193
185
  state.handles.place(handle.name, rect.left + rect.width * handle.x, rect.top + rect.height * handle.y);
194
186
  }
195
- // Below the image, clear of the handles sitting on its lower edge.
196
- place(state.alt, state.active.view.surface, {align: 'start', prefer: 'below', gap: panelGap, on: rect});
187
+ // Below the image and below its handles: they sit on its edges and reach
188
+ // past them, so what the form hangs off is the pair of them.
189
+ const lowest = Math.max(rect.bottom, ...HANDLES.map(handle =>
190
+ state.handles.button(handle.name)?.getBoundingClientRect().bottom ?? 0));
191
+ place(state.alt, state.active.view.surface, {align: 'start', prefer: 'below', gap: panelGap,
192
+ on: {left: rect.left, width: rect.width, top: rect.top, bottom: lowest}});
197
193
  }
198
194
 
199
195
  function build(state) {
@@ -1,6 +1,8 @@
1
1
  import {inlineUi} from '../config/config.js';
2
+ import {rangeRect} from '../browser/range-rect.js';
2
3
  import {linkHtml} from '../mark/standard.js';
3
4
  import {caretAfter} from '../ui/caret.js';
5
+ import {follows} from './contextual.js';
4
6
  import {panelGap, place} from '../ui/place.js';
5
7
  import {valueMark} from '../command/mark.js';
6
8
 
@@ -124,17 +126,9 @@ export function linkEditor({
124
126
  attach({editor, surface}) {
125
127
  const state = editors.get(editor);
126
128
  if (!state) throw new DOMException('The link extension is not set up', 'InvalidStateError');
127
- const controller = new state.document.defaultView.AbortController();
128
- const listen = {signal: controller.signal};
129
- surface.addEventListener('u2-rte-selectionchange', () => follow(state, surface), listen);
130
- surface.addEventListener('u2-rte-change', () => follow(state, surface), listen);
131
- // What a session's end takes away, its beginning brings back: coming
132
- // back to the same selection is no selection change to hear about.
133
- surface.addEventListener('u2-rte-activate', () => follow(state, surface), listen);
134
- surface.addEventListener('u2-rte-deactivate', () => close(state, surface), listen);
129
+ const following = follows(surface, () => follow(state, surface), () => close(state, surface));
135
130
  return {dispose() {
136
- controller.abort();
137
- close(state, surface);
131
+ following.dispose();
138
132
  state.views.delete(surface);
139
133
  }};
140
134
  },
@@ -420,13 +414,20 @@ function position(state) {
420
414
  align: 'start',
421
415
  prefer: 'below',
422
416
  gap: panelGap,
423
- on: active.element?.getBoundingClientRect() || null,
417
+ // What the link covers, not the box it occupies: an inline element that
418
+ // holds an image is a line box of text height, nowhere near the picture
419
+ // someone is looking at.
420
+ on: active.element ? rangeRect(contents(active.element), {root: active.surface.element}) : null,
424
421
  });
425
422
  }
426
423
 
427
- // The node the surface's saved selection starts in.
424
+ // The node the surface's saved selection starts in — or the one it holds, when
425
+ // that is a single element: an image's link is on the image, not on the block the
426
+ // range around it starts in.
428
427
  function at(surface) {
429
- return surface.selection?.range().startContainer || null;
428
+ const range = surface.selection?.range();
429
+ if (!range) return null;
430
+ return range.startContainer.childNodes?.[range.startOffset] ?? range.startContainer;
430
431
  }
431
432
 
432
433
  // The contents of one element, as a range.
@@ -1,6 +1,7 @@
1
1
  import {Tables} from '../command/table.js';
2
2
  import {inlineUi} from '../config/config.js';
3
3
  import {elementOf} from '../selection/ownership/ownership.js';
4
+ import {follows} from './contextual.js';
4
5
  import {Handles} from '../ui/handles.js';
5
6
 
6
7
 
@@ -54,19 +55,13 @@ export function tableTools({tag = 'table'} = {}) {
54
55
  const state = editors.get(editor);
55
56
  const view = state?.views.get(surface);
56
57
  if (!view) throw new DOMException('The table surface is not set up', 'InvalidStateError');
57
- const controller = new state.document.defaultView.AbortController();
58
- const listen = {signal: controller.signal};
59
58
  const sync = () => track(state, view);
60
- surface.addEventListener('u2-rte-selectionchange', sync, listen);
61
- surface.addEventListener('u2-rte-change', sync, listen);
62
- // What a session's end takes away, its beginning brings back: coming
63
- // back to the same selection is no selection change to hear about.
64
- surface.addEventListener('u2-rte-activate', sync, listen);
65
- surface.addEventListener('u2-rte-deactivate', () => close(state, surface), listen);
66
- surface.element.addEventListener('input', sync, listen);
59
+ const following = follows(surface, sync, () => close(state, surface));
60
+ // A table's own shape changes with what is typed in it, wherever the
61
+ // caret happens to be.
62
+ surface.element.addEventListener('input', sync, {signal: following.signal});
67
63
  return {dispose() {
68
- controller.abort();
69
- close(state, surface);
64
+ following.dispose();
70
65
  state.views.delete(surface);
71
66
  }};
72
67
  },
@@ -189,8 +189,8 @@ function selected(edit) {
189
189
  function canApply(edit, adapter, mark) {
190
190
  if (!selected(edit)) return false;
191
191
  const wrapper = adapter.render(mark, edit.document);
192
- for (const node of edit.range.textNodes()) {
193
- if (!node.data || blocked(edit, node) || has(edit, adapter, mark, node)) continue;
192
+ for (const node of markable(edit, adapter)) {
193
+ if (has(edit, adapter, mark, node)) continue;
194
194
  for (let element = node.parentElement; element && element !== edit.element; element = element.parentElement) {
195
195
  if (edit.model.block(element) || isEditingBoundary(element)) break;
196
196
  const current = adapter.parse(element);
@@ -224,8 +224,7 @@ function markState(edit, adapter, mark) {
224
224
  }
225
225
  let active = false;
226
226
  let inactive = false;
227
- for (const node of edit.range.textNodes()) {
228
- if (!node.data || blocked(edit, node)) continue;
227
+ for (const node of markable(edit, adapter)) {
229
228
  has(edit, adapter, mark, node) ? active = true : inactive = true;
230
229
  if (active && inactive) return 'mixed';
231
230
  }
@@ -265,8 +264,7 @@ function marksAt(edit, adapters, node) {
265
264
  function marksIn(edit, adapter) {
266
265
  const marks = [];
267
266
  if (!edit.range) return marks;
268
- for (const node of edit.range.textNodes()) {
269
- if (!node.data || blocked(edit, node)) continue;
267
+ for (const node of markable(edit, adapter)) {
270
268
  for (let element = node.parentElement; element && element !== edit.element; element = element.parentElement) {
271
269
  if (isEditingBoundary(element) || edit.model.block(element)) break;
272
270
  const mark = adapter.parse(element);
@@ -290,7 +288,18 @@ function apply(edit, adapter, mark, range) {
290
288
  changed.push(wrapper);
291
289
  continue;
292
290
  }
293
- if (node.nodeType !== Node.ELEMENT_NODE || isEditingBoundary(node) || edit.model.atomic(node)) continue;
291
+ if (node.nodeType !== Node.ELEMENT_NODE || isEditingBoundary(node)) continue;
292
+ // An atomic element carries a mark the way a word does: nothing can
293
+ // be marked inside it, so the mark goes around it.
294
+ if (edit.model.atomic(node)) {
295
+ if (!covered(range, node) || !wrappable(edit, adapter, mark, node)) continue;
296
+ const wrapper = adapter.render(mark, edit.document);
297
+ if (!edit.model.allows(parent, wrapper)) continue;
298
+ edit.map.wrap([node], wrapper);
299
+ edit.transaction.touch(wrapper);
300
+ changed.push(wrapper);
301
+ continue;
302
+ }
294
303
  if (covered(range, node) && reusable(edit, adapter, node)) {
295
304
  const current = adapter.parse(node);
296
305
  if (current && !current.conflicts(mark)) {
@@ -491,6 +500,29 @@ function marked(edit, adapter, mark, node) {
491
500
  return found;
492
501
  }
493
502
 
503
+ // What a mark can sit on inside the range: the text it formats, and — for a mark
504
+ // about content — the atomic elements that have no text to format. Everything
505
+ // asking what is marked, what could be, and what to replace asks this.
506
+ function markable(edit, adapter) {
507
+ if (!edit.range) return [];
508
+ return [...edit.range.textNodes().filter(node => node.data && !blocked(edit, node)),
509
+ ...atomics(edit, adapter)];
510
+ }
511
+
512
+ // The atomic elements in the range a content mark can sit on: nothing inside one
513
+ // can carry a mark, so the mark is around it — and a text mark has no business
514
+ // with them at all.
515
+ function atomics(edit, adapter) {
516
+ if (adapter.covers !== 'content' || !edit.range) return [];
517
+ return edit.range.roots().filter(node => node.nodeType === Node.ELEMENT_NODE
518
+ && !isEditingBoundary(node) && edit.model.atomic(node));
519
+ }
520
+
521
+ function wrappable(edit, adapter, mark, node) {
522
+ return adapter.covers === 'content' && node?.nodeType === Node.ELEMENT_NODE
523
+ && !isEditingBoundary(node) && edit.model.atomic(node) && !has(edit, adapter, mark, node);
524
+ }
525
+
494
526
  function has(edit, adapter, mark, node) {
495
527
  for (let element = elementOf(node); element && element !== edit.element; element = element.parentElement) {
496
528
  if (isEditingBoundary(element) || edit.model.block(element)) return false;
@@ -12,8 +12,7 @@ export class Rte extends EventTarget {
12
12
  #retained = new Set();
13
13
  #active = null;
14
14
  #unfocused = false;
15
- #pointer = null;
16
- #handed = false;
15
+ #press = null;
17
16
  #controller;
18
17
 
19
18
  constructor(root = document, options = {}) {
@@ -173,15 +172,15 @@ export class Rte extends EventTarget {
173
172
  // on that element — a host's own handler, a css `:focus` rule — sees it
174
173
  // too. Leaving it focused instead is worse: a caret that types into a
175
174
  // surface with no session behind it.
176
- if (surface && this.#pointer && !this.#pointer.includes(surface.element)) {
175
+ if (surface && this.#press && !this.#press.path.includes(surface.element)) {
177
176
  this.#unfocused = true;
178
177
  this.activate(null);
179
178
  // Handing the focus back once per press. An engine that answers a
180
179
  // blur by focusing again — the selection is still in there, after
181
180
  // all — would otherwise trade focus with the editor for as long as
182
181
  // the button is down, and the session stays refused either way.
183
- if (!this.#handed) {
184
- this.#handed = true;
182
+ if (!this.#press.handed) {
183
+ this.#press.handed = true;
185
184
  editable.blur();
186
185
  }
187
186
  return;
@@ -207,8 +206,7 @@ export class Rte extends EventTarget {
207
206
 
208
207
  #pointerDown = event => {
209
208
  const path = event.composedPath();
210
- this.#pointer = path;
211
- this.#handed = false;
209
+ this.#press = {path, handed: false};
212
210
  const editing = this.#editing(path[0]);
213
211
  // The right button opens a menu about what is selected; moving the
214
212
  // selection to what it was aimed at is what makes the menu useless.
@@ -233,7 +231,7 @@ export class Rte extends EventTarget {
233
231
  }
234
232
 
235
233
  #pointerDone = () => {
236
- this.#pointer = null;
234
+ this.#press = null;
237
235
  };
238
236
 
239
237
  #selectionChange = () => {
@@ -17,6 +17,13 @@ an attribute, a style, or something custom. `standard.js` provides the compact
17
17
  default HTML policies for bold, italic, underline, strike, code, and links. The
18
18
  range commands apply or remove those representations on selected text.
19
19
 
20
+ ## What a mark is about
21
+
22
+ A mark covers `text` by default: bold, italic, a content class. `covers:
23
+ "content"` says the other kind — a link is not about the letters but about what
24
+ they point at, so it also goes around content that has no text of its own. An
25
+ image can be a link; an emphasized image is nothing, and stays unmarked.
26
+
20
27
  ## Public contract
21
28
 
22
29
  `MarkType` describes one kind of formatting. `bold`, `link`, and `color` are
@@ -10,8 +10,9 @@ export class MarkAdapter {
10
10
  #write;
11
11
  #clear;
12
12
  #reuse;
13
+ #covers;
13
14
 
14
- constructor(type, {selector, tag, read = TRUE, write, clear, reuse = false, render} = {}) {
15
+ constructor(type, {selector, tag, read = TRUE, write, clear, reuse = false, render, covers = 'text'} = {}) {
15
16
  if (!(type instanceof MarkType)) throw new TypeError('A mark adapter requires a mark type');
16
17
  if (typeof selector !== 'string' || !selector.trim()) {
17
18
  throw new TypeError('A mark adapter requires a non-empty selector');
@@ -25,6 +26,7 @@ export class MarkAdapter {
25
26
  if (reuse && !write) throw new TypeError('A reusable mark adapter requires write');
26
27
  if (render !== undefined && typeof render !== 'function') throw new TypeError('A mark renderer must be a function');
27
28
  if (render && tag !== undefined) throw new TypeError('A custom mark renderer cannot be combined with tag');
29
+ if (covers !== 'text' && covers !== 'content') throw new TypeError('A mark covers "text" or "content"');
28
30
  if (!render && (typeof tag !== 'string' || !/^[a-z][a-z0-9-]*$/.test(tag))) {
29
31
  throw new TypeError('A mark adapter requires a lowercase HTML tag');
30
32
  }
@@ -34,6 +36,7 @@ export class MarkAdapter {
34
36
  this.#write = write;
35
37
  this.#clear = clear;
36
38
  this.#reuse = reuse;
39
+ this.#covers = covers;
37
40
  this.#render = render || ((document, value) => {
38
41
  const element = document.createElement(tag);
39
42
  write?.(element, value);
@@ -44,6 +47,10 @@ export class MarkAdapter {
44
47
  get type() { return this.#type; }
45
48
  get selector() { return this.#selector; }
46
49
  get reusable() { return !!this.#reuse; }
50
+ // What the mark is about: the text it formats, or the content it applies to.
51
+ // A link is the second kind — an image can be one, an emphasized image is
52
+ // nothing — and only that kind goes around content with no text of its own.
53
+ get covers() { return this.#covers; }
47
54
  get removable() { return !!this.#clear; }
48
55
 
49
56
  parse(element) {
@@ -18,6 +18,8 @@ export const codeHtml = element(code, 'code', 'code');
18
18
  export const linkHtml = new MarkAdapter(link, {
19
19
  selector: 'a[href]',
20
20
  tag: 'a',
21
+ // A link is about the content, not about its text: an image can be one.
22
+ covers: 'content',
21
23
  read: readLink,
22
24
  write: writeLink,
23
25
  clear: clearLink,
@@ -6,10 +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;
9
+ // One distance for every panel that hangs off something, and between two of them
10
+ // — near enough to read as belonging to it. What must be cleared belongs in the
11
+ // rect a caller anchors on, not in this number: a frame's handles reach past the
12
+ // element they sit on, and the panel below them is told so.
13
+ export const panelGap = 8;
13
14
 
14
15
  export function place(element, surface, {align = 'center', prefer = 'above', gap = 28, on = null} = {}) {
15
16
  const range = on ? null : surface.selection?.range();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u2ui",
3
- "version": "1.5.8",
3
+ "version": "1.5.10",
4
4
  "description": "HTML Advanced: More Capabilities, Same Feel, Same Simplicity",
5
5
  "keywords": ["web-components","pattern-library","component-library","ui-toolkit","design-system"],
6
6
  "author": "Tobias Buschor",