@openpresentation/opf-editor 0.24.0 → 0.24.1
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 +152 -2
- package/dist/agent-handoff-dialog.d.ts +45 -0
- package/dist/agent-handoff-dialog.js +182 -0
- package/dist/agent-handoff.d.ts +33 -0
- package/dist/agent-handoff.js +95 -0
- package/dist/block-controls.js +8 -8
- package/dist/export-dom.js +57 -0
- package/dist/export-menu.d.ts +125 -0
- package/dist/export-menu.js +392 -0
- package/dist/json-editor.d.ts +7 -0
- package/dist/json-editor.js +67 -7
- package/dist/json-source.js +2 -1
- package/dist/slide-navigator.d.ts +44 -0
- package/dist/slide-navigator.js +191 -0
- package/dist/source-lines.js +206 -0
- package/dist/source-pane.d.ts +88 -0
- package/dist/source-pane.js +664 -0
- package/dist/styles.css +3339 -0
- package/dist/thumbnail-cache.d.ts +25 -0
- package/dist/thumbnail-cache.js +50 -0
- package/dist/thumbnail-strip.d.ts +50 -0
- package/dist/thumbnail-strip.js +310 -0
- package/package.json +44 -7
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// RR-98: the Export menu. A trigger button ("Download Export v") and a grouped `role="menu"`: Slides (PowerPoint, PDF, SVG slides, PNG images),
|
|
2
|
+
// OPF source (JSON, YAML, Markdown) and For an agent (the handoff dialog). `mode: "sheet"` draws the same menu as the mobile bottom sheet.
|
|
3
|
+
//
|
|
4
|
+
// Slides go through this package's headless `convert` (`export.js`) with the host's fonts, catalogs and render options; PowerPoint lives in
|
|
5
|
+
// @openpresentation/opf-pptx, which the editor does not import, so the host injects `exportPptx(deck, { signal, onProgress })`. The source
|
|
6
|
+
// formats are core's `toYaml` and `toMarkdown` (`@openpresentation/opf/yaml`, `/markdown`, loaded on first use); JSON is the document as
|
|
7
|
+
// "Save OPF" writes it (catalog records embedded). Progress, Cancel (an AbortController), a success or failure line and the converter's
|
|
8
|
+
// notes show in a small panel under the trigger and are announced through an `aria-live` region. Downloads go to a Blob link unless the
|
|
9
|
+
// host takes them with `onDownload`. Plain DOM; styles in src/styles/export-menu.css (classes `opf-export-*`).
|
|
10
|
+
import { createAgentHandoffDialog } from "./agent-handoff-dialog.js";
|
|
11
|
+
import { prepareSave } from "./catalogs.js";
|
|
12
|
+
import { el, icon, saveBlob } from "./export-dom.js";
|
|
13
|
+
import { convert, exportFileName, slidesToConvert } from "./export.js";
|
|
14
|
+
|
|
15
|
+
const PPTX_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
|
16
|
+
export const EXPORT_DISABLED_REASON = "Showing the last valid slides. Fix the JSON to resume editing and downloads.";
|
|
17
|
+
const BUSY_REASON = "Another export is running. Cancel it first.";
|
|
18
|
+
const AUTO_HIDE_MS = 4000;
|
|
19
|
+
const MAX_NOTES = 6;
|
|
20
|
+
|
|
21
|
+
const SLIDE_FORMATS = {
|
|
22
|
+
pptx: { label: "PowerPoint (.pptx)", icon: "Presentation", meta: () => "editable" },
|
|
23
|
+
pdf: { label: "PDF", icon: "FileText", meta: () => "all slides" },
|
|
24
|
+
svg: { label: "SVG slides", icon: "Image", meta: (count) => (count > 1 ? "ZIP" : ".svg") },
|
|
25
|
+
png: { label: "PNG images", icon: "Images", meta: (count) => (count > 1 ? "ZIP" : ".png") },
|
|
26
|
+
};
|
|
27
|
+
const SOURCE_FORMATS = {
|
|
28
|
+
json: { label: "JSON", icon: "FileJson", meta: ".opf.json", extension: "opf.json", type: "application/json" },
|
|
29
|
+
yaml: { label: "YAML", icon: "FileCode", meta: ".opf.yaml", extension: "opf.yaml", type: "application/yaml" },
|
|
30
|
+
markdown: { label: "Markdown", icon: "FileType", meta: ".opf.md", extension: "opf.md", type: "text/markdown" },
|
|
31
|
+
};
|
|
32
|
+
const GROUP_LABELS = { slides: "Slides", source: "OPF source", agent: "For an agent" };
|
|
33
|
+
let counter = 0;
|
|
34
|
+
|
|
35
|
+
const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
|
|
36
|
+
const sizeText = (bytes) => (bytes < 1048576 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1048576).toFixed(1)} MB`);
|
|
37
|
+
const isAbort = (error, signal) => Boolean(signal?.aborted) || error?.code === "export-aborted" || error?.name === "AbortError";
|
|
38
|
+
const abortError = () => Object.assign(new Error("The export was cancelled."), { code: "export-aborted", name: "OPFEditorError" });
|
|
39
|
+
// A converter note: core's finding shape (`slide` is zero-based there) or a plain string from the host's exporter.
|
|
40
|
+
const noteText = (note) => (typeof note === "string" ? note : `${Number.isInteger(note.slide) ? `Slide ${note.slide + 1}: ` : note.path ? `${note.path}: ` : ""}${note.message ?? note.ruleId ?? ""}`);
|
|
41
|
+
|
|
42
|
+
// The promise, or the abort, whichever comes first: a host exporter that ignores the signal still stops being waited for.
|
|
43
|
+
function abortable(promise, signal) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
if (signal.aborted) { reject(abortError()); return; }
|
|
46
|
+
const stop = () => reject(abortError());
|
|
47
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
48
|
+
promise.then(
|
|
49
|
+
(value) => { signal.removeEventListener("abort", stop); resolve(value); },
|
|
50
|
+
(error) => { signal.removeEventListener("abort", stop); reject(error); },
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeItems(items, hasPptx) {
|
|
56
|
+
const known = (list, table) => list.filter((name) => name in table);
|
|
57
|
+
const slides = items?.slides === false ? [] : known(items?.slides ?? Object.keys(SLIDE_FORMATS).filter((name) => name !== "pptx" || hasPptx), SLIDE_FORMATS);
|
|
58
|
+
const source = items?.source === false ? [] : known(items?.source ?? Object.keys(SOURCE_FORMATS), SOURCE_FORMATS);
|
|
59
|
+
return { slides, source, agent: items?.agent !== false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Mount the Export menu in `container`. See `export-menu.d.ts` for every option. Returns
|
|
64
|
+
* `{ element, trigger, refresh, open, close, setDisabled, setMode, cancel, destroy }`.
|
|
65
|
+
*/
|
|
66
|
+
export function createExportMenu(container, options = {}) {
|
|
67
|
+
const doc = container.ownerDocument ?? document;
|
|
68
|
+
const id = `opf-export-${++counter}`;
|
|
69
|
+
const getDeck = options.getDeck;
|
|
70
|
+
if (typeof getDeck !== "function") throw new TypeError("createExportMenu needs getDeck(): the document to export.");
|
|
71
|
+
let mode = options.mode === "sheet" ? "sheet" : "menu";
|
|
72
|
+
let density = options.density === "touch" ? "touch" : "comfortable";
|
|
73
|
+
let disabledReason = options.disabled === true ? EXPORT_DISABLED_REASON : typeof options.disabled === "string" && options.disabled ? options.disabled : undefined;
|
|
74
|
+
let items = normalizeItems(options.items, typeof options.exportPptx === "function");
|
|
75
|
+
let isOpen = false, busy, handoffDialog, destroyed = false;
|
|
76
|
+
let hideTimer, liveTimer;
|
|
77
|
+
|
|
78
|
+
const catalogsNow = () => (typeof options.catalogs === "function" ? options.catalogs() : options.catalogs);
|
|
79
|
+
const peekDeck = () => { try { return getDeck(); } catch { return undefined; } };
|
|
80
|
+
|
|
81
|
+
// ---- the DOM ----------------------------------------------------------------------------------------------------------------------------
|
|
82
|
+
const trigger = el(doc, "button", { type: "button", class: "opf-export-trigger", "aria-haspopup": "menu", "aria-expanded": "false", "aria-controls": `${id}-menu` },
|
|
83
|
+
icon(doc, "Download"), el(doc, "span", { class: "opf-export-trigger-label", text: options.label ?? "Export" }), icon(doc, "ChevronDown"));
|
|
84
|
+
const menu = el(doc, "div", { class: "opf-export-menu", role: "menu", id: `${id}-menu`, "aria-label": "Export formats" });
|
|
85
|
+
const popover = el(doc, "div", { class: "opf-export-popover", hidden: true }, menu);
|
|
86
|
+
const scrim = el(doc, "div", { class: "opf-export-scrim", hidden: true });
|
|
87
|
+
const closeButton = el(doc, "button", { type: "button", class: "opf-export-close", "aria-label": "Close" }, icon(doc, "X", 18));
|
|
88
|
+
const sheet = el(doc, "div", { class: "opf-export-sheet", role: "dialog", "aria-modal": "true", "aria-label": "Export", hidden: true },
|
|
89
|
+
el(doc, "div", { class: "opf-export-handle", "aria-hidden": "true" }),
|
|
90
|
+
el(doc, "div", { class: "opf-export-sheet-head" }, el(doc, "span", { class: "opf-export-sheet-title", text: "Export" }), closeButton));
|
|
91
|
+
const live = el(doc, "div", { class: "opf-export-live", role: "status", "aria-live": "polite", "aria-atomic": "true" });
|
|
92
|
+
const statusIcon = el(doc, "span", { class: "opf-export-status-icon" });
|
|
93
|
+
const statusText = el(doc, "p", { class: "opf-export-status-text" });
|
|
94
|
+
const statusAction = el(doc, "button", { type: "button", class: "opf-export-status-action" });
|
|
95
|
+
const statusBar = el(doc, "progress", { class: "opf-export-progress", max: "1", hidden: true, "aria-label": "Export progress" });
|
|
96
|
+
const statusNotes = el(doc, "ul", { class: "opf-export-notes", "aria-label": "Export notes", hidden: true });
|
|
97
|
+
const statusPanel = el(doc, "div", { class: "opf-export-status", role: "group", "aria-label": "Export status", hidden: true },
|
|
98
|
+
el(doc, "div", { class: "opf-export-status-row" }, statusIcon, statusText, statusAction), statusBar, statusNotes);
|
|
99
|
+
const root = el(doc, "div", { class: "opf-export opf-ui", "data-mode": mode, "data-density": density }, trigger, popover, scrim, sheet, statusPanel, live);
|
|
100
|
+
|
|
101
|
+
// ---- the menu ---------------------------------------------------------------------------------------------------------------------------
|
|
102
|
+
function buildMenu() {
|
|
103
|
+
const deck = peekDeck();
|
|
104
|
+
const count = deck ? slidesToConvertSafe(deck) : 0;
|
|
105
|
+
const groups = [];
|
|
106
|
+
const row = (kind, format, label, iconName, meta) => el(doc, "button", { type: "button", class: "opf-export-item", role: "menuitem", tabindex: "-1", "data-kind": kind, "data-format": format },
|
|
107
|
+
icon(doc, iconName), el(doc, "span", { class: "opf-export-item-label", text: label }), el(doc, "span", { class: "opf-export-item-meta", text: meta }));
|
|
108
|
+
const group = (key, rows) => {
|
|
109
|
+
const label = el(doc, "div", { class: "opf-export-group-label", id: `${id}-${key}`, text: GROUP_LABELS[key] });
|
|
110
|
+
return el(doc, "div", { class: "opf-export-group", role: "group", "aria-labelledby": label.id, "data-group": key }, label, ...rows);
|
|
111
|
+
};
|
|
112
|
+
if (items.slides.length) groups.push(group("slides", items.slides.map((name) => row("slides", name, SLIDE_FORMATS[name].label, SLIDE_FORMATS[name].icon, SLIDE_FORMATS[name].meta(count)))));
|
|
113
|
+
if (items.source.length) groups.push(group("source", items.source.map((name) => row("source", name, SOURCE_FORMATS[name].label, SOURCE_FORMATS[name].icon, SOURCE_FORMATS[name].meta))));
|
|
114
|
+
if (items.agent) groups.push(group("agent", [row("agent", "handoff", "Agent handoff", "Bot", ".md")]));
|
|
115
|
+
menu.replaceChildren(...groups);
|
|
116
|
+
syncItems();
|
|
117
|
+
}
|
|
118
|
+
const slidesToConvertSafe = (deck) => { try { return slidesToConvert(deck, options.convertOptions).length; } catch { return 0; } };
|
|
119
|
+
const menuItems = () => [...menu.querySelectorAll(".opf-export-item")];
|
|
120
|
+
function reasonFor(item) {
|
|
121
|
+
if (item.dataset.kind === "agent") return undefined;
|
|
122
|
+
if (busy) return BUSY_REASON;
|
|
123
|
+
if (item.dataset.kind === "slides") {
|
|
124
|
+
if (disabledReason) return disabledReason;
|
|
125
|
+
if (item.dataset.format === "pptx" && typeof options.exportPptx !== "function") return "PowerPoint export is not set up in this editor.";
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
function syncItems() {
|
|
130
|
+
for (const item of menuItems()) {
|
|
131
|
+
const reason = reasonFor(item);
|
|
132
|
+
if (reason) { item.setAttribute("aria-disabled", "true"); item.title = reason; }
|
|
133
|
+
else { item.removeAttribute("aria-disabled"); item.removeAttribute("title"); }
|
|
134
|
+
}
|
|
135
|
+
if (busy) trigger.setAttribute("aria-busy", "true"); else trigger.removeAttribute("aria-busy");
|
|
136
|
+
root.toggleAttribute("data-busy", Boolean(busy));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- open and close ---------------------------------------------------------------------------------------------------------------------
|
|
140
|
+
function layout() {
|
|
141
|
+
const sheeted = mode === "sheet";
|
|
142
|
+
root.dataset.mode = mode;
|
|
143
|
+
(sheeted ? sheet : popover).append(menu);
|
|
144
|
+
popover.hidden = sheeted || !isOpen;
|
|
145
|
+
sheet.hidden = !sheeted || !isOpen;
|
|
146
|
+
scrim.hidden = !sheeted || !isOpen;
|
|
147
|
+
trigger.setAttribute("aria-expanded", String(isOpen));
|
|
148
|
+
trigger.setAttribute("aria-haspopup", sheeted ? "dialog" : "menu");
|
|
149
|
+
}
|
|
150
|
+
function focusItem(which = "first") {
|
|
151
|
+
const list = menuItems();
|
|
152
|
+
const enabled = list.filter((item) => item.getAttribute("aria-disabled") !== "true");
|
|
153
|
+
const pool = enabled.length ? enabled : list;
|
|
154
|
+
(which === "last" ? pool[pool.length - 1] : pool[0])?.focus();
|
|
155
|
+
}
|
|
156
|
+
function onDocumentPointerdown(event) { if (!root.contains(event.target)) close({ restoreFocus: false }); }
|
|
157
|
+
function onDocumentKeydown(event) {
|
|
158
|
+
if (event.key === "Escape" && isOpen) { event.preventDefault(); event.stopPropagation(); close(); }
|
|
159
|
+
}
|
|
160
|
+
function open(which = "first") {
|
|
161
|
+
if (destroyed || isOpen) return;
|
|
162
|
+
buildMenu();
|
|
163
|
+
if (!busy) hideStatus();
|
|
164
|
+
isOpen = true;
|
|
165
|
+
layout();
|
|
166
|
+
doc.addEventListener("pointerdown", onDocumentPointerdown, true);
|
|
167
|
+
doc.addEventListener("keydown", onDocumentKeydown, true);
|
|
168
|
+
focusItem(which);
|
|
169
|
+
}
|
|
170
|
+
function close({ restoreFocus = true } = {}) {
|
|
171
|
+
if (!isOpen) return;
|
|
172
|
+
isOpen = false;
|
|
173
|
+
layout();
|
|
174
|
+
doc.removeEventListener("pointerdown", onDocumentPointerdown, true);
|
|
175
|
+
doc.removeEventListener("keydown", onDocumentKeydown, true);
|
|
176
|
+
if (restoreFocus) trigger.focus();
|
|
177
|
+
}
|
|
178
|
+
function onMenuKeydown(event) {
|
|
179
|
+
const list = menuItems();
|
|
180
|
+
const at = list.indexOf(doc.activeElement);
|
|
181
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
184
|
+
list[(at + step + list.length) % list.length]?.focus();
|
|
185
|
+
} else if (event.key === "Home") { event.preventDefault(); list[0]?.focus(); }
|
|
186
|
+
else if (event.key === "End") { event.preventDefault(); list[list.length - 1]?.focus(); }
|
|
187
|
+
else if (event.key === "Tab") {
|
|
188
|
+
if (mode === "menu") close({ restoreFocus: false });
|
|
189
|
+
else {
|
|
190
|
+
// The sheet is modal: Tab cycles through its items and Close.
|
|
191
|
+
const stops = [...list, closeButton], here = stops.indexOf(doc.activeElement);
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
stops[(here + (event.shiftKey ? -1 : 1) + stops.length) % stops.length].focus();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- status -----------------------------------------------------------------------------------------------------------------------------
|
|
199
|
+
function announce(message) {
|
|
200
|
+
globalThis.clearTimeout(liveTimer);
|
|
201
|
+
live.textContent = "";
|
|
202
|
+
liveTimer = globalThis.setTimeout(() => { live.textContent = message; }, 20);
|
|
203
|
+
}
|
|
204
|
+
function hideStatus() {
|
|
205
|
+
globalThis.clearTimeout(hideTimer);
|
|
206
|
+
statusPanel.hidden = true;
|
|
207
|
+
}
|
|
208
|
+
// state: "busy" | "success" | "error" | "cancelled". `progress`: { value, indeterminate }. Notes keep the panel up until dismissed.
|
|
209
|
+
function showStatus(state, message, { notes = [], progress, announceIt = true } = {}) {
|
|
210
|
+
globalThis.clearTimeout(hideTimer);
|
|
211
|
+
statusPanel.dataset.state = state;
|
|
212
|
+
statusText.textContent = message;
|
|
213
|
+
statusIcon.replaceChildren(...(state === "success" ? [icon(doc, "Check")] : state === "error" ? [icon(doc, "X")] : []));
|
|
214
|
+
statusBar.hidden = state !== "busy";
|
|
215
|
+
if (state === "busy" && progress) { if (progress.indeterminate) statusBar.removeAttribute("value"); else statusBar.value = progress.value; }
|
|
216
|
+
statusAction.textContent = state === "busy" ? "Cancel" : "Dismiss";
|
|
217
|
+
statusAction.setAttribute("aria-label", state === "busy" ? "Cancel export" : "Dismiss export status");
|
|
218
|
+
const shown = notes.slice(0, MAX_NOTES).map((note) => el(doc, "li", { text: noteText(note) }));
|
|
219
|
+
if (notes.length > MAX_NOTES) shown.push(el(doc, "li", { text: `…and ${notes.length - MAX_NOTES} more` }));
|
|
220
|
+
statusNotes.replaceChildren(...shown);
|
|
221
|
+
statusNotes.hidden = !shown.length;
|
|
222
|
+
statusPanel.hidden = false;
|
|
223
|
+
if (announceIt) announce(state === "success" && notes.length ? `${message}. ${plural(notes.length, "note")} to review.` : message);
|
|
224
|
+
if ((state === "success" || state === "cancelled") && !notes.length) hideTimer = globalThis.setTimeout(hideStatus, AUTO_HIDE_MS);
|
|
225
|
+
options.onStatus?.({ state, message, notes: notes.map(noteText) });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- exporting --------------------------------------------------------------------------------------------------------------------------
|
|
229
|
+
function progressOf(update) {
|
|
230
|
+
if (!update) return { indeterminate: true };
|
|
231
|
+
const counted = Number(update.total) > 1 && (update.stage === "convert" || update.stage === "render" || update.stage === undefined);
|
|
232
|
+
return counted ? { value: Math.min(1, Math.max(0, update.done / update.total)) } : { indeterminate: true };
|
|
233
|
+
}
|
|
234
|
+
function reporter(label, signal) {
|
|
235
|
+
return (update) => {
|
|
236
|
+
if (destroyed || signal.aborted || busy?.signal !== signal) return;
|
|
237
|
+
showStatus("busy", update?.message || `Exporting ${label}…`, { progress: progressOf(update), announceIt: false });
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
async function exportSlides(deck, format, signal, label) {
|
|
241
|
+
const progress = reporter(label, signal);
|
|
242
|
+
if (format === "pptx") {
|
|
243
|
+
const out = await abortable(Promise.resolve().then(() => options.exportPptx(deck, { signal, onProgress: progress })), signal);
|
|
244
|
+
const raw = out instanceof Blob || out instanceof ArrayBuffer || ArrayBuffer.isView(out) ? { bytes: out } : out;
|
|
245
|
+
if (!raw || raw.bytes === undefined) throw new Error("The PowerPoint export returned no data.");
|
|
246
|
+
const blob = raw.bytes instanceof Blob ? raw.bytes : new Blob([raw.bytes], { type: PPTX_TYPE });
|
|
247
|
+
if (!blob.size) throw new Error("The PowerPoint export returned no data.");
|
|
248
|
+
return { blob, fileName: raw.fileName ?? exportFileName(deck, "pptx"), count: deck.slides?.length ?? 0, notes: raw.warnings ?? [] };
|
|
249
|
+
}
|
|
250
|
+
const convertOptions = options.convertOptions ?? {};
|
|
251
|
+
const zip = format !== "pdf" && slidesToConvert(deck, convertOptions).length > 1;
|
|
252
|
+
const made = await convert(deck, {
|
|
253
|
+
...convertOptions, format, ...(zip ? { zip: true } : {}),
|
|
254
|
+
fonts: options.fonts, catalogs: catalogsNow(), renderOptions: options.renderOptions,
|
|
255
|
+
signal, onProgress: progress,
|
|
256
|
+
});
|
|
257
|
+
const [file] = made.files;
|
|
258
|
+
return {
|
|
259
|
+
blob: new Blob([file.bytes], { type: file.type }), fileName: file.name,
|
|
260
|
+
count: file.slides?.length ?? file.entries?.length ?? 1,
|
|
261
|
+
// Fonts the file embeds are information; everything else is a note for the author.
|
|
262
|
+
notes: made.findings.filter((finding) => finding.severity !== "info"),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async function exportSource(deck, format) {
|
|
266
|
+
const spec = SOURCE_FORMATS[format];
|
|
267
|
+
const fileName = exportFileName(deck, spec.extension);
|
|
268
|
+
let text, notes = [];
|
|
269
|
+
if (format === "json") {
|
|
270
|
+
// As "Save OPF" writes it: every catalog record the deck uses is embedded, so the file renders the same where no catalog is registered.
|
|
271
|
+
const saved = prepareSave(deck, { catalogs: catalogsNow() });
|
|
272
|
+
text = JSON.stringify(saved.document, null, 2);
|
|
273
|
+
if (saved.unresolved.length) notes = [`${plural(saved.unresolved.length, "catalog reference")} did not resolve, so no record was embedded for ${saved.unresolved.length === 1 ? "it" : "them"}.`];
|
|
274
|
+
} else if (format === "yaml") {
|
|
275
|
+
const { toYaml } = await import("@openpresentation/opf/yaml");
|
|
276
|
+
text = toYaml(deck).yaml;
|
|
277
|
+
} else {
|
|
278
|
+
const { toMarkdown } = await import("@openpresentation/opf/markdown");
|
|
279
|
+
const made = toMarkdown(deck);
|
|
280
|
+
text = made.markdown;
|
|
281
|
+
notes = [...(made.report.embedded ?? []).map((part) => `Written as YAML inside the Markdown, which has no syntax for it: ${part.path || "(document)"}, ${part.reason}`), ...(made.report.loss ?? []).map((loss) => `Left out: ${loss}`)];
|
|
282
|
+
}
|
|
283
|
+
return { blob: new Blob([text], { type: spec.type }), fileName, notes };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function run(kind, format) {
|
|
287
|
+
if (destroyed || busy) return;
|
|
288
|
+
if (options.beforeExport?.(kind, format) === false) return;
|
|
289
|
+
const label = kind === "slides" ? SLIDE_FORMATS[format].label : SOURCE_FORMATS[format].label;
|
|
290
|
+
const controller = new AbortController();
|
|
291
|
+
const { signal } = controller;
|
|
292
|
+
busy = { kind, format, signal, controller };
|
|
293
|
+
syncItems();
|
|
294
|
+
showStatus("busy", `Exporting ${label}…`, { progress: { indeterminate: true } });
|
|
295
|
+
try {
|
|
296
|
+
// This committed snapshot is what is exported, even if the host edits the document meanwhile.
|
|
297
|
+
const deck = structuredClone(getDeck());
|
|
298
|
+
const made = kind === "slides" ? await exportSlides(deck, format, signal, label) : await exportSource(deck, format);
|
|
299
|
+
if (signal.aborted) throw abortError();
|
|
300
|
+
const download = { blob: made.blob, fileName: made.fileName, format };
|
|
301
|
+
if (options.onDownload) await options.onDownload(download);
|
|
302
|
+
else saveBlob(doc, made.blob, made.fileName);
|
|
303
|
+
if (destroyed) return;
|
|
304
|
+
const notes = made.notes ?? [];
|
|
305
|
+
const parts = [made.fileName, ...(made.count !== undefined ? [plural(made.count, "slide")] : []), sizeText(made.blob.size), ...(notes.length ? [`${plural(notes.length, "note")} to review`] : [])];
|
|
306
|
+
showStatus("success", parts.join(" · "), { notes });
|
|
307
|
+
options.onExport?.({ ...download, notes: notes.map(noteText) });
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (destroyed) return;
|
|
310
|
+
if (isAbort(error, signal)) showStatus("cancelled", "Export cancelled.");
|
|
311
|
+
else showStatus("error", `${label} export failed. ${error?.message ?? error}`);
|
|
312
|
+
} finally {
|
|
313
|
+
if (busy?.controller === controller) busy = undefined;
|
|
314
|
+
if (!destroyed) syncItems();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function handoff() {
|
|
319
|
+
if (options.beforeExport?.("agent", "handoff") === false) return;
|
|
320
|
+
const document = getDeck();
|
|
321
|
+
const target = options.handoffTarget ?? "pptx";
|
|
322
|
+
if (options.onAgentHandoff) { options.onAgentHandoff({ document, target }); return; }
|
|
323
|
+
handoffDialog?.destroy();
|
|
324
|
+
handoffDialog = createAgentHandoffDialog(options.handoffContainer ?? doc.body, {
|
|
325
|
+
document, target, density, fileName: options.handoffFileName, specUrl: options.specUrl, agentGuideUrl: options.agentGuideUrl,
|
|
326
|
+
onDownload: options.onDownload ? (download) => options.onDownload(download) : undefined,
|
|
327
|
+
onClose: () => { handoffDialog = undefined; },
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function activate(item) {
|
|
332
|
+
if (item.getAttribute("aria-disabled") === "true") return;
|
|
333
|
+
const { kind, format } = item.dataset;
|
|
334
|
+
close();
|
|
335
|
+
if (kind === "agent") handoff();
|
|
336
|
+
else run(kind, format);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---- wiring -----------------------------------------------------------------------------------------------------------------------------
|
|
340
|
+
trigger.addEventListener("click", () => (isOpen ? close() : open()));
|
|
341
|
+
trigger.addEventListener("keydown", (event) => {
|
|
342
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); if (!isOpen) open(event.key === "ArrowUp" ? "last" : "first"); else focusItem(event.key === "ArrowUp" ? "last" : "first"); }
|
|
343
|
+
});
|
|
344
|
+
menu.addEventListener("click", (event) => { const item = event.target.closest?.(".opf-export-item"); if (item && menu.contains(item)) activate(item); });
|
|
345
|
+
menu.addEventListener("keydown", onMenuKeydown);
|
|
346
|
+
sheet.addEventListener("keydown", (event) => { if (event.target === closeButton && event.key === "Tab") onMenuKeydown(event); });
|
|
347
|
+
closeButton.addEventListener("click", () => close());
|
|
348
|
+
scrim.addEventListener("click", () => close());
|
|
349
|
+
statusAction.addEventListener("click", () => {
|
|
350
|
+
if (busy) busy.controller.abort();
|
|
351
|
+
else { hideStatus(); trigger.focus(); }
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
buildMenu();
|
|
355
|
+
layout();
|
|
356
|
+
container.append(root);
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
element: root,
|
|
360
|
+
trigger,
|
|
361
|
+
/** Re-read the document for the menu's hints (ZIP or one file) and the disabled state. */
|
|
362
|
+
refresh() { buildMenu(); },
|
|
363
|
+
open,
|
|
364
|
+
close,
|
|
365
|
+
/** Disable the slide exports: `true` for the default reason, a string for your own (it is the items' title), `false` to enable. */
|
|
366
|
+
setDisabled(reason) {
|
|
367
|
+
disabledReason = reason === true ? EXPORT_DISABLED_REASON : typeof reason === "string" && reason ? reason : undefined;
|
|
368
|
+
syncItems();
|
|
369
|
+
},
|
|
370
|
+
/** Switch between the popover and the bottom sheet (for example at a media query). */
|
|
371
|
+
setMode(next) {
|
|
372
|
+
const wanted = next === "sheet" ? "sheet" : "menu";
|
|
373
|
+
if (wanted === mode) return;
|
|
374
|
+
if (isOpen) close({ restoreFocus: false });
|
|
375
|
+
mode = wanted;
|
|
376
|
+
layout();
|
|
377
|
+
},
|
|
378
|
+
setDensity(next) { density = next === "touch" ? "touch" : "comfortable"; root.dataset.density = density; },
|
|
379
|
+
/** Stop the export that is running, if any. */
|
|
380
|
+
cancel() { busy?.controller.abort(); },
|
|
381
|
+
destroy() {
|
|
382
|
+
if (destroyed) return;
|
|
383
|
+
busy?.controller.abort();
|
|
384
|
+
close({ restoreFocus: false });
|
|
385
|
+
destroyed = true;
|
|
386
|
+
globalThis.clearTimeout(hideTimer);
|
|
387
|
+
globalThis.clearTimeout(liveTimer);
|
|
388
|
+
handoffDialog?.destroy();
|
|
389
|
+
root.remove();
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
package/dist/json-editor.d.ts
CHANGED
|
@@ -20,6 +20,9 @@ export interface JsonCodeEditorOptions {
|
|
|
20
20
|
invalid?: boolean;
|
|
21
21
|
describedBy?: string;
|
|
22
22
|
lineNumbers?: boolean;
|
|
23
|
+
/** Highlighting and behavior: "json" (default) adds the parse linter, field highlights and the option menu. */
|
|
24
|
+
language?: "json" | "yaml" | "markdown";
|
|
25
|
+
readOnly?: boolean;
|
|
23
26
|
catalogs?: JsonCatalogContext;
|
|
24
27
|
/** Overrides the built-in menu, allowing an existing host UI to keep its appearance. */
|
|
25
28
|
onOptions?(left?: number, top?: number): void;
|
|
@@ -32,6 +35,10 @@ export interface JsonCodeEditor {
|
|
|
32
35
|
/** Apply external source without emitting onChange or adding a local undo step. */
|
|
33
36
|
update(code: string, invalid?: boolean, field?: string | null, describedBy?: string): void;
|
|
34
37
|
setCatalogs(catalogs: JsonCatalogContext): void;
|
|
38
|
+
setLanguage(language: "json" | "yaml" | "markdown"): void;
|
|
39
|
+
setReadOnly(readOnly: boolean): void;
|
|
40
|
+
/** The CodeMirror `EditorView`. */
|
|
41
|
+
readonly view: import("@codemirror/view").EditorView;
|
|
35
42
|
openOptions(left?: number, top?: number): Promise<void>;
|
|
36
43
|
destroy(): void;
|
|
37
44
|
}
|
package/dist/json-editor.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Annotation, Compartment, EditorSelection, EditorState, Prec, StateEffect, StateField, Transaction } from "@codemirror/state";
|
|
2
2
|
import { Decoration, EditorView, drawSelection, keymap, lineNumbers } from "@codemirror/view";
|
|
3
3
|
import { defaultKeymap, history, historyKeymap, indentWithTab, isolateHistory, redo } from "@codemirror/commands";
|
|
4
|
-
import { HighlightStyle, bracketMatching, codeFolding, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
|
|
4
|
+
import { HighlightStyle, StreamLanguage, bracketMatching, codeFolding, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
|
|
5
5
|
import { json, jsonParseLinter } from "@codemirror/lang-json";
|
|
6
|
+
import { yaml } from "@codemirror/lang-yaml";
|
|
6
7
|
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
|
|
7
8
|
import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search";
|
|
8
9
|
import { linter } from "@codemirror/lint";
|
|
@@ -18,8 +19,57 @@ const highlightStyle = HighlightStyle.define([
|
|
|
18
19
|
{ tag: tags.string, class: "tok-str" },
|
|
19
20
|
{ tag: [tags.number, tags.bool, tags.null], class: "tok-num" },
|
|
20
21
|
{ tag: tags.punctuation, class: "tok-punct" },
|
|
21
|
-
{ tag: tags.bracket, class: "tok-brace" }
|
|
22
|
+
{ tag: tags.bracket, class: "tok-brace" },
|
|
23
|
+
{ tag: tags.comment, class: "tok-comment" },
|
|
24
|
+
{ tag: tags.heading, class: "tok-head" },
|
|
25
|
+
{ tag: [tags.meta, tags.keyword, tags.operator], class: "tok-meta" }
|
|
22
26
|
]);
|
|
27
|
+
// OPF Markdown: front matter, "---" slide rules, headings, fences, lists and HTML comments. A small stream tokenizer
|
|
28
|
+
// keeps the bundle free of the full Markdown grammar (and of the HTML, CSS and JavaScript grammars it embeds).
|
|
29
|
+
const markdownLanguage = StreamLanguage.define({
|
|
30
|
+
name: "opf-markdown",
|
|
31
|
+
startState: () => ({ line: 0, front: 0, fence: null, comment: false }),
|
|
32
|
+
token(stream, state) {
|
|
33
|
+
if (state.comment) {
|
|
34
|
+
if (stream.match(/^.*?-->/)) state.comment = false; else stream.skipToEnd();
|
|
35
|
+
return "comment";
|
|
36
|
+
}
|
|
37
|
+
if (stream.sol()) {
|
|
38
|
+
state.line++;
|
|
39
|
+
if (state.fence) {
|
|
40
|
+
if (stream.match(/^(```|~~~)\s*$/)) { state.fence = null; return "meta"; }
|
|
41
|
+
stream.skipToEnd();
|
|
42
|
+
return "string";
|
|
43
|
+
}
|
|
44
|
+
if (stream.match(/^---\s*$/)) {
|
|
45
|
+
if (state.front === 1) state.front = 2; else if (state.line === 1) state.front = 1;
|
|
46
|
+
return "meta";
|
|
47
|
+
}
|
|
48
|
+
if (stream.match(/^(```|~~~)/)) { state.fence = "fence"; stream.skipToEnd(); return "meta"; }
|
|
49
|
+
if (state.front === 1) {
|
|
50
|
+
if (stream.match(/^\s*[\w$.-]+(?=:)/)) return "property";
|
|
51
|
+
stream.next();
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (stream.match(/^#{1,6}\s.*$/)) return "heading";
|
|
55
|
+
if (stream.match(/^\s*([-*+]|\d+\.)\s/)) return "operator";
|
|
56
|
+
if (stream.match(/^Notes?:/)) return "keyword";
|
|
57
|
+
if (stream.match(/^>\s?/)) return "operator";
|
|
58
|
+
}
|
|
59
|
+
if (stream.match("<!--")) {
|
|
60
|
+
if (!stream.match(/^.*?-->/)) { stream.skipToEnd(); state.comment = true; }
|
|
61
|
+
return "comment";
|
|
62
|
+
}
|
|
63
|
+
if (stream.match(/^`[^`]*`/)) return "string";
|
|
64
|
+
stream.next();
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
function languageExtension(name) {
|
|
69
|
+
if (name === "yaml") return yaml();
|
|
70
|
+
if (name === "markdown") return markdownLanguage;
|
|
71
|
+
return [json(), linter(jsonParseLinter(), { delay: 500 }), fieldDecorations];
|
|
72
|
+
}
|
|
23
73
|
function indentation(source) {
|
|
24
74
|
const spaces = source.match(/^[\t ]+(?=\S)/gm) ?? [];
|
|
25
75
|
return spaces.some((value) => value.includes(" ")) ? " " : " ".repeat(spaces.length ? Math.min(8, ...spaces.map((value) => value.length)) : 2);
|
|
@@ -102,12 +152,14 @@ function skipDelimiter(view, delimiter) {
|
|
|
102
152
|
return true;
|
|
103
153
|
}
|
|
104
154
|
function mountJsonCodeEditor(parent, options) {
|
|
105
|
-
const attributes = new Compartment(), indent = new Compartment();
|
|
155
|
+
const attributes = new Compartment(), indent = new Compartment(), language = new Compartment(), editable = new Compartment();
|
|
156
|
+
const editableExtension = (readOnly) => [EditorState.readOnly.of(Boolean(readOnly)), EditorView.editable.of(!readOnly)];
|
|
157
|
+
let languageName = options.language ?? "json";
|
|
106
158
|
let disposed=false, menu=null, catalogs=options.catalogs??[], generation=0;
|
|
107
159
|
function closeOptions(){generation++;menu?.destroy();menu=null;}
|
|
108
160
|
async function openOptions(left,top){
|
|
109
161
|
closeOptions();
|
|
110
|
-
if(disposed)return;
|
|
162
|
+
if(disposed||languageName!=="json")return;
|
|
111
163
|
if(options.onOptions){options.onOptions(left,top);return;}
|
|
112
164
|
const revision=generation,source=api.getValue(),selection=api.getSelection();
|
|
113
165
|
try{
|
|
@@ -127,7 +179,8 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
127
179
|
autocorrect: "off"
|
|
128
180
|
});
|
|
129
181
|
const view = new EditorView({ parent, state: EditorState.create({ doc: options.code, extensions: [
|
|
130
|
-
|
|
182
|
+
language.of(languageExtension(languageName)),
|
|
183
|
+
editable.of(editableExtension(options.readOnly)),
|
|
131
184
|
jsonSource.init(()=>options.code),jsonSourceHistory,
|
|
132
185
|
indent.of(indentUnit.of(currentIndent)),
|
|
133
186
|
indentOnInput(),
|
|
@@ -138,10 +191,8 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
138
191
|
codeFolding(),
|
|
139
192
|
search({ top: true }),
|
|
140
193
|
highlightSelectionMatches(),
|
|
141
|
-
linter(jsonParseLinter(), { delay: 500 }),
|
|
142
194
|
syntaxHighlighting(highlightStyle),
|
|
143
195
|
activeField,
|
|
144
|
-
fieldDecorations,
|
|
145
196
|
attributes.of(contentAttributes()),
|
|
146
197
|
options.lineNumbers ? lineNumbers() : [],
|
|
147
198
|
EditorView.lineWrapping,
|
|
@@ -245,6 +296,15 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
245
296
|
api,
|
|
246
297
|
openOptions,
|
|
247
298
|
setCatalogs(value){catalogs=value??[];closeOptions();},
|
|
299
|
+
/** Switch the highlighting between "json" (with its parse linter, field highlights and option menu), "yaml" and "markdown". */
|
|
300
|
+
setLanguage(name){
|
|
301
|
+
if(name===languageName)return;
|
|
302
|
+
languageName=name;closeOptions();
|
|
303
|
+
view.dispatch({ effects: language.reconfigure(languageExtension(name)) });
|
|
304
|
+
},
|
|
305
|
+
setReadOnly(value){view.dispatch({ effects: editable.reconfigure(editableExtension(value)) });},
|
|
306
|
+
/** The CodeMirror view, for hosts that scroll or measure it. */
|
|
307
|
+
view,
|
|
248
308
|
update(code, invalid=false, field=null, describedBy) {
|
|
249
309
|
if(code!==api.getValue())closeOptions();
|
|
250
310
|
const current = view.state.doc.toString(), normalized=textInputMap(code).text;
|
package/dist/json-source.js
CHANGED
|
@@ -6,7 +6,8 @@ import {textInputMap,preferredLineEnding} from './text-input.js';
|
|
|
6
6
|
// field, and store only removed newline spellings in its existing undo history.
|
|
7
7
|
export const setJsonSource=StateEffect.define();
|
|
8
8
|
const restoreEndings=StateEffect.define({map:(endings,changes)=>{
|
|
9
|
-
|
|
9
|
+
// A position past the end of the changed document (the text was deleted, then replaced from outside) cannot be placed: drop it.
|
|
10
|
+
const mapped=endings.map(({position,ending})=>({position:position<=changes.length?changes.mapPos(position,1,MapMode.TrackDel):null,ending})).filter(item=>item.position!==null);
|
|
10
11
|
return mapped.length?mapped:undefined;
|
|
11
12
|
}});
|
|
12
13
|
export const jsonSource=StateField.define({
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { EditorSession } from "./index.js";
|
|
2
|
+
|
|
3
|
+
export interface SlideNavigatorLabels {
|
|
4
|
+
/** The accessible name of the control group. Default: "Slide navigation". */
|
|
5
|
+
navigation?: string;
|
|
6
|
+
/** Default: "Previous slide". */
|
|
7
|
+
previous?: string;
|
|
8
|
+
/** Default: "Next slide". */
|
|
9
|
+
next?: string;
|
|
10
|
+
/** The tooltip of the counter, for example "Slide 2 of 3". */
|
|
11
|
+
position?: (index: number, count: number) => string;
|
|
12
|
+
}
|
|
13
|
+
export interface SlideNavigatorOptions {
|
|
14
|
+
/** The editor session. The slide count follows it. */
|
|
15
|
+
editor?: EditorSession;
|
|
16
|
+
/** Without an editor: an OPF presentation, or a function returning the current one. Call `refresh()` after it changes. */
|
|
17
|
+
presentation?: { slides?: unknown[] } | (() => { slides?: unknown[] });
|
|
18
|
+
/** The slide shown first (0-based). Default 0. */
|
|
19
|
+
slideIndex?: number;
|
|
20
|
+
/** Called with the new index when the user moves, and when a shrinking deck clamps the index. Not called by `setSlide`. */
|
|
21
|
+
onChange?: (index: number) => void;
|
|
22
|
+
/** `"comfortable"` (32px controls, default) or `"touch"` (40px). */
|
|
23
|
+
density?: "comfortable" | "touch";
|
|
24
|
+
labels?: SlideNavigatorLabels;
|
|
25
|
+
/** False to stop following the editor on its own; call `refresh()` yourself. Default true. */
|
|
26
|
+
autoRefresh?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface SlideNavigator {
|
|
29
|
+
readonly element: HTMLElement;
|
|
30
|
+
getSlide(): number;
|
|
31
|
+
/** Show another slide (clamped to the deck) without calling `onChange`. */
|
|
32
|
+
setSlide(index: number): void;
|
|
33
|
+
/** Read the slide count again. */
|
|
34
|
+
refresh(): void;
|
|
35
|
+
/**
|
|
36
|
+
* Page-level Left and Right arrows (default target: the document). They ignore typing in inputs, selects and editors, menus, dialogs, modified keys and
|
|
37
|
+
* events another handler already handled. Returns a function that removes the listener; `destroy()` removes every one.
|
|
38
|
+
*/
|
|
39
|
+
bindKeys(target?: Document | HTMLElement | Window): () => void;
|
|
40
|
+
destroy(): void;
|
|
41
|
+
}
|
|
42
|
+
/** The counter text, for example "01 / 03": both numbers are zero-padded to two digits, or to the width of the slide count from 100 slides. */
|
|
43
|
+
export declare function formatSlideCounter(index: number, count: number): string;
|
|
44
|
+
export declare function createSlideNavigator(container: HTMLElement, options?: SlideNavigatorOptions): SlideNavigator;
|