@openpresentation/opf-editor 0.24.0 → 0.24.2
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 +154 -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 +9 -0
- package/dist/json-editor.js +97 -8
- package/dist/json-options.d.ts +21 -0
- package/dist/json-options.js +127 -27
- 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-findings.js +27 -0
- package/dist/source-lines.js +206 -0
- package/dist/source-pane.d.ts +90 -0
- package/dist/source-pane.js +669 -0
- package/dist/styles.css +3383 -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,11 @@ 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;
|
|
26
|
+
/** Live completion popup in JSON: property names and enum, boolean and catalog values at the cursor's JSON path, from the same data as the field menu (default false here; the source pane turns it on). Ctrl+Space still opens the field menu. */
|
|
27
|
+
completions?: boolean;
|
|
23
28
|
catalogs?: JsonCatalogContext;
|
|
24
29
|
/** Overrides the built-in menu, allowing an existing host UI to keep its appearance. */
|
|
25
30
|
onOptions?(left?: number, top?: number): void;
|
|
@@ -32,6 +37,10 @@ export interface JsonCodeEditor {
|
|
|
32
37
|
/** Apply external source without emitting onChange or adding a local undo step. */
|
|
33
38
|
update(code: string, invalid?: boolean, field?: string | null, describedBy?: string): void;
|
|
34
39
|
setCatalogs(catalogs: JsonCatalogContext): void;
|
|
40
|
+
setLanguage(language: "json" | "yaml" | "markdown"): void;
|
|
41
|
+
setReadOnly(readOnly: boolean): void;
|
|
42
|
+
/** The CodeMirror `EditorView`. */
|
|
43
|
+
readonly view: import("@codemirror/view").EditorView;
|
|
35
44
|
openOptions(left?: number, top?: number): Promise<void>;
|
|
36
45
|
destroy(): void;
|
|
37
46
|
}
|
package/dist/json-editor.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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 {
|
|
6
|
+
import { yaml } from "@codemirror/lang-yaml";
|
|
7
|
+
import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap, startCompletion } from "@codemirror/autocomplete";
|
|
7
8
|
import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search";
|
|
8
9
|
import { linter } from "@codemirror/lint";
|
|
9
10
|
import { tags } from "@lezer/highlight";
|
|
@@ -18,8 +19,72 @@ 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, completions) {
|
|
69
|
+
if (name === "yaml") return yaml();
|
|
70
|
+
if (name === "markdown") return markdownLanguage;
|
|
71
|
+
return [json(), linter(jsonParseLinter(), { delay: 500 }), fieldDecorations, completions ?? []];
|
|
72
|
+
}
|
|
73
|
+
// The completion popup keeps CodeMirror's keys (arrows, Enter, Escape) but not Ctrl-Space, which opens the field menu.
|
|
74
|
+
const completionKeys = Prec.highest(keymap.of(completionKeymap.filter((binding) => binding.run !== startCompletion)));
|
|
75
|
+
// Accept a completion by rewriting its whole token (quotes included), so a string key, a bare word and a changed value type all land
|
|
76
|
+
// as valid JSON. A key adds ": " when none follows and then offers the values of that key.
|
|
77
|
+
function completionApply(found, option) {
|
|
78
|
+
return (view, completion, from, to) => {
|
|
79
|
+
const start = found.quoted ? from - 1 : from;
|
|
80
|
+
const end = found.closed ? to + 1 : to;
|
|
81
|
+
let insert = JSON.stringify(option.value);
|
|
82
|
+
const chain = found.kind === "key" && !/^\s*:/.test(view.state.sliceDoc(end, Math.min(view.state.doc.length, end + 200)));
|
|
83
|
+
if (chain) insert += ": ";
|
|
84
|
+
view.dispatch({ changes: { from: start, to: end, insert }, selection: { anchor: start + insert.length }, userEvent: "input.complete", scrollIntoView: true });
|
|
85
|
+
if (chain) setTimeout(() => startCompletion(view), 0);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
23
88
|
function indentation(source) {
|
|
24
89
|
const spaces = source.match(/^[\t ]+(?=\S)/gm) ?? [];
|
|
25
90
|
return spaces.some((value) => value.includes(" ")) ? " " : " ".repeat(spaces.length ? Math.min(8, ...spaces.map((value) => value.length)) : 2);
|
|
@@ -102,12 +167,14 @@ function skipDelimiter(view, delimiter) {
|
|
|
102
167
|
return true;
|
|
103
168
|
}
|
|
104
169
|
function mountJsonCodeEditor(parent, options) {
|
|
105
|
-
const attributes = new Compartment(), indent = new Compartment();
|
|
170
|
+
const attributes = new Compartment(), indent = new Compartment(), language = new Compartment(), editable = new Compartment();
|
|
171
|
+
const editableExtension = (readOnly) => [EditorState.readOnly.of(Boolean(readOnly)), EditorView.editable.of(!readOnly)];
|
|
172
|
+
let languageName = options.language ?? "json";
|
|
106
173
|
let disposed=false, menu=null, catalogs=options.catalogs??[], generation=0;
|
|
107
174
|
function closeOptions(){generation++;menu?.destroy();menu=null;}
|
|
108
175
|
async function openOptions(left,top){
|
|
109
176
|
closeOptions();
|
|
110
|
-
if(disposed)return;
|
|
177
|
+
if(disposed||languageName!=="json")return;
|
|
111
178
|
if(options.onOptions){options.onOptions(left,top);return;}
|
|
112
179
|
const revision=generation,source=api.getValue(),selection=api.getSelection();
|
|
113
180
|
try{
|
|
@@ -116,6 +183,20 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
116
183
|
menu=mountJsonFieldMenu(parent,api,catalogs,left,top,()=>{menu=null;});
|
|
117
184
|
}catch(error){options.onError?.(error);}
|
|
118
185
|
}
|
|
186
|
+
// Live completions (JSON only, `completions: true`): property names and values at the cursor's JSON path, from the same data as the
|
|
187
|
+
// field menu. The data loads with the field menu's chunk on first use.
|
|
188
|
+
async function completionSource(context) {
|
|
189
|
+
if (disposed || languageName !== "json") return null;
|
|
190
|
+
let module;
|
|
191
|
+
try { module = await import('./json-options.js'); } catch (error) { options.onError?.(error); return null; }
|
|
192
|
+
if (disposed || context.aborted) return null;
|
|
193
|
+
const found = module.getJsonCompletions(context.state.doc.toString(), context.pos, catalogs, { explicit: context.explicit });
|
|
194
|
+
if (!found) return null;
|
|
195
|
+
return { from: found.from, to: found.to, filter: true, options: found.options.map((option) => ({
|
|
196
|
+
label: option.label, detail: option.detail, info: option.info, boost: option.boost, type: found.kind === "key" ? "property" : "enum", apply: completionApply(found, option),
|
|
197
|
+
})) };
|
|
198
|
+
}
|
|
199
|
+
const completionExtension = options.completions ? [autocompletion({ override: [completionSource], defaultKeymap: false, icons: false, maxRenderedOptions: 60 }), completionKeys] : null;
|
|
119
200
|
let currentIndent = indentation(options.code);
|
|
120
201
|
const contentAttributes = (invalid = options.invalid, describedBy = options.describedBy) => EditorView.contentAttributes.of({
|
|
121
202
|
"aria-label": options.label,
|
|
@@ -127,7 +208,8 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
127
208
|
autocorrect: "off"
|
|
128
209
|
});
|
|
129
210
|
const view = new EditorView({ parent, state: EditorState.create({ doc: options.code, extensions: [
|
|
130
|
-
|
|
211
|
+
language.of(languageExtension(languageName, completionExtension)),
|
|
212
|
+
editable.of(editableExtension(options.readOnly)),
|
|
131
213
|
jsonSource.init(()=>options.code),jsonSourceHistory,
|
|
132
214
|
indent.of(indentUnit.of(currentIndent)),
|
|
133
215
|
indentOnInput(),
|
|
@@ -138,10 +220,8 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
138
220
|
codeFolding(),
|
|
139
221
|
search({ top: true }),
|
|
140
222
|
highlightSelectionMatches(),
|
|
141
|
-
linter(jsonParseLinter(), { delay: 500 }),
|
|
142
223
|
syntaxHighlighting(highlightStyle),
|
|
143
224
|
activeField,
|
|
144
|
-
fieldDecorations,
|
|
145
225
|
attributes.of(contentAttributes()),
|
|
146
226
|
options.lineNumbers ? lineNumbers() : [],
|
|
147
227
|
EditorView.lineWrapping,
|
|
@@ -245,6 +325,15 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
245
325
|
api,
|
|
246
326
|
openOptions,
|
|
247
327
|
setCatalogs(value){catalogs=value??[];closeOptions();},
|
|
328
|
+
/** Switch the highlighting between "json" (with its parse linter, field highlights and option menu), "yaml" and "markdown". */
|
|
329
|
+
setLanguage(name){
|
|
330
|
+
if(name===languageName)return;
|
|
331
|
+
languageName=name;closeOptions();
|
|
332
|
+
view.dispatch({ effects: language.reconfigure(languageExtension(name, completionExtension)) });
|
|
333
|
+
},
|
|
334
|
+
setReadOnly(value){view.dispatch({ effects: editable.reconfigure(editableExtension(value)) });},
|
|
335
|
+
/** The CodeMirror view, for hosts that scroll or measure it. */
|
|
336
|
+
view,
|
|
248
337
|
update(code, invalid=false, field=null, describedBy) {
|
|
249
338
|
if(code!==api.getValue())closeOptions();
|
|
250
339
|
const current = view.state.doc.toString(), normalized=textInputMap(code).text;
|
package/dist/json-options.d.ts
CHANGED
|
@@ -31,3 +31,24 @@ export declare function getJsonFieldContext(source: string, position: number, lo
|
|
|
31
31
|
export declare function replaceFieldOption(context: JsonFieldContext, value: JsonOptionValue): string;
|
|
32
32
|
/** Minimal single replacement for atomic source-editor undo. */
|
|
33
33
|
export declare function fieldOptionEdit(context: JsonFieldContext, value: JsonOptionValue): {from:number;to:number;insert:string};
|
|
34
|
+
export interface JsonCompletionOption {
|
|
35
|
+
/** A key option's property name, or the JSON value to write. */
|
|
36
|
+
value: JsonOptionValue;
|
|
37
|
+
label: string;
|
|
38
|
+
detail?: string;
|
|
39
|
+
info?: string;
|
|
40
|
+
/** CodeMirror ranking, -99 to 99. */
|
|
41
|
+
boost: number;
|
|
42
|
+
}
|
|
43
|
+
export interface JsonCompletions {
|
|
44
|
+
kind: 'key' | 'value';
|
|
45
|
+
/** The text being typed: inside the quotes of a string, or the bare word. */
|
|
46
|
+
from: number;
|
|
47
|
+
to: number;
|
|
48
|
+
quoted: boolean;
|
|
49
|
+
/** Whether a closing quote follows the typed text. */
|
|
50
|
+
closed: boolean;
|
|
51
|
+
options: JsonCompletionOption[];
|
|
52
|
+
}
|
|
53
|
+
/** The property names and values to offer at `position` of a JSON draft (it need not parse); null when there are none. `explicit` also offers choices where nothing is typed yet. */
|
|
54
|
+
export declare function getJsonCompletions(source: string, position: number, loadedCatalogs?: JsonCatalogContext, options?: { explicit?: boolean }): JsonCompletions | null;
|