@openpresentation/opf-editor 0.29.1 → 0.31.0
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/README.md +19 -0
- package/dist/deck-title.d.ts +59 -0
- package/dist/deck-title.js +184 -0
- package/dist/design-menu.d.ts +90 -0
- package/dist/design-menu.js +462 -0
- package/dist/editor-expand.d.ts +102 -0
- package/dist/editor-expand.js +370 -0
- package/dist/element-define.d.ts +2 -0
- package/dist/element-define.js +7 -0
- package/dist/element.d.ts +94 -0
- package/dist/element.js +585 -0
- package/dist/icons.d.ts +34 -0
- package/dist/icons.js +88 -0
- package/dist/presenter.d.ts +3 -0
- package/dist/presenter.js +6 -2
- package/dist/react.d.ts +9 -0
- package/dist/react.js +18 -0
- package/dist/review-pill.d.ts +83 -0
- package/dist/review-pill.js +137 -0
- package/dist/shortcuts-sheet.d.ts +82 -0
- package/dist/shortcuts-sheet.js +230 -0
- package/dist/slide-navigator.d.ts +3 -0
- package/dist/slide-navigator.js +6 -17
- package/dist/studio.js +13 -16
- package/dist/styles.css +901 -36
- package/dist/styles.layered.css +7782 -0
- package/dist/svelte.d.ts +16 -0
- package/dist/svelte.js +32 -0
- package/dist/thumbnail-strip.d.ts +59 -0
- package/dist/thumbnail-strip.js +399 -6
- package/dist/toolbar-actions.d.ts +59 -0
- package/dist/toolbar-actions.js +136 -0
- package/dist/toolbar.d.ts +113 -0
- package/dist/toolbar.js +398 -0
- package/dist/workspace-parts.js +8 -0
- package/dist/workspace-shell.js +93 -33
- package/dist/workspace-theme.js +11 -0
- package/dist/workspace.d.ts +143 -7
- package/dist/workspace.js +603 -27
- package/package.json +57 -9
package/dist/element.js
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
// CE-30: `<opf-editor>`, the editor as a custom element: one tag, no framework, for a CMS, WordPress or plain HTML.
|
|
2
|
+
//
|
|
3
|
+
// import { defineOpfEditorElement } from "@openpresentation/opf-editor/element";
|
|
4
|
+
// defineOpfEditorElement(); // or: import "@openpresentation/opf-editor/element/define";
|
|
5
|
+
// <opf-editor layout="embed" src="/decks/q3.json"></opf-editor>
|
|
6
|
+
//
|
|
7
|
+
// The element wraps `createOpfEditor` (workspace.js) the way react.js does: the workspace module is imported on demand (a page that defines the
|
|
8
|
+
// element and never uses the tag loads none of it), the editor is created once the element is connected and the page has parsed its content,
|
|
9
|
+
// `destroy()` runs when it is removed, and the editor handle is the single owner of everything it mounted. Importing this module has no side
|
|
10
|
+
// effect and runs on a server (no `HTMLElement` is touched until `defineOpfEditorElement` is called). The editor mounts in a `div` inside the
|
|
11
|
+
// element, so inline `<script type="application/json">` children stay where the author put them. No network is used unless the page sets `src`.
|
|
12
|
+
|
|
13
|
+
import { readDeckFile } from "./read-deck-file.js";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TAG = "opf-editor";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every event of the workspace handle, re-dispatched on the element as a `CustomEvent` named `opf-<name>` (camelCase to kebab-case). A drift test
|
|
19
|
+
* keeps this list a superset of the keys of `OpfEditorEvents` in workspace.d.ts: the chrome's events (CE-28) `deckName` (`opf-deck-name`),
|
|
20
|
+
* `expand`, `dirty`, `save` and `review-open` among them.
|
|
21
|
+
*/
|
|
22
|
+
export const OPF_EDITOR_EVENTS = Object.freeze([
|
|
23
|
+
"mounted", "ready", "change", "selection", "zoom", "layout", "command", "layoutStudio", "cursor", "slide", "view", "source", "checking", "fonts",
|
|
24
|
+
"geometry", "sync", "render", "draft", "commit", "cancel", "status", "error", "kept", "rename", "export", "persistence", "review", "review-goto",
|
|
25
|
+
"review-focus", "panel", "destroy", "deckName", "expand", "dirty", "save", "review-open",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** The DOM event type of a workspace event: `layoutStudio` becomes `opf-layout-studio`. */
|
|
29
|
+
export const opfEventType = (name) => `opf-${String(name).replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
|
|
30
|
+
|
|
31
|
+
const OBSERVED = ["layout", "toolbar", "readonly", "theme", "density", "slide", "src", "fonts"];
|
|
32
|
+
const SCRIPT_TYPES = [
|
|
33
|
+
{ name: "deck.json", test: (type) => type === "application/json" || /\+json$/.test(type) },
|
|
34
|
+
{ name: "deck.yaml", test: (type) => /^(?:application|text)\/(?:x-)?yaml$/.test(type) || /\+yaml$/.test(type) },
|
|
35
|
+
{ name: "deck.md", test: (type) => type === "text/markdown" || type === "text/x-markdown" },
|
|
36
|
+
];
|
|
37
|
+
const HOST_ATTRIBUTE = "data-opf-element-host";
|
|
38
|
+
|
|
39
|
+
// The workspace chunk and the default fonts are loaded once per page and shared by every element.
|
|
40
|
+
let workspaceModule;
|
|
41
|
+
const loadWorkspace = () => {
|
|
42
|
+
workspaceModule ??= import("./workspace.js");
|
|
43
|
+
workspaceModule.catch(() => { workspaceModule = undefined; });
|
|
44
|
+
return workspaceModule;
|
|
45
|
+
};
|
|
46
|
+
// A font root (the attribute `fonts="/opf-fonts"`, or a string given as `fonts`) is loaded the way `<opf-deck fonts>` loads it: the renderer's
|
|
47
|
+
// `loadPreviewFonts`, on demand, one shared handle per root. The renderer is an optional peer, so a page that gives a handle never loads it.
|
|
48
|
+
const loadFontRoot = (root) =>
|
|
49
|
+
import("@openpresentation/opf-render/preview-fonts").then(({ loadPreviewFonts }) => loadPreviewFonts(root), (cause) => {
|
|
50
|
+
throw new Error("A font root needs @openpresentation/opf-render (an optional peer of the editor): install it, or give the element a fonts handle.", { cause });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const styled = new WeakMap();
|
|
54
|
+
/** The default box of the element, once per document or shadow root. `:where()` keeps the specificity at zero, so any page rule wins. */
|
|
55
|
+
function ensureStyle(element, tag) {
|
|
56
|
+
const root = element.getRootNode?.();
|
|
57
|
+
const target = root && root.nodeType === 11 ? root : element.ownerDocument?.head;
|
|
58
|
+
if (!target) return;
|
|
59
|
+
const done = styled.get(target) ?? new Set();
|
|
60
|
+
if (done.has(tag)) return;
|
|
61
|
+
done.add(tag);
|
|
62
|
+
styled.set(target, done);
|
|
63
|
+
const style = element.ownerDocument.createElement("style");
|
|
64
|
+
style.setAttribute("data-opf-element-style", tag);
|
|
65
|
+
style.textContent = `:where(${tag}){display:block;position:relative;height:600px;min-height:240px}:where(${tag}[hidden]){display:none}:where(${tag})>[${HOST_ATTRIBUTE}]{height:100%;min-height:0}`;
|
|
66
|
+
target.prepend(style);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function shallowEqual(a, b) {
|
|
70
|
+
if (a === b) return true;
|
|
71
|
+
if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
|
|
72
|
+
const keys = Object.keys(a);
|
|
73
|
+
return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function deckNameFor(src, contentType = "") {
|
|
77
|
+
let last = "";
|
|
78
|
+
try {
|
|
79
|
+
last = decodeURIComponent(new URL(src, "http://x/").pathname.split("/").pop() ?? "");
|
|
80
|
+
} catch {
|
|
81
|
+
last = "";
|
|
82
|
+
}
|
|
83
|
+
if (/\.(?:json|opf|ya?ml|md|markdown)$/i.test(last)) return last;
|
|
84
|
+
const type = contentType.split(";")[0].trim().toLowerCase();
|
|
85
|
+
const kind = SCRIPT_TYPES.find((entry) => entry.test(type));
|
|
86
|
+
return kind ? kind.name : last || "deck.json";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function slideIndexOf(value) {
|
|
90
|
+
const index = Number(value);
|
|
91
|
+
if (!Number.isInteger(index) || index < 0) throw new RangeError(`slide must be a whole number from 0 (the first slide), not "${value}".`);
|
|
92
|
+
return index;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseToolbar(value) {
|
|
96
|
+
if (value === null || value.trim() === "") return undefined;
|
|
97
|
+
if (value.trim().toLowerCase() === "none") return false;
|
|
98
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Define the custom element (once) and return its class. `defaults` are the `fonts` and `catalogs` of every element of this name; a property on
|
|
103
|
+
* the element wins. Calling it again returns the class already defined under that name.
|
|
104
|
+
*/
|
|
105
|
+
export function defineOpfEditorElement(name = DEFAULT_TAG, defaults = {}) {
|
|
106
|
+
if (typeof customElements === "undefined" || typeof HTMLElement === "undefined") throw new TypeError("defineOpfEditorElement needs a browser (customElements).");
|
|
107
|
+
const existing = customElements.get(name);
|
|
108
|
+
if (existing) {
|
|
109
|
+
if (existing.opfEditorElement) return existing;
|
|
110
|
+
throw new Error(`"${name}" is already defined by another element.`);
|
|
111
|
+
}
|
|
112
|
+
const { fonts: defaultFontsOption, catalogs: defaultCatalogs } = defaults ?? {};
|
|
113
|
+
|
|
114
|
+
class OpfEditorElement extends HTMLElement {
|
|
115
|
+
static opfEditorElement = true;
|
|
116
|
+
static get observedAttributes() {
|
|
117
|
+
return OBSERVED;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#connected = false;
|
|
121
|
+
#editor = null;
|
|
122
|
+
#host = null;
|
|
123
|
+
#isReady = false;
|
|
124
|
+
#starting = false;
|
|
125
|
+
#generation = 0;
|
|
126
|
+
#leave = 0;
|
|
127
|
+
#loaded = null;
|
|
128
|
+
#explicit = false;
|
|
129
|
+
#pending = null;
|
|
130
|
+
#fetching = null;
|
|
131
|
+
#cancelParse = null;
|
|
132
|
+
#remountQueued = false;
|
|
133
|
+
#upgraded = false;
|
|
134
|
+
#deferred = null;
|
|
135
|
+
#options = {};
|
|
136
|
+
#fonts;
|
|
137
|
+
#catalogs;
|
|
138
|
+
|
|
139
|
+
constructor() {
|
|
140
|
+
super();
|
|
141
|
+
this.#deferred = this.#newDeferred();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- properties ---------------------------------------------------------------------------------------------------------------
|
|
145
|
+
/** The editor handle once it is created, else `null`. */
|
|
146
|
+
get editor() {
|
|
147
|
+
return this.#editor;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Settles with the handle when the editor has mounted and drawn; rejects when it could not be created. Read it again after a remount. */
|
|
151
|
+
get ready() {
|
|
152
|
+
return this.#deferred.promise;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The document: live once the editor is mounted, else the one it will open. Set an object, or JSON, YAML or Markdown text. */
|
|
156
|
+
get document() {
|
|
157
|
+
if (this.#editor) return this.#editor.getDocument();
|
|
158
|
+
return this.#loaded?.document ?? null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
set document(value) {
|
|
162
|
+
if (value === null || value === undefined) return;
|
|
163
|
+
if (typeof value === "string") {
|
|
164
|
+
this.#adoptText(value, "deck.json", "document").then((loaded) => this.#receive(loaded), (error) => this.#fail("document", error));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.#receive({ document: value });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** More `createOpfEditor` options. Read when the editor mounts: setting another object remounts a mounted editor. */
|
|
171
|
+
get options() {
|
|
172
|
+
return this.#options;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
set options(value) {
|
|
176
|
+
const next = value && typeof value === "object" ? value : {};
|
|
177
|
+
if (shallowEqual(next, this.#options)) return;
|
|
178
|
+
this.#options = next;
|
|
179
|
+
this.#remountIfMounted();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The fonts the editor draws with: a handle, a lease, a promise or a function of either (`OpfEditorFontsOption`), or the URL of a font root. */
|
|
183
|
+
get fonts() {
|
|
184
|
+
return this.#fonts ?? defaultFontsOption ?? this.#options.fonts ?? (this.getAttribute("fonts") || undefined);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
set fonts(value) {
|
|
188
|
+
if (value === this.#fonts) return;
|
|
189
|
+
this.#fonts = value ?? undefined;
|
|
190
|
+
this.#remountIfMounted();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
get catalogs() {
|
|
194
|
+
return this.#catalogs ?? defaultCatalogs;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
set catalogs(value) {
|
|
198
|
+
if (value === this.#catalogs) return;
|
|
199
|
+
this.#catalogs = value ?? undefined;
|
|
200
|
+
this.#remountIfMounted();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The slide on show (0-based): the editor's, once mounted. Setting it also sets the `slide` attribute. */
|
|
204
|
+
get slide() {
|
|
205
|
+
return this.#editor ? this.#editor.slideIndex : Number(this.getAttribute("slide")) || 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
set slide(value) {
|
|
209
|
+
this.setAttribute("slide", String(value));
|
|
210
|
+
if (this.#editor) this.#guard("slide", () => this.#editor.setSlide(slideIndexOf(value)));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
get readOnly() {
|
|
214
|
+
return this.hasAttribute("readonly");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
set readOnly(value) {
|
|
218
|
+
this.toggleAttribute("readonly", Boolean(value));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- lifecycle ----------------------------------------------------------------------------------------------------------------
|
|
222
|
+
connectedCallback() {
|
|
223
|
+
this.#connected = true;
|
|
224
|
+
this.#leave++;
|
|
225
|
+
this.#upgradeProperties();
|
|
226
|
+
ensureStyle(this, name);
|
|
227
|
+
if (!this.#editor && !this.#starting) this.#schedule();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
disconnectedCallback() {
|
|
231
|
+
this.#connected = false;
|
|
232
|
+
const token = ++this.#leave;
|
|
233
|
+
// A move in the DOM is a disconnect and a connect in one task: the editor is destroyed one microtask later, and not at all if it came back.
|
|
234
|
+
queueMicrotask(() => {
|
|
235
|
+
if (!this.#connected && token === this.#leave) this.#teardown();
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
attributeChangedCallback(attribute, before, after) {
|
|
240
|
+
if (before === after) return;
|
|
241
|
+
if (attribute === "src") {
|
|
242
|
+
this.#fetching?.abort();
|
|
243
|
+
if (!this.#editor) {
|
|
244
|
+
// Not mounted yet: the new address replaces whatever was loading or loaded, and a start in flight gives way to a fresh one.
|
|
245
|
+
if (!this.#explicit) this.#loaded = null;
|
|
246
|
+
this.#generation++;
|
|
247
|
+
this.#starting = false;
|
|
248
|
+
if (this.#connected) this.#schedule();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (after) this.#loadSrc(after);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const editor = this.#editor;
|
|
255
|
+
if (!editor) {
|
|
256
|
+
this.#retry();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.#guard(attribute, () => {
|
|
260
|
+
if (attribute === "readonly") editor.setReadOnly(after !== null);
|
|
261
|
+
else if (attribute === "density") editor.setDensity(after ?? this.#options.density ?? "auto");
|
|
262
|
+
else if (attribute === "slide") editor.setSlide(slideIndexOf(after ?? "0"));
|
|
263
|
+
else if (attribute === "theme") editor.setTheme(this.#theme());
|
|
264
|
+
else if (attribute === "toolbar" && typeof editor.setToolbar === "function") editor.setToolbar(parseToolbar(after));
|
|
265
|
+
else this.#remountIfMounted();
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---- start and stop -----------------------------------------------------------------------------------------------------------
|
|
270
|
+
#schedule() {
|
|
271
|
+
this.#starting = true;
|
|
272
|
+
if (this.#deferred.settled) this.#deferred = this.#newDeferred();
|
|
273
|
+
const generation = this.#generation;
|
|
274
|
+
queueMicrotask(() => {
|
|
275
|
+
if (generation === this.#generation) this.#start(generation);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#current(generation) {
|
|
280
|
+
return generation === this.#generation && this.#connected;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async #start(generation) {
|
|
284
|
+
try {
|
|
285
|
+
await this.#whenParsed();
|
|
286
|
+
if (!this.#current(generation)) return;
|
|
287
|
+
this.setAttribute("data-opf-state", "loading");
|
|
288
|
+
const loaded = this.#loaded ?? (await this.#initialContent());
|
|
289
|
+
if (!this.#current(generation)) return;
|
|
290
|
+
const { createOpfEditor } = await loadWorkspace();
|
|
291
|
+
if (!this.#current(generation)) return;
|
|
292
|
+
const host = this.ownerDocument.createElement("div");
|
|
293
|
+
host.setAttribute(HOST_ATTRIBUTE, "");
|
|
294
|
+
this.append(host);
|
|
295
|
+
let editor;
|
|
296
|
+
try {
|
|
297
|
+
editor = createOpfEditor(host, this.#createOptions(loaded));
|
|
298
|
+
} catch (error) {
|
|
299
|
+
host.remove();
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
this.#host = host;
|
|
303
|
+
this.#editor = editor;
|
|
304
|
+
this.#starting = false;
|
|
305
|
+
this.#loaded = null;
|
|
306
|
+
this.#explicit = false;
|
|
307
|
+
editor.ready.then(() => {
|
|
308
|
+
if (this.#editor !== editor) return;
|
|
309
|
+
this.#isReady = true;
|
|
310
|
+
this.setAttribute("data-opf-state", "ready");
|
|
311
|
+
const pending = this.#pending;
|
|
312
|
+
this.#pending = null;
|
|
313
|
+
if (pending) this.#apply(editor, pending);
|
|
314
|
+
this.#deferred.resolve(editor);
|
|
315
|
+
}, (error) => {
|
|
316
|
+
if (this.#editor !== editor) return;
|
|
317
|
+
this.setAttribute("data-opf-state", "error");
|
|
318
|
+
this.#deferred.reject(error);
|
|
319
|
+
});
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (generation !== this.#generation) return;
|
|
322
|
+
this.#starting = false;
|
|
323
|
+
this.setAttribute("data-opf-state", "error");
|
|
324
|
+
this.#fail(error?.opfOrigin ?? "mount", error);
|
|
325
|
+
this.#deferred.reject(error);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// The parser creates the element before its children: wait until they exist (the element has a next sibling, or the document is parsed).
|
|
330
|
+
#whenParsed() {
|
|
331
|
+
const doc = this.ownerDocument;
|
|
332
|
+
if (doc.readyState !== "loading" || this.nextSibling) return Promise.resolve();
|
|
333
|
+
return new Promise((resolve) => {
|
|
334
|
+
const done = () => {
|
|
335
|
+
doc.removeEventListener("DOMContentLoaded", done);
|
|
336
|
+
this.#cancelParse = null;
|
|
337
|
+
resolve();
|
|
338
|
+
};
|
|
339
|
+
this.#cancelParse = done;
|
|
340
|
+
doc.addEventListener("DOMContentLoaded", done);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#teardown() {
|
|
345
|
+
this.#generation++;
|
|
346
|
+
this.#starting = false;
|
|
347
|
+
this.#remountQueued = false;
|
|
348
|
+
this.#cancelParse?.();
|
|
349
|
+
this.#fetching?.abort();
|
|
350
|
+
this.#fetching = null;
|
|
351
|
+
const editor = this.#editor;
|
|
352
|
+
if (editor) {
|
|
353
|
+
this.#loaded = this.#snapshot(editor) ?? this.#loaded;
|
|
354
|
+
this.#editor = null;
|
|
355
|
+
this.#isReady = false;
|
|
356
|
+
try {
|
|
357
|
+
editor.destroy();
|
|
358
|
+
} catch (error) {
|
|
359
|
+
this.#fail("destroy", error);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
this.#pending = null;
|
|
363
|
+
this.#host?.remove();
|
|
364
|
+
this.#host = null;
|
|
365
|
+
this.removeAttribute("data-opf-state");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// An element whose start failed (or that has not started) tries again when something it reads changes.
|
|
369
|
+
#retry() {
|
|
370
|
+
if (!this.#editor && this.#connected && !this.#starting) this.#schedule();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
#remountIfMounted() {
|
|
374
|
+
if (!this.#editor) {
|
|
375
|
+
this.#retry();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (this.#remountQueued) return;
|
|
379
|
+
this.#remountQueued = true;
|
|
380
|
+
// `ready` is about the editor that is coming: a page that awaits it right after changing an attribute waits for the remount.
|
|
381
|
+
if (this.#deferred.settled) this.#deferred = this.#newDeferred();
|
|
382
|
+
queueMicrotask(() => {
|
|
383
|
+
if (!this.#remountQueued) return;
|
|
384
|
+
this.#remountQueued = false;
|
|
385
|
+
if (!this.#editor) return;
|
|
386
|
+
this.#teardown();
|
|
387
|
+
if (this.#connected) this.#schedule();
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// What a new editor needs to open where the old one stood: its document (with the text as written), slide and name.
|
|
392
|
+
#snapshot(editor) {
|
|
393
|
+
try {
|
|
394
|
+
const snapshot = { document: editor.getDocument(), slide: editor.slideIndex };
|
|
395
|
+
if (editor.mounted) {
|
|
396
|
+
const text = editor.readSource();
|
|
397
|
+
const name = editor.getName();
|
|
398
|
+
if (!text.draft) snapshot.source = { text: text.text, format: text.format, ...(name !== undefined ? { name } : {}) };
|
|
399
|
+
}
|
|
400
|
+
return snapshot;
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---- content ------------------------------------------------------------------------------------------------------------------
|
|
407
|
+
async #initialContent() {
|
|
408
|
+
const src = this.getAttribute("src");
|
|
409
|
+
if (src) return this.#fetchDeck(src);
|
|
410
|
+
const script = this.#inlineScript();
|
|
411
|
+
return script ? this.#adoptText(script.text, script.name, "inline") : null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
#inlineScript() {
|
|
415
|
+
for (const child of this.children) {
|
|
416
|
+
if (child.localName !== "script") continue;
|
|
417
|
+
const type = (child.getAttribute("type") ?? "").split(";")[0].trim().toLowerCase();
|
|
418
|
+
const kind = SCRIPT_TYPES.find((entry) => entry.test(type));
|
|
419
|
+
if (kind && child.textContent.trim()) return { text: child.textContent, name: kind.name };
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async #adoptText(text, name, origin) {
|
|
425
|
+
try {
|
|
426
|
+
const read = await readDeckFile({ name, text }, { catalogs: this.catalogs });
|
|
427
|
+
return { document: read.document, source: { text, format: read.format, name: read.name } };
|
|
428
|
+
} catch (error) {
|
|
429
|
+
error.opfOrigin = origin;
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async #fetchDeck(src) {
|
|
435
|
+
this.#fetching?.abort();
|
|
436
|
+
const controller = new AbortController();
|
|
437
|
+
this.#fetching = controller;
|
|
438
|
+
try {
|
|
439
|
+
let url = src;
|
|
440
|
+
try {
|
|
441
|
+
url = new URL(src, this.ownerDocument.baseURI).href;
|
|
442
|
+
} catch {
|
|
443
|
+
// No usable base (a page without an address): fetch reports the problem.
|
|
444
|
+
}
|
|
445
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
446
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status} ${response.statusText}`.trim());
|
|
447
|
+
const text = await response.text();
|
|
448
|
+
controller.signal.throwIfAborted();
|
|
449
|
+
return await this.#adoptText(text, deckNameFor(url, response.headers.get("content-type") ?? ""), "src");
|
|
450
|
+
} catch (error) {
|
|
451
|
+
error.opfOrigin = "src";
|
|
452
|
+
throw error;
|
|
453
|
+
} finally {
|
|
454
|
+
if (this.#fetching === controller) this.#fetching = null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// `src` changed on a mounted editor: fetch the deck and open it in place.
|
|
459
|
+
#loadSrc(src) {
|
|
460
|
+
this.#fetchDeck(src).then((loaded) => this.#receive(loaded), (error) => {
|
|
461
|
+
if (error?.name !== "AbortError") this.#fail("src", error);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// A document arrived (the property, `src`, a string): open it now if the editor is ready, after `ready` if it is mounting, else at mount.
|
|
466
|
+
#receive(loaded) {
|
|
467
|
+
if (this.#editor && this.#isReady) this.#apply(this.#editor, loaded);
|
|
468
|
+
else if (this.#editor) this.#pending = loaded;
|
|
469
|
+
else {
|
|
470
|
+
this.#loaded = loaded;
|
|
471
|
+
this.#explicit = true;
|
|
472
|
+
this.#generation++;
|
|
473
|
+
this.#starting = false;
|
|
474
|
+
this.#retry();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
#apply(editor, loaded) {
|
|
479
|
+
this.#guard("document", () => {
|
|
480
|
+
const opened = editor.setDocument(loaded.document, loaded.source ? { text: loaded.source.text, format: loaded.source.format, name: loaded.source.name } : undefined);
|
|
481
|
+
if (opened === false) throw new Error("The editor could not open this document; it is listed in the source pane with its problems.");
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ---- options ------------------------------------------------------------------------------------------------------------------
|
|
486
|
+
#theme() {
|
|
487
|
+
const scheme = this.getAttribute("theme");
|
|
488
|
+
const base = this.#options.theme;
|
|
489
|
+
return scheme ? { ...base, colorScheme: scheme } : base;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#createOptions(loaded) {
|
|
493
|
+
const user = this.#options;
|
|
494
|
+
const fromAttributes = {};
|
|
495
|
+
const layout = this.getAttribute("layout");
|
|
496
|
+
if (layout) fromAttributes.layout = layout;
|
|
497
|
+
const toolbar = parseToolbar(this.getAttribute("toolbar"));
|
|
498
|
+
if (toolbar !== undefined) fromAttributes.toolbar = toolbar;
|
|
499
|
+
if (this.hasAttribute("readonly")) fromAttributes.readOnly = true;
|
|
500
|
+
const theme = this.#theme();
|
|
501
|
+
if (theme !== undefined) fromAttributes.theme = theme;
|
|
502
|
+
const density = this.getAttribute("density");
|
|
503
|
+
if (density) fromAttributes.density = density;
|
|
504
|
+
let slide = loaded?.slide;
|
|
505
|
+
if (slide === undefined && this.hasAttribute("slide")) this.#guard("slide", () => { slide = slideIndexOf(this.getAttribute("slide")); });
|
|
506
|
+
if (slide !== undefined) fromAttributes.slideIndex = slide;
|
|
507
|
+
|
|
508
|
+
const on = {};
|
|
509
|
+
for (const event of OPF_EDITOR_EVENTS) {
|
|
510
|
+
on[event] = (detail) => {
|
|
511
|
+
user.on?.[event]?.(detail);
|
|
512
|
+
this.dispatchEvent(new CustomEvent(opfEventType(event), { bubbles: true, composed: true, detail }));
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
const options = { ...user, ...fromAttributes, on };
|
|
516
|
+
if (loaded) {
|
|
517
|
+
options.document = loaded.document;
|
|
518
|
+
if (loaded.source) options.source = { ...user.source, ...loaded.source };
|
|
519
|
+
}
|
|
520
|
+
const fonts = this.fonts;
|
|
521
|
+
if (typeof fonts === "string") options.fonts = () => loadFontRoot(fonts);
|
|
522
|
+
else if (fonts !== undefined) options.fonts = fonts;
|
|
523
|
+
else delete options.fonts;
|
|
524
|
+
const catalogs = this.catalogs;
|
|
525
|
+
if (catalogs !== undefined) options.catalogs = catalogs;
|
|
526
|
+
return options;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ---- errors and plumbing ------------------------------------------------------------------------------------------------------
|
|
530
|
+
#guard(origin, task) {
|
|
531
|
+
try {
|
|
532
|
+
task();
|
|
533
|
+
} catch (error) {
|
|
534
|
+
this.#fail(origin, error);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
#fail(origin, error) {
|
|
539
|
+
const value = error instanceof Error ? error : new Error(String(error));
|
|
540
|
+
this.dispatchEvent(new CustomEvent("opf-error", { bubbles: true, composed: true, detail: { error: value, message: value.message, source: origin } }));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
#newDeferred() {
|
|
544
|
+
const deferred = { settled: false };
|
|
545
|
+
deferred.promise = new Promise((resolve, reject) => {
|
|
546
|
+
deferred.resolve = (value) => { if (!deferred.settled) { deferred.settled = true; resolve(value); } };
|
|
547
|
+
deferred.reject = (error) => { if (!deferred.settled) { deferred.settled = true; reject(error); } };
|
|
548
|
+
});
|
|
549
|
+
// A page that never awaits `ready` must not get an unhandled rejection for a failure `opf-error` already reported.
|
|
550
|
+
deferred.promise.catch(() => {});
|
|
551
|
+
return deferred;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// A property set on the element before the class was defined shadows the accessor; hand it to the accessor.
|
|
555
|
+
#upgradeProperties() {
|
|
556
|
+
if (this.#upgraded) return;
|
|
557
|
+
this.#upgraded = true;
|
|
558
|
+
for (const property of ["options", "fonts", "catalogs", "document", "slide", "readOnly", "layout", "toolbar", "theme", "density", "src"]) {
|
|
559
|
+
if (Object.prototype.hasOwnProperty.call(this, property)) {
|
|
560
|
+
const value = this[property];
|
|
561
|
+
delete this[property];
|
|
562
|
+
this[property] = value;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// The remaining attributes are plain reflections: `el.layout = "embed"` is `setAttribute("layout", "embed")`.
|
|
569
|
+
for (const attribute of ["layout", "toolbar", "theme", "density", "src"]) {
|
|
570
|
+
Object.defineProperty(OpfEditorElement.prototype, attribute, {
|
|
571
|
+
configurable: true,
|
|
572
|
+
enumerable: true,
|
|
573
|
+
get() {
|
|
574
|
+
return this.getAttribute(attribute);
|
|
575
|
+
},
|
|
576
|
+
set(value) {
|
|
577
|
+
if (value === null || value === undefined || value === false) this.removeAttribute(attribute);
|
|
578
|
+
else this.setAttribute(attribute, String(value));
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
customElements.define(name, OpfEditorElement);
|
|
584
|
+
return OpfEditorElement;
|
|
585
|
+
}
|
package/dist/icons.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An icon a host can give a part: an SVG element (cloned into the part's document, never moved), a function returning one (called with the
|
|
3
|
+
* part's document; it may also return path data), or SVG path data for a 24x24 icon drawn as a 2px round stroke in `currentColor`
|
|
4
|
+
* (`"M12 5v14M5 12h14"`). The result is `<svg class="opf-icon" aria-hidden="true" focusable="false">`: it is decorative, the control carries
|
|
5
|
+
* the accessible name. An element keeps its own attributes (`viewBox`, `fill`, `stroke`); the part's CSS sizes it.
|
|
6
|
+
*/
|
|
7
|
+
export type OpfIcon = SVGElement | ((doc: Document) => SVGElement | string | null | undefined) | string;
|
|
8
|
+
|
|
9
|
+
/** The `icons` option of a part: icon names (listed in each part's `.d.ts`) to icons. A name the part does not have throws a RangeError. */
|
|
10
|
+
export type OpfIcons<Name extends string = string> = Partial<Record<Name, OpfIcon | null>>;
|
|
11
|
+
|
|
12
|
+
export interface ResolveIconOptions {
|
|
13
|
+
/** Written as `data-opf-icon="<name>"` so CSS can target one icon. */
|
|
14
|
+
name?: string;
|
|
15
|
+
/** Extra classes after `opf-icon`, for example the part's own (`"opf-expand-icon"`). */
|
|
16
|
+
className?: string;
|
|
17
|
+
/** `width` and `height` in px, unless the icon sets its own. The part's CSS overrides either. */
|
|
18
|
+
size?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A built-in icon that is more than one stroked path: `[tag, attributes]` shapes in a 24x24 box. */
|
|
22
|
+
export type OpfIconShapes = ReadonlyArray<readonly [string, Record<string, string | number>]>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The icon for one place in a part. `given` is the host's choice; `null` or `undefined` means `fallback` (path data or shapes). With neither the
|
|
26
|
+
* result is null. Throws a TypeError for a given value that is not an SVG element, a function or path data.
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveIcon(doc: Document, given: OpfIcon | null | undefined, fallback?: string | OpfIconShapes | null, options?: ResolveIconOptions): SVGSVGElement | null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check a part's `icons` option against the names it has. Returns a plain copy (`{}` for undefined). Throws a TypeError for a bad shape or
|
|
32
|
+
* value and a RangeError for an unknown name.
|
|
33
|
+
*/
|
|
34
|
+
export declare function normalizeIcons<Name extends string>(icons: OpfIcons<Name> | null | undefined, names: readonly Name[], label?: string): OpfIcons<Name>;
|