u2ui 1.5.13 → 1.5.14
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 +65 -3
- package/js/rte/README.md +1 -0
- package/js/rte/ai.js +4 -0
- package/js/rte/playground/index.html +52 -14
- package/js/rte/src/client/README.md +65 -3
- package/js/rte/src/client/ai.js +228 -0
- package/js/rte/src/client/blocks.js +36 -8
- package/js/rte/src/client/classes.js +36 -27
- package/js/rte/src/client/contextual.js +6 -1
- package/js/rte/src/client/editor.js +130 -0
- package/js/rte/src/client/link.js +37 -8
- package/js/rte/src/command/block-style.js +11 -1
- package/js/rte/src/command/commands.js +1 -2
- package/js/rte/src/command/edit.js +12 -2
- package/js/rte/src/command/enter.js +6 -1
- package/js/rte/src/config/README.md +30 -3
- package/js/rte/src/config/config.js +49 -11
- package/js/rte/src/core/core.js +3 -1
- package/js/rte/src/input/input-pipeline.js +6 -4
- package/js/rte/src/sanitize/README.md +26 -8
- package/js/rte/src/sanitize/native.js +2 -0
- package/js/rte/src/sanitize/policy.js +38 -8
- package/js/rte/src/source/source.js +4 -1
- package/js/rte/src/surface/surface.js +10 -1
- package/js/rte/src/ui/README.md +5 -0
- package/js/rte/src/ui/caret.js +19 -1
- package/js/rte/src/ui/toolbar.js +64 -7
- package/package.json +1 -1
|
@@ -1,48 +1,57 @@
|
|
|
1
1
|
import {classMark} from '../mark/standard.js';
|
|
2
2
|
import {valueMark} from '../command/mark.js';
|
|
3
3
|
|
|
4
|
-
// Optional content-class control. The choices come from the host's
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Optional content-class control. The choices come from the host's `--u2-rte-classes`, so one
|
|
5
|
+
// declaration decides what the control offers, what the sanitizer keeps, and what presentation
|
|
6
|
+
// cleanup leaves alone.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
// One set of classes is one mark type and therefore mutually exclusive. A field that combines axes —
|
|
9
|
+
// a colour *and* an alignment — names them in `--u2-rte-class-groups`, and each named set becomes a
|
|
10
|
+
// section of the same menu: exclusive in itself, free of the others. The menu is one control whose
|
|
11
|
+
// content is read per surface, so how many sets a field has stays a question CSS answers.
|
|
12
|
+
export function classStyles({label = 'Style', name = 'style', command = 'classStyle', text = 'A'} = {}) {
|
|
13
|
+
for (const value of [label, name, command, text]) {
|
|
12
14
|
if (typeof value !== 'string' || !value.trim()) throw new TypeError('A class control needs names');
|
|
13
15
|
}
|
|
14
16
|
const adapters = new Map();
|
|
15
17
|
return Object.freeze({
|
|
16
18
|
name: 'classes',
|
|
17
19
|
commands: ({surface}) => ({[command]: styleCommand(adapters, surface)}),
|
|
18
|
-
toolbar: Object.freeze([Object.freeze({
|
|
19
|
-
type: 'select',
|
|
20
|
-
name,
|
|
21
|
-
command,
|
|
22
|
-
label,
|
|
23
|
-
options: surface => surface.config.classes.map(value => ({value, label: value})),
|
|
24
|
-
})]),
|
|
20
|
+
toolbar: Object.freeze([Object.freeze({type: 'menu', name, command, label, text, groups: sets})]),
|
|
25
21
|
});
|
|
26
22
|
}
|
|
27
23
|
|
|
28
24
|
export const classes = classStyles();
|
|
29
25
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
26
|
+
// What the host offers, in the order it declared it: `Lead Caption, color(Red Green)` puts the two
|
|
27
|
+
// loose ones on top and the named set below them. `--u2-rte-classes` says what may *exist* — a class
|
|
28
|
+
// foreign content carries needs no control — so it is only fallen back on where nothing is offered.
|
|
29
|
+
function sets(surface) {
|
|
30
|
+
const {classes, classGroups} = surface.config;
|
|
31
|
+
const offered = Object.entries(classGroups ?? {}).map(([key, values]) => ({label: key === '*' ? '' : key, values}));
|
|
32
|
+
if (offered.length) return offered;
|
|
33
|
+
return classes.length ? [{label: '', values: classes}] : [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// One adapter per set, so the same host keeps one mark identity across refreshes and two hosts with
|
|
37
|
+
// the same classes share it.
|
|
32
38
|
function styleCommand(adapters, surface) {
|
|
33
|
-
const resolve =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (!adapters.has(key)) adapters.set(key, valueMark(classMark(names)));
|
|
39
|
+
const resolve = values => {
|
|
40
|
+
if (!values?.length) return null;
|
|
41
|
+
const key = values.join(' ');
|
|
42
|
+
if (!adapters.has(key)) adapters.set(key, valueMark(classMark(values)));
|
|
38
43
|
return adapters.get(key);
|
|
39
44
|
};
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
const
|
|
45
|
+
// Which set a value belongs to decides which mark answers for it — the sets are independent, and
|
|
46
|
+
// a class the host does not declare belongs to none, so it is simply unavailable.
|
|
47
|
+
const setOf = value => sets(surface).find(set => set.values.includes(value))?.values;
|
|
48
|
+
const of = edit => resolve(edit.value == null ? sets(surface)[0]?.values : setOf(edit.value));
|
|
49
|
+
const set = edit => edit.value != null && of(edit)?.state(edit) === edit.value;
|
|
43
50
|
return {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
// What is already set stays available: picking it again is how a set is emptied, and a set
|
|
52
|
+
// carrying nothing is a normal state — no colour is as valid as red.
|
|
53
|
+
enabled: edit => !!of(edit) && (set(edit) || of(edit).enabled(edit)),
|
|
54
|
+
state: edit => of(edit)?.state(edit) ?? null,
|
|
55
|
+
run: edit => of(edit)?.run(set(edit) ? edit.with({value: null}) : edit),
|
|
47
56
|
};
|
|
48
57
|
}
|
|
@@ -7,8 +7,13 @@
|
|
|
7
7
|
export function follows(surface, draw, close) {
|
|
8
8
|
const controller = new surface.element.ownerDocument.defaultView.AbortController();
|
|
9
9
|
const listen = {signal: controller.signal};
|
|
10
|
+
// Only while the surface has a session. A selection is not one: engines leave a selection inside
|
|
11
|
+
// an editable nobody focused — clicking beside one does that — and the core captures it before
|
|
12
|
+
// it decides that no session follows. Drawing on that capture would put a form on a caret the
|
|
13
|
+
// keyboard cannot reach, which is exactly what the toolbar already refuses to do.
|
|
14
|
+
const drawing = () => surface.active && draw();
|
|
10
15
|
for (const type of ['u2-rte-selectionchange', 'u2-rte-change', 'u2-rte-activate']) {
|
|
11
|
-
surface.addEventListener(type,
|
|
16
|
+
surface.addEventListener(type, drawing, listen);
|
|
12
17
|
}
|
|
13
18
|
surface.addEventListener('u2-rte-deactivate', close, listen);
|
|
14
19
|
return {
|
|
@@ -35,6 +35,38 @@ const STYLE = `
|
|
|
35
35
|
}
|
|
36
36
|
[data-command=bold] { font-weight: 700; }
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
/* A menu is the toolbar's own panel, one row per entry: sections keep their names, and a set that
|
|
40
|
+
carries something says so where the eye already is — at the row, not in the button. */
|
|
41
|
+
[data-menu] {
|
|
42
|
+
background: Canvas;
|
|
43
|
+
border: 1px solid color-mix(in srgb, CanvasText 25%, transparent);
|
|
44
|
+
border-radius: .34em;
|
|
45
|
+
color: CanvasText;
|
|
46
|
+
font: inherit;
|
|
47
|
+
margin: 0;
|
|
48
|
+
padding: .34em;
|
|
49
|
+
position: absolute;
|
|
50
|
+
|
|
51
|
+
& b { display: block; font-size: .86em; opacity: .6; padding: .5em .6em .17em; }
|
|
52
|
+
& button {
|
|
53
|
+
background: none;
|
|
54
|
+
border: 0;
|
|
55
|
+
border-radius: .34em;
|
|
56
|
+
color: inherit;
|
|
57
|
+
display: block;
|
|
58
|
+
font: inherit;
|
|
59
|
+
inline-size: 100%;
|
|
60
|
+
padding: .34em .6em .34em 1.7em;
|
|
61
|
+
position: relative;
|
|
62
|
+
text-align: start;
|
|
63
|
+
&:hover:not(:disabled) { background: color-mix(in srgb, Highlight 16%, transparent); }
|
|
64
|
+
&[aria-pressed=true] {
|
|
65
|
+
background: color-mix(in srgb, Highlight 20%, transparent);
|
|
66
|
+
&::before { content: '✓'; inset-inline-start: .5em; position: absolute; }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
38
70
|
`;
|
|
39
71
|
|
|
40
72
|
const CLIENT = Symbol.for('u2.rte.editor');
|
|
@@ -54,6 +86,7 @@ export class Editor {
|
|
|
54
86
|
#toolbar = null;
|
|
55
87
|
#element = null;
|
|
56
88
|
#dynamic = new Map();
|
|
89
|
+
#menus = new Map();
|
|
57
90
|
#controller;
|
|
58
91
|
#connected = true;
|
|
59
92
|
|
|
@@ -185,6 +218,7 @@ export class Editor {
|
|
|
185
218
|
refresh() {
|
|
186
219
|
if (!this.#connected) return false;
|
|
187
220
|
const surface = this.#core.active;
|
|
221
|
+
surface?.invalidate();
|
|
188
222
|
if (this.#records.has(surface) && surface.config.ui === 'roaming') this.#ensureToolbar();
|
|
189
223
|
this.#chrome?.follow(this.#records.has(surface) ? surface.element : null);
|
|
190
224
|
// A control whose choices come from the host's configuration is filled
|
|
@@ -192,6 +226,11 @@ export class Editor {
|
|
|
192
226
|
for (const [select, choices] of this.#dynamic) {
|
|
193
227
|
options(select, this.#records.has(surface) ? choices(surface) : []);
|
|
194
228
|
}
|
|
229
|
+
for (const [panel, groups] of this.#menus) {
|
|
230
|
+
entries(panel, this.#records.has(surface) ? groups(surface) : []);
|
|
231
|
+
// Where it fits changes with the toolbar, which moves with the caret.
|
|
232
|
+
if (!panel.hidden) placeMenu(panel, panel.previousElementSibling);
|
|
233
|
+
}
|
|
195
234
|
return this.#toolbar?.refresh() || false;
|
|
196
235
|
}
|
|
197
236
|
|
|
@@ -207,6 +246,7 @@ export class Editor {
|
|
|
207
246
|
this.#highlight?.dispose();
|
|
208
247
|
this.#highlight = null;
|
|
209
248
|
this.#dynamic.clear();
|
|
249
|
+
this.#menus.clear();
|
|
210
250
|
this.#toolbar = null;
|
|
211
251
|
this.#element = null;
|
|
212
252
|
for (const setup of [...this.#setups.values()].reverse()) setup.dispose();
|
|
@@ -331,6 +371,49 @@ export class Editor {
|
|
|
331
371
|
#append(module) {
|
|
332
372
|
if (!this.#element) return;
|
|
333
373
|
for (const control of module.toolbar || []) {
|
|
374
|
+
// A menu is one control whose content is read per surface, so a host may offer three
|
|
375
|
+
// sets of classes where another offers none, without a control per set.
|
|
376
|
+
if (control.type === 'menu') {
|
|
377
|
+
const panel = this.#document.createElement('div');
|
|
378
|
+
panel.id = `u2-rte-menu-${control.name || control.command}`;
|
|
379
|
+
panel.dataset.menu = control.command;
|
|
380
|
+
panel.hidden = true;
|
|
381
|
+
const button = this.#document.createElement('button');
|
|
382
|
+
button.type = 'button';
|
|
383
|
+
button.dataset.commandMenu = control.command;
|
|
384
|
+
button.dataset.control = control.name;
|
|
385
|
+
button.dataset.u2RteModule = module.name;
|
|
386
|
+
button.setAttribute('aria-label', control.label);
|
|
387
|
+
button.setAttribute('aria-expanded', 'false');
|
|
388
|
+
button.title = control.label;
|
|
389
|
+
button.textContent = control.text;
|
|
390
|
+
// Not a popover: the chrome raises itself into the top layer whenever anything it
|
|
391
|
+
// draws appears, and a separate top-layer panel would end up underneath it.
|
|
392
|
+
// Dismissal is therefore ours, and placement follows the toolbar while it is open.
|
|
393
|
+
const close = () => {
|
|
394
|
+
if (panel.hidden) return;
|
|
395
|
+
panel.hidden = true;
|
|
396
|
+
button.setAttribute('aria-expanded', 'false');
|
|
397
|
+
};
|
|
398
|
+
button.addEventListener('click', () => {
|
|
399
|
+
if (!panel.hidden) return close();
|
|
400
|
+
panel.hidden = false;
|
|
401
|
+
button.setAttribute('aria-expanded', 'true');
|
|
402
|
+
placeMenu(panel, button);
|
|
403
|
+
});
|
|
404
|
+
this.#element.addEventListener('pointerdown', event => {
|
|
405
|
+
if (!panel.contains(event.composedPath()[0]) && event.composedPath()[0] !== button) close();
|
|
406
|
+
});
|
|
407
|
+
this.#document.addEventListener('pointerdown', event => {
|
|
408
|
+
if (!this.#element?.contains(event.composedPath()[0])) close();
|
|
409
|
+
}, {signal: this.#controller.signal});
|
|
410
|
+
this.#document.addEventListener('keydown', event => {
|
|
411
|
+
if (event.key === 'Escape') close();
|
|
412
|
+
}, {signal: this.#controller.signal});
|
|
413
|
+
this.#element.append(button, panel);
|
|
414
|
+
if (typeof control.groups === 'function') this.#menus.set(panel, control.groups);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
334
417
|
if (control.type === 'select') {
|
|
335
418
|
const select = this.#document.createElement('select');
|
|
336
419
|
select.dataset.commandValue = control.command;
|
|
@@ -411,6 +494,15 @@ function validModule(module) {
|
|
|
411
494
|
});
|
|
412
495
|
return Object.freeze({...control, type, options: Object.freeze(choices)});
|
|
413
496
|
}
|
|
497
|
+
if (type === 'menu') {
|
|
498
|
+
for (const property of ['name', 'command', 'label', 'text']) {
|
|
499
|
+
if (typeof control[property] !== 'string' || !control[property].trim()) {
|
|
500
|
+
throw new TypeError(`An editor toolbar menu requires ${property}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (typeof control.groups !== 'function') throw new TypeError('An editor toolbar menu requires groups');
|
|
504
|
+
return Object.freeze({...control, type});
|
|
505
|
+
}
|
|
414
506
|
if (type !== 'button') throw new TypeError(`Unknown editor toolbar control: ${type}`);
|
|
415
507
|
for (const property of ['command', 'label', 'text']) {
|
|
416
508
|
if (typeof control[property] !== 'string' || !control[property].trim()) {
|
|
@@ -456,6 +548,44 @@ function keyLabel(shortcut) {
|
|
|
456
548
|
|
|
457
549
|
// Rewrites a select's choices, keeping its disabled placeholder and leaving the
|
|
458
550
|
// element alone when nothing changed.
|
|
551
|
+
// Anchored to the toolbar rather than to the viewport: the toolbar is the menu's offset parent, so
|
|
552
|
+
// a panel placed against it follows every move it makes without a frame loop. Above the button where
|
|
553
|
+
// the viewport allows — the toolbar sits over the text being edited, and a menu opening upwards
|
|
554
|
+
// covers the chrome instead of the words.
|
|
555
|
+
function placeMenu(panel, button) {
|
|
556
|
+
const toolbar = panel.parentElement.getBoundingClientRect();
|
|
557
|
+
const above = toolbar.top - panel.offsetHeight >= 0;
|
|
558
|
+
panel.style.insetInlineStart = `${button.offsetLeft}px`;
|
|
559
|
+
panel.style.insetBlockStart = above ? 'auto' : '100%';
|
|
560
|
+
panel.style.insetBlockEnd = above ? '100%' : 'auto';
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// A menu is rebuilt only when its sets changed: it is refilled on every surface change, and
|
|
564
|
+
// replacing the buttons under an open menu would close it.
|
|
565
|
+
function entries(panel, groups) {
|
|
566
|
+
const key = groups.map(group => `${group.label}:${group.values.join(' ')}`).join('|');
|
|
567
|
+
if (panel.dataset.sets === key) return false;
|
|
568
|
+
panel.dataset.sets = key;
|
|
569
|
+
panel.replaceChildren();
|
|
570
|
+
const command = panel.dataset.menu;
|
|
571
|
+
for (const group of groups) {
|
|
572
|
+
if (group.label) {
|
|
573
|
+
const caption = panel.ownerDocument.createElement('b');
|
|
574
|
+
caption.textContent = group.label;
|
|
575
|
+
panel.append(caption);
|
|
576
|
+
}
|
|
577
|
+
for (const value of group.values) {
|
|
578
|
+
const entry = panel.ownerDocument.createElement('button');
|
|
579
|
+
entry.type = 'button';
|
|
580
|
+
entry.dataset.commandValue = command;
|
|
581
|
+
entry.dataset.value = value;
|
|
582
|
+
entry.textContent = value;
|
|
583
|
+
panel.append(entry);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
|
|
459
589
|
function options(select, choices) {
|
|
460
590
|
const current = [...select.options].slice(1);
|
|
461
591
|
if (current.length === choices.length
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {inlineUi} from '../config/config.js';
|
|
2
2
|
import {rangeRect} from '../browser/range-rect.js';
|
|
3
3
|
import {linkHtml} from '../mark/standard.js';
|
|
4
|
-
import {caretAfter} from '../ui/caret.js';
|
|
4
|
+
import {caretAfter, caretAfterRange} from '../ui/caret.js';
|
|
5
5
|
import {follows} from './contextual.js';
|
|
6
6
|
import {panelGap, place} from '../ui/place.js';
|
|
7
7
|
import {valueMark} from '../command/mark.js';
|
|
@@ -43,6 +43,7 @@ const STYLE = `
|
|
|
43
43
|
|
|
44
44
|
const LABELS = {href: 'Address', target: 'New tab', title: 'Title', rel: 'Rel'};
|
|
45
45
|
|
|
46
|
+
|
|
46
47
|
// Optional contextual link editor.
|
|
47
48
|
//
|
|
48
49
|
// The form is where the caret is: it appears on its own when the caret is in a
|
|
@@ -342,8 +343,9 @@ function write(state, value, field = null) {
|
|
|
342
343
|
? [field.selectionStart, field.selectionEnd]
|
|
343
344
|
: null;
|
|
344
345
|
state.writing = true;
|
|
346
|
+
let changed = null;
|
|
345
347
|
try {
|
|
346
|
-
active.view.commands.run('link', {value, range, trigger: 'input'});
|
|
348
|
+
changed = active.view.commands.run('link', {value, range, trigger: 'input'});
|
|
347
349
|
} finally {
|
|
348
350
|
state.writing = false;
|
|
349
351
|
}
|
|
@@ -354,9 +356,11 @@ function write(state, value, field = null) {
|
|
|
354
356
|
// What the form edits from here on is whatever the run left behind: the link
|
|
355
357
|
// it made, or — when emptying the address took one away — the text that is
|
|
356
358
|
// left, so the same words can be linked again.
|
|
357
|
-
|
|
359
|
+
// What the run made, as the run reports it. Asking the caret instead answers about wherever the
|
|
360
|
+
// caret happens to be — and while a form is being typed into, that is the form.
|
|
361
|
+
active.element = made(changed) ?? marked(active.surface, at(active.surface));
|
|
358
362
|
active.range = active.element ? contents(active.element)
|
|
359
|
-
: active.surface.selection?.range() || active.range;
|
|
363
|
+
: live(active.surface) || active.surface.selection?.range() || active.range;
|
|
360
364
|
active.selection = active.surface.selection;
|
|
361
365
|
if (!active.element && value) return close(state);
|
|
362
366
|
position(state);
|
|
@@ -368,10 +372,18 @@ function write(state, value, field = null) {
|
|
|
368
372
|
function leave(state) {
|
|
369
373
|
const active = state.active;
|
|
370
374
|
const element = active?.element;
|
|
371
|
-
if (active?.creating)
|
|
372
|
-
|
|
375
|
+
if (!active?.creating && state.dirty) commit(state);
|
|
376
|
+
// What the document holds now, not what the form was opened on: committing re-marks the link,
|
|
377
|
+
// and the element the form remembered is the one that was replaced.
|
|
378
|
+
const surface = active?.surface;
|
|
379
|
+
const target = surface?.connected && marked(surface, at(surface)) || state.active?.element || element;
|
|
380
|
+
const range = state.active?.range || active?.range;
|
|
381
|
+
// Both keys mean the same thing: done. The form stays only while the caret is in a link, and
|
|
382
|
+
// emptying an address left none — it would otherwise follow the words it just unlinked.
|
|
383
|
+
close(state);
|
|
373
384
|
if (!active?.surface.connected) return null;
|
|
374
|
-
caretAfter(active.surface,
|
|
385
|
+
if (target?.isConnected) caretAfter(active.surface, target);
|
|
386
|
+
else caretAfterRange(active.surface, range);
|
|
375
387
|
return null;
|
|
376
388
|
}
|
|
377
389
|
|
|
@@ -424,12 +436,23 @@ function position(state) {
|
|
|
424
436
|
// The node the surface's saved selection starts in — or the one it holds, when
|
|
425
437
|
// that is a single element: an image's link is on the image, not on the block the
|
|
426
438
|
// range around it starts in.
|
|
439
|
+
// Where the caret is now, not where it was captured: an engine announces a selection change in its
|
|
440
|
+
// own time, and everything here happens between marking a link and that announcement.
|
|
427
441
|
function at(surface) {
|
|
428
|
-
const range = surface.selection?.range();
|
|
442
|
+
const range = live(surface) || surface.selection?.range();
|
|
429
443
|
if (!range) return null;
|
|
430
444
|
return range.startContainer.childNodes?.[range.startOffset] ?? range.startContainer;
|
|
431
445
|
}
|
|
432
446
|
|
|
447
|
+
// A copy, never the selection's own range: that one is live and follows every later move, so a form
|
|
448
|
+
// that remembered it would be holding the caret by the time it is used.
|
|
449
|
+
function live(surface) {
|
|
450
|
+
const selection = surface.core.selection;
|
|
451
|
+
if (!selection?.rangeCount) return null;
|
|
452
|
+
const range = selection.getRangeAt(0);
|
|
453
|
+
return surface.element.contains(range.startContainer) ? range.cloneRange() : null;
|
|
454
|
+
}
|
|
455
|
+
|
|
433
456
|
// The contents of one element, as a range.
|
|
434
457
|
function contents(element) {
|
|
435
458
|
const range = element.ownerDocument.createRange();
|
|
@@ -448,6 +471,12 @@ function target(surface) {
|
|
|
448
471
|
return whole;
|
|
449
472
|
}
|
|
450
473
|
|
|
474
|
+
// The link a run left behind, among the elements it reports as changed.
|
|
475
|
+
function made(changed) {
|
|
476
|
+
if (!Array.isArray(changed)) return null;
|
|
477
|
+
return changed.find(node => node?.nodeType === Node.ELEMENT_NODE && node.isConnected && linkHtml.parse(node)) ?? null;
|
|
478
|
+
}
|
|
479
|
+
|
|
451
480
|
function marked(surface, node) {
|
|
452
481
|
let element = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
|
|
453
482
|
for (; element && element !== surface.element; element = element.parentElement) {
|
|
@@ -41,9 +41,13 @@ export class BlockStyles {
|
|
|
41
41
|
return edit.range.blocks(element => element !== edit.element && this.#current(element));
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// The most specific match, not the last one declared: `p.lead` says more about an element than
|
|
45
|
+
// `p` does, so a plain tag needs no `:not()` to stay out of the way of its own variants.
|
|
44
46
|
#current(element) {
|
|
45
47
|
let current = null;
|
|
46
|
-
for (const style of this.#styles)
|
|
48
|
+
for (const style of this.#styles) {
|
|
49
|
+
if (matches(style, element) && (!current || conditions(style) >= conditions(current))) current = style;
|
|
50
|
+
}
|
|
47
51
|
return current;
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -118,3 +122,9 @@ function style(value) {
|
|
|
118
122
|
function matches(style, element) {
|
|
119
123
|
return element.matches(style.selector);
|
|
120
124
|
}
|
|
125
|
+
|
|
126
|
+
// How much a selector asks for beyond its tag: every class, attribute, id, or pseudo-class counts
|
|
127
|
+
// once. Enough to order `p` against `p.lead` and `p.lead[data-x]` without parsing css specificity.
|
|
128
|
+
function conditions(style) {
|
|
129
|
+
return (style.selector.match(/[.#[:]/g) ?? []).length;
|
|
130
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import {configuredElements} from '../config/config.js';
|
|
2
1
|
import {Edit, narrow} from './edit.js';
|
|
3
2
|
import {htmlModel} from '../model/html/html-model.js';
|
|
4
3
|
|
|
@@ -29,7 +28,7 @@ export class Commands {
|
|
|
29
28
|
|
|
30
29
|
get surface() { return this.#surface; }
|
|
31
30
|
get model() {
|
|
32
|
-
return narrow(this.#model,
|
|
31
|
+
return narrow(this.#model, this.#surface.config.elements);
|
|
33
32
|
}
|
|
34
33
|
get names() { return [...this.#commands.keys()]; }
|
|
35
34
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import {configuredElements} from '../config/config.js';
|
|
2
1
|
import {htmlModel} from '../model/html/html-model.js';
|
|
3
2
|
import {EditRange} from '../selection/range/edit-range.js';
|
|
4
3
|
import {PointMap} from '../selection/map/point-map.js';
|
|
@@ -56,13 +55,24 @@ export class Edit {
|
|
|
56
55
|
// checks never consult it. Resolving it on first use keeps a toolbar
|
|
57
56
|
// refresh from paying for every control that does not care.
|
|
58
57
|
get model() {
|
|
59
|
-
|
|
58
|
+
// The surface has read its host already; asking the platform again is the expensive half of
|
|
59
|
+
// an availability check, and every control makes one.
|
|
60
|
+
this.#narrowed ??= narrow(this.#model, this.config.elements);
|
|
60
61
|
return this.#narrowed;
|
|
61
62
|
}
|
|
62
63
|
get map() { return this.#map; }
|
|
63
64
|
get inputType() { return this.#inputType; }
|
|
64
65
|
get data() { return this.#data; }
|
|
65
66
|
get value() { return this.#value; }
|
|
67
|
+
|
|
68
|
+
/** The same edit with single inputs replaced: a command answering for a set of values has to ask
|
|
69
|
+
* its adapter about another one, and spreading an edit would leave its getters behind. */
|
|
70
|
+
with(options) {
|
|
71
|
+
return new Edit(this.#surface, this.#transaction, {
|
|
72
|
+
model: this.#model, range: this.#range?.range() ?? null, inputType: this.#inputType,
|
|
73
|
+
data: this.#data, value: this.#value, fragment: this.#fragment, ...options,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
66
76
|
get fragment() { return this.#fragment; }
|
|
67
77
|
get range() { return this.#range; }
|
|
68
78
|
get document() { return this.#surface.element.ownerDocument; }
|
|
@@ -132,9 +132,14 @@ function split(edit, unit) {
|
|
|
132
132
|
return tail;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// What Enter at the end of a block continues with: the host's plain default block. A heading, a
|
|
136
|
+
// lead, a note — anything carrying more than the bare tag — is a shape someone chose for that one
|
|
137
|
+
// block, and the line after it is a new thought. Splitting in the middle keeps the kind, because
|
|
138
|
+
// there both halves are the same block.
|
|
135
139
|
function continuationBlock(edit, unit, point) {
|
|
136
140
|
const tag = edit.config.block;
|
|
137
|
-
if (!tag || !edit.model.textBlock(unit) ||
|
|
141
|
+
if (!tag || !edit.model.textBlock(unit) || !blockEdge(unit, point, 'end')) return null;
|
|
142
|
+
if (unit.localName === tag && !unit.attributes.length) return null; // already plain
|
|
138
143
|
const block = edit.document.createElement(tag);
|
|
139
144
|
if (!edit.model.allows(unit.parentElement, block)) return null;
|
|
140
145
|
return block;
|
|
@@ -83,9 +83,36 @@ the text-level semantics that carry meaning — no layout, no embeds, no forms.
|
|
|
83
83
|
The host list still bounds it; neither can widen the sanitize policy.
|
|
84
84
|
|
|
85
85
|
`--u2-rte-import-sanitize` switches that whole stage off with `none`, leaving
|
|
86
|
-
what the browser inserted.
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
what the browser inserted.
|
|
87
|
+
|
|
88
|
+
`--u2-rte-elements`, `--u2-rte-attributes` and `--u2-rte-protocols` say what
|
|
89
|
+
content may carry, and each of them *is* the policy where it is declared rather
|
|
90
|
+
than a narrowing of one — a field may name an element the shipped default does
|
|
91
|
+
not, because what may be inserted is the site's decision. The two
|
|
92
|
+
element-specific ones share one grammar: a bare list applies to every element, `name(…)` to one of them, and
|
|
93
|
+
protocols name the attribute inside — `class title, a(href target)` and
|
|
94
|
+
`a(href: http https), img(src: data http)`. Unset means the policy the
|
|
95
|
+
application configured, so a declaration only states what it changes, and a
|
|
96
|
+
malformed value changes nothing rather than silently narrowing. A site declares
|
|
97
|
+
its own on the root and a field narrows from there, the way every other property
|
|
98
|
+
here cascades.
|
|
99
|
+
|
|
100
|
+
`--u2-rte-blocks` names the block styles a field offers — `Absatz(p), Lead(p.lead),
|
|
101
|
+
Notiz(p[data-note]), Titel(h2)`. The label names it, the selector says what it is:
|
|
102
|
+
the tag decides what is created, and what follows it — classes, attributes — is
|
|
103
|
+
written when the style is applied and taken off when another replaces it. The
|
|
104
|
+
most specific match wins, so a plain `p` needs no `:not(.lead)` to stay out of
|
|
105
|
+
the way of its own variants. Unset leaves the styles the application registered.
|
|
106
|
+
|
|
107
|
+
`--u2-rte-class-groups` names either-or sets — `color(Red Green), align(Left
|
|
108
|
+
Center Right)`. Each becomes a section of the class menu, exclusive in itself and
|
|
109
|
+
free of the others, so a field can carry one class from every set. It decides
|
|
110
|
+
nothing about what may exist, only how it is offered, so its names are declared
|
|
111
|
+
in `--u2-rte-classes` as well.
|
|
112
|
+
|
|
113
|
+
None of this is the security boundary. What a client sanitizer protects is the
|
|
114
|
+
editing session — markup inserted into the author's own logged-in page — while
|
|
115
|
+
what may be stored is decided where the content is saved and served.
|
|
89
116
|
|
|
90
117
|
## TODO
|
|
91
118
|
|
|
@@ -6,10 +6,15 @@ const INLINE_HOSTS = new Set([
|
|
|
6
6
|
const UNWRAPPED_HOSTS = new Set(['LI', 'CAPTION', 'TH', 'TD']);
|
|
7
7
|
const CLEANUP = new Set(['none', 'minimal', 'structural', 'canonical']);
|
|
8
8
|
const ENTER = new Set(['break', 'block', 'item', 'row', 'cell']);
|
|
9
|
+
const ATTRIBUTE = /^[a-z][a-z\d_.:-]*$/;
|
|
10
|
+
const PROTOCOL = /^[a-z][a-z\d+.-]*$/;
|
|
9
11
|
const UI = new Set(['none', 'roaming', 'static']);
|
|
10
12
|
const IMPORT_SANITIZE = new Set(['policy', 'none']);
|
|
11
13
|
const TAG = /^[a-z][a-z\d-]*$/;
|
|
12
14
|
const CLASS = /^-?[_a-zA-Z][\w-]*$/;
|
|
15
|
+
const LABEL = /^[^\s(),]+$/;
|
|
16
|
+
// A block style is one element with conditions on it: `p`, `p.lead`, `p[aria-label=note]`.
|
|
17
|
+
const SELECTOR = /^[a-z][a-z\d-]*(?:\.[_a-zA-Z][\w-]*|\[[a-z][\w-]*(?:=[^\]\s]+)?\])*$/;
|
|
13
18
|
const FALSE = new Set(['0', 'false', 'none', 'off']);
|
|
14
19
|
const DEFAULT_CLEAN_ON = Object.freeze(['input', 'paste', 'drop', 'command']);
|
|
15
20
|
const EMPTY_ELEMENTS = Object.freeze([]);
|
|
@@ -61,15 +66,18 @@ export function configuredElements(host) {
|
|
|
61
66
|
export function config(host) {
|
|
62
67
|
const style = getComputedStyle(host);
|
|
63
68
|
const defaults = hostDefaults(host);
|
|
64
|
-
const cleanOn = value(style, 'clean-on');
|
|
65
69
|
return Object.freeze({
|
|
66
70
|
block: tag(style, 'block', defaults.block),
|
|
67
71
|
enter: choice(style, 'enter', ENTER, defaults.enter),
|
|
68
72
|
cleanup: choice(style, 'cleanup', CLEANUP, 'structural'),
|
|
69
|
-
cleanOn:
|
|
73
|
+
cleanOn: list(value(style, 'clean-on'), TAG) ?? DEFAULT_CLEAN_ON,
|
|
70
74
|
elements: allowedElements(style, 'elements', null),
|
|
71
75
|
importElements: allowedElements(style, 'import-elements', elementPresets.content),
|
|
72
76
|
classes: classNames(style),
|
|
77
|
+
classGroups: groups(style, 'class-groups', text => list(text, CLASS), label),
|
|
78
|
+
blocks: groups(style, 'blocks', text => SELECTOR.test(text.trim()) ? text.trim() : null, label),
|
|
79
|
+
attributes: groups(style, 'attributes', text => list(text.toLowerCase(), ATTRIBUTE)),
|
|
80
|
+
protocols: groups(style, 'protocols', text => scheme(text.toLowerCase())),
|
|
73
81
|
// Foreign presentation through the class rung: pasted markup keeps no
|
|
74
82
|
// styles, no presentational attributes, and no undeclared classes.
|
|
75
83
|
importSanitize: choice(style, 'import-sanitize', IMPORT_SANITIZE, 'policy'),
|
|
@@ -89,9 +97,8 @@ export function inlineUi(config, name) {
|
|
|
89
97
|
|
|
90
98
|
function inlineNames(style) {
|
|
91
99
|
const result = value(style, 'inline-ui').toLowerCase();
|
|
92
|
-
if (!result) return null;
|
|
93
100
|
if (result === 'none') return EMPTY_ELEMENTS;
|
|
94
|
-
return
|
|
101
|
+
return list(result, TAG);
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
function value(style, name) {
|
|
@@ -114,19 +121,50 @@ function choice(style, name, choices, fallback) {
|
|
|
114
121
|
// control, the sanitizer, and presentation cleanup, so a class the host knows is
|
|
115
122
|
// never offered without being allowed, or cleaned away as foreign.
|
|
116
123
|
function classNames(style) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
return list(value(style, 'classes'), CLASS) ?? EMPTY_ELEMENTS;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Element-specific lists share one grammar: a bare list is what every element may carry, `name(…)`
|
|
128
|
+
// what one of them adds — `class title, a(href target)`, or one level deeper `a(href: http https)`.
|
|
129
|
+
// Unset means the policy's own default, so a declaration only says what it changes, and a typo
|
|
130
|
+
// yields nothing rather than silently narrowing.
|
|
131
|
+
function groups(style, name, read, key = element) {
|
|
132
|
+
const declared = value(style, name); // not lowercased: element names are, class names are not
|
|
133
|
+
if (!declared) return null;
|
|
134
|
+
const result = {};
|
|
135
|
+
for (const group of declared.split(',')) {
|
|
136
|
+
const match = group.trim().match(/^([^\s(),]+)\s*\((.*)\)$/s);
|
|
137
|
+
const parsed = read(match ? match[2] : group);
|
|
138
|
+
const name = match ? key(match[1]) : '*';
|
|
139
|
+
if (!parsed || !name) return null;
|
|
140
|
+
result[name] = parsed;
|
|
141
|
+
}
|
|
142
|
+
return Object.freeze(result);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// An element is a tag name, normalized; a group is a label and stays as it was written.
|
|
146
|
+
const element = name => TAG.test(name.toLowerCase()) ? name.toLowerCase() : null;
|
|
147
|
+
const label = name => LABEL.test(name) ? name : null;
|
|
148
|
+
|
|
149
|
+
function list(text, pattern) {
|
|
150
|
+
const names = text.split(/[\s,]+/).filter(Boolean);
|
|
151
|
+
return names.length && names.every(one => pattern.test(one)) ? Object.freeze([...new Set(names)]) : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function scheme(text) {
|
|
155
|
+
const [attribute, ...rest] = text.split(':');
|
|
156
|
+
const schemes = list(rest.join(':'), PROTOCOL);
|
|
157
|
+
return schemes && ATTRIBUTE.test(attribute.trim()) ? Object.freeze({[attribute.trim()]: schemes}) : null;
|
|
121
158
|
}
|
|
122
159
|
|
|
123
160
|
function allowedElements(style, name, fallback) {
|
|
124
161
|
const result = value(style, name).toLowerCase();
|
|
125
162
|
if (!result) return fallback;
|
|
126
163
|
if (result === 'all') return null;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
164
|
+
// An unreadable declaration is ignored, the way css ignores an invalid value: what applies is
|
|
165
|
+
// what applied before it, not an empty field.
|
|
166
|
+
if (result.startsWith('@')) return elementPresets[result.slice(1)] ?? fallback;
|
|
167
|
+
return list(result, TAG) ?? fallback;
|
|
130
168
|
}
|
|
131
169
|
|
|
132
170
|
function elements(value) {
|
package/js/rte/src/core/core.js
CHANGED
|
@@ -206,7 +206,9 @@ export class Rte extends EventTarget {
|
|
|
206
206
|
|
|
207
207
|
#pointerDown = event => {
|
|
208
208
|
const path = event.composedPath();
|
|
209
|
-
|
|
209
|
+
// A press in retained ui is the editor's own: its controls hand the focus back to the
|
|
210
|
+
// surface they act on, and refusing that session would take the toolbar away mid-command.
|
|
211
|
+
this.#press = path.some(node => this.#retained.has(node)) ? null : {path, handed: false};
|
|
210
212
|
const editing = this.#editing(path[0]);
|
|
211
213
|
// The right button opens a menu about what is selected; moving the
|
|
212
214
|
// selection to what it was aimed at is what makes the menu useless.
|