@openpresentation/opf-editor 0.29.0 → 0.30.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 +91 -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 +888 -30
- package/dist/styles.layered.css +7653 -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 +103 -0
- package/dist/toolbar.js +390 -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 +136 -4
- package/dist/workspace.js +710 -42
- package/package.json +57 -9
package/dist/workspace.js
CHANGED
|
@@ -12,7 +12,10 @@ import { whenFontsReady } from "./font-gate.js";
|
|
|
12
12
|
import { createSourcePane } from "./source-pane.js";
|
|
13
13
|
import { createPersistence } from "./persistence.js";
|
|
14
14
|
import { createPersistenceUi } from "./persistence-ui.js";
|
|
15
|
-
import { buildWorkspaceShell } from "./workspace-shell.js";
|
|
15
|
+
import { ITEM_SLOTS, TOOLBAR_PRESETS, buildItemControls, buildToolbar, buildWorkspaceShell, viewLabel } from "./workspace-shell.js";
|
|
16
|
+
import { TOOLBAR_ITEMS, createToolbar, normalizeToolbarItems } from "./toolbar.js";
|
|
17
|
+
import { normalizeIcons } from "./icons.js";
|
|
18
|
+
import { deckNamePatch } from "./deck-title.js";
|
|
16
19
|
import { fontSubstitutionNotes, layoutOptionsFor } from "./workspace-measure.js";
|
|
17
20
|
import { SOURCE_CHECKS, copyText, downloadSourceFile, lf, reportSource } from "./workspace-source.js";
|
|
18
21
|
import { createThemeController } from "./workspace-theme.js";
|
|
@@ -29,6 +32,7 @@ export { layoutOptionsFor } from "./workspace-measure.js";
|
|
|
29
32
|
let toSvg, createCanvasEditor, createSlideNavigator, createThumbnailStrip, createThumbnailCache, createSlideManager, createOutlineView, createExportMenu;
|
|
30
33
|
let bindPresentShortcut, createPresentButton, createPresenter, createReviewPanel, createFindPanel, installFindShortcuts, createDesignControls;
|
|
31
34
|
let prepareDimensionSwitch, mergeSlides, splitSlideByBlocks, installCanvasVariableEditing, createLayoutStudio, buildStudioShell, createStudio;
|
|
35
|
+
let createDeckTitle, createExpandController, createExpandButton, createDesignMenu, createReviewPill, createHelpButton, createToolbarActions, listSwitchOptions;
|
|
32
36
|
let partsModule;
|
|
33
37
|
let partsLoaded = false;
|
|
34
38
|
function loadParts() {
|
|
@@ -36,6 +40,7 @@ function loadParts() {
|
|
|
36
40
|
({ toSvg, createCanvasEditor, createSlideNavigator, createThumbnailStrip, createThumbnailCache, createSlideManager, createOutlineView, createExportMenu } = parts);
|
|
37
41
|
({ bindPresentShortcut, createPresentButton, createPresenter, createReviewPanel, createFindPanel, installFindShortcuts, createDesignControls } = parts);
|
|
38
42
|
({ prepareDimensionSwitch, mergeSlides, splitSlideByBlocks, installCanvasVariableEditing, createLayoutStudio, buildStudioShell, createStudio } = parts);
|
|
43
|
+
({ createDeckTitle, createExpandController, createExpandButton, createDesignMenu, createReviewPill, createHelpButton, createToolbarActions, listSwitchOptions } = parts);
|
|
39
44
|
partsLoaded = true;
|
|
40
45
|
});
|
|
41
46
|
// A chunk that failed to load is asked for again by the next editor.
|
|
@@ -49,7 +54,8 @@ function loadParts() {
|
|
|
49
54
|
* mount in (the presenter mounts in the overlay).
|
|
50
55
|
*/
|
|
51
56
|
const SOURCE_ONLY_PARTS = new Set(["source", "persistence", "history"]);
|
|
52
|
-
const
|
|
57
|
+
const CHROME_PARTS = ["title", "counter", "expand", "designMenu", "reviewPill", "help", "actions"];
|
|
58
|
+
const needsSlideParts = (parts, slots) => CHROME_PARTS.some((name) => slots[name]) || Object.entries(parts).some(([name, on]) => on && !SOURCE_ONLY_PARTS.has(name) && (name === "present" || Boolean(slots[name])));
|
|
53
59
|
|
|
54
60
|
const VIEWS = Object.freeze(["slide", "sorter", "outline", "layouts"]);
|
|
55
61
|
const MUTATORS = new Set(["set", "setGroupComposition", "setCatalog", "setCatalogs", "applyPatch", "replaceDocument", "restoreState", "undo", "redo", "setComposition", "paginateSlide"]);
|
|
@@ -68,12 +74,48 @@ const PART_NAMES = ["source", "navigator", "thumbnails", "history", "find", "pre
|
|
|
68
74
|
const LAYOUT_PARTS = {
|
|
69
75
|
full: { source: true, navigator: true, thumbnails: true, history: true, find: true, present: true, export: true, review: true, layouts: true, sorter: true, outline: true },
|
|
70
76
|
canvas: { navigator: true, thumbnails: true, history: true },
|
|
77
|
+
// CE-28: the slide alone, for a page that shows a deck (the homepage hero): its toolbar has the title, the history, the counter and Present.
|
|
78
|
+
embed: { history: true, present: true },
|
|
79
|
+
// CE-28 wave 2: a deck to look at: read-only, the slide and its counter, Present (Export when the toolbar lists it).
|
|
80
|
+
viewer: { present: true },
|
|
71
81
|
source: { source: true },
|
|
72
82
|
// CE-20: the Studio and its Playground preset arrange the same parts; the Studio mounts its own Design panel.
|
|
73
83
|
studio: { source: true, navigator: true, thumbnails: true, history: true, find: true, present: true, export: true, review: true, layouts: true, slides: true },
|
|
74
84
|
playground: { source: true, navigator: true, thumbnails: true, history: true, find: true, present: true, export: true, review: true, layouts: true, slides: true },
|
|
75
85
|
};
|
|
76
86
|
const STUDIO_LAYOUTS = new Set(["studio", "playground"]);
|
|
87
|
+
const LAYOUTS = ["full", "canvas", "embed", "viewer", "source", "slots", "studio", "playground"];
|
|
88
|
+
// The layouts that live inside a page: their keys (arrows, F5, Ctrl/Cmd Z and S, "?") act only while the focus is inside the editor.
|
|
89
|
+
const SCOPED_LAYOUTS = new Set(["embed", "viewer"]);
|
|
90
|
+
// CE-28: the toolbar items a host can place in its own element (`slots.<item>`); `autosave`, `export` and `present` were slots already.
|
|
91
|
+
// The slots that hold only the toolbar or its items: given alone, the layout is still built.
|
|
92
|
+
const CHROME_SLOTS = new Set(["title", "history", "counter", "views", "expand", "designMenu", "reviewPill", "help", "actions", "toolbar"]);
|
|
93
|
+
// The slot that places an item in the host's own element: the item's name, but `designMenu` and `reviewPill` for `design` and `review`.
|
|
94
|
+
const itemSlot = (id) => ITEM_SLOTS[id]?.[0] ?? id;
|
|
95
|
+
const ITEM_SLOT_NAMES = new Set(["title", "history", "counter", "views", "expand", "autosave", "export", "present", "design", "review", "help", "actions"]);
|
|
96
|
+
// Reserved (accepted and left out): `zoom` is the Studio's own.
|
|
97
|
+
const RESERVED_ITEMS = new Set(["zoom"]);
|
|
98
|
+
// CE-29 styling hooks: the icon names of every part the editor mounts, for the one flat `icons` option (`close` is the help sheet's and the
|
|
99
|
+
// Design menu's both). Each part gets the names it has.
|
|
100
|
+
const PART_ICONS = Object.freeze({
|
|
101
|
+
toolbar: ["more"],
|
|
102
|
+
expand: ["expand", "collapse"],
|
|
103
|
+
reviewPill: ["valid", "error", "warning", "info", "checking", "unavailable"],
|
|
104
|
+
help: ["help", "close"],
|
|
105
|
+
designMenu: ["trigger", "close", "check"],
|
|
106
|
+
navigator: ["previous", "next"],
|
|
107
|
+
strip: ["add"],
|
|
108
|
+
present: ["present"],
|
|
109
|
+
});
|
|
110
|
+
const ICON_NAMES = [...new Set(Object.values(PART_ICONS).flat())];
|
|
111
|
+
|
|
112
|
+
/** The `toolbar` option as the list of items it asks for (the layout's preset when it is not given). */
|
|
113
|
+
function toolbarSpec(options, layout) {
|
|
114
|
+
if (STUDIO_LAYOUTS.has(layout)) return [];
|
|
115
|
+
const spec = options.toolbar === false ? [] : options.toolbar === undefined || options.toolbar === true ? (TOOLBAR_PRESETS[layout] ?? []) : options.toolbar;
|
|
116
|
+
if (!Array.isArray(spec)) throw new TypeError("toolbar must be false or an array of toolbar items.");
|
|
117
|
+
return normalizeToolbarItems(spec);
|
|
118
|
+
}
|
|
77
119
|
|
|
78
120
|
const isThenable = (value) => value !== null && typeof value === "object" && typeof value.then === "function";
|
|
79
121
|
|
|
@@ -111,7 +153,7 @@ function guardSession(session, isReadOnly) {
|
|
|
111
153
|
});
|
|
112
154
|
}
|
|
113
155
|
|
|
114
|
-
function resolveParts(options, layout, slots) {
|
|
156
|
+
function resolveParts(options, layout, slots, asked = []) {
|
|
115
157
|
const panels = options.panels ?? {};
|
|
116
158
|
const base = LAYOUT_PARTS[layout];
|
|
117
159
|
const parts = {};
|
|
@@ -122,9 +164,48 @@ function resolveParts(options, layout, slots) {
|
|
|
122
164
|
}
|
|
123
165
|
// The slide is the editor; a source-only layout has none.
|
|
124
166
|
parts.canvas = layout === "source" ? false : panels.canvas !== false;
|
|
167
|
+
// CE-28 wave 2: an item the toolbar lists brings the part it shows: Export in the viewer, and Review (run without its panel where the layout
|
|
168
|
+
// has none, so the pill has counts) wherever it is listed.
|
|
169
|
+
const listed = (id) => asked.some((item) => item.id === id) || Boolean(slots[itemSlot(id)]);
|
|
170
|
+
if (panels.export === undefined && listed("export")) parts.export = true;
|
|
171
|
+
if (panels.review === undefined && listed("review") && !parts.review) {
|
|
172
|
+
parts.review = true;
|
|
173
|
+
// A layout without a Review panel gets none: the review runs headless for the pill.
|
|
174
|
+
parts.reviewHeadless = true;
|
|
175
|
+
}
|
|
125
176
|
return parts;
|
|
126
177
|
}
|
|
127
178
|
|
|
179
|
+
/**
|
|
180
|
+
* CE-28: the toolbar items this editor builds: the preset of the layout, or the `toolbar` option, without the items it has nothing for (Export
|
|
181
|
+
* when there is no Export, the views with one view, a reserved name) and without the ones the host placed in its own element (`slots.<item>`).
|
|
182
|
+
*/
|
|
183
|
+
function resolveToolbar(options, layout, parts, views, given, items) {
|
|
184
|
+
if (STUDIO_LAYOUTS.has(layout)) return [];
|
|
185
|
+
const preset = options.toolbar === undefined || options.toolbar === true;
|
|
186
|
+
const available = {
|
|
187
|
+
// The Design quick menu stands in for the Design side panel: a preset leaves it out where that panel is on (the embed has none).
|
|
188
|
+
design: !(preset && parts.design && layout !== "embed"),
|
|
189
|
+
review: parts.review,
|
|
190
|
+
help: true,
|
|
191
|
+
actions: true,
|
|
192
|
+
title: true,
|
|
193
|
+
// A host that arranges the page itself and asks for the history in its toolbar gets it (there is no other slot to name it by).
|
|
194
|
+
history: parts.history || (layout === "slots" && options.panels?.history !== false),
|
|
195
|
+
counter: parts.canvas,
|
|
196
|
+
views: views.length > 1,
|
|
197
|
+
find: parts.find,
|
|
198
|
+
autosave: parts.persistence,
|
|
199
|
+
export: parts.export,
|
|
200
|
+
present: parts.present,
|
|
201
|
+
expand: true,
|
|
202
|
+
more: true,
|
|
203
|
+
spacer: true,
|
|
204
|
+
"|": true,
|
|
205
|
+
};
|
|
206
|
+
return items.filter((item) => !RESERVED_ITEMS.has(item.id) && available[item.id] && !(ITEM_SLOT_NAMES.has(item.id) && given[itemSlot(item.id)]));
|
|
207
|
+
}
|
|
208
|
+
|
|
128
209
|
/**
|
|
129
210
|
* Mount the editor in `element`. Returns the handle at once; the parts mount once a `fonts` promise has resolved and, for a layout that shows
|
|
130
211
|
* slides, once the slide chunk has loaded (`mounted` is emitted then, and `ready` settles after the first update). A source-only editor whose
|
|
@@ -139,8 +220,10 @@ export function createOpfEditor(element, options = {}) {
|
|
|
139
220
|
const cleanups = [];
|
|
140
221
|
let destroyed = false;
|
|
141
222
|
let mounted = false;
|
|
142
|
-
|
|
223
|
+
// The viewer is read-only unless the host says otherwise.
|
|
224
|
+
let readOnly = Boolean(options.readOnly ?? options.layout === "viewer");
|
|
143
225
|
let lastError;
|
|
226
|
+
const densityFor = (setting) => (setting === "touch" || setting === "comfortable" ? setting : win?.matchMedia?.("(pointer: coarse)").matches ? "touch" : "comfortable");
|
|
144
227
|
|
|
145
228
|
// ---- events -----------------------------------------------------------------------------------------------------------------
|
|
146
229
|
function off(name, listener) {
|
|
@@ -153,6 +236,11 @@ export function createOpfEditor(element, options = {}) {
|
|
|
153
236
|
return () => off(name, listener);
|
|
154
237
|
}
|
|
155
238
|
function emit(name, detail = {}) {
|
|
239
|
+
// A copy or a download of the source (or a JSON, YAML or Markdown export) is the person's work kept somewhere: no longer dirty.
|
|
240
|
+
if (name === "kept") recordBaseline();
|
|
241
|
+
// The host's actions read state the editor does not change through the document: whether it is saved, expanded, the slide on show.
|
|
242
|
+
// `ready` too: a host's `enabled: () => editor.dirty` reads its own `editor` variable, assigned only once `createOpfEditor` has returned.
|
|
243
|
+
if (name === "dirty" || name === "expand" || name === "slide" || name === "ready") actionsPart?.refresh();
|
|
156
244
|
for (const listener of [...(listeners.get(name) ?? [])]) {
|
|
157
245
|
try {
|
|
158
246
|
listener(detail);
|
|
@@ -183,11 +271,26 @@ export function createOpfEditor(element, options = {}) {
|
|
|
183
271
|
|
|
184
272
|
// ---- layout, parts and slots ------------------------------------------------------------------------------------------------
|
|
185
273
|
const given = options.slots ?? {};
|
|
186
|
-
|
|
187
|
-
|
|
274
|
+
// CE-28: slots for the toolbar's items only (mode B: the title in the host's own header) keep the built layout.
|
|
275
|
+
const chromeOnly = Boolean(options.slots) && Object.keys(options.slots).every((name) => CHROME_SLOTS.has(name));
|
|
276
|
+
const layout = options.slots && !STUDIO_LAYOUTS.has(options.layout) && !chromeOnly ? "slots" : (options.layout ?? "full");
|
|
277
|
+
if (!LAYOUTS.includes(layout)) throw new RangeError('layout must be "full", "canvas", "embed", "viewer", "source", "studio" or "playground", or pass `slots`.');
|
|
188
278
|
const studioLayout = STUDIO_LAYOUTS.has(layout);
|
|
189
|
-
const
|
|
279
|
+
const asked = toolbarSpec(options, layout);
|
|
280
|
+
const icons = normalizeIcons(options.icons, ICON_NAMES, "createOpfEditor icons");
|
|
281
|
+
// The icons one part takes, or nothing (the part keeps its own) when the host gave none of its names.
|
|
282
|
+
const iconsFor = (part) => {
|
|
283
|
+
const picked = Object.fromEntries(PART_ICONS[part].filter((name) => name in icons).map((name) => [name, icons[name]]));
|
|
284
|
+
return Object.keys(picked).length ? { icons: picked } : {};
|
|
285
|
+
};
|
|
286
|
+
const parts = resolveParts(options, layout, given, asked);
|
|
190
287
|
const views = VIEWS.filter((view) => view === "slide" || parts[view]);
|
|
288
|
+
const toolbarItems = resolveToolbar(options, layout, parts, views, given, asked);
|
|
289
|
+
// CE-28: an embedded editor or a viewer keeps its keys (the arrows, F5, Ctrl/Cmd Z) to itself; the other layouts own the page.
|
|
290
|
+
const scopedKeys = SCOPED_LAYOUTS.has(layout);
|
|
291
|
+
// CE-28 wave 2: slide management on the thumbnail strip, in the layouts the editor arranges for editing (a host's own arrangement opts in).
|
|
292
|
+
const slideActions = options.slideActions ?? (layout === "full" || layout === "embed");
|
|
293
|
+
const keyTarget = scopedKeys ? element : doc;
|
|
191
294
|
if (options.view !== undefined && !views.includes(options.view)) throw new RangeError(`view "${options.view}" is not available in this layout (${views.join(", ")}).`);
|
|
192
295
|
if (options.persistence && parts.persistence && (typeof options.persistence !== "object" || typeof options.persistence.key !== "string" || !options.persistence.key)) {
|
|
193
296
|
throw new TypeError("persistence needs a `key` that names the document.");
|
|
@@ -197,11 +300,12 @@ export function createOpfEditor(element, options = {}) {
|
|
|
197
300
|
root.className = "opf-workspace opf-ui";
|
|
198
301
|
// The editor owns the content of the element it builds a layout in. With `slots` the element is the host's own page (it holds the slots), so the
|
|
199
302
|
// editor only adds one empty node for its overlays.
|
|
200
|
-
if (
|
|
303
|
+
if (layout === "slots") element.append(root);
|
|
201
304
|
else element.replaceChildren(root);
|
|
202
305
|
const themeController = createThemeController(element, options.theme);
|
|
203
306
|
let slots = { ...given };
|
|
204
307
|
let controls = {};
|
|
308
|
+
let toolbar;
|
|
205
309
|
let studioShell = null;
|
|
206
310
|
let overlay = root;
|
|
207
311
|
// CE-20: the Studio builds its own shell; a host's slots add parts to it (`topbarEnd`, `overlay`). Its code is in the slide chunk, so the Studio
|
|
@@ -216,9 +320,22 @@ export function createOpfEditor(element, options = {}) {
|
|
|
216
320
|
}
|
|
217
321
|
if (layout === "slots") root.setAttribute("data-opf-layout", "slots");
|
|
218
322
|
else if (!studioLayout) {
|
|
219
|
-
const built = buildWorkspaceShell(root, { parts, views, layout, labels: options.labels });
|
|
323
|
+
const built = buildWorkspaceShell(root, { parts, views, layout, labels: options.labels, toolbar: toolbarItems, createToolbar, density: densityFor(options.density), toolbarHost: given.toolbar, onError: (error) => report(error), onCollapse: (detail) => onToolbarCollapse(detail), ...iconsFor("toolbar") });
|
|
220
324
|
slots = { ...built.slots, ...given };
|
|
221
325
|
controls = built.controls;
|
|
326
|
+
toolbar = built.toolbar;
|
|
327
|
+
}
|
|
328
|
+
// CE-28: a host that arranges the page itself and gives `slots.toolbar` gets the toolbar there.
|
|
329
|
+
if (layout === "slots" && given.toolbar && toolbarItems.length) {
|
|
330
|
+
const built = buildToolbar(given.toolbar, { items: toolbarItems, views, labels: options.labels, density: densityFor(options.density), createToolbar, onError: (error) => report(error), onCollapse: (detail) => onToolbarCollapse(detail), ...iconsFor("toolbar") });
|
|
331
|
+
slots = { ...built.slots, ...given };
|
|
332
|
+
Object.assign(controls, built.controls);
|
|
333
|
+
toolbar = built.toolbar;
|
|
334
|
+
}
|
|
335
|
+
// Mode B: the history and the view switch in the host's own elements.
|
|
336
|
+
if (!studioLayout) {
|
|
337
|
+
if (given.history && parts.history) Object.assign(controls, buildItemControls("history", given.history, { labels: options.labels }));
|
|
338
|
+
if (given.views && views.length > 1) Object.assign(controls, buildItemControls("views", given.views, { views, labels: options.labels }));
|
|
222
339
|
}
|
|
223
340
|
overlay = slots.overlay ?? root;
|
|
224
341
|
// The slide chunk is loaded before the parts mount, unless only the source pane (with its autosave) is mounted. The Studio always needs it.
|
|
@@ -229,6 +346,18 @@ export function createOpfEditor(element, options = {}) {
|
|
|
229
346
|
let canvas;
|
|
230
347
|
let pane;
|
|
231
348
|
let slideNav;
|
|
349
|
+
let counterNav;
|
|
350
|
+
let deckTitle;
|
|
351
|
+
let expandCtl;
|
|
352
|
+
let expandButton;
|
|
353
|
+
let deckName;
|
|
354
|
+
let designMenu;
|
|
355
|
+
let reviewPill;
|
|
356
|
+
let helpButton;
|
|
357
|
+
let actionsPart;
|
|
358
|
+
let hostActions = Array.isArray(options.actions) ? [...options.actions] : [];
|
|
359
|
+
let reviewCounts = null;
|
|
360
|
+
let exportCollapsed = false;
|
|
232
361
|
let strip;
|
|
233
362
|
let slideManager;
|
|
234
363
|
let sorter;
|
|
@@ -266,6 +395,31 @@ export function createOpfEditor(element, options = {}) {
|
|
|
266
395
|
let statusText = "";
|
|
267
396
|
let density = "comfortable";
|
|
268
397
|
let exportMode = "menu";
|
|
398
|
+
// The parts are bound once the slide chunk has arrived and the fonts are at hand; `partsReady` is true from then on. A call on the handle made
|
|
399
|
+
// before that (a React host's effects run right after `createOpfEditor` returns) must neither reach a factory that is not bound yet nor be lost:
|
|
400
|
+
// a setting is remembered in `requested` and read when the parts mount; a call that acts on a mounted part waits in `queued` and runs, in order,
|
|
401
|
+
// once the editor is mounted (a later call with the same key replaces the earlier one).
|
|
402
|
+
let partsReady = false;
|
|
403
|
+
const requested = {};
|
|
404
|
+
const queued = [];
|
|
405
|
+
const waiting = () => !mounted && !destroyed;
|
|
406
|
+
const queue = (key, task) => {
|
|
407
|
+
const at = queued.findIndex((entry) => entry.key === key);
|
|
408
|
+
if (at >= 0) queued.splice(at, 1);
|
|
409
|
+
const entry = { key, task };
|
|
410
|
+
queued.push(entry);
|
|
411
|
+
return entry;
|
|
412
|
+
};
|
|
413
|
+
function flushQueued() {
|
|
414
|
+
for (const { task } of queued.splice(0)) {
|
|
415
|
+
if (destroyed) return;
|
|
416
|
+
try {
|
|
417
|
+
task();
|
|
418
|
+
} catch (error) {
|
|
419
|
+
report(error);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
269
423
|
let frameSize = { width: 0, height: 0 };
|
|
270
424
|
const draftMemory = createSourceMemory();
|
|
271
425
|
const variablesMode = options.variables === "resolved" ? "resolved" : "authored";
|
|
@@ -276,7 +430,6 @@ export function createOpfEditor(element, options = {}) {
|
|
|
276
430
|
if (slots.status) slots.status.textContent = message;
|
|
277
431
|
emit("status", { message });
|
|
278
432
|
};
|
|
279
|
-
const densityFor = (setting) => (setting === "touch" || setting === "comfortable" ? setting : win?.matchMedia?.("(pointer: coarse)").matches ? "touch" : "comfortable");
|
|
280
433
|
const exportModeFor = (setting) => (setting === "sheet" || setting === "menu" ? setting : win?.matchMedia?.("(max-width: 900px)").matches ? "sheet" : "menu");
|
|
281
434
|
|
|
282
435
|
// The options a draw uses: the host's, the image hook, and the registered catalogs. `variables` follow the document in "resolved" mode.
|
|
@@ -288,6 +441,12 @@ export function createOpfEditor(element, options = {}) {
|
|
|
288
441
|
}
|
|
289
442
|
const layoutFor = (deck, index) => layoutOptionsFor(fonts, deck, index, catalogs());
|
|
290
443
|
|
|
444
|
+
// The one slide index on every part that shows it: the slide row's navigator, the toolbar's counter and the strip.
|
|
445
|
+
function showIndex(index) {
|
|
446
|
+
slideNav?.setSlide(index);
|
|
447
|
+
counterNav?.setSlide(index);
|
|
448
|
+
strip?.setSlide(index);
|
|
449
|
+
}
|
|
291
450
|
// ---- navigation and the refresh loop ----------------------------------------------------------------------------------------
|
|
292
451
|
function renderViews(deck) {
|
|
293
452
|
// The Studio's filmstrip draws the thumbnails it shows itself, as they scroll in.
|
|
@@ -323,8 +482,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
323
482
|
const deck = session.snapshot().presentation;
|
|
324
483
|
slideIndex = Math.max(0, Math.min(slideIndex, deck.slides.length - 1));
|
|
325
484
|
renderViews(deck);
|
|
326
|
-
|
|
327
|
-
strip?.setSlide(slideIndex);
|
|
485
|
+
showIndex(slideIndex);
|
|
328
486
|
fonts?.registry?.clearSubstitutions?.();
|
|
329
487
|
if (canvas && canvas.slideIndex !== slideIndex) canvas.setSlide(slideIndex);
|
|
330
488
|
emit("sync", syncDetail(deck));
|
|
@@ -378,8 +536,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
378
536
|
if (canvas.setSlide(index)) {
|
|
379
537
|
const changed = index !== slideIndex;
|
|
380
538
|
slideIndex = index;
|
|
381
|
-
|
|
382
|
-
strip?.setSlide(index);
|
|
539
|
+
showIndex(index);
|
|
383
540
|
if (changed) emit("slide", { index, count: slides.length });
|
|
384
541
|
refresh();
|
|
385
542
|
return true;
|
|
@@ -387,11 +544,15 @@ export function createOpfEditor(element, options = {}) {
|
|
|
387
544
|
} catch (error) {
|
|
388
545
|
report(error);
|
|
389
546
|
}
|
|
390
|
-
|
|
391
|
-
strip?.setSlide(canvas.slideIndex);
|
|
547
|
+
showIndex(canvas.slideIndex);
|
|
392
548
|
return false;
|
|
393
549
|
}
|
|
394
550
|
function goTo(index, path) {
|
|
551
|
+
if (waiting()) {
|
|
552
|
+
go(index);
|
|
553
|
+
queue("goTo", () => goTo(index, path));
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
395
556
|
if (index !== slideIndex && !go(index)) return false;
|
|
396
557
|
if (studioCtl) studioCtl.revealPath(path ?? `slides.${index}`);
|
|
397
558
|
else if (path) canvas?.reveal(path);
|
|
@@ -405,6 +566,11 @@ export function createOpfEditor(element, options = {}) {
|
|
|
405
566
|
presentButton?.setDisabled(!inSync || slides.length === 0);
|
|
406
567
|
if (controls.undo) controls.undo.disabled = !inSync || readOnly || !session.canUndo;
|
|
407
568
|
if (controls.redo) controls.redo.disabled = !inSync || readOnly || !session.canRedo;
|
|
569
|
+
// The deck name is not renamed over text in the source pane that is not a valid deck (that would replace the person's draft).
|
|
570
|
+
deckTitle?.setDisabled(!inSync);
|
|
571
|
+
designMenu?.setDisabled(!inSync);
|
|
572
|
+
actionsPart?.setDisabled(!inSync);
|
|
573
|
+
actionsPart?.refresh();
|
|
408
574
|
// Text in the source pane that is not a valid deck: the slide shows the last valid one and is not editable until the text is fixed.
|
|
409
575
|
const frame = slots.canvas && (slots.slide ?? slots.canvas);
|
|
410
576
|
// The Studio keeps selection, zoom and Present while read-only (its commands say why they are off); only invalid text locks the slide.
|
|
@@ -454,6 +620,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
454
620
|
if (!views.includes(view)) return false;
|
|
455
621
|
if (!mounted) {
|
|
456
622
|
activeView = view;
|
|
623
|
+
requested.view = view;
|
|
457
624
|
return true;
|
|
458
625
|
}
|
|
459
626
|
if (view !== "slide" && canvas && !canvas.commit()) return false;
|
|
@@ -496,6 +663,15 @@ export function createOpfEditor(element, options = {}) {
|
|
|
496
663
|
return true;
|
|
497
664
|
}
|
|
498
665
|
function showPanel(name, focus = false) {
|
|
666
|
+
if (!mounted) {
|
|
667
|
+
// The tabs of the shell are known now, the Studio's when it mounts; the panel is shown after the default one.
|
|
668
|
+
if (destroyed || (!studioLayout && !controls.tabs?.[name])) return false;
|
|
669
|
+
queue("panel", () => showPanel(name, focus));
|
|
670
|
+
return true;
|
|
671
|
+
}
|
|
672
|
+
return showPanelNow(name, focus);
|
|
673
|
+
}
|
|
674
|
+
function showPanelNow(name, focus = false) {
|
|
499
675
|
if (studioCtl) return studioCtl.showTab(name, { focus });
|
|
500
676
|
const tabs = controls.tabs;
|
|
501
677
|
if (!tabs?.[name]) return false;
|
|
@@ -513,10 +689,15 @@ export function createOpfEditor(element, options = {}) {
|
|
|
513
689
|
|
|
514
690
|
// ---- the parts --------------------------------------------------------------------------------------------------------------
|
|
515
691
|
function mountSlideRow() {
|
|
692
|
+
// The row is built from the slide chunk's factories: before they are bound (and after destroy) there is nothing to rebuild; the first
|
|
693
|
+
// mount reads the density and the layout as they are by then.
|
|
694
|
+
if (!partsReady || destroyed) return;
|
|
516
695
|
unbindKeys?.();
|
|
517
696
|
slideNav?.destroy();
|
|
697
|
+
counterNav?.destroy();
|
|
518
698
|
strip?.destroy();
|
|
519
699
|
slideNav = undefined;
|
|
700
|
+
counterNav = undefined;
|
|
520
701
|
strip = undefined;
|
|
521
702
|
// The Studio shows its filmstrip; the slide row is the Playground preset's, mounted only while that preset is on.
|
|
522
703
|
if (studioLayout && (studioCtl?.layout ?? layout) !== "playground") return;
|
|
@@ -525,12 +706,27 @@ export function createOpfEditor(element, options = {}) {
|
|
|
525
706
|
editor: ui,
|
|
526
707
|
slideIndex,
|
|
527
708
|
density,
|
|
709
|
+
...iconsFor("navigator"),
|
|
528
710
|
...(options.labels?.navigator ? { labels: { navigation: options.labels.navigator } } : {}),
|
|
529
711
|
onChange: (index) => {
|
|
530
712
|
go(index);
|
|
531
713
|
},
|
|
532
714
|
});
|
|
533
|
-
unbindKeys = slideNav.bindKeys(
|
|
715
|
+
unbindKeys = slideNav.bindKeys(keyTarget);
|
|
716
|
+
}
|
|
717
|
+
// CE-28: the toolbar's counter is a second navigator on the same slide index; it binds the arrow keys only when there is no slide row.
|
|
718
|
+
if (parts.canvas && slots.counter) {
|
|
719
|
+
counterNav = createSlideNavigator(slots.counter, {
|
|
720
|
+
editor: ui,
|
|
721
|
+
slideIndex,
|
|
722
|
+
density,
|
|
723
|
+
...iconsFor("navigator"),
|
|
724
|
+
...(options.labels?.counter ? { labels: { navigation: options.labels.counter } } : {}),
|
|
725
|
+
onChange: (index) => {
|
|
726
|
+
go(index);
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
if (!slideNav) unbindKeys = counterNav.bindKeys(keyTarget);
|
|
534
730
|
}
|
|
535
731
|
if (parts.thumbnails && slots.thumbnails) {
|
|
536
732
|
strip = createThumbnailStrip(slots.thumbnails, {
|
|
@@ -538,12 +734,17 @@ export function createOpfEditor(element, options = {}) {
|
|
|
538
734
|
fonts,
|
|
539
735
|
slideIndex,
|
|
540
736
|
density,
|
|
737
|
+
...iconsFor("strip"),
|
|
541
738
|
...(options.labels?.thumbnails ? { labels: { navigation: options.labels.thumbnails } } : {}),
|
|
542
739
|
renderOptions: renderOptions(),
|
|
543
740
|
onSelect: (index) => {
|
|
544
741
|
if (index !== slideIndex) go(index);
|
|
545
742
|
},
|
|
546
743
|
onError: (error) => report(error instanceof Error ? error : new Error("Thumbnails unavailable")),
|
|
744
|
+
// CE-28 wave 2: "+" and the thumbnail menu (duplicate, delete, hide, move), each one undoable change; "+" offers the deck's layouts.
|
|
745
|
+
...(slideActions
|
|
746
|
+
? { actions: true, readOnly, onStatus: say, ...(listSwitchOptions ? { layoutOptions: (presentation) => listSwitchOptions(presentation, "layouts", { catalogs: catalogs() }) } : {}) }
|
|
747
|
+
: {}),
|
|
547
748
|
});
|
|
548
749
|
}
|
|
549
750
|
}
|
|
@@ -590,7 +791,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
590
791
|
...(configured.cursorDelay !== undefined ? { cursorDelay: configured.cursorDelay } : {}),
|
|
591
792
|
...(configured.completions !== undefined ? { completions: configured.completions } : {}),
|
|
592
793
|
...(configured.catalogs !== undefined ? { catalogs: configured.catalogs } : {}),
|
|
593
|
-
...(configured.name !== undefined ? { name: configured.name } : {}),
|
|
794
|
+
...((requested.name ?? configured.name) !== undefined ? { name: requested.name ?? configured.name } : {}),
|
|
594
795
|
...(text !== undefined ? { source: text } : {}),
|
|
595
796
|
editor: ui,
|
|
596
797
|
format: configured.format ?? "json",
|
|
@@ -616,6 +817,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
616
817
|
sourceState = { status: made.status, format, text: shown };
|
|
617
818
|
syncChrome();
|
|
618
819
|
emit("source", sourceState);
|
|
820
|
+
updateDirty();
|
|
619
821
|
return made.findings;
|
|
620
822
|
},
|
|
621
823
|
onCopy: async (file) => {
|
|
@@ -695,6 +897,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
695
897
|
},
|
|
696
898
|
onSelect: (value) => {
|
|
697
899
|
selection = { path: value.path, value: value.value };
|
|
900
|
+
actionsPart?.refresh();
|
|
698
901
|
// The Studio emits its own selection (a list of nodes); the canvas's single selection is internal there.
|
|
699
902
|
if (!studioLayout) emit("selection", { path: value.path, value: value.value, slideIndex, element: value.element });
|
|
700
903
|
design?.refresh();
|
|
@@ -729,8 +932,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
729
932
|
if (event.slideIndex !== slideIndex) {
|
|
730
933
|
// The canvas moved to another slide itself (its slide was removed, say): the rest of the parts follow it.
|
|
731
934
|
slideIndex = event.slideIndex;
|
|
732
|
-
|
|
733
|
-
strip?.setSlide(slideIndex);
|
|
935
|
+
showIndex(slideIndex);
|
|
734
936
|
emit("slide", { index: slideIndex, count: slides.length });
|
|
735
937
|
refresh();
|
|
736
938
|
}
|
|
@@ -841,12 +1043,18 @@ export function createOpfEditor(element, options = {}) {
|
|
|
841
1043
|
tab.textContent = found ? `${name} (${counts.total})` : name;
|
|
842
1044
|
tab.setAttribute("aria-label", found ? `${name}, ${counts.total} finding${counts.total === 1 ? "" : "s"}` : name);
|
|
843
1045
|
}
|
|
1046
|
+
reviewCounts = counts;
|
|
1047
|
+
reviewPill?.update(counts);
|
|
844
1048
|
emit("review", counts);
|
|
845
1049
|
},
|
|
846
1050
|
});
|
|
847
1051
|
}
|
|
848
1052
|
|
|
849
1053
|
function present(from) {
|
|
1054
|
+
if (waiting() && parts.present) {
|
|
1055
|
+
queue("present", () => present(from));
|
|
1056
|
+
return true;
|
|
1057
|
+
}
|
|
850
1058
|
if (destroyed || !mounted || !presenter || presenter.isRunning() || !inSync || slides.length === 0) return false;
|
|
851
1059
|
// An open canvas edit is committed, and text typed a moment ago is applied, before the document is shown; a source that is not a valid deck waits.
|
|
852
1060
|
if (canvas?.commit() === false) return false;
|
|
@@ -890,15 +1098,27 @@ export function createOpfEditor(element, options = {}) {
|
|
|
890
1098
|
present();
|
|
891
1099
|
},
|
|
892
1100
|
density,
|
|
1101
|
+
...iconsFor("present"),
|
|
893
1102
|
});
|
|
894
1103
|
}
|
|
895
1104
|
cleanups.push(
|
|
896
|
-
bindPresentShortcut(
|
|
1105
|
+
bindPresentShortcut(keyTarget, () => {
|
|
897
1106
|
present();
|
|
898
1107
|
}),
|
|
899
1108
|
);
|
|
900
1109
|
}
|
|
901
1110
|
if (parts.find && slots.find) mountFind();
|
|
1111
|
+
// CE-28 wave 2: a `review` item without a Review panel (the embed) runs the review in a hidden element, so the pill has its counts.
|
|
1112
|
+
if (parts.review && !slots.review && slots.reviewPill) {
|
|
1113
|
+
// The panel owns its container's attributes; the hidden wrapper keeps it out of sight.
|
|
1114
|
+
const hidden = doc.createElement("div");
|
|
1115
|
+
hidden.hidden = true;
|
|
1116
|
+
hidden.setAttribute("data-opf-headless", "review");
|
|
1117
|
+
slots.review = doc.createElement("div");
|
|
1118
|
+
slots.review.setAttribute("data-opf-slot", "review");
|
|
1119
|
+
hidden.append(slots.review);
|
|
1120
|
+
root.append(hidden);
|
|
1121
|
+
}
|
|
902
1122
|
if (parts.review && slots.review) mountReview();
|
|
903
1123
|
if (parts.design && slots.design) {
|
|
904
1124
|
const configured = typeof options.design === "object" ? options.design : {};
|
|
@@ -916,6 +1136,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
916
1136
|
});
|
|
917
1137
|
}
|
|
918
1138
|
if (parts.persistence) mountPersistence();
|
|
1139
|
+
mountChrome();
|
|
919
1140
|
mountControls();
|
|
920
1141
|
mountEnvironment();
|
|
921
1142
|
if (studioShell && canvas) mountStudio();
|
|
@@ -932,6 +1153,14 @@ export function createOpfEditor(element, options = {}) {
|
|
|
932
1153
|
if (event.type !== "catalogs") {
|
|
933
1154
|
syncChrome();
|
|
934
1155
|
emit("change", { document: event.snapshot.presentation, canUndo: event.snapshot.canUndo, canRedo: event.snapshot.canRedo, event });
|
|
1156
|
+
// CE-28: the deck's name changed, from whatever changed it (the title, the source, undo, the host).
|
|
1157
|
+
const name = currentDeckName();
|
|
1158
|
+
if (name !== deckName) {
|
|
1159
|
+
const previous = deckName;
|
|
1160
|
+
deckName = name;
|
|
1161
|
+
emit("deckName", { name, previous });
|
|
1162
|
+
}
|
|
1163
|
+
updateDirty();
|
|
935
1164
|
}
|
|
936
1165
|
refresh();
|
|
937
1166
|
}),
|
|
@@ -998,33 +1227,199 @@ export function createOpfEditor(element, options = {}) {
|
|
|
998
1227
|
getName: () => pane?.getName(),
|
|
999
1228
|
setName: (name) => pane?.setName(name),
|
|
1000
1229
|
layoutStudio: () => studio,
|
|
1230
|
+
// CE-28 wave 2: the rows the Studio's shortcut sheet adds to its commands, the same the help item lists.
|
|
1231
|
+
extraShortcuts: () => chromeShortcuts(),
|
|
1001
1232
|
mountSlideRow: () => mountSlideRow(),
|
|
1002
1233
|
});
|
|
1003
1234
|
}
|
|
1004
1235
|
|
|
1236
|
+
// ---- CE-28: the toolbar's parts --------------------------------------------------------------------------------------------------
|
|
1237
|
+
// What an open edit needs before the document changes from outside it: the canvas commits it, text typed in the source is applied.
|
|
1238
|
+
function commitPending() {
|
|
1239
|
+
if (canvas?.commit() === false) return false;
|
|
1240
|
+
pane?.getText();
|
|
1241
|
+
return true;
|
|
1242
|
+
}
|
|
1243
|
+
const chromeLabels = () => options.labels ?? {};
|
|
1244
|
+
function mountChrome() {
|
|
1245
|
+
deckName = currentDeckName();
|
|
1246
|
+
if (slots.title && createDeckTitle) {
|
|
1247
|
+
deckTitle = createDeckTitle(slots.title, {
|
|
1248
|
+
editor: ui,
|
|
1249
|
+
...(chromeLabels().untitled ? { placeholder: chromeLabels().untitled } : {}),
|
|
1250
|
+
...(chromeLabels().title ? { label: chromeLabels().title } : {}),
|
|
1251
|
+
readOnly,
|
|
1252
|
+
density,
|
|
1253
|
+
beforeCommit: () => inSync && commitPending(),
|
|
1254
|
+
onError: (error) => report(error),
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
// The editor fills the window (or, with `expand.fullscreen`, the screen): the mount element becomes a fixed overlay, in place.
|
|
1258
|
+
if (createExpandController && options.expand !== false) {
|
|
1259
|
+
const labels = { ...(chromeLabels().expand ? { expand: chromeLabels().expand } : {}), ...(chromeLabels().collapse ? { collapse: chromeLabels().collapse } : {}) };
|
|
1260
|
+
const configured = typeof options.expand === "object" && options.expand ? options.expand : {};
|
|
1261
|
+
const target = configured.target ?? element;
|
|
1262
|
+
// The host's own elements for the bar and its items that sit outside what expands stay usable over the overlay.
|
|
1263
|
+
const keep = ["toolbar", ...CHROME_SLOTS].map((name) => given[name]).filter((node, at, all) => node && all.indexOf(node) === at && !target.contains(node));
|
|
1264
|
+
expandCtl = createExpandController(target, {
|
|
1265
|
+
labels,
|
|
1266
|
+
onStatus: say,
|
|
1267
|
+
...(keep.length ? { keep } : {}),
|
|
1268
|
+
...(configured.fullscreen !== undefined ? { fullscreen: configured.fullscreen } : {}),
|
|
1269
|
+
onChange: ({ expanded }) => {
|
|
1270
|
+
if (destroyed) return;
|
|
1271
|
+
root.toggleAttribute("data-expanded", expanded);
|
|
1272
|
+
emit("expand", { expanded });
|
|
1273
|
+
},
|
|
1274
|
+
});
|
|
1275
|
+
if (slots.expand && createExpandButton) expandButton = createExpandButton(slots.expand, { controller: expandCtl, density, labels, ...iconsFor("expand") });
|
|
1276
|
+
}
|
|
1277
|
+
mountChromeParts();
|
|
1278
|
+
if (!toolbar) return;
|
|
1279
|
+
// What each item offers in the more menu once it has collapsed into it, read when the menu opens (so always the current state).
|
|
1280
|
+
const text = { undo: "Undo", redo: "Redo", find: "Find", ...chromeLabels() };
|
|
1281
|
+
const register = (name, part) => cleanups.push(toolbar.mount(name, part));
|
|
1282
|
+
register("history", {
|
|
1283
|
+
menuItems: () => [
|
|
1284
|
+
{ id: "undo", label: text.undo, disabled: Boolean(controls.undo?.disabled), run: () => undo() },
|
|
1285
|
+
{ id: "redo", label: text.redo, disabled: Boolean(controls.redo?.disabled), run: () => redo() },
|
|
1286
|
+
],
|
|
1287
|
+
});
|
|
1288
|
+
register("find", { menuItems: () => (find ? [{ id: "find", label: `${text.find}…`, run: () => find.openPanel({}) }] : []) });
|
|
1289
|
+
register("views", { menuItems: () => views.map((view) => ({ id: `view:${view}`, label: viewLabel(view), pressed: activeView === view, run: () => setView(view, { focus: true }) })) });
|
|
1290
|
+
register("counter", {
|
|
1291
|
+
menuItems: () => [
|
|
1292
|
+
{ id: "previous", label: "Previous slide", disabled: slideIndex <= 0, run: () => go(slideIndex - 1) },
|
|
1293
|
+
{ id: "next", label: "Next slide", disabled: slideIndex >= slides.length - 1, run: () => go(slideIndex + 1) },
|
|
1294
|
+
],
|
|
1295
|
+
});
|
|
1296
|
+
register("present", { menuItems: () => (presenter ? [{ id: "present", label: "Present", disabled: !inSync || slides.length === 0, run: () => present() }] : []) });
|
|
1297
|
+
// Export: one entry that opens the Export menu as its sheet (while Export is in ⋯ the menu is a sheet, so its progress and result stay on screen).
|
|
1298
|
+
register("export", { menuItems: () => (exportMenu ? [{ id: "export", label: text.exportItem ?? "Export…", run: () => openExportFromMore() }] : []) });
|
|
1299
|
+
if (deckTitle) register("title", deckTitle);
|
|
1300
|
+
if (expandButton) register("expand", expandButton);
|
|
1301
|
+
if (designMenu) register("design", designMenu);
|
|
1302
|
+
if (reviewPill) register("review", reviewPill);
|
|
1303
|
+
if (helpButton) register("help", helpButton);
|
|
1304
|
+
if (actionsPart) register("actions", actionsPart);
|
|
1305
|
+
}
|
|
1306
|
+
// The Export sheet opened from ⋯ gives the focus back to ⋯ when it closes (its own trigger is out of sight there), or, when ⋯ is gone (the
|
|
1307
|
+
// bar widened meanwhile), to the Export button back in the bar, else to the first control of the bar: never to the body.
|
|
1308
|
+
function openExportFromMore() {
|
|
1309
|
+
if (!exportMenu) return;
|
|
1310
|
+
const shown = (node) => Boolean(node?.isConnected && node.getClientRects().length && win.getComputedStyle(node).visibility !== "hidden");
|
|
1311
|
+
const back = () => {
|
|
1312
|
+
const more = toolbar?.element.querySelector(".opf-tb-more-button");
|
|
1313
|
+
const candidates = [more, exportMenu?.trigger, ...(toolbar ? toolbar.element.querySelectorAll("button, input") : [])];
|
|
1314
|
+
candidates.find((node) => shown(node) && !node.disabled)?.focus();
|
|
1315
|
+
};
|
|
1316
|
+
const watch = new win.MutationObserver(() => {
|
|
1317
|
+
if (exportMenu?.trigger.getAttribute("aria-expanded") === "true") return;
|
|
1318
|
+
watch.disconnect();
|
|
1319
|
+
if (destroyed) return;
|
|
1320
|
+
const active = doc.activeElement;
|
|
1321
|
+
if (!active || active === doc.body || !shown(active)) back();
|
|
1322
|
+
});
|
|
1323
|
+
watch.observe(exportMenu.trigger, { attributes: true, attributeFilter: ["aria-expanded"] });
|
|
1324
|
+
cleanups.push(() => watch.disconnect());
|
|
1325
|
+
exportMenu.open("first");
|
|
1326
|
+
}
|
|
1327
|
+
// While Export is collapsed into ⋯ its menu opens as the bottom sheet (fixed on screen); back in the bar it is the editor's mode again.
|
|
1328
|
+
function onToolbarCollapse({ collapsed }) {
|
|
1329
|
+
exportCollapsed = collapsed.includes("export");
|
|
1330
|
+
exportMenu?.setMode(exportCollapsed ? "sheet" : exportMode);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// CE-28 wave 2: the Design quick menu, the review pill, the help sheet and the host's actions.
|
|
1334
|
+
function mountChromeParts() {
|
|
1335
|
+
const labels = chromeLabels();
|
|
1336
|
+
const designPanel = () => Boolean(design || controls.tabs?.design || studioShell?.tabs?.design);
|
|
1337
|
+
if (slots.designMenu && createDesignMenu) {
|
|
1338
|
+
designMenu = createDesignMenu(slots.designMenu, {
|
|
1339
|
+
editor: ui,
|
|
1340
|
+
catalogs,
|
|
1341
|
+
getSlideIndex: () => slideIndex,
|
|
1342
|
+
density,
|
|
1343
|
+
readOnly,
|
|
1344
|
+
...iconsFor("designMenu"),
|
|
1345
|
+
...(labels.designMenu ? { labels: labels.designMenu } : {}),
|
|
1346
|
+
// "More design options…": the workspace's Design panel when there is one, else the host's (`designMenu.onOpenPanel`), else no row.
|
|
1347
|
+
...(designPanel()
|
|
1348
|
+
? { onOpenPanel: () => showPanel("design", true) }
|
|
1349
|
+
: typeof options.designMenu?.onOpenPanel === "function"
|
|
1350
|
+
? { onOpenPanel: () => options.designMenu.onOpenPanel() }
|
|
1351
|
+
: {}),
|
|
1352
|
+
onStatus: say,
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
if (slots.reviewPill && createReviewPill) {
|
|
1356
|
+
reviewPill = createReviewPill(slots.reviewPill, {
|
|
1357
|
+
density,
|
|
1358
|
+
...iconsFor("reviewPill"),
|
|
1359
|
+
...(labels.reviewPill ? { labels: labels.reviewPill } : {}),
|
|
1360
|
+
// The Review panel when there is one; without it (the embed) the host hears `review-open` and shows the findings its own way.
|
|
1361
|
+
onOpen: () => {
|
|
1362
|
+
if (controls.tabs?.review || studioShell?.tabs?.review) showPanel("review", true);
|
|
1363
|
+
else emit("review-open", { counts: reviewCounts, findings: review?.findings ?? [] });
|
|
1364
|
+
},
|
|
1365
|
+
});
|
|
1366
|
+
reviewPill.update(reviewCounts);
|
|
1367
|
+
}
|
|
1368
|
+
if (slots.help && createHelpButton) {
|
|
1369
|
+
helpButton = createHelpButton(slots.help, {
|
|
1370
|
+
overlay,
|
|
1371
|
+
getShortcuts: () => shortcuts(),
|
|
1372
|
+
density,
|
|
1373
|
+
...iconsFor("help"),
|
|
1374
|
+
...(labels.help ? { labels: labels.help } : {}),
|
|
1375
|
+
// "?" opens it from anywhere in a page the editor owns; an embedded editor or a viewer only from inside it (the button still works).
|
|
1376
|
+
bindKey: !scopedKeys,
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
if (slots.actions && createToolbarActions) {
|
|
1380
|
+
actionsPart = createToolbarActions(slots.actions, { actions: hostActions, density, ...(labels.actions ? { labels: labels.actions } : {}) });
|
|
1381
|
+
slots.actions.hidden = hostActions.length === 0;
|
|
1382
|
+
syncActionsPriority();
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1005
1386
|
// The shell's own controls: history, views, tabs.
|
|
1006
1387
|
function mountControls() {
|
|
1007
1388
|
controls.undo?.addEventListener("click", () => undo());
|
|
1008
1389
|
controls.redo?.addEventListener("click", () => redo());
|
|
1009
1390
|
for (const [name, button] of Object.entries(controls.views ?? {})) button.addEventListener("click", () => setView(name, { focus: true }));
|
|
1010
1391
|
for (const [name, tab] of Object.entries(controls.tabs ?? {})) {
|
|
1011
|
-
tab.addEventListener("click", () =>
|
|
1392
|
+
tab.addEventListener("click", () => showPanelNow(name));
|
|
1012
1393
|
tab.addEventListener("keydown", (event) => {
|
|
1013
1394
|
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
|
1014
1395
|
event.preventDefault();
|
|
1015
1396
|
const names = Object.keys(controls.tabs);
|
|
1016
1397
|
const at = names.indexOf(name);
|
|
1017
1398
|
const next = event.key === "Home" ? 0 : event.key === "End" ? names.length - 1 : (at + (event.key === "ArrowRight" ? 1 : -1) + names.length) % names.length;
|
|
1018
|
-
|
|
1399
|
+
showPanelNow(names[next], true);
|
|
1019
1400
|
});
|
|
1020
1401
|
}
|
|
1021
|
-
if (controls.tabs)
|
|
1022
|
-
|
|
1402
|
+
if (controls.tabs) showPanelNow(options.panel && controls.tabs[options.panel] ? options.panel : Object.keys(controls.tabs)[0]);
|
|
1403
|
+
// CE-28 wave 2: Ctrl/Cmd+S inside the editor asks the host to save (`save`); the browser's own Save page is kept unless a host listens.
|
|
1404
|
+
if (options.shortcuts !== false && layout !== "viewer") {
|
|
1405
|
+
const onSave = (event) => {
|
|
1406
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "s" || event.defaultPrevented) return;
|
|
1407
|
+
// As for Undo: the editor's own element, or the page's body (nothing focused) where the editor owns the page; embed and viewer stay scoped.
|
|
1408
|
+
if (!listeners.get("save")?.size) return;
|
|
1409
|
+
if (!element.contains(event.target) && (event.target !== doc.body || scopedKeys)) return;
|
|
1410
|
+
event.preventDefault();
|
|
1411
|
+
requestSave();
|
|
1412
|
+
};
|
|
1413
|
+
doc.addEventListener("keydown", onSave);
|
|
1414
|
+
cleanups.push(() => doc.removeEventListener("keydown", onSave));
|
|
1415
|
+
}
|
|
1416
|
+
// The viewer edits nothing, so it has no Undo keys.
|
|
1417
|
+
if (options.shortcuts !== false && layout !== "viewer") {
|
|
1023
1418
|
// Ctrl/Cmd+Z and +Shift+Z undo and redo when nothing that edits text has focus (the code editor and the canvas's inputs keep their own).
|
|
1024
1419
|
const onKey = (event) => {
|
|
1025
1420
|
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z" || event.defaultPrevented) return;
|
|
1026
1421
|
if (event.target?.closest?.("input,textarea,select,[contenteditable],.cm-editor,dialog[open]")) return;
|
|
1027
|
-
if (!element.contains(event.target) && event.target !== doc.body) return;
|
|
1422
|
+
if (!element.contains(event.target) && (event.target !== doc.body || scopedKeys)) return;
|
|
1028
1423
|
event.preventDefault();
|
|
1029
1424
|
if (event.shiftKey) redo();
|
|
1030
1425
|
else undo();
|
|
@@ -1055,8 +1450,8 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1055
1450
|
media.addEventListener("change", change);
|
|
1056
1451
|
cleanups.push(() => media.removeEventListener("change", change));
|
|
1057
1452
|
};
|
|
1058
|
-
follow(options.density, "(pointer: coarse)", setDensity);
|
|
1059
|
-
follow(options.exportMode, "(max-width: 900px)", setExportMode);
|
|
1453
|
+
follow("density" in requested ? requested.density : options.density, "(pointer: coarse)", setDensity);
|
|
1454
|
+
follow("exportMode" in requested ? requested.exportMode : options.exportMode, "(max-width: 900px)", setExportMode);
|
|
1060
1455
|
}
|
|
1061
1456
|
|
|
1062
1457
|
// ---- the handle -------------------------------------------------------------------------------------------------------------
|
|
@@ -1087,11 +1482,23 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1087
1482
|
function applyReadOnly() {
|
|
1088
1483
|
pane?.setReadOnly(readOnly);
|
|
1089
1484
|
studio?.setReadOnly(readOnly);
|
|
1485
|
+
deckTitle?.setReadOnly(readOnly);
|
|
1486
|
+
designMenu?.setReadOnly(readOnly);
|
|
1487
|
+
strip?.setReadOnly?.(readOnly);
|
|
1090
1488
|
root.toggleAttribute("data-readonly", readOnly);
|
|
1091
1489
|
syncChrome();
|
|
1092
1490
|
studioCtl?.refresh();
|
|
1093
1491
|
}
|
|
1094
1492
|
function setDensity(setting) {
|
|
1493
|
+
if (destroyed) return;
|
|
1494
|
+
// Before the parts mount the setting is kept; the mount reads it instead of the `density` option.
|
|
1495
|
+
if (!partsReady) {
|
|
1496
|
+
requested.density = setting;
|
|
1497
|
+
density = densityFor(setting);
|
|
1498
|
+
root.setAttribute("data-density", density);
|
|
1499
|
+
toolbar?.setDensity(density);
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1095
1502
|
const next = densityFor(setting);
|
|
1096
1503
|
if (next === density) return;
|
|
1097
1504
|
density = next;
|
|
@@ -1100,11 +1507,20 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1100
1507
|
exportMenu?.setDensity(density);
|
|
1101
1508
|
presentButton?.setDensity(density);
|
|
1102
1509
|
studio?.setDensity(density);
|
|
1510
|
+
deckTitle?.setDensity(density);
|
|
1511
|
+
expandButton?.setDensity(density);
|
|
1512
|
+
designMenu?.setDensity(density);
|
|
1513
|
+
reviewPill?.setDensity(density);
|
|
1514
|
+
helpButton?.setDensity(density);
|
|
1515
|
+
actionsPart?.setDensity(density);
|
|
1516
|
+
toolbar?.setDensity(density);
|
|
1103
1517
|
mountSlideRow();
|
|
1104
1518
|
}
|
|
1105
1519
|
function setExportMode(setting) {
|
|
1520
|
+
if (destroyed) return;
|
|
1521
|
+
if (!partsReady) requested.exportMode = setting;
|
|
1106
1522
|
exportMode = exportModeFor(setting);
|
|
1107
|
-
exportMenu?.setMode(exportMode);
|
|
1523
|
+
exportMenu?.setMode(exportCollapsed ? "sheet" : exportMode);
|
|
1108
1524
|
}
|
|
1109
1525
|
function setDocument(document, openOptions = {}) {
|
|
1110
1526
|
if (destroyed || !mounted) throw new OPFEditorError("not-ready", "The editor is not mounted yet. Await `ready` first.");
|
|
@@ -1122,7 +1538,10 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1122
1538
|
openedText = text !== undefined && pane.getFormat() === "json" ? openOptions.text : undefined;
|
|
1123
1539
|
} else session.replaceDocument(document, { source: "load" });
|
|
1124
1540
|
// A document the host loaded is not the person's work: it is not autosaved or warned about until it is edited.
|
|
1125
|
-
if (openOptions.rebase !== false)
|
|
1541
|
+
if (openOptions.rebase !== false) {
|
|
1542
|
+
persistence?.rebase();
|
|
1543
|
+
recordBaseline();
|
|
1544
|
+
}
|
|
1126
1545
|
return true;
|
|
1127
1546
|
} catch (error) {
|
|
1128
1547
|
if (error?.code !== "invalid-opf-edit") throw error;
|
|
@@ -1147,10 +1566,160 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1147
1566
|
...(openOptions.name !== undefined ? { name: openOptions.name } : {}),
|
|
1148
1567
|
});
|
|
1149
1568
|
// Text the host loaded is not the person's work: it is not autosaved or warned about until it is edited.
|
|
1150
|
-
if (applied && openOptions.rebase !== false)
|
|
1569
|
+
if (applied && openOptions.rebase !== false) {
|
|
1570
|
+
persistence?.rebase();
|
|
1571
|
+
recordBaseline();
|
|
1572
|
+
}
|
|
1151
1573
|
return applied;
|
|
1152
1574
|
}
|
|
1153
1575
|
|
|
1576
|
+
// ---- CE-28: the deck name and the expanded editor ------------------------------------------------------------------------------
|
|
1577
|
+
function currentDeckName() {
|
|
1578
|
+
const name = session.snapshot().presentation?.name;
|
|
1579
|
+
return typeof name === "string" ? name : "";
|
|
1580
|
+
}
|
|
1581
|
+
// ---- CE-28 wave 2: unsaved changes, save, the host's actions and the shortcuts ----------------------------------------------------
|
|
1582
|
+
// The baseline is what was last loaded or saved: the document (as JSON) and the text of the source pane. Dirty is the document differing from
|
|
1583
|
+
// it, or the pane holding a draft that is not a valid deck (it never reaches the document) whose text differs from the baseline's.
|
|
1584
|
+
let baseline = null;
|
|
1585
|
+
let dirty = false;
|
|
1586
|
+
const documentKey = () => JSON.stringify(session.snapshot().presentation);
|
|
1587
|
+
const paneText = () => pane?.editor?.api?.getValue?.() ?? "";
|
|
1588
|
+
function recordBaseline() {
|
|
1589
|
+
if (!partsReady || destroyed) return;
|
|
1590
|
+
baseline = { document: documentKey(), text: paneText() };
|
|
1591
|
+
updateDirty();
|
|
1592
|
+
}
|
|
1593
|
+
function updateDirty() {
|
|
1594
|
+
if (!baseline || destroyed) return;
|
|
1595
|
+
const next = documentKey() !== baseline.document || Boolean(pane && !inSync && paneText() !== baseline.text);
|
|
1596
|
+
if (next === dirty) return;
|
|
1597
|
+
dirty = next;
|
|
1598
|
+
root.toggleAttribute("data-dirty", dirty);
|
|
1599
|
+
emit("dirty", { dirty });
|
|
1600
|
+
}
|
|
1601
|
+
function markSaved() {
|
|
1602
|
+
if (destroyed) return false;
|
|
1603
|
+
if (!mounted) return true;
|
|
1604
|
+
commitPending();
|
|
1605
|
+
recordBaseline();
|
|
1606
|
+
persistence?.markSaved();
|
|
1607
|
+
return true;
|
|
1608
|
+
}
|
|
1609
|
+
/** What a save writes: the document, and the source pane's text as it is (a draft too, so a host can keep it as text). */
|
|
1610
|
+
function saveDetail() {
|
|
1611
|
+
commitPending();
|
|
1612
|
+
const source = handle.readSource();
|
|
1613
|
+
return { document: session.snapshot().presentation, text: source.text, format: source.format, draft: Boolean(source.draft) };
|
|
1614
|
+
}
|
|
1615
|
+
function requestSave() {
|
|
1616
|
+
if (!mounted || destroyed) return false;
|
|
1617
|
+
emit("save", saveDetail());
|
|
1618
|
+
return true;
|
|
1619
|
+
}
|
|
1620
|
+
function setActions(list) {
|
|
1621
|
+
if (!Array.isArray(list)) throw new TypeError("setActions needs an array of actions.");
|
|
1622
|
+
hostActions = [...list];
|
|
1623
|
+
if (actionsPart) {
|
|
1624
|
+
actionsPart.setActions(hostActions);
|
|
1625
|
+
slots.actions.hidden = hostActions.length === 0;
|
|
1626
|
+
syncActionsPriority();
|
|
1627
|
+
toolbar?.update();
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
// A primary action (Save) stays in the bar: while one is listed the item's priority is 90, above everything but the title and the counter
|
|
1631
|
+
// (85 < 90 < 95), unless the host gave the item a priority of its own in `toolbar`.
|
|
1632
|
+
const explicitActionsPriority = Array.isArray(options.toolbar) && options.toolbar.some((item) => item && typeof item === "object" && item.id === "actions" && item.priority !== undefined);
|
|
1633
|
+
function syncActionsPriority() {
|
|
1634
|
+
if (!toolbar || explicitActionsPriority) return;
|
|
1635
|
+
toolbar.setItem("actions", { priority: hostActions.some((action) => action?.primary) ? 90 : TOOLBAR_ITEMS.actions.priority });
|
|
1636
|
+
}
|
|
1637
|
+
/** The keyboard shortcuts this editor binds now, for the help sheet (and, in the Studio, beside its command registry). */
|
|
1638
|
+
function shortcuts() {
|
|
1639
|
+
const list = [];
|
|
1640
|
+
const add = (id, label, keys, group) => list.push({ id, label, keys, group });
|
|
1641
|
+
if (studioCtl) {
|
|
1642
|
+
// The Studio's registry: every command with a key, as its own sheet lists them; then what the registry does not hold.
|
|
1643
|
+
for (const entry of studioCtl.commands.list()) if (entry.keys.length) add(entry.id, entry.label, entry.keys, entry.group ?? "Studio");
|
|
1644
|
+
} else {
|
|
1645
|
+
if (parts.history && layout !== "viewer" && options.shortcuts !== false) {
|
|
1646
|
+
add("undo", "Undo", ["Mod-z"], "Edit");
|
|
1647
|
+
add("redo", "Redo", ["Shift-Mod-z"], "Edit");
|
|
1648
|
+
}
|
|
1649
|
+
if (find) {
|
|
1650
|
+
add("find", "Find", ["Mod-f"], "Edit");
|
|
1651
|
+
add("replace", "Find and replace", ["Mod-h"], "Edit");
|
|
1652
|
+
}
|
|
1653
|
+
if (slideNav || counterNav) {
|
|
1654
|
+
add("previous", "Previous slide", ["ArrowLeft"], "Slides");
|
|
1655
|
+
add("next", "Next slide", ["ArrowRight"], "Slides");
|
|
1656
|
+
}
|
|
1657
|
+
if (presenter) add("present", "Present from this slide", ["F5"], "Slides");
|
|
1658
|
+
if (helpButton && !scopedKeys) add("help", "Keyboard shortcuts", ["?"], "Window");
|
|
1659
|
+
}
|
|
1660
|
+
return [...list, ...chromeShortcuts()];
|
|
1661
|
+
}
|
|
1662
|
+
/** The shortcuts of the chrome itself (save, the title, expand): the help sheet lists them, and so does the Studio's own sheet. */
|
|
1663
|
+
function chromeShortcuts() {
|
|
1664
|
+
const list = [];
|
|
1665
|
+
const add = (id, label, keys, group) => list.push({ id, label, keys, group });
|
|
1666
|
+
if (listeners.get("save")?.size && layout !== "viewer" && options.shortcuts !== false) add("save", "Save", ["Mod-s"], "Edit");
|
|
1667
|
+
if (deckTitle && !readOnly) {
|
|
1668
|
+
add("title-commit", "Rename: keep the name typed in the title", ["Enter"], "Presentation name");
|
|
1669
|
+
add("title-cancel", "Rename: put the name back", ["Escape"], "Presentation name");
|
|
1670
|
+
}
|
|
1671
|
+
if (expandCtl) add("expand-exit", "Leave the expanded editor", ["Escape"], "Window");
|
|
1672
|
+
return list;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function setDeckName(name) {
|
|
1676
|
+
if (destroyed) return false;
|
|
1677
|
+
if (typeof name !== "string") throw new TypeError("setDeckName needs the name as a string.");
|
|
1678
|
+
if (waiting()) {
|
|
1679
|
+
queue("deckName", () => setDeckName(name));
|
|
1680
|
+
return true;
|
|
1681
|
+
}
|
|
1682
|
+
// Not over text in the source pane that is not a valid deck (that would replace the draft), and an open canvas edit is committed first.
|
|
1683
|
+
if (readOnly || !inSync || !commitPending()) return false;
|
|
1684
|
+
const patch = deckNamePatch(session.snapshot().presentation?.name, name);
|
|
1685
|
+
if (!patch) return true;
|
|
1686
|
+
try {
|
|
1687
|
+
ui.applyPatch(patch, { source: "deck-name", rejectInvalid: true });
|
|
1688
|
+
return true;
|
|
1689
|
+
} catch (error) {
|
|
1690
|
+
report(error);
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
function expand(on = true) {
|
|
1695
|
+
if (destroyed || options.expand === false) return false;
|
|
1696
|
+
if (waiting()) {
|
|
1697
|
+
queue("expand", () => expand(on));
|
|
1698
|
+
return true;
|
|
1699
|
+
}
|
|
1700
|
+
return expandCtl ? expandCtl.expand(on) : false;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// The Studio's command registry is not there before mount: `register` keeps the command and adds it when the Studio mounts (the function it
|
|
1704
|
+
// returns removes it either way); `list` is empty and `run` finds nothing yet.
|
|
1705
|
+
const preMountCommands = {
|
|
1706
|
+
list: () => [],
|
|
1707
|
+
run: () => false,
|
|
1708
|
+
register(command) {
|
|
1709
|
+
if (!waiting() || !studioLayout) return () => {};
|
|
1710
|
+
let stop;
|
|
1711
|
+
let withdrawn = false;
|
|
1712
|
+
const entry = queue(Symbol("command"), () => {
|
|
1713
|
+
if (!withdrawn) stop = studioCtl?.commands.register(command);
|
|
1714
|
+
});
|
|
1715
|
+
return () => {
|
|
1716
|
+
withdrawn = true;
|
|
1717
|
+
const at = queued.indexOf(entry);
|
|
1718
|
+
if (at >= 0) queued.splice(at, 1);
|
|
1719
|
+
stop?.();
|
|
1720
|
+
};
|
|
1721
|
+
},
|
|
1722
|
+
};
|
|
1154
1723
|
let fontsRef = { value: undefined };
|
|
1155
1724
|
let releaseFonts;
|
|
1156
1725
|
let readyPromise;
|
|
@@ -1220,24 +1789,33 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1220
1789
|
},
|
|
1221
1790
|
/** CE-20: switch the Studio between "studio" and "playground" in place (same session, history, slide and selection). */
|
|
1222
1791
|
setLayout(next) {
|
|
1223
|
-
if (
|
|
1224
|
-
|
|
1792
|
+
if (studioCtl) return studioCtl.setLayout(next);
|
|
1793
|
+
// Before mount the switch is queued (true), as the Studio's own check of the name would refuse it later.
|
|
1794
|
+
if (!waiting() || !studioLayout) return false;
|
|
1795
|
+
if (next !== "studio" && next !== "playground") throw new RangeError('setLayout takes "studio" or "playground".');
|
|
1796
|
+
queue("layout", () => studioCtl?.setLayout(next));
|
|
1797
|
+
return true;
|
|
1225
1798
|
},
|
|
1226
1799
|
get zoom() {
|
|
1227
1800
|
return studioCtl?.zoom ?? 1;
|
|
1228
1801
|
},
|
|
1229
1802
|
setZoom(value) {
|
|
1230
|
-
studioCtl
|
|
1803
|
+
if (studioCtl) studioCtl.setZoom(value);
|
|
1804
|
+
else if (waiting() && studioLayout) queue("zoom", () => studioCtl?.setZoom(value));
|
|
1231
1805
|
},
|
|
1232
1806
|
fit(mode) {
|
|
1233
|
-
studioCtl
|
|
1807
|
+
if (studioCtl) studioCtl.fit(mode);
|
|
1808
|
+
else if (waiting() && studioLayout) queue("zoom", () => studioCtl?.fit(mode));
|
|
1234
1809
|
},
|
|
1235
1810
|
openLayout(layoutId, openOptions = {}) {
|
|
1236
|
-
|
|
1811
|
+
if (studioCtl) return studioCtl.openLayout(layoutId, openOptions);
|
|
1812
|
+
if (!waiting() || !studioLayout) return false;
|
|
1813
|
+
queue("openLayout", () => studioCtl?.openLayout(layoutId, openOptions));
|
|
1814
|
+
return true;
|
|
1237
1815
|
},
|
|
1238
|
-
/** CE-20: the Studio's command registry (`list`, `run`, `register`); empty outside the Studio. */
|
|
1816
|
+
/** CE-20: the Studio's command registry (`list`, `run`, `register`); empty outside the Studio. Commands registered before mount are added when it mounts. */
|
|
1239
1817
|
get commands() {
|
|
1240
|
-
return studioCtl?.commands ??
|
|
1818
|
+
return studioCtl?.commands ?? preMountCommands;
|
|
1241
1819
|
},
|
|
1242
1820
|
get view() {
|
|
1243
1821
|
return activeView;
|
|
@@ -1264,6 +1842,40 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1264
1842
|
get slots() {
|
|
1265
1843
|
return slots;
|
|
1266
1844
|
},
|
|
1845
|
+
/** CE-28: the toolbar the editor built (undefined without one) and the deck title part. */
|
|
1846
|
+
get toolbar() {
|
|
1847
|
+
return toolbar;
|
|
1848
|
+
},
|
|
1849
|
+
get deckTitle() {
|
|
1850
|
+
return deckTitle;
|
|
1851
|
+
},
|
|
1852
|
+
/** CE-28 wave 2: the toolbar's other parts, once mounted (a host that runs its own Review feeds `reviewPill.update(counts)`). */
|
|
1853
|
+
get designMenu() {
|
|
1854
|
+
return designMenu;
|
|
1855
|
+
},
|
|
1856
|
+
get reviewPill() {
|
|
1857
|
+
return reviewPill;
|
|
1858
|
+
},
|
|
1859
|
+
get helpButton() {
|
|
1860
|
+
return helpButton;
|
|
1861
|
+
},
|
|
1862
|
+
get toolbarActions() {
|
|
1863
|
+
return actionsPart;
|
|
1864
|
+
},
|
|
1865
|
+
getDeckName: currentDeckName,
|
|
1866
|
+
/** CE-28 wave 2: whether the document (or a source draft) differs from what was last loaded or saved. */
|
|
1867
|
+
get dirty() {
|
|
1868
|
+
return dirty;
|
|
1869
|
+
},
|
|
1870
|
+
markSaved,
|
|
1871
|
+
save: requestSave,
|
|
1872
|
+
setActions,
|
|
1873
|
+
shortcuts,
|
|
1874
|
+
setDeckName,
|
|
1875
|
+
expand,
|
|
1876
|
+
get expanded() {
|
|
1877
|
+
return Boolean(expandCtl?.expanded);
|
|
1878
|
+
},
|
|
1267
1879
|
getDocument() {
|
|
1268
1880
|
return session.snapshot().presentation;
|
|
1269
1881
|
},
|
|
@@ -1273,6 +1885,10 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1273
1885
|
setSlide: go,
|
|
1274
1886
|
goTo,
|
|
1275
1887
|
select(path, selectOptions = {}) {
|
|
1888
|
+
if (waiting()) {
|
|
1889
|
+
queue("select", () => handle.select(path, selectOptions));
|
|
1890
|
+
return studioLayout ? true : null;
|
|
1891
|
+
}
|
|
1276
1892
|
if (studioCtl) return studioCtl.selectTargets(path, selectOptions);
|
|
1277
1893
|
return canvas?.reveal(path, { focus: Boolean(selectOptions.focus) }) ?? null;
|
|
1278
1894
|
},
|
|
@@ -1290,6 +1906,10 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1290
1906
|
return canvas?.commit() !== false;
|
|
1291
1907
|
},
|
|
1292
1908
|
focus(target) {
|
|
1909
|
+
if (waiting()) {
|
|
1910
|
+
queue("focus", () => handle.focus(target));
|
|
1911
|
+
return true;
|
|
1912
|
+
}
|
|
1293
1913
|
if ((target ?? (pane ? "source" : "canvas")) === "source") {
|
|
1294
1914
|
pane?.editor?.api?.focus?.();
|
|
1295
1915
|
return Boolean(pane);
|
|
@@ -1299,6 +1919,10 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1299
1919
|
return Boolean(node);
|
|
1300
1920
|
},
|
|
1301
1921
|
openFind(openOptions) {
|
|
1922
|
+
if (waiting() && parts.find) {
|
|
1923
|
+
queue("find", () => handle.openFind(openOptions));
|
|
1924
|
+
return true;
|
|
1925
|
+
}
|
|
1302
1926
|
if (!find) return false;
|
|
1303
1927
|
find.openPanel(openOptions ?? {});
|
|
1304
1928
|
return true;
|
|
@@ -1311,10 +1935,12 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1311
1935
|
return { text, format: pane.getFormat(), draft: draftInvalid };
|
|
1312
1936
|
},
|
|
1313
1937
|
setName(name) {
|
|
1938
|
+
if (waiting()) requested.name = name;
|
|
1314
1939
|
pane?.setName(name);
|
|
1315
1940
|
},
|
|
1316
1941
|
getName() {
|
|
1317
|
-
return pane
|
|
1942
|
+
if (pane) return pane.getName();
|
|
1943
|
+
return waiting() ? (requested.name ?? options.source?.name) : undefined;
|
|
1318
1944
|
},
|
|
1319
1945
|
setReadOnly(value) {
|
|
1320
1946
|
readOnly = Boolean(value);
|
|
@@ -1342,6 +1968,15 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1342
1968
|
}
|
|
1343
1969
|
}
|
|
1344
1970
|
studioCtl?.destroy();
|
|
1971
|
+
expandButton?.destroy();
|
|
1972
|
+
expandCtl?.destroy();
|
|
1973
|
+
deckTitle?.destroy();
|
|
1974
|
+
designMenu?.destroy();
|
|
1975
|
+
reviewPill?.destroy();
|
|
1976
|
+
helpButton?.destroy();
|
|
1977
|
+
actionsPart?.destroy();
|
|
1978
|
+
counterNav?.destroy();
|
|
1979
|
+
toolbar?.destroy();
|
|
1345
1980
|
unbindKeys?.();
|
|
1346
1981
|
checking?.disconnect();
|
|
1347
1982
|
persistence?.destroy();
|
|
@@ -1371,16 +2006,49 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1371
2006
|
function mount(resolved) {
|
|
1372
2007
|
if (studioLayout && !studioShell) buildStudio();
|
|
1373
2008
|
fonts = resolved;
|
|
1374
|
-
density
|
|
1375
|
-
|
|
2009
|
+
// A density, an export mode or a view the host chose before the parts mounted wins over the option.
|
|
2010
|
+
density = densityFor("density" in requested ? requested.density : options.density);
|
|
2011
|
+
exportMode = exportModeFor("exportMode" in requested ? requested.exportMode : options.exportMode);
|
|
1376
2012
|
root.setAttribute("data-density", density);
|
|
2013
|
+
partsReady = true;
|
|
1377
2014
|
mountParts();
|
|
1378
2015
|
mounted = true;
|
|
1379
2016
|
if (readOnly) applyReadOnly();
|
|
1380
2017
|
say(MESSAGES.ready);
|
|
1381
|
-
|
|
2018
|
+
const wantedView = "view" in requested ? requested.view : options.view;
|
|
2019
|
+
activeView = "slide";
|
|
2020
|
+
if (wantedView && wantedView !== "slide") setView(wantedView);
|
|
1382
2021
|
else markView("slide");
|
|
1383
2022
|
syncChrome();
|
|
2023
|
+
// The document as it was opened is the baseline of `dirty`; calls queued before mount (a rename) are changes after it.
|
|
2024
|
+
recordBaseline();
|
|
2025
|
+
if (options.guardUnload) mountUnloadGuard();
|
|
2026
|
+
flushQueued();
|
|
2027
|
+
// The bar's parts are in their slots now: fit the row at once rather than on the next frame.
|
|
2028
|
+
toolbar?.update();
|
|
2029
|
+
}
|
|
2030
|
+
// CE-28 wave 2 (promoted from openpresentation.org's source unload guard): the browser asks before the page closes while there is unsaved work.
|
|
2031
|
+
// `guardUnload: true` asks while `dirty`; a function decides itself (a host with state of its own), given the editor.
|
|
2032
|
+
function mountUnloadGuard() {
|
|
2033
|
+
if (!win?.addEventListener) return;
|
|
2034
|
+
const decide = typeof options.guardUnload === "function" ? () => Boolean(options.guardUnload(handle)) : () => dirty;
|
|
2035
|
+
const onBeforeUnload = (event) => {
|
|
2036
|
+
if (destroyed) return;
|
|
2037
|
+
// Text typed a moment ago is applied first, so its verdict counts.
|
|
2038
|
+
pane?.getText();
|
|
2039
|
+
updateDirty();
|
|
2040
|
+
let warn = false;
|
|
2041
|
+
try {
|
|
2042
|
+
warn = decide();
|
|
2043
|
+
} catch (error) {
|
|
2044
|
+
report(error);
|
|
2045
|
+
}
|
|
2046
|
+
if (!warn) return;
|
|
2047
|
+
event.preventDefault();
|
|
2048
|
+
event.returnValue = "";
|
|
2049
|
+
};
|
|
2050
|
+
win.addEventListener("beforeunload", onBeforeUnload);
|
|
2051
|
+
cleanups.push(() => win.removeEventListener("beforeunload", onBeforeUnload));
|
|
1384
2052
|
}
|
|
1385
2053
|
readyPromise = new Promise((resolve, reject) => {
|
|
1386
2054
|
const start = (arrived) => {
|