u2ui 1.5.3 → 1.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +487 -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 +553 -0
- package/js/rte/playground/index.html +835 -0
- package/js/rte/rte.js +56 -5
- package/js/rte/run.out +17 -0
- package/js/rte/source.js +12 -0
- package/js/rte/src/browser/README.md +40 -0
- package/js/rte/src/browser/interactive.js +17 -0
- package/js/rte/src/browser/range-rect.js +82 -0
- package/js/rte/src/client/README.md +385 -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 +472 -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 +285 -0
- package/js/rte/src/client/link.js +567 -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 +165 -0
- package/js/rte/src/client/unstyle.js +32 -0
- package/js/rte/src/command/README.md +520 -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 +159 -0
- package/js/rte/src/command/delete.js +119 -0
- package/js/rte/src/command/edit.js +78 -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 +134 -0
- package/js/rte/src/core/README.md +63 -0
- package/js/rte/src/core/core.js +247 -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 +173 -0
- package/js/rte/src/input/external-input.js +130 -0
- package/js/rte/src/input/input-pipeline.js +459 -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 +108 -0
- package/js/rte/src/sanitize/native.js +43 -0
- package/js/rte/src/sanitize/policy.js +206 -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 +271 -0
- package/js/rte/src/ui/caret.js +17 -0
- package/js/rte/src/ui/chrome.js +214 -0
- package/js/rte/src/ui/handles.js +179 -0
- package/js/rte/src/ui/highlight.js +86 -0
- package/js/rte/src/ui/place.js +25 -0
- package/js/rte/src/ui/toolbar.js +258 -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 +2 -2
- package/u2/plan-scoped-refactoring.md +133 -303
- package/u2/projects.json +18 -2
- package/u2/tools/update.repos.json.js +1 -1
- 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,472 @@
|
|
|
1
|
+
import {Commands} from '../command/commands.js';
|
|
2
|
+
import {deleteBackward, deleteForward} from '../command/delete.js';
|
|
3
|
+
import {insertFragment} from '../command/fragment.js';
|
|
4
|
+
import {enter, lineBreak} from '../command/enter.js';
|
|
5
|
+
import {History} from '../history/history.js';
|
|
6
|
+
import {PendingMarks} from '../command/pending-marks.js';
|
|
7
|
+
import {InputPipeline} from '../input/input-pipeline.js';
|
|
8
|
+
import {history as historyModule} from './history.js';
|
|
9
|
+
import {marks} from './marks.js';
|
|
10
|
+
import {structure} from './structure.js';
|
|
11
|
+
import {isPlainTextHost} from '../selection/ownership/ownership.js';
|
|
12
|
+
import {Chrome} from '../ui/chrome.js';
|
|
13
|
+
import {SelectionHighlight} from '../ui/highlight.js';
|
|
14
|
+
import {place} from '../ui/place.js';
|
|
15
|
+
import {Toolbar} from '../ui/toolbar.js';
|
|
16
|
+
|
|
17
|
+
const STYLE = `
|
|
18
|
+
#toolbar {
|
|
19
|
+
align-items: center;
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-wrap: wrap;
|
|
22
|
+
gap: .17em;
|
|
23
|
+
line-height: 1;
|
|
24
|
+
max-inline-size: 24.5em;
|
|
25
|
+
transition: left .14s, top .14s;
|
|
26
|
+
|
|
27
|
+
&[data-placing] { transition: none; }
|
|
28
|
+
@media (prefers-reduced-motion: reduce) { & { transition: none; } }
|
|
29
|
+
button, select { min-block-size: 2.29em; }
|
|
30
|
+
button {
|
|
31
|
+
border-radius: .34em;
|
|
32
|
+
min-inline-size: 2.29em;
|
|
33
|
+
&:hover:not(:disabled) { background: color-mix(in srgb, Highlight 16%, transparent); }
|
|
34
|
+
&[aria-pressed=true] { background: color-mix(in srgb, Highlight 28%, transparent); }
|
|
35
|
+
}
|
|
36
|
+
[data-command=bold] { font-weight: 700; }
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const CLIENT = Symbol.for('u2.rte.editor');
|
|
41
|
+
|
|
42
|
+
// Minimal convention client for the default editor stack. It exposes the
|
|
43
|
+
// per-surface registry and a narrow registration point for optional command
|
|
44
|
+
// modules without turning the client into a plugin framework.
|
|
45
|
+
export class Editor {
|
|
46
|
+
#core;
|
|
47
|
+
#document;
|
|
48
|
+
#records = new Map();
|
|
49
|
+
#modules = new Map();
|
|
50
|
+
#setups = new Map();
|
|
51
|
+
#sources = new WeakMap();
|
|
52
|
+
#chrome = null;
|
|
53
|
+
#highlight = null;
|
|
54
|
+
#toolbar = null;
|
|
55
|
+
#element = null;
|
|
56
|
+
#dynamic = new Map();
|
|
57
|
+
#controller;
|
|
58
|
+
#connected = true;
|
|
59
|
+
|
|
60
|
+
constructor(core) {
|
|
61
|
+
if (!core?.root || !Array.isArray(core.surfaces) || typeof core?.addEventListener !== 'function') {
|
|
62
|
+
throw new TypeError('An editor client requires an editor core');
|
|
63
|
+
}
|
|
64
|
+
if (core[CLIENT]) throw new RangeError('An editor core already has a convention client');
|
|
65
|
+
this.#core = core;
|
|
66
|
+
this.#document = core.root.nodeType === Node.DOCUMENT_NODE ? core.root : core.root.ownerDocument;
|
|
67
|
+
Object.defineProperty(core, CLIENT, {value: this, configurable: true});
|
|
68
|
+
this.#controller = new this.#document.defaultView.AbortController();
|
|
69
|
+
try {
|
|
70
|
+
const listen = {signal: this.#controller.signal};
|
|
71
|
+
core.addEventListener('u2-rte-add', this.#add, listen);
|
|
72
|
+
core.addEventListener('u2-rte-activechange', this.#activeChange, listen);
|
|
73
|
+
core.addEventListener('u2-rte-dispose', this.#coreDispose, listen);
|
|
74
|
+
for (const module of [historyModule, marks, structure]) this.add(module);
|
|
75
|
+
for (const surface of core.surfaces) this.#setup(surface);
|
|
76
|
+
this.refresh();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
this.dispose();
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get core() { return this.#core; }
|
|
84
|
+
|
|
85
|
+
// One shadow root for everything this editor draws. It is made on first use,
|
|
86
|
+
// so an editor that never shows anything adds nothing to the document.
|
|
87
|
+
get chrome() {
|
|
88
|
+
if (!this.#chrome) {
|
|
89
|
+
this.#chrome = new Chrome(this.#core.root, {name: 'editor'});
|
|
90
|
+
this.#core.retain(this.#chrome.element);
|
|
91
|
+
// What the chrome takes the focus away from has to stay visible.
|
|
92
|
+
this.#highlight = new SelectionHighlight(this.#core);
|
|
93
|
+
}
|
|
94
|
+
return this.#chrome;
|
|
95
|
+
}
|
|
96
|
+
get toolbar() { return this.#toolbar; }
|
|
97
|
+
get highlight() { return this.#highlight; }
|
|
98
|
+
get connected() { return this.#connected; }
|
|
99
|
+
|
|
100
|
+
commands(surface) {
|
|
101
|
+
return this.#records.get(surface)?.commands || null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// What this editor offers, by control name, so an application can build its
|
|
105
|
+
// own list of what `--u2-rte-toolbar` may choose from without guessing.
|
|
106
|
+
get controls() {
|
|
107
|
+
const result = [];
|
|
108
|
+
for (const module of this.#modules.values()) {
|
|
109
|
+
for (const control of module.toolbar) {
|
|
110
|
+
result.push(Object.freeze({
|
|
111
|
+
name: control.name || control.command,
|
|
112
|
+
command: control.command,
|
|
113
|
+
label: control.label,
|
|
114
|
+
module: module.name,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
history(surface) {
|
|
122
|
+
return this.#records.get(surface)?.history || null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
add(source) {
|
|
126
|
+
if (!this.#connected) {
|
|
127
|
+
throw new this.#document.defaultView.DOMException('The editor client is disposed', 'InvalidStateError');
|
|
128
|
+
}
|
|
129
|
+
const known = source && typeof source === 'object' ? this.#sources.get(source) : null;
|
|
130
|
+
if (known && this.#modules.has(known)) return this;
|
|
131
|
+
const module = validModule(source);
|
|
132
|
+
const current = this.#modules.get(module.name);
|
|
133
|
+
if (current) throw new RangeError(`Editor module already exists: ${module.name}`);
|
|
134
|
+
const installed = [];
|
|
135
|
+
let setup = null;
|
|
136
|
+
try {
|
|
137
|
+
setup = lifecycle(module.setup?.(Object.freeze({
|
|
138
|
+
editor: this,
|
|
139
|
+
core: this.#core,
|
|
140
|
+
root: this.#core.root,
|
|
141
|
+
chrome: this.chrome,
|
|
142
|
+
})), 'Editor module setup()');
|
|
143
|
+
for (const record of this.#records.values()) {
|
|
144
|
+
this.#install(record, module);
|
|
145
|
+
installed.push(record);
|
|
146
|
+
}
|
|
147
|
+
} catch (error) {
|
|
148
|
+
for (const record of installed) this.#uninstall(record, module.name);
|
|
149
|
+
setup?.dispose();
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
this.#modules.set(module.name, module);
|
|
153
|
+
if (setup) this.#setups.set(module.name, setup);
|
|
154
|
+
this.#sources.set(source, module.name);
|
|
155
|
+
this.#append(module);
|
|
156
|
+
this.refresh();
|
|
157
|
+
return this;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
delete(value) {
|
|
161
|
+
const name = typeof value === 'string' ? value.trim() : this.#sources.get(value);
|
|
162
|
+
const module = this.#modules.get(name);
|
|
163
|
+
if (!module) return false;
|
|
164
|
+
for (const record of this.#records.values()) {
|
|
165
|
+
this.#uninstall(record, name);
|
|
166
|
+
// A removed formatting command must not affect the next text input.
|
|
167
|
+
// Module topology changes therefore cancel all transient caret marks.
|
|
168
|
+
record.pending.clear();
|
|
169
|
+
}
|
|
170
|
+
this.#modules.delete(name);
|
|
171
|
+
this.#setups.get(name)?.dispose();
|
|
172
|
+
this.#setups.delete(name);
|
|
173
|
+
this.#sources.delete(module.source);
|
|
174
|
+
if (this.#element) {
|
|
175
|
+
for (const item of [...this.#element.children]) {
|
|
176
|
+
if (item.dataset.u2RteModule !== name) continue;
|
|
177
|
+
this.#dynamic.delete(item);
|
|
178
|
+
item.remove();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.refresh();
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
refresh() {
|
|
186
|
+
if (!this.#connected) return false;
|
|
187
|
+
const surface = this.#core.active;
|
|
188
|
+
if (this.#records.has(surface) && surface.config.ui === 'roaming') this.#ensureToolbar();
|
|
189
|
+
this.#chrome?.follow(this.#records.has(surface) ? surface.element : null);
|
|
190
|
+
// A control whose choices come from the host's configuration is filled
|
|
191
|
+
// for the surface that is about to be shown, not once at registration.
|
|
192
|
+
for (const [select, choices] of this.#dynamic) {
|
|
193
|
+
options(select, this.#records.has(surface) ? choices(surface) : []);
|
|
194
|
+
}
|
|
195
|
+
return this.#toolbar?.refresh() || false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
dispose() {
|
|
199
|
+
if (!this.#connected) return;
|
|
200
|
+
this.#controller.abort();
|
|
201
|
+
for (const surface of [...this.#records.keys()]) this.#drop(surface);
|
|
202
|
+
this.#toolbar?.dispose();
|
|
203
|
+
if (this.#chrome) this.#core.release(this.#chrome.element);
|
|
204
|
+
// The chrome reference is kept: its getter must not build a new one for
|
|
205
|
+
// a client that is gone.
|
|
206
|
+
this.#chrome?.dispose();
|
|
207
|
+
this.#highlight?.dispose();
|
|
208
|
+
this.#highlight = null;
|
|
209
|
+
this.#dynamic.clear();
|
|
210
|
+
this.#toolbar = null;
|
|
211
|
+
this.#element = null;
|
|
212
|
+
for (const setup of [...this.#setups.values()].reverse()) setup.dispose();
|
|
213
|
+
this.#setups.clear();
|
|
214
|
+
this.#modules.clear();
|
|
215
|
+
if (this.#core[CLIENT] === this) delete this.#core[CLIENT];
|
|
216
|
+
this.#connected = false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
[Symbol.dispose]() {
|
|
220
|
+
this.dispose();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#setup(surface) {
|
|
224
|
+
if (this.#records.has(surface) || isPlainTextHost(surface.element)) return null;
|
|
225
|
+
const pending = new PendingMarks(surface);
|
|
226
|
+
const history = new History(surface);
|
|
227
|
+
const commands = new Commands(surface, {commands: {
|
|
228
|
+
deleteBackward,
|
|
229
|
+
deleteForward,
|
|
230
|
+
enter,
|
|
231
|
+
lineBreak,
|
|
232
|
+
insertText: pending.insertText,
|
|
233
|
+
// Prepared content at the selection: what a host inserts itself, and
|
|
234
|
+
// what a key gets that its host answered instead of the text.
|
|
235
|
+
insert: insertFragment,
|
|
236
|
+
}});
|
|
237
|
+
const controller = new this.#document.defaultView.AbortController();
|
|
238
|
+
const record = {surface, pending, history, commands, pipeline: null, controller, modules: new Map()};
|
|
239
|
+
try {
|
|
240
|
+
for (const module of this.#modules.values()) this.#install(record, module);
|
|
241
|
+
record.pipeline = new InputPipeline(surface, {commands});
|
|
242
|
+
surface.addEventListener('u2-rte-disconnect', () => this.#drop(surface), {signal: controller.signal});
|
|
243
|
+
this.#records.set(surface, record);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
for (const name of [...record.modules.keys()].reverse()) this.#uninstall(record, name);
|
|
246
|
+
record.pipeline?.dispose();
|
|
247
|
+
history.dispose();
|
|
248
|
+
pending.dispose();
|
|
249
|
+
controller.abort();
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
return commands;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#drop(surface) {
|
|
256
|
+
const record = this.#records.get(surface);
|
|
257
|
+
if (!record) return false;
|
|
258
|
+
record.controller.abort();
|
|
259
|
+
for (const name of [...record.modules.keys()].reverse()) this.#uninstall(record, name);
|
|
260
|
+
record.pipeline?.dispose();
|
|
261
|
+
record.history.dispose();
|
|
262
|
+
record.pending.dispose();
|
|
263
|
+
this.#records.delete(surface);
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#install(record, module) {
|
|
268
|
+
const context = Object.freeze({
|
|
269
|
+
editor: this,
|
|
270
|
+
surface: record.surface,
|
|
271
|
+
pending: record.pending,
|
|
272
|
+
history: record.history,
|
|
273
|
+
commands: record.commands,
|
|
274
|
+
});
|
|
275
|
+
const result = module.commands ? module.commands(context) : {};
|
|
276
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
277
|
+
throw new TypeError('Editor module commands() must return an object');
|
|
278
|
+
}
|
|
279
|
+
const entries = Object.entries(result);
|
|
280
|
+
const inputs = new Map();
|
|
281
|
+
for (const [name] of entries) {
|
|
282
|
+
if (record.commands.has(name)) throw new RangeError(`Editor command already exists: ${name}`);
|
|
283
|
+
}
|
|
284
|
+
for (const [name, command] of entries) {
|
|
285
|
+
for (const inputType of command?.inputTypes || []) {
|
|
286
|
+
const owner = record.commands.input(inputType) || inputs.get(inputType);
|
|
287
|
+
if (owner && owner !== name) {
|
|
288
|
+
throw new RangeError(`Editor input type already belongs to ${owner}: ${inputType}`);
|
|
289
|
+
}
|
|
290
|
+
inputs.set(inputType, name);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const names = [];
|
|
294
|
+
let attached = null;
|
|
295
|
+
try {
|
|
296
|
+
for (const [name, command] of entries) {
|
|
297
|
+
record.commands.add(name, command);
|
|
298
|
+
names.push(name);
|
|
299
|
+
}
|
|
300
|
+
attached = lifecycle(module.attach?.(context), 'Editor module attach()');
|
|
301
|
+
} catch (error) {
|
|
302
|
+
attached?.dispose();
|
|
303
|
+
for (const name of names) record.commands.delete(name);
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
record.modules.set(module.name, {names, attached});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#uninstall(record, name) {
|
|
310
|
+
const installed = record.modules.get(name);
|
|
311
|
+
if (!installed) return false;
|
|
312
|
+
installed.attached?.dispose();
|
|
313
|
+
for (const command of installed.names) record.commands.delete(command);
|
|
314
|
+
return record.modules.delete(name);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#ensureToolbar() {
|
|
318
|
+
if (this.#toolbar) return this.#toolbar;
|
|
319
|
+
const element = this.chrome.part('toolbar', STYLE);
|
|
320
|
+
element.className = 'panel';
|
|
321
|
+
element.setAttribute('aria-label', 'Text formatting');
|
|
322
|
+
this.#element = element;
|
|
323
|
+
for (const module of this.#modules.values()) this.#append(module);
|
|
324
|
+
this.#toolbar = new Toolbar(this.#core, element, {
|
|
325
|
+
commands: surface => this.commands(surface),
|
|
326
|
+
place,
|
|
327
|
+
});
|
|
328
|
+
return this.#toolbar;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#append(module) {
|
|
332
|
+
if (!this.#element) return;
|
|
333
|
+
for (const control of module.toolbar || []) {
|
|
334
|
+
if (control.type === 'select') {
|
|
335
|
+
const select = this.#document.createElement('select');
|
|
336
|
+
select.dataset.commandValue = control.command;
|
|
337
|
+
select.dataset.control = control.name;
|
|
338
|
+
select.dataset.u2RteModule = module.name;
|
|
339
|
+
select.setAttribute('aria-label', control.label);
|
|
340
|
+
select.title = control.label;
|
|
341
|
+
const placeholder = this.#document.createElement('option');
|
|
342
|
+
placeholder.textContent = control.label;
|
|
343
|
+
placeholder.value = '';
|
|
344
|
+
placeholder.disabled = true;
|
|
345
|
+
select.append(placeholder);
|
|
346
|
+
if (typeof control.options === 'function') this.#dynamic.set(select, control.options);
|
|
347
|
+
else options(select, control.options);
|
|
348
|
+
this.#element.append(select);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const item = this.#document.createElement('button');
|
|
352
|
+
item.type = 'button';
|
|
353
|
+
item.dataset.command = control.command;
|
|
354
|
+
if (control.name) item.dataset.control = control.name;
|
|
355
|
+
item.dataset.u2RteModule = module.name;
|
|
356
|
+
if (control.shortcut) item.dataset.shortcut = control.shortcut;
|
|
357
|
+
if (control.state) item.setAttribute('data-state', '');
|
|
358
|
+
item.setAttribute('aria-label', control.label);
|
|
359
|
+
item.title = control.shortcut ? `${control.label} (${keyLabel(control.shortcut)})` : control.label;
|
|
360
|
+
item.textContent = control.text;
|
|
361
|
+
this.#element.append(item);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#add = event => this.#setup(event.detail.surface);
|
|
366
|
+
#activeChange = () => this.refresh();
|
|
367
|
+
#coreDispose = () => this.dispose();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function validModule(module) {
|
|
371
|
+
if (!module || typeof module !== 'object') throw new TypeError('An editor module must be an object');
|
|
372
|
+
if (typeof module.name !== 'string' || !module.name.trim()) {
|
|
373
|
+
throw new TypeError('An editor module name must be a non-empty string');
|
|
374
|
+
}
|
|
375
|
+
for (const name of ['commands', 'setup', 'attach']) {
|
|
376
|
+
if (module[name] !== undefined && typeof module[name] !== 'function') {
|
|
377
|
+
throw new TypeError(`Editor module ${name} must be a function`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (!module.commands && !module.setup && !module.attach) {
|
|
381
|
+
throw new TypeError('An editor module must implement commands(), setup(), or attach()');
|
|
382
|
+
}
|
|
383
|
+
if (module.toolbar !== undefined && !Array.isArray(module.toolbar)) {
|
|
384
|
+
throw new TypeError('An editor module toolbar must be an array');
|
|
385
|
+
}
|
|
386
|
+
const toolbar = (module.toolbar || []).map(control => {
|
|
387
|
+
if (!control || typeof control !== 'object') throw new TypeError('An editor toolbar control must be an object');
|
|
388
|
+
const type = control.type || 'button';
|
|
389
|
+
if (type === 'select') {
|
|
390
|
+
for (const property of ['name', 'command', 'label']) {
|
|
391
|
+
if (typeof control[property] !== 'string' || !control[property].trim()) {
|
|
392
|
+
throw new TypeError(`An editor toolbar select requires ${property}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (typeof control.options === 'function') {
|
|
396
|
+
return Object.freeze({...control, type});
|
|
397
|
+
}
|
|
398
|
+
if (!Array.isArray(control.options) || !control.options.length) {
|
|
399
|
+
throw new TypeError('An editor toolbar select requires options');
|
|
400
|
+
}
|
|
401
|
+
const choices = control.options.map(option => {
|
|
402
|
+
if (!option || typeof option !== 'object') {
|
|
403
|
+
throw new TypeError('An editor toolbar option must be an object');
|
|
404
|
+
}
|
|
405
|
+
for (const property of ['value', 'label']) {
|
|
406
|
+
if (typeof option[property] !== 'string' || !option[property].trim()) {
|
|
407
|
+
throw new TypeError(`An editor toolbar option requires ${property}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return Object.freeze({...option});
|
|
411
|
+
});
|
|
412
|
+
return Object.freeze({...control, type, options: Object.freeze(choices)});
|
|
413
|
+
}
|
|
414
|
+
if (type !== 'button') throw new TypeError(`Unknown editor toolbar control: ${type}`);
|
|
415
|
+
for (const property of ['command', 'label', 'text']) {
|
|
416
|
+
if (typeof control[property] !== 'string' || !control[property].trim()) {
|
|
417
|
+
throw new TypeError(`An editor toolbar control requires ${property}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (control.shortcut !== undefined && (typeof control.shortcut !== 'string' || !control.shortcut.trim())) {
|
|
421
|
+
throw new TypeError('An editor toolbar shortcut must be a non-empty string');
|
|
422
|
+
}
|
|
423
|
+
if (control.name !== undefined && (typeof control.name !== 'string' || !control.name.trim())) {
|
|
424
|
+
throw new TypeError('An editor toolbar control name must be a non-empty string');
|
|
425
|
+
}
|
|
426
|
+
if (control.state !== undefined && typeof control.state !== 'boolean') {
|
|
427
|
+
throw new TypeError('An editor toolbar state flag must be boolean');
|
|
428
|
+
}
|
|
429
|
+
return Object.freeze({...control, type});
|
|
430
|
+
});
|
|
431
|
+
return Object.freeze({
|
|
432
|
+
name: module.name.trim(),
|
|
433
|
+
commands: module.commands,
|
|
434
|
+
setup: module.setup,
|
|
435
|
+
attach: module.attach,
|
|
436
|
+
toolbar: Object.freeze(toolbar),
|
|
437
|
+
source: module,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function lifecycle(value, label) {
|
|
442
|
+
if (value == null) return null;
|
|
443
|
+
if (typeof value !== 'object' || typeof value.dispose !== 'function') {
|
|
444
|
+
throw new TypeError(`${label} must return an object with dispose()`);
|
|
445
|
+
}
|
|
446
|
+
return value;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// `ctrl+shift+8` reads as `Ctrl+Shift+8`. Ctrl stands for Command on Apple
|
|
450
|
+
// keyboards, which is what the registry matches.
|
|
451
|
+
function keyLabel(shortcut) {
|
|
452
|
+
return shortcut.split(' ')[0].split('+')
|
|
453
|
+
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
454
|
+
.join('+');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Rewrites a select's choices, keeping its disabled placeholder and leaving the
|
|
458
|
+
// element alone when nothing changed.
|
|
459
|
+
function options(select, choices) {
|
|
460
|
+
const current = [...select.options].slice(1);
|
|
461
|
+
if (current.length === choices.length
|
|
462
|
+
&& current.every((option, index) => option.value === choices[index].value
|
|
463
|
+
&& option.textContent === choices[index].label)) return false;
|
|
464
|
+
for (const option of current) option.remove();
|
|
465
|
+
for (const choice of choices) {
|
|
466
|
+
const option = select.ownerDocument.createElement('option');
|
|
467
|
+
option.textContent = choice.label;
|
|
468
|
+
option.value = choice.value;
|
|
469
|
+
select.append(option);
|
|
470
|
+
}
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {insertFragment} from '../command/fragment.js';
|
|
2
|
+
import {ExternalInput} from '../input/external-input.js';
|
|
3
|
+
import {defaultUnstyle} from '../unstyle/unstyle.js';
|
|
4
|
+
|
|
5
|
+
// Optional convention-client bridge. The sanitizer is deliberately mandatory:
|
|
6
|
+
// choosing a security boundary is application policy until one safe adapter is
|
|
7
|
+
// available in every target engine.
|
|
8
|
+
export function externalInputs({
|
|
9
|
+
sanitizer,
|
|
10
|
+
unstyle = defaultUnstyle,
|
|
11
|
+
through,
|
|
12
|
+
name = 'external-input',
|
|
13
|
+
command = 'insertFragment',
|
|
14
|
+
} = {}) {
|
|
15
|
+
if (typeof sanitizer?.sanitize !== 'function') {
|
|
16
|
+
throw new TypeError('External input module requires a sanitizer');
|
|
17
|
+
}
|
|
18
|
+
if (unstyle !== null && typeof unstyle?.clean !== 'function') {
|
|
19
|
+
throw new TypeError('External input module requires an Unstyle policy or null');
|
|
20
|
+
}
|
|
21
|
+
if (through === undefined) through = unstyle ? importLevel : null;
|
|
22
|
+
if (through !== null && typeof through !== 'string' && typeof through !== 'function') {
|
|
23
|
+
throw new TypeError('External input module cleanup level must be a string, function, or null');
|
|
24
|
+
}
|
|
25
|
+
if (through !== null && !unstyle) {
|
|
26
|
+
throw new TypeError('External input module cleanup level requires an Unstyle policy');
|
|
27
|
+
}
|
|
28
|
+
for (const [property, value] of Object.entries({name, command})) {
|
|
29
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
30
|
+
throw new TypeError(`External input module ${property} must be a non-empty string`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
name = name.trim();
|
|
34
|
+
command = command.trim();
|
|
35
|
+
const registered = Object.freeze({[command]: insertFragment});
|
|
36
|
+
return Object.freeze({
|
|
37
|
+
name,
|
|
38
|
+
commands: () => registered,
|
|
39
|
+
attach({surface, commands}) {
|
|
40
|
+
return new ExternalInput(surface, {commands, sanitizer, unstyle, through, command});
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function importLevel({surface}) {
|
|
46
|
+
return surface.config.importUnstyle;
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Undo and redo as one convention module. The commands come from the surface's
|
|
2
|
+
// own `History`; this module only names them and their controls.
|
|
3
|
+
//
|
|
4
|
+
// The keys are declared on the commands themselves, so they work whether or not
|
|
5
|
+
// these controls are on screen.
|
|
6
|
+
export const history = Object.freeze({
|
|
7
|
+
name: 'history',
|
|
8
|
+
commands: ({history}) => history.commands,
|
|
9
|
+
toolbar: Object.freeze([
|
|
10
|
+
Object.freeze({command: 'undo', label: 'Undo', text: '↶', shortcut: 'ctrl+z'}),
|
|
11
|
+
Object.freeze({command: 'redo', label: 'Redo', text: '↷', shortcut: 'ctrl+y ctrl+shift+z'}),
|
|
12
|
+
]),
|
|
13
|
+
});
|