@openpresentation/opf-editor 0.10.5 → 0.11.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 +333 -8
- package/dist/annotations.d.ts +71 -0
- package/dist/annotations.js +281 -0
- package/dist/assets.d.ts +67 -0
- package/dist/assets.js +176 -0
- package/dist/background-options.d.ts +48 -0
- package/dist/background-options.js +134 -0
- package/dist/block-convert.d.ts +64 -0
- package/dist/block-convert.js +142 -0
- package/dist/canvas.d.ts +16 -0
- package/dist/canvas.js +82 -21
- package/dist/chart-data.d.ts +32 -0
- package/dist/chart-data.js +101 -0
- package/dist/chart-options-panel.d.ts +16 -0
- package/dist/chart-options-panel.js +127 -0
- package/dist/chart-options.d.ts +49 -0
- package/dist/chart-options.js +157 -0
- package/dist/content-actions.d.ts +91 -0
- package/dist/content-actions.js +207 -0
- package/dist/content-controls.js +326 -0
- package/dist/data-grid.d.ts +37 -0
- package/dist/data-grid.js +1035 -0
- package/dist/design-controls.d.ts +43 -0
- package/dist/design-controls.js +1077 -0
- package/dist/design-options.d.ts +108 -0
- package/dist/design-options.js +412 -0
- package/dist/edit-helpers.js +52 -0
- package/dist/export.d.ts +77 -0
- package/dist/export.js +216 -0
- package/dist/find-panel.d.ts +44 -0
- package/dist/find-panel.js +431 -0
- package/dist/find-replace.d.ts +100 -0
- package/dist/find-replace.js +374 -0
- package/dist/grid-model.d.ts +135 -0
- package/dist/grid-model.js +836 -0
- package/dist/grid-text.d.ts +33 -0
- package/dist/grid-text.js +251 -0
- package/dist/image-crop.d.ts +59 -0
- package/dist/image-crop.js +336 -0
- package/dist/image-cropper.d.ts +29 -0
- package/dist/image-cropper.js +519 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +104 -171
- package/dist/numbering-panel.d.ts +21 -0
- package/dist/numbering-panel.js +200 -0
- package/dist/numbering.d.ts +62 -0
- package/dist/numbering.js +223 -0
- package/dist/outline-view.d.ts +17 -0
- package/dist/outline-view.js +278 -0
- package/dist/outline.d.ts +56 -0
- package/dist/outline.js +271 -0
- package/dist/persistence-ui.d.ts +24 -0
- package/dist/persistence-ui.js +81 -0
- package/dist/persistence.d.ts +105 -0
- package/dist/persistence.js +429 -0
- package/dist/review-panel.d.ts +44 -0
- package/dist/review-panel.js +359 -0
- package/dist/review.d.ts +75 -0
- package/dist/review.js +170 -0
- package/dist/slide-manager.d.ts +44 -0
- package/dist/slide-manager.js +695 -0
- package/dist/slides.d.ts +96 -0
- package/dist/slides.js +433 -0
- package/dist/switches.d.ts +26 -0
- package/dist/switches.js +127 -43
- package/dist/table-options.d.ts +80 -0
- package/dist/table-options.js +419 -0
- package/dist/table-structure.d.ts +30 -0
- package/dist/table-structure.js +92 -0
- package/dist/template-panel.d.ts +31 -0
- package/dist/template-panel.js +377 -0
- package/dist/templates.d.ts +126 -0
- package/dist/templates.js +331 -0
- package/dist/zip.d.ts +4 -0
- package/dist/zip.js +71 -0
- package/package.json +150 -10
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
// Slide manager (RR-21): the DOM list of slides used as the slide navigator and, with `variant: "sorter"`, as the slide sorter grid. It
|
|
2
|
+
// selects (click, Ctrl/Cmd+click, Shift+click, Shift+arrows), reorders by drag and drop and by keyboard (Alt+arrows), and offers every slide
|
|
3
|
+
// operation from a toolbar and a context menu: add (with a layout), duplicate, delete, hide, sections, split and merge. Each action is one
|
|
4
|
+
// undoable session change from `slides.js`; the manager owns no document state, only the selection, and it announces every result in a
|
|
5
|
+
// polite live region. Importing needs no DOM; mounting does.
|
|
6
|
+
import {
|
|
7
|
+
addSection, addSlide, duplicateSlides, hasSections, listSections, moveSection, moveSlides, moveSlidesBy, removeSection, removeSlides, renameSection, setHidden, setSection, slideTitle,
|
|
8
|
+
} from "./slides.js";
|
|
9
|
+
import { listSwitchOptions } from "./switches.js";
|
|
10
|
+
|
|
11
|
+
let instances = 0;
|
|
12
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
13
|
+
const MIME = "application/x-opf-slides";
|
|
14
|
+
const ICONS = {
|
|
15
|
+
duplicate: "M8 8h12v13H8zM15 8V3H3v13h5",
|
|
16
|
+
delete: "M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3",
|
|
17
|
+
hide: "M3 3l18 18M10.6 6.1A9.8 9.8 0 0 1 12 6c5 0 8.5 4.2 9.5 6-.4.8-1.3 2.1-2.7 3.4M6.6 7.7C4.4 9.1 3 11.2 2.5 12c1 1.8 4.5 6 9.5 6 1.3 0 2.5-.3 3.6-.8M9.9 10a3 3 0 0 0 4.1 4.1",
|
|
18
|
+
show: "M2.5 12c1-1.8 4.5-6 9.5-6s8.5 4.2 9.5 6c-1 1.8-4.5 6-9.5 6s-8.5-4.2-9.5-6zM12 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z",
|
|
19
|
+
more: "M5 12h.01M12 12h.01M19 12h.01",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function icon(name) {
|
|
23
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
24
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
25
|
+
svg.setAttribute("class", "icon");
|
|
26
|
+
svg.setAttribute("aria-hidden", "true");
|
|
27
|
+
svg.setAttribute("fill", "none");
|
|
28
|
+
svg.setAttribute("stroke", "currentColor");
|
|
29
|
+
svg.setAttribute("stroke-width", name === "more" ? "3" : "1.8");
|
|
30
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
31
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
32
|
+
const path = document.createElementNS(SVG_NS, "path");
|
|
33
|
+
path.setAttribute("d", ICONS[name]);
|
|
34
|
+
svg.append(path);
|
|
35
|
+
return svg;
|
|
36
|
+
}
|
|
37
|
+
function el(tag, props = {}, ...children) {
|
|
38
|
+
const node = document.createElement(tag);
|
|
39
|
+
for (const [key, value] of Object.entries(props)) {
|
|
40
|
+
if (value === undefined || value === null || value === false) continue;
|
|
41
|
+
if (key === "class") node.className = value;
|
|
42
|
+
else if (key === "text") node.textContent = value;
|
|
43
|
+
else if (key.startsWith("on")) node.addEventListener(key.slice(2), value);
|
|
44
|
+
else node.setAttribute(key, value === true ? "" : String(value));
|
|
45
|
+
}
|
|
46
|
+
node.append(...children.filter((child) => child !== null && child !== undefined && child !== false));
|
|
47
|
+
return node;
|
|
48
|
+
}
|
|
49
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
50
|
+
const range = (from, to) => Array.from({ length: Math.abs(to - from) + 1 }, (_, offset) => Math.min(from, to) + offset);
|
|
51
|
+
const isMac = () => /Mac|iPhone|iPad/.test(globalThis.navigator?.platform ?? "");
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mount the slide list into `container` (a `nav` or `div`; one `.slide-card` button per slide, with section headers between when the deck has
|
|
55
|
+
* sections). Options: `editor`, `getSlideIndex()`, `setSlideIndex(index)`, `renderThumbnail(document, index)` (an HTML string), `variant`
|
|
56
|
+
* (`"navigator"` or `"sorter"`), `toolbar` (an element to fill with the action buttons), `contentActions` (the RR-26 split and merge
|
|
57
|
+
* functions: `{ splitSlideByBlocks, mergeSlides }`), `layoutOptions(document)` (layout choices for "Add slide with layout"), `onStatus(message)`
|
|
58
|
+
* `autoRender` (false to skip the first draw) and `onError(error)`. Returns `{ render, getSelection, setSelection, openLayoutPicker, destroy }`.
|
|
59
|
+
*/
|
|
60
|
+
export function createSlideManager(container, options) {
|
|
61
|
+
const { editor } = options;
|
|
62
|
+
const variant = options.variant === "sorter" ? "sorter" : "navigator";
|
|
63
|
+
const id = `opf-slides-${++instances}`;
|
|
64
|
+
const doc = container.ownerDocument;
|
|
65
|
+
let selection = new Set();
|
|
66
|
+
let anchor = 0;
|
|
67
|
+
let focusIndex = 0;
|
|
68
|
+
let pendingFocus = false;
|
|
69
|
+
const collapsed = new Set(); // section start labels collapsed by the user, by `${occurrence}:${name}`
|
|
70
|
+
let drag;
|
|
71
|
+
let menu;
|
|
72
|
+
|
|
73
|
+
container.classList.add("opf-slides");
|
|
74
|
+
container.dataset.variant = variant;
|
|
75
|
+
const live = el("div", { class: "sr-only opf-slides-live", role: "status", "aria-live": "polite", "aria-atomic": "true", id: `${id}-live` });
|
|
76
|
+
container.after(live);
|
|
77
|
+
const hint = el("span", { id: `${id}-hint`, class: "sr-only", text: variant === "sorter"
|
|
78
|
+
? "Arrow keys move between slides. Shift plus arrow keys extend the selection. Alt plus arrow keys move the selected slides. Delete removes them. Press the context menu key for more actions."
|
|
79
|
+
: "Up and Down arrows move between slides. Shift plus arrow keys extend the selection. Alt plus Up or Down moves the selected slides. Delete removes them. Press the context menu key for more actions." });
|
|
80
|
+
container.after(hint);
|
|
81
|
+
|
|
82
|
+
const deck = () => editor.document;
|
|
83
|
+
const count = () => deck().slides.length;
|
|
84
|
+
const current = () => Math.max(0, Math.min(count() - 1, options.getSlideIndex()));
|
|
85
|
+
const announce = (message) => {
|
|
86
|
+
live.textContent = "";
|
|
87
|
+
// Re-set on the next tick so the same message announces twice in a row.
|
|
88
|
+
globalThis.setTimeout(() => { live.textContent = message; }, 20);
|
|
89
|
+
options.onStatus?.(message);
|
|
90
|
+
};
|
|
91
|
+
const report = (error) => {
|
|
92
|
+
const message = error?.issues?.[0]?.message ?? error?.message ?? String(error);
|
|
93
|
+
announce(message);
|
|
94
|
+
options.onError?.(error);
|
|
95
|
+
};
|
|
96
|
+
const selectedList = () => {
|
|
97
|
+
const all = [...selection].filter((index) => index >= 0 && index < count());
|
|
98
|
+
if (!all.includes(current())) all.push(current());
|
|
99
|
+
return [...new Set(all)].sort((a, b) => a - b);
|
|
100
|
+
};
|
|
101
|
+
const sectionLabel = (name) => (name === undefined ? "No section" : name);
|
|
102
|
+
|
|
103
|
+
// --- rendering ------------------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function sectionKey(sections, section) {
|
|
106
|
+
const occurrence = sections.slice(0, section.index).filter((other) => other.name === section.name).length;
|
|
107
|
+
return `${occurrence}:${section.name ?? ""}`;
|
|
108
|
+
}
|
|
109
|
+
function render() {
|
|
110
|
+
const document = deck();
|
|
111
|
+
const sections = hasSections(document) ? listSections(document) : [];
|
|
112
|
+
const slideCount = document.slides.length;
|
|
113
|
+
selection = new Set([...selection].filter((index) => index < slideCount));
|
|
114
|
+
selection.add(current());
|
|
115
|
+
if (selection.size === 1) anchor = current();
|
|
116
|
+
const hadFocus = pendingFocus || container.contains(doc.activeElement);
|
|
117
|
+
pendingFocus = false;
|
|
118
|
+
if (!container.contains(doc.activeElement) && !hadFocus) focusIndex = current();
|
|
119
|
+
focusIndex = Math.max(0, Math.min(slideCount - 1, focusIndex));
|
|
120
|
+
// The current slide's section is never collapsed away from it.
|
|
121
|
+
for (const section of sections) if (current() >= section.start && current() < section.start + section.count) collapsed.delete(sectionKey(sections, section));
|
|
122
|
+
const nodes = [];
|
|
123
|
+
const sectionAtStart = new Map(sections.map((section) => [section.start, section]));
|
|
124
|
+
const sectionOfIndex = (index) => sections.find((section) => index >= section.start && index < section.start + section.count);
|
|
125
|
+
document.slides.forEach((slide, index) => {
|
|
126
|
+
const section = sectionAtStart.get(index);
|
|
127
|
+
if (section) nodes.push(sectionHeader(sections, section));
|
|
128
|
+
const owner = sectionOfIndex(index);
|
|
129
|
+
if (owner && collapsed.has(sectionKey(sections, owner))) return;
|
|
130
|
+
nodes.push(card(document, slide, index, slideCount));
|
|
131
|
+
});
|
|
132
|
+
container.replaceChildren(...nodes);
|
|
133
|
+
container.setAttribute("aria-label", variant === "sorter" ? "Slide sorter" : "Slides");
|
|
134
|
+
if (hadFocus) container.querySelector(`.slide-card[data-index="${focusIndex}"]`)?.focus({ preventScroll: false });
|
|
135
|
+
renderToolbar();
|
|
136
|
+
}
|
|
137
|
+
function card(document, slide, index, slideCount) {
|
|
138
|
+
const selected = selection.has(index);
|
|
139
|
+
const title = slideTitle(slide) || "Untitled slide";
|
|
140
|
+
const hidden = slide.hidden === true;
|
|
141
|
+
const button = el("button", {
|
|
142
|
+
type: "button", class: "slide-card", draggable: "true", "data-index": index, "aria-current": String(index === current()),
|
|
143
|
+
"aria-label": `Slide ${index + 1} of ${slideCount}: ${title}${hidden ? ", hidden" : ""}${selected && selection.size > 1 ? ", selected" : ""}`,
|
|
144
|
+
"aria-describedby": `${id}-hint`, "aria-keyshortcuts": "Alt+ArrowUp Alt+ArrowDown Delete", tabindex: index === focusIndex ? "0" : "-1",
|
|
145
|
+
});
|
|
146
|
+
button.dataset.selected = String(selected);
|
|
147
|
+
button.dataset.hidden = String(hidden);
|
|
148
|
+
// Some browsers (Firefox) do not start a drag from a <button> itself, only from what is inside it: the contents are draggable too, and
|
|
149
|
+
// their dragstart and dragend bubble to the card.
|
|
150
|
+
const number = el("span", { class: "thumbnail-number", draggable: "true", text: String(index + 1).padStart(2, "0") });
|
|
151
|
+
const thumbnail = el("span", { class: "thumbnail", "aria-hidden": "true" });
|
|
152
|
+
let html = "";
|
|
153
|
+
try { html = options.renderThumbnail?.(document, index) ?? ""; } catch { html = "Preview unavailable"; }
|
|
154
|
+
thumbnail.innerHTML = html;
|
|
155
|
+
thumbnail.querySelectorAll("[tabindex]").forEach((node) => node.removeAttribute("tabindex"));
|
|
156
|
+
const label = el("span", { class: "thumbnail-title", text: slideTitle(slide) || "Untitled slide" });
|
|
157
|
+
const content = el("span", { class: "thumbnail-content", draggable: "true" }, thumbnail, label);
|
|
158
|
+
if (hidden) content.append(el("span", { class: "slide-badge", text: "Hidden" }));
|
|
159
|
+
button.append(number, content);
|
|
160
|
+
button.addEventListener("click", (event) => choose(index, event));
|
|
161
|
+
button.addEventListener("keydown", (event) => keydown(event, index));
|
|
162
|
+
button.addEventListener("focus", () => { focusIndex = index; });
|
|
163
|
+
button.addEventListener("contextmenu", (event) => { event.preventDefault(); if (!selection.has(index)) choose(index, {}); openMenu(event.clientX, event.clientY, button); });
|
|
164
|
+
button.addEventListener("dragstart", (event) => dragStart(event, index, button));
|
|
165
|
+
button.addEventListener("dragend", dragEnd);
|
|
166
|
+
return button;
|
|
167
|
+
}
|
|
168
|
+
function sectionHeader(sections, section) {
|
|
169
|
+
const key = sectionKey(sections, section);
|
|
170
|
+
const isCollapsed = collapsed.has(key);
|
|
171
|
+
const name = sectionLabel(section.name);
|
|
172
|
+
const header = el("div", { class: "slide-section", "data-section": section.index, "data-unnamed": String(section.unnamed) });
|
|
173
|
+
const toggle = el("button", {
|
|
174
|
+
type: "button", class: "section-toggle", "aria-expanded": String(!isCollapsed), "aria-label": `${name}, ${plural(section.count, "slide")}`,
|
|
175
|
+
onclick: () => { if (isCollapsed) collapsed.delete(key); else collapsed.add(key); render(); },
|
|
176
|
+
}, el("span", { class: "section-caret", "aria-hidden": "true", text: isCollapsed ? "▸" : "▾" }), el("span", { class: "section-name", text: name }), el("span", { class: "section-count", "aria-hidden": "true", text: String(section.count) }));
|
|
177
|
+
const more = el("button", { type: "button", class: "section-more", "aria-label": `Section actions: ${name}`, "aria-haspopup": "menu", title: "Section actions" });
|
|
178
|
+
more.append(icon("more"));
|
|
179
|
+
more.addEventListener("click", () => { const box = more.getBoundingClientRect(); openMenu(box.left, box.bottom, more, sectionMenu(section)); });
|
|
180
|
+
header.append(toggle, more);
|
|
181
|
+
header.addEventListener("dragover", (event) => { if (!hasSlides(event)) return; event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = "move"; mark(header, "drop-into"); });
|
|
182
|
+
header.addEventListener("dragleave", () => header.classList.remove("drop-into"));
|
|
183
|
+
header.addEventListener("drop", (event) => {
|
|
184
|
+
event.preventDefault();
|
|
185
|
+
event.stopPropagation();
|
|
186
|
+
clearMarks();
|
|
187
|
+
const target = isCollapsed ? section.start + section.count : section.start;
|
|
188
|
+
dropSlides(target, section.name ?? null);
|
|
189
|
+
});
|
|
190
|
+
return header;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- selection and navigation ---------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
function setCurrent(index) {
|
|
196
|
+
options.setSlideIndex(index);
|
|
197
|
+
}
|
|
198
|
+
function choose(index, event) {
|
|
199
|
+
const modifier = event.ctrlKey || event.metaKey;
|
|
200
|
+
if (event.shiftKey) selection = new Set(range(anchor, index));
|
|
201
|
+
else if (modifier) { if (selection.has(index) && selection.size > 1) selection.delete(index); else selection.add(index); anchor = index; }
|
|
202
|
+
else { selection = new Set([index]); anchor = index; }
|
|
203
|
+
focusIndex = index;
|
|
204
|
+
pendingFocus = true;
|
|
205
|
+
if (event.shiftKey || modifier) { options.setSlideIndex(index); }
|
|
206
|
+
else setCurrent(index);
|
|
207
|
+
// The host refreshes (and re-renders) on a slide change; render here too so a selection-only change is visible at once.
|
|
208
|
+
render();
|
|
209
|
+
}
|
|
210
|
+
/** How many slides sit in one row of the sorter grid (1 in the navigator). */
|
|
211
|
+
function columns() {
|
|
212
|
+
if (variant !== "sorter") return 1;
|
|
213
|
+
const tracks = doc.defaultView.getComputedStyle(container).gridTemplateColumns;
|
|
214
|
+
const count = typeof tracks === "string" && tracks !== "none" ? tracks.trim().split(/\s+/).length : 1;
|
|
215
|
+
return Math.max(1, count);
|
|
216
|
+
}
|
|
217
|
+
function keydown(event, index) {
|
|
218
|
+
const last = count() - 1;
|
|
219
|
+
const step = (delta) => Math.max(0, Math.min(last, index + delta));
|
|
220
|
+
const mod = event.ctrlKey || event.metaKey;
|
|
221
|
+
const verticalStep = variant === "sorter" ? columns() : 1;
|
|
222
|
+
const arrows = { ArrowUp: -verticalStep, ArrowDown: verticalStep, ArrowLeft: variant === "sorter" ? -1 : 0, ArrowRight: variant === "sorter" ? 1 : 0 };
|
|
223
|
+
if (event.altKey && !mod && event.key in arrows && arrows[event.key] !== 0) {
|
|
224
|
+
event.preventDefault();
|
|
225
|
+
moveBy(selectedList(), arrows[event.key]);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (event.key in arrows && arrows[event.key] !== 0 && !event.altKey && !mod) {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
const target = step(arrows[event.key]);
|
|
231
|
+
if (event.shiftKey) { selection = new Set(range(anchor, target)); } else { selection = new Set([target]); anchor = target; }
|
|
232
|
+
focusIndex = target;
|
|
233
|
+
pendingFocus = true;
|
|
234
|
+
options.setSlideIndex(target);
|
|
235
|
+
render();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if ((event.key === "Home" || event.key === "End") && !event.altKey && !mod) {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
const target = event.key === "Home" ? 0 : last;
|
|
241
|
+
if (event.shiftKey) selection = new Set(range(anchor, target)); else { selection = new Set([target]); anchor = target; }
|
|
242
|
+
focusIndex = target;
|
|
243
|
+
pendingFocus = true;
|
|
244
|
+
options.setSlideIndex(target);
|
|
245
|
+
render();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (mod && event.key.toLowerCase() === "a") { event.preventDefault(); selection = new Set(range(0, last)); pendingFocus = true; render(); announce(`${plural(count(), "slide")} selected`); return; }
|
|
249
|
+
if (mod && event.key.toLowerCase() === "d" && !event.shiftKey && !event.altKey) { event.preventDefault(); act("duplicate"); return; }
|
|
250
|
+
if ((event.key === "Delete" || event.key === "Backspace") && !mod && !event.altKey) { event.preventDefault(); act("delete"); return; }
|
|
251
|
+
if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) {
|
|
252
|
+
event.preventDefault();
|
|
253
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
254
|
+
openMenu(box.left + 12, box.top + 12, event.currentTarget);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (event.key === "Escape" && selection.size > 1) { selection = new Set([current()]); anchor = current(); pendingFocus = true; render(); announce("Selection cleared"); }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// --- actions --------------------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
function show(change, message) {
|
|
263
|
+
if (!change?.changed) return false;
|
|
264
|
+
const target = change.selection?.length ? change.selection : [current()];
|
|
265
|
+
selection = new Set(target);
|
|
266
|
+
anchor = target[0];
|
|
267
|
+
focusIndex = target[0];
|
|
268
|
+
pendingFocus = true;
|
|
269
|
+
options.setSlideIndex(target[0]);
|
|
270
|
+
render();
|
|
271
|
+
announce(message);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
function moveBy(indices, delta) {
|
|
275
|
+
try {
|
|
276
|
+
const before = deck().slides.length;
|
|
277
|
+
const change = moveSlidesBy(editor, indices, delta);
|
|
278
|
+
if (!change.changed) { announce(delta < 0 ? (indices.includes(0) ? "Already the first slide" : "Cannot move up") : "Already the last slide"); return; }
|
|
279
|
+
const position = change.selection[0] + 1;
|
|
280
|
+
show(change, indices.length === 1 ? `Slide moved to position ${position} of ${before}${sectionNote(change.selection[0])}` : `${plural(indices.length, "slide")} moved, now at positions ${change.selection.map((index) => index + 1).join(", ")} of ${before}`);
|
|
281
|
+
} catch (error) { report(error); }
|
|
282
|
+
}
|
|
283
|
+
function sectionNote(index) {
|
|
284
|
+
const document = deck();
|
|
285
|
+
if (!hasSections(document)) return "";
|
|
286
|
+
const name = document.slides[index].section;
|
|
287
|
+
return name ? `, in section ${name}` : ", with no section";
|
|
288
|
+
}
|
|
289
|
+
function moveTo(indices, edge) {
|
|
290
|
+
try {
|
|
291
|
+
const to = edge === "start" ? 0 : count();
|
|
292
|
+
const change = moveSlides(editor, indices, to);
|
|
293
|
+
if (!change.changed) { announce(edge === "start" ? "Already at the start" : "Already at the end"); return; }
|
|
294
|
+
show(change, `${plural(indices.length, "slide")} moved to the ${edge}`);
|
|
295
|
+
} catch (error) { report(error); }
|
|
296
|
+
}
|
|
297
|
+
function act(name, extra) {
|
|
298
|
+
const list = selectedList();
|
|
299
|
+
try {
|
|
300
|
+
if (name === "duplicate") {
|
|
301
|
+
const change = duplicateSlides(editor, list);
|
|
302
|
+
show(change, `Duplicated ${plural(list.length, "slide")}. Undo with ${isMac() ? "Command" : "Control"} Z.`);
|
|
303
|
+
} else if (name === "delete") {
|
|
304
|
+
const total = count();
|
|
305
|
+
if (list.length >= total) { announce("A presentation needs at least one slide"); return; }
|
|
306
|
+
const change = removeSlides(editor, list);
|
|
307
|
+
show(change, `Deleted ${plural(list.length, "slide")}. ${total - list.length} left. Undo with ${isMac() ? "Command" : "Control"} Z.`);
|
|
308
|
+
} else if (name === "hide") {
|
|
309
|
+
const change = setHidden(editor, list);
|
|
310
|
+
show(change, change.hidden ? `${plural(list.length, "slide")} hidden from the slide show` : `${plural(list.length, "slide")} shown again`);
|
|
311
|
+
if (change.changed) { selection = new Set(list); render(); }
|
|
312
|
+
} else if (name === "split") {
|
|
313
|
+
const change = options.contentActions.splitSlideByBlocks(editor, current(), { each: true });
|
|
314
|
+
if (!change.changed) { announce(change.reason ?? "This slide has nothing to split"); return; }
|
|
315
|
+
const first = change.range?.start ?? current();
|
|
316
|
+
const total = change.slideCount ?? 2;
|
|
317
|
+
const slides = range(first, first + total - 1);
|
|
318
|
+
show({ changed: true, selection: slides }, `Slide split into ${total} slides`);
|
|
319
|
+
} else if (name === "merge") {
|
|
320
|
+
const sorted = list;
|
|
321
|
+
const contiguous = sorted.length > 1 && sorted.every((index, at) => at === 0 || index === sorted[at - 1] + 1);
|
|
322
|
+
const start = contiguous ? sorted[0] : current();
|
|
323
|
+
const total = contiguous ? sorted.length : 2;
|
|
324
|
+
if (start + total > count()) { announce("There is no slide after this one to merge with"); return; }
|
|
325
|
+
const change = options.contentActions.mergeSlides(editor, start, total);
|
|
326
|
+
if (!change.changed) { announce(change.reason ?? "Nothing to merge"); return; }
|
|
327
|
+
const loss = change.loss?.length ? ` Not carried over: ${change.loss.join(", ")}.` : "";
|
|
328
|
+
show({ changed: true, selection: [start] }, `Merged ${plural(total, "slide")} into one.${loss}`);
|
|
329
|
+
} else if (name === "move-start") moveTo(list, "start");
|
|
330
|
+
else if (name === "move-end") moveTo(list, "end");
|
|
331
|
+
else if (name === "move-up") moveBy(list, -1);
|
|
332
|
+
else if (name === "move-down") moveBy(list, 1);
|
|
333
|
+
else if (name === "select-all") { selection = new Set(range(0, count() - 1)); pendingFocus = true; render(); announce(`${plural(count(), "slide")} selected`); }
|
|
334
|
+
else if (name === "new-section") sectionPrompt(list[0]);
|
|
335
|
+
else if (name === "to-section") {
|
|
336
|
+
const change = setSection(editor, list, extra.name);
|
|
337
|
+
show(change, extra.name ? `${plural(list.length, "slide")} added to section ${extra.name}` : `${plural(list.length, "slide")} removed from their section`);
|
|
338
|
+
}
|
|
339
|
+
} catch (error) { report(error); }
|
|
340
|
+
}
|
|
341
|
+
async function sectionPrompt(index) {
|
|
342
|
+
const document = deck();
|
|
343
|
+
const hasAny = hasSections(document);
|
|
344
|
+
const name = await ask("Add section", "Section name", hasAny ? "" : "Section 1", `The section starts at slide ${index + 1}.`);
|
|
345
|
+
if (name === null) { focusCard(index); return; }
|
|
346
|
+
try {
|
|
347
|
+
const change = addSection(editor, index, name);
|
|
348
|
+
show(change, `Section ${name} started at slide ${index + 1}`);
|
|
349
|
+
} catch (error) { report(error); }
|
|
350
|
+
}
|
|
351
|
+
function focusCard(index) {
|
|
352
|
+
focusIndex = index;
|
|
353
|
+
container.querySelector(`.slide-card[data-index="${index}"]`)?.focus();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// --- section menu ---------------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
function sectionMenu(section) {
|
|
359
|
+
const sections = listSections(deck());
|
|
360
|
+
const items = [];
|
|
361
|
+
items.push({ label: "Rename section…", disabled: section.unnamed, run: async () => {
|
|
362
|
+
const name = await ask("Rename section", "Section name", section.name ?? "");
|
|
363
|
+
if (name === null) return;
|
|
364
|
+
try { show(renameSection(editor, section.index, name), `Section renamed to ${name}`); } catch (error) { report(error); }
|
|
365
|
+
} });
|
|
366
|
+
items.push({ label: "Add section at this slide…", run: () => sectionPrompt(section.start) });
|
|
367
|
+
items.push({ separator: true });
|
|
368
|
+
items.push({ label: "Move section up", disabled: section.index === 0, run: () => runSection(() => moveSection(editor, section.index, section.index - 1), "Section moved up") });
|
|
369
|
+
items.push({ label: "Move section down", disabled: section.index === sections.length - 1, run: () => runSection(() => moveSection(editor, section.index, section.index + 2), "Section moved down") });
|
|
370
|
+
items.push({ separator: true });
|
|
371
|
+
items.push({ label: "Remove section (keep slides)", disabled: section.unnamed, run: () => runSection(() => removeSection(editor, section.index), "Section removed; its slides joined the section before it") });
|
|
372
|
+
items.push({ label: "Remove section and delete its slides", disabled: section.unnamed || section.count >= count(), run: () => runSection(() => removeSection(editor, section.index, { deleteSlides: true }), "Section and its slides deleted") });
|
|
373
|
+
return items;
|
|
374
|
+
}
|
|
375
|
+
function runSection(run, message) {
|
|
376
|
+
try { show(run(), message); } catch (error) { report(error); }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// --- context menu ---------------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
function slideMenu() {
|
|
382
|
+
const list = selectedList();
|
|
383
|
+
const document = deck();
|
|
384
|
+
const sections = hasSections(document) ? listSections(document) : [];
|
|
385
|
+
const everyHidden = list.every((index) => document.slides[index].hidden === true);
|
|
386
|
+
const first = list[0];
|
|
387
|
+
const items = [
|
|
388
|
+
{ label: list.length > 1 ? `Duplicate ${list.length} slides` : "Duplicate slide", shortcut: `${isMac() ? "⌘" : "Ctrl"}+D`, run: () => act("duplicate") },
|
|
389
|
+
{ label: list.length > 1 ? `Delete ${list.length} slides` : "Delete slide", shortcut: "Delete", disabled: list.length >= count(), run: () => act("delete") },
|
|
390
|
+
{ label: everyHidden ? "Show slide in slide show" : "Hide slide from slide show", run: () => act("hide") },
|
|
391
|
+
{ separator: true },
|
|
392
|
+
{ label: "Move up", shortcut: "Alt+↑", disabled: list.includes(0) && list[0] === 0 && list.every((index, at) => index === at), run: () => act("move-up") },
|
|
393
|
+
{ label: "Move down", shortcut: "Alt+↓", disabled: list.every((index, at) => index === count() - list.length + at), run: () => act("move-down") },
|
|
394
|
+
{ label: "Move to start", run: () => act("move-start") },
|
|
395
|
+
{ label: "Move to end", run: () => act("move-end") },
|
|
396
|
+
{ separator: true },
|
|
397
|
+
{ label: "Add section here…", run: () => act("new-section") },
|
|
398
|
+
];
|
|
399
|
+
if (sections.length) {
|
|
400
|
+
const names = [...new Set(sections.map((section) => section.name).filter((name) => name !== undefined))];
|
|
401
|
+
for (const name of names) items.push({ label: `Move to section: ${name}`, run: () => act("to-section", { name }) });
|
|
402
|
+
items.push({ label: "Remove from section", run: () => act("to-section", { name: null }) });
|
|
403
|
+
}
|
|
404
|
+
if (options.contentActions) {
|
|
405
|
+
const sorted = list;
|
|
406
|
+
const contiguous = sorted.length > 1 && sorted.every((index, at) => at === 0 || index === sorted[at - 1] + 1);
|
|
407
|
+
items.push({ separator: true });
|
|
408
|
+
items.push({ label: "Split slide into one slide per block", disabled: list.length > 1, run: () => act("split") });
|
|
409
|
+
items.push({ label: contiguous ? `Merge ${sorted.length} slides into one` : "Merge with next slide", disabled: !contiguous && first >= count() - 1, run: () => act("merge") });
|
|
410
|
+
}
|
|
411
|
+
items.push({ separator: true });
|
|
412
|
+
items.push({ label: "Select all slides", shortcut: `${isMac() ? "⌘" : "Ctrl"}+A`, run: () => act("select-all") });
|
|
413
|
+
return items;
|
|
414
|
+
}
|
|
415
|
+
function closeMenu(restore = true) {
|
|
416
|
+
if (!menu) return;
|
|
417
|
+
const { root, opener, off } = menu;
|
|
418
|
+
off();
|
|
419
|
+
root.remove();
|
|
420
|
+
menu = undefined;
|
|
421
|
+
// A render while the menu was open replaced the card it came from: fall back to the card at the same place.
|
|
422
|
+
if (restore) (opener?.isConnected ? opener : container.querySelector(`.slide-card[data-index="${focusIndex}"]`))?.focus?.();
|
|
423
|
+
}
|
|
424
|
+
function openMenu(x, y, opener, items = slideMenu()) {
|
|
425
|
+
closeMenu(false);
|
|
426
|
+
const root = el("div", { class: "opf-menu", role: "menu", "aria-label": opener.getAttribute?.("aria-label") ?? "Slide actions", tabindex: "-1" });
|
|
427
|
+
const buttons = [];
|
|
428
|
+
for (const item of items) {
|
|
429
|
+
if (item.separator) { root.append(el("div", { class: "opf-menu-separator", role: "separator" })); continue; }
|
|
430
|
+
const button = el("button", { type: "button", role: "menuitem", class: "opf-menu-item", tabindex: "-1", disabled: item.disabled }, el("span", { text: item.label }), item.shortcut ? el("kbd", { text: item.shortcut }) : null);
|
|
431
|
+
button.addEventListener("click", () => { closeMenu(false); Promise.resolve(item.run()).catch(report); });
|
|
432
|
+
root.append(button);
|
|
433
|
+
if (!item.disabled) buttons.push(button);
|
|
434
|
+
}
|
|
435
|
+
doc.body.append(root);
|
|
436
|
+
const box = root.getBoundingClientRect();
|
|
437
|
+
const view = doc.defaultView;
|
|
438
|
+
root.style.left = `${Math.max(4, Math.min(x, view.innerWidth - box.width - 4))}px`;
|
|
439
|
+
root.style.top = `${Math.max(4, Math.min(y, view.innerHeight - box.height - 4))}px`;
|
|
440
|
+
const onKey = (event) => {
|
|
441
|
+
const at = buttons.indexOf(doc.activeElement);
|
|
442
|
+
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeMenu(); }
|
|
443
|
+
else if (event.key === "Tab") closeMenu(false);
|
|
444
|
+
else if (event.key === "ArrowDown") { event.preventDefault(); buttons[(at + 1) % buttons.length]?.focus(); }
|
|
445
|
+
else if (event.key === "ArrowUp") { event.preventDefault(); buttons[(at - 1 + buttons.length) % buttons.length]?.focus(); }
|
|
446
|
+
else if (event.key === "Home") { event.preventDefault(); buttons[0]?.focus(); }
|
|
447
|
+
else if (event.key === "End") { event.preventDefault(); buttons.at(-1)?.focus(); }
|
|
448
|
+
};
|
|
449
|
+
const onPointer = (event) => { if (!root.contains(event.target)) closeMenu(false); };
|
|
450
|
+
root.addEventListener("keydown", onKey);
|
|
451
|
+
doc.addEventListener("pointerdown", onPointer, true);
|
|
452
|
+
menu = { root, opener, off: () => doc.removeEventListener("pointerdown", onPointer, true) };
|
|
453
|
+
buttons[0]?.focus();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// --- dialogs --------------------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
function ask(title, label, value = "", help = "") {
|
|
459
|
+
return new Promise((resolve) => {
|
|
460
|
+
const dialog = el("dialog", { class: "opf-sm-dialog", "aria-labelledby": `${id}-ask-title` });
|
|
461
|
+
const input = el("input", { type: "text", id: `${id}-ask-input`, value, autocomplete: "off", spellcheck: "false", maxlength: "120" });
|
|
462
|
+
const form = el("form", { method: "dialog" },
|
|
463
|
+
el("h2", { id: `${id}-ask-title`, text: title }),
|
|
464
|
+
el("label", { for: input.id, text: label }), input,
|
|
465
|
+
help ? el("p", { class: "field-help", text: help }) : null,
|
|
466
|
+
el("p", { class: "opf-sm-error", role: "alert" }),
|
|
467
|
+
el("div", { class: "opf-sm-actions" }, el("button", { type: "button", class: "secondary", "data-cancel": "", text: "Cancel" }), el("button", { type: "submit", class: "primary", text: "OK" })));
|
|
468
|
+
dialog.append(form);
|
|
469
|
+
doc.body.append(dialog);
|
|
470
|
+
let result = null;
|
|
471
|
+
form.addEventListener("submit", (event) => {
|
|
472
|
+
event.preventDefault();
|
|
473
|
+
const text = input.value.trim();
|
|
474
|
+
if (!text) { form.querySelector(".opf-sm-error").textContent = "Enter a name."; input.focus(); return; }
|
|
475
|
+
result = text;
|
|
476
|
+
dialog.close();
|
|
477
|
+
});
|
|
478
|
+
form.querySelector("[data-cancel]").addEventListener("click", () => dialog.close());
|
|
479
|
+
dialog.addEventListener("close", () => { dialog.remove(); resolve(result); });
|
|
480
|
+
dialog.showModal();
|
|
481
|
+
input.select();
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
function layoutChoices() {
|
|
485
|
+
return options.layoutOptions ? options.layoutOptions(deck()) : listSwitchOptions(deck(), "layouts");
|
|
486
|
+
}
|
|
487
|
+
function openLayoutPicker() {
|
|
488
|
+
const choices = layoutChoices();
|
|
489
|
+
if (!choices.length) { announce("No layouts are available"); return; }
|
|
490
|
+
const opener = doc.activeElement;
|
|
491
|
+
const dialog = el("dialog", { class: "opf-sm-dialog opf-layout-dialog", "aria-labelledby": `${id}-layout-title` });
|
|
492
|
+
const filter = el("input", { type: "search", id: `${id}-layout-filter`, placeholder: "Filter layouts", autocomplete: "off" });
|
|
493
|
+
const select = el("select", { id: `${id}-layout-list`, size: "9", "aria-label": "Layouts" });
|
|
494
|
+
const detail = el("p", { class: "field-help", id: `${id}-layout-detail`, "aria-live": "polite" });
|
|
495
|
+
const where = el("select", { id: `${id}-layout-where` },
|
|
496
|
+
el("option", { value: "after", text: "After the current slide" }), el("option", { value: "end", text: "At the end" }));
|
|
497
|
+
const fill = () => {
|
|
498
|
+
const needle = filter.value.trim().toLowerCase();
|
|
499
|
+
const shown = choices.filter((choice) => !needle || `${choice.label} ${choice.id}`.toLowerCase().includes(needle));
|
|
500
|
+
select.replaceChildren(...shown.map((choice) => el("option", { value: choice.id, text: choice.label })));
|
|
501
|
+
if (shown.length) select.selectedIndex = 0;
|
|
502
|
+
describe();
|
|
503
|
+
};
|
|
504
|
+
const describe = () => {
|
|
505
|
+
const choice = choices.find((entry) => entry.id === select.value);
|
|
506
|
+
const slots = Array.isArray(choice?.record?.placeholders) ? choice.record.placeholders.map((slot) => slot.type).join(", ") : "";
|
|
507
|
+
detail.textContent = choice ? `${choice.record?.description ?? ""}${slots ? ` Slots: ${slots}.` : choice.record?.placeholders ? " An empty slide." : ""}`.trim() : "No layout matches.";
|
|
508
|
+
};
|
|
509
|
+
const form = el("form", { method: "dialog" },
|
|
510
|
+
el("h2", { id: `${id}-layout-title`, text: "Add slide with layout" }),
|
|
511
|
+
el("label", { for: filter.id, text: "Layout" }), filter,
|
|
512
|
+
select, detail,
|
|
513
|
+
el("label", { for: where.id, text: "Where" }), where,
|
|
514
|
+
el("p", { class: "opf-sm-error", role: "alert" }),
|
|
515
|
+
el("div", { class: "opf-sm-actions" }, el("button", { type: "button", class: "secondary", "data-cancel": "", text: "Cancel" }), el("button", { type: "submit", class: "primary", text: "Add slide" })));
|
|
516
|
+
dialog.append(form);
|
|
517
|
+
doc.body.append(dialog);
|
|
518
|
+
filter.addEventListener("input", fill);
|
|
519
|
+
select.addEventListener("change", describe);
|
|
520
|
+
select.addEventListener("dblclick", () => form.requestSubmit());
|
|
521
|
+
filter.addEventListener("keydown", (event) => { if (event.key === "ArrowDown") { event.preventDefault(); select.focus(); } });
|
|
522
|
+
form.addEventListener("submit", (event) => {
|
|
523
|
+
event.preventDefault();
|
|
524
|
+
if (!select.value) { form.querySelector(".opf-sm-error").textContent = "Choose a layout."; return; }
|
|
525
|
+
try {
|
|
526
|
+
const at = where.value === "end" ? count() : current() + 1;
|
|
527
|
+
const change = addSlide(editor, { layout: select.value, at });
|
|
528
|
+
dialog.close();
|
|
529
|
+
show(change, `Added a slide with the ${choices.find((entry) => entry.id === select.value)?.label ?? select.value} layout at position ${change.index + 1}`);
|
|
530
|
+
} catch (error) { form.querySelector(".opf-sm-error").textContent = error.message; }
|
|
531
|
+
});
|
|
532
|
+
form.querySelector("[data-cancel]").addEventListener("click", () => dialog.close());
|
|
533
|
+
dialog.addEventListener("close", () => { dialog.remove(); if (!container.contains(doc.activeElement)) opener?.focus?.(); });
|
|
534
|
+
fill();
|
|
535
|
+
dialog.showModal();
|
|
536
|
+
filter.focus();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// --- drag and drop --------------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
const hasSlides = (event) => [...(event.dataTransfer?.types ?? [])].includes(MIME);
|
|
542
|
+
function dragStart(event, index, node) {
|
|
543
|
+
// Dragging a slide that is not selected drags just that slide; dragging a selected one drags the whole selection. Nothing re-renders
|
|
544
|
+
// mid-drag (a rebuilt list would lose the drag), so the selection itself stays as it is until the drop.
|
|
545
|
+
const list = selection.has(index) ? selectedList() : [index];
|
|
546
|
+
drag = { indices: list };
|
|
547
|
+
event.dataTransfer.effectAllowed = "move";
|
|
548
|
+
event.dataTransfer.setData(MIME, JSON.stringify(list));
|
|
549
|
+
if (list.length > 1) {
|
|
550
|
+
const badge = el("span", { class: "opf-drag-badge", text: `${list.length} slides` });
|
|
551
|
+
doc.body.append(badge);
|
|
552
|
+
event.dataTransfer.setDragImage(badge, 12, 12);
|
|
553
|
+
globalThis.setTimeout(() => badge.remove(), 0);
|
|
554
|
+
}
|
|
555
|
+
container.classList.add("is-dragging");
|
|
556
|
+
node.classList.add("is-drag-source");
|
|
557
|
+
for (const other of container.querySelectorAll(".slide-card")) if (list.includes(Number(other.dataset.index))) other.classList.add("is-drag-source");
|
|
558
|
+
}
|
|
559
|
+
function dragEnd() {
|
|
560
|
+
drag = undefined;
|
|
561
|
+
container.classList.remove("is-dragging");
|
|
562
|
+
clearMarks();
|
|
563
|
+
for (const node of container.querySelectorAll(".is-drag-source")) node.classList.remove("is-drag-source");
|
|
564
|
+
}
|
|
565
|
+
function mark(node, className) {
|
|
566
|
+
clearMarks();
|
|
567
|
+
node.classList.add(className);
|
|
568
|
+
}
|
|
569
|
+
function clearMarks() {
|
|
570
|
+
for (const node of container.querySelectorAll(".drop-before, .drop-after, .drop-into, .drop-end")) node.classList.remove("drop-before", "drop-after", "drop-into", "drop-end");
|
|
571
|
+
}
|
|
572
|
+
/** The drop gap and section under the pointer. */
|
|
573
|
+
function dropTarget(event) {
|
|
574
|
+
const node = event.target.closest?.(".slide-card");
|
|
575
|
+
if (!node) return { gap: count(), node: null, after: true };
|
|
576
|
+
const index = Number(node.dataset.index);
|
|
577
|
+
const box = node.getBoundingClientRect();
|
|
578
|
+
const after = variant === "sorter" ? event.clientX > box.left + box.width / 2 : event.clientY > box.top + box.height / 2;
|
|
579
|
+
return { gap: index + (after ? 1 : 0), node, after, index };
|
|
580
|
+
}
|
|
581
|
+
function dropSlides(gap, section) {
|
|
582
|
+
const list = drag?.indices ?? selectedList();
|
|
583
|
+
drag = undefined;
|
|
584
|
+
try {
|
|
585
|
+
const change = moveSlides(editor, list, gap, hasSections(deck()) ? { section } : {});
|
|
586
|
+
if (!change.changed) { announce("The slide stays where it is"); return; }
|
|
587
|
+
show(change, list.length === 1 ? `Slide moved to position ${change.selection[0] + 1} of ${count()}${sectionNote(change.selection[0])}` : `${plural(list.length, "slide")} moved`);
|
|
588
|
+
} catch (error) { report(error); }
|
|
589
|
+
}
|
|
590
|
+
const onDragOver = (event) => {
|
|
591
|
+
if (!hasSlides(event)) return;
|
|
592
|
+
event.preventDefault();
|
|
593
|
+
event.dataTransfer.dropEffect = "move";
|
|
594
|
+
const target = dropTarget(event);
|
|
595
|
+
if (!target.node) { mark(container.lastElementChild ?? container, "drop-end"); return; }
|
|
596
|
+
mark(target.node, target.after ? "drop-after" : "drop-before");
|
|
597
|
+
};
|
|
598
|
+
const onDrop = (event) => {
|
|
599
|
+
if (!hasSlides(event)) return;
|
|
600
|
+
event.preventDefault();
|
|
601
|
+
const target = dropTarget(event);
|
|
602
|
+
clearMarks();
|
|
603
|
+
const sectionName = target.node ? (deck().slides[target.index].section ?? null) : (deck().slides.at(-1)?.section ?? null);
|
|
604
|
+
dropSlides(target.gap, sectionName);
|
|
605
|
+
};
|
|
606
|
+
const onDragLeave = (event) => { if (!container.contains(event.relatedTarget)) clearMarks(); };
|
|
607
|
+
container.addEventListener("dragover", onDragOver);
|
|
608
|
+
container.addEventListener("drop", onDrop);
|
|
609
|
+
container.addEventListener("dragleave", onDragLeave);
|
|
610
|
+
|
|
611
|
+
// --- toolbar --------------------------------------------------------------------------------------
|
|
612
|
+
|
|
613
|
+
let toolbarButtons;
|
|
614
|
+
function renderToolbar() {
|
|
615
|
+
const bar = options.toolbar;
|
|
616
|
+
if (!bar) return;
|
|
617
|
+
const list = selectedList();
|
|
618
|
+
const everyHidden = list.every((index) => deck().slides[index].hidden === true);
|
|
619
|
+
if (!toolbarButtons) {
|
|
620
|
+
bar.setAttribute("role", "toolbar");
|
|
621
|
+
bar.setAttribute("aria-label", "Slide actions");
|
|
622
|
+
const make = (name, label, glyph, run) => {
|
|
623
|
+
const button = el("button", { type: "button", class: "icon-button slide-tool", "data-action": name, "aria-label": label, title: label });
|
|
624
|
+
button.append(icon(glyph));
|
|
625
|
+
button.addEventListener("click", () => { run(); });
|
|
626
|
+
return button;
|
|
627
|
+
};
|
|
628
|
+
toolbarButtons = {
|
|
629
|
+
duplicate: make("duplicate", "Duplicate slide", "duplicate", () => act("duplicate")),
|
|
630
|
+
delete: make("delete", "Delete slide", "delete", () => act("delete")),
|
|
631
|
+
hide: make("hide", "Hide slide", "hide", () => act("hide")),
|
|
632
|
+
more: make("more", "More slide actions", "more", () => { const box = toolbarButtons.more.getBoundingClientRect(); openMenu(box.left, box.bottom, toolbarButtons.more); }),
|
|
633
|
+
};
|
|
634
|
+
toolbarButtons.more.setAttribute("aria-haspopup", "menu");
|
|
635
|
+
bar.replaceChildren(...Object.values(toolbarButtons));
|
|
636
|
+
bar.addEventListener("keydown", (event) => {
|
|
637
|
+
const buttons = Object.values(toolbarButtons).filter((button) => !button.disabled);
|
|
638
|
+
const at = buttons.indexOf(doc.activeElement);
|
|
639
|
+
if (event.key === "ArrowRight") { event.preventDefault(); buttons[(at + 1) % buttons.length]?.focus(); }
|
|
640
|
+
else if (event.key === "ArrowLeft") { event.preventDefault(); buttons[(at - 1 + buttons.length) % buttons.length]?.focus(); }
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
const many = list.length > 1;
|
|
644
|
+
toolbarButtons.duplicate.setAttribute("aria-label", many ? `Duplicate ${list.length} slides` : "Duplicate slide");
|
|
645
|
+
toolbarButtons.duplicate.title = toolbarButtons.duplicate.getAttribute("aria-label");
|
|
646
|
+
toolbarButtons.delete.setAttribute("aria-label", many ? `Delete ${list.length} slides` : "Delete slide");
|
|
647
|
+
toolbarButtons.delete.title = toolbarButtons.delete.getAttribute("aria-label");
|
|
648
|
+
toolbarButtons.delete.disabled = list.length >= count();
|
|
649
|
+
const hideLabel = everyHidden ? (many ? "Show slides" : "Show slide") : (many ? "Hide slides" : "Hide slide");
|
|
650
|
+
toolbarButtons.hide.setAttribute("aria-label", hideLabel);
|
|
651
|
+
toolbarButtons.hide.title = hideLabel;
|
|
652
|
+
toolbarButtons.hide.replaceChildren(icon(everyHidden ? "show" : "hide"));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// A change that did not come from this manager (Undo, a canvas edit, Apply in the source dialog, the outline) leaves a multi-selection
|
|
656
|
+
// meaningless, so the selection falls back to the current slide; the manager's own operations set it themselves afterwards.
|
|
657
|
+
const unsubscribe = editor.subscribe((event) => { if (event.meta?.source !== "slides") selection = new Set(); });
|
|
658
|
+
// A host that draws only after its fonts are loaded passes `autoRender: false` and calls `render()` itself.
|
|
659
|
+
if (options.autoRender !== false) render();
|
|
660
|
+
return {
|
|
661
|
+
render,
|
|
662
|
+
getSelection: selectedList,
|
|
663
|
+
setSelection(indices) {
|
|
664
|
+
selection = new Set(indices);
|
|
665
|
+
anchor = indices[0] ?? 0;
|
|
666
|
+
render();
|
|
667
|
+
},
|
|
668
|
+
/** Move focus to a slide's card. */
|
|
669
|
+
focus: focusCard,
|
|
670
|
+
openLayoutPicker,
|
|
671
|
+
/** Collapse or expand a section (an index from `listSections`). Collapsing is view state: nothing is written to the document, and the section that holds the current slide cannot stay collapsed. */
|
|
672
|
+
setSectionCollapsed(sectionIndex, isCollapsed = true) {
|
|
673
|
+
const sections = listSections(deck());
|
|
674
|
+
const section = sections[sectionIndex];
|
|
675
|
+
if (!section) return false;
|
|
676
|
+
const key = sectionKey(sections, section);
|
|
677
|
+
if (isCollapsed) collapsed.add(key); else collapsed.delete(key);
|
|
678
|
+
render();
|
|
679
|
+
return true;
|
|
680
|
+
},
|
|
681
|
+
/** Run a named action on the selection: duplicate, delete, hide, move-up, move-down, move-start, move-end, split, merge, select-all. */
|
|
682
|
+
run: act,
|
|
683
|
+
destroy() {
|
|
684
|
+
closeMenu(false);
|
|
685
|
+
unsubscribe();
|
|
686
|
+
container.removeEventListener("dragover", onDragOver);
|
|
687
|
+
container.removeEventListener("drop", onDrop);
|
|
688
|
+
container.removeEventListener("dragleave", onDragLeave);
|
|
689
|
+
live.remove();
|
|
690
|
+
hint.remove();
|
|
691
|
+
container.replaceChildren();
|
|
692
|
+
container.classList.remove("opf-slides");
|
|
693
|
+
},
|
|
694
|
+
};
|
|
695
|
+
}
|