@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/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,51 @@ 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, Present (CE-30: also where the host arranges the
|
|
168
|
+
// parts with `slots`), and Review (run without its panel where the layout 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.present === undefined && listed("present")) parts.present = true;
|
|
172
|
+
if (panels.review === undefined && listed("review") && !parts.review) {
|
|
173
|
+
parts.review = true;
|
|
174
|
+
// A layout without a Review panel gets none: the review runs headless for the pill.
|
|
175
|
+
parts.reviewHeadless = true;
|
|
176
|
+
}
|
|
125
177
|
return parts;
|
|
126
178
|
}
|
|
127
179
|
|
|
180
|
+
/**
|
|
181
|
+
* 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
|
|
182
|
+
* 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>`).
|
|
183
|
+
*/
|
|
184
|
+
function resolveToolbar(options, layout, parts, views, given, items) {
|
|
185
|
+
if (STUDIO_LAYOUTS.has(layout)) return [];
|
|
186
|
+
const preset = options.toolbar === undefined || options.toolbar === true;
|
|
187
|
+
const available = {
|
|
188
|
+
// 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).
|
|
189
|
+
design: !(preset && parts.design && layout !== "embed"),
|
|
190
|
+
review: parts.review,
|
|
191
|
+
help: true,
|
|
192
|
+
actions: true,
|
|
193
|
+
title: true,
|
|
194
|
+
// 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).
|
|
195
|
+
history: parts.history || (layout === "slots" && options.panels?.history !== false),
|
|
196
|
+
counter: parts.canvas,
|
|
197
|
+
views: views.length > 1,
|
|
198
|
+
find: parts.find,
|
|
199
|
+
autosave: parts.persistence,
|
|
200
|
+
export: parts.export,
|
|
201
|
+
present: parts.present,
|
|
202
|
+
expand: true,
|
|
203
|
+
more: true,
|
|
204
|
+
spacer: true,
|
|
205
|
+
"|": true,
|
|
206
|
+
};
|
|
207
|
+
// A host's own item (`{ id, priority }` with a name of its own) is always built: its slot is the host's to fill (`editor.toolbar.slot(id)`).
|
|
208
|
+
const host = (id) => !Object.hasOwn(TOOLBAR_ITEMS, id);
|
|
209
|
+
return items.filter((item) => !RESERVED_ITEMS.has(item.id) && (host(item.id) || available[item.id]) && !(ITEM_SLOT_NAMES.has(item.id) && given[itemSlot(item.id)]));
|
|
210
|
+
}
|
|
211
|
+
|
|
128
212
|
/**
|
|
129
213
|
* 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
214
|
* 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 +223,10 @@ export function createOpfEditor(element, options = {}) {
|
|
|
139
223
|
const cleanups = [];
|
|
140
224
|
let destroyed = false;
|
|
141
225
|
let mounted = false;
|
|
142
|
-
|
|
226
|
+
// The viewer is read-only unless the host says otherwise.
|
|
227
|
+
let readOnly = Boolean(options.readOnly ?? options.layout === "viewer");
|
|
143
228
|
let lastError;
|
|
229
|
+
const densityFor = (setting) => (setting === "touch" || setting === "comfortable" ? setting : win?.matchMedia?.("(pointer: coarse)").matches ? "touch" : "comfortable");
|
|
144
230
|
|
|
145
231
|
// ---- events -----------------------------------------------------------------------------------------------------------------
|
|
146
232
|
function off(name, listener) {
|
|
@@ -153,6 +239,11 @@ export function createOpfEditor(element, options = {}) {
|
|
|
153
239
|
return () => off(name, listener);
|
|
154
240
|
}
|
|
155
241
|
function emit(name, detail = {}) {
|
|
242
|
+
// 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.
|
|
243
|
+
if (name === "kept") recordBaseline();
|
|
244
|
+
// The host's actions read state the editor does not change through the document: whether it is saved, expanded, the slide on show.
|
|
245
|
+
// `ready` too: a host's `enabled: () => editor.dirty` reads its own `editor` variable, assigned only once `createOpfEditor` has returned.
|
|
246
|
+
if (name === "dirty" || name === "expand" || name === "slide" || name === "ready") actionsPart?.refresh();
|
|
156
247
|
for (const listener of [...(listeners.get(name) ?? [])]) {
|
|
157
248
|
try {
|
|
158
249
|
listener(detail);
|
|
@@ -183,11 +274,26 @@ export function createOpfEditor(element, options = {}) {
|
|
|
183
274
|
|
|
184
275
|
// ---- layout, parts and slots ------------------------------------------------------------------------------------------------
|
|
185
276
|
const given = options.slots ?? {};
|
|
186
|
-
|
|
187
|
-
|
|
277
|
+
// CE-28: slots for the toolbar's items only (mode B: the title in the host's own header) keep the built layout.
|
|
278
|
+
const chromeOnly = Boolean(options.slots) && Object.keys(options.slots).every((name) => CHROME_SLOTS.has(name));
|
|
279
|
+
const layout = options.slots && !STUDIO_LAYOUTS.has(options.layout) && !chromeOnly ? "slots" : (options.layout ?? "full");
|
|
280
|
+
if (!LAYOUTS.includes(layout)) throw new RangeError('layout must be "full", "canvas", "embed", "viewer", "source", "studio" or "playground", or pass `slots`.');
|
|
188
281
|
const studioLayout = STUDIO_LAYOUTS.has(layout);
|
|
189
|
-
const
|
|
282
|
+
const asked = toolbarSpec(options, layout);
|
|
283
|
+
const icons = normalizeIcons(options.icons, ICON_NAMES, "createOpfEditor icons");
|
|
284
|
+
// The icons one part takes, or nothing (the part keeps its own) when the host gave none of its names.
|
|
285
|
+
const iconsFor = (part) => {
|
|
286
|
+
const picked = Object.fromEntries(PART_ICONS[part].filter((name) => name in icons).map((name) => [name, icons[name]]));
|
|
287
|
+
return Object.keys(picked).length ? { icons: picked } : {};
|
|
288
|
+
};
|
|
289
|
+
const parts = resolveParts(options, layout, given, asked);
|
|
190
290
|
const views = VIEWS.filter((view) => view === "slide" || parts[view]);
|
|
291
|
+
const toolbarItems = resolveToolbar(options, layout, parts, views, given, asked);
|
|
292
|
+
// 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.
|
|
293
|
+
const scopedKeys = SCOPED_LAYOUTS.has(layout);
|
|
294
|
+
// 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).
|
|
295
|
+
const slideActions = options.slideActions ?? (layout === "full" || layout === "embed");
|
|
296
|
+
const keyTarget = scopedKeys ? element : doc;
|
|
191
297
|
if (options.view !== undefined && !views.includes(options.view)) throw new RangeError(`view "${options.view}" is not available in this layout (${views.join(", ")}).`);
|
|
192
298
|
if (options.persistence && parts.persistence && (typeof options.persistence !== "object" || typeof options.persistence.key !== "string" || !options.persistence.key)) {
|
|
193
299
|
throw new TypeError("persistence needs a `key` that names the document.");
|
|
@@ -197,11 +303,12 @@ export function createOpfEditor(element, options = {}) {
|
|
|
197
303
|
root.className = "opf-workspace opf-ui";
|
|
198
304
|
// 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
305
|
// editor only adds one empty node for its overlays.
|
|
200
|
-
if (
|
|
306
|
+
if (layout === "slots") element.append(root);
|
|
201
307
|
else element.replaceChildren(root);
|
|
202
308
|
const themeController = createThemeController(element, options.theme);
|
|
203
309
|
let slots = { ...given };
|
|
204
310
|
let controls = {};
|
|
311
|
+
let toolbar;
|
|
205
312
|
let studioShell = null;
|
|
206
313
|
let overlay = root;
|
|
207
314
|
// 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 +323,22 @@ export function createOpfEditor(element, options = {}) {
|
|
|
216
323
|
}
|
|
217
324
|
if (layout === "slots") root.setAttribute("data-opf-layout", "slots");
|
|
218
325
|
else if (!studioLayout) {
|
|
219
|
-
const built = buildWorkspaceShell(root, { parts, views, layout, labels: options.labels });
|
|
326
|
+
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
327
|
slots = { ...built.slots, ...given };
|
|
221
328
|
controls = built.controls;
|
|
329
|
+
toolbar = built.toolbar;
|
|
330
|
+
}
|
|
331
|
+
// CE-28: a host that arranges the page itself and gives `slots.toolbar` gets the toolbar there.
|
|
332
|
+
if (layout === "slots" && given.toolbar && toolbarItems.length) {
|
|
333
|
+
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") });
|
|
334
|
+
slots = { ...built.slots, ...given };
|
|
335
|
+
Object.assign(controls, built.controls);
|
|
336
|
+
toolbar = built.toolbar;
|
|
337
|
+
}
|
|
338
|
+
// Mode B: the history and the view switch in the host's own elements.
|
|
339
|
+
if (!studioLayout) {
|
|
340
|
+
if (given.history && parts.history) Object.assign(controls, buildItemControls("history", given.history, { labels: options.labels }));
|
|
341
|
+
if (given.views && views.length > 1) Object.assign(controls, buildItemControls("views", given.views, { views, labels: options.labels }));
|
|
222
342
|
}
|
|
223
343
|
overlay = slots.overlay ?? root;
|
|
224
344
|
// 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 +349,18 @@ export function createOpfEditor(element, options = {}) {
|
|
|
229
349
|
let canvas;
|
|
230
350
|
let pane;
|
|
231
351
|
let slideNav;
|
|
352
|
+
let counterNav;
|
|
353
|
+
let deckTitle;
|
|
354
|
+
let expandCtl;
|
|
355
|
+
let expandButton;
|
|
356
|
+
let deckName;
|
|
357
|
+
let designMenu;
|
|
358
|
+
let reviewPill;
|
|
359
|
+
let helpButton;
|
|
360
|
+
let actionsPart;
|
|
361
|
+
let hostActions = Array.isArray(options.actions) ? [...options.actions] : [];
|
|
362
|
+
let reviewCounts = null;
|
|
363
|
+
let exportCollapsed = false;
|
|
232
364
|
let strip;
|
|
233
365
|
let slideManager;
|
|
234
366
|
let sorter;
|
|
@@ -293,7 +425,10 @@ export function createOpfEditor(element, options = {}) {
|
|
|
293
425
|
}
|
|
294
426
|
let frameSize = { width: 0, height: 0 };
|
|
295
427
|
const draftMemory = createSourceMemory();
|
|
296
|
-
|
|
428
|
+
// CE-30: `variables: { focusSource }` is "resolved" with the host's own code editor: a press on text without a field of its own focuses that.
|
|
429
|
+
const variablesObject = options.variables !== null && typeof options.variables === "object" ? options.variables : undefined;
|
|
430
|
+
const variablesMode = options.variables === "resolved" || variablesObject ? "resolved" : "authored";
|
|
431
|
+
if (variablesObject && variablesObject.focusSource !== undefined && typeof variablesObject.focusSource !== "function") throw new TypeError("variables.focusSource must be a function.");
|
|
297
432
|
const fitMode = studioLayout ? false : options.fit === undefined ? (layout === "slots" ? false : "contain") : options.fit;
|
|
298
433
|
|
|
299
434
|
const say = (message) => {
|
|
@@ -301,7 +436,6 @@ export function createOpfEditor(element, options = {}) {
|
|
|
301
436
|
if (slots.status) slots.status.textContent = message;
|
|
302
437
|
emit("status", { message });
|
|
303
438
|
};
|
|
304
|
-
const densityFor = (setting) => (setting === "touch" || setting === "comfortable" ? setting : win?.matchMedia?.("(pointer: coarse)").matches ? "touch" : "comfortable");
|
|
305
439
|
const exportModeFor = (setting) => (setting === "sheet" || setting === "menu" ? setting : win?.matchMedia?.("(max-width: 900px)").matches ? "sheet" : "menu");
|
|
306
440
|
|
|
307
441
|
// The options a draw uses: the host's, the image hook, and the registered catalogs. `variables` follow the document in "resolved" mode.
|
|
@@ -313,6 +447,12 @@ export function createOpfEditor(element, options = {}) {
|
|
|
313
447
|
}
|
|
314
448
|
const layoutFor = (deck, index) => layoutOptionsFor(fonts, deck, index, catalogs());
|
|
315
449
|
|
|
450
|
+
// The one slide index on every part that shows it: the slide row's navigator, the toolbar's counter and the strip.
|
|
451
|
+
function showIndex(index) {
|
|
452
|
+
slideNav?.setSlide(index);
|
|
453
|
+
counterNav?.setSlide(index);
|
|
454
|
+
strip?.setSlide(index);
|
|
455
|
+
}
|
|
316
456
|
// ---- navigation and the refresh loop ----------------------------------------------------------------------------------------
|
|
317
457
|
function renderViews(deck) {
|
|
318
458
|
// The Studio's filmstrip draws the thumbnails it shows itself, as they scroll in.
|
|
@@ -348,8 +488,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
348
488
|
const deck = session.snapshot().presentation;
|
|
349
489
|
slideIndex = Math.max(0, Math.min(slideIndex, deck.slides.length - 1));
|
|
350
490
|
renderViews(deck);
|
|
351
|
-
|
|
352
|
-
strip?.setSlide(slideIndex);
|
|
491
|
+
showIndex(slideIndex);
|
|
353
492
|
fonts?.registry?.clearSubstitutions?.();
|
|
354
493
|
if (canvas && canvas.slideIndex !== slideIndex) canvas.setSlide(slideIndex);
|
|
355
494
|
emit("sync", syncDetail(deck));
|
|
@@ -403,8 +542,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
403
542
|
if (canvas.setSlide(index)) {
|
|
404
543
|
const changed = index !== slideIndex;
|
|
405
544
|
slideIndex = index;
|
|
406
|
-
|
|
407
|
-
strip?.setSlide(index);
|
|
545
|
+
showIndex(index);
|
|
408
546
|
if (changed) emit("slide", { index, count: slides.length });
|
|
409
547
|
refresh();
|
|
410
548
|
return true;
|
|
@@ -412,8 +550,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
412
550
|
} catch (error) {
|
|
413
551
|
report(error);
|
|
414
552
|
}
|
|
415
|
-
|
|
416
|
-
strip?.setSlide(canvas.slideIndex);
|
|
553
|
+
showIndex(canvas.slideIndex);
|
|
417
554
|
return false;
|
|
418
555
|
}
|
|
419
556
|
function goTo(index, path) {
|
|
@@ -435,6 +572,11 @@ export function createOpfEditor(element, options = {}) {
|
|
|
435
572
|
presentButton?.setDisabled(!inSync || slides.length === 0);
|
|
436
573
|
if (controls.undo) controls.undo.disabled = !inSync || readOnly || !session.canUndo;
|
|
437
574
|
if (controls.redo) controls.redo.disabled = !inSync || readOnly || !session.canRedo;
|
|
575
|
+
// 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).
|
|
576
|
+
deckTitle?.setDisabled(!inSync);
|
|
577
|
+
designMenu?.setDisabled(!inSync);
|
|
578
|
+
actionsPart?.setDisabled(!inSync);
|
|
579
|
+
actionsPart?.refresh();
|
|
438
580
|
// 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.
|
|
439
581
|
const frame = slots.canvas && (slots.slide ?? slots.canvas);
|
|
440
582
|
// The Studio keeps selection, zoom and Present while read-only (its commands say why they are off); only invalid text locks the slide.
|
|
@@ -558,8 +700,10 @@ export function createOpfEditor(element, options = {}) {
|
|
|
558
700
|
if (!partsReady || destroyed) return;
|
|
559
701
|
unbindKeys?.();
|
|
560
702
|
slideNav?.destroy();
|
|
703
|
+
counterNav?.destroy();
|
|
561
704
|
strip?.destroy();
|
|
562
705
|
slideNav = undefined;
|
|
706
|
+
counterNav = undefined;
|
|
563
707
|
strip = undefined;
|
|
564
708
|
// The Studio shows its filmstrip; the slide row is the Playground preset's, mounted only while that preset is on.
|
|
565
709
|
if (studioLayout && (studioCtl?.layout ?? layout) !== "playground") return;
|
|
@@ -568,12 +712,27 @@ export function createOpfEditor(element, options = {}) {
|
|
|
568
712
|
editor: ui,
|
|
569
713
|
slideIndex,
|
|
570
714
|
density,
|
|
715
|
+
...iconsFor("navigator"),
|
|
571
716
|
...(options.labels?.navigator ? { labels: { navigation: options.labels.navigator } } : {}),
|
|
572
717
|
onChange: (index) => {
|
|
573
718
|
go(index);
|
|
574
719
|
},
|
|
575
720
|
});
|
|
576
|
-
unbindKeys = slideNav.bindKeys(
|
|
721
|
+
unbindKeys = slideNav.bindKeys(keyTarget);
|
|
722
|
+
}
|
|
723
|
+
// 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.
|
|
724
|
+
if (parts.canvas && slots.counter) {
|
|
725
|
+
counterNav = createSlideNavigator(slots.counter, {
|
|
726
|
+
editor: ui,
|
|
727
|
+
slideIndex,
|
|
728
|
+
density,
|
|
729
|
+
...iconsFor("navigator"),
|
|
730
|
+
...(options.labels?.counter ? { labels: { navigation: options.labels.counter } } : {}),
|
|
731
|
+
onChange: (index) => {
|
|
732
|
+
go(index);
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
if (!slideNav) unbindKeys = counterNav.bindKeys(keyTarget);
|
|
577
736
|
}
|
|
578
737
|
if (parts.thumbnails && slots.thumbnails) {
|
|
579
738
|
strip = createThumbnailStrip(slots.thumbnails, {
|
|
@@ -581,12 +740,17 @@ export function createOpfEditor(element, options = {}) {
|
|
|
581
740
|
fonts,
|
|
582
741
|
slideIndex,
|
|
583
742
|
density,
|
|
743
|
+
...iconsFor("strip"),
|
|
584
744
|
...(options.labels?.thumbnails ? { labels: { navigation: options.labels.thumbnails } } : {}),
|
|
585
745
|
renderOptions: renderOptions(),
|
|
586
746
|
onSelect: (index) => {
|
|
587
747
|
if (index !== slideIndex) go(index);
|
|
588
748
|
},
|
|
589
749
|
onError: (error) => report(error instanceof Error ? error : new Error("Thumbnails unavailable")),
|
|
750
|
+
// CE-28 wave 2: "+" and the thumbnail menu (duplicate, delete, hide, move), each one undoable change; "+" offers the deck's layouts.
|
|
751
|
+
...(slideActions
|
|
752
|
+
? { actions: true, readOnly, onStatus: say, ...(listSwitchOptions ? { layoutOptions: (presentation) => listSwitchOptions(presentation, "layouts", { catalogs: catalogs() }) } : {}) }
|
|
753
|
+
: {}),
|
|
590
754
|
});
|
|
591
755
|
}
|
|
592
756
|
}
|
|
@@ -659,6 +823,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
659
823
|
sourceState = { status: made.status, format, text: shown };
|
|
660
824
|
syncChrome();
|
|
661
825
|
emit("source", sourceState);
|
|
826
|
+
updateDirty();
|
|
662
827
|
return made.findings;
|
|
663
828
|
},
|
|
664
829
|
onCopy: async (file) => {
|
|
@@ -738,6 +903,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
738
903
|
},
|
|
739
904
|
onSelect: (value) => {
|
|
740
905
|
selection = { path: value.path, value: value.value };
|
|
906
|
+
actionsPart?.refresh();
|
|
741
907
|
// The Studio emits its own selection (a list of nodes); the canvas's single selection is internal there.
|
|
742
908
|
if (!studioLayout) emit("selection", { path: value.path, value: value.value, slideIndex, element: value.element });
|
|
743
909
|
design?.refresh();
|
|
@@ -772,8 +938,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
772
938
|
if (event.slideIndex !== slideIndex) {
|
|
773
939
|
// The canvas moved to another slide itself (its slide was removed, say): the rest of the parts follow it.
|
|
774
940
|
slideIndex = event.slideIndex;
|
|
775
|
-
|
|
776
|
-
strip?.setSlide(slideIndex);
|
|
941
|
+
showIndex(slideIndex);
|
|
777
942
|
emit("slide", { index: slideIndex, count: slides.length });
|
|
778
943
|
refresh();
|
|
779
944
|
}
|
|
@@ -785,7 +950,14 @@ export function createOpfEditor(element, options = {}) {
|
|
|
785
950
|
cleanups.push(
|
|
786
951
|
installCanvasVariableEditing(slots.canvas, ui, canvas, {
|
|
787
952
|
resolved: () => !authoredTemplate,
|
|
788
|
-
focusSource: () =>
|
|
953
|
+
focusSource: () => {
|
|
954
|
+
if (!variablesObject?.focusSource) return pane?.editor?.api?.focus?.();
|
|
955
|
+
try {
|
|
956
|
+
variablesObject.focusSource();
|
|
957
|
+
} catch (error) {
|
|
958
|
+
report(error);
|
|
959
|
+
}
|
|
960
|
+
},
|
|
789
961
|
}),
|
|
790
962
|
);
|
|
791
963
|
}
|
|
@@ -884,6 +1056,8 @@ export function createOpfEditor(element, options = {}) {
|
|
|
884
1056
|
tab.textContent = found ? `${name} (${counts.total})` : name;
|
|
885
1057
|
tab.setAttribute("aria-label", found ? `${name}, ${counts.total} finding${counts.total === 1 ? "" : "s"}` : name);
|
|
886
1058
|
}
|
|
1059
|
+
reviewCounts = counts;
|
|
1060
|
+
reviewPill?.update(counts);
|
|
887
1061
|
emit("review", counts);
|
|
888
1062
|
},
|
|
889
1063
|
});
|
|
@@ -937,15 +1111,27 @@ export function createOpfEditor(element, options = {}) {
|
|
|
937
1111
|
present();
|
|
938
1112
|
},
|
|
939
1113
|
density,
|
|
1114
|
+
...iconsFor("present"),
|
|
940
1115
|
});
|
|
941
1116
|
}
|
|
942
1117
|
cleanups.push(
|
|
943
|
-
bindPresentShortcut(
|
|
1118
|
+
bindPresentShortcut(keyTarget, () => {
|
|
944
1119
|
present();
|
|
945
1120
|
}),
|
|
946
1121
|
);
|
|
947
1122
|
}
|
|
948
1123
|
if (parts.find && slots.find) mountFind();
|
|
1124
|
+
// 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.
|
|
1125
|
+
if (parts.review && !slots.review && slots.reviewPill) {
|
|
1126
|
+
// The panel owns its container's attributes; the hidden wrapper keeps it out of sight.
|
|
1127
|
+
const hidden = doc.createElement("div");
|
|
1128
|
+
hidden.hidden = true;
|
|
1129
|
+
hidden.setAttribute("data-opf-headless", "review");
|
|
1130
|
+
slots.review = doc.createElement("div");
|
|
1131
|
+
slots.review.setAttribute("data-opf-slot", "review");
|
|
1132
|
+
hidden.append(slots.review);
|
|
1133
|
+
root.append(hidden);
|
|
1134
|
+
}
|
|
949
1135
|
if (parts.review && slots.review) mountReview();
|
|
950
1136
|
if (parts.design && slots.design) {
|
|
951
1137
|
const configured = typeof options.design === "object" ? options.design : {};
|
|
@@ -963,6 +1149,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
963
1149
|
});
|
|
964
1150
|
}
|
|
965
1151
|
if (parts.persistence) mountPersistence();
|
|
1152
|
+
mountChrome();
|
|
966
1153
|
mountControls();
|
|
967
1154
|
mountEnvironment();
|
|
968
1155
|
if (studioShell && canvas) mountStudio();
|
|
@@ -979,6 +1166,14 @@ export function createOpfEditor(element, options = {}) {
|
|
|
979
1166
|
if (event.type !== "catalogs") {
|
|
980
1167
|
syncChrome();
|
|
981
1168
|
emit("change", { document: event.snapshot.presentation, canUndo: event.snapshot.canUndo, canRedo: event.snapshot.canRedo, event });
|
|
1169
|
+
// CE-28: the deck's name changed, from whatever changed it (the title, the source, undo, the host).
|
|
1170
|
+
const name = currentDeckName();
|
|
1171
|
+
if (name !== deckName) {
|
|
1172
|
+
const previous = deckName;
|
|
1173
|
+
deckName = name;
|
|
1174
|
+
emit("deckName", { name, previous });
|
|
1175
|
+
}
|
|
1176
|
+
updateDirty();
|
|
982
1177
|
}
|
|
983
1178
|
refresh();
|
|
984
1179
|
}),
|
|
@@ -1045,10 +1240,162 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1045
1240
|
getName: () => pane?.getName(),
|
|
1046
1241
|
setName: (name) => pane?.setName(name),
|
|
1047
1242
|
layoutStudio: () => studio,
|
|
1243
|
+
// CE-28 wave 2: the rows the Studio's shortcut sheet adds to its commands, the same the help item lists.
|
|
1244
|
+
extraShortcuts: () => chromeShortcuts(),
|
|
1048
1245
|
mountSlideRow: () => mountSlideRow(),
|
|
1049
1246
|
});
|
|
1050
1247
|
}
|
|
1051
1248
|
|
|
1249
|
+
// ---- CE-28: the toolbar's parts --------------------------------------------------------------------------------------------------
|
|
1250
|
+
// What an open edit needs before the document changes from outside it: the canvas commits it, text typed in the source is applied.
|
|
1251
|
+
function commitPending() {
|
|
1252
|
+
if (canvas?.commit() === false) return false;
|
|
1253
|
+
pane?.getText();
|
|
1254
|
+
return true;
|
|
1255
|
+
}
|
|
1256
|
+
const chromeLabels = () => options.labels ?? {};
|
|
1257
|
+
function mountChrome() {
|
|
1258
|
+
deckName = currentDeckName();
|
|
1259
|
+
if (slots.title && createDeckTitle) {
|
|
1260
|
+
deckTitle = createDeckTitle(slots.title, {
|
|
1261
|
+
editor: ui,
|
|
1262
|
+
...(chromeLabels().untitled ? { placeholder: chromeLabels().untitled } : {}),
|
|
1263
|
+
...(chromeLabels().title ? { label: chromeLabels().title } : {}),
|
|
1264
|
+
readOnly,
|
|
1265
|
+
density,
|
|
1266
|
+
beforeCommit: () => inSync && commitPending(),
|
|
1267
|
+
onError: (error) => report(error),
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
// The editor fills the window (or, with `expand.fullscreen`, the screen): the mount element becomes a fixed overlay, in place.
|
|
1271
|
+
if (createExpandController && options.expand !== false) {
|
|
1272
|
+
const labels = { ...(chromeLabels().expand ? { expand: chromeLabels().expand } : {}), ...(chromeLabels().collapse ? { collapse: chromeLabels().collapse } : {}) };
|
|
1273
|
+
const configured = typeof options.expand === "object" && options.expand ? options.expand : {};
|
|
1274
|
+
const target = configured.target ?? element;
|
|
1275
|
+
// The host's own elements for the bar and its items that sit outside what expands stay usable over the overlay.
|
|
1276
|
+
const keep = ["toolbar", ...CHROME_SLOTS].map((name) => given[name]).filter((node, at, all) => node && all.indexOf(node) === at && !target.contains(node));
|
|
1277
|
+
expandCtl = createExpandController(target, {
|
|
1278
|
+
labels,
|
|
1279
|
+
onStatus: say,
|
|
1280
|
+
...(keep.length ? { keep } : {}),
|
|
1281
|
+
...(configured.fullscreen !== undefined ? { fullscreen: configured.fullscreen } : {}),
|
|
1282
|
+
onChange: ({ expanded }) => {
|
|
1283
|
+
if (destroyed) return;
|
|
1284
|
+
root.toggleAttribute("data-expanded", expanded);
|
|
1285
|
+
emit("expand", { expanded });
|
|
1286
|
+
},
|
|
1287
|
+
});
|
|
1288
|
+
if (slots.expand && createExpandButton) expandButton = createExpandButton(slots.expand, { controller: expandCtl, density, labels, ...iconsFor("expand") });
|
|
1289
|
+
}
|
|
1290
|
+
mountChromeParts();
|
|
1291
|
+
if (!toolbar) return;
|
|
1292
|
+
// What each item offers in the more menu once it has collapsed into it, read when the menu opens (so always the current state).
|
|
1293
|
+
const text = { undo: "Undo", redo: "Redo", find: "Find", ...chromeLabels() };
|
|
1294
|
+
const register = (name, part) => cleanups.push(toolbar.mount(name, part));
|
|
1295
|
+
register("history", {
|
|
1296
|
+
menuItems: () => [
|
|
1297
|
+
{ id: "undo", label: text.undo, disabled: Boolean(controls.undo?.disabled), run: () => undo() },
|
|
1298
|
+
{ id: "redo", label: text.redo, disabled: Boolean(controls.redo?.disabled), run: () => redo() },
|
|
1299
|
+
],
|
|
1300
|
+
});
|
|
1301
|
+
register("find", { menuItems: () => (find ? [{ id: "find", label: `${text.find}…`, run: () => find.openPanel({}) }] : []) });
|
|
1302
|
+
register("views", { menuItems: () => views.map((view) => ({ id: `view:${view}`, label: viewLabel(view), pressed: activeView === view, run: () => setView(view, { focus: true }) })) });
|
|
1303
|
+
register("counter", {
|
|
1304
|
+
menuItems: () => [
|
|
1305
|
+
{ id: "previous", label: "Previous slide", disabled: slideIndex <= 0, run: () => go(slideIndex - 1) },
|
|
1306
|
+
{ id: "next", label: "Next slide", disabled: slideIndex >= slides.length - 1, run: () => go(slideIndex + 1) },
|
|
1307
|
+
],
|
|
1308
|
+
});
|
|
1309
|
+
register("present", { menuItems: () => (presenter ? [{ id: "present", label: "Present", disabled: !inSync || slides.length === 0, run: () => present() }] : []) });
|
|
1310
|
+
// 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).
|
|
1311
|
+
register("export", { menuItems: () => (exportMenu ? [{ id: "export", label: text.exportItem ?? "Export…", run: () => openExportFromMore() }] : []) });
|
|
1312
|
+
if (deckTitle) register("title", deckTitle);
|
|
1313
|
+
if (expandButton) register("expand", expandButton);
|
|
1314
|
+
if (designMenu) register("design", designMenu);
|
|
1315
|
+
if (reviewPill) register("review", reviewPill);
|
|
1316
|
+
if (helpButton) register("help", helpButton);
|
|
1317
|
+
if (actionsPart) register("actions", actionsPart);
|
|
1318
|
+
}
|
|
1319
|
+
// 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
|
|
1320
|
+
// bar widened meanwhile), to the Export button back in the bar, else to the first control of the bar: never to the body.
|
|
1321
|
+
function openExportFromMore() {
|
|
1322
|
+
if (!exportMenu) return;
|
|
1323
|
+
const shown = (node) => Boolean(node?.isConnected && node.getClientRects().length && win.getComputedStyle(node).visibility !== "hidden");
|
|
1324
|
+
const back = () => {
|
|
1325
|
+
const more = toolbar?.element.querySelector(".opf-tb-more-button");
|
|
1326
|
+
const candidates = [more, exportMenu?.trigger, ...(toolbar ? toolbar.element.querySelectorAll("button, input") : [])];
|
|
1327
|
+
candidates.find((node) => shown(node) && !node.disabled)?.focus();
|
|
1328
|
+
};
|
|
1329
|
+
const watch = new win.MutationObserver(() => {
|
|
1330
|
+
if (exportMenu?.trigger.getAttribute("aria-expanded") === "true") return;
|
|
1331
|
+
watch.disconnect();
|
|
1332
|
+
if (destroyed) return;
|
|
1333
|
+
const active = doc.activeElement;
|
|
1334
|
+
if (!active || active === doc.body || !shown(active)) back();
|
|
1335
|
+
});
|
|
1336
|
+
watch.observe(exportMenu.trigger, { attributes: true, attributeFilter: ["aria-expanded"] });
|
|
1337
|
+
cleanups.push(() => watch.disconnect());
|
|
1338
|
+
exportMenu.open("first");
|
|
1339
|
+
}
|
|
1340
|
+
// 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.
|
|
1341
|
+
function onToolbarCollapse({ collapsed }) {
|
|
1342
|
+
exportCollapsed = collapsed.includes("export");
|
|
1343
|
+
exportMenu?.setMode(exportCollapsed ? "sheet" : exportMode);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// CE-28 wave 2: the Design quick menu, the review pill, the help sheet and the host's actions.
|
|
1347
|
+
function mountChromeParts() {
|
|
1348
|
+
const labels = chromeLabels();
|
|
1349
|
+
const designPanel = () => Boolean(design || controls.tabs?.design || studioShell?.tabs?.design);
|
|
1350
|
+
if (slots.designMenu && createDesignMenu) {
|
|
1351
|
+
designMenu = createDesignMenu(slots.designMenu, {
|
|
1352
|
+
editor: ui,
|
|
1353
|
+
catalogs,
|
|
1354
|
+
getSlideIndex: () => slideIndex,
|
|
1355
|
+
density,
|
|
1356
|
+
readOnly,
|
|
1357
|
+
...iconsFor("designMenu"),
|
|
1358
|
+
...(labels.designMenu ? { labels: labels.designMenu } : {}),
|
|
1359
|
+
// "More design options…": the workspace's Design panel when there is one, else the host's (`designMenu.onOpenPanel`), else no row.
|
|
1360
|
+
...(designPanel()
|
|
1361
|
+
? { onOpenPanel: () => showPanel("design", true) }
|
|
1362
|
+
: typeof options.designMenu?.onOpenPanel === "function"
|
|
1363
|
+
? { onOpenPanel: () => options.designMenu.onOpenPanel() }
|
|
1364
|
+
: {}),
|
|
1365
|
+
onStatus: say,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
if (slots.reviewPill && createReviewPill) {
|
|
1369
|
+
reviewPill = createReviewPill(slots.reviewPill, {
|
|
1370
|
+
density,
|
|
1371
|
+
...iconsFor("reviewPill"),
|
|
1372
|
+
...(labels.reviewPill ? { labels: labels.reviewPill } : {}),
|
|
1373
|
+
// The Review panel when there is one; without it (the embed) the host hears `review-open` and shows the findings its own way.
|
|
1374
|
+
onOpen: () => {
|
|
1375
|
+
if (controls.tabs?.review || studioShell?.tabs?.review) showPanel("review", true);
|
|
1376
|
+
else emit("review-open", { counts: reviewCounts, findings: review?.findings ?? [] });
|
|
1377
|
+
},
|
|
1378
|
+
});
|
|
1379
|
+
reviewPill.update(reviewCounts);
|
|
1380
|
+
}
|
|
1381
|
+
if (slots.help && createHelpButton) {
|
|
1382
|
+
helpButton = createHelpButton(slots.help, {
|
|
1383
|
+
overlay,
|
|
1384
|
+
getShortcuts: () => shortcuts(),
|
|
1385
|
+
density,
|
|
1386
|
+
...iconsFor("help"),
|
|
1387
|
+
...(labels.help ? { labels: labels.help } : {}),
|
|
1388
|
+
// "?" opens it from anywhere in a page the editor owns; an embedded editor or a viewer only from inside it (the button still works).
|
|
1389
|
+
bindKey: !scopedKeys,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
if (slots.actions && createToolbarActions) {
|
|
1393
|
+
actionsPart = createToolbarActions(slots.actions, { actions: hostActions, density, ...(labels.actions ? { labels: labels.actions } : {}) });
|
|
1394
|
+
slots.actions.hidden = hostActions.length === 0;
|
|
1395
|
+
syncActionsPriority();
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1052
1399
|
// The shell's own controls: history, views, tabs.
|
|
1053
1400
|
function mountControls() {
|
|
1054
1401
|
controls.undo?.addEventListener("click", () => undo());
|
|
@@ -1066,12 +1413,26 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1066
1413
|
});
|
|
1067
1414
|
}
|
|
1068
1415
|
if (controls.tabs) showPanelNow(options.panel && controls.tabs[options.panel] ? options.panel : Object.keys(controls.tabs)[0]);
|
|
1069
|
-
|
|
1416
|
+
// 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.
|
|
1417
|
+
if (options.shortcuts !== false && layout !== "viewer") {
|
|
1418
|
+
const onSave = (event) => {
|
|
1419
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "s" || event.defaultPrevented) return;
|
|
1420
|
+
// 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.
|
|
1421
|
+
if (!listeners.get("save")?.size) return;
|
|
1422
|
+
if (!element.contains(event.target) && (event.target !== doc.body || scopedKeys)) return;
|
|
1423
|
+
event.preventDefault();
|
|
1424
|
+
requestSave();
|
|
1425
|
+
};
|
|
1426
|
+
doc.addEventListener("keydown", onSave);
|
|
1427
|
+
cleanups.push(() => doc.removeEventListener("keydown", onSave));
|
|
1428
|
+
}
|
|
1429
|
+
// The viewer edits nothing, so it has no Undo keys.
|
|
1430
|
+
if (options.shortcuts !== false && layout !== "viewer") {
|
|
1070
1431
|
// 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).
|
|
1071
1432
|
const onKey = (event) => {
|
|
1072
1433
|
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z" || event.defaultPrevented) return;
|
|
1073
1434
|
if (event.target?.closest?.("input,textarea,select,[contenteditable],.cm-editor,dialog[open]")) return;
|
|
1074
|
-
if (!element.contains(event.target) && event.target !== doc.body) return;
|
|
1435
|
+
if (!element.contains(event.target) && (event.target !== doc.body || scopedKeys)) return;
|
|
1075
1436
|
event.preventDefault();
|
|
1076
1437
|
if (event.shiftKey) redo();
|
|
1077
1438
|
else undo();
|
|
@@ -1134,6 +1495,9 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1134
1495
|
function applyReadOnly() {
|
|
1135
1496
|
pane?.setReadOnly(readOnly);
|
|
1136
1497
|
studio?.setReadOnly(readOnly);
|
|
1498
|
+
deckTitle?.setReadOnly(readOnly);
|
|
1499
|
+
designMenu?.setReadOnly(readOnly);
|
|
1500
|
+
strip?.setReadOnly?.(readOnly);
|
|
1137
1501
|
root.toggleAttribute("data-readonly", readOnly);
|
|
1138
1502
|
syncChrome();
|
|
1139
1503
|
studioCtl?.refresh();
|
|
@@ -1145,6 +1509,7 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1145
1509
|
requested.density = setting;
|
|
1146
1510
|
density = densityFor(setting);
|
|
1147
1511
|
root.setAttribute("data-density", density);
|
|
1512
|
+
toolbar?.setDensity(density);
|
|
1148
1513
|
return;
|
|
1149
1514
|
}
|
|
1150
1515
|
const next = densityFor(setting);
|
|
@@ -1155,13 +1520,20 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1155
1520
|
exportMenu?.setDensity(density);
|
|
1156
1521
|
presentButton?.setDensity(density);
|
|
1157
1522
|
studio?.setDensity(density);
|
|
1523
|
+
deckTitle?.setDensity(density);
|
|
1524
|
+
expandButton?.setDensity(density);
|
|
1525
|
+
designMenu?.setDensity(density);
|
|
1526
|
+
reviewPill?.setDensity(density);
|
|
1527
|
+
helpButton?.setDensity(density);
|
|
1528
|
+
actionsPart?.setDensity(density);
|
|
1529
|
+
toolbar?.setDensity(density);
|
|
1158
1530
|
mountSlideRow();
|
|
1159
1531
|
}
|
|
1160
1532
|
function setExportMode(setting) {
|
|
1161
1533
|
if (destroyed) return;
|
|
1162
1534
|
if (!partsReady) requested.exportMode = setting;
|
|
1163
1535
|
exportMode = exportModeFor(setting);
|
|
1164
|
-
exportMenu?.setMode(exportMode);
|
|
1536
|
+
exportMenu?.setMode(exportCollapsed ? "sheet" : exportMode);
|
|
1165
1537
|
}
|
|
1166
1538
|
function setDocument(document, openOptions = {}) {
|
|
1167
1539
|
if (destroyed || !mounted) throw new OPFEditorError("not-ready", "The editor is not mounted yet. Await `ready` first.");
|
|
@@ -1179,7 +1551,10 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1179
1551
|
openedText = text !== undefined && pane.getFormat() === "json" ? openOptions.text : undefined;
|
|
1180
1552
|
} else session.replaceDocument(document, { source: "load" });
|
|
1181
1553
|
// A document the host loaded is not the person's work: it is not autosaved or warned about until it is edited.
|
|
1182
|
-
if (openOptions.rebase !== false)
|
|
1554
|
+
if (openOptions.rebase !== false) {
|
|
1555
|
+
persistence?.rebase();
|
|
1556
|
+
recordBaseline();
|
|
1557
|
+
}
|
|
1183
1558
|
return true;
|
|
1184
1559
|
} catch (error) {
|
|
1185
1560
|
if (error?.code !== "invalid-opf-edit") throw error;
|
|
@@ -1204,10 +1579,140 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1204
1579
|
...(openOptions.name !== undefined ? { name: openOptions.name } : {}),
|
|
1205
1580
|
});
|
|
1206
1581
|
// Text the host loaded is not the person's work: it is not autosaved or warned about until it is edited.
|
|
1207
|
-
if (applied && openOptions.rebase !== false)
|
|
1582
|
+
if (applied && openOptions.rebase !== false) {
|
|
1583
|
+
persistence?.rebase();
|
|
1584
|
+
recordBaseline();
|
|
1585
|
+
}
|
|
1208
1586
|
return applied;
|
|
1209
1587
|
}
|
|
1210
1588
|
|
|
1589
|
+
// ---- CE-28: the deck name and the expanded editor ------------------------------------------------------------------------------
|
|
1590
|
+
function currentDeckName() {
|
|
1591
|
+
const name = session.snapshot().presentation?.name;
|
|
1592
|
+
return typeof name === "string" ? name : "";
|
|
1593
|
+
}
|
|
1594
|
+
// ---- CE-28 wave 2: unsaved changes, save, the host's actions and the shortcuts ----------------------------------------------------
|
|
1595
|
+
// 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
|
|
1596
|
+
// 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.
|
|
1597
|
+
let baseline = null;
|
|
1598
|
+
let dirty = false;
|
|
1599
|
+
const documentKey = () => JSON.stringify(session.snapshot().presentation);
|
|
1600
|
+
const paneText = () => pane?.editor?.api?.getValue?.() ?? "";
|
|
1601
|
+
function recordBaseline() {
|
|
1602
|
+
if (!partsReady || destroyed) return;
|
|
1603
|
+
baseline = { document: documentKey(), text: paneText() };
|
|
1604
|
+
updateDirty();
|
|
1605
|
+
}
|
|
1606
|
+
function updateDirty() {
|
|
1607
|
+
if (!baseline || destroyed) return;
|
|
1608
|
+
const next = documentKey() !== baseline.document || Boolean(pane && !inSync && paneText() !== baseline.text);
|
|
1609
|
+
if (next === dirty) return;
|
|
1610
|
+
dirty = next;
|
|
1611
|
+
root.toggleAttribute("data-dirty", dirty);
|
|
1612
|
+
emit("dirty", { dirty });
|
|
1613
|
+
}
|
|
1614
|
+
function markSaved() {
|
|
1615
|
+
if (destroyed) return false;
|
|
1616
|
+
if (!mounted) return true;
|
|
1617
|
+
commitPending();
|
|
1618
|
+
recordBaseline();
|
|
1619
|
+
persistence?.markSaved();
|
|
1620
|
+
return true;
|
|
1621
|
+
}
|
|
1622
|
+
/** 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). */
|
|
1623
|
+
function saveDetail() {
|
|
1624
|
+
commitPending();
|
|
1625
|
+
const source = handle.readSource();
|
|
1626
|
+
return { document: session.snapshot().presentation, text: source.text, format: source.format, draft: Boolean(source.draft) };
|
|
1627
|
+
}
|
|
1628
|
+
function requestSave() {
|
|
1629
|
+
if (!mounted || destroyed) return false;
|
|
1630
|
+
emit("save", saveDetail());
|
|
1631
|
+
return true;
|
|
1632
|
+
}
|
|
1633
|
+
function setActions(list) {
|
|
1634
|
+
if (!Array.isArray(list)) throw new TypeError("setActions needs an array of actions.");
|
|
1635
|
+
hostActions = [...list];
|
|
1636
|
+
if (actionsPart) {
|
|
1637
|
+
actionsPart.setActions(hostActions);
|
|
1638
|
+
slots.actions.hidden = hostActions.length === 0;
|
|
1639
|
+
syncActionsPriority();
|
|
1640
|
+
toolbar?.update();
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
// 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
|
|
1644
|
+
// (85 < 90 < 95), unless the host gave the item a priority of its own in `toolbar`.
|
|
1645
|
+
const explicitActionsPriority = Array.isArray(options.toolbar) && options.toolbar.some((item) => item && typeof item === "object" && item.id === "actions" && item.priority !== undefined);
|
|
1646
|
+
function syncActionsPriority() {
|
|
1647
|
+
if (!toolbar || explicitActionsPriority) return;
|
|
1648
|
+
toolbar.setItem("actions", { priority: hostActions.some((action) => action?.primary) ? 90 : TOOLBAR_ITEMS.actions.priority });
|
|
1649
|
+
}
|
|
1650
|
+
/** The keyboard shortcuts this editor binds now, for the help sheet (and, in the Studio, beside its command registry). */
|
|
1651
|
+
function shortcuts() {
|
|
1652
|
+
const list = [];
|
|
1653
|
+
const add = (id, label, keys, group) => list.push({ id, label, keys, group });
|
|
1654
|
+
if (studioCtl) {
|
|
1655
|
+
// The Studio's registry: every command with a key, as its own sheet lists them; then what the registry does not hold.
|
|
1656
|
+
for (const entry of studioCtl.commands.list()) if (entry.keys.length) add(entry.id, entry.label, entry.keys, entry.group ?? "Studio");
|
|
1657
|
+
} else {
|
|
1658
|
+
if (parts.history && layout !== "viewer" && options.shortcuts !== false) {
|
|
1659
|
+
add("undo", "Undo", ["Mod-z"], "Edit");
|
|
1660
|
+
add("redo", "Redo", ["Shift-Mod-z"], "Edit");
|
|
1661
|
+
}
|
|
1662
|
+
if (find) {
|
|
1663
|
+
add("find", "Find", ["Mod-f"], "Edit");
|
|
1664
|
+
add("replace", "Find and replace", ["Mod-h"], "Edit");
|
|
1665
|
+
}
|
|
1666
|
+
if (slideNav || counterNav) {
|
|
1667
|
+
add("previous", "Previous slide", ["ArrowLeft"], "Slides");
|
|
1668
|
+
add("next", "Next slide", ["ArrowRight"], "Slides");
|
|
1669
|
+
}
|
|
1670
|
+
if (presenter) add("present", "Present from this slide", ["F5"], "Slides");
|
|
1671
|
+
if (helpButton && !scopedKeys) add("help", "Keyboard shortcuts", ["?"], "Window");
|
|
1672
|
+
}
|
|
1673
|
+
return [...list, ...chromeShortcuts()];
|
|
1674
|
+
}
|
|
1675
|
+
/** The shortcuts of the chrome itself (save, the title, expand): the help sheet lists them, and so does the Studio's own sheet. */
|
|
1676
|
+
function chromeShortcuts() {
|
|
1677
|
+
const list = [];
|
|
1678
|
+
const add = (id, label, keys, group) => list.push({ id, label, keys, group });
|
|
1679
|
+
if (listeners.get("save")?.size && layout !== "viewer" && options.shortcuts !== false) add("save", "Save", ["Mod-s"], "Edit");
|
|
1680
|
+
if (deckTitle && !readOnly) {
|
|
1681
|
+
add("title-commit", "Rename: keep the name typed in the title", ["Enter"], "Presentation name");
|
|
1682
|
+
add("title-cancel", "Rename: put the name back", ["Escape"], "Presentation name");
|
|
1683
|
+
}
|
|
1684
|
+
if (expandCtl) add("expand-exit", "Leave the expanded editor", ["Escape"], "Window");
|
|
1685
|
+
return list;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function setDeckName(name) {
|
|
1689
|
+
if (destroyed) return false;
|
|
1690
|
+
if (typeof name !== "string") throw new TypeError("setDeckName needs the name as a string.");
|
|
1691
|
+
if (waiting()) {
|
|
1692
|
+
queue("deckName", () => setDeckName(name));
|
|
1693
|
+
return true;
|
|
1694
|
+
}
|
|
1695
|
+
// 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.
|
|
1696
|
+
if (readOnly || !inSync || !commitPending()) return false;
|
|
1697
|
+
const patch = deckNamePatch(session.snapshot().presentation?.name, name);
|
|
1698
|
+
if (!patch) return true;
|
|
1699
|
+
try {
|
|
1700
|
+
ui.applyPatch(patch, { source: "deck-name", rejectInvalid: true });
|
|
1701
|
+
return true;
|
|
1702
|
+
} catch (error) {
|
|
1703
|
+
report(error);
|
|
1704
|
+
return false;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
function expand(on = true) {
|
|
1708
|
+
if (destroyed || options.expand === false) return false;
|
|
1709
|
+
if (waiting()) {
|
|
1710
|
+
queue("expand", () => expand(on));
|
|
1711
|
+
return true;
|
|
1712
|
+
}
|
|
1713
|
+
return expandCtl ? expandCtl.expand(on) : false;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1211
1716
|
// The Studio's command registry is not there before mount: `register` keeps the command and adds it when the Studio mounts (the function it
|
|
1212
1717
|
// returns removes it either way); `list` is empty and `run` finds nothing yet.
|
|
1213
1718
|
const preMountCommands = {
|
|
@@ -1350,6 +1855,40 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1350
1855
|
get slots() {
|
|
1351
1856
|
return slots;
|
|
1352
1857
|
},
|
|
1858
|
+
/** CE-28: the toolbar the editor built (undefined without one) and the deck title part. */
|
|
1859
|
+
get toolbar() {
|
|
1860
|
+
return toolbar;
|
|
1861
|
+
},
|
|
1862
|
+
get deckTitle() {
|
|
1863
|
+
return deckTitle;
|
|
1864
|
+
},
|
|
1865
|
+
/** CE-28 wave 2: the toolbar's other parts, once mounted (a host that runs its own Review feeds `reviewPill.update(counts)`). */
|
|
1866
|
+
get designMenu() {
|
|
1867
|
+
return designMenu;
|
|
1868
|
+
},
|
|
1869
|
+
get reviewPill() {
|
|
1870
|
+
return reviewPill;
|
|
1871
|
+
},
|
|
1872
|
+
get helpButton() {
|
|
1873
|
+
return helpButton;
|
|
1874
|
+
},
|
|
1875
|
+
get toolbarActions() {
|
|
1876
|
+
return actionsPart;
|
|
1877
|
+
},
|
|
1878
|
+
getDeckName: currentDeckName,
|
|
1879
|
+
/** CE-28 wave 2: whether the document (or a source draft) differs from what was last loaded or saved. */
|
|
1880
|
+
get dirty() {
|
|
1881
|
+
return dirty;
|
|
1882
|
+
},
|
|
1883
|
+
markSaved,
|
|
1884
|
+
save: requestSave,
|
|
1885
|
+
setActions,
|
|
1886
|
+
shortcuts,
|
|
1887
|
+
setDeckName,
|
|
1888
|
+
expand,
|
|
1889
|
+
get expanded() {
|
|
1890
|
+
return Boolean(expandCtl?.expanded);
|
|
1891
|
+
},
|
|
1353
1892
|
getDocument() {
|
|
1354
1893
|
return session.snapshot().presentation;
|
|
1355
1894
|
},
|
|
@@ -1442,6 +1981,15 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1442
1981
|
}
|
|
1443
1982
|
}
|
|
1444
1983
|
studioCtl?.destroy();
|
|
1984
|
+
expandButton?.destroy();
|
|
1985
|
+
expandCtl?.destroy();
|
|
1986
|
+
deckTitle?.destroy();
|
|
1987
|
+
designMenu?.destroy();
|
|
1988
|
+
reviewPill?.destroy();
|
|
1989
|
+
helpButton?.destroy();
|
|
1990
|
+
actionsPart?.destroy();
|
|
1991
|
+
counterNav?.destroy();
|
|
1992
|
+
toolbar?.destroy();
|
|
1445
1993
|
unbindKeys?.();
|
|
1446
1994
|
checking?.disconnect();
|
|
1447
1995
|
persistence?.destroy();
|
|
@@ -1485,7 +2033,35 @@ export function createOpfEditor(element, options = {}) {
|
|
|
1485
2033
|
if (wantedView && wantedView !== "slide") setView(wantedView);
|
|
1486
2034
|
else markView("slide");
|
|
1487
2035
|
syncChrome();
|
|
2036
|
+
// The document as it was opened is the baseline of `dirty`; calls queued before mount (a rename) are changes after it.
|
|
2037
|
+
recordBaseline();
|
|
2038
|
+
if (options.guardUnload) mountUnloadGuard();
|
|
1488
2039
|
flushQueued();
|
|
2040
|
+
// The bar's parts are in their slots now: fit the row at once rather than on the next frame.
|
|
2041
|
+
toolbar?.update();
|
|
2042
|
+
}
|
|
2043
|
+
// CE-28 wave 2 (promoted from openpresentation.org's source unload guard): the browser asks before the page closes while there is unsaved work.
|
|
2044
|
+
// `guardUnload: true` asks while `dirty`; a function decides itself (a host with state of its own), given the editor.
|
|
2045
|
+
function mountUnloadGuard() {
|
|
2046
|
+
if (!win?.addEventListener) return;
|
|
2047
|
+
const decide = typeof options.guardUnload === "function" ? () => Boolean(options.guardUnload(handle)) : () => dirty;
|
|
2048
|
+
const onBeforeUnload = (event) => {
|
|
2049
|
+
if (destroyed) return;
|
|
2050
|
+
// Text typed a moment ago is applied first, so its verdict counts.
|
|
2051
|
+
pane?.getText();
|
|
2052
|
+
updateDirty();
|
|
2053
|
+
let warn = false;
|
|
2054
|
+
try {
|
|
2055
|
+
warn = decide();
|
|
2056
|
+
} catch (error) {
|
|
2057
|
+
report(error);
|
|
2058
|
+
}
|
|
2059
|
+
if (!warn) return;
|
|
2060
|
+
event.preventDefault();
|
|
2061
|
+
event.returnValue = "";
|
|
2062
|
+
};
|
|
2063
|
+
win.addEventListener("beforeunload", onBeforeUnload);
|
|
2064
|
+
cleanups.push(() => win.removeEventListener("beforeunload", onBeforeUnload));
|
|
1489
2065
|
}
|
|
1490
2066
|
readyPromise = new Promise((resolve, reject) => {
|
|
1491
2067
|
const start = (arrived) => {
|