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 +42 -43
- package/js/rte/README.md +1 -1
- package/js/rte/playground/hosts.html +35 -0
- package/js/rte/playground/index.html +11 -0
- package/js/rte/run.out +17 -0
- package/js/rte/src/browser/interactive.js +17 -0
- package/js/rte/src/client/README.md +13 -6
- package/js/rte/src/client/editor.js +11 -0
- package/js/rte/src/client/images.js +30 -20
- package/js/rte/src/client/link.js +8 -13
- package/js/rte/src/client/tables.js +3 -0
- package/js/rte/src/command/README.md +4 -1
- package/js/rte/src/command/commands.js +4 -3
- package/js/rte/src/command/delete.js +8 -1
- package/js/rte/src/command/edit.js +8 -2
- package/js/rte/src/config/config.js +7 -0
- package/js/rte/src/core/README.md +25 -3
- package/js/rte/src/core/core.js +97 -6
- package/js/rte/src/input/README.md +18 -0
- package/js/rte/src/input/input-pipeline.js +45 -0
- package/js/rte/src/sanitize/README.md +5 -2
- package/js/rte/src/sanitize/policy.js +5 -1
- package/js/rte/src/ui/README.md +22 -6
- package/js/rte/src/ui/caret.js +17 -0
- package/js/rte/src/ui/chrome.js +1 -1
- package/js/rte/src/ui/handles.js +1 -1
- package/js/rte/src/ui/highlight.js +86 -0
- package/js/rte/src/ui/toolbar.js +16 -41
- package/package.json +1 -1
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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-
|
|
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
|
|
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
|
|
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><button> <span contenteditable></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><form> <button contenteditable></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><div contenteditable> <a></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><div contenteditable></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
|
|
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.
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
369
|
-
//
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
30
|
-
|
|
48
|
+
session, so everything drawn for it goes at once. So does focus that lands
|
|
49
|
+
nowhere: clicking a plain paragraph focuses nothing, and no focus event
|
|
50
|
+
follows to say so, which is what `focusout` is listened to for. Retaining is
|
|
51
|
+
therefore how a UI that takes focus stays alive; `Toolbar` retains its element
|
|
52
|
+
itself.
|
|
31
53
|
- `dispose()` removes listeners and disconnects every surface. It is
|
|
32
54
|
idempotent; `[Symbol.dispose]()` exposes the same teardown to `using`.
|
|
33
55
|
|
package/js/rte/src/core/core.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {enabled} from '../config/config.js';
|
|
2
|
+
import {interactiveAround} from '../browser/interactive.js';
|
|
2
3
|
import {editingHost, isEditableHost, isEditingBoundary} from '../selection/ownership/ownership.js';
|
|
3
4
|
import {Surface} from '../surface/surface.js';
|
|
4
5
|
|
|
@@ -10,6 +11,9 @@ export class Rte extends EventTarget {
|
|
|
10
11
|
#elements = new WeakMap();
|
|
11
12
|
#retained = new Set();
|
|
12
13
|
#active = null;
|
|
14
|
+
#unfocused = false;
|
|
15
|
+
#pointer = null;
|
|
16
|
+
#handed = false;
|
|
13
17
|
#controller;
|
|
14
18
|
|
|
15
19
|
constructor(root = document, options = {}) {
|
|
@@ -24,6 +28,15 @@ export class Rte extends EventTarget {
|
|
|
24
28
|
const listen = {capture: true, signal: this.#controller.signal};
|
|
25
29
|
root.addEventListener('focus', this.#focus, listen);
|
|
26
30
|
root.addEventListener('focusin', this.#focus, listen);
|
|
31
|
+
root.addEventListener('focusout', this.#focusOut, listen);
|
|
32
|
+
// A press answers for every focus until it is over: one drag can hand the
|
|
33
|
+
// focus back more than once. A key taking over ends it too, so what is
|
|
34
|
+
// left of a release that never arrived cannot answer for the keyboard.
|
|
35
|
+
root.addEventListener('pointerdown', this.#pointerDown, listen);
|
|
36
|
+
for (const type of ['pointerup', 'pointercancel', 'keydown']) {
|
|
37
|
+
root.addEventListener(type, this.#pointerDone, listen);
|
|
38
|
+
}
|
|
39
|
+
root.addEventListener('click', this.#click, listen);
|
|
27
40
|
this.#document.addEventListener('selectionchange', this.#selectionChange, {signal: this.#controller.signal});
|
|
28
41
|
}
|
|
29
42
|
|
|
@@ -108,6 +121,12 @@ export class Rte extends EventTarget {
|
|
|
108
121
|
this.activate(null);
|
|
109
122
|
return null;
|
|
110
123
|
}
|
|
124
|
+
// A selection is not a session: once the focus has left the editor, only
|
|
125
|
+
// focus brings it back. Engines leave a selection inside an editable that
|
|
126
|
+
// nobody focused — clicking beside one does that — and a toolbar over a
|
|
127
|
+
// caret the keyboard cannot reach is worse than no toolbar. What the
|
|
128
|
+
// surface captured, it keeps either way.
|
|
129
|
+
if (this.#unfocused && this.#active !== surface) return null;
|
|
111
130
|
this.activate(surface);
|
|
112
131
|
return surface;
|
|
113
132
|
}
|
|
@@ -125,26 +144,98 @@ export class Rte extends EventTarget {
|
|
|
125
144
|
}
|
|
126
145
|
|
|
127
146
|
#focus = event => {
|
|
128
|
-
|
|
129
|
-
|
|
147
|
+
const path = event.composedPath();
|
|
148
|
+
if (path.some(node => this.#retained.has(node))) {
|
|
149
|
+
this.#unfocused = false;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const editable = path.find(node =>
|
|
130
153
|
node?.nodeType === Node.ELEMENT_NODE && isEditingBoundary(node)
|
|
131
154
|
);
|
|
132
155
|
// Focus that went somewhere else entirely ends the session: retained UI
|
|
133
156
|
// is already excluded above, so what is left is not the editor's, and
|
|
134
157
|
// everything drawn for it has to go with it.
|
|
135
|
-
if (!editable) {
|
|
158
|
+
if (!editable || !isEditableHost(editable)) {
|
|
159
|
+
this.#unfocused = true;
|
|
136
160
|
this.activate(null);
|
|
137
161
|
return;
|
|
138
162
|
}
|
|
139
|
-
|
|
163
|
+
let surface = this.#elements.get(editable);
|
|
164
|
+
if (!surface && this.#options.auto && enabled(editable)) surface = this.add(editable);
|
|
165
|
+
// An engine gives the focus to the nearest editable when a press lands
|
|
166
|
+
// beside one: an inline host collects its whole line. A press that did
|
|
167
|
+
// not land in this surface is nobody starting to edit it, and the caret
|
|
168
|
+
// it would leave behind is one no one asked for.
|
|
169
|
+
//
|
|
170
|
+
// Taking it back is the one thing here that reaches into the document:
|
|
171
|
+
// the element really is focused for a moment, so the page sees a
|
|
172
|
+
// focus/blur pair it did not ask for, and anything listening for either
|
|
173
|
+
// on that element — a host's own handler, a css `:focus` rule — sees it
|
|
174
|
+
// too. Leaving it focused instead is worse: a caret that types into a
|
|
175
|
+
// surface with no session behind it.
|
|
176
|
+
if (surface && this.#pointer && !this.#pointer.includes(surface.element)) {
|
|
177
|
+
this.#unfocused = true;
|
|
140
178
|
this.activate(null);
|
|
179
|
+
// Handing the focus back once per press. An engine that answers a
|
|
180
|
+
// blur by focusing again — the selection is still in there, after
|
|
181
|
+
// all — would otherwise trade focus with the editor for as long as
|
|
182
|
+
// the button is down, and the session stays refused either way.
|
|
183
|
+
if (!this.#handed) {
|
|
184
|
+
this.#handed = true;
|
|
185
|
+
editable.blur();
|
|
186
|
+
}
|
|
141
187
|
return;
|
|
142
188
|
}
|
|
143
|
-
|
|
144
|
-
if (!surface && this.#options.auto && enabled(editable)) surface = this.add(editable);
|
|
189
|
+
this.#unfocused = !surface;
|
|
145
190
|
this.activate(surface || null);
|
|
146
191
|
};
|
|
147
192
|
|
|
193
|
+
// Focus falling back to the document is the one way a session ends without
|
|
194
|
+
// a focus event to end it: nothing is focused, so nothing announces it.
|
|
195
|
+
// A related target inside a surface or in retained UI is `#focus`'s to
|
|
196
|
+
// decide — deactivating here would end a session that is only moving.
|
|
197
|
+
#focusOut = event => {
|
|
198
|
+
if (this.#owns(event.relatedTarget)) return;
|
|
199
|
+
this.#unfocused = true;
|
|
200
|
+
this.activate(null);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// The editor's world: one of its surfaces, or UI it retains.
|
|
204
|
+
#owns(node) {
|
|
205
|
+
return !!node && (this.retains(node) || this.#elements.has(editingHost(node)));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#pointerDown = event => {
|
|
209
|
+
const path = event.composedPath();
|
|
210
|
+
this.#pointer = path;
|
|
211
|
+
this.#handed = false;
|
|
212
|
+
const editing = this.#editing(path[0]);
|
|
213
|
+
// The right button opens a menu about what is selected; moving the
|
|
214
|
+
// selection to what it was aimed at is what makes the menu useless.
|
|
215
|
+
if (event.button === 2 && editing) event.preventDefault();
|
|
216
|
+
// The drag has to be off before this very press is acted on, which is
|
|
217
|
+
// what a capturing listener is early enough for. It stays off: a link
|
|
218
|
+
// wrapping editable content is not one anybody drags on purpose.
|
|
219
|
+
const around = interactiveAround(editing);
|
|
220
|
+
if (around) around.draggable = false;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// The press belonged to the text, and so does its release.
|
|
224
|
+
#click = event => {
|
|
225
|
+
if (interactiveAround(this.#editing(event.composedPath()[0]))) event.preventDefault();
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// The editing host a node sits in, if this core is the editor of it.
|
|
229
|
+
#editing(node) {
|
|
230
|
+
const host = editingHost(node);
|
|
231
|
+
if (!host) return null;
|
|
232
|
+
return this.#elements.has(host) || this.#options.auto && enabled(host) ? host : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#pointerDone = () => {
|
|
236
|
+
this.#pointer = null;
|
|
237
|
+
};
|
|
238
|
+
|
|
148
239
|
#selectionChange = () => {
|
|
149
240
|
this.sync();
|
|
150
241
|
};
|
|
@@ -63,6 +63,24 @@ to `using`. Disconnecting the surface also disposes them automatically.
|
|
|
63
63
|
surface)` is its public, shared conversion from a native `StaticRange` to an
|
|
64
64
|
owned live `Range`; missing target ranges fall back to the owned selection.
|
|
65
65
|
|
|
66
|
+
## Keys a host answers itself
|
|
67
|
+
|
|
68
|
+
A button answers Space and Enter by activating itself, whatever it holds: the
|
|
69
|
+
text inside never sees them and no `beforeinput` arrives at all. In that host,
|
|
70
|
+
and only there, the pipeline turns the key into the input it should have been —
|
|
71
|
+
`insertParagraph` for Enter, and for Space the `insert` command with a space of
|
|
72
|
+
its own — a non-breaking one where a plain space would collapse away, which is
|
|
73
|
+
what the browser itself types there. Everywhere else ordinary typing stays the
|
|
74
|
+
browser's own.
|
|
75
|
+
|
|
76
|
+
## Pointing at an atomic element
|
|
77
|
+
|
|
78
|
+
An atomic element is addressable only as the selection: engines disagree about
|
|
79
|
+
whether pointing at one selects it, and a caret an engine leaves inside one —
|
|
80
|
+
clicking a rule does that in some — reaches nothing at all. A click on one
|
|
81
|
+
selects it, which is what makes a frame, a command, or a delete apply to it. The
|
|
82
|
+
host itself is left alone: a surface is not a thing inside itself.
|
|
83
|
+
|
|
66
84
|
## Rich external input
|
|
67
85
|
|
|
68
86
|
The native path above needs no HTML parser or sanitizer adapter because RTE
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {htmlModel} from '../model/html/html-model.js';
|
|
2
2
|
import {narrow} from '../command/edit.js';
|
|
3
|
+
import {activatingAround} from '../browser/interactive.js';
|
|
3
4
|
import {Normalizer} from '../normalize/normalizer/normalizer.js';
|
|
4
5
|
import {PointMap} from '../selection/map/point-map.js';
|
|
5
6
|
import {EditRange} from '../selection/range/edit-range.js';
|
|
@@ -71,6 +72,7 @@ export class InputPipeline {
|
|
|
71
72
|
root.addEventListener('compositionend', this.#compositionEnd, listen);
|
|
72
73
|
root.addEventListener('paste', this.#paste, listen);
|
|
73
74
|
root.addEventListener('drop', this.#drop, listen);
|
|
75
|
+
root.addEventListener('click', this.#click, listen);
|
|
74
76
|
surface.addEventListener('u2-rte-command', this.#command, listen);
|
|
75
77
|
surface.addEventListener('u2-rte-disconnect', this.#disconnect, listen);
|
|
76
78
|
}
|
|
@@ -198,6 +200,7 @@ export class InputPipeline {
|
|
|
198
200
|
if (this.#invoke(event, shortcut, {range: selectionRange(this.#surface)})) return;
|
|
199
201
|
}
|
|
200
202
|
if (event.altKey) return;
|
|
203
|
+
if ((event.key === 'Enter' || event.key === ' ') && this.#activation(event)) return;
|
|
201
204
|
const inputType = keyInput(event);
|
|
202
205
|
if (!inputType) return;
|
|
203
206
|
this.#surface.capture();
|
|
@@ -208,6 +211,19 @@ export class InputPipeline {
|
|
|
208
211
|
});
|
|
209
212
|
};
|
|
210
213
|
|
|
214
|
+
// In a button, Enter and Space belong to the button: the text inside never
|
|
215
|
+
// sees them and no `beforeinput` arrives, so the editor puts in what the key
|
|
216
|
+
// meant. Everywhere else the browser's own input is left alone.
|
|
217
|
+
#activation(event) {
|
|
218
|
+
if (event.ctrlKey || event.metaKey || !activatingAround(this.#root)) return false;
|
|
219
|
+
this.#surface.capture();
|
|
220
|
+
const range = selectionRange(this.#surface);
|
|
221
|
+
if (event.key === 'Enter') return this.#route(event, {inputType: 'insertParagraph', data: null, range});
|
|
222
|
+
const fragment = this.#root.ownerDocument.createDocumentFragment();
|
|
223
|
+
fragment.append(space(range));
|
|
224
|
+
return this.#invoke(event, 'insert', {fragment, range});
|
|
225
|
+
}
|
|
226
|
+
|
|
211
227
|
#input = event => {
|
|
212
228
|
if (!this.#owns(event)) return;
|
|
213
229
|
const pending = this.#pending;
|
|
@@ -245,6 +261,25 @@ export class InputPipeline {
|
|
|
245
261
|
});
|
|
246
262
|
};
|
|
247
263
|
|
|
264
|
+
// An atomic element is addressable only as the selection: engines disagree
|
|
265
|
+
// about whether pointing at one selects it, and a caret one leaves inside it
|
|
266
|
+
// reaches nothing at all. The host itself is left alone — a surface is not a
|
|
267
|
+
// thing inside itself.
|
|
268
|
+
#click = event => {
|
|
269
|
+
const target = event.composedPath()[0];
|
|
270
|
+
if (target === this.#root || !this.#owns(event) || target?.nodeType !== Node.ELEMENT_NODE) return;
|
|
271
|
+
// The base model, not the narrowed one: what an element is does not
|
|
272
|
+
// depend on which elements a host allows, and narrowing costs a computed
|
|
273
|
+
// style on a path every click in the text goes through.
|
|
274
|
+
if (!this.#model.atomic(target)) return;
|
|
275
|
+
const range = this.#root.ownerDocument.createRange();
|
|
276
|
+
range.selectNode(target);
|
|
277
|
+
const selection = this.#surface.core.selection;
|
|
278
|
+
selection.removeAllRanges();
|
|
279
|
+
selection.addRange(range);
|
|
280
|
+
this.#surface.capture();
|
|
281
|
+
};
|
|
282
|
+
|
|
248
283
|
#paste = event => {
|
|
249
284
|
if (this.#owns(event)) this.#rememberSource('paste');
|
|
250
285
|
};
|
|
@@ -323,6 +358,16 @@ export class InputPipeline {
|
|
|
323
358
|
}
|
|
324
359
|
}
|
|
325
360
|
|
|
361
|
+
// A plain space collapses away where there is nothing after it, or where another
|
|
362
|
+
// space already is — so there the browser types a non-breaking one, and so does
|
|
363
|
+
// this. Anywhere else a space is a space.
|
|
364
|
+
function space(range) {
|
|
365
|
+
const node = range?.startContainer;
|
|
366
|
+
if (node?.nodeType !== Node.TEXT_NODE) return '\u00a0';
|
|
367
|
+
const before = range.startOffset ? node.data[range.startOffset - 1] : '';
|
|
368
|
+
return before.trim() && range.startOffset < node.length ? ' ' : '\u00a0';
|
|
369
|
+
}
|
|
370
|
+
|
|
326
371
|
function keyInput(event) {
|
|
327
372
|
if (event.ctrlKey || event.metaKey || event.shiftKey) return null;
|
|
328
373
|
return DELETE_KEYS.get(event.key) || null;
|
|
@@ -16,8 +16,11 @@ repairs editable document shape.
|
|
|
16
16
|
- `comments` and `dataAttributes`: explicit booleans, both false by default.
|
|
17
17
|
|
|
18
18
|
The supplied `sanitizePolicy` accepts the `document` element preset, ordinary
|
|
19
|
-
editor metadata, and conservative web URLs.
|
|
20
|
-
|
|
19
|
+
editor metadata, and conservative web URLs. An image may carry a `data:` URL,
|
|
20
|
+
because an image executes nothing — not even an SVG one, which browsers draw in
|
|
21
|
+
a script-free context; in a navigating attribute the same protocol is a page, so
|
|
22
|
+
links and citations keep it out. Inline styles, event attributes, comments, and
|
|
23
|
+
data attributes are not enabled by default.
|
|
21
24
|
Relative URLs use the explicit `relative` protocol token. Known URL attributes
|
|
22
25
|
without a matching protocol rule are removed rather than accepted implicitly.
|
|
23
26
|
|
|
@@ -19,7 +19,11 @@ const DEFAULT_ATTRIBUTES = Object.freeze({
|
|
|
19
19
|
const DEFAULT_PROTOCOLS = Object.freeze({
|
|
20
20
|
a: Object.freeze({href: Object.freeze(['http', 'https', 'mailto', 'relative', 'tel'])}),
|
|
21
21
|
blockquote: Object.freeze({cite: Object.freeze(['http', 'https', 'relative'])}),
|
|
22
|
-
|
|
22
|
+
// `data:` belongs to an image and to nothing else here: an image executes
|
|
23
|
+
// nothing, not even an SVG one, which browsers draw in a script-free
|
|
24
|
+
// context. In a navigating attribute the same protocol is a page, so `a`
|
|
25
|
+
// and the citations keep it out.
|
|
26
|
+
img: Object.freeze({src: Object.freeze(['data', 'http', 'https', 'relative'])}),
|
|
23
27
|
q: Object.freeze({cite: Object.freeze(['http', 'https', 'relative'])}),
|
|
24
28
|
});
|
|
25
29
|
|
package/js/rte/src/ui/README.md
CHANGED
|
@@ -218,11 +218,22 @@ while a wide one loses a great deal.
|
|
|
218
218
|
|
|
219
219
|
## Focus across the boundary
|
|
220
220
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
the toolbar
|
|
224
|
-
|
|
225
|
-
|
|
221
|
+
Whether focus leaving the editor ends the session is the core's decision, not
|
|
222
|
+
the toolbar's: it knows every surface and every retained element, so one rule
|
|
223
|
+
covers the toolbar, the contextual UI in the chrome, and anything a host
|
|
224
|
+
retains. The toolbar shows and hides with the active surface and does not watch
|
|
225
|
+
focus itself.
|
|
226
|
+
|
|
227
|
+
## The selection while a form has the focus
|
|
228
|
+
|
|
229
|
+
A browser paints a selection only where the focus is, so a form of the editor's
|
|
230
|
+
own makes the text it is about to change stop looking selected. `highlight.js`
|
|
231
|
+
draws the surface's saved selection in its place, through the CSS custom
|
|
232
|
+
highlight API, and stops the moment the text has the focus again — the browser's
|
|
233
|
+
own selection is never painted over. The rule goes into the tree that holds the
|
|
234
|
+
text, because a highlight is painted on the range's own nodes, and sits in the
|
|
235
|
+
`u2-rte` layer, so `::highlight(u2-rte-selection)` in a page's own stylesheet
|
|
236
|
+
replaces it. Engines without the API simply show nothing extra.
|
|
226
237
|
|
|
227
238
|
## Placement
|
|
228
239
|
|
|
@@ -232,12 +243,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
|
|
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
|
+
}
|
package/js/rte/src/ui/chrome.js
CHANGED
|
@@ -196,7 +196,7 @@ export class Chrome {
|
|
|
196
196
|
// The closest boundary is the one that owns the target's current top-layer
|
|
197
197
|
// context. Walking through shadow hosts keeps the rule identical for document
|
|
198
198
|
// and ShadowRoot editors.
|
|
199
|
-
|
|
199
|
+
function topLayer(target) {
|
|
200
200
|
for (let element = target; element?.nodeType === Node.ELEMENT_NODE; element = composedParent(element)) {
|
|
201
201
|
for (const selector of [':fullscreen', ':modal', ':popover-open']) {
|
|
202
202
|
try {
|
package/js/rte/src/ui/handles.js
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const NAME = 'u2-rte-selection';
|
|
2
|
+
// System colours, so what stands in for the selection looks like one. In a
|
|
3
|
+
// layer, so a page that wants its own answer simply writes an ordinary rule.
|
|
4
|
+
const STYLE = `@layer u2-rte {
|
|
5
|
+
::highlight(${NAME}) { background-color: Highlight; color: HighlightText; }
|
|
6
|
+
}`;
|
|
7
|
+
|
|
8
|
+
// A browser paints a selection only where the focus is, so the moment a form of
|
|
9
|
+
// the editor's own takes the caret, the text it is about to change stops looking
|
|
10
|
+
// selected. The surface's saved selection is drawn in its place: the range the
|
|
11
|
+
// commands act on is the range the eye follows.
|
|
12
|
+
//
|
|
13
|
+
// It is the editor's, not one form's: a link address, an image's name and
|
|
14
|
+
// anything else that takes focus all leave the same gap behind them.
|
|
15
|
+
export class SelectionHighlight {
|
|
16
|
+
#core;
|
|
17
|
+
#document;
|
|
18
|
+
#style = null;
|
|
19
|
+
#controller;
|
|
20
|
+
#connected = true;
|
|
21
|
+
|
|
22
|
+
constructor(core) {
|
|
23
|
+
if (!core?.root || typeof core?.addEventListener !== 'function') {
|
|
24
|
+
throw new TypeError('A selection highlight requires an editor core');
|
|
25
|
+
}
|
|
26
|
+
const root = core.root;
|
|
27
|
+
this.#core = core;
|
|
28
|
+
this.#document = root.nodeType === Node.DOCUMENT_NODE ? root : root.ownerDocument;
|
|
29
|
+
this.#controller = new this.#document.defaultView.AbortController();
|
|
30
|
+
const listen = {capture: true, signal: this.#controller.signal};
|
|
31
|
+
// Surface events bubble out composed, so one listener covers every
|
|
32
|
+
// surface the core owns, whichever of them is being edited.
|
|
33
|
+
for (const type of ['focusin', 'focusout', 'u2-rte-selectionchange', 'u2-rte-change']) {
|
|
34
|
+
root.addEventListener(type, this.#update, listen);
|
|
35
|
+
}
|
|
36
|
+
core.addEventListener('u2-rte-activechange', this.#update, {signal: this.#controller.signal});
|
|
37
|
+
this.#update();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get connected() { return this.#connected; }
|
|
41
|
+
|
|
42
|
+
refresh() {
|
|
43
|
+
const surface = this.#core.active;
|
|
44
|
+
const selection = surface?.selection;
|
|
45
|
+
const highlights = this.#document.defaultView.CSS?.highlights;
|
|
46
|
+
if (!highlights) return false;
|
|
47
|
+
// Not while the text itself has the focus: the browser is already
|
|
48
|
+
// painting that selection, and painting it twice can only disagree.
|
|
49
|
+
// `activeElement` of the core's own root: in a shadow tree the document
|
|
50
|
+
// reports the host, which says nothing about what is focused inside it.
|
|
51
|
+
if (!selection || selection.collapsed || surface.element.contains(this.#core.root.activeElement)) {
|
|
52
|
+
highlights.delete(NAME);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
this.#ensure();
|
|
56
|
+
highlights.set(NAME, new this.#document.defaultView.Highlight(selection.range()));
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
dispose() {
|
|
61
|
+
if (!this.#connected) return;
|
|
62
|
+
this.#controller.abort();
|
|
63
|
+
this.#document.defaultView.CSS?.highlights?.delete(NAME);
|
|
64
|
+
this.#style?.remove();
|
|
65
|
+
this.#style = null;
|
|
66
|
+
this.#connected = false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
[Symbol.dispose]() {
|
|
70
|
+
this.dispose();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The rule belongs to the tree that holds the text, not to the editor's own
|
|
74
|
+
// shadow root: a highlight is painted on the range's own nodes.
|
|
75
|
+
#ensure() {
|
|
76
|
+
if (this.#style) return;
|
|
77
|
+
const root = this.#core.root;
|
|
78
|
+
const style = this.#document.createElement('style');
|
|
79
|
+
style.dataset.u2RteHighlight = '';
|
|
80
|
+
style.textContent = STYLE;
|
|
81
|
+
(root.nodeType === Node.DOCUMENT_NODE ? this.#document.head || this.#document.documentElement : root).append(style);
|
|
82
|
+
this.#style = style;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#update = () => this.refresh();
|
|
86
|
+
}
|
package/js/rte/src/ui/toolbar.js
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
258
|
-
|
|
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
|
|
263
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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.
|
|
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",
|