u2ui 1.5.4 → 1.5.7

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 CHANGED
@@ -28,46 +28,25 @@ intercepting its mutations. Complete mark sets now resolve exclusions, remove
28
28
  configured values absent from an exact target, and apply their canonical result
29
29
  inside one mapped operation.
30
30
 
31
- The 201-test mark-algebra baseline was confirmed in current Chromium, Firefox,
32
- and WebKit. The 233-test pending-mark baseline passes in current Chromium,
33
- Firefox, and WebKitGTK through GNOME Web. The 361-test baseline was confirmed in
34
- Chromium, Firefox, and WebKit. The current runner adds empty list
35
- exit, context-aware nested-list Enter, mixed generic-block repair, the roaming
36
- toolbar, semantic wrapper removal, ready-made bold, the convention client with
37
- optional command modules and block-style values, heading-aware Enter,
38
- content-model-driven block merging in both deletion directions, focus
39
- dismissal, collapsed range geometry, and one shared structural element policy
40
- for normalization, commands, and toolbar choices. The current lifecycle and
41
- visible-break extension, sanitizing policy/native adapter, selection-only
42
- toolbar mode, staged Unstyle policy/command, mapped context-aware fragment
43
- replacement, fail-closed rich paste/drop composition, and mapped post-native
44
- presentation cleanup and composition-aware pending marks raise the verified
45
- runner to 369 tests. Complete mark-set coverage raises the current runner to
46
- 375 tests. Nested canonical mark coverage raises the current runner to 379
47
- tests. Standard HTML mark coverage raises the current runner to 381 tests.
48
- State-based history — baseline entries, transaction-grouped recording, path
49
- addressed selection restoration, coalescing, branch and limit handling, and its
50
- commands — raises the current runner to 398 tests. Lists, their nesting levels,
51
- model-placed element insertion, and the standard mark and structure convention
52
- modules raise the current runner to 427 tests. Atomic-block deletion,
53
- selection-owned history commands, and the HTML source responsibility with its
54
- dialog module raise the current runner to 452 tests. Value marks and the
55
- contextual link editor raise the current runner to 470 tests. The CSS-declared
56
- content classes shared by the style control, the sanitizer, and presentation
57
- cleanup raise the current runner to 486 tests. One shared placement policy and
58
- its coverage raise it to 490 tests. The full remove-format ladder and
59
- registry-owned shortcuts raise it to 500 tests. Depth-independent wrapper
60
- removal, a canonical level that does something, and paste cleanup that covers
61
- what arrived raise it to 508 tests. The attribute policy on native imports
62
- and a strict import element policy raise it to 513 tests, confirmed in Chrome
63
- 152 and Firefox 154. Import aliases raise it to 519 tests, and the table
64
- structure commands with their contextual handles to 538, and the image frame
65
- with its attribute command to 554, all of it drawn in one shadow root per
66
- editor; whole-content remove-format and the presence/availability rule raise it
67
- to 565. The playground now configures its prototype surface live from a panel
68
- built out of the registered modules, so every host property can be tried without
69
- editing the page.
70
- WebKit verification of that revision is pending.
31
+ The browser runner covers every phase below: mark algebra, pending overrides
32
+ carried through native IME composition, complete and nested canonical mark sets,
33
+ state-based history with its commands, lists and their nesting levels,
34
+ model-placed element insertion, the HTML source responsibility with its dialog,
35
+ value marks and the contextual link editor, CSS-declared content classes shared
36
+ by the style control, the sanitizer and presentation cleanup, one shared
37
+ placement policy, the full remove-format ladder, registry-owned shortcuts, the
38
+ attribute and element policies on imports with their aliases, the table
39
+ structure commands and the image frame with their contextual handles, and the
40
+ session rule that takes all of it away at once — everything the convention
41
+ client draws in one shadow root per editor. The playground configures its
42
+ prototype surface live from a panel built out of the registered modules, so
43
+ every host property can be tried without editing the page.
44
+
45
+ How many tests that is, the runner says itself; the number is not repeated here,
46
+ because a number in prose is stale the moment the next test is written. A result
47
+ is cross-browser evidence only after that exact revision has run in current
48
+ Chromium, Firefox, and WebKit. The last such confirmation was Chrome 152 and
49
+ Firefox 154; WebKit verification of that revision is pending.
71
50
 
72
51
  Remove-format now runs a ladder that does not stop at the application's own
73
52
  presentation: six presentation rungs in `Unstyle` (styles, presentational
@@ -97,7 +76,7 @@ allowlist, and layout ids, tracking attributes and inline styles came straight
97
76
  through. The pipeline applies `SanitizePolicy.clean()` to the nodes that
98
77
  arrived, before presentation cleanup and structural repair, so an id removed in
99
78
  the first stage is what lets the third dissolve the wrapper carrying it.
100
- `--u2-rte-import-attributes: keep` opts a host out.
79
+ `--u2-rte-import-sanitize: none` opts a host out.
101
80
 
102
81
  Pasted content is now cleaned where it landed. The cleanup scope was derived
103
82
  from the caret, which sits at the *end* of a paste, so a pasted document was
@@ -127,11 +106,31 @@ content model dropped from 10.0 to 1.8 µs, the list scan from 31.4 to 21.0 µs,
127
106
  and one toolbar refresh from 490 to 437 µs. A lazy `config()` was measured and
128
107
  rejected: building eight accessor descriptors costs more than parsing the values
129
108
  eagerly.
109
+
110
+ Measured again in Chrome after the host-shape work, then sampled with the CPU
111
+ profiler over 400 keystrokes. What a keystroke costs was never normalization: a
112
+ third of it was reading CSS the caller did not need. `Commands.model` and
113
+ `Edit.model` narrow the content model by one property and were parsing all
114
+ eleven, and the toolbar resolved a computed style four times per refresh. Both
115
+ now read what they use, without a cache anywhere — the settings stay live.
116
+
117
+ | | before | after |
118
+ | --- | --- | --- |
119
+ | `Commands.model` | 8.7 µs | 0.7 µs |
120
+ | `commands.enabled('bold')` | 11.3 µs | 6.1 µs |
121
+ | `Toolbar.refresh` (8 controls) | 160 µs | 120 µs |
122
+ | one keystroke, roaming toolbar | 519 µs | 415 µs |
123
+
124
+ What is left is flat: `getBoundingClientRect` for the toolbar's placement is the
125
+ largest single item at 14% of a keystroke, then event dispatch, range cloning,
126
+ and the model's own rules. A click inside a surface costs 8.0 µs against 6.0 µs
127
+ for the same event outside the editor, a keydown 4.6 against 3.4, and drawing
128
+ the saved selection 0.5 µs per selection change.
130
129
  Treat the number shown by `/u2/js/rte/tests/` as authoritative.
131
130
 
132
131
  ## 0. Foundation
133
132
 
134
- Status: implemented. The 106-test foundation and policy suite passed in current
133
+ Status: implemented. The foundation and policy suite passed in current
135
134
  Chromium, Firefox, and WebKit. Later phase tests extend the same browser runner.
136
135
 
137
136
  - CSS configuration with semantic host defaults.
@@ -343,7 +342,7 @@ because a declared class is content rather than presentation.
343
342
  Status: started. The optional roaming `Toolbar` binds application-owned markup
344
343
  to the active surface's resolved command registry, reflects availability and
345
344
  boolean/mixed state, keeps saved selections across UI focus, supports simple
346
- Ctrl/Command shortcuts, hides after focus leaves surface and toolbar, and
345
+ Ctrl/Command shortcuts, shows and hides with the active surface, and
347
346
  delegates placement and presentation. Application-supplied manual popovers and
348
347
  the convention toolbar now enter and leave the browser top layer with the same
349
348
  visibility contract. `--u2-rte-toolbar-when: selection` optionally requires a
package/js/rte/README.md CHANGED
@@ -117,7 +117,7 @@ rte/
117
117
  │ ├── surface/ State of one editable host
118
118
  │ ├── transaction/ Atomic editing changes and dirty scopes
119
119
  │ ├── unstyle/ Shared selection and external presentation policy
120
- │ └── ui/ Editor chrome: toolbar, handles, placement
120
+ │ └── ui/ Editor chrome: toolbar, handles, placement, caret
121
121
  ├── docs/ Project-wide guides
122
122
  ├── playground/ Visual normalization, input, and mark inspection
123
123
  └── tests/ Shared harness and cross-module browser cases
@@ -222,6 +222,23 @@
222
222
  <output></output>
223
223
  </section>
224
224
 
225
+ <section class=case>
226
+ <h3><code>&lt;button&gt; &lt;span contenteditable&gt;</code></h3>
227
+ <button type=button>
228
+ Wraps <span class=rte-host contenteditable data-name=inside-button>editable host</span>
229
+ </button>
230
+ <output></output>
231
+ </section>
232
+
233
+ <section class=case>
234
+ <h3><code>&lt;form&gt; &lt;button contenteditable&gt;</code></h3>
235
+ <form id=submit-form>
236
+ <button class=rte-host contenteditable data-name=submit-button>Editable submit button</button>
237
+ <output id=submit-state>not submitted</output>
238
+ </form>
239
+ <output></output>
240
+ </section>
241
+
225
242
  <section class=case>
226
243
  <h3><code>&lt;div contenteditable&gt; &lt;a&gt;</code></h3>
227
244
  <div class=rte-host contenteditable data-name=around-link>
@@ -230,6 +247,18 @@
230
247
  <output></output>
231
248
  </section>
232
249
 
250
+ <h2 class=group>Atomic content</h2>
251
+
252
+ <section class=case>
253
+ <h3><code>&lt;div contenteditable&gt;</code> with a rule, an image, and a control</h3>
254
+ <div class=rte-host contenteditable data-name=atomic>
255
+ <p>Text before the rule</p>
256
+ <hr>
257
+ <p>An image <img src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='40'%3E%3Crect width='80' height='40' fill='%23bcd'/%3E%3C/svg%3E" alt="Placeholder" width=80 height=40> and a <button type=button>button</button> in the text</p>
258
+ </div>
259
+ <output></output>
260
+ </section>
261
+
233
262
  <h2 class=group>Lists</h2>
234
263
 
235
264
  <section class=case>
@@ -433,6 +462,12 @@
433
462
  import {config, rte, Source} from '../rte.js';
434
463
 
435
464
  const hosts = [...document.querySelectorAll('.rte-host')];
465
+ // An editable submit button: typing in it must never send the form, which is
466
+ // what a key and a click both mean to a button that is not editable.
467
+ document.querySelector('#submit-form').addEventListener('submit', event => {
468
+ event.preventDefault();
469
+ document.querySelector('#submit-state').textContent = 'submitted';
470
+ });
436
471
  const events = [];
437
472
  const active = document.querySelector('#active');
438
473
  const selection = document.querySelector('#selection');
@@ -374,6 +374,7 @@
374
374
  import '../source.js';
375
375
  import '../breaks.js';
376
376
  import '../unstyle.js';
377
+ import {externalInputs, NativeSanitizer} from '../rte.js';
377
378
  import {rangeRect} from '../src/browser/range-rect.js';
378
379
  import {Commands} from '../src/command/commands.js';
379
380
  import {deleteBackward, deleteForward} from '../src/command/delete.js';
@@ -395,6 +396,16 @@
395
396
  import {SelectionSnapshot} from '../src/selection/snapshot.js';
396
397
  import {Toolbar} from '../src/ui/toolbar.js';
397
398
 
399
+ // Taking over paste and drop is the application's call, because a sanitizer
400
+ // is a security boundary and the engine does not pick one. Left to the
401
+ // browser, an image dragged inside the text is whatever that browser decides
402
+ // to make of it: Firefox writes a `data:` image out as its URL, in plain
403
+ // text. The one shipped here needs `Element.setHTML()`, which not every
404
+ // engine has yet.
405
+ if (NativeSanitizer.supported()) {
406
+ conventionEditor.add(externalInputs({sanitizer: new NativeSanitizer()}));
407
+ }
408
+
398
409
  const controls = {
399
410
  scenario: document.querySelector('#scenario'),
400
411
  level: document.querySelector('#level'),
package/js/rte/run.out ADDED
@@ -0,0 +1,17 @@
1
+ node:internal/modules/cjs/loader:1433
2
+ throw err;
3
+ ^
4
+
5
+ Error: Cannot find module '/var/www/workplace/u2/js/rte/run.mjs'
6
+ at Function._resolveFilename (node:internal/modules/cjs/loader:1430:15)
7
+ at defaultResolveImpl (node:internal/modules/cjs/loader:1040:19)
8
+ at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1045:22)
9
+ at Function._load (node:internal/modules/cjs/loader:1216:25)
10
+ at wrapModuleLoad (node:internal/modules/cjs/loader:254:19)
11
+ at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:171:5)
12
+ at node:internal/main/run_main_module:36:49 {
13
+ code: 'MODULE_NOT_FOUND',
14
+ requireStack: []
15
+ }
16
+
17
+ Node.js v22.23.2
@@ -0,0 +1,17 @@
1
+ // A link, a button, or a label around an editing host — `<a><span
2
+ // contenteditable>` — is a host shape engines answer with the element instead of
3
+ // the text: the link is dragged rather than giving the text a caret and followed
4
+ // when the press ends, and the label hands the press to the control it names.
5
+ // All of it belongs to what surrounds the text, not to someone editing it.
6
+ //
7
+ // Only what surrounds the host is asked for: a link the content itself holds is
8
+ // content, and what a press does to that one is the editor's own business.
9
+ export function interactiveAround(host) {
10
+ return host?.closest('a[href], button, label') || null;
11
+ }
12
+
13
+ // And a button answers Space and Enter by activating itself, whatever it holds:
14
+ // the text inside never sees those keys, and no `beforeinput` arrives at all.
15
+ export function activatingAround(host) {
16
+ return host?.closest('button') || null;
17
+ }
@@ -32,7 +32,9 @@ presentation cleanup leaves alone.
32
32
  The optional `images.js` entry frames a selected image, with handles on its
33
33
  trailing edges and its alt text in a field below it. Both come and go with the
34
34
  selection and neither takes the focus by appearing, so naming an image is one
35
- click away and nothing has to be opened.
35
+ click away and nothing has to be opened. The name is written as it is typed, so
36
+ Enter has nothing to confirm: it hands the caret back to the text, after the
37
+ image.
36
38
 
37
39
  The optional `link.js` entry adds a contextual link editor. Its `link` command
38
40
  is an ordinary `valueMark`, so any other UI can drive the same create, change,
@@ -85,12 +87,18 @@ text before they had seen the answer.
85
87
 
86
88
  `complete(text, surface)` is asked what addresses go with what is being typed and
87
89
  answers with entries `{value, label}` — or `{value, html}` for a row with markup,
88
- which the form writes through `setHTML()`, so the platform sanitizes it. The form
90
+ which the form writes through `setHTML()`, so the platform sanitizes it; where an
91
+ engine does not ship that yet, the entry shows its address instead. The form
89
92
  renders its own list rather than a native datalist, which cannot show markup: the
90
93
  arrow keys move through it, Enter takes the current entry, Escape closes the list
91
94
  before it closes the form, and a pointer does the same. One question is in flight
92
95
  at a time, and a late answer to a word that has since changed is dropped.
93
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.
101
+
94
102
  Everything this client draws goes into one `Chrome`: its toolbar, the contextual
95
103
  handles, the link form, the source dialog. A module receives it as `chrome` in
96
104
  its setup context and puts its UI in `chrome.root`, so no module owns a layer, a
@@ -110,10 +118,9 @@ text flow with its top and start edges held in place, so dragging the other side
110
118
  is the only direction that grows it where the eye expects — a handle on a
111
119
  leading edge would move the picture rather than resize it. A drag moves the
112
120
  frame only and the size is written once when it is released, so a resize is one
113
- undo step rather than a trail of them. The module also selects an image that is
114
- clicked, because engines disagree about whether pointing at one does, and
115
- nothing is addressable until it is the selection. What is resizable is a
116
- selector, so any atomic element can be made sizeable.
121
+ undo step rather than a trail of them. Selecting the image that was clicked is
122
+ not its job: the input pipeline selects any atomic element a click lands on.
123
+ What is resizable is a selector, so any atomic element can be made sizeable.
117
124
 
118
125
  The optional `tables.js` entry puts its actions on the table itself. Row handles
119
126
  run down the table's left edge and column handles along its top, each lined up
@@ -1,5 +1,6 @@
1
1
  import {Commands} from '../command/commands.js';
2
2
  import {deleteBackward, deleteForward} from '../command/delete.js';
3
+ import {insertFragment} from '../command/fragment.js';
3
4
  import {enter, lineBreak} from '../command/enter.js';
4
5
  import {History} from '../history/history.js';
5
6
  import {PendingMarks} from '../command/pending-marks.js';
@@ -9,6 +10,7 @@ import {marks} from './marks.js';
9
10
  import {structure} from './structure.js';
10
11
  import {isPlainTextHost} from '../selection/ownership/ownership.js';
11
12
  import {Chrome} from '../ui/chrome.js';
13
+ import {SelectionHighlight} from '../ui/highlight.js';
12
14
  import {place} from '../ui/place.js';
13
15
  import {Toolbar} from '../ui/toolbar.js';
14
16
 
@@ -48,6 +50,7 @@ export class Editor {
48
50
  #setups = new Map();
49
51
  #sources = new WeakMap();
50
52
  #chrome = null;
53
+ #highlight = null;
51
54
  #toolbar = null;
52
55
  #element = null;
53
56
  #dynamic = new Map();
@@ -85,10 +88,13 @@ export class Editor {
85
88
  if (!this.#chrome) {
86
89
  this.#chrome = new Chrome(this.#core.root, {name: 'editor'});
87
90
  this.#core.retain(this.#chrome.element);
91
+ // What the chrome takes the focus away from has to stay visible.
92
+ this.#highlight = new SelectionHighlight(this.#core);
88
93
  }
89
94
  return this.#chrome;
90
95
  }
91
96
  get toolbar() { return this.#toolbar; }
97
+ get highlight() { return this.#highlight; }
92
98
  get connected() { return this.#connected; }
93
99
 
94
100
  commands(surface) {
@@ -198,6 +204,8 @@ export class Editor {
198
204
  // The chrome reference is kept: its getter must not build a new one for
199
205
  // a client that is gone.
200
206
  this.#chrome?.dispose();
207
+ this.#highlight?.dispose();
208
+ this.#highlight = null;
201
209
  this.#dynamic.clear();
202
210
  this.#toolbar = null;
203
211
  this.#element = null;
@@ -222,6 +230,9 @@ export class Editor {
222
230
  enter,
223
231
  lineBreak,
224
232
  insertText: pending.insertText,
233
+ // Prepared content at the selection: what a host inserts itself, and
234
+ // what a key gets that its host answered instead of the text.
235
+ insert: insertFragment,
225
236
  }});
226
237
  const controller = new this.#document.defaultView.AbortController();
227
238
  const record = {surface, pending, history, commands, pipeline: null, controller, modules: new Map()};
@@ -1,5 +1,6 @@
1
1
  import {elementAttributes, selectedElement} from '../command/element.js';
2
2
  import {inlineUi} from '../config/config.js';
3
+ import {caretAfter} from '../ui/caret.js';
3
4
  import {Handles} from '../ui/handles.js';
4
5
  import {place} from '../ui/place.js';
5
6
 
@@ -57,6 +58,7 @@ export function imageTools({selector = 'img', minimum = 16} = {}) {
57
58
  minimum,
58
59
  handles: null,
59
60
  alt: null,
61
+ writing: false,
60
62
  controller: null,
61
63
  pending: 0,
62
64
  };
@@ -96,22 +98,10 @@ export function imageTools({selector = 'img', minimum = 16} = {}) {
96
98
  const sync = () => track(state, view, match);
97
99
  surface.addEventListener('u2-rte-selectionchange', sync, listen);
98
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);
99
104
  surface.addEventListener('u2-rte-deactivate', () => close(state, surface), listen);
100
- // A click has to select the image itself: engines disagree about
101
- // whether pointing at one does, and nothing is addressable until it
102
- // is the selection.
103
- surface.element.addEventListener('click', event => {
104
- const element = event.composedPath()[0];
105
- if (element?.nodeType !== Node.ELEMENT_NODE || !element.matches?.(selector)) return;
106
- if (!surface.element.contains(element)) return;
107
- const range = state.document.createRange();
108
- range.selectNode(element);
109
- const selection = surface.core.selection;
110
- selection.removeAllRanges();
111
- selection.addRange(range);
112
- surface.capture();
113
- sync();
114
- }, listen);
115
105
  return {dispose() {
116
106
  controller.abort();
117
107
  close(state, surface);
@@ -127,6 +117,9 @@ export function imageTools({selector = 'img', minimum = 16} = {}) {
127
117
  export const images = imageTools();
128
118
 
129
119
  function track(state, view, match) {
120
+ // Not while the field is naming the image: what it says is being typed, and
121
+ // the attribute is only what has been typed so far.
122
+ if (state.writing) return;
130
123
  if (!inlineUi(view.surface.config, 'image')) return close(state, view.surface);
131
124
  const element = current(view, match);
132
125
  if (!element) return close(state, view.surface);
@@ -134,9 +127,7 @@ function track(state, view, match) {
134
127
  state.active = {view, element};
135
128
  state.handles.show();
136
129
  state.alt.hidden = false;
137
- if (state.document.activeElement !== state.chrome.element) {
138
- state.alt.firstElementChild.value = element.getAttribute('alt') || '';
139
- }
130
+ state.alt.firstElementChild.value = element.getAttribute('alt') || '';
140
131
  schedule(state);
141
132
  }
142
133
 
@@ -165,12 +156,26 @@ function name(state, field) {
165
156
  const active = state.active;
166
157
  if (!active) return null;
167
158
  const caret = [field.selectionStart, field.selectionEnd];
168
- active.view.commands.run('imageAlt', {value: {alt: field.value.trim()}, trigger: 'input'});
159
+ state.writing = true;
160
+ try {
161
+ active.view.commands.run('imageAlt', {value: {alt: field.value.trim()}, trigger: 'input'});
162
+ } finally {
163
+ state.writing = false;
164
+ }
169
165
  field.focus();
170
166
  field.setSelectionRange(...caret);
171
167
  return active.element;
172
168
  }
173
169
 
170
+ function leave(state) {
171
+ const active = state.active;
172
+ if (!active) return;
173
+ const {element} = active;
174
+ const surface = active.view.surface;
175
+ close(state);
176
+ if (surface.connected) caretAfter(surface, element);
177
+ }
178
+
174
179
  function schedule(state) {
175
180
  if (state.pending || !state.handles) return;
176
181
  state.pending = state.document.defaultView.requestAnimationFrame(() => {
@@ -203,7 +208,12 @@ function build(state) {
203
208
  field.placeholder = 'Alt text';
204
209
  field.setAttribute('aria-label', 'Alt text');
205
210
  form.append(field);
206
- form.addEventListener('submit', event => event.preventDefault());
211
+ // One field, so Enter submits implicitly — and with the name already
212
+ // written, there is nothing left to confirm: it just leaves.
213
+ form.addEventListener('submit', event => {
214
+ event.preventDefault();
215
+ leave(state);
216
+ });
207
217
  form.addEventListener('input', () => name(state, field));
208
218
  state.alt = form;
209
219
  state.handles = new Handles(state.chrome.root, {
@@ -1,5 +1,6 @@
1
1
  import {inlineUi} from '../config/config.js';
2
2
  import {linkHtml} from '../mark/standard.js';
3
+ import {caretAfter} from '../ui/caret.js';
3
4
  import {place} from '../ui/place.js';
4
5
  import {valueMark} from '../command/mark.js';
5
6
 
@@ -127,6 +128,9 @@ export function linkEditor({
127
128
  const listen = {signal: controller.signal};
128
129
  surface.addEventListener('u2-rte-selectionchange', () => follow(state, surface), listen);
129
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);
130
134
  surface.addEventListener('u2-rte-deactivate', () => close(state, surface), listen);
131
135
  return {dispose() {
132
136
  controller.abort();
@@ -365,24 +369,15 @@ function write(state, value, field = null) {
365
369
  return active.element;
366
370
  }
367
371
 
368
- // Leaving hands the caret back to the text, and puts it after the link rather
369
- // than inside it: someone who just made a link wants to keep writing, without it.
372
+ // What is left behind decides where the caret goes: the link that was made, or
373
+ // the text that is left when emptying the address took one away.
370
374
  function leave(state) {
371
375
  const active = state.active;
372
376
  const element = active?.element;
373
377
  if (active?.creating) close(state);
374
378
  else if (state.dirty) commit(state);
375
- if (!active?.surface.connected) return;
376
- active.surface.element.focus();
377
- const after = state.active?.element || element;
378
- if (!after?.isConnected) return active.surface.restore();
379
- const range = state.document.createRange();
380
- range.setStartAfter(after);
381
- range.collapse(true);
382
- const selection = active.surface.core.selection;
383
- selection.removeAllRanges();
384
- selection.addRange(range);
385
- active.surface.capture();
379
+ if (!active?.surface.connected) return null;
380
+ caretAfter(active.surface, state.active?.element || element);
386
381
  return null;
387
382
  }
388
383
 
@@ -59,6 +59,9 @@ export function tableTools({tag = 'table'} = {}) {
59
59
  const sync = () => track(state, view);
60
60
  surface.addEventListener('u2-rte-selectionchange', sync, listen);
61
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);
62
65
  surface.addEventListener('u2-rte-deactivate', () => close(state, surface), listen);
63
66
  surface.element.addEventListener('input', sync, listen);
64
67
  return {dispose() {
@@ -52,7 +52,10 @@ own object under the same name, or register commands the engine never ships.
52
52
  ## Edit
53
53
 
54
54
  - `range` is the `EditRange` to act on: the explicit target range if one was
55
- given, otherwise the current selection. Foreign ranges resolve to `null`.
55
+ given, otherwise the current selection, and otherwise the selection the
56
+ surface has saved — a field of the editor's own chrome holds the caret while
57
+ it names what is still selected in the surface. Foreign ranges resolve to
58
+ `null`.
56
59
  - `map` is a fresh `PointMap`. Mutations that go through it keep every tracked
57
60
  point, so a command can compute its resulting caret before it exists.
58
61
  - `select(start, end, backward)` sets the resulting selection; one point
@@ -1,3 +1,4 @@
1
+ import {configuredElements} from '../config/config.js';
1
2
  import {Edit, narrow} from './edit.js';
2
3
  import {htmlModel} from '../model/html/html-model.js';
3
4
 
@@ -28,7 +29,7 @@ export class Commands {
28
29
 
29
30
  get surface() { return this.#surface; }
30
31
  get model() {
31
- return narrow(this.#model, this.#surface.config.elements);
32
+ return narrow(this.#model, configuredElements(this.#surface.element));
32
33
  }
33
34
  get names() { return [...this.#commands.keys()]; }
34
35
 
@@ -131,7 +132,7 @@ const MODIFIERS = ['ctrl', 'alt', 'shift'];
131
132
  // A shortcut is one or more chords separated by spaces, each written as
132
133
  // `[modifier+]*key`. `ctrl` matches Control or Command, so one declaration
133
134
  // serves both platforms.
134
- export function chords(value) {
135
+ function chords(value) {
135
136
  if (value === undefined || value === null) return [];
136
137
  if (typeof value !== 'string' || !value.trim()) throw new TypeError('A command shortcut must be a non-empty string');
137
138
  return value.trim().toLowerCase().split(/\s+/).map(item => {
@@ -147,7 +148,7 @@ export function chords(value) {
147
148
  // The chord a keyboard event denotes. A digit is read from its physical key, so
148
149
  // a shifted digit is the same shortcut on every keyboard layout, while letters
149
150
  // keep their layout meaning.
150
- export function chord(event) {
151
+ function chord(event) {
151
152
  const digit = /^Digit(\d)$/.exec(event.code || '');
152
153
  const parts = [];
153
154
  if (event.ctrlKey || event.metaKey) parts.push('ctrl');
@@ -63,7 +63,14 @@ function merge(edit, left, right, between) {
63
63
 
64
64
  function boundary(edit, direction) {
65
65
  const point = edit.range?.collapsed && edit.range.start;
66
- if (!point || insideAtomic(edit, point.node)) return null;
66
+ if (!point) return null;
67
+ // An engine can leave the caret inside an element that has no inside —
68
+ // clicking a rule does that in some. The position is at the element, not in
69
+ // it, and deleting there is what takes the element away.
70
+ const hollow = elementOf(point.node) === point.node && !point.node.childNodes.length
71
+ && point.node !== edit.element && edit.model.atomic(point.node);
72
+ if (hollow) return {atomic: point.node, between: []};
73
+ if (insideAtomic(edit, point.node)) return null;
67
74
  const edge = direction === 'backward' ? 'start' : 'end';
68
75
  for (let current = elementOf(point.node); current && current !== edit.element; current = current.parentElement) {
69
76
  if (!edit.model.mergeable(current) || !atEdge(current, point, edge, edit.model)) continue;
@@ -1,3 +1,4 @@
1
+ import {configuredElements} from '../config/config.js';
1
2
  import {htmlModel} from '../model/html/html-model.js';
2
3
  import {EditRange} from '../selection/range/edit-range.js';
3
4
  import {PointMap} from '../selection/map/point-map.js';
@@ -37,9 +38,14 @@ export class Edit {
37
38
  this.#data = data;
38
39
  this.#value = value;
39
40
  this.#fragment = fragment;
41
+ // The same rule a transaction applies: a live selection inside the
42
+ // surface is newer than the saved one, and a selection outside it says
43
+ // nothing about what is being edited — a field of the editor's own
44
+ // chrome holds the caret while it names what is still selected here.
40
45
  this.#range = range
41
46
  ? EditRange.fromRange(range, surface.element)
42
- : EditRange.fromSelection(surface.core.selection, surface.element);
47
+ : EditRange.fromSelection(surface.core.selection, surface.element)
48
+ || (surface.selection ? EditRange.fromRange(surface.selection.range(), surface.element) : null);
43
49
  }
44
50
 
45
51
  get surface() { return this.#surface; }
@@ -50,7 +56,7 @@ export class Edit {
50
56
  // checks never consult it. Resolving it on first use keeps a toolbar
51
57
  // refresh from paying for every control that does not care.
52
58
  get model() {
53
- this.#narrowed ??= narrow(this.#model, this.#surface.config.elements);
59
+ this.#narrowed ??= narrow(this.#model, configuredElements(this.#surface.element));
54
60
  return this.#narrowed;
55
61
  }
56
62
  get map() { return this.#map; }
@@ -51,6 +51,13 @@ export function hostDefaults(host) {
51
51
  return DEFAULTS.block;
52
52
  }
53
53
 
54
+ // Only the setting the model narrowing wants. The whole configuration parses
55
+ // eleven properties, and reading a computed style is the expensive part of a
56
+ // path every keystroke and every availability check goes through.
57
+ export function configuredElements(host) {
58
+ return allowedElements(getComputedStyle(host), 'elements', null);
59
+ }
60
+
54
61
  export function config(host) {
55
62
  const style = getComputedStyle(host);
56
63
  const defaults = hostDefaults(host);
@@ -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,17 @@ 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
+ `caretAfter(surface, element)` is its counterpart at the end: the one way a
247
+ contextual UI hands the caret back, after the element it was about to change
248
+ rather than inside it. An element that is no longer there leaves the surface
249
+ with the selection it had.
250
+
235
251
  A select with no usable choice hides like a button whose command is unavailable:
236
252
  a control that cannot be used is not shown.
237
253
 
238
254
  Pointing anywhere at the toolbar never moves the editor's selection, including
239
255
  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
256
+ button would blur the surface and end the editing session. Fields keep their own
241
257
  pointer behaviour so they can be opened and typed in.
242
258
 
243
259
  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
+ }
@@ -8,7 +8,6 @@ export class Toolbar {
8
8
  #surfaceController = null;
9
9
  #surface = null;
10
10
  #commands = null;
11
- #dismissed = false;
12
11
  #connected = true;
13
12
 
14
13
  constructor(core, element, {commands, place = null} = {}) {
@@ -39,8 +38,6 @@ export class Toolbar {
39
38
  }, listen);
40
39
  element.addEventListener('click', this.#click, listen);
41
40
  element.addEventListener('change', this.#change, listen);
42
- core.root.addEventListener('focusin', this.#focusIn, {...listen, capture: true});
43
- core.root.addEventListener('focusout', this.#focusOut, {...listen, capture: true});
44
41
  if (!element.hasAttribute('role')) element.setAttribute('role', 'toolbar');
45
42
  element.hidden = true;
46
43
  // Focus landing in a control is still the editor's: without this the core
@@ -66,12 +63,16 @@ export class Toolbar {
66
63
  const commands = surface?.connected ? this.#resolve(surface) : null;
67
64
  if (commands != null && !registry(commands)) throw new TypeError('Toolbar commands must resolve to a command registry');
68
65
  this.#commands = commands || null;
69
- const active = !!commands && !this.#dismissed && surface.config.ui === 'roaming'
70
- && visibleForSelection(surface);
71
- const names = active ? configured(surface.element) : null;
66
+ // One computed style for the settings only this toolbar reads: asking for
67
+ // it three times is three style resolutions on a path every keystroke
68
+ // goes down. The shared ones keep coming from the surface, defaults and
69
+ // validation included.
70
+ const style = commands && getComputedStyle(surface.element);
71
+ const active = !!commands && surface.config.ui === 'roaming' && visibleForSelection(surface, style);
72
+ const names = active ? configured(style) : null;
72
73
  // A host may prefer a toolbar that only ever shows what it can do, at
73
74
  // the cost of a shape that moves with the caret.
74
- const hiding = active && unavailable(surface.element) === 'hide';
75
+ const hiding = active && setting(style, 'toolbar-unavailable') === 'hide';
75
76
  const detail = surface?.selection ? {range: surface.selection.range()} : undefined;
76
77
  let visible = 0;
77
78
  for (const item of this.#items()) {
@@ -138,7 +139,6 @@ export class Toolbar {
138
139
  this.#surfaceController = null;
139
140
  this.#surface = null;
140
141
  this.#commands = null;
141
- this.#dismissed = false;
142
142
  display(this.#element, false);
143
143
  this.#connected = false;
144
144
  }
@@ -154,7 +154,6 @@ export class Toolbar {
154
154
  }
155
155
  this.#surfaceController?.abort();
156
156
  this.#surface = surface || null;
157
- this.#dismissed = false;
158
157
  this.#surfaceController = null;
159
158
  if (surface) {
160
159
  const Controller = this.#element.ownerDocument.defaultView.AbortController;
@@ -194,18 +193,6 @@ export class Toolbar {
194
193
  #disconnect = () => this.#activate(null);
195
194
  #refresh = () => this.refresh();
196
195
 
197
- #focusIn = event => {
198
- if (!this.#dismissed || !this.#ownsFocus(event.composedPath()[0])) return;
199
- this.#dismissed = false;
200
- this.refresh();
201
- };
202
-
203
- #focusOut = event => {
204
- if (!this.#ownsFocus(event.composedPath()[0]) || this.#ownsFocus(event.relatedTarget)) return;
205
- this.#dismissed = true;
206
- display(this.#element, false);
207
- };
208
-
209
196
  // The toolbar is chrome: pointing at it must never move the editor's
210
197
  // selection, not even at a control that currently has nothing to run.
211
198
  // Fields keep their own pointer behaviour so they can be opened and typed in.
@@ -233,18 +220,6 @@ export class Toolbar {
233
220
  }
234
221
  this.#run(command, {value: name});
235
222
  };
236
-
237
-
238
- #ownsFocus(node) {
239
- if (!node || !this.#surface) return false;
240
- // A `focusout` listener outside the toolbar's shadow tree sees its
241
- // related target retargeted to that tree's host, so focus moving into a
242
- // control of its own would otherwise read as focus leaving.
243
- const root = this.#element.getRootNode();
244
- if (root.host && node === root.host) return true;
245
- return node === this.#element || this.#element.contains(node)
246
- || node === this.#surface.element || this.#surface.element.contains(node);
247
- }
248
223
  }
249
224
 
250
225
  function registry(value) {
@@ -254,18 +229,18 @@ function registry(value) {
254
229
  && typeof value?.run === 'function';
255
230
  }
256
231
 
257
- function configured(element) {
258
- const value = getComputedStyle(element).getPropertyValue('--u2-rte-toolbar').trim();
259
- return value ? new Set(value.split(/[\s,]+/).filter(Boolean)) : null;
232
+ function setting(style, name) {
233
+ return style.getPropertyValue(`--u2-rte-${name}`).trim();
260
234
  }
261
235
 
262
- function unavailable(element) {
263
- return getComputedStyle(element).getPropertyValue('--u2-rte-toolbar-unavailable').trim();
236
+ function configured(style) {
237
+ const value = setting(style, 'toolbar');
238
+ return value ? new Set(value.split(/[\s,]+/).filter(Boolean)) : null;
264
239
  }
265
240
 
266
- function visibleForSelection(surface) {
267
- const value = getComputedStyle(surface.element).getPropertyValue('--u2-rte-toolbar-when').trim();
268
- return value !== 'selection' || !!surface.selection && !surface.selection.collapsed;
241
+ function visibleForSelection(surface, style) {
242
+ return setting(style, 'toolbar-when') !== 'selection'
243
+ || !!surface.selection && !surface.selection.collapsed;
269
244
  }
270
245
 
271
246
  function state(item, value, disabled) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u2ui",
3
- "version": "1.5.4",
3
+ "version": "1.5.7",
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",