@openpresentation/opf-editor 0.28.0 → 0.29.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 +20 -6
- package/dist/canvas.d.ts +13 -1
- package/dist/canvas.js +39 -9
- package/dist/chart-options-panel.js +16 -16
- package/dist/codemirror-guard.js +69 -0
- package/dist/content-controls.js +127 -74
- package/dist/data-grid.js +15 -7
- package/dist/design-controls.d.ts +13 -0
- package/dist/design-controls.js +134 -124
- package/dist/exact-source.d.ts +30 -0
- package/dist/font-gate.js +20 -1
- package/dist/json-editor.js +8 -2
- package/dist/json-options.d.ts +2 -1
- package/dist/json-options.js +7 -6
- package/dist/layout-templates.d.ts +8 -3
- package/dist/layout-templates.js +30 -16
- package/dist/numbering-panel.js +2 -3
- package/dist/react.d.ts +12 -4
- package/dist/react.js +39 -5
- package/dist/slide-adapter.d.ts +49 -0
- package/dist/slide-adapter.js +289 -0
- package/dist/slide-edits.d.ts +46 -0
- package/dist/slide-edits.js +629 -0
- package/dist/slide-manager.js +2 -1
- package/dist/slide-scene.d.ts +96 -0
- package/dist/slide-scene.js +413 -0
- package/dist/source-checker.js +45 -6
- package/dist/source-completions.js +6 -4
- package/dist/source-pane.d.ts +18 -6
- package/dist/source-pane.js +45 -0
- package/dist/source-worker.js +8 -4
- package/dist/studio-canvas.d.ts +34 -0
- package/dist/studio-canvas.js +586 -0
- package/dist/studio-commands.d.ts +15 -0
- package/dist/studio-commands.js +155 -0
- package/dist/studio-design.d.ts +16 -0
- package/dist/studio-design.js +354 -0
- package/dist/studio-kit/dom.d.ts +16 -0
- package/dist/studio-kit/dom.js +42 -6
- package/dist/studio-layers.d.ts +12 -0
- package/dist/studio-layers.js +218 -0
- package/dist/studio.d.ts +31 -0
- package/dist/studio.js +1608 -0
- package/dist/styles.css +1546 -117
- package/dist/thumbnail-cache.d.ts +2 -0
- package/dist/thumbnail-cache.js +10 -1
- package/dist/workspace-measure.js +6 -4
- package/dist/workspace-parts.js +20 -0
- package/dist/workspace-shell.js +25 -15
- package/dist/workspace-theme.js +3 -0
- package/dist/workspace.d.ts +88 -9
- package/dist/workspace.js +210 -49
- package/package.json +29 -18
package/dist/studio.js
ADDED
|
@@ -0,0 +1,1608 @@
|
|
|
1
|
+
// CE-20: the Presentation and Slide Studio, `createOpfEditor(el, { layout: "studio" })` (STUDIO-SPEC.md). A Figma Auto Layout-style editor
|
|
2
|
+
// over the same session, canvas, source pane, presenter, Review and export the workspace already mounts: a filmstrip over a layers tree on
|
|
3
|
+
// the left, the rendered slide in a zoomable viewport with the kit overlay in the middle (and the source as a split, synchronized both ways),
|
|
4
|
+
// the Design and Review tabs on the right, and a bar and a status line. Selection is one ordered list of sibling keys (the scene's id paths)
|
|
5
|
+
// that every surface shows and changes; every structural command is one validated, undoable change through the slide adapter (CE-19), so
|
|
6
|
+
// there is one history, shared with the source pane. Slides stay auto-layout (decision D2): no free x/y, no corner handles.
|
|
7
|
+
//
|
|
8
|
+
// The `playground` preset (decision D1) is the same editor arranged for first contact: source and slide side by side, click to type, the
|
|
9
|
+
// slide row and no Studio panels. `setLayout` switches in place, keeping the session, the history, the slide and the selection.
|
|
10
|
+
//
|
|
11
|
+
// This file builds the shell (`buildStudioShell`, called by the workspace instead of its own shell) and the controller (`createStudio`,
|
|
12
|
+
// called once the workspace has mounted its parts). Plain DOM, styled only by `--opf-ui-*` tokens (src/styles/studio.css), no network.
|
|
13
|
+
import { createDom, isTypingTarget, openMenu } from "./studio-kit/dom.js";
|
|
14
|
+
import { createSelection } from "./studio-kit/selection.js";
|
|
15
|
+
import { formatKey, isHandled, markHandled, matchKey } from "./studio-kit/commands.js";
|
|
16
|
+
import { createSlideAdapter, composeSlideGeometry } from "./slide-adapter.js";
|
|
17
|
+
import { createStudioCommands, contextMenuGroups, STUDIO_INSERT_HINTS, STUDIO_INSERT_KINDS, STUDIO_INSERT_LABELS } from "./studio-commands.js";
|
|
18
|
+
import { createStudioCanvas, dottedPath, isTextNode } from "./studio-canvas.js";
|
|
19
|
+
import { createStudioLayers } from "./studio-layers.js";
|
|
20
|
+
import { createStudioDesign } from "./studio-design.js";
|
|
21
|
+
import { createContentBlock } from "./blocks.js";
|
|
22
|
+
import { addSlide, setHidden } from "./slides.js";
|
|
23
|
+
import { blockConversionTargets, convertBlock } from "./block-convert.js";
|
|
24
|
+
import { listSwitchOptions, switchDimension } from "./switches.js";
|
|
25
|
+
import { groupLayoutChoices } from "./layout-templates.js";
|
|
26
|
+
import { buildLayoutDiagram } from "./layout-diagram.js";
|
|
27
|
+
import { prepareSave } from "./catalogs.js";
|
|
28
|
+
import { prepareOpfImport } from "./transfer.js";
|
|
29
|
+
import { collectReservedPresentationIds } from "./presentation-ids.js";
|
|
30
|
+
import { regionsAsBlocks } from "./content-actions.js";
|
|
31
|
+
import { downloadSourceFile, copyText } from "./workspace-source.js";
|
|
32
|
+
import { DECK_FILE_ACCEPT, readDeckFile } from "./read-deck-file.js";
|
|
33
|
+
import { formatPointer, jsonEqual, parsePointer } from "@openpresentation/opf/patch";
|
|
34
|
+
|
|
35
|
+
const PREFIX = "opf-studio";
|
|
36
|
+
const WIDE = 1100, NARROW = 760;
|
|
37
|
+
const SIZES = { left: [232, 176, 360], right: [280, 240, 420], split: [0.42, 0.2, 0.8], stack: [0.5, 0.15, 0.85] };
|
|
38
|
+
const INSERT_ICONS = { text: "AlignLeft", list: "List", image: "Image", video: "Video", chart: "ChartColumn", table: "Table", code: "Code", metric: "Hash", quote: "Quote", timeline: "CalendarRange", group: "Group" };
|
|
39
|
+
const PLACEHOLDER_PICTURE = `data:image/svg+xml;utf8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900"><rect width="1600" height="900" fill="#e4e8ef"/><path d="M640 600l160-200 120 140 80-90 160 150z" fill="#b4bdcb"/><circle cx="1010" cy="330" r="50" fill="#b4bdcb"/></svg>')}`;
|
|
40
|
+
// The clipboard of every Studio on the page, so copy in one editor and paste in another works (the system clipboard gets OPF text too).
|
|
41
|
+
let clipboard = null;
|
|
42
|
+
|
|
43
|
+
const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
|
|
44
|
+
const isSlideKey = (key) => typeof key === "string" && !key.includes("#") && /^\/slides\/[^/]+$/.test(key);
|
|
45
|
+
const toPointer = (path) => (typeof path === "string" && path.startsWith("/") ? path : formatPointer(String(path ?? "").split(".").filter(Boolean)));
|
|
46
|
+
const prefixOf = (a, b) => a.length <= b.length && a.every((token, at) => token === b[at]);
|
|
47
|
+
|
|
48
|
+
// ---- the shell ------------------------------------------------------------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the Studio's DOM inside `root` (the workspace's `.opf-workspace` element). `parts` says which parts the workspace mounts; `layout` is
|
|
52
|
+
* the preset ("studio" or "playground"). Returns `{ slots, controls, shell }`: the slots the workspace mounts its parts in, the bar controls
|
|
53
|
+
* it drives (undo, redo), and the Studio's own elements.
|
|
54
|
+
*/
|
|
55
|
+
export function buildStudioShell(root, { parts, layout, labels = {}, studio: config = {}, slots: given = {} }) {
|
|
56
|
+
const dom = createDom(root.ownerDocument, { prefix: PREFIX });
|
|
57
|
+
const { el } = dom;
|
|
58
|
+
const slots = {};
|
|
59
|
+
const controls = {};
|
|
60
|
+
const slot = (name, tag = "div", className = "", attributes = {}) => (slots[name] = el(tag, className, { "data-opf-slot": name, ...attributes }));
|
|
61
|
+
root.classList.add("opf-studio");
|
|
62
|
+
root.dataset.opfLayout = layout;
|
|
63
|
+
root.dataset.preset = layout;
|
|
64
|
+
root.dataset.source = "off";
|
|
65
|
+
root.dataset.panels = "shown";
|
|
66
|
+
root.dataset.width = "wide";
|
|
67
|
+
|
|
68
|
+
// The bar.
|
|
69
|
+
const bar = el("div", `${PREFIX}-bar`, { role: "toolbar", "aria-label": labels.studio ?? "Studio" });
|
|
70
|
+
const file = dom.button(`${PREFIX}-barbutton`, { text: "File", iconName: "Menu", size: 16, attributes: { "aria-haspopup": "menu", "data-studio": "file" } });
|
|
71
|
+
const name = el("input", `${PREFIX}-name`, { type: "text", "aria-label": "Presentation name", spellcheck: "false", autocomplete: "off", "data-studio": "name" });
|
|
72
|
+
const tools = el("div", `${PREFIX}-tools`, { role: "group", "aria-label": "Tools" });
|
|
73
|
+
const toolButtons = {};
|
|
74
|
+
for (const [id, text, icon, key] of [["select", "Select", "MousePointer2", "V"], ["hand", "Hand", "Hand", "H"], ["text", "Text", "Type", "T"]]) {
|
|
75
|
+
toolButtons[id] = dom.button(`${PREFIX}-iconbutton`, { label: `${text} tool`, iconName: icon, size: 16, pressed: id === "select", title: `${text} tool (${key})`, attributes: { "data-tool": id, "aria-keyshortcuts": key } });
|
|
76
|
+
tools.append(toolButtons[id]);
|
|
77
|
+
}
|
|
78
|
+
const insert = dom.button(`${PREFIX}-barbutton`, { text: "Insert", iconName: "Plus", size: 16, attributes: { "aria-haspopup": "menu", "data-studio": "insert" } });
|
|
79
|
+
const addSlideButton = dom.button(`${PREFIX}-barbutton`, { text: "Slide", iconName: "Plus", size: 16, label: "New slide", attributes: { "aria-haspopup": "menu", "data-studio": "slide" } });
|
|
80
|
+
addSlideButton.removeAttribute("aria-label");
|
|
81
|
+
addSlideButton.setAttribute("aria-label", "Slide: add a slide");
|
|
82
|
+
const history = el("div", `${PREFIX}-history`, { role: "group", "aria-label": "History" });
|
|
83
|
+
controls.undo = dom.button(`${PREFIX}-iconbutton`, { label: "Undo", iconName: "Undo2", size: 16, attributes: { "data-opf-action": "undo", "aria-keyshortcuts": "Control+Z Meta+Z" } });
|
|
84
|
+
controls.redo = dom.button(`${PREFIX}-iconbutton`, { label: "Redo", iconName: "Redo2", size: 16, attributes: { "data-opf-action": "redo", "aria-keyshortcuts": "Control+Shift+Z Meta+Shift+Z" } });
|
|
85
|
+
history.append(controls.undo, controls.redo);
|
|
86
|
+
// The Playground's view switch: Source, Both, Preview.
|
|
87
|
+
const views = el("div", `${PREFIX}-views`, { role: "group", "aria-label": "Show" });
|
|
88
|
+
const viewButtons = {};
|
|
89
|
+
for (const [id, text] of [["source", "Source"], ["both", "Both"], ["preview", "Preview"]]) {
|
|
90
|
+
viewButtons[id] = dom.button(`${PREFIX}-segment`, { text, pressed: id === "both", attributes: { "data-view": id } });
|
|
91
|
+
views.append(viewButtons[id]);
|
|
92
|
+
}
|
|
93
|
+
const spacer = el("span", `${PREFIX}-spacer`);
|
|
94
|
+
const zoom = dom.button(`${PREFIX}-barbutton ${PREFIX}-zoombutton`, { text: "100%", attributes: { "aria-haspopup": "menu", "aria-label": "Zoom 100%", "data-studio": "zoom" } });
|
|
95
|
+
const source = dom.button(`${PREFIX}-barbutton`, { text: "Source", iconName: "Code", size: 16, pressed: false, attributes: { "data-studio": "source", "aria-keyshortcuts": "Control+`" } });
|
|
96
|
+
const openStudio = dom.button(`${PREFIX}-barbutton ${PREFIX}-openstudio`, { text: "Open in Studio", iconName: "Layers", size: 16, attributes: { "data-studio": "open-studio" } });
|
|
97
|
+
bar.append(file, name, tools, insert, addSlideButton, history, views, spacer, zoom);
|
|
98
|
+
if (parts.source) bar.append(source);
|
|
99
|
+
if (parts.present) bar.append(slot("present", "div", `${PREFIX}-present`));
|
|
100
|
+
const topbarEnd = el("div", `${PREFIX}-topbar-end`, { "data-opf-slot": "topbarEnd" });
|
|
101
|
+
if (given.topbarEnd) topbarEnd.append(given.topbarEnd);
|
|
102
|
+
bar.append(topbarEnd);
|
|
103
|
+
if (parts.export) bar.append(slot("export", "div", `${PREFIX}-export`));
|
|
104
|
+
bar.append(openStudio);
|
|
105
|
+
|
|
106
|
+
// The left panel: Slides over Layers, with a divider; at medium widths a rail of two buttons opens them as an overlay.
|
|
107
|
+
const left = el("aside", `${PREFIX}-left`, { "aria-label": "Slides and layers" });
|
|
108
|
+
const rail = el("div", `${PREFIX}-rail`, { role: "group", "aria-label": "Panels" });
|
|
109
|
+
const railSlides = dom.button(`${PREFIX}-iconbutton`, { label: "Slides", iconName: "Square", size: 18, pressed: false, attributes: { "data-panel": "slides" } });
|
|
110
|
+
const railLayers = dom.button(`${PREFIX}-iconbutton`, { label: "Layers", iconName: "Layers", size: 18, pressed: false, attributes: { "data-panel": "layers" } });
|
|
111
|
+
rail.append(railSlides, railLayers);
|
|
112
|
+
const slidesSection = el("section", `${PREFIX}-slides`, { "aria-labelledby": "opf-studio-slides-title" });
|
|
113
|
+
const slidesHead = el("div", `${PREFIX}-panelhead`);
|
|
114
|
+
slidesHead.append(el("h2", `${PREFIX}-paneltitle`, { id: "opf-studio-slides-title", text: "Slides" }), slot("slideToolbar", "div", `${PREFIX}-slidetools`));
|
|
115
|
+
slidesSection.append(slidesHead, slot("slides", "div", `${PREFIX}-filmstrip`));
|
|
116
|
+
const stackDivider = el("div", `${PREFIX}-splitter`, { role: "separator", "aria-orientation": "horizontal", "aria-label": "Resize slides and layers", tabindex: "0", "data-resize": "stack" });
|
|
117
|
+
const layersSection = el("section", `${PREFIX}-layers`, { "aria-labelledby": "opf-studio-layers-title" });
|
|
118
|
+
const layersHost = el("div", `${PREFIX}-layershost`);
|
|
119
|
+
layersSection.append(el("div", `${PREFIX}-panelhead`), layersHost);
|
|
120
|
+
layersSection.firstChild.append(el("h2", `${PREFIX}-paneltitle`, { id: "opf-studio-layers-title", text: "Layers" }));
|
|
121
|
+
left.append(rail, slidesSection, stackDivider, layersSection);
|
|
122
|
+
const leftDivider = el("div", `${PREFIX}-splitter`, { role: "separator", "aria-orientation": "vertical", "aria-label": "Resize the left panel", tabindex: "0", "data-resize": "left" });
|
|
123
|
+
|
|
124
|
+
// The center: the viewport with the slide, the source split, the data dock, the Playground's slide row, Layout Studio.
|
|
125
|
+
const center = el("div", `${PREFIX}-center`);
|
|
126
|
+
const canvasArea = el("section", `${PREFIX}-canvasarea`, { "aria-label": "Slide" });
|
|
127
|
+
const viewport = el("div", `${PREFIX}-viewport`, { "data-opf-slot": "slide" });
|
|
128
|
+
slots.slide = viewport;
|
|
129
|
+
const stage = el("div", `${PREFIX}-stage`);
|
|
130
|
+
const frame = el("div", `${PREFIX}-frame`, { role: "group", "aria-label": "Slide", tabindex: "-1" });
|
|
131
|
+
const overlay = el("div", `${PREFIX}-overlay`);
|
|
132
|
+
frame.append(slot("canvas", "div", `${PREFIX}-canvas`), overlay);
|
|
133
|
+
stage.append(frame);
|
|
134
|
+
viewport.append(stage);
|
|
135
|
+
const actionbar = el("div", `${PREFIX}-actionbar`, { role: "toolbar", "aria-label": "Selection actions" });
|
|
136
|
+
actionbar.hidden = true;
|
|
137
|
+
const dataDock = el("section", `${PREFIX}-datadock`, { "aria-label": "Data" });
|
|
138
|
+
dataDock.hidden = true;
|
|
139
|
+
const dock = el("div", `${PREFIX}-dock`);
|
|
140
|
+
if (parts.navigator) dock.append(slot("navigator", "div", `${PREFIX}-navigator`));
|
|
141
|
+
if (parts.thumbnails) dock.append(slot("thumbnails", "div", `${PREFIX}-thumbnails`));
|
|
142
|
+
canvasArea.append(viewport, actionbar, dataDock, dock);
|
|
143
|
+
const sourceDivider = el("div", `${PREFIX}-splitter`, { role: "separator", "aria-label": "Resize the source", tabindex: "0", "data-resize": "split" });
|
|
144
|
+
const sourceArea = el("section", `${PREFIX}-sourcearea`, { "aria-label": "OPF source" });
|
|
145
|
+
if (parts.source) sourceArea.append(slot("source", "div", `${PREFIX}-source`));
|
|
146
|
+
const layoutsArea = el("section", `${PREFIX}-layoutsarea`, { "aria-label": "Layout Studio" });
|
|
147
|
+
const crumbs = el("nav", `${PREFIX}-crumbs`, { "aria-label": "Breadcrumb" });
|
|
148
|
+
layoutsArea.hidden = true;
|
|
149
|
+
if (parts.layouts) layoutsArea.append(crumbs, slot("layouts", "div", `${PREFIX}-layouts`));
|
|
150
|
+
center.append(canvasArea, sourceDivider, sourceArea, layoutsArea);
|
|
151
|
+
slots.layoutsView = layoutsArea;
|
|
152
|
+
|
|
153
|
+
// The right panel: Design, Review and the host's tabs.
|
|
154
|
+
const right = el("aside", `${PREFIX}-right`, { "aria-label": "Properties" });
|
|
155
|
+
const rightDivider = el("div", `${PREFIX}-splitter`, { role: "separator", "aria-orientation": "vertical", "aria-label": "Resize the right panel", tabindex: "0", "data-resize": "right" });
|
|
156
|
+
const tablist = el("div", `${PREFIX}-tabs`, { role: "tablist", "aria-label": "Properties" });
|
|
157
|
+
right.append(tablist);
|
|
158
|
+
const tabs = {};
|
|
159
|
+
const addTab = (id, text) => {
|
|
160
|
+
const tab = el("button", `${PREFIX}-tab`, { type: "button", role: "tab", id: `${PREFIX}-tab-${id}`, "aria-selected": "false", "aria-controls": `${PREFIX}-panel-${id}`, "data-tab": id, tabindex: "-1", text });
|
|
161
|
+
const panel = el("div", `${PREFIX}-tabpanel`, { role: "tabpanel", id: `${PREFIX}-panel-${id}`, "aria-labelledby": tab.id, tabindex: "0" });
|
|
162
|
+
panel.hidden = true;
|
|
163
|
+
tablist.append(tab);
|
|
164
|
+
right.append(panel);
|
|
165
|
+
tabs[id] = { tab, panel };
|
|
166
|
+
return panel;
|
|
167
|
+
};
|
|
168
|
+
addTab("design", "Design").append(el("div", `${PREFIX}-design`));
|
|
169
|
+
if (parts.review) addTab("review", labels.review ?? "Review").append(slot("review", "div", `${PREFIX}-review`));
|
|
170
|
+
for (const host of config.hostPanels ?? []) {
|
|
171
|
+
if (!host?.id || typeof host.mount !== "function") continue;
|
|
172
|
+
if (host.side === "left") {
|
|
173
|
+
const section = el("section", `${PREFIX}-hostsection`, { "aria-label": host.label, "data-host-panel": host.id });
|
|
174
|
+
section.append(el("div", `${PREFIX}-panelhead`), el("div", `${PREFIX}-hostbody`));
|
|
175
|
+
section.firstChild.append(el("h2", `${PREFIX}-paneltitle`, { text: host.label }));
|
|
176
|
+
left.append(section);
|
|
177
|
+
tabs[`host:${host.id}`] = { panel: section.lastChild, host };
|
|
178
|
+
} else {
|
|
179
|
+
const panel = addTab(`host-${host.id}`, host.label);
|
|
180
|
+
tabs[`host-${host.id}`].host = host;
|
|
181
|
+
panel.dataset.hostPanel = host.id;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const main = el("div", `${PREFIX}-main`);
|
|
186
|
+
main.append(left, leftDivider, center, rightDivider, right);
|
|
187
|
+
|
|
188
|
+
// The status line.
|
|
189
|
+
const status = el("footer", `${PREFIX}-status`);
|
|
190
|
+
const validity = dom.button(`${PREFIX}-validity`, { text: "Valid", attributes: { "data-studio": "validity" } });
|
|
191
|
+
const counter = el("span", `${PREFIX}-counter`, { text: "" });
|
|
192
|
+
const format = el("span", `${PREFIX}-format`, { text: "JSON" });
|
|
193
|
+
const message = slot("status", "p", `${PREFIX}-message`, { role: "status", "aria-live": "polite" });
|
|
194
|
+
const sheetBar = el("div", `${PREFIX}-sheetbar`, { role: "group", "aria-label": "Panels" });
|
|
195
|
+
const sheetButtons = {};
|
|
196
|
+
for (const [id, text, icon] of [["slides", "Slides", "Square"], ["layers", "Layers", "Layers"], ["design", "Design", "Pencil"], ["review", "Review", "Info"]]) {
|
|
197
|
+
sheetButtons[id] = dom.button(`${PREFIX}-iconbutton`, { label: text, iconName: icon, size: 18, pressed: false, attributes: { "data-sheet": id } });
|
|
198
|
+
sheetBar.append(sheetButtons[id]);
|
|
199
|
+
}
|
|
200
|
+
const zoombar = el("div", `${PREFIX}-zoombar`, { role: "group", "aria-label": "Zoom" });
|
|
201
|
+
const zoomOut = dom.button(`${PREFIX}-iconbutton`, { label: "Zoom out", iconName: "ZoomOut", size: 16, attributes: { "aria-keyshortcuts": "Control+- Meta+-" } });
|
|
202
|
+
const zoomLabel = el("output", `${PREFIX}-zoomlabel`, { "aria-live": "off", text: "100%" });
|
|
203
|
+
const zoomIn = dom.button(`${PREFIX}-iconbutton`, { label: "Zoom in", iconName: "ZoomIn", size: 16, attributes: { "aria-keyshortcuts": "Control+= Meta+=" } });
|
|
204
|
+
const zoomFit = dom.button(`${PREFIX}-iconbutton`, { label: "Zoom to fit", iconName: "Maximize", size: 16, attributes: { "aria-keyshortcuts": "Shift+1" } });
|
|
205
|
+
zoombar.append(zoomOut, zoomLabel, zoomIn, zoomFit);
|
|
206
|
+
status.append(validity, counter, format);
|
|
207
|
+
if (parts.persistence) status.append(slot("autosave", "span", `${PREFIX}-autosave`));
|
|
208
|
+
status.append(message, sheetBar, zoombar);
|
|
209
|
+
|
|
210
|
+
if (parts.persistence) root.append(bar, slot("banner", "div", `${PREFIX}-banner`, { hidden: true }));
|
|
211
|
+
else root.append(bar);
|
|
212
|
+
if (parts.find) root.append(slot("find", "div", `${PREFIX}-find`, { hidden: true }));
|
|
213
|
+
root.append(main, status);
|
|
214
|
+
|
|
215
|
+
const shell = {
|
|
216
|
+
dom, bar, file, name, tools, toolButtons, insert, addSlide: addSlideButton, views, viewButtons, zoom, source, openStudio,
|
|
217
|
+
left, rail, railSlides, railLayers, slidesSection, layersSection, layersHost, stackDivider, leftDivider, rightDivider, sourceDivider,
|
|
218
|
+
center, canvasArea, viewport, stage, frame, overlay, actionbar, dataDock, dock, sourceArea, layoutsArea, crumbs,
|
|
219
|
+
right, tablist, tabs, main, status, validity, counter, format, message, sheetBar, sheetButtons, zoomOut, zoomLabel, zoomIn, zoomFit,
|
|
220
|
+
};
|
|
221
|
+
return { slots, controls, shell };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- the controller -------------------------------------------------------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The Studio over the workspace's mounted parts. `ctx` is what the workspace hands over (see src/workspace.js): the session (`session`, and
|
|
228
|
+
* `ui`, the same session refusing writes while read-only), the parts (`canvas`, `pane`, `presenter`, `review`, `exportMenu`, `slideManager`,
|
|
229
|
+
* `find`, `thumbnailCache`), and its functions (`go`, `present`, `say`, `emit`, `report`, `setView`, `readOnly`, `inSync`, `fontsLoading`,
|
|
230
|
+
* `catalogs`, `fonts`, `layoutFor`, `setDocument`, `layoutStudio`). Returns the controller; the workspace forwards its handle members to it.
|
|
231
|
+
*/
|
|
232
|
+
export function createStudio(ctx) {
|
|
233
|
+
const { root, shell, session, ui, canvas, options } = ctx;
|
|
234
|
+
const config = options.studio ?? {};
|
|
235
|
+
const dom = shell.dom;
|
|
236
|
+
const { el } = dom;
|
|
237
|
+
const doc = root.ownerDocument, win = doc.defaultView;
|
|
238
|
+
const cleanups = [];
|
|
239
|
+
const listen = (target, type, handler, opts) => { target.addEventListener(type, handler, opts); cleanups.push(() => target.removeEventListener(type, handler, opts)); };
|
|
240
|
+
let layout = ctx.layout;
|
|
241
|
+
let destroyed = false;
|
|
242
|
+
let menu = null;
|
|
243
|
+
let sheet = null;
|
|
244
|
+
let dataGrid = null;
|
|
245
|
+
let layoutHandoff = null;
|
|
246
|
+
let sourceMode = "off";
|
|
247
|
+
let studioSource = config.panels?.source === false ? "off" : null; // the Studio's own split, remembered while the Playground shows its own
|
|
248
|
+
let playgroundView = "both";
|
|
249
|
+
let sizes = readSizes();
|
|
250
|
+
let lastRender = null;
|
|
251
|
+
|
|
252
|
+
// ---- the adapter, its geometry and the selection --------------------------------------------------------------------------------------
|
|
253
|
+
// The scene's boxes come from the geometry the canvas drew (same fonts, same measurement); a document it has not drawn is composed here.
|
|
254
|
+
const geometry = (presentation, index) => {
|
|
255
|
+
if (lastRender && lastRender.presentation === presentation && lastRender.slideIndex === index) return lastRender.geometry;
|
|
256
|
+
return composeSlideGeometry(presentation, index, { catalogs: ctx.catalogs(), fonts: ctx.layoutFor(presentation, index).fonts });
|
|
257
|
+
};
|
|
258
|
+
const adapter = createSlideAdapter(ui, {
|
|
259
|
+
slideIndex: ctx.slideIndex(),
|
|
260
|
+
geometry,
|
|
261
|
+
selection: () => selection.snapshot(),
|
|
262
|
+
onPreview: ({ presentation }) => {
|
|
263
|
+
if (presentation) canvas.render(presentation);
|
|
264
|
+
else canvas.render();
|
|
265
|
+
},
|
|
266
|
+
onSelection: (keys, info) => setSelection(keys, { source: info.source }),
|
|
267
|
+
onRefuse: ({ reason }) => announce(reason),
|
|
268
|
+
});
|
|
269
|
+
const scene = () => adapter.scene();
|
|
270
|
+
let selectionSource = "init";
|
|
271
|
+
const selection = createSelection(adapter.selectionAdapter(), { onChange: ({ source } = {}) => selectionChanged(source) });
|
|
272
|
+
|
|
273
|
+
const controller = {
|
|
274
|
+
selecting: false,
|
|
275
|
+
get layout() { return layout; },
|
|
276
|
+
get readOnly() { return ctx.readOnly(); },
|
|
277
|
+
get inSync() { return ctx.inSync(); },
|
|
278
|
+
get fontsLoading() { return ctx.fontsLoading(); },
|
|
279
|
+
get slideIndex() { return ctx.slideIndex(); },
|
|
280
|
+
get hasSource() { return Boolean(ctx.pane); },
|
|
281
|
+
get hasFind() { return Boolean(ctx.find); },
|
|
282
|
+
get hasExport() { return Boolean(ctx.exportMenu); },
|
|
283
|
+
catalogs: () => ctx.catalogs(),
|
|
284
|
+
};
|
|
285
|
+
const s = controller;
|
|
286
|
+
const commands = createStudioCommands({ extra: config.commands ?? [], keymap: config.keymap });
|
|
287
|
+
|
|
288
|
+
// ---- selection ----------------------------------------------------------------------------------------------------------------------
|
|
289
|
+
s.selectedKeys = () => selection.items;
|
|
290
|
+
s.primaryKey = () => selection.primary;
|
|
291
|
+
s.selectedNodes = () => selection.items.map((key) => scene().node(key) ?? (isSlideKey(key) ? slideNodeFor(key) : null)).filter(Boolean);
|
|
292
|
+
s.primaryNode = () => { const key = selection.primary; return key ? scene().node(key) ?? (isSlideKey(key) ? slideNodeFor(key) : null) : null; };
|
|
293
|
+
const slideNodeFor = (key) => { const s0 = scene(); return s0.slideKey === key ? s0.node(key) : null; };
|
|
294
|
+
function setSelection(keys, { source = "api" } = {}) {
|
|
295
|
+
selectionSource = source;
|
|
296
|
+
// A slide key of another slide shows that slide.
|
|
297
|
+
const slideKey = keys.find(isSlideKey);
|
|
298
|
+
if (slideKey && slideKey !== scene().slideKey) {
|
|
299
|
+
const index = adapter.slides().findIndex((entry) => entry.key === slideKey);
|
|
300
|
+
if (index >= 0 && index !== ctx.slideIndex()) { revealing = true; ctx.go(index); revealing = false; adapter.setSlide(index); }
|
|
301
|
+
}
|
|
302
|
+
if (!selection.set(keys, { source })) selectionChanged(source, { quiet: true });
|
|
303
|
+
}
|
|
304
|
+
s.select = (keys, options = {}) => setSelection(keys, options);
|
|
305
|
+
s.selectAdd = (key, { source = "canvas" } = {}) => {
|
|
306
|
+
const before = selection.items;
|
|
307
|
+
const node = scene().node(key);
|
|
308
|
+
const first = before.length ? scene().node(before[0]) : null;
|
|
309
|
+
selectionSource = source;
|
|
310
|
+
selection.add(key, { source });
|
|
311
|
+
if (first && node && first.parent !== node.parent) announce(`Selected ${node.label.split(":")[0]}. Multiple selection works within one group.`);
|
|
312
|
+
};
|
|
313
|
+
s.selectRange = (key, { source = "layers" } = {}) => { selectionSource = source; selection.range(key, { source }); };
|
|
314
|
+
s.selectParent = () => {
|
|
315
|
+
const key = selection.primary;
|
|
316
|
+
if (key && isSlideKey(key)) { setSelection([], { source: "keyboard" }); return; }
|
|
317
|
+
if (!key) return;
|
|
318
|
+
selection.selectParent({ source: "keyboard" });
|
|
319
|
+
};
|
|
320
|
+
s.selectChildren = () => { selection.selectChild({ source: "keyboard" }); };
|
|
321
|
+
s.selectSibling = (delta) => {
|
|
322
|
+
if (!selection.primary) { setSelection([scene().slideKey], { source: "keyboard" }); return; }
|
|
323
|
+
selection.selectSibling(delta, { source: "keyboard" });
|
|
324
|
+
};
|
|
325
|
+
s.hasSibling = (delta) => {
|
|
326
|
+
const key = selection.primary;
|
|
327
|
+
if (!key) return true;
|
|
328
|
+
return adapter.selectionAdapter().sibling(key, delta) ? true : delta > 0 ? "This is the last one." : "This is the first one.";
|
|
329
|
+
};
|
|
330
|
+
s.selectSiblings = () => { selection.selectSiblings({ source: "keyboard" }); };
|
|
331
|
+
|
|
332
|
+
let revealing = false;
|
|
333
|
+
function selectionChanged(source = selectionSource, { quiet = false } = {}) {
|
|
334
|
+
if (destroyed) return;
|
|
335
|
+
selectionSource = source;
|
|
336
|
+
paint();
|
|
337
|
+
// Focus in the layers follows the selection (an edit made from the layers keeps the focus on its result).
|
|
338
|
+
layers?.paintSelection({ focus: shell.layersHost.contains(doc.activeElement) });
|
|
339
|
+
design?.refresh();
|
|
340
|
+
syncActionBar();
|
|
341
|
+
if (source !== "source" && !quiet) revealInSource();
|
|
342
|
+
if (source === "keyboard" && (shell.frame.contains(doc.activeElement) || doc.activeElement === doc.body)) canvasView?.focusPrimary();
|
|
343
|
+
if (!quiet) ctx.emit("selection", selectionDetail(source));
|
|
344
|
+
}
|
|
345
|
+
function selectionDetail(source) {
|
|
346
|
+
const items = s.selection;
|
|
347
|
+
const primary = items.at(-1) ?? null;
|
|
348
|
+
const path = primary ? (primary.path === "" ? undefined : dottedPath(primary.path)) : undefined;
|
|
349
|
+
return { items, primary, source, path, value: path ? ui.get(path) : undefined, slideIndex: ctx.slideIndex() };
|
|
350
|
+
}
|
|
351
|
+
// The list is made once per selection (and scene), so a host's store snapshot (`useOpfSelection`) is stable between changes.
|
|
352
|
+
let selectionCache = { items: null, scene: null, list: [] };
|
|
353
|
+
Object.defineProperty(s, "selection", {
|
|
354
|
+
get() {
|
|
355
|
+
const current = scene();
|
|
356
|
+
if (selectionCache.items === selection.items && selectionCache.scene === current) return selectionCache.list;
|
|
357
|
+
const list = Object.freeze(selection.items.map((key) => {
|
|
358
|
+
const node = scene().node(key) ?? slideNodeFor(key);
|
|
359
|
+
const path = node?.path ?? adapter.pathOf(key) ?? "";
|
|
360
|
+
return Object.freeze({ key, path, ...(key.includes("@") ? { idPath: key.split("#")[0] } : {}), kind: node?.kind ?? (isSlideKey(key) ? "slide" : "block") });
|
|
361
|
+
}));
|
|
362
|
+
selectionCache = { items: selection.items, scene: current, list };
|
|
363
|
+
return list;
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ---- announcements and the status line ------------------------------------------------------------------------------------------------
|
|
368
|
+
const announce = (message) => { if (message) ctx.say(message); };
|
|
369
|
+
s.announce = announce;
|
|
370
|
+
s.status = announce;
|
|
371
|
+
|
|
372
|
+
// ---- the parts --------------------------------------------------------------------------------------------------------------------------
|
|
373
|
+
const canvasView = createStudioCanvas({ dom, elements: { root, viewport: shell.viewport, stage: shell.stage, frame: shell.frame, overlay: shell.overlay }, canvas, adapter, studio: s });
|
|
374
|
+
const layers = createStudioLayers(shell.layersHost, { dom, adapter, studio: s });
|
|
375
|
+
const design = createStudioDesign(shell.tabs.design.panel.firstChild, { dom, ui, studio: s, catalogs: ctx.hostCatalogs, designOptions: typeof options.design === "object" ? options.design : {}, onAddAsset: options.onAddAsset });
|
|
376
|
+
|
|
377
|
+
function paint() {
|
|
378
|
+
if (destroyed) return;
|
|
379
|
+
root.dataset.editing = s.editingText() ? "true" : "false";
|
|
380
|
+
canvasView.paint();
|
|
381
|
+
syncStatus();
|
|
382
|
+
syncBar();
|
|
383
|
+
}
|
|
384
|
+
s.frameLabel = () => {
|
|
385
|
+
const slides = adapter.slides();
|
|
386
|
+
const current = slides[ctx.slideIndex()];
|
|
387
|
+
const node = scene().node(scene().slideKey);
|
|
388
|
+
return `Slide ${ctx.slideIndex() + 1} of ${slides.length}${current && node?.label.includes(" · ") ? `, ${node.label.split(" · ")[1]}` : ""}`;
|
|
389
|
+
};
|
|
390
|
+
s.slideLabel = (node) => node.label;
|
|
391
|
+
s.slideUsesLayout = () => scene().layout;
|
|
392
|
+
|
|
393
|
+
// ---- tools and zoom ---------------------------------------------------------------------------------------------------------------------
|
|
394
|
+
s.setTool = (tool) => {
|
|
395
|
+
canvasView.setTool(tool);
|
|
396
|
+
for (const [id, button] of Object.entries(shell.toolButtons)) button.setAttribute("aria-pressed", String(id === tool));
|
|
397
|
+
announce(`${tool === "select" ? "Select" : tool === "hand" ? "Hand" : "Text"} tool`);
|
|
398
|
+
};
|
|
399
|
+
s.zoomBy = (direction) => canvasView.step(direction);
|
|
400
|
+
s.setZoom = (value) => canvasView.setZoom(value);
|
|
401
|
+
s.fit = (mode = "slide") => canvasView.fit(mode);
|
|
402
|
+
s.onZoom = (value) => {
|
|
403
|
+
const text = `${Math.round(value * 100)}%`;
|
|
404
|
+
shell.zoom.querySelector(`.${PREFIX}-button-text`).textContent = text;
|
|
405
|
+
shell.zoom.setAttribute("aria-label", `Zoom ${text}`);
|
|
406
|
+
shell.zoomLabel.textContent = text;
|
|
407
|
+
ctx.emit("zoom", { zoom: value });
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// ---- panels, source split, responsive ---------------------------------------------------------------------------------------------------
|
|
411
|
+
function readSizes() {
|
|
412
|
+
const out = Object.fromEntries(Object.entries(SIZES).map(([key, [value]]) => [key, value]));
|
|
413
|
+
try {
|
|
414
|
+
const stored = config.sizes?.storage?.get?.(`${config.sizes.key}:studio-sizes`);
|
|
415
|
+
if (stored) Object.assign(out, JSON.parse(stored));
|
|
416
|
+
} catch { /* the defaults */ }
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
function writeSizes() {
|
|
420
|
+
try { config.sizes?.storage?.set?.(`${config.sizes.key}:studio-sizes`, JSON.stringify(sizes)); } catch { /* not kept */ }
|
|
421
|
+
}
|
|
422
|
+
function applySizes() {
|
|
423
|
+
root.style.setProperty("--opf-studio-left", `${sizes.left}px`);
|
|
424
|
+
root.style.setProperty("--opf-studio-right", `${sizes.right}px`);
|
|
425
|
+
root.style.setProperty("--opf-studio-split", String(sizes.split));
|
|
426
|
+
root.style.setProperty("--opf-studio-stack", String(sizes.stack));
|
|
427
|
+
for (const [name, separator] of [["left", shell.leftDivider], ["right", shell.rightDivider], ["split", shell.sourceDivider], ["stack", shell.stackDivider]]) {
|
|
428
|
+
const [, min, max] = SIZES[name];
|
|
429
|
+
separator.setAttribute("aria-valuemin", String(name === "split" || name === "stack" ? Math.round(min * 100) : min));
|
|
430
|
+
separator.setAttribute("aria-valuemax", String(name === "split" || name === "stack" ? Math.round(max * 100) : max));
|
|
431
|
+
separator.setAttribute("aria-valuenow", String(name === "split" || name === "stack" ? Math.round(sizes[name] * 100) : sizes[name]));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function resizer(separator, name) {
|
|
435
|
+
const [, min, max] = SIZES[name];
|
|
436
|
+
const fraction = name === "split" || name === "stack";
|
|
437
|
+
const clamp = (value) => Math.max(min, Math.min(max, value));
|
|
438
|
+
const set = (value) => { sizes[name] = fraction ? Math.round(clamp(value) * 1000) / 1000 : Math.round(clamp(value)); applySizes(); };
|
|
439
|
+
listen(separator, "pointerdown", (event) => {
|
|
440
|
+
if (event.button !== 0) return;
|
|
441
|
+
event.preventDefault();
|
|
442
|
+
try { separator.setPointerCapture?.(event.pointerId); } catch { /* a pointer that cannot be captured (a synthetic one) */ }
|
|
443
|
+
const start = { x: event.clientX, y: event.clientY, value: sizes[name] };
|
|
444
|
+
const box = (name === "stack" ? shell.left : shell.center).getBoundingClientRect();
|
|
445
|
+
const vertical = name === "stack" || (name === "split" && sourceMode === "bottom");
|
|
446
|
+
const move = (next) => {
|
|
447
|
+
if (name === "left") set(start.value + (next.clientX - start.x));
|
|
448
|
+
else if (name === "right") set(start.value - (next.clientX - start.x));
|
|
449
|
+
else if (vertical) set(start.value + (name === "stack" ? 1 : -1) * (next.clientY - start.y) / Math.max(1, box.height));
|
|
450
|
+
else set(start.value + (sourceMode === "left" ? 1 : -1) * (next.clientX - start.x) / Math.max(1, box.width));
|
|
451
|
+
};
|
|
452
|
+
const up = () => { separator.removeEventListener("pointermove", move); separator.removeEventListener("pointerup", up); writeSizes(); };
|
|
453
|
+
separator.addEventListener("pointermove", move);
|
|
454
|
+
separator.addEventListener("pointerup", up);
|
|
455
|
+
});
|
|
456
|
+
listen(separator, "keydown", (event) => {
|
|
457
|
+
const step = (event.shiftKey ? 4 : 1) * (fraction ? 0.02 : 8);
|
|
458
|
+
const keys = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 };
|
|
459
|
+
if (!(event.key in keys)) return;
|
|
460
|
+
event.preventDefault();
|
|
461
|
+
event.stopPropagation();
|
|
462
|
+
const sign = name === "right" || (name === "split" && sourceMode !== "left") ? -1 : 1;
|
|
463
|
+
set(sizes[name] + keys[event.key] * sign * step);
|
|
464
|
+
writeSizes();
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
resizer(shell.leftDivider, "left");
|
|
468
|
+
resizer(shell.rightDivider, "right");
|
|
469
|
+
resizer(shell.sourceDivider, "split");
|
|
470
|
+
resizer(shell.stackDivider, "stack");
|
|
471
|
+
applySizes();
|
|
472
|
+
|
|
473
|
+
function setSourceMode(mode) {
|
|
474
|
+
sourceMode = ctx.pane ? mode : "off";
|
|
475
|
+
root.dataset.source = sourceMode;
|
|
476
|
+
shell.source.setAttribute("aria-pressed", String(sourceMode !== "off"));
|
|
477
|
+
shell.sourceDivider.setAttribute("aria-orientation", sourceMode === "bottom" ? "horizontal" : "vertical");
|
|
478
|
+
shell.sourceArea.hidden = sourceMode === "off" && layout === "studio";
|
|
479
|
+
if (sourceMode !== "off") revealInSource();
|
|
480
|
+
}
|
|
481
|
+
s.toggleSource = () => {
|
|
482
|
+
if (layout === "playground") { setPlaygroundView(playgroundView === "preview" ? "both" : "preview"); return; }
|
|
483
|
+
const width = root.getBoundingClientRect().width;
|
|
484
|
+
studioSource = sourceMode === "off" ? (config.panels?.source && config.panels.source !== "tab" ? config.panels.source.replace("split-", "") : width < WIDE ? "bottom" : "right") : "off";
|
|
485
|
+
setSourceMode(studioSource);
|
|
486
|
+
announce(studioSource === "off" ? "Source hidden" : "Source shown");
|
|
487
|
+
};
|
|
488
|
+
s.togglePanels = () => {
|
|
489
|
+
root.dataset.panels = root.dataset.panels === "hidden" ? "shown" : "hidden";
|
|
490
|
+
announce(root.dataset.panels === "hidden" ? "Panels hidden" : "Panels shown");
|
|
491
|
+
};
|
|
492
|
+
function setPlaygroundView(view) {
|
|
493
|
+
playgroundView = view;
|
|
494
|
+
root.dataset.playgroundView = view;
|
|
495
|
+
for (const [id, button] of Object.entries(shell.viewButtons)) button.setAttribute("aria-pressed", String(id === view));
|
|
496
|
+
setSourceMode(view === "preview" ? "off" : "left");
|
|
497
|
+
}
|
|
498
|
+
const observer = typeof win.ResizeObserver === "function" ? new win.ResizeObserver(() => syncWidth()) : null;
|
|
499
|
+
observer?.observe(root);
|
|
500
|
+
cleanups.push(() => observer?.disconnect());
|
|
501
|
+
function syncWidth() {
|
|
502
|
+
const width = root.getBoundingClientRect().width;
|
|
503
|
+
const next = width >= WIDE ? "wide" : width >= NARROW ? "medium" : "narrow";
|
|
504
|
+
if (root.dataset.width !== next) {
|
|
505
|
+
root.dataset.width = next;
|
|
506
|
+
root.dataset.leftOpen = "";
|
|
507
|
+
closeSheet();
|
|
508
|
+
if (layout === "studio" && sourceMode === "right" && next !== "wide") setSourceMode("bottom");
|
|
509
|
+
}
|
|
510
|
+
syncAspect();
|
|
511
|
+
}
|
|
512
|
+
function syncAspect() {
|
|
513
|
+
const g = lastRender?.geometry;
|
|
514
|
+
if (g?.width && g?.height) root.style.setProperty("--opf-studio-aspect", String(g.width / g.height));
|
|
515
|
+
}
|
|
516
|
+
// The rail (medium widths) and the sheet (phones).
|
|
517
|
+
for (const button of [shell.railSlides, shell.railLayers]) {
|
|
518
|
+
listen(button, "click", () => {
|
|
519
|
+
const panel = button.dataset.panel;
|
|
520
|
+
const open = root.dataset.leftOpen === panel ? "" : panel;
|
|
521
|
+
root.dataset.leftOpen = open;
|
|
522
|
+
shell.railSlides.setAttribute("aria-pressed", String(open === "slides"));
|
|
523
|
+
shell.railLayers.setAttribute("aria-pressed", String(open === "layers"));
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
function openSheet(name) {
|
|
527
|
+
sheet = sheet === name ? null : name;
|
|
528
|
+
root.dataset.sheet = sheet ?? "";
|
|
529
|
+
for (const [id, button] of Object.entries(shell.sheetButtons)) button.setAttribute("aria-pressed", String(id === sheet));
|
|
530
|
+
if (sheet === "design" || sheet === "review") showTab(sheet);
|
|
531
|
+
}
|
|
532
|
+
function closeSheet() { sheet = null; root.dataset.sheet = ""; for (const button of Object.values(shell.sheetButtons)) button.setAttribute("aria-pressed", "false"); }
|
|
533
|
+
for (const [id, button] of Object.entries(shell.sheetButtons)) listen(button, "click", () => openSheet(id));
|
|
534
|
+
|
|
535
|
+
// ---- tabs (Design, Review, host panels) -------------------------------------------------------------------------------------------------
|
|
536
|
+
const tabIds = Object.keys(shell.tabs).filter((id) => shell.tabs[id].tab);
|
|
537
|
+
function showTab(id, { focus = false } = {}) {
|
|
538
|
+
if (!shell.tabs[id]?.tab) return false;
|
|
539
|
+
for (const name of tabIds) {
|
|
540
|
+
const { tab, panel } = shell.tabs[name];
|
|
541
|
+
const on = name === id;
|
|
542
|
+
tab.setAttribute("aria-selected", String(on));
|
|
543
|
+
tab.tabIndex = on ? 0 : -1;
|
|
544
|
+
panel.hidden = !on;
|
|
545
|
+
}
|
|
546
|
+
if (focus) shell.tabs[id].tab.focus();
|
|
547
|
+
ctx.emit("panel", { panel: id });
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
s.showTab = showTab;
|
|
551
|
+
for (const id of tabIds) {
|
|
552
|
+
const { tab } = shell.tabs[id];
|
|
553
|
+
listen(tab, "click", () => showTab(id));
|
|
554
|
+
listen(tab, "keydown", (event) => {
|
|
555
|
+
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
|
556
|
+
event.preventDefault();
|
|
557
|
+
const at = tabIds.indexOf(id);
|
|
558
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? tabIds.length - 1 : (at + (event.key === "ArrowRight" ? 1 : -1) + tabIds.length) % tabIds.length;
|
|
559
|
+
showTab(tabIds[next], { focus: true });
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
showTab(options.panel && shell.tabs[options.panel]?.tab ? options.panel : "design");
|
|
563
|
+
// Host panels: each is mounted once into its panel element; its cleanup runs on destroy.
|
|
564
|
+
for (const entry of Object.values(shell.tabs)) {
|
|
565
|
+
if (!entry.host) continue;
|
|
566
|
+
try {
|
|
567
|
+
const stop = entry.host.mount(entry.panel, ctx.handle);
|
|
568
|
+
if (typeof stop === "function") cleanups.push(stop);
|
|
569
|
+
} catch (error) { ctx.report(error); }
|
|
570
|
+
}
|
|
571
|
+
s.showDesignSection = (name) => { showTab("design"); design.showSection(name); };
|
|
572
|
+
|
|
573
|
+
// ---- source sync (CE-17) ---------------------------------------------------------------------------------------------------------------
|
|
574
|
+
function revealInSource() {
|
|
575
|
+
const pane = ctx.pane;
|
|
576
|
+
if (!pane || sourceMode === "off" || layout !== "studio") return;
|
|
577
|
+
const node = s.primaryNode();
|
|
578
|
+
const pointer = node ? node.path : null;
|
|
579
|
+
if (!pointer) return;
|
|
580
|
+
try { pane.revealPath(pointer, { focus: false, flash: true }); } catch { /* text that does not read */ }
|
|
581
|
+
}
|
|
582
|
+
cleanups.push(ctx.on("cursor", (detail) => {
|
|
583
|
+
if (layout !== "studio" || !detail?.pointer || !shell.sourceArea.contains(doc.activeElement) && !detail.force) return;
|
|
584
|
+
selectPointer(detail.pointer, "source");
|
|
585
|
+
}));
|
|
586
|
+
/** Select the deepest node of the scene whose path holds `pointer` (showing its slide first). */
|
|
587
|
+
function selectPointer(pointer, source) {
|
|
588
|
+
let tokens;
|
|
589
|
+
try { tokens = parsePointer(toPointer(pointer)); } catch { return false; }
|
|
590
|
+
if (tokens[0] !== "slides" || !/^\d+$/.test(tokens[1] ?? "")) return false;
|
|
591
|
+
const index = Number(tokens[1]);
|
|
592
|
+
if (index !== ctx.slideIndex()) { revealing = true; ctx.go(index); revealing = false; adapter.setSlide(index); }
|
|
593
|
+
const s0 = scene();
|
|
594
|
+
if (s0.slideIndex !== index) return false;
|
|
595
|
+
let best = null;
|
|
596
|
+
for (const node of s0.nodes) {
|
|
597
|
+
if (node.virtual || node.kind === "part") continue;
|
|
598
|
+
const path = parsePointer(node.path);
|
|
599
|
+
if (prefixOf(path, tokens) && (!best || path.length > parsePointer(best.path).length)) best = node;
|
|
600
|
+
}
|
|
601
|
+
if (!best) return false;
|
|
602
|
+
if (selection.primary === best.key && selection.items.length === 1) return true;
|
|
603
|
+
setSelection([best.key], { source });
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
s.revealPath = (path) => selectPointer(path, "reveal");
|
|
607
|
+
/** Select keys (scene keys or slide keys) or paths (JSON Pointers or dotted OPF paths); returns whether everything was found. */
|
|
608
|
+
s.selectTargets = (list) => {
|
|
609
|
+
const items = [].concat(list ?? []).filter((item) => typeof item === "string");
|
|
610
|
+
if (!items.length) { setSelection([], { source: "api" }); return true; }
|
|
611
|
+
const keys = [];
|
|
612
|
+
for (const item of items) {
|
|
613
|
+
if (scene().node(item) || (isSlideKey(item) && adapter.slides().some((entry) => entry.key === item))) { keys.push(item); continue; }
|
|
614
|
+
if (!selectPointer(item, "api")) return false;
|
|
615
|
+
keys.push(selection.primary);
|
|
616
|
+
}
|
|
617
|
+
setSelection(keys, { source: "api" });
|
|
618
|
+
return keys.every((key) => selection.items.includes(key));
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
// ---- commands state --------------------------------------------------------------------------------------------------------------------
|
|
622
|
+
s.slideScope = () => {
|
|
623
|
+
const active = doc.activeElement;
|
|
624
|
+
if (ctx.slots.slides?.contains(active)) return true;
|
|
625
|
+
return Boolean(selection.primary && isSlideKey(selection.primary));
|
|
626
|
+
};
|
|
627
|
+
const filmstripKeys = () => {
|
|
628
|
+
const list = ctx.slideManager?.getSelection?.() ?? [ctx.slideIndex()];
|
|
629
|
+
const slides = adapter.slides();
|
|
630
|
+
return list.map((index) => slides[index]?.key).filter(Boolean);
|
|
631
|
+
};
|
|
632
|
+
const scopeKeys = () => (s.slideScope() ? (ctx.slots.slides?.contains(doc.activeElement) ? filmstripKeys() : selection.items.filter(isSlideKey)) : selection.items);
|
|
633
|
+
s.hasClipboard = () => Boolean(clipboard);
|
|
634
|
+
s.canUndo = () => session.canUndo;
|
|
635
|
+
s.canRedo = () => session.canRedo;
|
|
636
|
+
s.slideCount = () => adapter.slides().length;
|
|
637
|
+
s.groupBeside = (delta) => {
|
|
638
|
+
const nodes = s.selectedNodes();
|
|
639
|
+
if (!nodes.length || !nodes[0].parent) return "Select blocks first.";
|
|
640
|
+
const list = scene().contentChildren(nodes[0].parent);
|
|
641
|
+
const at = delta < 0 ? list.findIndex((node) => node.key === nodes[0].key) - 1 : list.findIndex((node) => node.key === nodes.at(-1).key) + 1;
|
|
642
|
+
return list[at]?.kind === "group" ? true : delta < 0 ? "There is no group just before this." : "There is no group just after this.";
|
|
643
|
+
};
|
|
644
|
+
s.canPlaceInRegions = () => (scene().layout ? "This slide uses a layout's regions." : scene().node(scene().slideKey)?.container?.promoted ? "This slide's content is already in regions." : true);
|
|
645
|
+
s.canReleaseRegions = () => (scene().node(scene().slideKey)?.container?.promoted ? true : "This slide's content is not in regions.");
|
|
646
|
+
s.canEditLayout = () => {
|
|
647
|
+
if (!ctx.slots.layouts) return "Layout Studio is not part of this editor.";
|
|
648
|
+
const slide = ui.get(`/slides/${ctx.slideIndex()}`);
|
|
649
|
+
return typeof slide?.layout === "string" && slide.layout !== "auto" ? true : "This slide has no layout to edit. Choose one first.";
|
|
650
|
+
};
|
|
651
|
+
s.textTarget = () => {
|
|
652
|
+
const node = s.primaryNode();
|
|
653
|
+
if (!node || selection.items.length !== 1 || !isTextNode(node)) return null;
|
|
654
|
+
return textPathOf(node);
|
|
655
|
+
};
|
|
656
|
+
s.editingText = () => Boolean(canvas.editingPath) || Boolean(canvas.cropping);
|
|
657
|
+
/** Whether a key event is a shortcut of an enabled command other than a tool (so typing on a selected text node does not take it). */
|
|
658
|
+
s.isShortcut = (event) => {
|
|
659
|
+
const command = commands.match(event, s, { scope: "canvas" }) ?? commands.match(event, s, { scope: "global" });
|
|
660
|
+
return Boolean(command && command.group !== "Tools");
|
|
661
|
+
};
|
|
662
|
+
// One command's state and shortcut, without evaluating the whole registry (the Design panel asks for several on every selection).
|
|
663
|
+
s.commandState = (id) => {
|
|
664
|
+
const [info] = commands.menu([id], s, { win });
|
|
665
|
+
return info ? { enabled: !info.disabled, reason: info.title ?? "", shortcut: info.hint ?? "" } : { enabled: false, reason: "Unknown command." };
|
|
666
|
+
};
|
|
667
|
+
s.run = (id) => runCommand(id);
|
|
668
|
+
function runCommand(id, event) {
|
|
669
|
+
const command = commands.get(id);
|
|
670
|
+
if (!command) return false;
|
|
671
|
+
const verdict = command.when ? command.when(s) : true;
|
|
672
|
+
if (verdict !== true) {
|
|
673
|
+
if (typeof verdict === "string") announce(verdict);
|
|
674
|
+
ctx.emit("command", { id, ok: false, reason: typeof verdict === "string" ? verdict : "" });
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
let ok = true;
|
|
678
|
+
try {
|
|
679
|
+
const result = command.run(s, event);
|
|
680
|
+
ok = result === undefined ? true : Boolean(result);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
ok = false;
|
|
683
|
+
announce(error?.message ?? String(error));
|
|
684
|
+
}
|
|
685
|
+
ctx.emit("command", { id, ok });
|
|
686
|
+
return ok;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ---- structural commands (one adapter call each: one undo step) -----------------------------------------------------------------------
|
|
690
|
+
const nameOf = (node) => (node ? node.label.split(":")[0] : "");
|
|
691
|
+
function placeMessage(keys, verb) {
|
|
692
|
+
const s0 = scene();
|
|
693
|
+
const node = s0.node(keys[0]);
|
|
694
|
+
if (!node) return verb;
|
|
695
|
+
const list = s0.contentChildren(node.parent);
|
|
696
|
+
const at = list.findIndex((entry) => entry.key === node.key);
|
|
697
|
+
const lastAt = list.findIndex((entry) => entry.key === keys.at(-1));
|
|
698
|
+
const subject = keys.length > 1 ? plural(keys.length, "block") : nameOf(node);
|
|
699
|
+
const next = list[lastAt + 1], previous = list[at - 1];
|
|
700
|
+
if (next) return `${verb} ${subject} before ${nameOf(next)}`;
|
|
701
|
+
if (previous) return `${verb} ${subject} after ${nameOf(previous)}`;
|
|
702
|
+
const parent = s0.node(node.parent);
|
|
703
|
+
return `${verb} ${subject} into ${parent?.kind === "slide" ? "the slide" : nameOf(parent)}`;
|
|
704
|
+
}
|
|
705
|
+
function applyCommand(command, args, message) {
|
|
706
|
+
if (s.editingText() && !canvas.commit()) return false;
|
|
707
|
+
const result = adapter.apply(command, ...args);
|
|
708
|
+
if (!result.ok) return false;
|
|
709
|
+
if (!result.changed) { announce("Nothing changed."); return false; }
|
|
710
|
+
announce(typeof message === "function" ? message(result) : message ?? result.label);
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
s.move = (keys, target, index) => applyCommand("move", [keys, target, index], (result) => placeMessage(result.keys, "Moved"));
|
|
714
|
+
s.moveBy = (delta) => applyCommand("moveBy", [selection.items, delta], (result) => placeMessage(result.keys, "Moved"));
|
|
715
|
+
s.moveInto = (delta) => applyCommand("moveInto", [selection.items, delta], (result) => placeMessage(result.keys, "Moved"));
|
|
716
|
+
s.group = () => applyCommand("group", [selection.items], (result) => `${result.label}`);
|
|
717
|
+
s.ungroup = () => applyCommand("ungroup", [selection.primary], (result) => `Ungrouped into ${plural(result.keys.length, "block")}`);
|
|
718
|
+
s.duplicate = () => {
|
|
719
|
+
const keys = scopeKeys();
|
|
720
|
+
return applyCommand("duplicate", [keys]);
|
|
721
|
+
};
|
|
722
|
+
s.remove = () => {
|
|
723
|
+
const keys = scopeKeys();
|
|
724
|
+
const slides = keys.every(isSlideKey);
|
|
725
|
+
if (slides && keys.length >= adapter.slides().length) { announce("A presentation keeps at least one slide."); return false; }
|
|
726
|
+
return applyCommand("delete", [keys], (result) => `${result.label}. Undo with ${formatKey("Mod-z", win)}.`);
|
|
727
|
+
};
|
|
728
|
+
s.equalize = (key, index) => applyCommand("equalize", [key, index], "Equalized");
|
|
729
|
+
s.setWeights = (key, weights) => applyCommand("setWeights", [key, weights]);
|
|
730
|
+
s.nudgeDivider = (key, index, step, big) => {
|
|
731
|
+
const node = scene().node(key);
|
|
732
|
+
const track = node?.container?.tracks;
|
|
733
|
+
if (!track) return false;
|
|
734
|
+
const base = track[index].length / (track[index].length + track[index + 1].length);
|
|
735
|
+
const current = node.container.tracks.map((entry) => entry.weight);
|
|
736
|
+
for (let attempt = 1; attempt <= 40; attempt += 1) {
|
|
737
|
+
const fraction = Math.max(0.02, Math.min(0.98, base + step * attempt * (big ? 0.05 : 0.01)));
|
|
738
|
+
let prepared;
|
|
739
|
+
try { prepared = adapter.prepare("resizeTracks", key, index, fraction); } catch (error) { announce(error.message); return false; }
|
|
740
|
+
const moved = prepared.changed && (!jsonEqual(prepared.weights, current) || attempt === 1 && !current.length);
|
|
741
|
+
if (moved) return applyCommand("resizeTracks", [key, index, fraction]);
|
|
742
|
+
if (fraction === 0.02 || fraction === 0.98) break;
|
|
743
|
+
}
|
|
744
|
+
announce("The divider is at its end.");
|
|
745
|
+
return false;
|
|
746
|
+
};
|
|
747
|
+
s.writeComposition = (node, composition, label) => {
|
|
748
|
+
if (s.readOnly) { announce("This editor is read-only."); return false; }
|
|
749
|
+
try {
|
|
750
|
+
const pointer = `${node.path}/composition`;
|
|
751
|
+
const patches = composition === undefined ? (ui.get(pointer) === undefined ? [] : [{ op: "remove", path: pointer }]) : [{ op: ui.get(pointer) === undefined ? "add" : "replace", path: pointer, value: composition }];
|
|
752
|
+
if (!patches.length) return false;
|
|
753
|
+
ui.applyPatch(patches, { source: "studio", label, selection: selection.snapshot(), rejectInvalid: true });
|
|
754
|
+
announce(label);
|
|
755
|
+
return true;
|
|
756
|
+
} catch (error) { announce(error.message); return false; }
|
|
757
|
+
};
|
|
758
|
+
s.writeValue = (pointer, value, label) => {
|
|
759
|
+
if (s.readOnly) { announce("This editor is read-only."); return false; }
|
|
760
|
+
try {
|
|
761
|
+
const has = ui.get(pointer) !== undefined;
|
|
762
|
+
const patches = value === undefined ? (has ? [{ op: "remove", path: pointer }] : []) : [{ op: has ? "replace" : "add", path: pointer, value }];
|
|
763
|
+
if (patches.length) ui.applyPatch(patches, { source: "studio", label, rejectInvalid: true });
|
|
764
|
+
announce(label);
|
|
765
|
+
return true;
|
|
766
|
+
} catch (error) { announce(error.message); return false; }
|
|
767
|
+
};
|
|
768
|
+
s.undo = () => { if (s.editingText() && !canvas.commit()) return false; ctx.pane?.getText(); return adapter.undo() ? (announce("Undone"), true) : false; };
|
|
769
|
+
s.redo = () => { if (s.editingText() && !canvas.commit()) return false; ctx.pane?.getText(); return adapter.redo() ? (announce("Redone"), true) : false; };
|
|
770
|
+
s.afterGesture = () => paint();
|
|
771
|
+
|
|
772
|
+
// Convert to: the primary block's targets.
|
|
773
|
+
s.canConvert = (kind) => {
|
|
774
|
+
const node = s.primaryNode();
|
|
775
|
+
if (!node || selection.items.length !== 1 || !(node.kind === "block" || node.kind === "implicit")) return "Select one block to convert it.";
|
|
776
|
+
if (node.type === kind) return `This is already ${kind}.`;
|
|
777
|
+
const target = blockConversionTargets(ui.presentation, dottedPath(node.path), { catalogs: ctx.catalogs() }).find((entry) => entry.kind === kind);
|
|
778
|
+
if (!target) return `This cannot become ${kind}.`;
|
|
779
|
+
return target.available ? true : target.reason ?? `This cannot become ${kind}.`;
|
|
780
|
+
};
|
|
781
|
+
s.convert = (kind) => {
|
|
782
|
+
const node = s.primaryNode();
|
|
783
|
+
try {
|
|
784
|
+
const change = convertBlock(ui, dottedPath(node.path), kind, { source: "studio", selection: selection.snapshot() }, { catalogs: ctx.catalogs() });
|
|
785
|
+
announce(`Converted to ${STUDIO_INSERT_LABELS[kind]?.toLowerCase() ?? kind}${change.lossless === false ? ". Some content did not fit; undo to get it back." : ""}`);
|
|
786
|
+
return true;
|
|
787
|
+
} catch (error) { announce(error.message); return false; }
|
|
788
|
+
};
|
|
789
|
+
s.placeInRegions = () => { setSelection([scene().slideKey], { source: "api" }); showTab("design"); design.showSection("slide-content"); announce("Choose how to place the blocks in the slide's Design panel."); };
|
|
790
|
+
s.releaseRegions = () => {
|
|
791
|
+
try { regionsAsBlocks(ui, ctx.slideIndex(), { source: "studio" }); announce("Regions are now blocks, in reading order."); return true; } catch (error) { announce(error.message); return false; }
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// ---- clipboard ------------------------------------------------------------------------------------------------------------------------
|
|
795
|
+
const token = Symbol("studio");
|
|
796
|
+
s.copy = () => {
|
|
797
|
+
const keys = scopeKeys();
|
|
798
|
+
try {
|
|
799
|
+
const clip = adapter.copy(keys);
|
|
800
|
+
if (clip.kind === "opf-studio-blocks") {
|
|
801
|
+
// A self-contained one-slide deck carries the catalog records the blocks use, so a paste into another deck namespaces them.
|
|
802
|
+
const slide = { ...ui.get(`/slides/${ctx.slideIndex()}`) };
|
|
803
|
+
for (const key of Object.keys(slide)) if (!["id", "layout", "design", "composition"].includes(key)) delete slide[key];
|
|
804
|
+
delete slide.layout;
|
|
805
|
+
clip.deck = prepareSave({ ...ui.presentation, slides: [{ ...slide, blocks: structuredClone(clip.blocks) }] }, { catalogs: ctx.catalogs() }).document;
|
|
806
|
+
}
|
|
807
|
+
clipboard = { ...clip, source: token, documentHash: session.documentHash() };
|
|
808
|
+
const text = JSON.stringify(clip.kind === "opf-studio-slides" ? clip.presentation : clip.deck, null, 2);
|
|
809
|
+
copyText(text).catch(() => {});
|
|
810
|
+
announce(clip.kind === "opf-studio-slides" ? `Copied ${plural(clip.count, "slide")}` : `Copied ${plural(clip.count, "block")}`);
|
|
811
|
+
return true;
|
|
812
|
+
} catch (error) { announce(error.message); return false; }
|
|
813
|
+
};
|
|
814
|
+
s.cut = () => (s.copy() ? s.remove() : false);
|
|
815
|
+
s.paste = () => {
|
|
816
|
+
const clip = clipboard;
|
|
817
|
+
if (!clip) { announce("The clipboard is empty."); return false; }
|
|
818
|
+
if (s.editingText() && !canvas.commit()) return false;
|
|
819
|
+
if (clip.kind === "opf-studio-slides") {
|
|
820
|
+
const after = s.slideScope() && ctx.slideManager ? Math.max(...(ctx.slideManager.getSelection?.() ?? [ctx.slideIndex()])) : ctx.slideIndex();
|
|
821
|
+
return applyCommand("paste", [clip, { slideIndex: after }], (result) => `${result.label}`);
|
|
822
|
+
}
|
|
823
|
+
const place = pastePlace();
|
|
824
|
+
if (typeof place === "string") { announce(place); return false; }
|
|
825
|
+
if (clip.source === token) return applyCommand("paste", [clip, place]);
|
|
826
|
+
return pasteForeign(clip, place);
|
|
827
|
+
};
|
|
828
|
+
// Where a paste or an insert lands: after the primary in its container, into a selected container, or at the end of the slide.
|
|
829
|
+
function pastePlace(kind) {
|
|
830
|
+
const s0 = scene();
|
|
831
|
+
const node = s.primaryNode();
|
|
832
|
+
if (node && (node.kind === "block" || node.kind === "implicit" || node.kind === "group" && kind === undefined)) {
|
|
833
|
+
const parent = s0.node(node.parent);
|
|
834
|
+
if (parent && (parent.kind !== "slide" || !s0.layout)) return { target: parent.key, index: s0.contentChildren(parent.key).findIndex((entry) => entry.key === node.key) + 1 };
|
|
835
|
+
}
|
|
836
|
+
if (node && (node.kind === "group" || node.kind === "region")) return { target: node.key, index: s0.contentChildren(node.key).length };
|
|
837
|
+
const slide = s0.node(s0.slideKey);
|
|
838
|
+
if (s0.layout || slide.container?.promoted) {
|
|
839
|
+
const regions = s0.children(s0.slideKey).filter((entry) => entry.kind === "region");
|
|
840
|
+
const region = regions.find((entry) => !kind || !entry.accepts?.length || entry.accepts.includes(kind));
|
|
841
|
+
if (!region) return kind ? `No region of this layout accepts ${kind}.` : "Select a region to paste into.";
|
|
842
|
+
return { target: region.key, index: s0.contentChildren(region.key).length };
|
|
843
|
+
}
|
|
844
|
+
return { target: s0.slideKey, index: s0.contentChildren(s0.slideKey).length };
|
|
845
|
+
}
|
|
846
|
+
// Blocks copied from another deck: the transfer import brings their catalog records, assets and datasets along (renamed where they
|
|
847
|
+
// differ), then the blocks are pasted; one change.
|
|
848
|
+
function pasteForeign(clip, place) {
|
|
849
|
+
try {
|
|
850
|
+
const base = ui.presentation;
|
|
851
|
+
const imported = prepareOpfImport(base, { presentation: clip.deck }, { mode: "insert", slideIndex: base.slides.length - 1, catalogs: ctx.catalogs() });
|
|
852
|
+
const added = imported.presentation.slides[base.slides.length];
|
|
853
|
+
const blocks = added?.blocks ?? clip.blocks;
|
|
854
|
+
const patches = [];
|
|
855
|
+
for (const key of new Set([...Object.keys(base), ...Object.keys(imported.presentation)])) {
|
|
856
|
+
if (key === "slides" || jsonEqual(base[key], imported.presentation[key])) continue;
|
|
857
|
+
patches.push(imported.presentation[key] === undefined ? { op: "remove", path: formatPointer([key]) } : { op: Object.hasOwn(base, key) ? "replace" : "add", path: formatPointer([key]), value: imported.presentation[key] });
|
|
858
|
+
}
|
|
859
|
+
const prepared = adapter.prepare("paste", { kind: "opf-studio-blocks", blocks, count: blocks.length }, place);
|
|
860
|
+
ui.applyPatch([...patches, ...prepared.patches], { source: "studio", label: prepared.label, selection: selection.snapshot(), rejectInvalid: true }, { expectHash: session.documentHash() });
|
|
861
|
+
setSelection(prepared.paths.map((path) => adapter.keyOf(path)), { source: "paste" });
|
|
862
|
+
const renamed = Object.keys(imported.renamed ?? {}).length;
|
|
863
|
+
announce(`${prepared.label}${renamed ? `, with ${plural(renamed, "catalog record")} renamed` : ""}`);
|
|
864
|
+
return true;
|
|
865
|
+
} catch (error) { announce(error.message); return false; }
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ---- insert ---------------------------------------------------------------------------------------------------------------------------
|
|
869
|
+
s.insertVerdict = (kind, targetKey) => {
|
|
870
|
+
const s0 = scene();
|
|
871
|
+
const target = s0.node(targetKey);
|
|
872
|
+
if (!target) return "Drop inside the slide.";
|
|
873
|
+
if (target.kind === "region" && target.virtual && target.accepts?.length && !target.accepts.includes(kind)) return `Region ${target.region} accepts only ${target.accepts.join(", ")}.`;
|
|
874
|
+
if (target.kind === "slide" && s0.layout) return "Drop into one of the layout's regions.";
|
|
875
|
+
if (target.kind === "slide" && target.container?.promoted) return "Drop into one of the slide's regions.";
|
|
876
|
+
if (target.kind === "region" && target.virtual && target.max && s0.contentChildren(target.key).length >= target.max) return `Region ${target.region} is full.`;
|
|
877
|
+
return true;
|
|
878
|
+
};
|
|
879
|
+
s.canInsert = (kind) => {
|
|
880
|
+
const place = pastePlace(kind);
|
|
881
|
+
if (typeof place === "string") return place;
|
|
882
|
+
return s.insertVerdict(kind, place.target);
|
|
883
|
+
};
|
|
884
|
+
function newBlock(kind) {
|
|
885
|
+
if (kind === "image") return { image: { src: PLACEHOLDER_PICTURE, alt: "Placeholder picture" } };
|
|
886
|
+
return createContentBlock(kind);
|
|
887
|
+
}
|
|
888
|
+
s.insert = (kind, place = pastePlace(kind)) => {
|
|
889
|
+
if (typeof place === "string") { announce(place); return false; }
|
|
890
|
+
const verdict = s.insertVerdict(kind, place.target);
|
|
891
|
+
if (verdict !== true) { announce(verdict); return false; }
|
|
892
|
+
if (kind === "video") { promptVideo((src) => insertBlock({ video: { src } }, kind, place)); return true; }
|
|
893
|
+
return insertBlock(newBlock(kind), kind, place);
|
|
894
|
+
};
|
|
895
|
+
function insertBlock(block, kind, place) {
|
|
896
|
+
if (s.editingText() && !canvas.commit()) return false;
|
|
897
|
+
// A deck that uses ids gives the new block one too (`chart-2`, unused), so its key survives later moves.
|
|
898
|
+
if (typeof ui.get(`/slides/${ctx.slideIndex()}/id`) === "string" && block.id === undefined) {
|
|
899
|
+
const taken = new Set(collectReservedPresentationIds(ui.presentation));
|
|
900
|
+
let number = 1;
|
|
901
|
+
while (taken.has(`${kind}-${number}`)) number += 1;
|
|
902
|
+
block.id = `${kind}-${number}`;
|
|
903
|
+
}
|
|
904
|
+
const label = `Inserted ${STUDIO_INSERT_LABELS[kind].toLowerCase()}`;
|
|
905
|
+
const result = adapter.apply("paste", { kind: "opf-studio-blocks", blocks: [block], count: 1 }, { ...place, label });
|
|
906
|
+
if (!result.changed) return false;
|
|
907
|
+
announce(label);
|
|
908
|
+
if (["text", "list", "quote", "metric", "code"].includes(kind)) win.requestAnimationFrame?.(() => { if (!destroyed && selection.primary === result.keys[0]) s.beginTextEdit(result.keys[0]); });
|
|
909
|
+
return true;
|
|
910
|
+
}
|
|
911
|
+
s.insertAtPoint = (kind, point) => {
|
|
912
|
+
const found = adapter.dropAt(point, []);
|
|
913
|
+
s.setTool("select");
|
|
914
|
+
if (!found) return false;
|
|
915
|
+
return s.insert(kind, { target: found.target, index: found.index });
|
|
916
|
+
};
|
|
917
|
+
function promptVideo(done) {
|
|
918
|
+
const dialog = el("div", `${PREFIX}-dialog`, { role: "dialog", "aria-modal": "true", "aria-labelledby": "opf-studio-video-title" });
|
|
919
|
+
const input = el("input", `${PREFIX}-input`, { type: "url", placeholder: "https://…", "aria-label": "Video address" });
|
|
920
|
+
const ok = dom.button(`${PREFIX}-action`, { text: "Insert video" });
|
|
921
|
+
const cancel = dom.button(`${PREFIX}-action`, { text: "Cancel" });
|
|
922
|
+
const close = () => { dialog.remove(); canvasView.focusPrimary(); };
|
|
923
|
+
ok.addEventListener("click", () => { const value = input.value.trim(); if (!value) { input.focus(); return; } close(); done(value); });
|
|
924
|
+
cancel.addEventListener("click", close);
|
|
925
|
+
dialog.addEventListener("keydown", (event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); } if (event.key === "Enter" && event.target === input) ok.click(); });
|
|
926
|
+
dialog.append(el("h2", `${PREFIX}-dialog-title`, { id: "opf-studio-video-title", text: "Insert a video" }), input, el("div", `${PREFIX}-actions`));
|
|
927
|
+
dialog.lastChild.append(ok, cancel);
|
|
928
|
+
root.append(dialog);
|
|
929
|
+
input.focus();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ---- text, data, crop -------------------------------------------------------------------------------------------------------------------
|
|
933
|
+
const preview = () => ctx.slots.canvas.querySelector(".opf-canvas-preview");
|
|
934
|
+
function textPathOf(node, point) {
|
|
935
|
+
const host = preview();
|
|
936
|
+
if (!host) return null;
|
|
937
|
+
const dotted = dottedPath(node.path);
|
|
938
|
+
const matches = (path) => path === dotted || path.startsWith(`${dotted}.`);
|
|
939
|
+
if (point) {
|
|
940
|
+
for (const element of doc.elementsFromPoint(point.clientX, point.clientY)) {
|
|
941
|
+
const target = element.closest?.("[data-canvas-target]");
|
|
942
|
+
if (target && host.contains(target) && matches(target.getAttribute("data-opf-path"))) return target.getAttribute("data-opf-path");
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
for (const target of host.querySelectorAll("[data-canvas-target]")) if (matches(target.getAttribute("data-opf-path"))) return target.getAttribute("data-opf-path");
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
s.beginTextEdit = (key, { point, replace } = {}) => {
|
|
949
|
+
const node = scene().node(key);
|
|
950
|
+
if (!node) return false;
|
|
951
|
+
if (s.readOnly) { announce("This editor is read-only."); return false; }
|
|
952
|
+
if (!(selection.items.length === 1 && selection.primary === key)) setSelection([key], { source: "canvas" });
|
|
953
|
+
const path = textPathOf(node, point);
|
|
954
|
+
if (!path) { announce("There is no text to edit here."); return false; }
|
|
955
|
+
root.dataset.editing = "true";
|
|
956
|
+
canvas.beginEdit(path, point ? { point } : undefined);
|
|
957
|
+
if (replace) {
|
|
958
|
+
const input = doc.activeElement;
|
|
959
|
+
if (input && typeof input.setRangeText === "function") {
|
|
960
|
+
input.setRangeText(replace, input.selectionStart ?? 0, input.selectionEnd ?? input.value.length, "end");
|
|
961
|
+
input.dispatchEvent(new win.Event("input", { bubbles: true }));
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
root.dataset.editing = s.editingText() ? "true" : "false";
|
|
965
|
+
return true;
|
|
966
|
+
};
|
|
967
|
+
s.editText = () => s.beginTextEdit(selection.primary);
|
|
968
|
+
s.cropImage = () => {
|
|
969
|
+
const node = s.primaryNode();
|
|
970
|
+
if (!node) return false;
|
|
971
|
+
const dotted = dottedPath(node.path);
|
|
972
|
+
const path = node.kind === "implicit" ? dotted : `${dotted}.image`;
|
|
973
|
+
canvas.cropImage(path).then((opened) => { if (!opened) announce("This picture cannot be cropped."); else root.dataset.editing = "true"; });
|
|
974
|
+
return true;
|
|
975
|
+
};
|
|
976
|
+
s.editData = () => {
|
|
977
|
+
const node = s.primaryNode();
|
|
978
|
+
if (!node) return false;
|
|
979
|
+
openDataDock(dottedPath(node.path));
|
|
980
|
+
return true;
|
|
981
|
+
};
|
|
982
|
+
async function openDataDock(path) {
|
|
983
|
+
shell.dataDock.hidden = false;
|
|
984
|
+
if (!dataGrid) {
|
|
985
|
+
const { createDataGrid } = await import("./data-grid.js");
|
|
986
|
+
if (destroyed) return;
|
|
987
|
+
const head = el("div", `${PREFIX}-datahead`);
|
|
988
|
+
const close = dom.button(`${PREFIX}-iconbutton`, { label: "Close data", iconName: "X", size: 16 });
|
|
989
|
+
close.addEventListener("click", () => { shell.dataDock.hidden = true; canvasView.focusPrimary(); });
|
|
990
|
+
head.append(el("h2", `${PREFIX}-paneltitle`, { text: "Data" }), close);
|
|
991
|
+
const host = el("div", `${PREFIX}-datagrid`);
|
|
992
|
+
shell.dataDock.replaceChildren(head, host);
|
|
993
|
+
dataGrid = createDataGrid(host, { editor: ui, getSelectedPath: () => dataPath, maxHeight: "220px", onStatus: (message) => announce(message) });
|
|
994
|
+
}
|
|
995
|
+
dataPath = path;
|
|
996
|
+
dataGrid.refresh();
|
|
997
|
+
dataGrid.focus();
|
|
998
|
+
}
|
|
999
|
+
let dataPath = null;
|
|
1000
|
+
|
|
1001
|
+
// ---- slides -----------------------------------------------------------------------------------------------------------------------------
|
|
1002
|
+
const currentLayout = () => { const slide = ui.get(`/slides/${ctx.slideIndex()}`); return typeof slide?.layout === "string" ? slide.layout : undefined; };
|
|
1003
|
+
s.newSlide = (layoutId = currentLayout(), at = ctx.slideIndex() + 1) => {
|
|
1004
|
+
try {
|
|
1005
|
+
const change = addSlide(ui, { at, ...(layoutId && layoutId !== "auto" ? { layout: layoutId } : {}) }, { source: "studio", label: "New slide", selection: selection.snapshot() });
|
|
1006
|
+
const index = change?.index ?? at;
|
|
1007
|
+
ctx.go(index);
|
|
1008
|
+
adapter.setSlide(index);
|
|
1009
|
+
setSelection([adapter.slides()[index].key], { source: "slide" });
|
|
1010
|
+
announce(`Added slide ${index + 1}${layoutId ? ` with ${layoutId}` : ""}`);
|
|
1011
|
+
return true;
|
|
1012
|
+
} catch (error) { announce(error.message); return false; }
|
|
1013
|
+
};
|
|
1014
|
+
s.applyLayout = (layoutId) => {
|
|
1015
|
+
try {
|
|
1016
|
+
switchDimension(ui, "layouts", layoutId, { slideIndex: ctx.slideIndex(), catalogs: ctx.catalogs() });
|
|
1017
|
+
announce(`Slide ${ctx.slideIndex() + 1} uses ${layoutId}`);
|
|
1018
|
+
return true;
|
|
1019
|
+
} catch (error) { announce(error.message); return false; }
|
|
1020
|
+
};
|
|
1021
|
+
s.duplicateSlide = () => applyCommand("duplicate", [[scene().slideKey]]);
|
|
1022
|
+
s.deleteSlide = () => applyCommand("delete", [[scene().slideKey]], (result) => `${result.label}. Undo with ${formatKey("Mod-z", win)}.`);
|
|
1023
|
+
s.hideSlide = () => {
|
|
1024
|
+
const index = ctx.slideIndex();
|
|
1025
|
+
const hidden = ui.get(`/slides/${index}/hidden`) === true;
|
|
1026
|
+
try { setHidden(ui, [index], !hidden, { source: "studio" }); announce(hidden ? `Slide ${index + 1} is shown` : `Slide ${index + 1} is hidden`); return true; } catch (error) { announce(error.message); return false; }
|
|
1027
|
+
};
|
|
1028
|
+
s.goSlide = (delta) => { const index = Math.max(0, Math.min(s.slideCount() - 1, ctx.slideIndex() + delta)); ctx.go(index); };
|
|
1029
|
+
s.present = (fromStart) => ctx.present(fromStart ? 0 : ctx.slideIndex());
|
|
1030
|
+
|
|
1031
|
+
// ---- deck: find, export, file ---------------------------------------------------------------------------------------------------------
|
|
1032
|
+
s.find = (replace) => ctx.openFind(replace ? { replace: true } : {});
|
|
1033
|
+
s.openExport = () => { ctx.exportMenu?.open("first"); return true; };
|
|
1034
|
+
s.openShortcuts = () => openShortcutSheet();
|
|
1035
|
+
s.download = async (format) => {
|
|
1036
|
+
try {
|
|
1037
|
+
const deck = ui.presentation;
|
|
1038
|
+
let text, mime, extension;
|
|
1039
|
+
if (format === "json") { text = JSON.stringify(prepareSave(deck, { catalogs: ctx.catalogs() }).document, null, 2); mime = "application/json"; extension = "opf.json"; }
|
|
1040
|
+
else if (format === "yaml") { const { toYaml } = await import("@openpresentation/opf/yaml"); text = toYaml(deck).yaml; mime = "application/yaml"; extension = "opf.yaml"; }
|
|
1041
|
+
else { const { toMarkdown } = await import("@openpresentation/opf/markdown"); text = toMarkdown(deck).markdown; mime = "text/markdown"; extension = "opf.md"; }
|
|
1042
|
+
const base = String(ctx.getName() || deck.name || "presentation").replace(/[\\/:*?"<>|]+/g, "-").trim() || "presentation";
|
|
1043
|
+
const fileOut = { filename: `${base}.${extension}`, text, mime, format };
|
|
1044
|
+
if (options.export?.onDownload) await options.export.onDownload({ blob: new Blob([text], { type: mime }), fileName: fileOut.filename, format });
|
|
1045
|
+
else downloadSourceFile(fileOut);
|
|
1046
|
+
ctx.persistence?.markSaved?.();
|
|
1047
|
+
announce(`Downloaded ${fileOut.filename}`);
|
|
1048
|
+
return true;
|
|
1049
|
+
} catch (error) { announce(error.message); return false; }
|
|
1050
|
+
};
|
|
1051
|
+
s.copyDocument = () => {
|
|
1052
|
+
copyText(JSON.stringify(prepareSave(ui.presentation, { catalogs: ctx.catalogs() }).document, null, 2)).then(() => announce("Copied the presentation as OPF JSON"), (error) => announce(error.message));
|
|
1053
|
+
return true;
|
|
1054
|
+
};
|
|
1055
|
+
let fileInput = null;
|
|
1056
|
+
s.openFile = () => {
|
|
1057
|
+
fileInput ??= Object.assign(el("input", `${PREFIX}-fileinput`, { type: "file", accept: DECK_FILE_ACCEPT, "aria-hidden": "true", tabindex: "-1" }), {});
|
|
1058
|
+
if (!fileInput.isConnected) {
|
|
1059
|
+
root.append(fileInput);
|
|
1060
|
+
fileInput.addEventListener("change", async () => {
|
|
1061
|
+
const chosen = fileInput.files?.[0];
|
|
1062
|
+
fileInput.value = "";
|
|
1063
|
+
if (!chosen) return;
|
|
1064
|
+
try {
|
|
1065
|
+
const read = await readDeckFile(chosen, { catalogs: ctx.catalogs() });
|
|
1066
|
+
const text = await chosen.text();
|
|
1067
|
+
ctx.setDocument(read.document, { text, format: read.format, name: read.name });
|
|
1068
|
+
announce(`Opened ${chosen.name}`);
|
|
1069
|
+
} catch (error) { announce(error.message); }
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
fileInput.click();
|
|
1073
|
+
return true;
|
|
1074
|
+
};
|
|
1075
|
+
s.newDocument = () => { ctx.setDocument({ name: "Untitled presentation", slides: [{ id: "slide-1", title: "Untitled slide" }] }, { name: "Untitled presentation" }); announce("New presentation"); return true; };
|
|
1076
|
+
|
|
1077
|
+
// ---- Layout Studio handoff ("Edit layout") ---------------------------------------------------------------------------------------------
|
|
1078
|
+
s.editLayout = () => openLayout();
|
|
1079
|
+
function openLayout(layoutId = currentLayout(), { slide = ctx.slideIndex() } = {}) {
|
|
1080
|
+
if (!layoutId || !ctx.slots.layouts) return false;
|
|
1081
|
+
if (s.editingText() && !canvas.commit()) return false;
|
|
1082
|
+
const deck = ui.presentation;
|
|
1083
|
+
const custom = deck.catalogs?.custom?.layouts ?? {};
|
|
1084
|
+
let record, recordId = layoutId, forked = false;
|
|
1085
|
+
if (custom[layoutId]) record = { id: layoutId, ...structuredClone(custom[layoutId]) };
|
|
1086
|
+
else {
|
|
1087
|
+
const choice = listSwitchOptions(deck, "layouts", { catalogs: ctx.catalogs() }).find((entry) => entry.id === layoutId);
|
|
1088
|
+
if (!choice?.record) { announce(`Layout ${layoutId} was not found.`); return false; }
|
|
1089
|
+
let id = `${layoutId.replace(/^.*:/, "")}-custom`;
|
|
1090
|
+
for (let n = 2; custom[id]; n += 1) id = `${layoutId.replace(/^.*:/, "")}-custom-${n}`;
|
|
1091
|
+
recordId = id;
|
|
1092
|
+
forked = true;
|
|
1093
|
+
record = { ...structuredClone(choice.record), id, name: `${choice.record.name ?? choice.label ?? layoutId} (custom)` };
|
|
1094
|
+
}
|
|
1095
|
+
if (slide !== ctx.slideIndex()) ctx.go(slide);
|
|
1096
|
+
ctx.setView("layouts");
|
|
1097
|
+
const studioPart = ctx.layoutStudio();
|
|
1098
|
+
if (!studioPart) return false;
|
|
1099
|
+
studioPart.editor.setRecord(record);
|
|
1100
|
+
const start = JSON.stringify(studioPart.editor.getRecord());
|
|
1101
|
+
layoutHandoff = { slide, recordId, forked, start, selection: selection.snapshot(), part: studioPart };
|
|
1102
|
+
root.dataset.view = "layouts";
|
|
1103
|
+
shell.layoutsArea.hidden = false;
|
|
1104
|
+
buildCrumbs();
|
|
1105
|
+
ctx.emit("layoutStudio", { open: true, recordId });
|
|
1106
|
+
announce(forked ? `Editing a copy of ${layoutId} as ${recordId}` : `Editing ${recordId}`);
|
|
1107
|
+
return true;
|
|
1108
|
+
}
|
|
1109
|
+
s.openLayout = openLayout;
|
|
1110
|
+
function buildCrumbs() {
|
|
1111
|
+
const crumbs = shell.crumbs;
|
|
1112
|
+
const back = dom.button(`${PREFIX}-action`, { text: "Back", iconName: "ChevronRight", size: 14, attributes: { "data-handoff": "back" } });
|
|
1113
|
+
back.firstChild.style.transform = "rotate(180deg)";
|
|
1114
|
+
const use = dom.button(`${PREFIX}-action ${PREFIX}-primary`, { text: "Use on this slide", attributes: { "data-handoff": "apply" } });
|
|
1115
|
+
const save = dom.button(`${PREFIX}-action`, { text: "Save to deck", attributes: { "data-handoff": "save" } });
|
|
1116
|
+
use.disabled = save.disabled = s.readOnly;
|
|
1117
|
+
const trail = el("ol", `${PREFIX}-trail`);
|
|
1118
|
+
trail.append(el("li", "", { text: `Slide ${layoutHandoff.slide + 1}` }), el("li", "", { text: `Layout ${layoutHandoff.recordId}`, "aria-current": "page" }));
|
|
1119
|
+
crumbs.replaceChildren(back, trail, el("span", `${PREFIX}-spacer`), use, save);
|
|
1120
|
+
back.addEventListener("click", () => closeLayout({ ask: true }));
|
|
1121
|
+
use.addEventListener("click", () => {
|
|
1122
|
+
try {
|
|
1123
|
+
layoutHandoff.part.editor.applyToSlide(ui, layoutHandoff.slide, { source: "studio" });
|
|
1124
|
+
announce(`Slide ${layoutHandoff.slide + 1} now uses ${layoutHandoff.part.editor.getRecord().id}`);
|
|
1125
|
+
closeLayout();
|
|
1126
|
+
} catch (error) { announce(error.issues?.[0]?.message ?? error.message); }
|
|
1127
|
+
});
|
|
1128
|
+
save.addEventListener("click", () => {
|
|
1129
|
+
try {
|
|
1130
|
+
const change = layoutHandoff.part.editor.saveToDeck(ui, { replace: !layoutHandoff.forked });
|
|
1131
|
+
announce(`Saved ${change.layoutId} in this presentation's layouts`);
|
|
1132
|
+
closeLayout();
|
|
1133
|
+
} catch (error) { announce(error.issues?.[0]?.message ?? error.message); }
|
|
1134
|
+
});
|
|
1135
|
+
back.focus();
|
|
1136
|
+
}
|
|
1137
|
+
function closeLayout({ ask = false } = {}) {
|
|
1138
|
+
if (!layoutHandoff) return true;
|
|
1139
|
+
const changed = JSON.stringify(layoutHandoff.part.editor.getRecord()) !== layoutHandoff.start;
|
|
1140
|
+
if (ask && changed && !win.confirm("Leave the layout without saving your changes?")) return false;
|
|
1141
|
+
const { slide, recordId, selection: kept } = layoutHandoff;
|
|
1142
|
+
layoutHandoff = null;
|
|
1143
|
+
shell.layoutsArea.hidden = true;
|
|
1144
|
+
root.dataset.view = "slide";
|
|
1145
|
+
ctx.setView("slide");
|
|
1146
|
+
if (slide !== ctx.slideIndex()) ctx.go(slide);
|
|
1147
|
+
adapter.setSlide(slide);
|
|
1148
|
+
setSelection(kept, { source: "api" });
|
|
1149
|
+
ctx.emit("layoutStudio", { open: false, recordId });
|
|
1150
|
+
canvasView.focusPrimary();
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
s.closeLayout = closeLayout;
|
|
1154
|
+
|
|
1155
|
+
// ---- presets -------------------------------------------------------------------------------------------------------------------------
|
|
1156
|
+
function applyPreset() {
|
|
1157
|
+
root.dataset.preset = layout;
|
|
1158
|
+
root.dataset.opfLayout = layout;
|
|
1159
|
+
canvas.setTextEntry(layout === "playground" ? "click" : "dblclick");
|
|
1160
|
+
canvas.setTargets(layout === "playground" ? "interactive" : "passive");
|
|
1161
|
+
canvasView.setEnabled(layout === "studio");
|
|
1162
|
+
if (layout === "playground") setPlaygroundView(playgroundView);
|
|
1163
|
+
else {
|
|
1164
|
+
if (studioSource === null) studioSource = config.panels?.source && config.panels.source !== "tab" ? config.panels.source.replace("split-", "") : "off";
|
|
1165
|
+
setSourceMode(studioSource);
|
|
1166
|
+
}
|
|
1167
|
+
for (const button of [shell.insert, shell.addSlide, ...Object.values(shell.toolButtons)]) button.hidden = layout !== "studio";
|
|
1168
|
+
shell.views.hidden = layout !== "playground";
|
|
1169
|
+
shell.openStudio.hidden = layout !== "playground";
|
|
1170
|
+
shell.zoom.hidden = layout !== "studio";
|
|
1171
|
+
}
|
|
1172
|
+
s.setLayout = (next) => {
|
|
1173
|
+
if (next !== "studio" && next !== "playground") throw new RangeError('setLayout takes "studio" or "playground".');
|
|
1174
|
+
if (next === layout) return false;
|
|
1175
|
+
if (s.editingText() && !canvas.commit()) return false;
|
|
1176
|
+
if (layoutHandoff) closeLayout();
|
|
1177
|
+
const previous = layout;
|
|
1178
|
+
layout = next;
|
|
1179
|
+
applyPreset();
|
|
1180
|
+
ctx.mountSlideRow?.();
|
|
1181
|
+
paint();
|
|
1182
|
+
layers.render();
|
|
1183
|
+
design.refresh();
|
|
1184
|
+
ctx.emit("layout", { layout: next, previous });
|
|
1185
|
+
announce(next === "studio" ? "Studio" : "Playground");
|
|
1186
|
+
return true;
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
// ---- menus: File, Insert, + Slide, zoom, context ---------------------------------------------------------------------------------------
|
|
1190
|
+
const menuFrom = (ids) => commands.menu(ids, s, { win }).map((item) => ({ ...item, onSelect: () => runCommand(item.id) }));
|
|
1191
|
+
function show(items, anchor, label, extra = {}) {
|
|
1192
|
+
menu?.close({ restore: false });
|
|
1193
|
+
const box = anchor.getBoundingClientRect?.() ?? { left: anchor.x, bottom: anchor.y };
|
|
1194
|
+
menu = openMenu(dom, root, { x: box.left, y: box.bottom + 4, items, label, returnFocus: extra.returnFocus ?? anchor, onClose: () => { menu = null; anchor.setAttribute?.("aria-expanded", "false"); }, className: extra.className });
|
|
1195
|
+
anchor.setAttribute?.("aria-expanded", "true");
|
|
1196
|
+
return menu;
|
|
1197
|
+
}
|
|
1198
|
+
function fileMenu() {
|
|
1199
|
+
const items = [
|
|
1200
|
+
...menuFrom(["studio.file.new", "studio.file.open"]),
|
|
1201
|
+
...(typeof options.import?.pptx === "function" ? [{ label: "Import PowerPoint…", onSelect: () => options.import.pptx(ctx.handle) }] : []),
|
|
1202
|
+
{ separator: true },
|
|
1203
|
+
...menuFrom(["studio.file.download.json", "studio.file.download.yaml", "studio.file.download.markdown", "studio.file.copy"]),
|
|
1204
|
+
{ separator: true },
|
|
1205
|
+
...menuFrom(["studio.export", "studio.present", "studio.shortcuts"]),
|
|
1206
|
+
{ separator: true },
|
|
1207
|
+
...menuFrom([layout === "studio" ? "studio.layout.playground" : "studio.layout.studio"]),
|
|
1208
|
+
];
|
|
1209
|
+
for (const item of config.fileItems ?? []) items.push({ label: item.label, onSelect: () => item.run(ctx.handle) });
|
|
1210
|
+
show(items, shell.file, "File");
|
|
1211
|
+
}
|
|
1212
|
+
function insertItems({ drag = true, target } = {}) {
|
|
1213
|
+
return STUDIO_INSERT_KINDS.map((kind) => {
|
|
1214
|
+
// Into a given region (its placeholder's +): its own verdict and place.
|
|
1215
|
+
const regionVerdict = target ? (s.readOnly ? "This editor is read-only." : s.insertVerdict(kind, target)) : null;
|
|
1216
|
+
const verdict = target ? { enabled: regionVerdict === true, reason: regionVerdict === true ? "" : regionVerdict } : commands.list(s, { win }).find((entry) => entry.id === `studio.insert.${kind}`);
|
|
1217
|
+
return {
|
|
1218
|
+
label: STUDIO_INSERT_LABELS[kind], description: STUDIO_INSERT_HINTS[kind], icon: INSERT_ICONS[kind],
|
|
1219
|
+
disabled: !verdict.enabled, title: verdict.reason || undefined,
|
|
1220
|
+
onSelect: () => (target ? s.insert(kind, { target, index: 0 }) : runCommand(`studio.insert.${kind}`)),
|
|
1221
|
+
pointerdown: drag ? (event, _item, menuHandle) => insertPress(event, kind, menuHandle) : undefined,
|
|
1222
|
+
};
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
// A press on an Insert item that moves past 4px becomes a drag onto the slide (the drop lines decide where the block lands).
|
|
1226
|
+
function insertPress(event, kind, menuHandle) {
|
|
1227
|
+
if (event.button !== 0 || s.readOnly) return;
|
|
1228
|
+
const start = { x: event.clientX, y: event.clientY, id: event.pointerId };
|
|
1229
|
+
const move = (next) => {
|
|
1230
|
+
if (next.pointerId !== start.id || Math.hypot(next.clientX - start.x, next.clientY - start.y) < 4) return;
|
|
1231
|
+
stop();
|
|
1232
|
+
menuHandle.close();
|
|
1233
|
+
canvasView.beginInsertDrag(kind, next);
|
|
1234
|
+
};
|
|
1235
|
+
const stop = () => { doc.removeEventListener("pointermove", move, true); doc.removeEventListener("pointerup", stop, true); };
|
|
1236
|
+
doc.addEventListener("pointermove", move, true);
|
|
1237
|
+
doc.addEventListener("pointerup", stop, true);
|
|
1238
|
+
}
|
|
1239
|
+
s.openInsertMenu = (anchor = shell.insert, { target } = {}) => show(insertItems({ target, drag: !target }), anchor, target ? `Insert into ${scene().node(target)?.label ?? "region"}` : "Insert", { className: `${PREFIX}-insertmenu` });
|
|
1240
|
+
function layoutChoices() {
|
|
1241
|
+
return listSwitchOptions(ui.presentation, "layouts", { catalogs: ctx.catalogs() });
|
|
1242
|
+
}
|
|
1243
|
+
s.openSlideMenu = (anchor = shell.addSlide, { apply = false } = {}) => {
|
|
1244
|
+
const items = [];
|
|
1245
|
+
if (!apply) items.push({ label: "New slide", description: currentLayout() ? `With ${currentLayout()}, after this one` : "After this one", icon: "Plus", onSelect: () => runCommand("studio.slide.new") });
|
|
1246
|
+
for (const group of groupLayoutChoices(layoutChoices())) {
|
|
1247
|
+
for (const choice of group.choices) {
|
|
1248
|
+
items.push({ label: choice.label, group: group.name, media: choice.record ? buildLayoutDiagram(doc, choice.record) : undefined, disabled: s.readOnly, title: s.readOnly ? "This editor is read-only." : undefined, onSelect: () => (apply ? s.applyLayout(choice.id) : s.newSlide(choice.id)) });
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
show(items, anchor, apply ? "Change layout" : "Add a slide", { className: `${PREFIX}-slidemenu` });
|
|
1252
|
+
};
|
|
1253
|
+
function zoomMenu() {
|
|
1254
|
+
const items = [
|
|
1255
|
+
...menuFrom(["studio.zoom.in", "studio.zoom.out", "studio.zoom.fit"]),
|
|
1256
|
+
{ label: "Fit width", onSelect: () => s.fit("width") },
|
|
1257
|
+
{ separator: true },
|
|
1258
|
+
...[0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4].map((value) => ({ label: `${value * 100}%`, role: "menuitemcheckbox", checked: Math.abs(canvasView.zoom - value) < 0.001, onSelect: () => s.setZoom(value) })),
|
|
1259
|
+
];
|
|
1260
|
+
show(items, shell.zoom, "Zoom");
|
|
1261
|
+
}
|
|
1262
|
+
s.openContextMenu = (point, { returnFocus } = {}) => {
|
|
1263
|
+
const node = s.primaryNode();
|
|
1264
|
+
const slide = !node || node.kind === "slide";
|
|
1265
|
+
const items = [];
|
|
1266
|
+
const groups = contextMenuGroups({ slide });
|
|
1267
|
+
groups.forEach((ids, at) => {
|
|
1268
|
+
if (at) items.push({ separator: true });
|
|
1269
|
+
items.push(...menuFrom(ids));
|
|
1270
|
+
if (at === 1 && !slide) {
|
|
1271
|
+
// Move into: the groups and regions of this slide.
|
|
1272
|
+
const s0 = scene();
|
|
1273
|
+
for (const target of s0.nodes.filter((entry) => (entry.kind === "group" || entry.kind === "region") && !selection.items.includes(entry.key) && entry.key !== node?.parent)) {
|
|
1274
|
+
const verdict = node ? adapter.trialMove(selection.items, target.key, s0.contentChildren(target.key).length) : { ok: false, reason: "" };
|
|
1275
|
+
items.push({ label: `Move into ${target.label}`, group: "Move into", disabled: !verdict.ok || s.readOnly, title: verdict.reason || undefined, onSelect: () => s.move(selection.items, target.key, s0.contentChildren(target.key).length) });
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (at === 1 && !slide) {
|
|
1279
|
+
for (const kind of STUDIO_INSERT_KINDS) {
|
|
1280
|
+
const id = `studio.convert.${kind}`;
|
|
1281
|
+
if (!commands.has(id)) continue;
|
|
1282
|
+
const verdict = commands.list(s, { win }).find((entry) => entry.id === id);
|
|
1283
|
+
if (verdict.enabled) items.push({ label: `Convert to ${STUDIO_INSERT_LABELS[kind].toLowerCase()}`, group: "Convert to", onSelect: () => runCommand(id) });
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
const focusNode = returnFocus ?? canvasView.boxOf(selection.primary) ?? shell.frame;
|
|
1288
|
+
menu?.close({ restore: false });
|
|
1289
|
+
menu = openMenu(dom, root, { x: point.x, y: point.y, items, label: slide ? "Slide actions" : "Block actions", returnFocus: focusNode, onClose: () => { menu = null; } });
|
|
1290
|
+
return menu;
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
// ---- the shortcut sheet --------------------------------------------------------------------------------------------------------------
|
|
1294
|
+
let sheetDialog = null;
|
|
1295
|
+
function openShortcutSheet() {
|
|
1296
|
+
sheetDialog?.close();
|
|
1297
|
+
const returnFocus = doc.activeElement;
|
|
1298
|
+
const dialog = el("div", `${PREFIX}-dialog ${PREFIX}-shortcuts`, { role: "dialog", "aria-modal": "true", "aria-labelledby": "opf-studio-shortcuts-title" });
|
|
1299
|
+
const search = el("input", `${PREFIX}-input`, { type: "search", placeholder: "Search commands", "aria-label": "Search commands" });
|
|
1300
|
+
const list = el("div", `${PREFIX}-shortcutlist`);
|
|
1301
|
+
const close = dom.button(`${PREFIX}-iconbutton`, { label: "Close", iconName: "X", size: 16 });
|
|
1302
|
+
const head = el("div", `${PREFIX}-dialoghead`);
|
|
1303
|
+
head.append(el("h2", `${PREFIX}-dialog-title`, { id: "opf-studio-shortcuts-title", text: "Keyboard shortcuts" }), close);
|
|
1304
|
+
dialog.append(head, search, list);
|
|
1305
|
+
const draw = () => {
|
|
1306
|
+
const query = search.value.trim().toLowerCase();
|
|
1307
|
+
const entries = commands.list(s, { win }).filter((entry) => (entry.keys.length || query) && (!query || `${entry.label} ${entry.group} ${entry.id}`.toLowerCase().includes(query)));
|
|
1308
|
+
const groups = new Map();
|
|
1309
|
+
for (const entry of entries) { if (!groups.has(entry.group)) groups.set(entry.group, []); groups.get(entry.group).push(entry); }
|
|
1310
|
+
list.replaceChildren(...[...groups].map(([group, rows]) => {
|
|
1311
|
+
const section = el("section", `${PREFIX}-shortcutgroup`);
|
|
1312
|
+
const table = el("dl", "");
|
|
1313
|
+
for (const row of rows) {
|
|
1314
|
+
const keys = el("dd", "");
|
|
1315
|
+
for (const key of row.keys.slice(0, 2)) keys.append(el("kbd", "", { text: formatKey(key, win) }));
|
|
1316
|
+
const term = el("dt", "");
|
|
1317
|
+
const run = dom.button(`${PREFIX}-linkbutton`, { text: row.label, attributes: { "aria-disabled": row.enabled ? undefined : "true", title: row.reason || undefined } });
|
|
1318
|
+
run.addEventListener("click", () => { if (!row.enabled) { announce(row.reason); return; } closeSheetDialog(); runCommand(row.id); });
|
|
1319
|
+
term.append(run);
|
|
1320
|
+
table.append(term, keys);
|
|
1321
|
+
}
|
|
1322
|
+
section.append(el("h3", `${PREFIX}-section-title`, { text: group }), table);
|
|
1323
|
+
return section;
|
|
1324
|
+
}));
|
|
1325
|
+
};
|
|
1326
|
+
const closeSheetDialog = () => { dialog.remove(); sheetDialog = null; returnFocus?.focus?.({ preventScroll: true }); };
|
|
1327
|
+
sheetDialog = { close: closeSheetDialog };
|
|
1328
|
+
search.addEventListener("input", draw);
|
|
1329
|
+
close.addEventListener("click", closeSheetDialog);
|
|
1330
|
+
dialog.addEventListener("keydown", (event) => {
|
|
1331
|
+
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeSheetDialog(); return; }
|
|
1332
|
+
if (event.key === "Tab") {
|
|
1333
|
+
const focusable = [...dialog.querySelectorAll("button, input")].filter((node) => !node.disabled);
|
|
1334
|
+
const first = focusable[0], last = focusable.at(-1);
|
|
1335
|
+
if (event.shiftKey && doc.activeElement === first) { event.preventDefault(); last.focus(); }
|
|
1336
|
+
else if (!event.shiftKey && doc.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
1337
|
+
}
|
|
1338
|
+
event.stopPropagation();
|
|
1339
|
+
});
|
|
1340
|
+
draw();
|
|
1341
|
+
root.append(dialog);
|
|
1342
|
+
search.focus();
|
|
1343
|
+
return true;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// ---- the keyboard -------------------------------------------------------------------------------------------------------------------------
|
|
1347
|
+
function focusArea(target) {
|
|
1348
|
+
if (!target || target === doc.body || target === root) return "body";
|
|
1349
|
+
if (target.closest?.(".cm-editor") || shell.sourceArea.contains(target)) return "source";
|
|
1350
|
+
if (target.closest?.(`[role=dialog], dialog, .${PREFIX}-dialog`)) return "dialog";
|
|
1351
|
+
if (target.closest?.("[role=menu]")) return "menu";
|
|
1352
|
+
if (ctx.slots.slides?.contains(target)) return "filmstrip";
|
|
1353
|
+
if (shell.layersHost.contains(target)) return "layers";
|
|
1354
|
+
if (shell.viewport.contains(target)) return "canvas";
|
|
1355
|
+
if (shell.layoutsArea.contains(target)) return "layouts";
|
|
1356
|
+
return "chrome";
|
|
1357
|
+
}
|
|
1358
|
+
function onKey(event) {
|
|
1359
|
+
if (destroyed || isHandled(event) || event.defaultPrevented || event.isComposing) return;
|
|
1360
|
+
const area = focusArea(event.target);
|
|
1361
|
+
// The source split toggles from anywhere, the source pane included.
|
|
1362
|
+
if (matchKey(event, "Ctrl-`") && commands.enabled("studio.source.toggle", s)) { event.preventDefault(); markHandled(event); runCommand("studio.source.toggle"); return; }
|
|
1363
|
+
if (["source", "dialog", "menu", "layouts"].includes(area) || isTypingTarget(event.target)) return;
|
|
1364
|
+
if (layout !== "studio") {
|
|
1365
|
+
// The Playground keeps the global commands (zoom aside), so undo, Present and the sheet work as they do in the Studio.
|
|
1366
|
+
if (commands.handle(event, s, { scope: "global" })) return;
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
const onSlide = area === "canvas" || area === "layers" || area === "body" || area === "filmstrip";
|
|
1370
|
+
if (event.key === "Escape" && onSlide && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
1371
|
+
event.preventDefault();
|
|
1372
|
+
markHandled(event);
|
|
1373
|
+
if (menu) { menu.close(); return; }
|
|
1374
|
+
if (canvasView.dragging) { canvasView.cancelDrag(); return; }
|
|
1375
|
+
if (canvasView.tool !== "select") { s.setTool("select"); return; }
|
|
1376
|
+
if (!shell.dataDock.hidden && shell.dataDock.contains(event.target)) { shell.dataDock.hidden = true; return; }
|
|
1377
|
+
s.selectParent();
|
|
1378
|
+
if (area === "canvas" || area === "body") canvasView.focusPrimary();
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
if (area === "canvas" || area === "body") {
|
|
1382
|
+
if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey && s.textTarget() && !s.readOnly) { event.preventDefault(); markHandled(event); s.editText(); return; }
|
|
1383
|
+
if ((event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey))) {
|
|
1384
|
+
event.preventDefault();
|
|
1385
|
+
markHandled(event);
|
|
1386
|
+
const box = (canvasView.boxOf(selection.primary) ?? shell.frame).getBoundingClientRect();
|
|
1387
|
+
s.openContextMenu({ x: box.left + Math.min(40, box.width / 2), y: box.top + Math.min(40, box.height / 2) });
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
if (onSlide && commands.handle(event, s, { scope: "canvas" })) return;
|
|
1392
|
+
if (commands.handle(event, s, { scope: "global" })) return;
|
|
1393
|
+
// A key of a command that is off says why (read-only, fonts loading, nothing to paste), without taking the key.
|
|
1394
|
+
if (isTypingTarget(event.target)) return;
|
|
1395
|
+
const scopes = onSlide ? ["canvas", "global"] : ["global"];
|
|
1396
|
+
for (const entry of commands.list(s, { win })) {
|
|
1397
|
+
if (entry.enabled || !entry.reason || entry.group === "Selection") continue;
|
|
1398
|
+
const command = commands.get(entry.id);
|
|
1399
|
+
if (!scopes.some((scope) => [].concat(command.scope ?? "global").includes(scope))) continue;
|
|
1400
|
+
if (entry.keys.some((key) => matchKey(event, key))) { announce(entry.reason); break; }
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
listen(root, "keydown", onKey);
|
|
1404
|
+
// The filmstrip: Enter adds a slide after the focused one with its layout (PowerPoint); the slide manager keeps its other keys.
|
|
1405
|
+
if (ctx.slots.slides) {
|
|
1406
|
+
listen(ctx.slots.slides, "keydown", (event) => {
|
|
1407
|
+
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey || layout !== "studio") return;
|
|
1408
|
+
const card = event.target.closest?.(".slide-card");
|
|
1409
|
+
if (!card) return;
|
|
1410
|
+
event.preventDefault();
|
|
1411
|
+
event.stopPropagation();
|
|
1412
|
+
markHandled(event);
|
|
1413
|
+
const index = Number(card.dataset.index);
|
|
1414
|
+
const slide = ui.get(`/slides/${index}`);
|
|
1415
|
+
if (s.readOnly) { announce("This editor is read-only."); return; }
|
|
1416
|
+
s.newSlide(typeof slide?.layout === "string" ? slide.layout : undefined, index + 1);
|
|
1417
|
+
win.requestAnimationFrame?.(() => ctx.slots.slides.querySelector(`.slide-card[data-index="${index + 1}"]`)?.focus());
|
|
1418
|
+
}, true);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// ---- the bar --------------------------------------------------------------------------------------------------------------------------
|
|
1422
|
+
listen(shell.file, "click", () => fileMenu());
|
|
1423
|
+
listen(shell.insert, "click", () => s.openInsertMenu(shell.insert));
|
|
1424
|
+
listen(shell.addSlide, "click", () => s.openSlideMenu(shell.addSlide));
|
|
1425
|
+
listen(shell.zoom, "click", () => zoomMenu());
|
|
1426
|
+
listen(shell.source, "click", () => runCommand("studio.source.toggle"));
|
|
1427
|
+
listen(shell.openStudio, "click", () => s.setLayout("studio"));
|
|
1428
|
+
for (const [id, button] of Object.entries(shell.toolButtons)) listen(button, "click", () => runCommand(`studio.tool.${id}`));
|
|
1429
|
+
for (const [id, button] of Object.entries(shell.viewButtons)) listen(button, "click", () => setPlaygroundView(id));
|
|
1430
|
+
listen(shell.zoomOut, "click", () => runCommand("studio.zoom.out"));
|
|
1431
|
+
listen(shell.zoomIn, "click", () => runCommand("studio.zoom.in"));
|
|
1432
|
+
listen(shell.zoomFit, "click", () => runCommand("studio.zoom.fit"));
|
|
1433
|
+
listen(shell.validity, "click", () => {
|
|
1434
|
+
if (!ctx.inSync() && ctx.pane) { if (sourceMode === "off") s.toggleSource(); ctx.pane.editor?.api?.focus?.(); return; }
|
|
1435
|
+
if (shell.tabs.review) { if (root.dataset.width === "narrow") openSheet("review"); else showTab("review", { focus: true }); }
|
|
1436
|
+
});
|
|
1437
|
+
// The name: the presentation's `name`, one undoable change (the source pane keeps its own file name).
|
|
1438
|
+
const deckName = () => { const value = ui.get("/name"); return typeof value === "string" ? value : ""; };
|
|
1439
|
+
listen(shell.name, "change", () => {
|
|
1440
|
+
const value = shell.name.value.trim();
|
|
1441
|
+
if (value === deckName()) return;
|
|
1442
|
+
if (s.writeValue("/name", value || undefined, value ? `Renamed to ${value}` : "Name removed")) return;
|
|
1443
|
+
shell.name.value = deckName();
|
|
1444
|
+
});
|
|
1445
|
+
listen(shell.name, "keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); shell.name.blur(); } if (event.key === "Escape") { shell.name.value = deckName(); shell.name.blur(); } });
|
|
1446
|
+
function syncBar() {
|
|
1447
|
+
const list = commands.list(s, { win });
|
|
1448
|
+
const state = (id) => list.find((entry) => entry.id === id);
|
|
1449
|
+
const insertState = STUDIO_INSERT_KINDS.some((kind) => state(`studio.insert.${kind}`)?.enabled);
|
|
1450
|
+
shell.insert.disabled = !insertState;
|
|
1451
|
+
shell.insert.title = insertState ? "Insert a block" : state("studio.insert.text")?.reason ?? "";
|
|
1452
|
+
const slideState = state("studio.slide.new");
|
|
1453
|
+
shell.addSlide.disabled = !slideState?.enabled;
|
|
1454
|
+
shell.addSlide.title = slideState?.enabled ? "Add a slide" : slideState?.reason ?? "";
|
|
1455
|
+
if (doc.activeElement !== shell.name) shell.name.value = deckName();
|
|
1456
|
+
shell.name.readOnly = s.readOnly;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// ---- the status line ----------------------------------------------------------------------------------------------------------------
|
|
1460
|
+
function syncStatus() {
|
|
1461
|
+
const slides = adapter.slides();
|
|
1462
|
+
shell.counter.textContent = `Slide ${ctx.slideIndex() + 1} of ${slides.length}`;
|
|
1463
|
+
shell.format.textContent = (ctx.pane?.getFormat?.() ?? "json").toUpperCase().replace("MD", "Markdown");
|
|
1464
|
+
const findings = session.validation?.findings?.filter((finding) => finding.severity === "error" || finding.severity === "warning").length ?? 0;
|
|
1465
|
+
const text = !ctx.inSync() ? "Source has errors" : findings ? plural(findings, "finding") : "Valid";
|
|
1466
|
+
const label = shell.validity.querySelector(`.${PREFIX}-button-text`);
|
|
1467
|
+
if (label) label.textContent = text;
|
|
1468
|
+
shell.validity.dataset.state = !ctx.inSync() ? "error" : findings ? "findings" : "valid";
|
|
1469
|
+
shell.validity.setAttribute("aria-label", `${text}. ${!ctx.inSync() ? "Go to the source." : "Open Review."}`);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// ---- the touch action bar --------------------------------------------------------------------------------------------------------------
|
|
1473
|
+
const actionButtons = [["studio.duplicate", "Duplicate", "Copy"], ["studio.delete", "Delete", "Trash2"], ["studio.group", "Group", "Group"], ["studio.move.earlier", "Move up", "ArrowUp"], ["studio.move.later", "Move down", "ArrowDown"]].map(([id, label, icon]) => {
|
|
1474
|
+
const button = dom.button(`${PREFIX}-iconbutton`, { label, iconName: icon, size: 18, attributes: { "data-command": id } });
|
|
1475
|
+
button.addEventListener("click", () => runCommand(id));
|
|
1476
|
+
shell.actionbar.append(button);
|
|
1477
|
+
return { id, button };
|
|
1478
|
+
});
|
|
1479
|
+
function syncActionBar() {
|
|
1480
|
+
const coarse = root.dataset.density === "touch" || win.matchMedia?.("(pointer: coarse)").matches;
|
|
1481
|
+
const nodes = s.selectedNodes();
|
|
1482
|
+
const show = coarse && layout === "studio" && nodes.length && nodes.every((node) => node.kind === "block" || node.kind === "group" || node.kind === "implicit");
|
|
1483
|
+
shell.actionbar.hidden = !show;
|
|
1484
|
+
if (!show) return;
|
|
1485
|
+
for (const { id, button } of actionButtons) { const verdict = s.commandState(id); button.disabled = !verdict.enabled; button.title = verdict.reason || button.getAttribute("aria-label"); }
|
|
1486
|
+
// 8px above the primary box, inside the canvas area.
|
|
1487
|
+
const box = canvasView.boxOf(selection.primary)?.getBoundingClientRect();
|
|
1488
|
+
const area = shell.canvasArea.getBoundingClientRect();
|
|
1489
|
+
if (!box) return;
|
|
1490
|
+
const width = shell.actionbar.offsetWidth || 240, height = shell.actionbar.offsetHeight || 52;
|
|
1491
|
+
shell.actionbar.style.left = `${Math.max(8, Math.min(area.width - width - 8, box.left - area.left + box.width / 2 - width / 2))}px`;
|
|
1492
|
+
shell.actionbar.style.top = `${Math.max(8, box.top - area.top - height - 8)}px`;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// ---- lazy filmstrip thumbnails --------------------------------------------------------------------------------------------------------------
|
|
1496
|
+
const intersect = typeof win.IntersectionObserver === "function" && ctx.slots.slides ? new win.IntersectionObserver((entries) => {
|
|
1497
|
+
const wanted = entries.filter((entry) => entry.isIntersecting).map((entry) => Number(entry.target.closest(".slide-card")?.dataset.index)).filter(Number.isInteger);
|
|
1498
|
+
if (!wanted.length) return;
|
|
1499
|
+
fillThumbnails(wanted);
|
|
1500
|
+
}, { root: ctx.slots.slides.closest(`.${PREFIX}-slides`) ?? null, rootMargin: "200px 0px" }) : null;
|
|
1501
|
+
cleanups.push(() => intersect?.disconnect());
|
|
1502
|
+
function fillThumbnails(indices) {
|
|
1503
|
+
const deck = session.snapshot().presentation;
|
|
1504
|
+
try { ctx.thumbnailCache?.prepare(deck, indices); } catch { return; }
|
|
1505
|
+
for (const index of indices) {
|
|
1506
|
+
const card = ctx.slots.slides.querySelector(`.slide-card[data-index="${index}"] .thumbnail`);
|
|
1507
|
+
if (!card || card.dataset.filled === "true") continue;
|
|
1508
|
+
const html = ctx.thumbnailCache?.peek?.(deck, index);
|
|
1509
|
+
if (html === undefined) continue;
|
|
1510
|
+
card.innerHTML = html;
|
|
1511
|
+
card.querySelectorAll("[tabindex]").forEach((node) => node.removeAttribute("tabindex"));
|
|
1512
|
+
card.dataset.filled = "true";
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
function observeThumbnails() {
|
|
1516
|
+
if (!intersect) return;
|
|
1517
|
+
intersect.disconnect();
|
|
1518
|
+
for (const thumb of ctx.slots.slides.querySelectorAll(".slide-card .thumbnail")) {
|
|
1519
|
+
if (thumb.childElementCount || thumb.textContent) { thumb.dataset.filled = "true"; continue; }
|
|
1520
|
+
intersect.observe(thumb);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// ---- following the session, the canvas and the workspace ----------------------------------------------------------------------------------
|
|
1525
|
+
cleanups.push(ctx.on("render", (event) => {
|
|
1526
|
+
lastRender = { presentation: event.draft ? event.presentation : session.snapshot().presentation, slideIndex: event.slideIndex, geometry: event.geometry };
|
|
1527
|
+
if (event.slideIndex !== adapter.slideIndex) adapter.setSlide(event.slideIndex);
|
|
1528
|
+
syncAspect();
|
|
1529
|
+
reconcile();
|
|
1530
|
+
if (!event.draft) layers.render();
|
|
1531
|
+
paint();
|
|
1532
|
+
}));
|
|
1533
|
+
cleanups.push(ctx.on("sync", () => {
|
|
1534
|
+
if (adapter.slideIndex !== ctx.slideIndex()) adapter.setSlide(ctx.slideIndex());
|
|
1535
|
+
observeThumbnails();
|
|
1536
|
+
design.refresh();
|
|
1537
|
+
syncStatus();
|
|
1538
|
+
syncBar();
|
|
1539
|
+
}));
|
|
1540
|
+
cleanups.push(ctx.on("slide", ({ index }) => {
|
|
1541
|
+
adapter.setSlide(index);
|
|
1542
|
+
if (!revealing) setSelection([adapter.slides()[index]?.key].filter(Boolean), { source: "slide" });
|
|
1543
|
+
layers.render();
|
|
1544
|
+
}));
|
|
1545
|
+
for (const name of ["commit", "cancel"]) {
|
|
1546
|
+
cleanups.push(ctx.on(name, () => {
|
|
1547
|
+
root.dataset.editing = s.editingText() ? "true" : "false";
|
|
1548
|
+
if (!s.editingText() && (shell.frame.contains(doc.activeElement) || doc.activeElement === doc.body)) canvasView.focusPrimary();
|
|
1549
|
+
}));
|
|
1550
|
+
}
|
|
1551
|
+
cleanups.push(ctx.on("source", () => { syncStatus(); design.refresh(); }));
|
|
1552
|
+
cleanups.push(session.subscribe((event) => {
|
|
1553
|
+
if (event.type === "catalogs") return;
|
|
1554
|
+
// Keys that are gone fall back to their nearest surviving ancestor (an edit by the source pane, another tool or undo).
|
|
1555
|
+
win.queueMicrotask?.(() => { if (!destroyed) { reconcile(); syncStatus(); syncBar(); } });
|
|
1556
|
+
}));
|
|
1557
|
+
function reconcile() {
|
|
1558
|
+
const items = selection.items;
|
|
1559
|
+
if (!items.length) return;
|
|
1560
|
+
const s0 = scene();
|
|
1561
|
+
const kept = [];
|
|
1562
|
+
for (const key of items) {
|
|
1563
|
+
if (isSlideKey(key) ? adapter.slides().some((entry) => entry.key === key) : s0.node(key)) { kept.push(key); continue; }
|
|
1564
|
+
const survivor = isSlideKey(key) ? s0.slideKey : s0.survivor(key) ?? s0.slideKey;
|
|
1565
|
+
if (survivor && !kept.includes(survivor)) kept.push(survivor);
|
|
1566
|
+
}
|
|
1567
|
+
const parents = new Set(kept.map((key) => s0.node(key)?.parent ?? null));
|
|
1568
|
+
const next = parents.size > 1 ? [kept.at(-1)] : kept;
|
|
1569
|
+
if (next.length !== items.length || next.some((key, at) => key !== items[at])) { selection.replace(next); selectionChanged("edit", { quiet: false }); }
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// ---- start ------------------------------------------------------------------------------------------------------------------------
|
|
1573
|
+
applyPreset();
|
|
1574
|
+
syncWidth();
|
|
1575
|
+
s.onZoom(canvasView.zoom);
|
|
1576
|
+
setSelection([scene().slideKey], { source: "init" });
|
|
1577
|
+
layers.render();
|
|
1578
|
+
// The deck gets ids once when the Studio opens a document without them (one undoable change, "Add ids"), so keys survive reorders.
|
|
1579
|
+
if (layout === "studio" && config.assignIds !== false && !s.readOnly) {
|
|
1580
|
+
try { if (adapter.ensureIds()) setSelection([scene().slideKey], { source: "init" }); } catch { /* a deck that refuses ids keeps index keys */ }
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
Object.defineProperty(s, "zoom", { get: () => canvasView.zoom, enumerable: true });
|
|
1584
|
+
Object.assign(s, {
|
|
1585
|
+
commands: {
|
|
1586
|
+
list: () => commands.list(s, { win }),
|
|
1587
|
+
run: (id) => runCommand(id),
|
|
1588
|
+
register: (command) => commands.register(command),
|
|
1589
|
+
},
|
|
1590
|
+
focusCanvas: () => canvasView.focusPrimary(),
|
|
1591
|
+
refresh() { paint(); layers.render(); design.refresh(); },
|
|
1592
|
+
paint,
|
|
1593
|
+
adapter,
|
|
1594
|
+
destroy() {
|
|
1595
|
+
destroyed = true;
|
|
1596
|
+
menu?.close({ restore: false });
|
|
1597
|
+
sheetDialog?.close();
|
|
1598
|
+
for (const stop of cleanups.splice(0).reverse()) { try { stop(); } catch { /* gone */ } }
|
|
1599
|
+
dataGrid?.destroy();
|
|
1600
|
+
canvasView.destroy();
|
|
1601
|
+
layers.destroy();
|
|
1602
|
+
design.destroy();
|
|
1603
|
+
adapter.destroy();
|
|
1604
|
+
fileInput?.remove();
|
|
1605
|
+
},
|
|
1606
|
+
});
|
|
1607
|
+
return s;
|
|
1608
|
+
}
|