u2ui 1.5.2 → 1.5.4
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/el/ico/ico.js +5 -5
- package/el/rte/README.md +53 -13
- package/el/rte/manifest.json +19 -4
- package/el/rte/rte.css +31 -6
- package/el/rte/rte.js +174 -44
- package/el/rte0/README.md +37 -0
- package/el/rte0/manifest.json +33 -0
- package/el/rte0/rte0.css +9 -0
- package/el/rte0/rte0.js +56 -0
- package/el/system/styler.js +2 -0
- package/el/tooltip/tooltip.css +2 -2
- package/js/Placer/Placer.js +2 -1
- package/js/rte/AGENTS.md +142 -0
- package/js/rte/PLAN.md +488 -0
- package/js/rte/README.md +319 -25
- package/js/rte/blocks.js +7 -0
- package/js/rte/breaks.js +6 -0
- package/js/rte/classes.js +7 -0
- package/js/rte/docs/usage.md +845 -0
- package/js/rte/images.js +7 -0
- package/js/rte/link.js +8 -0
- package/js/rte/playground/README.md +95 -0
- package/js/rte/playground/hosts.html +518 -0
- package/js/rte/playground/index.html +824 -0
- package/js/rte/rte.js +56 -5
- package/js/rte/source.js +12 -0
- package/js/rte/src/browser/README.md +40 -0
- package/js/rte/src/browser/range-rect.js +82 -0
- package/js/rte/src/client/README.md +378 -0
- package/js/rte/src/client/blocks.js +38 -0
- package/js/rte/src/client/breaks.js +212 -0
- package/js/rte/src/client/classes.js +48 -0
- package/js/rte/src/client/editor.js +461 -0
- package/js/rte/src/client/external-input.js +47 -0
- package/js/rte/src/client/history.js +13 -0
- package/js/rte/src/client/images.js +275 -0
- package/js/rte/src/client/link.js +572 -0
- package/js/rte/src/client/marks.js +25 -0
- package/js/rte/src/client/source.js +182 -0
- package/js/rte/src/client/structure.js +27 -0
- package/js/rte/src/client/tables.js +162 -0
- package/js/rte/src/client/unstyle.js +32 -0
- package/js/rte/src/command/README.md +517 -0
- package/js/rte/src/command/block-boundary.js +43 -0
- package/js/rte/src/command/block-style.js +120 -0
- package/js/rte/src/command/commands.js +158 -0
- package/js/rte/src/command/delete.js +112 -0
- package/js/rte/src/command/edit.js +72 -0
- package/js/rte/src/command/element.js +45 -0
- package/js/rte/src/command/enter.js +175 -0
- package/js/rte/src/command/fragment.js +76 -0
- package/js/rte/src/command/insert.js +52 -0
- package/js/rte/src/command/list.js +284 -0
- package/js/rte/src/command/mark.js +583 -0
- package/js/rte/src/command/pending-marks.js +216 -0
- package/js/rte/src/command/table.js +207 -0
- package/js/rte/src/command/unstyle.js +248 -0
- package/js/rte/src/config/README.md +96 -0
- package/js/rte/src/config/config.js +127 -0
- package/js/rte/src/core/README.md +41 -0
- package/js/rte/src/core/core.js +156 -0
- package/js/rte/src/history/README.md +83 -0
- package/js/rte/src/history/history.js +283 -0
- package/js/rte/src/input/README.md +155 -0
- package/js/rte/src/input/external-input.js +130 -0
- package/js/rte/src/input/input-pipeline.js +414 -0
- package/js/rte/src/mark/README.md +219 -0
- package/js/rte/src/mark/dom-adapter.js +100 -0
- package/js/rte/src/mark/mark.js +133 -0
- package/js/rte/src/mark/standard.js +76 -0
- package/js/rte/src/model/README.md +71 -0
- package/js/rte/src/model/content-model.js +213 -0
- package/js/rte/src/model/html/README.md +51 -0
- package/js/rte/src/model/html/html-model.js +90 -0
- package/js/rte/src/normalize/execute/README.md +33 -0
- package/js/rte/src/normalize/execute/repair-executor.js +129 -0
- package/js/rte/src/normalize/normalizer/README.md +48 -0
- package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
- package/js/rte/src/normalize/repair/README.md +70 -0
- package/js/rte/src/normalize/repair/repair-planner.js +208 -0
- package/js/rte/src/sanitize/README.md +105 -0
- package/js/rte/src/sanitize/native.js +43 -0
- package/js/rte/src/sanitize/policy.js +202 -0
- package/js/rte/src/selection/README.md +39 -0
- package/js/rte/src/selection/map/README.md +53 -0
- package/js/rte/src/selection/map/point-map.js +307 -0
- package/js/rte/src/selection/ownership/README.md +32 -0
- package/js/rte/src/selection/ownership/ownership.js +42 -0
- package/js/rte/src/selection/point/README.md +34 -0
- package/js/rte/src/selection/point/point.js +83 -0
- package/js/rte/src/selection/range/README.md +38 -0
- package/js/rte/src/selection/range/edit-range.js +223 -0
- package/js/rte/src/selection/snapshot.js +64 -0
- package/js/rte/src/source/README.md +64 -0
- package/js/rte/src/source/source.js +193 -0
- package/js/rte/src/surface/README.md +36 -0
- package/js/rte/src/surface/content.js +16 -0
- package/js/rte/src/surface/surface.js +87 -0
- package/js/rte/src/transaction/README.md +35 -0
- package/js/rte/src/transaction/transaction.js +56 -0
- package/js/rte/src/ui/README.md +255 -0
- package/js/rte/src/ui/chrome.js +214 -0
- package/js/rte/src/ui/handles.js +179 -0
- package/js/rte/src/ui/place.js +25 -0
- package/js/rte/src/ui/toolbar.js +283 -0
- package/js/rte/src/unstyle/README.md +103 -0
- package/js/rte/src/unstyle/unstyle.js +140 -0
- package/js/rte/tables.js +7 -0
- package/js/rte/unstyle.js +8 -0
- package/js/rte0/README.md +37 -0
- package/js/rte0/rte.js +7 -0
- package/js/{rte → rte0}/src/behavior.js +0 -1
- package/js/{rte → rte0}/src/events.js +0 -1
- package/js/{rte → rte0}/src/range.js +0 -2
- package/js/{rte → rte0}/src/ui.items.js +0 -2
- package/js/{rte → rte0}/src/ui.js +0 -1
- package/package.json +1 -1
- package/u2/Element.js +16 -4
- package/u2/enhance.js +2 -1
- package/u2/plan-scoped-refactoring.md +133 -304
- package/u2/projects.json +18 -2
- package/u2/tools/update.repos.json.js +1 -1
- package/u2/utils.js +0 -16
- package/el/qrcode/package.json +0 -22
- /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
- /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/main.css +0 -0
- /package/js/{rte → rte0}/old/old.rte.js +0 -0
- /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
- /package/js/{rte → rte0}/src/fixes.js +0 -0
- /package/js/{rte → rte0}/src/rte.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
- /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
- /package/js/{rte → rte0}/src/util/indentHtml.js +0 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
export const handleStyle = `
|
|
2
|
+
.handles > .frame {
|
|
3
|
+
border: 1px solid Highlight;
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
display: none;
|
|
6
|
+
pointer-events: none;
|
|
7
|
+
position: fixed;
|
|
8
|
+
}
|
|
9
|
+
.handles[framed] > .frame { display: block; }
|
|
10
|
+
.handles > button {
|
|
11
|
+
align-items: center;
|
|
12
|
+
background: Canvas;
|
|
13
|
+
border: 1px solid color-mix(in srgb, CanvasText 45%, transparent);
|
|
14
|
+
border-radius: 50%;
|
|
15
|
+
block-size: var(--u2-handle-size, 1.43em);
|
|
16
|
+
color: CanvasText;
|
|
17
|
+
display: flex;
|
|
18
|
+
font-size: .7em;
|
|
19
|
+
inline-size: var(--u2-handle-size, 1.43em);
|
|
20
|
+
justify-content: center;
|
|
21
|
+
padding: 0;
|
|
22
|
+
pointer-events: auto;
|
|
23
|
+
position: fixed;
|
|
24
|
+
/* Trimming the font's leading centres the glyph itself rather than its
|
|
25
|
+
metric box, which is what makes a bare + or × sit straight. */
|
|
26
|
+
text-box: trim-both cap alphabetic;
|
|
27
|
+
}
|
|
28
|
+
.handles > button:hover:not(:disabled) { background: color-mix(in srgb, Highlight 20%, Canvas); }
|
|
29
|
+
.handles > button:disabled { opacity: .45; }
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
// A set of buttons placed around something, with an optional frame drawn behind
|
|
33
|
+
// them. It owns no editor concept: a caller says where each handle goes and what
|
|
34
|
+
// pressing one means, which is what lets anything with a rectangle use it.
|
|
35
|
+
//
|
|
36
|
+
// Given a shadow root it places itself inside it and registers its stylesheet
|
|
37
|
+
// there. Given a document it makes a shadow root of its own, so it works just as
|
|
38
|
+
// well outside an editor.
|
|
39
|
+
export class Handles {
|
|
40
|
+
#host;
|
|
41
|
+
#root;
|
|
42
|
+
#frame;
|
|
43
|
+
#buttons = new Map();
|
|
44
|
+
#action;
|
|
45
|
+
#owned = null;
|
|
46
|
+
#connected = true;
|
|
47
|
+
|
|
48
|
+
constructor(root, {handles, action = null, press = null, name = 'handles'} = {}) {
|
|
49
|
+
const isDocument = root?.nodeType === Node.DOCUMENT_NODE;
|
|
50
|
+
const isShadow = root?.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
|
|
51
|
+
if (!isDocument && !isShadow) throw new TypeError('Handles require a document or shadow root');
|
|
52
|
+
if (!Array.isArray(handles) || !handles.length) throw new TypeError('Handles require descriptors');
|
|
53
|
+
for (const [label, value] of [['action', action], ['press', press]]) {
|
|
54
|
+
if (value !== null && typeof value !== 'function') throw new TypeError(`A handle ${label} must be a function`);
|
|
55
|
+
}
|
|
56
|
+
const document = isDocument ? root : root.ownerDocument;
|
|
57
|
+
this.#action = action;
|
|
58
|
+
this.#host = document.createElement('div');
|
|
59
|
+
this.#host.id = name;
|
|
60
|
+
this.#host.className = 'handles';
|
|
61
|
+
this.#host.hidden = true;
|
|
62
|
+
this.#host.setAttribute('aria-hidden', 'true');
|
|
63
|
+
this.#frame = document.createElement('div');
|
|
64
|
+
this.#frame.className = 'frame';
|
|
65
|
+
this.#host.append(this.#frame);
|
|
66
|
+
for (const handle of handles) {
|
|
67
|
+
if (typeof handle?.name !== 'string' || !handle.name.trim()) throw new TypeError('A handle requires a name');
|
|
68
|
+
const button = document.createElement('button');
|
|
69
|
+
button.type = 'button';
|
|
70
|
+
button.dataset.handle = handle.name;
|
|
71
|
+
button.tabIndex = -1;
|
|
72
|
+
button.title = handle.label || handle.name;
|
|
73
|
+
button.setAttribute('aria-label', handle.label || handle.name);
|
|
74
|
+
button.textContent = handle.text ?? '';
|
|
75
|
+
if (handle.cursor) button.style.cursor = handle.cursor;
|
|
76
|
+
this.#buttons.set(handle.name, button);
|
|
77
|
+
this.#host.append(button);
|
|
78
|
+
}
|
|
79
|
+
// Listening on the containing root rather than the host: an event that
|
|
80
|
+
// is not composed never leaves the shadow tree it happened in.
|
|
81
|
+
this.#root = isShadow ? root : ownRoot(this.#host, document);
|
|
82
|
+
this.#root.addEventListener('pointerdown', event => {
|
|
83
|
+
const button = named(event, this.#host);
|
|
84
|
+
if (!button || button.disabled) return;
|
|
85
|
+
// Pointing at editor chrome must never move the selection it acts on.
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
press?.(button.dataset.handle, event);
|
|
88
|
+
});
|
|
89
|
+
this.#root.addEventListener('click', event => {
|
|
90
|
+
const button = named(event, this.#host);
|
|
91
|
+
if (button && !button.disabled) this.#action?.(button.dataset.handle, event);
|
|
92
|
+
});
|
|
93
|
+
if (isShadow) {
|
|
94
|
+
if (!root.getElementById('handles-style')) {
|
|
95
|
+
const style = document.createElement('style');
|
|
96
|
+
style.id = 'handles-style';
|
|
97
|
+
style.textContent = handleStyle;
|
|
98
|
+
root.append(style);
|
|
99
|
+
}
|
|
100
|
+
root.append(this.#host);
|
|
101
|
+
} else {
|
|
102
|
+
this.#owned = this.#host.parentNode;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get element() { return this.#host; }
|
|
107
|
+
|
|
108
|
+
button(name) {
|
|
109
|
+
return this.#buttons.get(name) || null;
|
|
110
|
+
}
|
|
111
|
+
get names() { return [...this.#buttons.keys()]; }
|
|
112
|
+
get visible() { return !this.#host.hidden; }
|
|
113
|
+
get connected() { return this.#connected; }
|
|
114
|
+
|
|
115
|
+
show(visible = true) {
|
|
116
|
+
this.#host.hidden = !visible;
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A handle is placed by its centre, in viewport coordinates.
|
|
121
|
+
place(name, x, y) {
|
|
122
|
+
const button = this.#buttons.get(name);
|
|
123
|
+
if (!button) throw new RangeError(`Unknown handle: ${name}`);
|
|
124
|
+
const size = button.offsetWidth || 20;
|
|
125
|
+
button.style.left = `${x - size / 2}px`;
|
|
126
|
+
button.style.top = `${y - size / 2}px`;
|
|
127
|
+
return this;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
disable(name, disabled = true) {
|
|
131
|
+
const button = this.#buttons.get(name);
|
|
132
|
+
if (!button) throw new RangeError(`Unknown handle: ${name}`);
|
|
133
|
+
button.disabled = !!disabled;
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// `null` hides the frame; a rectangle draws it in viewport coordinates.
|
|
138
|
+
frame(rect) {
|
|
139
|
+
this.#host.toggleAttribute('framed', !!rect);
|
|
140
|
+
if (!rect) return this;
|
|
141
|
+
this.#frame.style.left = `${rect.left}px`;
|
|
142
|
+
this.#frame.style.top = `${rect.top}px`;
|
|
143
|
+
this.#frame.style.width = `${rect.width}px`;
|
|
144
|
+
this.#frame.style.height = `${rect.height}px`;
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
dispose() {
|
|
149
|
+
if (!this.#connected) return;
|
|
150
|
+
(this.#owned?.host || this.#host).remove();
|
|
151
|
+
this.#buttons.clear();
|
|
152
|
+
this.#connected = false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
[Symbol.dispose]() {
|
|
156
|
+
this.dispose();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function named(event, host) {
|
|
161
|
+
const button = event.composedPath()[0]?.closest?.('button[data-handle]');
|
|
162
|
+
return button && host.contains(button) ? button : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Standalone use: the handles bring their own encapsulated root.
|
|
166
|
+
function ownRoot(host, document) {
|
|
167
|
+
const outer = document.createElement('div');
|
|
168
|
+
outer.dataset.u2RteChrome = 'handles';
|
|
169
|
+
const root = outer.attachShadow({mode: 'open'});
|
|
170
|
+
const style = document.createElement('style');
|
|
171
|
+
style.textContent = ':host { all: initial; pointer-events: none; position: fixed; inset: 0; }\n' + handleStyle;
|
|
172
|
+
root.append(style, host);
|
|
173
|
+
(document.body || document.documentElement).append(outer);
|
|
174
|
+
if (typeof outer.showPopover === 'function') {
|
|
175
|
+
outer.popover = 'manual';
|
|
176
|
+
outer.showPopover();
|
|
177
|
+
}
|
|
178
|
+
return root;
|
|
179
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {rangeRect} from '../browser/range-rect.js';
|
|
2
|
+
|
|
3
|
+
// One placement policy for every contextual UI: anchor on the surface's saved
|
|
4
|
+
// selection, keep the element inside the viewport, and fall to the other side
|
|
5
|
+
// when the preferred one does not fit.
|
|
6
|
+
//
|
|
7
|
+
// A UI that belongs to one element rather than to the selection passes that
|
|
8
|
+
// element's rect as `on`: what it points at should not shift with the caret.
|
|
9
|
+
export function place(element, surface, {align = 'center', prefer = 'above', gap = 28, on = null} = {}) {
|
|
10
|
+
const range = on ? null : surface.selection?.range();
|
|
11
|
+
if (!on && !range) return false;
|
|
12
|
+
const anchor = on || rangeRect(range, {root: surface.element});
|
|
13
|
+
const view = element.ownerDocument.defaultView;
|
|
14
|
+
const box = element.getBoundingClientRect();
|
|
15
|
+
const start = align === 'center' ? anchor.left + anchor.width / 2 - box.width / 2 : anchor.left;
|
|
16
|
+
const above = anchor.top - box.height - gap;
|
|
17
|
+
const below = anchor.bottom + gap;
|
|
18
|
+
const fits = prefer === 'above' ? above >= gap : below + box.height + gap <= view.innerHeight;
|
|
19
|
+
const top = prefer === 'above'
|
|
20
|
+
? (fits ? above : Math.min(view.innerHeight - box.height - gap, below))
|
|
21
|
+
: (fits ? below : Math.max(gap, above));
|
|
22
|
+
element.style.left = `${Math.max(gap, Math.min(view.innerWidth - box.width - gap, start))}px`;
|
|
23
|
+
element.style.top = `${top}px`;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
export class Toolbar {
|
|
2
|
+
#core;
|
|
3
|
+
#element;
|
|
4
|
+
#resolve;
|
|
5
|
+
#place;
|
|
6
|
+
#hovered = false;
|
|
7
|
+
#controller;
|
|
8
|
+
#surfaceController = null;
|
|
9
|
+
#surface = null;
|
|
10
|
+
#commands = null;
|
|
11
|
+
#dismissed = false;
|
|
12
|
+
#connected = true;
|
|
13
|
+
|
|
14
|
+
constructor(core, element, {commands, place = null} = {}) {
|
|
15
|
+
if (!core?.root || typeof core?.addEventListener !== 'function') {
|
|
16
|
+
throw new TypeError('A toolbar requires an editor core');
|
|
17
|
+
}
|
|
18
|
+
if (element?.nodeType !== Node.ELEMENT_NODE) throw new TypeError('A toolbar requires an element');
|
|
19
|
+
if (typeof commands !== 'function') throw new TypeError('A toolbar requires a command resolver');
|
|
20
|
+
if (place !== null && typeof place !== 'function') throw new TypeError('Toolbar placement must be a function');
|
|
21
|
+
const document = core.root.nodeType === Node.DOCUMENT_NODE ? core.root : core.root.ownerDocument;
|
|
22
|
+
if (element.ownerDocument !== document) throw new RangeError('A toolbar must share its core document');
|
|
23
|
+
this.#core = core;
|
|
24
|
+
this.#element = element;
|
|
25
|
+
this.#resolve = commands;
|
|
26
|
+
this.#place = place;
|
|
27
|
+
this.#controller = new document.defaultView.AbortController();
|
|
28
|
+
const listen = {signal: this.#controller.signal};
|
|
29
|
+
core.addEventListener('u2-rte-activechange', this.#activeChange, listen);
|
|
30
|
+
core.addEventListener('u2-rte-dispose', this.#coreDispose, listen);
|
|
31
|
+
element.addEventListener('pointerdown', this.#pointerDown, listen);
|
|
32
|
+
// A toolbar that moves out from under the pointer is a toolbar you
|
|
33
|
+
// cannot click. While it is being aimed at, it stays where it is and
|
|
34
|
+
// catches up when the pointer goes.
|
|
35
|
+
element.addEventListener('pointerenter', () => { this.#hovered = true; }, listen);
|
|
36
|
+
element.addEventListener('pointerleave', () => {
|
|
37
|
+
this.#hovered = false;
|
|
38
|
+
if (!this.#element.hidden && this.#surface) this.#place?.(this.#element, this.#surface);
|
|
39
|
+
}, listen);
|
|
40
|
+
element.addEventListener('click', this.#click, listen);
|
|
41
|
+
element.addEventListener('change', this.#change, listen);
|
|
42
|
+
core.root.addEventListener('focusin', this.#focusIn, {...listen, capture: true});
|
|
43
|
+
core.root.addEventListener('focusout', this.#focusOut, {...listen, capture: true});
|
|
44
|
+
if (!element.hasAttribute('role')) element.setAttribute('role', 'toolbar');
|
|
45
|
+
element.hidden = true;
|
|
46
|
+
// Focus landing in a control is still the editor's: without this the core
|
|
47
|
+
// would end the session the moment a button took the focus.
|
|
48
|
+
core.retain(element);
|
|
49
|
+
try {
|
|
50
|
+
this.#activate(core.active);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
this.dispose();
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get core() { return this.#core; }
|
|
58
|
+
get element() { return this.#element; }
|
|
59
|
+
get surface() { return this.#surface; }
|
|
60
|
+
get commands() { return this.#commands; }
|
|
61
|
+
get connected() { return this.#connected; }
|
|
62
|
+
|
|
63
|
+
refresh() {
|
|
64
|
+
if (!this.#connected) return false;
|
|
65
|
+
const surface = this.#surface;
|
|
66
|
+
const commands = surface?.connected ? this.#resolve(surface) : null;
|
|
67
|
+
if (commands != null && !registry(commands)) throw new TypeError('Toolbar commands must resolve to a command registry');
|
|
68
|
+
this.#commands = commands || null;
|
|
69
|
+
const active = !!commands && !this.#dismissed && surface.config.ui === 'roaming'
|
|
70
|
+
&& visibleForSelection(surface);
|
|
71
|
+
const names = active ? configured(surface.element) : null;
|
|
72
|
+
// A host may prefer a toolbar that only ever shows what it can do, at
|
|
73
|
+
// the cost of a shape that moves with the caret.
|
|
74
|
+
const hiding = active && unavailable(surface.element) === 'hide';
|
|
75
|
+
const detail = surface?.selection ? {range: surface.selection.range()} : undefined;
|
|
76
|
+
let visible = 0;
|
|
77
|
+
for (const item of this.#items()) {
|
|
78
|
+
const name = item.dataset.command.trim();
|
|
79
|
+
const control = item.dataset.control?.trim() || name;
|
|
80
|
+
const offered = active && commands.has(name) && (!names || names.has(control));
|
|
81
|
+
const disabled = !offered || !commands.enabled(name, detail);
|
|
82
|
+
item.hidden = !offered || hiding && disabled;
|
|
83
|
+
if (item.hidden) {
|
|
84
|
+
state(item, null, true);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
visible++;
|
|
88
|
+
state(item, item.hasAttribute('data-state') ? commands.state(name, detail) : null, disabled);
|
|
89
|
+
}
|
|
90
|
+
for (const select of this.#values()) {
|
|
91
|
+
const command = select.dataset.commandValue.trim();
|
|
92
|
+
const control = select.dataset.control?.trim() || command;
|
|
93
|
+
const show = active && commands.has(command) && (!names || names.has(control));
|
|
94
|
+
let choices = 0;
|
|
95
|
+
for (const option of select.options) {
|
|
96
|
+
if (!option.value) continue;
|
|
97
|
+
const enabled = show && commands.enabled(command, {...detail, value: option.value});
|
|
98
|
+
option.hidden = !enabled;
|
|
99
|
+
option.disabled = !enabled;
|
|
100
|
+
if (enabled) choices++;
|
|
101
|
+
}
|
|
102
|
+
// Presence answers what this editor offers, availability what the
|
|
103
|
+
// selection allows. A select with no configured choice at all is
|
|
104
|
+
// absent; one whose choices simply do not apply here stays and is
|
|
105
|
+
// disabled, so the toolbar keeps its shape as the caret moves.
|
|
106
|
+
const offered = show && [...select.options].some(option => option.value);
|
|
107
|
+
const usable = offered && choices > 0;
|
|
108
|
+
const value = usable ? commands.state(command, detail) : null;
|
|
109
|
+
select.hidden = !offered || hiding && !usable;
|
|
110
|
+
select.disabled = !usable;
|
|
111
|
+
select.setAttribute('aria-disabled', String(!usable));
|
|
112
|
+
const selected = [...select.options].find(option => option.value === value && !option.disabled);
|
|
113
|
+
select.value = selected && value !== 'mixed' ? value : '';
|
|
114
|
+
if (!select.hidden) visible++;
|
|
115
|
+
}
|
|
116
|
+
const show = active && !!visible;
|
|
117
|
+
// Appearing is not moving: a toolbar that becomes visible must not slide
|
|
118
|
+
// in from wherever it last stood, so the first placement is made with
|
|
119
|
+
// any transition suppressed.
|
|
120
|
+
const appearing = show && this.#element.hidden;
|
|
121
|
+
display(this.#element, show);
|
|
122
|
+
if (show && !(this.#hovered && !appearing)) {
|
|
123
|
+
if (appearing) this.#element.dataset.placing = '';
|
|
124
|
+
this.#place?.(this.#element, surface);
|
|
125
|
+
if (appearing) {
|
|
126
|
+
const view = this.#element.ownerDocument.defaultView;
|
|
127
|
+
view.requestAnimationFrame(() => delete this.#element.dataset.placing);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return show;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
dispose() {
|
|
134
|
+
if (!this.#connected) return;
|
|
135
|
+
this.#core.release(this.#element);
|
|
136
|
+
this.#surfaceController?.abort();
|
|
137
|
+
this.#controller.abort();
|
|
138
|
+
this.#surfaceController = null;
|
|
139
|
+
this.#surface = null;
|
|
140
|
+
this.#commands = null;
|
|
141
|
+
this.#dismissed = false;
|
|
142
|
+
display(this.#element, false);
|
|
143
|
+
this.#connected = false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
[Symbol.dispose]() {
|
|
147
|
+
this.dispose();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#activate(surface) {
|
|
151
|
+
if (surface === this.#surface) {
|
|
152
|
+
this.refresh();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.#surfaceController?.abort();
|
|
156
|
+
this.#surface = surface || null;
|
|
157
|
+
this.#dismissed = false;
|
|
158
|
+
this.#surfaceController = null;
|
|
159
|
+
if (surface) {
|
|
160
|
+
const Controller = this.#element.ownerDocument.defaultView.AbortController;
|
|
161
|
+
this.#surfaceController = new Controller();
|
|
162
|
+
const listen = {signal: this.#surfaceController.signal};
|
|
163
|
+
surface.addEventListener('u2-rte-selectionchange', this.#refresh, listen);
|
|
164
|
+
surface.addEventListener('u2-rte-change', this.#refresh, listen);
|
|
165
|
+
surface.addEventListener('u2-rte-history', this.#refresh, listen);
|
|
166
|
+
surface.addEventListener('u2-rte-disconnect', this.#disconnect, listen);
|
|
167
|
+
}
|
|
168
|
+
this.refresh();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#items() {
|
|
172
|
+
return this.#element.querySelectorAll('[data-command]');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#values() {
|
|
176
|
+
return this.#element.querySelectorAll('select[data-command-value]');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#item(target) {
|
|
180
|
+
const item = target?.closest?.('[data-command]');
|
|
181
|
+
if (!item || !this.#element.contains(item) || item.hidden || item.getAttribute('aria-disabled') === 'true') return null;
|
|
182
|
+
const name = item.dataset.command.trim();
|
|
183
|
+
return this.#commands?.has(name) ? item : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#run(name, detail) {
|
|
187
|
+
if (!this.#surface.capture()) this.#surface.restore();
|
|
188
|
+
this.#commands.run(name, detail);
|
|
189
|
+
this.refresh();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#activeChange = event => this.#activate(event.detail.surface);
|
|
193
|
+
#coreDispose = () => this.dispose();
|
|
194
|
+
#disconnect = () => this.#activate(null);
|
|
195
|
+
#refresh = () => this.refresh();
|
|
196
|
+
|
|
197
|
+
#focusIn = event => {
|
|
198
|
+
if (!this.#dismissed || !this.#ownsFocus(event.composedPath()[0])) return;
|
|
199
|
+
this.#dismissed = false;
|
|
200
|
+
this.refresh();
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
#focusOut = event => {
|
|
204
|
+
if (!this.#ownsFocus(event.composedPath()[0]) || this.#ownsFocus(event.relatedTarget)) return;
|
|
205
|
+
this.#dismissed = true;
|
|
206
|
+
display(this.#element, false);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// The toolbar is chrome: pointing at it must never move the editor's
|
|
210
|
+
// selection, not even at a control that currently has nothing to run.
|
|
211
|
+
// Fields keep their own pointer behaviour so they can be opened and typed in.
|
|
212
|
+
#pointerDown = event => {
|
|
213
|
+
const target = event.composedPath()[0];
|
|
214
|
+
if (target?.closest?.('select, input, textarea')) return;
|
|
215
|
+
event.preventDefault();
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
#click = event => {
|
|
219
|
+
const item = this.#item(event.target);
|
|
220
|
+
if (!item) return;
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
this.#run(item.dataset.command.trim());
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
#change = event => {
|
|
226
|
+
const select = event.target?.closest?.('select[data-command-value]');
|
|
227
|
+
if (!select || !this.#element.contains(select) || select.hidden || select.disabled) return;
|
|
228
|
+
const name = select.value.trim();
|
|
229
|
+
const command = select.dataset.commandValue.trim();
|
|
230
|
+
if (!name || select.selectedOptions[0]?.disabled || !this.#commands?.has(command)) {
|
|
231
|
+
this.refresh();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this.#run(command, {value: name});
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
#ownsFocus(node) {
|
|
239
|
+
if (!node || !this.#surface) return false;
|
|
240
|
+
// A `focusout` listener outside the toolbar's shadow tree sees its
|
|
241
|
+
// related target retargeted to that tree's host, so focus moving into a
|
|
242
|
+
// control of its own would otherwise read as focus leaving.
|
|
243
|
+
const root = this.#element.getRootNode();
|
|
244
|
+
if (root.host && node === root.host) return true;
|
|
245
|
+
return node === this.#element || this.#element.contains(node)
|
|
246
|
+
|| node === this.#surface.element || this.#surface.element.contains(node);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function registry(value) {
|
|
251
|
+
return typeof value?.has === 'function'
|
|
252
|
+
&& typeof value?.enabled === 'function'
|
|
253
|
+
&& typeof value?.state === 'function'
|
|
254
|
+
&& typeof value?.run === 'function';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function configured(element) {
|
|
258
|
+
const value = getComputedStyle(element).getPropertyValue('--u2-rte-toolbar').trim();
|
|
259
|
+
return value ? new Set(value.split(/[\s,]+/).filter(Boolean)) : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function unavailable(element) {
|
|
263
|
+
return getComputedStyle(element).getPropertyValue('--u2-rte-toolbar-unavailable').trim();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function visibleForSelection(surface) {
|
|
267
|
+
const value = getComputedStyle(surface.element).getPropertyValue('--u2-rte-toolbar-when').trim();
|
|
268
|
+
return value !== 'selection' || !!surface.selection && !surface.selection.collapsed;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function state(item, value, disabled) {
|
|
272
|
+
if ('disabled' in item) item.disabled = disabled;
|
|
273
|
+
item.setAttribute('aria-disabled', String(disabled));
|
|
274
|
+
if (value === true || value === false || value === 'mixed') item.setAttribute('aria-pressed', String(value));
|
|
275
|
+
else item.removeAttribute('aria-pressed');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function display(element, visible) {
|
|
279
|
+
const popover = element.hasAttribute('popover') && typeof element.showPopover === 'function';
|
|
280
|
+
if (!visible && popover && element.matches(':popover-open')) element.hidePopover();
|
|
281
|
+
element.hidden = !visible;
|
|
282
|
+
if (visible && popover && !element.matches(':popover-open')) element.showPopover();
|
|
283
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Unstyle policy
|
|
2
|
+
|
|
3
|
+
`unstyle.js` describes presentation cleanup independently of where the HTML
|
|
4
|
+
came from. The same immutable policy can power the selected-content command,
|
|
5
|
+
clean a detached fragment after security sanitizing, or clean only the element
|
|
6
|
+
roots added by native paste/drop.
|
|
7
|
+
|
|
8
|
+
## Contract
|
|
9
|
+
|
|
10
|
+
An `Unstyle` policy contains ordered named levels. A level lists exact
|
|
11
|
+
attributes to remove and/or inline elements to unwrap:
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
const policy = new Unstyle([
|
|
15
|
+
{name: 'classes', attributes: ['class']},
|
|
16
|
+
{name: 'styles', attributes: ['style']},
|
|
17
|
+
{name: 'formatting', elements: ['b', 'strong', 'span']},
|
|
18
|
+
]);
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The default levels are `classes`, `styles`, legacy presentation `attributes`,
|
|
22
|
+
and `formatting`. Removing the last attribute from a `span` also unwraps that
|
|
23
|
+
neutral wrapper. Links, code, revision elements, lists, and tables are not
|
|
24
|
+
dismantled by the default formatting level.
|
|
25
|
+
|
|
26
|
+
`clean(root, {through, map, transaction, preserve})` applies every level up to and
|
|
27
|
+
including `through` to a DOM `Element` or `DocumentFragment`. `map` and
|
|
28
|
+
`transaction` are optional for detached fragments and preserve points plus
|
|
29
|
+
dirty ownership when cleanup runs on live editor content. `preserve` may be a
|
|
30
|
+
set of elements whose own presentation must remain unchanged even when a new
|
|
31
|
+
ancestor is cleaned. Their newly added descendants remain eligible. The
|
|
32
|
+
strength is mandatory: external input must not become more destructive merely
|
|
33
|
+
because an earlier level found nothing.
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
const fragment = sanitizer.sanitize(externalHtml);
|
|
37
|
+
defaultUnstyle.clean(fragment, {through: 'styles'});
|
|
38
|
+
// Insert the detached fragment through an editor transaction afterwards.
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`InputPipeline` uses the mapped live form only on element roots observed during
|
|
42
|
+
one native paste/drop. `ExternalInput` uses the detached form after explicitly
|
|
43
|
+
sanitizing rich data. Its `through` option may resolve a different named level
|
|
44
|
+
per surface or input type.
|
|
45
|
+
|
|
46
|
+
This order is deliberate:
|
|
47
|
+
|
|
48
|
+
1. the browser inserts natively, or a security sanitizer safely parses external HTML;
|
|
49
|
+
2. Unstyle removes unwanted presentation according to application policy;
|
|
50
|
+
3. structural normalization repairs the fragment in its editing context.
|
|
51
|
+
|
|
52
|
+
Unstyle is not a sanitizer and must never parse untrusted strings or precede
|
|
53
|
+
the security boundary.
|
|
54
|
+
|
|
55
|
+
## Invariants
|
|
56
|
+
|
|
57
|
+
- Policies and their levels are immutable and application-replaceable.
|
|
58
|
+
- Levels are cumulative for foreign fragments but individually addressable by
|
|
59
|
+
the selection command.
|
|
60
|
+
- Cleanup preserves text and child order while unwrapping elements.
|
|
61
|
+
- Security, presentation cleanup, and structural validity remain separate.
|
|
62
|
+
|
|
63
|
+
## The ladder
|
|
64
|
+
|
|
65
|
+
The levels are ordered from least to most destructive and do not stop at the
|
|
66
|
+
application's own presentation. Someone who keeps pressing wants more removed,
|
|
67
|
+
so the ladder runs out of rungs only when the selection is plain:
|
|
68
|
+
|
|
69
|
+
| # | level | removes |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| 1 | `styles` | the `style` attribute |
|
|
72
|
+
| 2 | `attributes` | `align`, `bgcolor`, `width` and the other presentational attributes |
|
|
73
|
+
| 3 | `classes` | classes the host has **not** declared as content |
|
|
74
|
+
| 4 | `formatting` | `b`, `font`, `i`, `s`, `span`, `strike`, `u` — unwrapped |
|
|
75
|
+
| 5 | `contentClasses` | the declared classes too, and the wrappers carrying them |
|
|
76
|
+
| 6 | `inline` | the remaining semantic inline elements, links included |
|
|
77
|
+
|
|
78
|
+
Rungs 1–4 are scoped `foreign`: they spare the declared classes. Rung 5 is where
|
|
79
|
+
the application's own presentation goes, so rung 6 no longer needs to protect
|
|
80
|
+
anything.
|
|
81
|
+
|
|
82
|
+
With nothing selected the whole content is the target, so the ladder is a way to
|
|
83
|
+
clean a document as much as a way to clean a passage.
|
|
84
|
+
|
|
85
|
+
A seventh rung, `blocks`, reduces structure to the host's default block. It
|
|
86
|
+
needs the content model and mapped mutation and therefore lives in
|
|
87
|
+
[`../command/`](../command/README.md), not here: this policy also runs on
|
|
88
|
+
detached paste fragments, where there is no surface and no structure to reduce.
|
|
89
|
+
|
|
90
|
+
## Declared content classes
|
|
91
|
+
|
|
92
|
+
`clean(root, {keep})` names the classes the host treats as content. The class
|
|
93
|
+
level then narrows the attribute to those names instead of removing it, and the
|
|
94
|
+
formatting level leaves a wrapper that carries one, because the class applies to
|
|
95
|
+
that wrapper. `removable()`, `strip()`, and `declared()` are exported so the
|
|
96
|
+
selection command applies the identical rule from `--u2-rte-classes`.
|
|
97
|
+
|
|
98
|
+
## TODO
|
|
99
|
+
|
|
100
|
+
- Add separately configured structural levels for converting headings, lists,
|
|
101
|
+
and tables while preserving visible text and boundaries.
|
|
102
|
+
- Add optional class-token and style-property rules before applications need
|
|
103
|
+
finer cleanup than whole attributes.
|