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.
@@ -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
- // `--u2-rte-classes`, so one declaration decides what the control offers, what
6
- // the sanitizer keeps, and what presentation cleanup leaves alone.
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
- // The group is one mark type and therefore mutually exclusive. An application
9
- // that needs independent axes registers a second module with its own adapter.
10
- export function classStyles({label = 'Style', name = 'style', command = 'classStyle'} = {}) {
11
- for (const value of [label, name, command]) {
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
- // One adapter per configured group, so the same host keeps one mark identity
31
- // across refreshes and two hosts with the same classes share it.
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
- const names = surface.config.classes;
35
- if (!names.length) return null;
36
- const key = names.join(' ');
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
- // The group is closed, so a value outside it is simply unavailable rather
41
- // than an error the adapter has to raise.
42
- const known = edit => edit.value == null || surface.config.classes.includes(edit.value);
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
- enabled: edit => known(edit) && !!resolve()?.enabled(edit),
45
- state: edit => resolve()?.state(edit) ?? null,
46
- run: edit => known(edit) ? resolve()?.run(edit) : undefined,
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, draw, listen);
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
- active.element = marked(active.surface, at(active.surface));
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) close(state);
372
- else if (state.dirty) commit(state);
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, state.active?.element || element);
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) if (matches(style, element)) current = style;
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, configuredElements(this.#surface.element));
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
- this.#narrowed ??= narrow(this.#model, configuredElements(this.#surface.element));
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) || unit.localName === tag || !blockEdge(unit, point, 'end')) return null;
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. Which attributes and protocols are acceptable stays
87
- in JavaScript with the policy — that is a security decision, not a presentation
88
- one, and not something CSS should be carrying.
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: Object.freeze(cleanOn ? cleanOn.split(/[\s,]+/).filter(Boolean) : [...DEFAULT_CLEAN_ON]),
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 Object.freeze([...new Set(result.split(/[\s,]+/).filter(Boolean))]);
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
- const result = value(style, 'classes');
118
- if (!result) return EMPTY_ELEMENTS;
119
- const names = result.split(/[\s,]+/).filter(Boolean);
120
- return names.every(name => CLASS.test(name)) ? Object.freeze([...new Set(names)]) : EMPTY_ELEMENTS;
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
- if (result.startsWith('@')) return elementPresets[result.slice(1)] || EMPTY_ELEMENTS;
128
- const names = result.split(/[\s,]+/).filter(Boolean);
129
- return names.length && names.every(name => TAG.test(name)) ? Object.freeze([...new Set(names)]) : EMPTY_ELEMENTS;
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) {
@@ -206,7 +206,9 @@ export class Rte extends EventTarget {
206
206
 
207
207
  #pointerDown = event => {
208
208
  const path = event.composedPath();
209
- this.#press = {path, handed: false};
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.