正在连接 neo-ppt
-编辑器就绪后会自动载入示例文稿。
-diff --git a/CHANGELOG.md b/CHANGELOG.md index ec0c415..bfb1694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ 本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [1.3.0] - 2026-08-07 + +### 新增 + +- 本地离线 neo-ppt 编辑器镜像(`editor/`):无 iframe、无 `www.kimi.com`,支持打开 PPTD 文件夹编辑与官方 UI 内导出 +- Skill install 同步拷贝完整 `editor/` 与 patched WASM,供已安装的 skill 树离线使用 +- 图片 QA / `--browser` 导出改为打本地编辑器(`?ndExport=1` + `payload.json`),不再依赖 kimi.com + +### 变更 + +- 默认 PPTX 导出统一走本地 patched WASM(单一真源 `editor/neo-ppt/assets/pptd_wasm_bg-DPPWdROu.wasm`) +- 本地编辑器关闭分享 / 云盘 / Google Drive 入口;拦截相关云端与 Google API 请求 +- 移除旧 iframe 壳(`app.js` / `styles.css`)、废弃的 `export_host.html` 与未接线的浏览器顶栏导出胶水 + +### 修复 + +- 修复系统深色主题导致的白屏闪烁(强制 light) +- 修复 demo / 只读打开后无法编辑、演示、导出(`isCreate: false` + 可编辑配置) +- 浏览器会话剥离 `HTTP(S)_PROXY`,避免公司代理 403 本地回环地址 + ## [1.2.0] - 2026-08-06 ### 新增 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index fa82fdc..26dda6b 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -4,6 +4,26 @@ This project follows [Semantic Versioning](https://semver.org/). +## [1.3.0] - 2026-08-07 + +### Added + +- Offline neo-ppt editor mirror (`editor/`): no iframe, no `www.kimi.com`; open a PPTD folder and export from the official UI +- Skill install copies the full `editor/` tree plus patched WASM into installed skill directories +- Image QA / `--browser` export now drives the local editor (`?ndExport=1` + `payload.json`) instead of kimi.com + +### Changed + +- Default PPTX export uses the local patched WASM (single source: `editor/neo-ppt/assets/pptd_wasm_bg-DPPWdROu.wasm`) +- Local editor hides share / cloud / Google Drive entry points and blocks related cloud/Google API calls +- Removed the old iframe shell (`app.js` / `styles.css`), deprecated `export_host.html`, and unused browser top-bar export glue + +### Fixed + +- Avoid dark-theme FOUC by forcing light mode +- Demo / read-only opens can edit, present, and export again (`isCreate: false` + editable config) +- Browser sessions strip `HTTP(S)_PROXY` so corporate proxies do not 403 localhost + ## [1.2.0] - 2026-08-06 ### Added diff --git a/README.md b/README.md index 74add4d..f89d4c0 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,9 @@ npx open-kimi-ppt-skill serve --port 56000 ### Windows:常驻调试浏览器说明 -在 Windows 上导出 PPTX 时,脚本会自动启动一个**常驻的调试浏览器**。这是有意设计,不是异常进程: +仅在使用 **视觉质检**(`export_images.py`)或 **`--browser` PPTX**(本地编辑器 UI)时需要。默认 Node WASM 导出不启动浏览器。 + +在 Windows 上走浏览器路径时,脚本会自动启动一个**常驻的调试浏览器**。这是有意设计,不是异常进程: - **为什么需要**:agent-browser 在 Windows 下无法自行启动 Chrome(Chrome 启动器把进程交接给子进程后立即退出,被误判为崩溃),导出只能改为驱动一个外部启动的浏览器。 - **它是什么**:优先使用本机 Chrome,未安装时回退到 Edge;以 `--remote-debugging-port`(默认 `9337`)和独立配置目录 `%TEMP%\okp-cdp-profile` 启动,窗口定位在屏幕外,不影响日常使用。 @@ -233,13 +235,10 @@ deck/ - CLI 只在 `127.0.0.1` 启动静态文件服务,不会监听局域网地址。 - 浏览器只在用户主动授权后读取完整 PPTD 项目目录。 - 保存回调只允许修改 `.pptd` 和 `.page` 文件,并拒绝绝对路径与 `..` 路径越界。 -- PPTD 内容由本地宿主交给公开的 Kimi 网页编辑器处理;远程图片、字体和编辑器资源仍可能从对应服务器加载。 +- 默认编辑与 PPTX / 图片导出使用本地 neo-ppt 镜像 + patched WASM,**不依赖** `www.kimi.com`;远程图片、字体若被文稿引用仍可能从对应服务器加载。 +- 视觉质检(`export_images.py`)与 `--browser` 路径驱动的是同一套本地编辑器(需本机 Chromium),不再访问公开 Kimi 站。 - 本项目不会提供或注入 Kimi 登录令牌,也不会访问用户的 Kimi 私有文稿。 -## 兼容性说明 - -这是针对当前公开实现的兼容宿主,不是稳定的官方 SDK。Kimi 更新前端资源哈希、PPTD 格式或 iframe/RPC 协议后,本项目可能需要同步升级。成功生成 PPTX 也不代表 PowerPoint、WPS 和 Keynote 对所有动画效果都能完全一致地播放。 - ## 本地开发 ```bash diff --git a/README_EN.md b/README_EN.md index 39261cd..7fe0b85 100644 --- a/README_EN.md +++ b/README_EN.md @@ -123,7 +123,9 @@ Writable folder access requires a Chromium-based browser with the File System Ac ### Windows: a persistent debug browser -On Windows, exporting PPTX automatically starts a **persistent debug browser**. This is by design, not a stray process: +Only needed for **visual QA** (`export_images.py`) or the **`--browser` PPTX fallback**. Default local WASM export does not start a browser. + +On Windows, the browser path automatically starts a **persistent debug browser**. This is by design, not a stray process: - **Why it's needed**: agent-browser cannot launch Chrome by itself on Windows (the Chrome launcher hands off to a child process and exits immediately, which is misread as a crash), so the export drives an externally started browser instead. - **What it is**: your installed Chrome (falling back to Edge), launched with `--remote-debugging-port` (default `9337`) and a dedicated profile at `%TEMP%\okp-cdp-profile`, with the window positioned off-screen so it stays out of the way. @@ -221,13 +223,10 @@ deck/ - The CLI serves static files on `127.0.0.1` only and does not listen on LAN interfaces. - The browser reads a complete PPTD project directory only after explicit user authorization. - Save callbacks may only modify `.pptd` and `.page` files; absolute paths and `..` traversal are rejected. -- The local host passes PPTD content to the public Kimi web editor. Remote images, fonts, and editor resources may still be fetched from their respective servers. +- Default editing and PPTX/image export use the local neo-ppt mirror plus patched WASM and do **not** depend on `www.kimi.com`. Remote images or fonts referenced by the deck may still be fetched from their hosts. +- Visual QA (`export_images.py`) and `--browser` drive that same local editor (Chromium required) and no longer hit the public Kimi site. - This project does not provide or inject Kimi login tokens and does not access private Kimi documents. -## Compatibility - -This is a compatibility host for the current public implementation, not a stable official SDK. Updates to Kimi frontend asset hashes, the PPTD format, or the iframe/RPC protocol may require a corresponding project update. Successfully generating a PPTX does not guarantee identical animation playback in PowerPoint, WPS, and Keynote. - ## Local development ```bash diff --git a/bin/open-kimi-ppt-skill.js b/bin/open-kimi-ppt-skill.js index ebd6149..0d5bb07 100755 --- a/bin/open-kimi-ppt-skill.js +++ b/bin/open-kimi-ppt-skill.js @@ -190,22 +190,28 @@ async function promptInstallTargets() { const choices = knownTargetDirectories(); const selected = new Set([0]); let cursor = 0; - const lines = choices.length + 3; + // title + help + one line per choice (must match what render() writes) + const lines = choices.length + 2; + + const writeLine = (text) => { + output.write(`\u001b[2K${text}\n`); + }; const render = (initial = false) => { if (!initial) { output.write(`\u001b[${lines}A`); } - output.write("Install open-kimi-ppt to which skills directories?\n"); - output.write("↑/↓ move · space select · a all · enter confirm · ctrl+c cancel\n"); + writeLine("Install open-kimi-ppt to which skills directories?"); + writeLine("↑/↓ move · space select · a all · enter confirm · ctrl+c cancel"); for (const [index, choice] of choices.entries()) { const pointer = index === cursor ? "❯" : " "; const mark = selected.has(index) ? "◉" : "◯"; const installed = existsSync(join(choice.directory, SKILL_NAME, "SKILL.md")) ? " (installed)" : ""; - const line = `${pointer}${mark} ${displayPath(choice.directory).padEnd(28)} ${choice.label}${installed}`; - output.write(`\u001b[2K${line}\n`); + writeLine( + `${pointer}${mark} ${displayPath(choice.directory).padEnd(28)} ${choice.label}${installed}`, + ); } }; @@ -311,10 +317,21 @@ async function resolveInstallTargets(options) { return promptInstallTargets(); } +const CANONICAL_WASM = join( + packageRoot, + "editor", + "neo-ppt", + "assets", + "pptd_wasm_bg-DPPWdROu.wasm", +); + function installSkillTo(skillsDirectory) { if (!existsSync(join(sourceDirectory, "SKILL.md"))) { throw new Error(`packaged skill is incomplete: ${sourceDirectory}`); } + if (!existsSync(CANONICAL_WASM)) { + throw new Error(`packaged patched WASM is missing: ${CANONICAL_WASM}`); + } const destination = join(skillsDirectory, SKILL_NAME); const replaced = existsSync(destination); @@ -328,6 +345,19 @@ function installSkillTo(skillsDirectory) { recursive: true, filter: (source) => ![".DS_Store", "_user_meta.json"].includes(basename(source)), }); + // Single canonical WASM in editor/; copy into skill tree for agent scripts. + const stagedWasmDir = join(stagedSkill, "scripts", "local-export"); + mkdirSync(stagedWasmDir, { recursive: true }); + cpSync(CANONICAL_WASM, join(stagedWasmDir, "pptd_wasm_bg.wasm")); + // Offline image/PPTX browser path needs the local neo-ppt mirror. + const packagedEditor = join(packageRoot, "editor"); + if (!existsSync(join(packagedEditor, "index.html"))) { + throw new Error(`packaged editor is missing: ${packagedEditor}`); + } + cpSync(packagedEditor, join(stagedSkill, "editor"), { + recursive: true, + filter: (source) => basename(source) !== ".DS_Store", + }); rmSync(destination, { recursive: true, force: true }); renameSync(stagedSkill, destination); diff --git a/editor/README.md b/editor/README.md new file mode 100644 index 0000000..661dc0c --- /dev/null +++ b/editor/README.md @@ -0,0 +1,38 @@ +# NeoDeck Local(完全离线 · 无 iframe) + +夺舍官方 Kimi neo-ppt 前端镜像,单页运行: + +- 无 iframe、无 www.kimi.com、无云同步 / 分享 / 云盘 / AI +- 本地打开 PPTD 文件夹并自动保存 +- 官方编辑器内「导出」走本地 patched WASM + +## 启动 + +```bash +cd editor +npx serve . +# 或 python3 -m http.server 55173 +``` + +浏览器打开根路径即可(不要用 file://)。仓库内也可用 `npx open-kimi-ppt-skill serve`。 + +## 使用 + +1. **打开 PPTD 文件夹**(Chromium 可读写;其它浏览器可退化为只读上传) +2. 在官方编辑器 UI 中编辑 +3. 顶栏 **导出** → 下载 PPTX / 图片(无分享、无 Google 云盘) + +## 结构 + +``` +editor/ + index.html # 唯一入口(官方 UI + 本地顶栏) + local-bridge.js # 取代 Penpal 父页面的本地桥 + local-shell.css + neo-ppt/ # 官方前端镜像 + assets/ + pptd_wasm_bg-DPPWdROu.wasm # 唯一一份 patched PPTX WASM(真源) + index_bg-*.wasm # resvg(渲染用,勿与上者混淆) +``` + +Agent 侧 `scripts/local-export/` 在 skill install 时会从上述真源拷贝一份 `pptd_wasm_bg.wasm`;仓库内导出脚本直接引用本路径。 diff --git a/editor/app.js b/editor/app.js deleted file mode 100644 index 03672ff..0000000 --- a/editor/app.js +++ /dev/null @@ -1,798 +0,0 @@ -import { - assertWritableChangePath, - basename, - dirname, - extractPagePaths, - joinDeckPath, - normalizeRelativePath, - titleFromManifest, -} from "./lib.js"; - -const EDITOR_ORIGIN = "https://www.kimi.com"; -const PENPAL_MODULE = "https://statics.moonshot.cn/neo-design/assets/penpal-C4NjirZE.js"; -const MAX_TEXT_BYTES = 20 * 1024 * 1024; -const MAX_IMAGE_BYTES = 20 * 1024 * 1024; -const MAX_CHANGE_COUNT = 600; - -const elements = { - frame: document.querySelector("#editor"), - openFolder: document.querySelector("#open-folder"), - openDemo: document.querySelector("#open-demo"), - reload: document.querySelector("#reload-deck"), - documentTitle: document.querySelector("#document-title"), - documentPath: document.querySelector("#document-path"), - connectionStatus: document.querySelector("#connection-status"), - connectionLabel: document.querySelector("#connection-label"), - saveState: document.querySelector("#save-state"), - loadingCard: document.querySelector("#loading-card"), - loadingTitle: document.querySelector("#loading-title"), - loadingMessage: document.querySelector("#loading-message"), - activityPanel: document.querySelector("#activity-panel"), - activityList: document.querySelector("#activity-list"), - toggleActivity: document.querySelector("#toggle-activity"), - closeActivity: document.querySelector("#close-activity"), - openDialog: document.querySelector("#open-dialog"), - closeOpenDialog: document.querySelector("#close-open-dialog"), - uploadDropzone: document.querySelector("#upload-dropzone"), - chooseWritableFolder: document.querySelector("#choose-writable-folder"), - uploadFolder: document.querySelector("#upload-folder"), - deckDialog: document.querySelector("#deck-dialog"), - deckOptions: document.querySelector("#deck-options"), - folderFallback: document.querySelector("#folder-fallback"), - toastRegion: document.querySelector("#toast-region"), -}; - -const state = { - remote: null, - connection: null, - source: "demo", - directoryHandle: null, - fileIndex: new Map(), - memoryFiles: new Map(), - manifestPath: "presentation.pptd", - manifestDirectory: "", - manifestContent: "", - deckTitle: "演示文稿", - lastDeckPayload: null, - saveQueue: Promise.resolve(), - imageCache: new Map(), - dragDepth: 0, - readOnlyFallback: false, -}; - -function setConnection(kind, label) { - elements.connectionStatus.className = `status-pill status-${kind}`; - elements.connectionLabel.textContent = label; - document.documentElement.dataset.connection = kind; -} - -function setSaveState(label, kind = "idle") { - elements.saveState.textContent = label; - elements.saveState.dataset.kind = kind; - document.documentElement.dataset.saveState = kind; -} - -function setLoading(visible, title, message) { - elements.loadingCard.classList.toggle("is-hidden", !visible); - elements.loadingCard.setAttribute("aria-hidden", String(!visible)); - if (title) elements.loadingTitle.textContent = title; - if (message) elements.loadingMessage.textContent = message; -} - -function formatTime(date = new Date()) { - return new Intl.DateTimeFormat("zh-CN", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }).format(date); -} - -function addActivity(message, tone = "info") { - const item = document.createElement("li"); - item.dataset.tone = tone; - item.innerHTML = ``; - item.querySelector("span").textContent = message; - elements.activityList.prepend(item); - while (elements.activityList.children.length > 40) { - elements.activityList.lastElementChild.remove(); - } -} - -function toast(message, tone = "info") { - const item = document.createElement("div"); - item.className = `toast toast-${tone}`; - item.textContent = message; - elements.toastRegion.append(item); - requestAnimationFrame(() => item.classList.add("is-visible")); - setTimeout(() => { - item.classList.remove("is-visible"); - setTimeout(() => item.remove(), 220); - }, 3200); -} - -function editorQuery() { - return new URLSearchParams({ - 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", - }); -} - -function demoDeck() { - const manifest = JSON.stringify({ - version: "v2", - title: "NeoDeck Local", - size: [960, 540], - pages: ["pages/01.page", "pages/02.page"], - }); - const pages = [ - { - path: "pages/01.page", - content: JSON.stringify({ - pageType: "content", - background: { color: "#f7f8fc" }, - elements: [ - { - elementId: "eyebrow", - elementType: "text", - bounds: [92, 96, 776, 40], - content: { - text: '
NEODECK LOCAL
', - }, - }, - { - elementId: "title", - elementType: "text", - bounds: [92, 155, 776, 142], - content: { - text: '直接打开和保存
本地 PPTD
第三方宿主 · 本地文件 · Kimi neo-ppt 编辑内核
', - }, - }, - ], - animations: [ - { - elementId: "title", - effect: "fade-in", - trigger: "onClick", - direction: "up", - easing: "ease-in-out", - durationMs: 700, - }, - ], - }), - }, - { - path: "pages/02.page", - content: JSON.stringify({ - pageType: "content", - background: { color: "#171923" }, - elements: [ - { - elementId: "second-title", - elementType: "text", - bounds: [100, 120, 760, 96], - content: { - text: '文件系统桥接已经就绪
', - }, - }, - { - elementId: "steps", - elementType: "text", - bounds: [102, 250, 730, 165], - content: { - text: '① 选择完整的 PPTD 项目文件夹
② 编辑器自动读取清单、页面与素材
③ 自动保存变更到原目录
', - }, - }, - ], - }), - }, - ]; - return { - id: "neodeck-demo", - title: "NeoDeck Local", - manifestPath: "presentation.pptd", - manifestContent: manifest, - pages, - basePath: "", - isCreate: true, - }; -} - -function requireRemote() { - if (!state.remote) throw new Error("编辑器还没有连接完成"); - return state.remote; -} - -async function setDeck(payload, sourceLabel) { - const remote = requireRemote(); - setLoading(true, "正在载入 PPTD", sourceLabel); - state.lastDeckPayload = payload; - state.manifestContent = payload.manifestContent; - state.manifestPath = payload.manifestPath; - state.manifestDirectory = dirname(payload.manifestPath); - state.deckTitle = payload.title; - elements.documentTitle.textContent = payload.title; - elements.documentPath.textContent = sourceLabel; - elements.reload.disabled = false; - - await remote.setPPTD(payload.id, { - pptdContent: payload.manifestContent, - pages: payload.pages, - basePath: payload.basePath, - pptdPath: payload.manifestPath, - isCreate: true, - }); - await remote.setEditable(true); - const status = await remote.getSlideStatus(); - setLoading(false); - addActivity(`已载入「${payload.title}」,共 ${payload.pages.length} 页`, "success"); - document.documentElement.dataset.deckStatus = "ready"; - window.dispatchEvent(new CustomEvent("neodeck:ready", { detail: status })); -} - -async function openDemo() { - state.source = "demo"; - state.directoryHandle = null; - state.fileIndex.clear(); - state.imageCache.clear(); - state.memoryFiles.clear(); - state.readOnlyFallback = false; - const payload = demoDeck(); - state.memoryFiles.set(payload.manifestPath, payload.manifestContent); - for (const page of payload.pages) state.memoryFiles.set(page.path, page.content); - await setDeck(payload, "内置示例 · 修改保存在本页内存"); - setSaveState("示例 · 内存保存", "memory"); -} - -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 chooseManifest(paths) { - if (paths.length === 1) return paths[0]; - elements.deckOptions.replaceChildren(); - const choice = new Promise((resolve) => { - for (const path of paths) { - const button = document.createElement("button"); - button.className = "deck-option"; - button.type = "button"; - button.innerHTML = `›`; - button.querySelector("strong").textContent = basename(path); - button.querySelector("small").textContent = path; - button.addEventListener("click", () => { - elements.deckDialog.close(); - resolve(path); - }); - elements.deckOptions.append(button); - } - elements.deckDialog.addEventListener( - "close", - () => { - if (elements.deckDialog.returnValue === "cancel") resolve(null); - }, - { once: true }, - ); - }); - elements.deckDialog.showModal(); - return choice; -} - -async function textFromIndexedFile(path) { - const entry = state.fileIndex.get(normalizeRelativePath(path)); - if (!entry) throw new Error(`找不到文件:${path}`); - const file = await entry.getFile(); - if (file.size > MAX_TEXT_BYTES) throw new Error(`文件超过 20 MiB:${path}`); - return file.text(); -} - -async function loadManifest(manifestPath, sourceLabel) { - const manifestContent = await textFromIndexedFile(manifestPath); - const pagePaths = extractPagePaths(manifestContent); - const manifestDirectory = dirname(manifestPath); - const pages = []; - const missing = []; - for (const pagePath of pagePaths.slice(0, 500)) { - const indexedPath = joinDeckPath(manifestDirectory, pagePath); - try { - pages.push({ path: pagePath, content: await textFromIndexedFile(indexedPath) }); - } catch { - missing.push(pagePath); - } - } - if (pages.length === 0) throw new Error(".pptd 清单引用的页面均无法读取"); - if (missing.length) toast(`跳过 ${missing.length} 个无法读取的页面`, "warning"); - - const fallbackTitle = basename(manifestPath).replace(/\.pptd$/i, ""); - const title = titleFromManifest(manifestContent, fallbackTitle); - await setDeck( - { - id: fallbackTitle, - title, - manifestPath, - manifestContent, - pages, - basePath: manifestDirectory ? `${manifestDirectory}/` : "", - isCreate: true, - }, - sourceLabel, - ); - setSaveState(state.readOnlyFallback ? "只读打开" : "自动保存已开启", state.readOnlyFallback ? "readonly" : "saved"); -} - -async function openDirectoryHandle(directoryHandle, label = directoryHandle.name) { - setLoading(true, "正在扫描文件夹", label); - let permission = await directoryHandle.queryPermission?.({ mode: "readwrite" }); - if (permission !== "granted") permission = await directoryHandle.requestPermission?.({ mode: "readwrite" }); - if (permission !== "granted") throw new Error("没有获得文件夹读写权限"); - - state.source = "directory"; - state.directoryHandle = directoryHandle; - state.readOnlyFallback = false; - state.fileIndex = await indexDirectory(directoryHandle); - state.imageCache.clear(); - const manifests = [...state.fileIndex.keys()].filter((path) => path.toLowerCase().endsWith(".pptd")); - if (manifests.length === 0) throw new Error("文件夹里没有找到 .pptd 清单文件"); - const manifestPath = await chooseManifest(manifests.sort()); - if (!manifestPath) { - setLoading(false); - return; - } - await loadManifest(manifestPath, `${label}/${manifestPath}`); -} - -async function openFallbackFiles(files) { - state.source = "fallback"; - state.directoryHandle = null; - state.readOnlyFallback = true; - state.fileIndex = indexFallbackFiles(files); - state.imageCache.clear(); - const manifests = [...state.fileIndex.keys()].filter((path) => path.toLowerCase().endsWith(".pptd")); - if (manifests.length === 0) throw new Error("文件夹里没有找到 .pptd 清单文件"); - const manifestPath = await chooseManifest(manifests.sort()); - if (manifestPath) await loadManifest(manifestPath, `${manifestPath} · 只读兼容模式`); -} - -async function openDirectoryHandleReadOnly(directoryHandle, label = directoryHandle.name) { - setLoading(true, "正在扫描文件夹", label); - state.source = "fallback"; - state.directoryHandle = null; - state.readOnlyFallback = true; - state.fileIndex = await indexDirectory(directoryHandle); - state.imageCache.clear(); - const manifests = [...state.fileIndex.keys()].filter((path) => path.toLowerCase().endsWith(".pptd")); - if (manifests.length === 0) throw new Error("文件夹里没有找到 .pptd 清单文件"); - const manifestPath = await chooseManifest(manifests.sort()); - if (manifestPath) await loadManifest(manifestPath, `${label}/${manifestPath} · 拖放只读模式`); -} - -async function pickDirectory() { - try { - if ("showDirectoryPicker" in window) { - const directoryHandle = await window.showDirectoryPicker({ id: "neodeck-project", mode: "readwrite" }); - setOpenDialog(false); - await openDirectoryHandle(directoryHandle); - } else { - elements.folderFallback.click(); - } - } catch (error) { - if (error?.name === "AbortError") return; - handleError(error, "打开文件夹失败"); - } -} - -async function openDroppedFolder(handles) { - const usableHandles = handles.filter(Boolean); - if (usableHandles.length !== 1 || usableHandles[0].kind !== "directory") { - throw new Error("只能拖入一个完整的 PPTD 项目文件夹,不能单独拖入 .pptd 文件"); - } - - const directoryHandle = usableHandles[0]; - try { - setOpenDialog(false); - await openDirectoryHandle(directoryHandle); - } catch (error) { - setOpenDialog(false); - toast("无法以可写模式载入,正在尝试只读打开", "warning"); - await openDirectoryHandleReadOnly(directoryHandle); - } -} - -function resolveIndexedPath(requestedPath) { - if (typeof requestedPath !== "string" || !requestedPath.trim()) return null; - if (/^(?:data:image\/|https?:\/\/|blob:)/i.test(requestedPath)) return requestedPath; - let candidate = requestedPath.replace(/^file:\/\/+/, "").replaceAll("\\", "/"); - try { - candidate = decodeURIComponent(candidate); - } catch { - // Preserve literal percent signs. - } - - const directCandidates = []; - try { - directCandidates.push(normalizeRelativePath(candidate)); - } catch { - const parts = candidate.split("/").filter(Boolean); - for (let index = 0; index < parts.length; index += 1) { - try { - directCandidates.push(normalizeRelativePath(parts.slice(index).join("/"))); - } catch { - // Keep looking for a safe suffix. - } - } - } - try { - directCandidates.push(joinDeckPath(state.manifestDirectory, candidate)); - } catch { - // Absolute paths may still match a safe indexed suffix below. - } - for (const path of directCandidates) if (state.fileIndex.has(path)) return path; - for (const path of state.fileIndex.keys()) { - if (directCandidates.some((candidatePath) => path.endsWith(`/${candidatePath}`) || path === candidatePath)) return path; - } - return null; -} - -function fileToDataUrl(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener("load", () => resolve(String(reader.result))); - reader.addEventListener("error", () => reject(reader.error || new Error("图片读取失败"))); - reader.readAsDataURL(file); - }); -} - -async function resolveImage(requestedPath) { - if (/^(?:data:image\/|https?:\/\/|blob:)/i.test(requestedPath)) return requestedPath; - const path = resolveIndexedPath(requestedPath); - if (!path) return ""; - const entry = state.fileIndex.get(path); - const file = await entry.getFile(); - if (file.size > MAX_IMAGE_BYTES) return ""; - const cacheKey = `${path}:${file.lastModified}:${file.size}`; - if (!state.imageCache.has(cacheKey)) state.imageCache.set(cacheKey, fileToDataUrl(file)); - return state.imageCache.get(cacheKey); -} - -async function getImages(payload = {}) { - const paths = Array.isArray(payload.filePath) ? payload.filePath : []; - const result = await Promise.all(paths.map((path) => resolveImage(path).catch(() => ""))); - const misses = result.filter((value) => !value).length; - if (misses) addActivity(`${paths.length} 张图片中有 ${misses} 张未找到`, "warning"); - return result; -} - -async function getHandleForPath(root, path, create = false) { - const parts = normalizeRelativePath(path).split("/"); - const fileName = parts.pop(); - let directory = root; - for (const part of parts) directory = await directory.getDirectoryHandle(part, { create }); - return { directory, fileName, file: await directory.getFileHandle(fileName, { create }) }; -} - -async function readBackup(path) { - try { - const { file } = await getHandleForPath(state.directoryHandle, path, false); - return { existed: true, bytes: await (await file.getFile()).arrayBuffer() }; - } catch (error) { - if (error?.name === "NotFoundError") return { existed: false, bytes: null }; - throw error; - } -} - -async function writeFile(path, content) { - const { file } = await getHandleForPath(state.directoryHandle, path, true); - const writable = await file.createWritable(); - try { - await writable.write(content); - } finally { - await writable.close(); - } - state.fileIndex.set(path, file); -} - -async function deleteFile(path) { - const parts = normalizeRelativePath(path).split("/"); - const fileName = parts.pop(); - let directory = state.directoryHandle; - for (const part of parts) directory = await directory.getDirectoryHandle(part, { create: false }); - try { - await directory.removeEntry(fileName); - } catch (error) { - if (error?.name !== "NotFoundError") throw error; - } - state.fileIndex.delete(path); -} - -async function rollbackChanges(backups) { - const entries = [...backups.entries()].reverse(); - for (const [path, backup] of entries) { - if (backup.existed) await writeFile(path, backup.bytes); - else await deleteFile(path); - } -} - -async function persistChanges(payload = {}) { - const changes = Array.isArray(payload.changes) ? payload.changes : []; - if (changes.length === 0) return { fileContent: payload.fileContent, lastModifiedTime: Date.now() }; - if (changes.length > MAX_CHANGE_COUNT) throw new Error(`一次保存不能超过 ${MAX_CHANGE_COUNT} 个文件`); - - const normalized = changes.map((change) => { - const path = assertWritableChangePath(state.manifestDirectory, change.path); - const operation = change.operate === "delete" ? "delete" : "put"; - const content = operation === "put" ? String(change.content ?? "") : null; - if (content && new Blob([content]).size > MAX_TEXT_BYTES) throw new Error(`保存内容超过 20 MiB:${path}`); - return { path, operation, content }; - }); - - if (state.source === "demo") { - for (const change of normalized) { - if (change.operation === "delete") state.memoryFiles.delete(change.path); - else state.memoryFiles.set(change.path, change.content); - } - setSaveState("刚刚保存到内存", "memory"); - addActivity(`已在内存保存 ${normalized.length} 项变更`, "success"); - return { fileContent: payload.fileContent, lastModifiedTime: Date.now() }; - } - - if (state.readOnlyFallback || !state.directoryHandle) { - throw new Error("当前是只读兼容模式,请用支持文件夹授权的 Chromium 浏览器打开"); - } - - const permission = await state.directoryHandle.queryPermission({ mode: "readwrite" }); - if (permission !== "granted") throw new Error("项目文件夹的写入权限已经失效"); - - setSaveState("正在保存…", "saving"); - const backups = new Map(); - try { - for (const change of normalized) backups.set(change.path, await readBackup(change.path)); - for (const change of normalized) { - if (change.operation === "delete") await deleteFile(change.path); - else await writeFile(change.path, change.content); - } - } catch (error) { - await rollbackChanges(backups).catch((rollbackError) => { - addActivity(`回滚未完全成功:${rollbackError.message}`, "error"); - }); - throw error; - } - - setSaveState("刚刚已保存", "saved"); - addActivity(`已保存 ${normalized.length} 个文件变更`, "success"); - return { fileContent: payload.fileContent, lastModifiedTime: Date.now() }; -} - -function onSave(payload) { - const operation = state.saveQueue.then(() => persistChanges(payload)); - state.saveQueue = operation.catch(() => undefined); - return operation.catch((error) => { - setSaveState("保存失败", "error"); - addActivity(error.message, "error"); - toast(`保存失败:${error.message}`, "error"); - throw error; - }); -} - -async function toggleFullscreen(value) { - if (value === false || document.fullscreenElement) { - if (document.fullscreenElement) await document.exitFullscreen(); - return false; - } - await document.documentElement.requestFullscreen(); - return true; -} - -function handleError(error, context = "操作失败") { - const message = error?.message || String(error); - setLoading(false); - addActivity(`${context}:${message}`, "error"); - toast(`${context}:${message}`, "error"); - console.error(context, error); -} - -async function connectEditor() { - setConnection("connecting", "连接中"); - addActivity("正在连接公开 neo-ppt 编辑器"); - elements.frame.src = `${EDITOR_ORIGIN}/neo-ppt/?${editorQuery()}`; - - try { - const [{ i: connect, r: WindowMessenger }] = await Promise.all([ - import(PENPAL_MODULE), - new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error("编辑器 iframe 加载超时")), 20_000); - elements.frame.addEventListener( - "load", - () => { - clearTimeout(timeout); - resolve(); - }, - { once: true }, - ); - }), - ]); - - const messenger = new WindowMessenger({ - remoteWindow: elements.frame.contentWindow, - allowedOrigins: [EDITOR_ORIGIN], - }); - state.connection = connect({ - messenger, - methods: { - close() { - toast("当前文稿由 NeoDeck Local 托管"); - }, - reenter() { - return state.lastDeckPayload ? setDeck(state.lastDeckPayload, elements.documentPath.textContent) : undefined; - }, - toggleFullScreen: toggleFullscreen, - showFeedback() {}, - sendPrompt() { - toast("AI 提示词回传尚未接入", "warning"); - }, - showMessage(payload) { - const message = typeof payload === "string" ? payload : payload?.message || payload?.content; - if (message) toast(message); - }, - hideMessage() {}, - onSave, - getImages, - setAnnotationMode() {}, - setAnnotationCurrentPage() {}, - upsertAnnotation() {}, - removeAnnotation() {}, - clearAnnotations() {}, - }, - }); - state.remote = await Promise.race([ - state.connection.promise, - new Promise((_, reject) => setTimeout(() => reject(new Error("RPC 握手超时")), 20_000)), - ]); - await state.remote.setSlideConfig({ editable: true, locale: "zh-CN", theme: "light" }); - setConnection("ready", "已连接"); - addActivity("编辑器 RPC 握手完成", "success"); - await openDemo(); - } catch (error) { - setConnection("error", "连接失败"); - setLoading(true, "无法连接编辑器", "请检查网络,或 Kimi 是否更新了公开前端资源。"); - handleError(error, "编辑器连接失败"); - } -} - -function setActivityPanel(open) { - elements.activityPanel.classList.toggle("is-open", open); - elements.activityPanel.setAttribute("aria-hidden", String(!open)); -} - -function setOpenDialog(open) { - if (open && !elements.openDialog.open) elements.openDialog.showModal(); - if (!open && elements.openDialog.open) elements.openDialog.close(); - elements.openDialog.dataset.dropState = "idle"; - elements.uploadDropzone.classList.remove("is-dragging"); -} - -elements.openFolder.addEventListener("click", () => setOpenDialog(true)); -elements.closeOpenDialog.addEventListener("click", () => setOpenDialog(false)); -elements.chooseWritableFolder.addEventListener("click", pickDirectory); -elements.uploadFolder.addEventListener("click", () => elements.folderFallback.click()); -elements.uploadDropzone.addEventListener("click", () => elements.folderFallback.click()); -elements.openDialog.addEventListener("click", (event) => { - if (event.target === elements.openDialog) setOpenDialog(false); -}); -elements.openDemo.addEventListener("click", () => openDemo().catch((error) => handleError(error, "示例载入失败"))); -elements.reload.addEventListener("click", () => { - if (!state.lastDeckPayload) return; - const reload = state.source === "directory" ? loadManifest(state.manifestPath, elements.documentPath.textContent) : setDeck(state.lastDeckPayload, elements.documentPath.textContent); - reload.catch((error) => handleError(error, "重新载入失败")); -}); -elements.toggleActivity.addEventListener("click", () => setActivityPanel(!elements.activityPanel.classList.contains("is-open"))); -elements.closeActivity.addEventListener("click", () => setActivityPanel(false)); -elements.folderFallback.addEventListener("change", () => { - if (elements.folderFallback.files?.length) { - setOpenDialog(false); - openFallbackFiles(elements.folderFallback.files).catch((error) => handleError(error, "上传文件夹失败")); - } - elements.folderFallback.value = ""; -}); - -window.addEventListener("dragenter", (event) => { - event.preventDefault(); - state.dragDepth += 1; - setOpenDialog(true); - elements.openDialog.dataset.dropState = "active"; - elements.uploadDropzone.classList.add("is-dragging"); -}); -window.addEventListener("dragover", (event) => event.preventDefault()); -window.addEventListener("dragleave", () => { - state.dragDepth = Math.max(0, state.dragDepth - 1); - if (state.dragDepth === 0) { - elements.openDialog.dataset.dropState = "idle"; - elements.uploadDropzone.classList.remove("is-dragging"); - } -}); -window.addEventListener("drop", async (event) => { - event.preventDefault(); - state.dragDepth = 0; - elements.openDialog.dataset.dropState = "loading"; - elements.uploadDropzone.classList.remove("is-dragging"); - try { - const handles = await Promise.all([...event.dataTransfer.items].map((item) => item.getAsFileSystemHandle?.())); - if (handles.some(Boolean)) await openDroppedFolder(handles); - else if (event.dataTransfer.files?.length) { - const files = [...event.dataTransfer.files]; - const roots = new Set(files.map((file) => file.webkitRelativePath?.split("/")[0]).filter(Boolean)); - const isSingleFolder = files.length > 0 && roots.size === 1 && files.every((file) => file.webkitRelativePath?.includes("/")); - if (!isSingleFolder) throw new Error("只能拖入一个完整的 PPTD 项目文件夹,不能单独拖入 .pptd 文件"); - setOpenDialog(false); - await openFallbackFiles(files); - } else throw new Error("请拖入包含 .pptd、pages 和 media 的完整项目文件夹"); - } catch (error) { - handleError(error, "拖放打开失败"); - } finally { - elements.openDialog.dataset.dropState = "idle"; - } -}); - -window.addEventListener("beforeunload", (event) => { - if (elements.saveState.dataset.kind === "saving") { - event.preventDefault(); - event.returnValue = ""; - } -}); - -window.neoDeck = { - openDemo, - getSlideStatus: () => state.remote?.getSlideStatus(), - get status() { - return { - connected: Boolean(state.remote), - source: state.source, - title: state.deckTitle, - manifestPath: state.manifestPath, - indexedFiles: state.fileIndex.size, - saveState: elements.saveState.dataset.kind, - }; - }, -}; - -connectEditor(); diff --git a/editor/index.html b/editor/index.html index 00bc6d1..a480600 100644 --- a/editor/index.html +++ b/editor/index.html @@ -1,150 +1,105 @@ - + - - - - + + +编辑器就绪后会自动载入示例文稿。
-