编辑器桥接与保存事务 深度解析
这一页专门拆 File System Access、PPTD manifest 载入、图片解析、远端 iframe RPC 和保存事务。核心问题是:怎样让远端编辑器能改本地 PPTD,同时不越权、不乱序、不吞错。
导入链与契约
▸安全路径库是所有文件操作的入口
编辑器 app 一开始从 lib/editor-lib.js:1 导入 normalizeRelativePath,并在 lib/editor-app.js:2 到 lib/editor-app.js:8 引入 assertWritableChangePath、dirname、extractPagePaths、joinDeckPath 等函数。这个导入链说明文件安全不是 UI 事件临时判断,而是所有 manifest、page、media、save change 的共同底座。
1import { 2 assertWritableChangePath, 3 basename, 4 dirname, 5 extractPagePaths, 6 joinDeckPath, 7 normalizeRelativePath, 8 titleFromManifest, 9} from "./lib.js";
核心实现,逐段拆
▸文件夹索引与 manifest 选择
lib/editor-app.js:266 递归遍历 directoryHandle 并建立 fileIndex,lib/editor-app.js:293 在多个 .pptd 之间弹出选择。随后 lib/editor-app.js:330 读取 manifest,lib/editor-app.js:332 提取 pages,lib/editor-app.js:336 最多加载 500 页,lib/editor-app.js:347 取标题。这个流程把目录扫描、清单解析和 deck payload 构造清晰分层。
266async function indexDirectory(directoryHandle) { 267 const index = new Map(); 268 async function walk(handle, prefix = "") { 269 for await (const [name, entry] of handle.entries()) { 270 if (name === ".DS_Store") continue; 271 const path = prefix ? `${prefix}/${name}` : name; 272 if (entry.kind === "directory") await walk(entry, path); 273 else index.set(normalizeRelativePath(path), entry); 274 } 275 } 276 await walk(directoryHandle); 277 return index; 278} 279 280function indexFallbackFiles(fileList) { 281 const index = new Map(); 282 const files = [...fileList]; 283 const firstPath = files[0]?.webkitRelativePath || files[0]?.name || ""; 284 const rootName = firstPath.includes("/") ? firstPath.split("/")[0] : ""; 285 for (const file of files) { 286 let path = file.webkitRelativePath || file.name; 287 if (rootName && path.startsWith(`${rootName}/`)) path = path.slice(rootName.length + 1); 288 index.set(normalizeRelativePath(path), { kind: "file", getFile: async () => file }); 289 } 290 return index; 291} 292 293async function chooseManifest(paths) { 294 if (paths.length === 1) return paths[0]; 295 elements.deckOptions.replaceChildren(); 296 const choice = new Promise((resolve) => { 297 for (const path of paths) { 298 const button = document.createElement("button"); 299 button.className = "deck-option"; 300 button.type = "button"; 301 button.innerHTML = `<span class="deck-file-icon">P</span><span><strong></strong><small></small></span><span>›</span>`; 302 button.querySelector("strong").textContent = basename(path); 303 button.querySelector("small").textContent = path; 304 button.addEventListener("click", () => { 305 elements.deckDialog.close(); 306 resolve(path); 307 }); 308 elements.deckOptions.append(button); 309 } 310 elements.deckDialog.addEventListener( 311 "close", 312 () => { 313 if (elements.deckDialog.returnValue === "cancel") resolve(null); 314 }, 315 { once: true }, 316 ); 317 }); 318 elements.deckDialog.showModal(); 319 return choice; 320} 321 322async function textFromIndexedFile(path) { 323 const entry = state.fileIndex.get(normalizeRelativePath(path)); 324 if (!entry) throw new Error(`找不到文件:${path}`); 325 const file = await entry.getFile(); 326 if (file.size > MAX_TEXT_BYTES) throw new Error(`文件超过 20 MiB:${path}`); 327 return file.text(); 328} 329 330async function loadManifest(manifestPath, sourceLabel) { 331 const manifestContent = await textFromIndexedFile(manifestPath); 332 const pagePaths = extractPagePaths(manifestContent); 333 const manifestDirectory = dirname(manifestPath); 334 const pages = []; 335 const missing = []; 336 for (const pagePath of pagePaths.slice(0, 500)) { 337 const indexedPath = joinDeckPath(manifestDirectory, pagePath); 338 try { 339 pages.push({ path: pagePath, content: await textFromIndexedFile(indexedPath) }); 340 } catch { 341 missing.push(pagePath); 342 } 343 } 344 if (pages.length === 0) throw new Error(".pptd 清单引用的页面均无法读取"); 345 if (missing.length) toast(`跳过 ${missing.length} 个无法读取的页面`, "warning"); 346 347 const fallbackTitle = basename(manifestPath).replace(/\.pptd$/i, ""); 348 const title = titleFromManifest(manifestContent, fallbackTitle); 349 await setDeck( 350 { 351 id: fallbackTitle, 352 title, 353 manifestPath, 354 manifestContent, 355 pages, 356 basePath: manifestDirectory ? `${manifestDirectory}/` : "", 357 isCreate: true, 358 }, 359 sourceLabel, 360 );
▸图片解析只返回 data URL 或空字符串
图片资源解析在 lib/editor-app.js:442 先允许 data/http/blob,其他路径走 indexed file。lib/editor-app.js:486 到 lib/editor-app.js:495 读取文件并按 lastModified/size 缓存 data URL;lib/editor-app.js:498 会记录 miss 数量但返回空字符串而非中断整个 deck。这种降级适合编辑器预览,避免一张丢失图片阻断所有页面。
442function resolveIndexedPath(requestedPath) { 443 if (typeof requestedPath !== "string" || !requestedPath.trim()) return null; 444 if (/^(?:data:image\/|https?:\/\/|blob:)/i.test(requestedPath)) return requestedPath; 445 let candidate = requestedPath.replace(/^file:\/\/+/, "").replaceAll("\\", "/"); 446 try { 447 candidate = decodeURIComponent(candidate); 448 } catch { 449 // Preserve literal percent signs. 450 } 451 452 const directCandidates = []; 453 try { 454 directCandidates.push(normalizeRelativePath(candidate)); 455 } catch { 456 const parts = candidate.split("/").filter(Boolean); 457 for (let index = 0; index < parts.length; index += 1) { 458 try { 459 directCandidates.push(normalizeRelativePath(parts.slice(index).join("/"))); 460 } catch { 461 // Keep looking for a safe suffix. 462 } 463 } 464 } 465 try { 466 directCandidates.push(joinDeckPath(state.manifestDirectory, candidate)); 467 } catch { 468 // Absolute paths may still match a safe indexed suffix below. 469 } 470 for (const path of directCandidates) if (state.fileIndex.has(path)) return path; 471 for (const path of state.fileIndex.keys()) { 472 if (directCandidates.some((candidatePath) => path.endsWith(`/${candidatePath}`) || path === candidatePath)) return path; 473 } 474 return null; 475} 476 477function fileToDataUrl(file) { 478 return new Promise((resolve, reject) => { 479 const reader = new FileReader(); 480 reader.addEventListener("load", () => resolve(String(reader.result))); 481 reader.addEventListener("error", () => reject(reader.error || new Error("图片读取失败"))); 482 reader.readAsDataURL(file); 483 }); 484} 485 486async function resolveImage(requestedPath) { 487 if (/^(?:data:image\/|https?:\/\/|blob:)/i.test(requestedPath)) return requestedPath; 488 const path = resolveIndexedPath(requestedPath); 489 if (!path) return ""; 490 const entry = state.fileIndex.get(path); 491 const file = await entry.getFile(); 492 if (file.size > MAX_IMAGE_BYTES) return ""; 493 const cacheKey = `${path}:${file.lastModified}:${file.size}`; 494 if (!state.imageCache.has(cacheKey)) state.imageCache.set(cacheKey, fileToDataUrl(file)); 495 return state.imageCache.get(cacheKey); 496} 497 498async function getImages(payload = {}) { 499 const paths = Array.isArray(payload.filePath) ? payload.filePath : []; 500 const result = await Promise.all(paths.map((path) => resolveImage(path).catch(() => ""))); 501 const misses = result.filter((value) => !value).length; 502 if (misses) addActivity(`${paths.length} 张图片中有 ${misses} 张未找到`, "warning"); 503 return result;
▸保存事务具备备份和回滚
保存路径的强约束来自 lib/editor-app.js:556 的 persistChanges。它在 lib/editor-app.js:559 限制一次最多 600 个变更,在 lib/editor-app.js:561 到 lib/editor-app.js:567 规范化变更,在 lib/editor-app.js:587 建立备份,失败时 lib/editor-app.js:595 回滚。外层 lib/editor-app.js:606 用 promise chain 让 onSave 串行执行,避免远端快速保存造成乱序写。
506async function getHandleForPath(root, path, create = false) { 507 const parts = normalizeRelativePath(path).split("/"); 508 const fileName = parts.pop(); 509 let directory = root; 510 for (const part of parts) directory = await directory.getDirectoryHandle(part, { create }); 511 return { directory, fileName, file: await directory.getFileHandle(fileName, { create }) }; 512} 513 514async function readBackup(path) { 515 try { 516 const { file } = await getHandleForPath(state.directoryHandle, path, false); 517 return { existed: true, bytes: await (await file.getFile()).arrayBuffer() }; 518 } catch (error) { 519 if (error?.name === "NotFoundError") return { existed: false, bytes: null }; 520 throw error; 521 } 522} 523 524async function writeFile(path, content) { 525 const { file } = await getHandleForPath(state.directoryHandle, path, true); 526 const writable = await file.createWritable(); 527 try { 528 await writable.write(content); 529 } finally { 530 await writable.close(); 531 } 532 state.fileIndex.set(path, file); 533} 534 535async function deleteFile(path) { 536 const parts = normalizeRelativePath(path).split("/"); 537 const fileName = parts.pop(); 538 let directory = state.directoryHandle; 539 for (const part of parts) directory = await directory.getDirectoryHandle(part, { create: false }); 540 try { 541 await directory.removeEntry(fileName); 542 } catch (error) { 543 if (error?.name !== "NotFoundError") throw error; 544 } 545 state.fileIndex.delete(path); 546} 547 548async function rollbackChanges(backups) { 549 const entries = [...backups.entries()].reverse(); 550 for (const [path, backup] of entries) { 551 if (backup.existed) await writeFile(path, backup.bytes); 552 else await deleteFile(path); 553 } 554} 555 556async function persistChanges(payload = {}) { 557 const changes = Array.isArray(payload.changes) ? payload.changes : []; 558 if (changes.length === 0) return { fileContent: payload.fileContent, lastModifiedTime: Date.now() }; 559 if (changes.length > MAX_CHANGE_COUNT) throw new Error(`一次保存不能超过 ${MAX_CHANGE_COUNT} 个文件`); 560 561 const normalized = changes.map((change) => { 562 const path = assertWritableChangePath(state.manifestDirectory, change.path); 563 const operation = change.operate === "delete" ? "delete" : "put"; 564 const content = operation === "put" ? String(change.content ?? "") : null; 565 if (content && new Blob([content]).size > MAX_TEXT_BYTES) throw new Error(`保存内容超过 20 MiB:${path}`); 566 return { path, operation, content }; 567 }); 568 569 if (state.source === "demo") { 570 for (const change of normalized) { 571 if (change.operation === "delete") state.memoryFiles.delete(change.path); 572 else state.memoryFiles.set(change.path, change.content); 573 } 574 setSaveState("刚刚保存到内存", "memory"); 575 addActivity(`已在内存保存 ${normalized.length} 项变更`, "success"); 576 return { fileContent: payload.fileContent, lastModifiedTime: Date.now() }; 577 } 578 579 if (state.readOnlyFallback || !state.directoryHandle) { 580 throw new Error("当前是只读兼容模式,请用支持文件夹授权的 Chromium 浏览器打开"); 581 } 582 583 const permission = await state.directoryHandle.queryPermission({ mode: "readwrite" }); 584 if (permission !== "granted") throw new Error("项目文件夹的写入权限已经失效"); 585 586 setSaveState("正在保存…", "saving"); 587 const backups = new Map(); 588 try { 589 for (const change of normalized) backups.set(change.path, await readBackup(change.path)); 590 for (const change of normalized) { 591 if (change.operation === "delete") await deleteFile(change.path); 592 else await writeFile(change.path, change.content); 593 } 594 } catch (error) { 595 await rollbackChanges(backups).catch((rollbackError) => { 596 addActivity(`回滚未完全成功:${rollbackError.message}`, "error"); 597 }); 598 throw error; 599 } 600 601 setSaveState("刚刚已保存", "saved"); 602 addActivity(`已保存 ${normalized.length} 个文件变更`, "success"); 603 return { fileContent: payload.fileContent, lastModifiedTime: Date.now() }; 604}
关键流程
这条流转从 lib/editor-app.js:410 的 pickDirectory 开始,经 lib/editor-app.js:364 申请授权,最后在 lib/editor-app.js:237 调用 remote.setPPTD。远端保存回调不会直接碰文件,而是进入 lib/editor-app.js:606 的 queue,再落到 lib/editor-app.js:556 的事务函数。
边界 / 失败模式
- 路径逃逸由
lib/editor-lib.js:14、lib/editor-lib.js:21和lib/editor-lib.js:25拦截,测试在tests/editor-lib.test.js:28覆盖。 - 非 Chromium 或无写权限场景进入只读 fallback,关键分支在
lib/editor-app.js:385和lib/editor-app.js:397。 - RPC 失败时
lib/editor-app.js:695设置 error 状态,并通过lib/editor-app.js:697给出公开资源可能变更的提示。
关联模块
lib/editor-lib.js:67 与 tests/editor-lib.test.js:21,它们决定 manifest 兼容范围。scripts/export_pptx.py:196,再由浏览器侧写出 PPTX。