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 +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 +32 -22
- package/js/rte/src/client/link.js +10 -15
- 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 +27 -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/place.js +24 -2
- 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,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
|
-
|
|
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(() => {
|
|
@@ -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:
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
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
|
|
|
@@ -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:
|
|
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
|
|
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);
|