/** * NeoDeck Local bridge — no iframe, no cloud. * Official neo-ppt talks to us via window.__NEODECK_CONNECT__ instead of Penpal parent. */ import { basename, dirname, extractPagePaths, joinDeckPath, normalizeRelativePath, titleFromManifest, } from "./lib.js"; const state = { editor: null, // methods exposed by official editor (setPPTD, ...) directoryHandle: null, fileIndex: new Map(), memoryFiles: new Map(), imageMap: Object.create(null), // path → data URL (export host / payload) manifestPath: "", manifestDirectory: "", manifestContent: "", deckTitle: "未打开文稿", saveQueue: Promise.resolve(), imageCache: new Map(), readOnly: false, ready: false, exportMode: false, }; const $ = (sel) => document.querySelector(sel); const exportMode = new URL(location.href).searchParams.get("ndExport") === "1" || new URL(location.href).searchParams.get("export") === "1"; state.exportMode = exportMode; if (exportMode) { document.documentElement.classList.add("nd-export-mode"); document.documentElement.dataset.deckStatus = "booting"; } function toast(msg, kind = "info") { const el = $("#nd-toast"); if (!el) return; el.hidden = false; el.dataset.kind = kind; el.textContent = msg; clearTimeout(toast._t); toast._t = setTimeout(() => { el.hidden = true; }, 3200); } function setStatus(text) { const el = $("#nd-status"); if (el) el.textContent = text; } function setTitle(text) { state.deckTitle = text; const el = $("#nd-title"); if (el) el.textContent = text; } async function indexDirectory(directoryHandle) { const index = new Map(); async function walk(handle, prefix = "") { for await (const [name, entry] of handle.entries()) { if (name === ".DS_Store") continue; const path = prefix ? `${prefix}/${name}` : name; if (entry.kind === "directory") await walk(entry, path); else index.set(normalizeRelativePath(path), entry); } } await walk(directoryHandle); return index; } function indexFallbackFiles(fileList) { const index = new Map(); const files = [...fileList]; const firstPath = files[0]?.webkitRelativePath || files[0]?.name || ""; const rootName = firstPath.includes("/") ? firstPath.split("/")[0] : ""; for (const file of files) { let path = file.webkitRelativePath || file.name; if (rootName && path.startsWith(`${rootName}/`)) path = path.slice(rootName.length + 1); index.set(normalizeRelativePath(path), { kind: "file", getFile: async () => file }); } return index; } async function textFromIndexed(path) { const key = normalizeRelativePath(path); if (state.memoryFiles.has(key)) return state.memoryFiles.get(key); const entry = state.fileIndex.get(key); if (!entry) throw new Error(`找不到文件:${path}`); return (await entry.getFile()).text(); } function fileToDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }); } function lookupImageMap(path) { if (!path || !state.imageMap) return ""; if (state.imageMap[path]) return state.imageMap[path]; const keys = Object.keys(state.imageMap); const hit = keys.find( (key) => path.endsWith(`/${key}`) || key.endsWith(`/${path}`) || key === path || key.endsWith(`/${path.split("/").pop()}`), ); return hit ? state.imageMap[hit] : ""; } async function resolveImage(requestedPath) { if (requestedPath == null || requestedPath === "") return ""; const raw = String(requestedPath); if (/^(?:data:image\/|https?:\/\/|blob:)/i.test(raw)) return raw; let path; try { path = normalizeRelativePath(raw.replace(/^file:\/\/+/, "").replace(/^\.\//, "")); } catch { path = raw.replace(/^file:\/\/+/, "").replace(/^\.\//, "").replaceAll("\\", "/"); } const mapped = lookupImageMap(path); if (mapped) return mapped; const candidates = []; const add = (p) => { if (p && !candidates.includes(p)) candidates.push(p); }; add(path); if (state.manifestDirectory) { try { add(joinDeckPath(state.manifestDirectory, path)); } catch { /* ignore */ } } // common media layouts const base = path.split("/").pop(); if (base) { add(`media/${base}`); add(`assets/${base}`); add(`images/${base}`); if (state.manifestDirectory) { try { add(joinDeckPath(state.manifestDirectory, `media/${base}`)); } catch { /* ignore */ } } } // fuzzy: any indexed file ending with same relative suffix or basename for (const key of state.fileIndex.keys()) { if (key === path || key.endsWith("/" + path) || (base && key.endsWith("/" + base)) || key === base) { add(key); } } for (const c of candidates) { if (!state.fileIndex.has(c) && !state.memoryFiles.has(c)) continue; const cacheKey = c; if (!state.imageCache.has(cacheKey)) { try { if (state.fileIndex.has(c)) { const file = await state.fileIndex.get(c).getFile(); state.imageCache.set(cacheKey, await fileToDataUrl(file)); } else { // memory text shouldn't be image; skip continue; } } catch (e) { console.warn("[neodeck] image read failed", c, e); continue; } } const url = await state.imageCache.get(cacheKey); if (url) return url; } console.warn("[neodeck] image not found", requestedPath, "tried", candidates.slice(0, 8)); return ""; } async function getImages(payload = {}) { // Official contract: { chatId, filePath: string[] } -> string[] (data URLs / public URLs) let paths = payload?.filePath; if (paths == null) paths = []; if (!Array.isArray(paths)) paths = [paths]; const out = []; for (const p of paths) { try { out.push(await resolveImage(p)); } catch (e) { console.warn("[neodeck] getImages item failed", p, e); out.push(""); } } return out; } async function writeIndexedFile(path, content) { if (state.readOnly || !state.directoryHandle) { state.memoryFiles.set(path, content); return; } const parts = path.split("/"); let dir = state.directoryHandle; for (let i = 0; i < parts.length - 1; i++) { dir = await dir.getDirectoryHandle(parts[i], { create: true }); } const handle = await dir.getFileHandle(parts[parts.length - 1], { create: true }); const writable = await handle.createWritable(); await writable.write(content); await writable.close(); state.fileIndex.set(path, handle); state.memoryFiles.set(path, content); } async function onSave(payload) { const changes = payload?.changes || payload?.files || []; const list = Array.isArray(changes) ? changes : []; setStatus("正在保存…"); try { for (const change of list) { if (!change?.path) continue; const path = normalizeRelativePath(change.path); if (!/\.(?:pptd|page)$/i.test(path)) continue; await writeIndexedFile(path, change.content ?? ""); } setStatus(state.readOnly ? "只读 · 已存内存" : "已保存到本地"); } catch (e) { setStatus("保存失败"); toast(`保存失败:${e.message || e}`, "error"); throw e; } } /** Called by patched official editor instead of Penpal connect */ window.__NEODECK_CONNECT__ = function neoDeckConnect(options) { const methods = options?.methods || {}; state.editor = methods; state.ready = true; setStatus("编辑器就绪"); // Force light chrome immediately (official default is system → OS dark FOUC). Promise.resolve( methods.setSlideConfig?.({ editable: true, locale: "zh-CN", theme: "light" }), ).catch((e) => console.warn("[neodeck] setSlideConfig", e)); // host methods the editor will call via connection.promise const host = { close() { toast("本地模式无需关闭"); }, reenter() {}, async toggleFullScreen(want) { try { const el = document.documentElement; const isFs = Boolean(document.fullscreenElement); if (want === true || (want !== false && !isFs)) { if (!isFs && el.requestFullscreen) await el.requestFullscreen(); } else if (want === false || isFs) { if (isFs && document.exitFullscreen) await document.exitFullscreen(); } } catch (e) { console.warn("[neodeck] fullscreen", e); } return Boolean(document.fullscreenElement); }, showFeedback() {}, sendPrompt() { toast("本地模式已禁用 AI", "warning"); }, showMessage(payload) { const message = typeof payload === "string" ? payload : payload?.message || payload?.content; if (message) toast(String(message)); }, hideMessage() {}, onSave, getImages, setAnnotationMode() {}, setAnnotationCurrentPage() {}, upsertAnnotation() {}, removeAnnotation() {}, clearAnnotations() {}, }; if (state.exportMode) { loadExportPayload().catch((error) => { console.error("[neodeck] export payload failed", error); document.documentElement.dataset.deckStatus = "error"; window.exportHostError = String(error?.stack || error); setStatus("导出载荷加载失败"); }); } else { // Auto-open demo if nothing loaded after a beat setTimeout(() => { if (!state.manifestPath) openDemo().catch((e) => console.error(e)); }, 400); } return { promise: Promise.resolve(host), destroy() { state.editor = null; state.ready = false; }, }; }; /** Headless / agent-browser export: load deck from ./payload.json (no folder picker). */ async function loadExportPayload() { document.documentElement.dataset.deckStatus = "loading"; setStatus("载入导出载荷…"); const response = await fetch("./payload.json", { cache: "no-store" }); if (!response.ok) throw new Error(`payload HTTP ${response.status}`); const payload = await response.json(); if (!state.editor?.setPPTD) throw new Error("编辑器尚未就绪"); state.directoryHandle = null; state.readOnly = true; state.fileIndex = new Map(); state.memoryFiles = new Map(); state.imageCache = new Map(); state.imageMap = Object.create(null); for (const [key, value] of Object.entries(payload.imageMap || {})) { try { state.imageMap[normalizeRelativePath(key)] = value; } catch { state.imageMap[String(key).replaceAll("\\", "/")] = value; } state.imageCache.set(key, value); } const manifestPath = payload.manifestPath || "deck.pptd"; state.manifestPath = manifestPath; state.manifestDirectory = dirname(manifestPath); state.manifestContent = payload.manifestContent || ""; state.memoryFiles.set(normalizeRelativePath(manifestPath), state.manifestContent); for (const page of payload.pages || []) { if (!page?.path) continue; state.memoryFiles.set(normalizeRelativePath(page.path), page.content ?? ""); } const title = payload.title || titleFromManifest(state.manifestContent, basename(manifestPath)); setTitle(title); await state.editor.setSlideConfig?.({ editable: true, locale: "zh-CN", theme: "light", slideId: payload.id, }); await state.editor.setPPTD(payload.id || `export-${Date.now()}`, { pptdContent: state.manifestContent, pages: payload.pages || [], pptdPath: manifestPath, basePath: "", isCreate: false, }); await state.editor.setEditable?.(true); window.exportRemote = state.editor; try { window.exportSlideStatus = await state.editor.getSlideStatus?.(); } catch { window.exportSlideStatus = null; } setStatus(`导出模式 · ${title}`); document.documentElement.dataset.deckStatus = "ready"; } async function loadDeckFromIndex(manifestPath, sourceLabel, options = {}) { if (typeof options === "boolean") { options = { readOnly: options, editable: !options }; } const readOnly = Boolean(options.readOnly); const editable = options.editable !== false; if (!state.editor?.setPPTD) throw new Error("编辑器尚未就绪"); const manifestContent = await textFromIndexed(manifestPath); const pagePaths = extractPagePaths(manifestContent); const pages = []; const missing = []; const manifestDirectory = dirname(manifestPath); for (const pagePath of pagePaths.slice(0, 500)) { const indexedPath = joinDeckPath(manifestDirectory, pagePath); try { pages.push({ path: pagePath, content: await textFromIndexed(indexedPath) }); } catch { missing.push(pagePath); } } if (missing.length) throw new Error(`缺少页面:${missing.slice(0, 5).join(", ")}`); const title = titleFromManifest(manifestContent, basename(manifestPath)); state.manifestPath = manifestPath; state.manifestDirectory = manifestDirectory; state.manifestContent = manifestContent; state.readOnly = readOnly; setTitle(title); setStatus(sourceLabel); // isCreate:true leaves the official UI in a "generating / loading" state and // disables export / present until generate_end — use false for local opens. await state.editor.setPPTD(`local-${Date.now()}`, { pptdContent: manifestContent, pages, basePath: "", pptdPath: manifestPath, isCreate: false, }); await state.editor.setEditable?.(editable); await state.editor.setSlideConfig?.({ editable, locale: "zh-CN", theme: "light" }); toast(`已载入「${title}」· ${pages.length} 页`); } async function openDemo() { state.directoryHandle = null; state.fileIndex.clear(); state.imageCache.clear(); state.memoryFiles.clear(); const manifest = JSON.stringify({ version: "v2", title: "NeoDeck Local", size: [960, 540], pages: ["pages/01.page", "pages/02.page"], }); const page1 = JSON.stringify({ pageType: "content", background: { color: "#f7f8fc" }, elements: [ { elementId: "title", elementType: "text", bounds: [80, 160, 800, 120], content: { text: '
完全本地离线
', }, }, { elementId: "sub", elementType: "text", bounds: [80, 300, 720, 60], content: { text: '官方编辑器镜像 · 无 iframe · 无云端接口
', }, }, ], }); const page2 = JSON.stringify({ pageType: "content", background: { color: "#171923" }, elements: [ { elementId: "t", elementType: "text", bounds: [80, 200, 800, 80], content: { text: '打开 PPTD 文件夹开始编辑
', }, }, ], }); state.memoryFiles.set("presentation.pptd", manifest); state.memoryFiles.set("pages/01.page", page1); state.memoryFiles.set("pages/02.page", page2); state.fileIndex.clear(); // synthetic index for demo for (const [path, content] of state.memoryFiles) { state.fileIndex.set(path, { kind: "file", getFile: async () => new File([content], basename(path), { type: "text/plain" }), }); } // Editable in UI; readOnly only means "don't write to disk" (saves stay in memory). await loadDeckFromIndex("presentation.pptd", "内置示例 · 内存", { readOnly: true, editable: true, }); } async function openDirectoryHandle(handle) { setStatus("扫描文件夹…"); state.directoryHandle = handle; state.fileIndex = await indexDirectory(handle); state.imageCache.clear(); state.memoryFiles.clear(); const manifests = [...state.fileIndex.keys()].filter((p) => p.toLowerCase().endsWith(".pptd")); if (!manifests.length) throw new Error("文件夹内没有 .pptd"); const manifestPath = manifests.length === 1 ? manifests[0] : manifests.sort()[0]; await loadDeckFromIndex(manifestPath, `${handle.name} · 可写`, { readOnly: false, editable: true, }); } async function openFallbackFiles(fileList) { setStatus("读取上传…"); state.directoryHandle = null; state.readOnly = true; state.fileIndex = indexFallbackFiles(fileList); state.imageCache.clear(); state.memoryFiles.clear(); const manifests = [...state.fileIndex.keys()].filter((p) => p.toLowerCase().endsWith(".pptd")); if (!manifests.length) throw new Error("没有 .pptd"); await loadDeckFromIndex(manifests.sort()[0], "上传 · 只读", { readOnly: true, editable: true, }); } function wireUi() { $("#nd-open")?.addEventListener("click", async () => { try { if (window.showDirectoryPicker) { const handle = await window.showDirectoryPicker({ mode: "readwrite" }); await openDirectoryHandle(handle); } else { $("#nd-folder")?.click(); } } catch (e) { if (e?.name === "AbortError") return; toast(e.message || String(e), "error"); } }); $("#nd-folder")?.addEventListener("change", async (ev) => { const files = ev.target.files; if (!files?.length) return; try { await openFallbackFiles(files); } catch (e) { toast(e.message || String(e), "error"); } ev.target.value = ""; }); } // Force sdk query params so official app enters ppt-editor external mode (function forceSdkQuery() { const url = new URL(location.href); let changed = false; const params = { sdkMode: "ppt-editor", pptPlatform: "neodeck-local", functional: JSON.stringify({ fullscreen: true, present: true, export: true, close: false, annotation: false, feedback: false, share: false, versionHistory: false, }), sdkSaveMode: "external", sdkImageMode: "external", }; for (const [k, v] of Object.entries(params)) { if (url.searchParams.get(k) !== v) { url.searchParams.set(k, v); changed = true; } } if (changed) history.replaceState(null, "", url); })(); wireUi(); setStatus("等待编辑器…");