u2ui 1.5.4 → 1.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/js/rte/PLAN.md 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,7 +1,8 @@
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
- import {place} from '../ui/place.js';
5
+ import {panelGap, place} from '../ui/place.js';
5
6
 
6
7
  const STYLE = `
7
8
  #image {
@@ -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(() => {
@@ -188,7 +193,7 @@ function position(state, box = null) {
188
193
  state.handles.place(handle.name, rect.left + rect.width * handle.x, rect.top + rect.height * handle.y);
189
194
  }
190
195
  // Below the image, clear of the handles sitting on its lower edge.
191
- place(state.alt, state.active.view.surface, {align: 'start', prefer: 'below', gap: 16, on: rect});
196
+ place(state.alt, state.active.view.surface, {align: 'start', prefer: 'below', gap: panelGap, on: rect});
192
197
  }
193
198
 
194
199
  function build(state) {
@@ -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,6 +1,7 @@
1
1
  import {inlineUi} from '../config/config.js';
2
2
  import {linkHtml} from '../mark/standard.js';
3
- import {place} from '../ui/place.js';
3
+ import {caretAfter} from '../ui/caret.js';
4
+ import {panelGap, place} from '../ui/place.js';
4
5
  import {valueMark} from '../command/mark.js';
5
6
 
6
7
  const STYLE = `
@@ -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
 
@@ -424,7 +419,7 @@ function position(state) {
424
419
  return place(state.form, active.surface, {
425
420
  align: 'start',
426
421
  prefer: 'below',
427
- gap: 4,
422
+ gap: panelGap,
428
423
  on: active.element?.getBoundingClientRect() || null,
429
424
  });
430
425
  }
@@ -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);