diff --git a/docs/SINGLE_FILE_BUNDLER.zh-CN.md b/docs/SINGLE_FILE_BUNDLER.zh-CN.md new file mode 100644 index 00000000..c028213a --- /dev/null +++ b/docs/SINGLE_FILE_BUNDLER.zh-CN.md @@ -0,0 +1,91 @@ +# 单文件 HTML 打包器实现说明 + +## 摘要 + +Slidev HTML 导出支持两种模式,并以 `standalone` 作为默认值: + +1. `standalone`:生成单个 `index-standalone.html`,JS 与 CSS 都内联到 HTML 中,目标是直接双击即可查看。 +2. `server-script`:保留 Slidev 默认多文件产物,并生成 `start-server.sh` 与 `start-server.bat`,适合需要本地 HTTP 服务或继续调试资源文件的场景。 + +默认模式面向普通用户,核心目标是减少解释成本;高级模式保留给明确需要更小拆分资源、HTTP 访问或 Web 托管的人。 + +## 技术模型 + +`standalone` 模式的关键问题是:浏览器在 `file://` 协议下会限制 ES module 动态导入。打包器通过构造一个受控的 CommonJS 风格模块系统规避这一点: + +```javascript +const __moduleCode = { + './assets/index.js': '/* transformed module */' +}; + +function __require(modulePath) { + const module = { exports: {} }; + const fn = new Function('__require', 'module', 'exports', 'require', __moduleCode[modulePath]); + fn(__require, module, module.exports, __require); + return module.exports; +} +``` + +主要转换规则: + +- `import('./chunk.js')` 转换为 `Promise.resolve(__require('./chunk.js'))` +- `import.meta.url` 转换为稳定的静态路径 +- `export { X as default }` 同时写入 `module.exports.default` 与 `module.exports` +- CSS、运行时入口与 Slidev 生成的 deck 模块一起内联到最终 HTML + +这套方案的用户价值不是“更像构建工具”,而是让导出结果脱离本地服务也能被检查、发送和归档。 + +## 代码表面 + +核心相关文件: + +- `src/slideExport/singleFileBundler.ts`:单文件打包主逻辑 +- `src/slideExport/slidevExporter.ts`:根据 HTML 模式选择 standalone 或 server-script 路径 +- `src/slideExport/serverScripts.ts`:server-script 模式的启动脚本与说明文件生成 +- `src/slideExport/types.ts`:`htmlMode` 与 HTML 导出结果类型 +- `src/ui/NotemdSettingTab.ts`:设置页中的 HTML 模式选择 +- `src/main.ts`:把当前设置传入导出流程 + +## 用户流程 + +`standalone` 模式: + +```text +选择 Markdown 文件 -> 导出为演示 -> 构建 Slidev -> 生成 index-standalone.html -> 双击查看 +``` + +`server-script` 模式: + +```text +选择 Markdown 文件 -> 导出为演示 -> 构建 Slidev -> 生成 index.html/assets/start-server.* -> 运行本地服务 -> 浏览器打开 localhost +``` + +## 当前 UI 行为 + +侧边栏的“导出”区现在提供格式选择与演示导出控制: + +- 默认只显示“一次性导出”。 +- “导出前先输出大纲”默认关闭。 +- 打开该开关后,导出动作切换为“先输出大纲”和“基于大纲继续导出”两步。 +- 环境检测结果以内联面板显示,并给出缺失工具的命令与官网链接。 +- 导出进度写入现有进度区、API 活动与日志输出,不再依赖阻塞式弹窗。 + +## 验证重点 + +维护者验收不应只看 Slidev CLI 是否构建成功,还要确认: + +1. 最终 HTML 的实际模式是 `standalone`,而不是兼容 fallback。 +2. `index-standalone.html` 可以被浏览器打开并渲染内容。 +3. Deck 生成前加载了完整 Slidev skill 目录,而不是只读取 `SKILL.md`。 +4. 真实 `architecture.zh-CN.md` 导出没有可见溢出、空白 slide 或旧资源污染。 +5. 生成产物留在本地可检查,但除非任务明确要求,不提交一次性导出文件。 + +推荐命令: + +```bash +npm run verify:slidev-export -- --format html --html-mode standalone --require-native-standalone --source architecture.zh-CN.md --json +``` + +## 取舍 + +`standalone` 牺牲了部分资源拆分与缓存收益,换来最强的分发与检查便利。`server-script` 更接近传统前端构建输出,但要求用户理解本地服务。对 Obsidian 插件里的“导出为演示”来说,默认值应优先服务非命令行用户;需要 HTTP 服务的人仍可在设置中切换。 diff --git a/docs/SLIDEV_HTML_FIX.zh-CN.md b/docs/SLIDEV_HTML_FIX.zh-CN.md new file mode 100644 index 00000000..387b81f0 --- /dev/null +++ b/docs/SLIDEV_HTML_FIX.zh-CN.md @@ -0,0 +1,102 @@ +# Slidev HTML 导出验证说明 + +本文记录 NoteMD Slidev 工作流中 HTML 导出的当前行为与验收方式。 + +## 当前事实 + +HTML 导出支持两个模式: + +1. `standalone`:生成 `index-standalone.html`。这是默认路径,也是维护者检查本地可分发 HTML 时的首选路径。 +2. `server-script`:生成普通 Slidev SPA、`assets/` 目录和本地启动脚本。该模式用于兼容需要 HTTP 服务的浏览器环境。 + +默认维护者烟测命令为: + +```bash +npm run verify:slidev-export +``` + +当验收目标明确是原生 standalone HTML 时,应使用严格门禁: + +```bash +npm run verify:slidev-export -- --format html --html-mode standalone --require-native-standalone --source architecture.zh-CN.md --json +``` + +严格 standalone 门禁要求 `htmlExport.actualMode = "standalone"` 且 `standaloneGate.passed = true`。兼容 HTML 验证通过并不等于 standalone 一定通过,因为它可能走了 `server-script-fallback`。 + +## 验证覆盖 + +维护者命令覆盖以下内容: + +1. 从 `docs/architecture.zh-CN.md` 准备 Slidev source deck。 +2. 加载完整 Slidev skill 目录,包括 `references/*.md`。 +3. 本地 Slidev fork 存在时优先使用该 fork。 +4. 构建前重建 HTML 输出目录,避免旧资源污染结果。 +5. 生成 deck 不包含历史产物中的过期文本。 +6. 生成 deck 不选择未安装主题。 +7. Playwright 打开最终 HTML,并默认审计全 deck。 +8. 严格 standalone 模式会在 fallback 时失败,而不是把兼容通过当作 standalone 通过。 +9. 生成的检查产物不会被 `.gitignore` 隐藏到无法人工检查。 + +## 手动验证 + +运行: + +```bash +npm run verify:slidev-export +``` + +典型报告字段应类似: + +```json +{ + "ok": true, + "htmlExport": { + "actualMode": "standalone", + "requiresLocalServer": false + }, + "standaloneGate": { + "passed": true + }, + "slideSource": { + "skillReferenceCount": 52 + }, + "ignoredOutputs": [] +} +``` + +`skillReferenceCount` 会随本地 Slidev skill 更新而变化,但在 skill 目录存在时不应降为 `0`。 + +## Server-Script 兼容路径 + +验证 server-script 模式: + +```bash +npm run verify:slidev-export -- --html-mode server-script --no-playwright +``` + +预期输出包含: + +```text +index.html +assets/ +start-server.sh +start-server.bat +README.md +``` + +人工测试浏览器行为时,应进入输出目录并运行生成的启动脚本。 + +## UI 与用户反馈 + +侧边栏中的“检测演示导出环境”不应弹出阻塞窗口。检测结果以内联面板显示: + +- 已安装工具显示版本或可用状态。 +- 缺失工具显示错误、安装命令和官网链接。 +- `Slidev CLI` 与 `Playwright Chromium` 可从面板触发自动安装。 +- `Node.js` 与 `ffmpeg` 给出手动安装指引。 + +导出任务进度写入侧边栏底部的进度区、API 活动和日志输出,让用户在任务运行时仍能继续查看上下文。 + +## 输出策略 + +`docs/export/` 下的生成文件是本地检查产物。它们应在测试期间保留给维护者检查,但除非任务明确要求提交生成内容,不应把一次性导出产物加入 main。 diff --git a/docs/SLIDEV_SOLUTION.zh-CN.md b/docs/SLIDEV_SOLUTION.zh-CN.md new file mode 100644 index 00000000..843eda55 --- /dev/null +++ b/docs/SLIDEV_SOLUTION.zh-CN.md @@ -0,0 +1,182 @@ +# Slidev 导出方案总结 + +本文记录 NoteMD 当前 Slidev 导出的真实架构与验收标准。 + +## 当前导出模型 + +NoteMD 不再把直接运行 `slidev build` 视为 UI 导出按钮已经可用的充分证据。维护中的导出流程是: + +1. 如果当前笔记不是 Slidev deck,先准备为可导出的 Slidev source deck。 +2. 如果当前文件已经是 Slidev deck,先复制到隔离的 prepared workspace,再进行审计和导出,避免修改源文件。 +3. 加载完整 Slidev skill 目录,包括 `SKILL.md` 与 `references/*.md`。 +4. Deck 生成后应用展示 guardrails,避免大 Mermaid、表格、代码块或密集文本直接挤进单页。 +5. 本地 Slidev fork 存在时优先使用本地 fork。 +6. 每次构建前重建输出目录,避免旧 chunk 或旧 deck 污染结果。 +7. HTML 默认尝试原生 standalone;当 sanity check 发现 loader binding 缺失时,才进入 server-script 兼容 fallback。 +8. Playwright 默认审计完整 deck,而不是只抽样几页。 +9. 产品导出路径和维护者 verifier 共享 `convergeSlidevDeckLayout()` 渲染收敛循环。 + +维护者工作流见: + +```text +docs/maintainer/slidev-export-workflow.md +docs/maintainer/slidev-export-workflow.zh-CN.md +``` + +严格 standalone 验收记录见: + +```text +docs/maintainer/slidev-standalone-acceptance-2026-06-18.md +docs/maintainer/slidev-standalone-acceptance-2026-06-18.zh-CN.md +``` + +## HTML 模式 + +### Standalone HTML + +Standalone 是默认的本地检查与分享路径。预期输出: + +```text +docs/export/-slides/index-standalone.html +``` + +该文件应能直接从文件系统打开。若验收声明是“原生 standalone 已通过”,必须使用严格门禁: + +```bash +node scripts/verify-slidev-export-workflow.cjs --json --format html --html-mode standalone --require-native-standalone --source architecture.zh-CN.md +``` + +严格通过条件包括: + +1. `htmlExport.actualMode = "standalone"` +2. `htmlExport.requiresLocalServer = false` +3. `htmlExport.standaloneAttempt.accepted = true` +4. `htmlExport.standaloneAttempt.loaderGaps = []` +5. `standaloneGate.required = true` +6. `standaloneGate.passed = true` + +### Server-Script HTML + +Server-script 是兼容路径,用于普通 Slidev SPA 或 native standalone sanity check 失败后的 fallback。预期输出: + +```text +docs/export/-slides/index.html +docs/export/-slides/start-server.sh +docs/export/-slides/start-server.bat +docs/export/-slides/README.md +``` + +该模式需要通过本地 HTTP 服务查看。 + +## 当前侧边栏交互 + +导出 UI 现在遵循渐进披露: + +1. 默认只暴露“一次性导出”主动作。 +2. “导出前先输出大纲”开关默认关闭。 +3. 打开开关后,主动作切换为两个有顺序的按钮:“先输出大纲”和“基于大纲继续导出”。 +4. 大纲文件写入 `export/_slidev-outlines/.outline.md`。 +5. 基于大纲继续导出时,流程会读取保存的大纲,并把完整 Slidev skill references 和源笔记一起交给 deck 生成阶段。 +6. 导出格式选择保留在 UI 中,直接写回 `slideExportDefaultFormat`。 + +这不是只换按钮。两步模式对应真实的 outline artifact 与后续 deck generation 路径;默认一次性导出仍走原先的 source preparation、layout convergence 和最终格式导出。 + +## 环境检测体验 + +“检测演示导出环境”应是非阻塞操作: + +- 结果显示在侧边栏导出区内联面板中。 +- 任务状态显示在底部进度区。 +- 详细过程写入日志输出。 +- 涉及 LLM 的步骤继续进入 API 活动区。 +- 缺失工具必须给出命令和官网链接。 + +当前检查项: + +```text +Node.js +Slidev CLI +Playwright Chromium +ffmpeg +``` + +典型安装提示: + +```bash +node --version +corepack enable +pnpm add -D @slidev/cli +npx playwright install chromium +sudo apt install ffmpeg +``` + +## 本地 Fork 解析 + +Slidev 命令解析优先级: + +1. `NOTEMD_SLIDEV_BIN` +2. `SLIDEV_CLI_PATH` +3. `$HOME/slidev/packages/slidev/bin/slidev.mjs` +4. `npx -y @slidev/cli` + +在 Jacob 的工作站上,维护者验证报告应显示本地 fork 路径。 + +## 真实验证命令 + +默认真实源文件: + +```text +docs/architecture.zh-CN.md +``` + +推荐命令: + +```bash +npm run verify:slidev-export +``` + +关键通过条件: + +1. `ok: true` +2. `slideSource.skillReferenceCount > 0` +3. `ignoredOutputs: []` +4. 无旧 deck 文本污染 +5. 无缺失主题标记 +6. Playwright 样本无失败 +7. `layoutAuditSummary.overflowCount = 0` +8. `layoutAuditSummary.unreadableCount = 0` + +## 当前渲染收敛模型 + +渲染反馈循环由三部分组成: + +1. `prepareSlidevExportSource()` 加载完整 skill,并在 prompt 中要求拆分密集内容。 +2. `convergeSlidevDeckLayout()` 构建 HTML、打开浏览器、测量真实 `slidev-page` 与易溢出元素。 +3. patcher 根据渲染证据应用 slide `zoom`、局部 ``、结构化拆分或内容级重写。 + +支持的结构化 patch 范围包括: + +- Mermaid `flowchart` / `graph` / `mindmap` +- Mermaid `sequenceDiagram` +- 简单标题、段落、列表 slide +- Markdown 表格拆行、拆列与 record-list fallback +- 非 Mermaid 代码块分块 +- slot-marked layout 与部分 component-heavy slot zone + +当前仍存在的边界: + +1. 更复杂的自定义 Vue layout 仍可能需要保守 zoom 或人工复查。 +2. Standalone 正确性依赖 post-build sanity detection;fallback 通过不能当作 native standalone 通过。 +3. 全 deck Playwright 审计更正确但更慢,后续应优化收敛效率,而不是削弱审计范围。 +4. Obsidian CLI 可以派发 `notemd:export-slides`,但缺少导出完成握手,所以宿主命令烟测弱于 verifier。 + +## 输出策略 + +`docs/export/` 下的验证产物用于本地检查。提交前应确认没有把一次性导出内容误加入 main: + +```bash +git status --short docs/export +git check-ignore -v docs/export/_slidev-sources/architecture.zh-CN.slidev.md docs/export/architecture.zh-CN-slides/index-standalone.html +``` + +本地验证产物不应被 `.gitignore` 隐藏到无法检查,但也不应在没有明确要求时作为测试文件提交。 diff --git a/docs/STANDALONE_BUNDLE_FIX.zh-CN.md b/docs/STANDALONE_BUNDLE_FIX.zh-CN.md new file mode 100644 index 00000000..b086130d --- /dev/null +++ b/docs/STANDALONE_BUNDLE_FIX.zh-CN.md @@ -0,0 +1,85 @@ +# Standalone HTML Bundle 导出修复说明 + +## 已解决的问题 + +早期 standalone HTML bundle 会显示空 slide,只看到 Vue comment 节点 ``,而不是实际的 Slidev 内容。 + +根因是 ES module 到 CommonJS 的转换没有正确处理 Vue 组件模块里的默认导出: + +```javascript +export { S as default } +``` + +旧转换只生成: + +```javascript +module.exports.default = S; +``` + +但 Vue 的异步组件加载在该场景下还需要 `module.exports` 本身指向默认导出。 + +## 修复方案 + +默认导出现在同时写入两个位置: + +```javascript +module.exports.default = module.exports = S; +``` + +这样既保留 `default` 访问,又兼容 CommonJS loader 直接读取 `module.exports` 的路径。 + +## 转换规则 + +| 原始 ES Module | 转换后的 CommonJS | +| --- | --- | +| `export default X` | `module.exports.default = module.exports = X` | +| `export { X as default }` | `module.exports.default = module.exports = X` | +| `export { X as Y }` | `module.exports.Y = X` | +| `export { X }` | `module.exports.X = X` | +| `export const X = ...` | `const X = module.exports.X = ...` | + +同时仍保留: + +- `import()` 到 `Promise.resolve(__require())` 的转换 +- `import.meta.url` 的静态路径替换 +- 自定义 `__require()` 模块缓存 +- 所有 JS/CSS 内联到最终 HTML + +## 验证方式 + +维护者验证应优先使用真实 workflow: + +```bash +npm run verify:slidev-export -- --format html --html-mode standalone --require-native-standalone --source architecture.zh-CN.md --json +``` + +如果只验证单文件打包器的历史 POC,可运行对应的 bundle 脚本并用浏览器检查: + +```bash +node test-bundle-FIXED.js +``` + +真实验收应确认: + +1. slide DOM 存在; +2. slide 文本可见; +3. Vue 组件不再退化成空 comment; +4. 浏览器控制台没有 loader binding 错误; +5. 输出为 `index-standalone.html`,而不是仅靠 server-script fallback 通过。 + +## 用户影响 + +修复后的 standalone HTML 可以: + +- 直接双击打开; +- 离线查看; +- 作为单文件发送; +- 不要求用户启动本地 HTTP 服务。 + +如果未来 Slidev 生成的 bundle 形态再次变化,strict standalone gate 应先失败并保留 rejected attempt,而不是把坏的 `index-standalone.html` 当作成功产物。 + +## 安全与性能 + +Standalone bundle 是静态 HTML,不启动本地服务,也不需要外部网络。它使用受控构建产物生成 `Function` loader;这不是运行时加载远程代码,但仍应把输入边界限定在本地构建出的 Slidev 资源内。 + +性能目标与普通 Slidev SPA 一致:现代浏览器应在短时间内加载完成,渲染质量由后续 Playwright 全 deck 审计确认。 diff --git a/docs/export/README.zh-CN.md b/docs/export/README.zh-CN.md new file mode 100644 index 00000000..f680b7c2 --- /dev/null +++ b/docs/export/README.zh-CN.md @@ -0,0 +1,140 @@ +# Slidev 导出目录 + +该目录用于存放 NotEMD 插件生成的 Slidev 演示导出结果。 + +## 导出模式 + +### Standalone 模式(默认) + +输出为单个 HTML 文件: + +```text +export/presentation-slides/ +└── index-standalone.html +``` + +适用场景: + +- 需要双击直接查看; +- 需要离线归档; +- 需要把演示作为单文件发送; +- 接收方不熟悉命令行或本地服务。 + +### Server-Script 模式(高级) + +输出为多文件结构和本地启动脚本: + +```text +export/presentation-slides/ +├── index.html +├── assets/ +├── start-server.sh +├── start-server.bat +└── README.md +``` + +适用场景: + +- 需要继续检查或修改构建资源; +- 需要通过 localhost 查看; +- 计划把产物部署到 Web 服务器; +- standalone sanity check 失败后需要兼容 fallback。 + +启动方式: + +```bash +cd export/presentation-slides/ +./start-server.sh +``` + +Windows 下运行: + +```cmd +start-server.bat +``` + +然后访问: + +```text +http://localhost:8765 +``` + +## UI 配置 + +导出格式可在侧边栏“导出”区快速选择,也可在 Obsidian 设置中的 Slide Export 配置项里调整。 + +当前侧边栏行为: + +- 默认只显示“一次性导出”。 +- 打开“导出前先输出大纲”后,显示“先输出大纲”和“基于大纲继续导出”两个顺序动作。 +- 环境检测结果以内联面板展示,包含安装命令和官网链接。 +- 导出进度写入现有进度区、API 活动和日志输出,不使用阻塞弹窗。 + +## 文件结构 + +```text +docs/export/ +├── presentation-1-slides/ +│ └── index-standalone.html +├── presentation-2-slides/ +│ ├── index.html +│ ├── assets/ +│ ├── start-server.sh +│ ├── start-server.bat +│ └── README.md +└── README.md +``` + +## 故障排查 + +### Standalone 模式打开后空白 + +1. 确认文件是 `index-standalone.html`。 +2. 用 Chrome、Firefox 或 Edge 再试一次。 +3. 打开浏览器开发者工具检查 console。 +4. 使用严格 verifier 重新导出: + +```bash +npm run verify:slidev-export -- --format html --html-mode standalone --require-native-standalone --source architecture.zh-CN.md --json +``` + +### Server-Script 模式无法启动 + +1. 检查 Python 或 Node.js 是否可用: + +```bash +python3 --version +node --version +``` + +2. Linux/macOS 下确认脚本可执行: + +```bash +chmod +x start-server.sh +``` + +3. 如果端口 `8765` 被占用,修改脚本或用手动方式启动其他端口。 + +### localhost 页面空白 + +1. 确认服务仍在运行。 +2. 确认访问的是 `http://localhost:8765`。 +3. 清理浏览器缓存或换浏览器。 +4. 查看 console 中是否有资源路径或 ES module 错误。 + +## 安全说明 + +Standalone 模式是静态文件,不需要网络或本地服务。 + +Server-script 模式只绑定本地机器,用户手动启动和停止。完成查看后可以用 `Ctrl+C` 关闭服务。 + +## 相关文档 + +- [STANDALONE_BUNDLE_FIX.md](../STANDALONE_BUNDLE_FIX.md) +- [SINGLE_FILE_BUNDLER.md](../SINGLE_FILE_BUNDLER.md) +- [SLIDEV_SOLUTION.md](../SLIDEV_SOLUTION.md) +- [SLIDEV_HTML_FIX.md](../SLIDEV_HTML_FIX.md) + +## 提交策略 + +该目录中的导出产物主要用于本地检查。除非任务明确要求提交生成结果,不要把一次性测试导出文件提交到 main。 diff --git a/scripts/verify-slidev-export-workflow.cjs b/scripts/verify-slidev-export-workflow.cjs index c5086593..f6ef682c 100644 --- a/scripts/verify-slidev-export-workflow.cjs +++ b/scripts/verify-slidev-export-workflow.cjs @@ -491,7 +491,7 @@ async function runPlaywrightChecks(htmlPath, sampleSlides, writeScreenshots, sli }); const targetUrl = baseUrl ? `${baseUrl}#/${slide}` : `file://${htmlPath}#/${slide}`; - await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 30000 }); + await openSlideForAudit(page, targetUrl); await page.waitForTimeout(1000); const text = await page.locator('body').innerText({ timeout: 5000 }).catch(() => ''); const measurement = await collectRenderedSlideMeasurement(page, slide); @@ -522,6 +522,23 @@ async function runPlaywrightChecks(htmlPath, sampleSlides, writeScreenshots, sli return { checks, layoutAudits }; } +async function openSlideForAudit(page, targetUrl) { + await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + if (typeof page.waitForLoadState === 'function') { + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => undefined); + } + if (typeof page.waitForFunction === 'function') { + await page.waitForFunction( + () => { + const root = document.querySelector('.slidev-page, .slidev-layout, .slidev-slide-content, #app'); + return Boolean(root && (root.textContent || '').trim().length > 0); + }, + null, + { timeout: 15000 } + ).catch(() => undefined); + } +} + function resolveSlidesToAudit(sampleSlides, deckMarkdown, slideExport) { const deckSlideCount = deckMarkdown ? slideExport.countSlideDeckSlides(deckMarkdown) : 0; const allSlides = Array.from({ length: Math.max(deckSlideCount, 1) }, (_, index) => index + 1); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 6c15826b..1623d3d9 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -995,6 +995,44 @@ export const STRINGS_EN = { formatRequiresNotice: '{format} export requires: {missing}', probeBtnName: 'Probe Environment', openOutputBtnName: 'Open Output', + oneShotExportButton: 'One-shot export', + oneShotExportTooltip: 'Generate and export the slide deck in one run.', + outlineBeforeExportToggle: 'Generate outline before export', + outlineBeforeExportTooltip: 'Switch between direct export and the outline-first export sequence.', + generateOutlineButton: 'Generate outline', + generateOutlineTooltip: 'Create a reviewable Slidev outline file before building the deck.', + continueFromOutlineButton: 'Continue from outline', + continueFromOutlineTooltip: 'Use the saved outline to continue Slidev deck generation and export.', + outlineOutputSuccess: 'Slide export outline saved: {path}', + outlineOutputLog: 'Outline file: {path}', + outlineMissingError: 'No Slidev export outline found at {path}. Generate the outline first.', + outlineLoadedLog: 'Loaded Slidev export outline: {path}', + outlinePreparingDeck: 'Preparing Slidev deck from saved outline...', + outlineLlmUnavailable: 'Slidev outline generation provider unavailable: {message}. Using deterministic outline.', + environmentCheckingTitle: 'Checking local export stack', + environmentCheckingDesc: 'Notemd is checking Node.js, Slidev, Playwright Chromium, and ffmpeg without blocking the workspace.', + environmentReportTitle: 'Local export stack', + environmentCapabilitySummary: '{count}/{total} formats available', + environmentCheckComplete: 'Slide export environment check complete', + environmentCheckFailedTitle: 'Environment check failed', + environmentCheckFailed: 'Environment check failed: {message}', + environmentAvailableFormats: 'Available Slidev export formats: {formats}', + environmentMissingTools: 'Missing Slidev export tools: {tools}', + availableShort: 'Available', + unavailableShort: 'Unavailable', + installedStatus: 'Installed', + missingStatus: 'Missing', + noneShort: 'None', + nodeWebsiteLabel: 'Node.js official site', + slidevWebsiteLabel: 'Slidev install guide', + playwrightWebsiteLabel: 'Playwright docs', + ffmpegWebsiteLabel: 'ffmpeg downloads', + installingTool: 'Installing {tool}...', + installComplete: 'Installation finished. Environment refreshed.', + installFailed: 'Installation failed: {message}', + copyInstallCommand: 'Copy command', + copyInstallCommandSuccess: 'Install command copied.', + copyInstallCommandFailed: 'Failed to copy install command.', // Runtime progress messages probingEnvironment: 'Probing environment...', exportingSlides: 'Exporting slides...', diff --git a/src/i18n/locales/zh_cn.ts b/src/i18n/locales/zh_cn.ts index 95d1d550..ee651275 100644 --- a/src/i18n/locales/zh_cn.ts +++ b/src/i18n/locales/zh_cn.ts @@ -986,6 +986,44 @@ export const STRINGS_ZH_CN: DeepPartial = { formatRequiresNotice: '{format} 导出需要:{missing}', probeBtnName: '检测环境', openOutputBtnName: '打开输出', + oneShotExportButton: '一次性导出', + oneShotExportTooltip: '一次性生成并导出演示稿。', + outlineBeforeExportToggle: '导出前先输出大纲', + outlineBeforeExportTooltip: '在直接导出与先输出大纲再继续导出的流程之间快速切换。', + generateOutlineButton: '先输出大纲', + generateOutlineTooltip: '先生成可检查、可编辑的 Slidev 大纲文件。', + continueFromOutlineButton: '基于大纲继续导出', + continueFromOutlineTooltip: '读取已保存的大纲,继续生成 Slidev deck 并导出。', + outlineOutputSuccess: '演示导出大纲已保存:{path}', + outlineOutputLog: '大纲文件:{path}', + outlineMissingError: '未找到演示导出大纲:{path}。请先运行“先输出大纲”。', + outlineLoadedLog: '已读取演示导出大纲:{path}', + outlinePreparingDeck: '正在基于已保存大纲准备 Slidev deck…', + outlineLlmUnavailable: '演示大纲生成提供商不可用:{message}。将使用确定性大纲。', + environmentCheckingTitle: '正在检测本地导出栈', + environmentCheckingDesc: 'Notemd 正在检测 Node.js、Slidev、Playwright Chromium 与 ffmpeg,检测过程不会阻塞工作区。', + environmentReportTitle: '本地导出栈', + environmentCapabilitySummary: '{count}/{total} 种格式可用', + environmentCheckComplete: '演示导出环境检测完成', + environmentCheckFailedTitle: '环境检测失败', + environmentCheckFailed: '环境检测失败:{message}', + environmentAvailableFormats: '可用的 Slidev 导出格式:{formats}', + environmentMissingTools: '缺失的 Slidev 导出工具:{tools}', + availableShort: '可用', + unavailableShort: '不可用', + installedStatus: '已安装', + missingStatus: '缺失', + noneShort: '无', + nodeWebsiteLabel: 'Node.js 官网', + slidevWebsiteLabel: 'Slidev 安装指南', + playwrightWebsiteLabel: 'Playwright 文档', + ffmpegWebsiteLabel: 'ffmpeg 下载', + installingTool: '正在安装 {tool}…', + installComplete: '安装已完成,环境状态已刷新。', + installFailed: '安装失败:{message}', + copyInstallCommand: '复制命令', + copyInstallCommandSuccess: '安装命令已复制。', + copyInstallCommandFailed: '复制安装命令失败。', // 运行时进度消息 probingEnvironment: '正在检测环境…', exportingSlides: '正在导出演示…', diff --git a/src/main.ts b/src/main.ts index f85966a8..674c90c7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -98,6 +98,7 @@ import { runPreviewDiagramCommandWithHost } from './operations/diagramCommandHostAdapter'; import { stopAllServers } from './slideExport/localServer'; +import type { SlideExportConfig, SlidevExportSource } from './slideExport/types'; import { DiagramCommandExecutionHost, runArtifactDiagramExecutionWithHost, @@ -1366,6 +1367,17 @@ export default class NotemdPlugin extends Plugin { } } + async getSidebarReporter(): Promise { + await this.activateView(); + const view = this.app.workspace.getLeavesOfType(NOTEMD_SIDEBAR_VIEW_TYPE)[0]?.view; + if (view instanceof NotemdSidebarView) { + this.app.workspace.revealLeaf(view.leaf); + view.clearDisplay(); + return view; + } + return this.getReporter(); + } + /** Helper to show folder selection modal */ private getFolderPickerOptions(): string[] { const folders = this.app.vault.getAllLoadedFiles() @@ -2793,15 +2805,17 @@ export default class NotemdPlugin extends Plugin { /** Command: Probe Slide Export Environment */ async probeSlideExportEnvironmentCommand(): Promise { - const { EnvironmentProbeModal } = await import('./slideExport'); - new EnvironmentProbeModal(this.app).open(); + await this.activateView(); + const view = this.app.workspace.getLeavesOfType(NOTEMD_SIDEBAR_VIEW_TYPE)[0]?.view; + if (view instanceof NotemdSidebarView) { + await view.runSlideExportEnvironmentProbe(); + return; + } + new Notice(this.getUiStrings().notices.couldNotOpenSidebar); } - /** Command: Export Slides */ - async exportSlidesCommand(file: TFile, reporter?: ProgressReporter): Promise { - const { probeEnvironment, prepareSlidevExportSource, convergeSlidevDeckLayout, exportSlidevPdf, exportSlidevPng, exportVideoMp4 } = await import('./slideExport'); - const uiStrings = this.getUiStrings(); - const config = { + private buildSlideExportConfig(): SlideExportConfig { + return { format: this.settings.slideExportDefaultFormat, withClicks: this.settings.slideExportWithClicks, outputSubfolder: this.settings.slideExportOutputSubfolder, @@ -2811,61 +2825,166 @@ export default class NotemdPlugin extends Plugin { timeoutMs: this.settings.slideExportTimeoutMs, htmlMode: this.settings.slideExportHtmlMode, }; + } - const modal = new ProgressModal(this.app, uiStrings.slideExport.exportingSlides); - const modalReporter = reporter || modal; - const logSlideExportProgress = (phase: string, detail?: string) => { - modalReporter.log(detail ? `${phase}: ${detail}` : phase); - }; - modal.open(); + private createSlidevDeckGenerationProfile(reporter: ProgressReporter) { + const uiStrings = this.getUiStrings(); + try { + const { provider, modelName } = this.getProviderAndModelForTask('generateTitle'); + return { + provider, + modelName, + settings: this.settings, + reporter, + }; + } catch (providerError) { + const message = providerError instanceof Error ? providerError.message : String(providerError); + reporter.log(formatI18n(uiStrings.slideExport.outlineLlmUnavailable, { message })); + return undefined; + } + } + + async generateSlidevExportOutlineCommand(file: TFile, reporter?: ProgressReporter): Promise { + const { generateSlidevExportOutline } = await import('./slideExport'); + const uiStrings = this.getUiStrings(); + const activeReporter = reporter ?? await this.getSidebarReporter(); + const ownsReporter = !reporter; + const label = uiStrings.slideExport.generateOutlineButton; + const config = this.buildSlideExportConfig(); + + if (ownsReporter) { + this.startReporterAction(activeReporter, label); + } try { - modalReporter.log(uiStrings.slideExport.probingEnvironment); + const deckGeneration = this.createSlidevDeckGenerationProfile(activeReporter); + const outlinePath = await generateSlidevExportOutline( + this.app, + file, + config, + { deckGeneration }, + (phase, detail) => activeReporter.log(detail ? `${phase}: ${detail}` : phase) + ); + activeReporter.log(formatI18n(uiStrings.slideExport.outlineOutputLog, { path: outlinePath })); + activeReporter.updateStatus(formatI18n(uiStrings.slideExport.outlineOutputSuccess, { path: outlinePath }), 100); + new Notice(formatI18n(uiStrings.slideExport.outlineOutputSuccess, { path: outlinePath })); + return outlinePath; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const failureNotice = formatI18n(uiStrings.slideExport.exportFailedNotice, { message }); + activeReporter.log(failureNotice); + activeReporter.updateStatus(failureNotice, -1); + new Notice(failureNotice); + console.error('Slide outline export error:', error); + return ''; + } finally { + if (ownsReporter && activeReporter instanceof NotemdSidebarView) { + activeReporter.finishProcessing(); + } + } + } + + /** Command: Export Slides */ + async exportSlidesCommand(file: TFile, reporter?: ProgressReporter): Promise { + const { prepareSlidevExportSource } = await import('./slideExport'); + await this.exportSlidesWithPreparedSource( + file, + this.getUiStrings().slideExport.oneShotExportButton, + reporter, + async (config, activeReporter, logSlideExportProgress) => prepareSlidevExportSource( + this.app, + file, + config, + { deckGeneration: this.createSlidevDeckGenerationProfile(activeReporter) }, + logSlideExportProgress + ) + ); + } + + async exportSlidesFromOutlineCommand(file: TFile, reporter?: ProgressReporter): Promise { + const { getSlidevExportOutlinePath, prepareSlidevExportSourceFromOutline } = await import('./slideExport'); + const uiStrings = this.getUiStrings(); + await this.exportSlidesWithPreparedSource( + file, + uiStrings.slideExport.continueFromOutlineButton, + reporter, + async (config, activeReporter, logSlideExportProgress) => { + const outlinePath = getSlidevExportOutlinePath(file, config); + let outlineMarkdown = ''; + try { + outlineMarkdown = await this.app.vault.adapter.read(outlinePath); + } catch { + throw new Error(formatI18n(uiStrings.slideExport.outlineMissingError, { path: outlinePath })); + } + activeReporter.log(formatI18n(uiStrings.slideExport.outlineLoadedLog, { path: outlinePath })); + activeReporter.log(uiStrings.slideExport.outlinePreparingDeck); + return prepareSlidevExportSourceFromOutline( + this.app, + file, + outlineMarkdown, + config, + { deckGeneration: this.createSlidevDeckGenerationProfile(activeReporter) }, + logSlideExportProgress + ); + } + ); + } + + private async exportSlidesWithPreparedSource( + file: TFile, + label: string, + reporter: ProgressReporter | undefined, + prepareSource: ( + config: SlideExportConfig, + activeReporter: ProgressReporter, + logSlideExportProgress: (phase: string, detail?: string) => void + ) => Promise + ): Promise { + const { probeEnvironment, convergeSlidevDeckLayout, exportSlidevPdf, exportSlidevPng, exportVideoMp4 } = await import('./slideExport'); + const uiStrings = this.getUiStrings(); + const config = this.buildSlideExportConfig(); + const activeReporter = reporter ?? await this.getSidebarReporter(); + const ownsReporter = !reporter; + const logSlideExportProgress = (phase: string, detail?: string) => { + activeReporter.log(detail ? `${phase}: ${detail}` : phase); + }; + + if (ownsReporter) { + this.startReporterAction(activeReporter, label); + } + + try { + activeReporter.updateStatus(uiStrings.slideExport.probingEnvironment, 8); + activeReporter.log(uiStrings.slideExport.probingEnvironment); const envReport = await probeEnvironment(); if (!envReport.capabilities[config.format]) { throw new Error(uiStrings.slideExport.formatNotSupported.replace('{format}', config.format.toUpperCase())); } - let deckGeneration; - try { - const { provider, modelName } = this.getProviderAndModelForTask('generateTitle'); - deckGeneration = { - provider, - modelName, - settings: this.settings, - reporter: modalReporter, - }; - } catch (providerError) { - const message = providerError instanceof Error ? providerError.message : String(providerError); - modalReporter.log(`Slide deck LLM preparation unavailable: ${message}`); - } - - const slideSource = await prepareSlidevExportSource( - this.app, - file, - config, - { deckGeneration }, - logSlideExportProgress - ); + activeReporter.updateStatus(uiStrings.slideExport.exportingSlides, 22); + const slideSource = await prepareSource(config, activeReporter, logSlideExportProgress); + activeReporter.updateStatus(uiStrings.slideExport.exportingSlides, 48); const layoutConvergence = await convergeSlidevDeckLayout( this.app, slideSource, config, logSlideExportProgress ); + activeReporter.updateStatus(uiStrings.slideExport.exportingSlides, 74); if (config.format === 'html') { const outputPath = layoutConvergence.exportPath; - modalReporter.log(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath)); + activeReporter.log(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath)); + activeReporter.updateStatus(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath), 100); new Notice(uiStrings.slideExport.exportComplete); const requiresLocalServer = outputPath.endsWith('/index.html'); if (config.htmlMode === 'server-script' || requiresLocalServer) { if (requiresLocalServer && config.htmlMode !== 'server-script') { - modalReporter.log('Standalone HTML fallback requires a local server; opening compatible HTML export...'); + activeReporter.log('Standalone HTML fallback requires a local server; opening compatible HTML export...'); } - modalReporter.log('Opening in browser...'); + activeReporter.log('Opening in browser...'); const { openHtmlInBrowser } = await import('./slideExport/localServer'); const vaultRoot = (this.app.vault.adapter as any).basePath; await openHtmlInBrowser(outputPath, vaultRoot); @@ -2877,7 +2996,8 @@ export default class NotemdPlugin extends Plugin { config, logSlideExportProgress ); - modalReporter.log(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath)); + activeReporter.log(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath)); + activeReporter.updateStatus(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath), 100); new Notice(uiStrings.slideExport.exportComplete); } else if (config.format === 'png') { const outputPath = await exportSlidevPng( @@ -2886,17 +3006,19 @@ export default class NotemdPlugin extends Plugin { config, logSlideExportProgress ); - modalReporter.log(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath)); + activeReporter.log(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath)); + activeReporter.updateStatus(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath), 100); new Notice(uiStrings.slideExport.exportComplete); } else if (config.format === 'mp4') { - modalReporter.log(uiStrings.slideExport.exportingPngSequence); + activeReporter.log(uiStrings.slideExport.exportingPngSequence); const pngDir = await exportSlidevPng( this.app, slideSource, config, logSlideExportProgress ); - modalReporter.log(uiStrings.slideExport.convertingToVideo); + activeReporter.updateStatus(uiStrings.slideExport.convertingToVideo, 88); + activeReporter.log(uiStrings.slideExport.convertingToVideo); const outputPath = await exportVideoMp4( this.app, pngDir, @@ -2904,18 +3026,20 @@ export default class NotemdPlugin extends Plugin { config, logSlideExportProgress ); - modalReporter.log(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath)); + activeReporter.log(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath)); + activeReporter.updateStatus(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath), 100); new Notice(uiStrings.slideExport.exportComplete); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const failureNotice = formatI18n(uiStrings.slideExport.exportFailedNotice, { message: errorMessage }); - modalReporter.log(failureNotice); + activeReporter.log(failureNotice); + activeReporter.updateStatus(failureNotice, -1); new Notice(failureNotice); console.error('Slide export error:', error); } finally { - if (modal && !reporter) { - modal.close(); + if (ownsReporter && activeReporter instanceof NotemdSidebarView) { + activeReporter.finishProcessing(); } } } diff --git a/src/slideExport/index.ts b/src/slideExport/index.ts index 4d29892c..f83757d9 100644 --- a/src/slideExport/index.ts +++ b/src/slideExport/index.ts @@ -9,7 +9,12 @@ export { type SlideExportFormat, type SlideExportConfig, type SlidevExportSource, type EnvironmentReport, type ProbeResult, type ExportCapabilities, type ExecResult, type ExportProgressCallback, type SlidevHtmlActualMode, type SlidevHtmlExportOutcome, type SlidevHtmlMode, type SlidevStandaloneAttempt } from './types'; export { isDesktopApp, getVaultBasePath, safeRequire, execFileAsync, resolveNpxCommand, getOsPlatform } from './platformUtils'; export { probeEnvironment, probeNode, probeSlidev, probePlaywright, probeFfmpeg } from './environmentProber'; -export { prepareSlidevExportSource } from './slidevSourcePreparer'; +export { + prepareSlidevExportSource, + prepareSlidevExportSourceFromOutline, + generateSlidevExportOutline, + getSlidevExportOutlinePath +} from './slidevSourcePreparer'; export { exportSlidevHtml, exportSlidevHtmlWithOutcome, exportSlidevPdf, exportSlidevPng, autoInstallSlidev, autoInstallPlaywright } from './slidevExporter'; export { convergeSlidevDeckLayout } from './slidevLayoutWorkflow'; export { exportVideoMp4, getFfmpegInstallInstructions } from './videoExporter'; diff --git a/src/slideExport/slidevLayoutWorkflow.ts b/src/slideExport/slidevLayoutWorkflow.ts index a0e79422..465f314e 100644 --- a/src/slideExport/slidevLayoutWorkflow.ts +++ b/src/slideExport/slidevLayoutWorkflow.ts @@ -209,7 +209,7 @@ async function runPlaywrightLayoutChecks( }); const targetUrl = baseUrl ? `${baseUrl}#/${slide}` : `file://${htmlPath}#/${slide}`; - await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 30_000 }); + await openSlideForLayoutAudit(page, targetUrl); await page.waitForTimeout(1_000); const text = await page.locator('body').innerText({ timeout: 5_000 }).catch(() => ''); const measurement = await collectRenderedSlideMeasurement(page, slide); @@ -239,6 +239,23 @@ async function runPlaywrightLayoutChecks( return { checks, layoutAudits }; } +async function openSlideForLayoutAudit(page: any, targetUrl: string): Promise { + await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + if (typeof page.waitForLoadState === 'function') { + await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => undefined); + } + if (typeof page.waitForFunction === 'function') { + await page.waitForFunction( + () => { + const root = document.querySelector('.slidev-page, .slidev-layout, .slidev-slide-content, #app'); + return Boolean(root && (root.textContent || '').trim().length > 0); + }, + null, + { timeout: 15_000 }, + ).catch(() => undefined); + } +} + function resolveSlidesToAudit(sampleSlides: number[] | null | undefined, deckMarkdown: string | null): number[] { const deckSlideCount = deckMarkdown ? countSlideDeckSlides(deckMarkdown) : 0; const allSlides = Array.from({ length: Math.max(deckSlideCount, 1) }, (_, index) => index + 1); diff --git a/src/slideExport/slidevSourcePreparer.ts b/src/slideExport/slidevSourcePreparer.ts index 6e674413..4ae6b529 100644 --- a/src/slideExport/slidevSourcePreparer.ts +++ b/src/slideExport/slidevSourcePreparer.ts @@ -87,6 +87,87 @@ export async function prepareSlidevExportSource( }; } +export async function generateSlidevExportOutline( + app: App, + sourceFile: TFile, + config: SlideExportConfig, + options: SlidevSourcePreparationOptions = {}, + onProgress?: ExportProgressCallback, +): Promise { + const sourceMarkdown = await app.vault.read(sourceFile); + onProgress?.('slidev-outline', 'Preparing Slidev export outline...'); + + const skillContext = loadSlidevSkillContext(); + if (skillContext.rootPath) { + onProgress?.('slidev-outline', `Loaded Slidev skill from ${skillContext.rootPath} (${skillContext.referenceFiles.length} references).`); + } else { + onProgress?.('slidev-outline', 'Slidev skill directory not found; using deterministic outline.'); + } + + const outlineMarkdown = options.deckGeneration + ? await tryGenerateOutlineWithLlm(sourceFile, sourceMarkdown, skillContext, options.deckGeneration, onProgress) + : null; + const outlinePath = await writeSlidevOutline( + app, + sourceFile, + config, + outlineMarkdown ?? buildDeterministicSlidevOutline(sourceMarkdown, sourceFile.basename) + ); + onProgress?.('slidev-outline', `Prepared Slidev outline: ${outlinePath}`); + return outlinePath; +} + +export async function prepareSlidevExportSourceFromOutline( + app: App, + sourceFile: TFile, + outlineMarkdown: string, + config: SlideExportConfig, + options: SlidevSourcePreparationOptions = {}, + onProgress?: ExportProgressCallback, +): Promise { + const sourceMarkdown = await app.vault.read(sourceFile); + if (isSlidevDeckMarkdown(sourceMarkdown)) { + onProgress?.('slidev-source', 'Current file is already a Slidev deck; outline is not needed for this export.'); + const preparedDeckPath = await writePreparedDeckWorkspace(app, sourceFile, config, decorateComponentHeavySlotZones(sourceMarkdown), onProgress); + return { + inputFilePath: preparedDeckPath, + outputBasename: sourceFile.basename, + sourceLabel: preparedDeckPath, + preparedDeckPath, + }; + } + + onProgress?.('slidev-source', 'Preparing Slidev deck from saved outline...'); + const skillContext = loadSlidevSkillContext(); + if (skillContext.rootPath) { + onProgress?.('slidev-source', `Loaded Slidev skill from ${skillContext.rootPath} (${skillContext.referenceFiles.length} references).`); + } else { + onProgress?.('slidev-source', 'Slidev skill directory not found; using deterministic preparation.'); + } + const generatedDeck = options.deckGeneration + ? await tryGenerateDeckFromOutlineWithLlm(sourceFile, sourceMarkdown, outlineMarkdown, skillContext, options.deckGeneration, onProgress) + : null; + const deckMarkdown = applySlidevPresentationGuardrails( + generatedDeck ?? buildDeterministicSlidevDeck(sourceMarkdown, sourceFile.basename), + config.slidevTheme || 'default', + ); + const preparedDeckPath = await writePreparedDeck(app, sourceFile, config, decorateComponentHeavySlotZones(deckMarkdown)); + + onProgress?.('slidev-source', `Prepared Slidev deck: ${preparedDeckPath}`); + return { + inputFilePath: preparedDeckPath, + outputBasename: sourceFile.basename, + sourceLabel: preparedDeckPath, + preparedDeckPath, + skillRootPath: skillContext.rootPath ?? undefined, + skillReferencePaths: skillContext.referenceFiles.map(reference => reference.relativePath), + }; +} + +export function getSlidevExportOutlinePath(sourceFile: TFile, config: SlideExportConfig): string { + return normalizeVaultPath(`${config.outputSubfolder}/_slidev-outlines/${sourceFile.basename}.outline.md`); +} + export function isSlidevDeckMarkdown(markdown: string): boolean { const slideSeparators = findSlideSeparators(markdown); if (slideSeparators.length > 0) { @@ -149,6 +230,46 @@ export function buildDeterministicSlidevDeck(markdown: string, fallbackTitle: st + '\n'; } +export function buildDeterministicSlidevOutline(markdown: string, fallbackTitle: string): string { + const title = extractTitle(markdown) || fallbackTitle; + const segments = splitMarkdownIntoSegments(markdown).filter(segment => segment.headingLevel > 1); + const lines = [ + `# Slidev Export Outline: ${title}`, + '', + '## Deck intent', + `- Title: ${title}`, + '- Output: polished Slidev deck generated from the source note', + '- Layout rule: split dense sections instead of compressing tables, diagrams, or code into one slide', + '- Safety rule: use per-slide zoom or structural splits when content risks clipping on a 16:9 canvas', + '', + '## Slide sequence', + '', + '1. Cover', + ` - Heading: ${title}`, + ' - Layout: cover or center', + ]; + + let step = 2; + for (const segment of segments) { + lines.push( + `${step}. ${segment.headingText}`, + ` - Source heading: ${'#'.repeat(segment.headingLevel)} ${segment.headingText}`, + ` - Density: ${describeSegmentDensity(segment.body)}`, + ` - Handling: ${describeSegmentHandling(segment.body)}`, + ); + step++; + } + + lines.push( + `${step}. Closing`, + ' - Heading: End', + ' - Layout: center', + '' + ); + + return lines.join('\n'); +} + export function applySlidevPresentationGuardrails(deckMarkdown: string, deckTheme = 'default'): string { const separatorLines = findSlideSeparators(deckMarkdown); if (separatorLines.length === 0) { @@ -647,6 +768,77 @@ async function tryGenerateDeckWithLlm( } } +async function tryGenerateDeckFromOutlineWithLlm( + sourceFile: TFile, + sourceMarkdown: string, + outlineMarkdown: string, + skillContext: SlidevSkillContext, + deckGeneration: SlidevDeckGenerationProfile, + onProgress?: ExportProgressCallback, +): Promise { + if (!skillContext.skillText && skillContext.referenceFiles.length === 0) { + onProgress?.('slidev-source', 'Slidev skill context unavailable; using deterministic deck preparation.'); + return null; + } + + onProgress?.('slidev-source', `Generating Slidev deck from saved outline with ${deckGeneration.provider.name}...`); + try { + const prompt = buildSlidevDeckFromOutlinePrompt(sourceFile, sourceMarkdown, outlineMarkdown, skillContext); + const response = await callLLM( + deckGeneration.provider, + prompt.system, + prompt.user, + deckGeneration.settings, + deckGeneration.reporter, + deckGeneration.modelName, + deckGeneration.reporter.abortController?.signal, + ); + const deckMarkdown = extractMarkdownDeck(response); + if (!isSlidevDeckMarkdown(deckMarkdown)) { + onProgress?.('slidev-source', 'LLM response was not a valid Slidev deck; using deterministic deck preparation.'); + return null; + } + return deckMarkdown.trim() + '\n'; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + onProgress?.('slidev-source', `LLM deck preparation from outline failed: ${message}; using deterministic deck preparation.`); + return null; + } +} + +async function tryGenerateOutlineWithLlm( + sourceFile: TFile, + sourceMarkdown: string, + skillContext: SlidevSkillContext, + deckGeneration: SlidevDeckGenerationProfile, + onProgress?: ExportProgressCallback, +): Promise { + if (!skillContext.skillText && skillContext.referenceFiles.length === 0) { + onProgress?.('slidev-outline', 'Slidev skill context unavailable; using deterministic outline.'); + return null; + } + + onProgress?.('slidev-outline', `Generating Slidev outline with ${deckGeneration.provider.name} and Slidev skill references...`); + try { + const prompt = buildSlidevOutlinePrompt(sourceFile, sourceMarkdown, skillContext); + const response = await callLLM( + deckGeneration.provider, + prompt.system, + prompt.user, + deckGeneration.settings, + deckGeneration.reporter, + deckGeneration.modelName, + deckGeneration.reporter.abortController?.signal, + ); + const outlineMarkdown = extractMarkdownOutline(response); + return outlineMarkdown.trim().length > 0 ? `${outlineMarkdown.trim()}\n` : null; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + onProgress?.('slidev-outline', `LLM outline generation failed: ${message}; using deterministic outline.`); + return null; + } +} + function buildSlidevDeckPrompt( sourceFile: TFile, sourceMarkdown: string, @@ -690,6 +882,96 @@ function buildSlidevDeckPrompt( return { system, user }; } +function buildSlidevDeckFromOutlinePrompt( + sourceFile: TFile, + sourceMarkdown: string, + outlineMarkdown: string, + skillContext: SlidevSkillContext, +): { system: string; user: string } { + const referenceIndex = skillContext.referenceFiles + .map(reference => `- ${reference.relativePath}`) + .join('\n'); + const referenceText = skillContext.referenceFiles + .map(reference => `## ${reference.relativePath}\n\n${reference.content.trim()}`) + .join('\n\n'); + const sourceTitle = extractTitle(sourceMarkdown) || sourceFile.basename; + + const system = [ + 'You convert long-form Markdown notes into polished Slidev decks.', + 'Use the saved Slidev export outline as the deck plan. Do not ignore it unless it conflicts with source facts.', + 'Use the official Slidev skill instructions and references provided below.', + 'Return only a complete Slidev Markdown deck. Do not wrap it in a code fence.', + 'The deck must start with Slidev headmatter and use --- slide separators.', + 'Preserve fenced code blocks, Mermaid diagrams, tables, and important technical details.', + 'Split dense source sections into multiple readable slides instead of making one overflowing slide.', + 'For large diagrams, tables, or code blocks, use per-slide frontmatter such as zoom: 0.55-0.75 or Transform so nothing is clipped on a 16:9 canvas.', + ].join('\n'); + + const user = [ + '# Slidev Skill', + skillContext.skillText.trim(), + '', + '# Available Slidev Skill References', + referenceIndex, + '', + '# Slidev Skill Reference Contents', + truncateText(referenceText, MAX_LLM_REFERENCE_CHARS), + '', + '# Saved Slidev Export Outline', + outlineMarkdown.trim(), + '', + '# Source Note', + `Path: ${sourceFile.path}`, + `Deck title: ${sourceTitle}`, + '', + sourceMarkdown, + ].join('\n'); + + return { system, user }; +} + +function buildSlidevOutlinePrompt( + sourceFile: TFile, + sourceMarkdown: string, + skillContext: SlidevSkillContext, +): { system: string; user: string } { + const referenceIndex = skillContext.referenceFiles + .map(reference => `- ${reference.relativePath}`) + .join('\n'); + const referenceText = skillContext.referenceFiles + .map(reference => `## ${reference.relativePath}\n\n${reference.content.trim()}`) + .join('\n\n'); + const sourceTitle = extractTitle(sourceMarkdown) || sourceFile.basename; + + const system = [ + 'You create reviewable implementation outlines for Slidev decks.', + 'Use the official Slidev skill instructions and references provided below.', + 'Return only Markdown. Do not wrap the outline in a code fence.', + 'Define the intended slide sequence, each slide purpose, likely layout, source sections, and risks.', + 'Call out diagrams, tables, code blocks, and dense sections that require splitting, zoom, Transform, or alternate layouts.', + 'The outline is a planning artifact for a later Slidev deck generation step, not the final deck.', + ].join('\n'); + + const user = [ + '# Slidev Skill', + skillContext.skillText.trim(), + '', + '# Available Slidev Skill References', + referenceIndex, + '', + '# Slidev Skill Reference Contents', + truncateText(referenceText, MAX_LLM_REFERENCE_CHARS), + '', + '# Source Note', + `Path: ${sourceFile.path}`, + `Deck title: ${sourceTitle}`, + '', + sourceMarkdown, + ].join('\n'); + + return { system, user }; +} + function loadSlidevSkillContext(): SlidevSkillContext { const fs: any = safeRequire('fs'); const path: any = safeRequire('path'); @@ -779,6 +1061,14 @@ function extractMarkdownDeck(response: string): string { return trimmed; } +function extractMarkdownOutline(response: string): string { + const fencedBlocks = [...response.matchAll(/```[^\n]*\n([\s\S]*?)\n```/gi)]; + if (fencedBlocks.length > 0) { + return fencedBlocks[0][1].trim(); + } + return response.trim(); +} + function truncateText(text: string, maxChars: number): string { if (text.length <= maxChars) { return text; @@ -786,6 +1076,14 @@ function truncateText(text: string, maxChars: number): string { return `${text.slice(0, maxChars)}\n\n[Truncated to fit the deck generation prompt budget.]`; } +async function writeSlidevOutline(app: App, sourceFile: TFile, config: SlideExportConfig, outlineMarkdown: string): Promise { + const directoryPath = normalizeVaultPath(`${config.outputSubfolder}/_slidev-outlines`); + const filePath = getSlidevExportOutlinePath(sourceFile, config); + await ensureVaultDirectory(app, directoryPath); + await app.vault.adapter.write(filePath, outlineMarkdown); + return filePath; +} + async function writePreparedDeck(app: App, sourceFile: TFile, config: SlideExportConfig, deckMarkdown: string): Promise { const directoryPath = normalizeVaultPath(`${config.outputSubfolder}/_slidev-sources`); const filePath = normalizeVaultPath(`${directoryPath}/${sourceFile.basename}.slidev.md`); @@ -885,6 +1183,37 @@ function normalizeVaultPath(path: string): string { .replace(/\/$/, ''); } +function describeSegmentDensity(body: string): string { + const lineCount = body.split(/\r?\n/).filter(line => line.trim().length > 0).length; + if (lineCount >= 36) { + return 'high'; + } + if (lineCount >= 16) { + return 'medium'; + } + return 'low'; +} + +function describeSegmentHandling(body: string): string { + const hasMermaid = /```+\s*mermaid/i.test(body); + const hasTable = /^\s*\|.+\|\s*$/m.test(body); + const hasCode = /```+/.test(body); + const handling: string[] = []; + if (hasMermaid) { + handling.push('audit Mermaid fit and apply zoom or split if needed'); + } + if (hasTable) { + handling.push('split or transform wide tables'); + } + if (hasCode) { + handling.push('keep code readable with small focused excerpts'); + } + if (handling.length === 0) { + handling.push('summarize into concise slide body'); + } + return handling.join('; '); +} + function quoteYamlString(value: string): string { return JSON.stringify(value); } diff --git a/src/tests/sidebarButtonTriggerChains.test.ts b/src/tests/sidebarButtonTriggerChains.test.ts index 1ea0684a..887c7f55 100644 --- a/src/tests/sidebarButtonTriggerChains.test.ts +++ b/src/tests/sidebarButtonTriggerChains.test.ts @@ -75,7 +75,8 @@ function createPluginMock() { fixFormulaFormatsCommand: jest.fn().mockResolvedValue(undefined), batchFixFormulaFormatsCommand: jest.fn().mockResolvedValue(undefined), checkAndRemoveDuplicateConceptNotesCommand: jest.fn().mockResolvedValue(undefined), - testLlmConnectionCommand: jest.fn().mockResolvedValue(undefined) + testLlmConnectionCommand: jest.fn().mockResolvedValue(undefined), + exportSlidesCommand: jest.fn().mockResolvedValue(undefined) }; } @@ -132,7 +133,9 @@ describe('NotemdSidebarView button trigger chains', () => { 'batch-fix-formula', 'check-duplicates-current', 'check-remove-duplicate-concepts', - 'test-llm-connection' + 'test-llm-connection', + 'probe-slide-export-env', + 'export-slides' ]; const definedActionIds = SIDEBAR_ACTION_DEFINITIONS.map(def => def.id); expect(new Set(testedActionIds)).toEqual(new Set(definedActionIds)); @@ -325,4 +328,18 @@ describe('NotemdSidebarView button trigger chains', () => { await executeAction('test-llm-connection', reporter); expect(plugin.testLlmConnectionCommand).toHaveBeenCalledWith(reporter); }); + + test('probe-slide-export-env triggers inline sidebar environment probe', async () => { + const probeSpy = jest.spyOn(sidebar, 'runSlideExportEnvironmentProbe').mockResolvedValue(undefined); + + await executeAction('probe-slide-export-env', reporter); + + expect(probeSpy).toHaveBeenCalledTimes(1); + }); + + test('export-slides triggers exportSlidesCommand with active markdown file', async () => { + await executeAction('export-slides', reporter); + + expect(plugin.exportSlidesCommand).toHaveBeenCalledWith(activeMdFile, reporter); + }); }); diff --git a/src/tests/sidebarDomButtonClicks.test.ts b/src/tests/sidebarDomButtonClicks.test.ts index e47b9249..e5c030d4 100644 --- a/src/tests/sidebarDomButtonClicks.test.ts +++ b/src/tests/sidebarDomButtonClicks.test.ts @@ -2,6 +2,11 @@ import { getLanguage, MarkdownView, TFile } from 'obsidian'; import { NotemdSidebarView } from '../ui/NotemdSidebarView'; import { mockApp } from './__mocks__/app'; import { ApiLivenessEvent } from '../types'; +import { probeEnvironment } from '../slideExport'; + +jest.mock('../slideExport', () => ({ + probeEnvironment: jest.fn() +})); type MockPlugin = { app: typeof mockApp; @@ -40,6 +45,9 @@ type MockPlugin = { batchFixFormulaFormatsCommand: jest.Mock, [any]>; checkAndRemoveDuplicateConceptNotesCommand: jest.Mock, [any]>; testLlmConnectionCommand: jest.Mock, [any]>; + generateSlidevExportOutlineCommand: jest.Mock, [any, any]>; + exportSlidesCommand: jest.Mock, [any, any]>; + exportSlidesFromOutlineCommand: jest.Mock, [any, any]>; }; class FakeElement { @@ -53,6 +61,7 @@ class FakeElement { disabled = false; open = false; dataset: Record = {}; + attributes: Record = {}; style: Record = {}; value = ''; type = ''; @@ -60,12 +69,16 @@ class FakeElement { scrollTop = 0; scrollHeight = 100; inputEl: any; + href = ''; + title = ''; - constructor(tag: string, options?: { text?: string; cls?: string; type?: string }) { + constructor(tag: string, options?: { text?: string; cls?: string; type?: string; href?: string; title?: string }) { this.tag = tag; if (options?.text) this.text = options.text; if (options?.cls) this.cls = options.cls.split(' ').filter(Boolean); if (options?.type) this.type = options.type; + if (options?.href) this.href = options.href; + if (options?.title) this.title = options.title; this.inputEl = { setAttrs: jest.fn(), value: '' @@ -78,7 +91,7 @@ class FakeElement { return child; } - createEl(tag: string, options?: { text?: string; cls?: string; type?: string }): FakeElement { + createEl(tag: string, options?: { text?: string; cls?: string; type?: string; href?: string; title?: string }): FakeElement { return this.appendChild(new FakeElement(tag, options)); } @@ -103,10 +116,14 @@ class FakeElement { } setAttr(_name: string, _value: string) { + this.attributes[_name] = _value; + if (_name === 'href') this.href = _value; + if (_name === 'title') this.title = _value; return; } setAttrs(_attrs: Record) { + Object.assign(this.attributes, _attrs); return; } @@ -115,7 +132,8 @@ class FakeElement { } findButton(text: string): FakeElement | null { - if (this.tag === 'button' && this.text === text) { + const content = this.textContent(); + if (this.tag === 'button' && (content === text || content.endsWith(` ${text}`))) { return this; } for (const child of this.children) { @@ -135,6 +153,37 @@ class FakeElement { } return null; } + + findVisibleButton(text: string): FakeElement | null { + const content = this.textContent(); + if ( + this.tag === 'button' + && !this.isHidden() + && (content === text || content.endsWith(` ${text}`)) + ) { + return this; + } + for (const child of this.children) { + const match = child.findVisibleButton(text); + if (match) return match; + } + return null; + } + + isHidden(): boolean { + let current: FakeElement | null = this; + while (current) { + if (current.cls.includes('is-hidden') || current.attributes['aria-hidden'] === 'true') { + return true; + } + current = current.parent; + } + return false; + } + + textContent(): string { + return [this.text, ...this.children.map(child => child.textContent())].filter(Boolean).join(' '); + } } function createPluginMock(): MockPlugin { @@ -177,7 +226,10 @@ function createPluginMock(): MockPlugin { fixFormulaFormatsCommand: jest.fn().mockResolvedValue(undefined), batchFixFormulaFormatsCommand: jest.fn().mockResolvedValue(undefined), checkAndRemoveDuplicateConceptNotesCommand: jest.fn().mockResolvedValue(undefined), - testLlmConnectionCommand: jest.fn().mockResolvedValue(undefined) + testLlmConnectionCommand: jest.fn().mockResolvedValue(undefined), + generateSlidevExportOutlineCommand: jest.fn().mockResolvedValue('export/_slidev-outlines/Active.outline.md'), + exportSlidesCommand: jest.fn().mockResolvedValue(undefined), + exportSlidesFromOutlineCommand: jest.fn().mockResolvedValue(undefined) }; } @@ -202,6 +254,17 @@ function findAllByClass(root: FakeElement, cls: string): FakeElement[] { return matches; } +function collectLinks(root: FakeElement): FakeElement[] { + const matches: FakeElement[] = []; + if (root.tag === 'a') { + matches.push(root); + } + for (const child of root.children) { + matches.push(...collectLinks(child)); + } + return matches; +} + function findApiActivitySection(root: FakeElement, title: string): FakeElement | null { return findAllByClass(root, 'notemd-api-activity-section').find(section => ( section.children.some(child => child.cls.includes('notemd-api-activity-section-title') && child.text === title) @@ -252,6 +315,15 @@ describe('NotemdSidebarView DOM button wiring', () => { (mockApp.vault.read as jest.Mock).mockResolvedValue('duplicate duplicate words'); (mockApp.vault.getAbstractFileByPath as jest.Mock).mockReturnValue(null); (mockApp.workspace.iterateAllLeaves as jest.Mock).mockImplementation(() => {}); + (probeEnvironment as jest.Mock).mockResolvedValue({ + isDesktop: true, + platform: 'linux', + node: { tool: 'node', installed: false, version: null, error: 'node not found in PATH' }, + slidev: { tool: 'slidev', installed: false, version: null, error: 'Not available via npx slidev' }, + playwright: { tool: 'playwright', installed: false, version: null, error: 'Playwright chromium not installed' }, + ffmpeg: { tool: 'ffmpeg', installed: false, version: null, error: 'ffmpeg not found in PATH' }, + capabilities: { html: false, pdf: false, png: false, mp4: false } + }); }); async function clickButton(label: string) { @@ -441,6 +513,89 @@ describe('NotemdSidebarView DOM button wiring', () => { expect(plugin.saveSettings).toHaveBeenCalledTimes(1); }); + test('renders slide export controls as one-shot by default and reveals ordered outline steps on demand', async () => { + await sidebar.onOpen(); + + const outlineToggle = contentContainer.findByClass('notemd-slide-export-outline-toggle'); + const directGroup = contentContainer.findByClass('notemd-slide-export-direct-actions'); + const outlineGroup = contentContainer.findByClass('notemd-slide-export-outline-actions'); + + expect(outlineToggle).not.toBeNull(); + expect(outlineToggle?.tag).toBe('button'); + expect(outlineToggle?.attributes.role).toBe('switch'); + expect(outlineToggle?.attributes['aria-checked']).toBe('false'); + const probeButton = contentContainer.findVisibleButton('Probe slide export env'); + const oneShotButton = contentContainer.findVisibleButton('One-shot export'); + expect(probeButton).not.toBeNull(); + expect(probeButton?.cls).toContain('notemd-slide-export-secondary-button'); + expect(probeButton?.cls).not.toContain('mod-cta'); + expect(oneShotButton).not.toBeNull(); + expect(oneShotButton?.cls).toContain('mod-cta'); + expect(oneShotButton?.cls).toContain('is-primary'); + expect(contentContainer.findVisibleButton('Generate outline')).toBeNull(); + expect(contentContainer.findVisibleButton('Continue from outline')).toBeNull(); + expect(directGroup?.cls).not.toContain('is-hidden'); + expect(outlineGroup?.cls).toContain('is-hidden'); + + await outlineToggle!.onclick?.(); + + expect(outlineToggle?.attributes['aria-checked']).toBe('true'); + expect(outlineToggle?.cls).toContain('is-enabled'); + expect(directGroup?.cls).toContain('is-hidden'); + expect(outlineGroup?.cls).not.toContain('is-hidden'); + expect(contentContainer.findVisibleButton('One-shot export')).toBeNull(); + expect(contentContainer.findVisibleButton('Generate outline')).not.toBeNull(); + expect(contentContainer.findVisibleButton('Continue from outline')).not.toBeNull(); + }); + + test('slide export controls call the concrete one-shot and outline commands with the sidebar reporter', async () => { + await sidebar.onOpen(); + + await contentContainer.findButton('One-shot export')!.onclick?.(); + expect(plugin.exportSlidesCommand).toHaveBeenCalledWith(mdFile, expect.objectContaining({ + log: expect.any(Function), + updateStatus: expect.any(Function) + })); + + const outlineToggle = contentContainer.findByClass('notemd-slide-export-outline-toggle'); + await outlineToggle!.onclick?.(); + + await contentContainer.findButton('Generate outline')!.onclick?.(); + expect(plugin.generateSlidevExportOutlineCommand).toHaveBeenCalledWith(mdFile, expect.objectContaining({ + log: expect.any(Function), + updateStatus: expect.any(Function) + })); + + await contentContainer.findButton('Continue from outline')!.onclick?.(); + expect(plugin.exportSlidesFromOutlineCommand).toHaveBeenCalledWith(mdFile, expect.objectContaining({ + log: expect.any(Function), + updateStatus: expect.any(Function) + })); + }); + + test('slide export environment check renders inline install guidance with commands and official links', async () => { + await sidebar.onOpen(); + + await contentContainer.findButton('Probe slide export env')!.onclick?.(); + + const panel = contentContainer.findByClass('notemd-slide-export-env-panel'); + expect(panel).not.toBeNull(); + expect(panel?.parent?.parent?.open).toBe(true); + expect(panel?.textContent()).toContain('node --version'); + expect(panel?.textContent()).toContain('corepack enable'); + expect(panel?.textContent()).toContain('npx playwright install chromium'); + expect(panel?.textContent()).toContain('sudo apt install ffmpeg'); + expect(panel?.textContent()).toContain('Copy command'); + + const links = collectLinks(panel!); + expect(links.map(link => link.href)).toEqual(expect.arrayContaining([ + 'https://nodejs.org/en/download', + 'https://sli.dev/builtin/cli', + 'https://playwright.dev/docs/intro', + 'https://ffmpeg.org/download.html' + ])); + }); + test('updateStatus swaps between active progress and idle standby states', async () => { await sidebar.onOpen(); diff --git a/src/tests/slidevLayoutWorkflow.test.ts b/src/tests/slidevLayoutWorkflow.test.ts index 74d7f2bd..3d59f7ff 100644 --- a/src/tests/slidevLayoutWorkflow.test.ts +++ b/src/tests/slidevLayoutWorkflow.test.ts @@ -169,6 +169,8 @@ describe('slidevLayoutWorkflow', () => { removeAllListeners: jest.fn(), on: jest.fn(), goto: jest.fn(), + waitForLoadState: jest.fn().mockRejectedValue(new Error('networkidle timeout')), + waitForFunction: jest.fn().mockResolvedValue(undefined), waitForTimeout: jest.fn(), locator: jest.fn(() => ({ innerText: jest.fn().mockResolvedValue('1 / 1'), @@ -239,6 +241,9 @@ describe('slidevLayoutWorkflow', () => { expect(result.htmlExport.actualMode).toBe('server-script-fallback'); expect(result.htmlExportHistory).toHaveLength(2); expect(result.checks).toHaveLength(1); + expect(page.goto).toHaveBeenCalledWith(expect.any(String), { waitUntil: 'domcontentloaded', timeout: 30_000 }); + expect(page.waitForLoadState).toHaveBeenCalledWith('networkidle', { timeout: 10_000 }); + expect(page.waitForFunction).toHaveBeenCalled(); expect(mockStartLocalServer).toHaveBeenCalled(); expect(mockStopLocalServer).toHaveBeenCalled(); expect(process.env.PLAYWRIGHT_BROWSERS_PATH).toBe('/home/user/.cache/ms-playwright'); diff --git a/src/tests/slidevSourcePreparer.test.ts b/src/tests/slidevSourcePreparer.test.ts index ef90ff6d..76d3abf8 100644 --- a/src/tests/slidevSourcePreparer.test.ts +++ b/src/tests/slidevSourcePreparer.test.ts @@ -3,8 +3,11 @@ import * as path from 'path'; import { applySlidevPresentationGuardrails, buildDeterministicSlidevDeck, + buildDeterministicSlidevOutline, + generateSlidevExportOutline, isSlidevDeckMarkdown, prepareSlidevExportSource, + prepareSlidevExportSourceFromOutline, } from '../slideExport/slidevSourcePreparer'; import type { SlideExportConfig } from '../slideExport/types'; import type { TFile } from 'obsidian'; @@ -129,6 +132,30 @@ describe('slidevSourcePreparer', () => { expect(deck).toContain('layout: section'); }); + test('deterministic outline captures dense tables and diagrams before export', () => { + const outline = buildDeterministicSlidevOutline([ + '# Architecture', + '', + '## Runtime', + '', + '```mermaid', + 'flowchart TB', + ' A --> B', + '```', + '', + '## Metrics', + '', + '| Metric | Value |', + '|---|---|', + '| Latency | 120ms |', + ].join('\n'), 'architecture'); + + expect(outline).toContain('# Slidev Export Outline: Architecture'); + expect(outline).toContain('Runtime'); + expect(outline).toContain('audit Mermaid fit'); + expect(outline).toContain('split or transform wide tables'); + }); + test('existing Slidev decks are copied into an isolated prepared export workspace instead of exporting the source file directly', async () => { const markdown = [ '---', @@ -445,6 +472,21 @@ describe('slidevSourcePreparer', () => { ); }); + test('writes a reviewable outline artifact before outline-based export', async () => { + const app = createApp('# Architecture\n\n## Runtime\n\nContent'); + const file = createFile('architecture.zh-CN.md'); + + const outlinePath = await generateSlidevExportOutline(app, file, config, {}, jest.fn()); + + expect(outlinePath).toBe('export/_slidev-outlines/architecture.zh-CN.outline.md'); + expect(app.vault.adapter.mkdir).toHaveBeenCalledWith('export'); + expect(app.vault.adapter.mkdir).toHaveBeenCalledWith('export/_slidev-outlines'); + expect(app.vault.adapter.write).toHaveBeenCalledWith( + 'export/_slidev-outlines/architecture.zh-CN.outline.md', + expect.stringContaining('# Slidev Export Outline: Architecture') + ); + }); + test('loads all Slidev skill references for LLM deck preparation', async () => { const skillRoot = '/skills/slidev'; process.env.NOTEMD_SLIDEV_SKILL_DIR = skillRoot; @@ -599,4 +641,81 @@ describe('slidevSourcePreparer', () => { expect.stringMatching(/^---\ntheme: default/) ); }); + + test('uses a saved outline as deck-generation guidance during export continuation', async () => { + const skillRoot = '/skills/slidev'; + process.env.NOTEMD_SLIDEV_SKILL_DIR = skillRoot; + const files = new Map([ + [`${skillRoot}/SKILL.md`, 'Slidev skill instructions'], + [`${skillRoot}/references/core-syntax.md`, 'Use --- separators.'], + [`${skillRoot}/references/layout-fit.md`, 'Split dense slides and control zoom.'], + ]); + mockSafeRequire.mockImplementation((name: string) => { + if (name === 'path') { + return { + join: (...parts: string[]) => parts.join('/').replace(/\/+/g, '/'), + }; + } + if (name === 'fs') { + return { + existsSync: (path: string) => files.has(path) || path === `${skillRoot}/references`, + readFileSync: (path: string) => files.get(path) || '', + readdirSync: (path: string) => path === `${skillRoot}/references` + ? ['core-syntax.md', 'layout-fit.md'] + : [], + }; + } + return null; + }); + mockCallLLM.mockResolvedValue([ + '---', + 'theme: default', + 'title: Architecture', + '---', + '', + '# Architecture', + '', + '---', + '', + '# Runtime', + ].join('\n')); + const app = createApp('# Architecture\n\n## Runtime\n\nContent'); + const file = createFile('architecture.zh-CN.md'); + const reporter = { + abortController: new AbortController(), + activeTasks: 0, + log: jest.fn(), + updateStatus: jest.fn(), + requestCancel: jest.fn(), + clearDisplay: jest.fn(), + updateActiveTasks: jest.fn(), + get cancelled() { + return false; + }, + }; + + await prepareSlidevExportSourceFromOutline( + app, + file, + '# Saved Outline\n\n1. Cover\n2. Runtime with zoom safety', + config, + { + deckGeneration: { + provider: { name: 'Mock', type: 'openai-compatible', apiKey: 'key', baseUrl: 'https://example.test', models: [] } as any, + modelName: 'mock-model', + settings: {} as any, + reporter, + }, + } + ); + + const userPrompt = mockCallLLM.mock.calls[0][2]; + expect(userPrompt).toContain('# Saved Slidev Export Outline'); + expect(userPrompt).toContain('Runtime with zoom safety'); + expect(userPrompt).toContain('Split dense slides and control zoom.'); + expect(app.vault.adapter.write).toHaveBeenCalledWith( + 'export/_slidev-sources/architecture.zh-CN.slidev.md', + expect.stringMatching(/^---\ntheme: default/) + ); + }); }); diff --git a/src/ui/NotemdSidebarView.ts b/src/ui/NotemdSidebarView.ts index 37c5c9cd..c4a8b8e5 100644 --- a/src/ui/NotemdSidebarView.ts +++ b/src/ui/NotemdSidebarView.ts @@ -3,6 +3,7 @@ import NotemdPlugin from '../main'; import { ApiLivenessEvent, ApiLivenessPhase, NotemdSettings, ProgressReporter } from '../types'; import { NOTEMD_SIDEBAR_ICON, NOTEMD_SIDEBAR_VIEW_TYPE } from '../constants'; import { findDuplicates } from '../fileUtils'; +import { FFMPEG_INSTALL_HINTS, type EnvironmentReport, type ProbeResult } from '../slideExport/types'; import { ActionCategory, CustomWorkflowButton, @@ -112,6 +113,11 @@ export class NotemdSidebarView extends ItemView implements ProgressReporter { private cancelButton: HTMLButtonElement | null = null; private languageSelector: HTMLSelectElement | null = null; private slideExportFormatSelector: HTMLSelectElement | null = null; + private slideExportOutlineToggleButton: HTMLButtonElement | null = null; + private slideExportDirectActionsEl: HTMLElement | null = null; + private slideExportOutlineActionsEl: HTMLElement | null = null; + private slideExportControlsSectionEl: (HTMLElement & { open?: boolean }) | null = null; + private slideExportEnvironmentPanelEl: HTMLElement | null = null; private apiLivenessPhase: ApiLivenessVisualPhase = 'idle'; private apiLivenessTimer: ReturnType | null = null; private apiLivenessRequests = new Map(); @@ -123,6 +129,7 @@ export class NotemdSidebarView extends ItemView implements ProgressReporter { private isProcessing = false; private isCancelled = false; private currentAbortController: AbortController | null = null; + private slideExportOutlineMode = false; activeTasks = 0; private actionButtons = new Map(); @@ -758,6 +765,9 @@ export class NotemdSidebarView extends ItemView implements ProgressReporter { if (this.slideExportFormatSelector) { this.slideExportFormatSelector.disabled = processing; } + if (this.slideExportOutlineToggleButton) { + this.slideExportOutlineToggleButton.disabled = processing; + } if (this.cancelButton) { this.cancelButton.disabled = !processing || this.isCancelled; @@ -1137,7 +1147,7 @@ export class NotemdSidebarView extends ItemView implements ProgressReporter { break; } case 'probe-slide-export-env': { - await this.plugin.probeSlideExportEnvironmentCommand(); + await this.runSlideExportEnvironmentProbe(); break; } case 'export-slides': { @@ -1200,6 +1210,441 @@ export class NotemdSidebarView extends ItemView implements ProgressReporter { this.slideExportFormatSelector = selector; } + private buildSlideExportControls(parent: HTMLElement) { + const i18n = this.getStrings(); + this.slideExportControlsSectionEl = this.resolveContainingDetails(parent); + + const probeButton = this.createSecondarySlideExportActionButton( + parent, + 'notemd-slide-export-probe-button', + getSidebarActionLabel(i18n, 'probe-slide-export-env'), + getSidebarActionTooltip(i18n, 'probe-slide-export-env'), + () => this.runSlideExportEnvironmentProbe() + ); + this.actionButtons.set('probe-slide-export-env', probeButton); + + this.buildSlideExportFormatSelector(parent); + + const toggleButton = parent.createEl('button', { cls: 'notemd-slide-export-outline-toggle' }) as HTMLButtonElement; + toggleButton.type = 'button'; + toggleButton.title = i18n.slideExport.outlineBeforeExportTooltip; + toggleButton.setAttr('role', 'switch'); + toggleButton.onclick = () => { + this.slideExportOutlineMode = !this.slideExportOutlineMode; + this.syncSlideExportOutlineControls(); + }; + toggleButton.createEl('span', { cls: 'notemd-slide-export-outline-toggle-track' }); + toggleButton.createEl('span', { text: i18n.slideExport.outlineBeforeExportToggle, cls: 'notemd-slide-export-outline-toggle-label' }); + this.slideExportOutlineToggleButton = toggleButton; + + this.slideExportDirectActionsEl = parent.createDiv({ cls: 'notemd-slide-export-direct-actions' }); + const directButton = this.createPrimarySlideExportActionButton( + this.slideExportDirectActionsEl, + 'notemd-slide-export-direct-button', + i18n.slideExport.oneShotExportButton, + i18n.slideExport.oneShotExportTooltip, + () => this.runSlideExportFileAction( + i18n.slideExport.oneShotExportButton, + (file, reporter) => this.plugin.exportSlidesCommand(file, reporter) + ) + ); + this.actionButtons.set('slide-export-one-shot', directButton); + + this.slideExportOutlineActionsEl = parent.createDiv({ cls: 'notemd-slide-export-outline-actions is-hidden' }); + const outlineButton = this.createSlideExportStepButton( + this.slideExportOutlineActionsEl, + '1', + i18n.slideExport.generateOutlineButton, + i18n.slideExport.generateOutlineTooltip, + () => this.runSlideExportFileAction( + i18n.slideExport.generateOutlineButton, + (file, reporter) => this.plugin.generateSlidevExportOutlineCommand(file, reporter) + ) + ); + const continueButton = this.createSlideExportStepButton( + this.slideExportOutlineActionsEl, + '2', + i18n.slideExport.continueFromOutlineButton, + i18n.slideExport.continueFromOutlineTooltip, + () => this.runSlideExportFileAction( + i18n.slideExport.continueFromOutlineButton, + (file, reporter) => this.plugin.exportSlidesFromOutlineCommand(file, reporter) + ) + ); + this.actionButtons.set('slide-export-outline-generate', outlineButton); + this.actionButtons.set('slide-export-outline-continue', continueButton); + + this.slideExportEnvironmentPanelEl = parent.createDiv({ cls: 'notemd-slide-export-env-panel is-hidden' }); + this.syncSlideExportOutlineControls(); + } + + private createPrimarySlideExportActionButton( + parent: HTMLElement, + className: string, + label: string, + tooltip: string, + onClick: () => Promise + ): HTMLButtonElement { + return this.appendSlideExportActionButton(parent, [className, 'mod-cta', 'is-primary'], label, tooltip, onClick); + } + + private createSecondarySlideExportActionButton( + parent: HTMLElement, + className: string, + label: string, + tooltip: string, + onClick: () => Promise + ): HTMLButtonElement { + return this.appendSlideExportActionButton(parent, [className, 'notemd-slide-export-secondary-button'], label, tooltip, onClick); + } + + private appendSlideExportActionButton( + parent: HTMLElement, + classNames: string[], + label: string, + tooltip: string, + onClick: () => Promise + ): HTMLButtonElement { + const classes = ['notemd-action-button', ...classNames]; + const button = parent.createEl('button', { + text: label, + cls: classes.join(' ') + }) as HTMLButtonElement; + button.title = tooltip; + button.onclick = onClick; + return button; + } + + private resolveContainingDetails(element: HTMLElement): (HTMLElement & { open?: boolean }) | null { + const parentElement = element.parentElement ?? (element as any).parent ?? null; + if (!parentElement || !('open' in parentElement)) { + return null; + } + return parentElement as HTMLElement & { open?: boolean }; + } + + private createSlideExportStepButton( + parent: HTMLElement, + stepNumber: string, + label: string, + tooltip: string, + onClick: () => Promise + ): HTMLButtonElement { + const button = parent.createEl('button', { + cls: 'notemd-action-button notemd-slide-export-step-button' + }) as HTMLButtonElement; + button.title = tooltip; + button.onclick = onClick; + button.createEl('span', { text: stepNumber, cls: 'notemd-slide-export-step-index' }); + button.createEl('span', { text: label, cls: 'notemd-slide-export-step-label' }); + return button; + } + + private syncSlideExportOutlineControls() { + this.slideExportOutlineToggleButton?.setAttr('aria-checked', this.slideExportOutlineMode ? 'true' : 'false'); + if (this.slideExportOutlineMode) { + this.slideExportOutlineToggleButton?.addClass('is-enabled'); + this.slideExportDirectActionsEl?.addClass('is-hidden'); + this.slideExportDirectActionsEl?.setAttr('aria-hidden', 'true'); + this.slideExportOutlineActionsEl?.removeClass('is-hidden'); + this.slideExportOutlineActionsEl?.setAttr('aria-hidden', 'false'); + return; + } + this.slideExportOutlineToggleButton?.removeClass('is-enabled'); + this.slideExportDirectActionsEl?.removeClass('is-hidden'); + this.slideExportDirectActionsEl?.setAttr('aria-hidden', 'false'); + this.slideExportOutlineActionsEl?.addClass('is-hidden'); + this.slideExportOutlineActionsEl?.setAttr('aria-hidden', 'true'); + } + + private getActiveMarkdownFileForSlideExport(): TFile { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile || !(activeFile instanceof TFile) || activeFile.extension !== 'md') { + throw new Error(this.getStrings().notices.noActiveMarkdownFileSelected); + } + return activeFile; + } + + private async runSlideExportFileAction( + label: string, + operation: (file: TFile, reporter: ProgressReporter) => Promise + ): Promise { + const i18n = this.getStrings(); + if (this.isProcessing || this.plugin.getIsBusy()) { + new Notice(i18n.notices.processingAlreadyRunning); + return; + } + + this.openSlideExportControlsSection(); + this.startProcessing(formatI18n(i18n.sidebar.status.runningAction, { label })); + const reporter = this.createReporterProxy(); + try { + const file = this.getActiveMarkdownFileForSlideExport(); + await operation(file, reporter); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const failureMessage = formatI18n(i18n.sidebar.status.actionFailed, { message }); + this.log(failureMessage); + this.updateStatus(failureMessage, -1); + } finally { + this.finishProcessing(); + } + } + + async runSlideExportEnvironmentProbe(): Promise { + const i18n = this.getStrings(); + if (this.isProcessing || this.plugin.getIsBusy()) { + new Notice(i18n.notices.processingAlreadyRunning); + return; + } + + this.openSlideExportControlsSection(); + this.startProcessing(i18n.slideExport.probingEnvironment); + this.renderSlideExportEnvironmentLoading(); + const reporter = this.createReporterProxy(); + try { + reporter.log(i18n.slideExport.probingEnvironment); + const { probeEnvironment } = await import('../slideExport'); + const report = await probeEnvironment(); + this.renderSlideExportEnvironmentReport(report); + this.logSlideExportEnvironmentSummary(report); + this.updateStatus(i18n.slideExport.environmentCheckComplete, 100); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.renderSlideExportEnvironmentFailure(message); + this.log(formatI18n(i18n.slideExport.environmentCheckFailed, { message })); + this.updateStatus(formatI18n(i18n.slideExport.environmentCheckFailed, { message }), -1); + } finally { + this.finishProcessing(); + } + } + + private openSlideExportControlsSection() { + if (this.slideExportControlsSectionEl) { + this.slideExportControlsSectionEl.open = true; + } + } + + private renderSlideExportEnvironmentLoading() { + const i18n = this.getStrings(); + const panel = this.ensureSlideExportEnvironmentPanel(); + panel.empty(); + panel.removeClass('is-hidden'); + panel.createEl('div', { text: i18n.slideExport.environmentCheckingTitle, cls: 'notemd-slide-export-env-title' }); + panel.createEl('p', { text: i18n.slideExport.environmentCheckingDesc, cls: 'notemd-slide-export-env-desc' }); + } + + private renderSlideExportEnvironmentFailure(message: string) { + const i18n = this.getStrings(); + const panel = this.ensureSlideExportEnvironmentPanel(); + panel.empty(); + panel.removeClass('is-hidden'); + panel.createEl('div', { text: i18n.slideExport.environmentCheckFailedTitle, cls: 'notemd-slide-export-env-title' }); + panel.createEl('p', { + text: formatI18n(i18n.slideExport.environmentCheckFailed, { message }), + cls: 'notemd-slide-export-env-desc is-error' + }); + } + + private renderSlideExportEnvironmentReport(report: EnvironmentReport) { + const i18n = this.getStrings(); + const panel = this.ensureSlideExportEnvironmentPanel(); + panel.empty(); + panel.removeClass('is-hidden'); + + const availableCount = SLIDE_EXPORT_FORMATS.filter(format => report.capabilities[format]).length; + const titleRow = panel.createDiv({ cls: 'notemd-slide-export-env-heading' }); + titleRow.createEl('div', { text: i18n.slideExport.environmentReportTitle, cls: 'notemd-slide-export-env-title' }); + titleRow.createEl('div', { + text: formatI18n(i18n.slideExport.environmentCapabilitySummary, { count: availableCount, total: SLIDE_EXPORT_FORMATS.length }), + cls: 'notemd-slide-export-env-badge' + }); + + if (!report.isDesktop) { + panel.createEl('p', { + text: i18n.slideExport.mobileUnsupportedError, + cls: 'notemd-slide-export-env-desc is-error' + }); + return; + } + + const toolGrid = panel.createDiv({ cls: 'notemd-slide-export-env-tool-grid' }); + this.renderSlideExportToolRow(toolGrid, 'node', 'Node.js', report.node, report); + this.renderSlideExportToolRow(toolGrid, 'slidev', 'Slidev CLI', report.slidev, report); + this.renderSlideExportToolRow(toolGrid, 'playwright', 'Playwright Chromium', report.playwright, report); + this.renderSlideExportToolRow(toolGrid, 'ffmpeg', 'ffmpeg', report.ffmpeg, report); + + const capabilityList = panel.createDiv({ cls: 'notemd-slide-export-env-capabilities' }); + const capabilityLabels: Record = { + html: 'HTML', + pdf: 'PDF', + png: 'PNG', + mp4: 'MP4' + }; + for (const format of SLIDE_EXPORT_FORMATS) { + const item = capabilityList.createDiv({ + cls: report.capabilities[format] + ? 'notemd-slide-export-env-capability is-available' + : 'notemd-slide-export-env-capability is-unavailable' + }); + item.createEl('span', { + text: report.capabilities[format] ? i18n.slideExport.availableShort : i18n.slideExport.unavailableShort, + cls: 'notemd-slide-export-env-capability-state' + }); + item.createEl('span', { text: capabilityLabels[format] }); + } + } + + private renderSlideExportToolRow( + parent: HTMLElement, + tool: ProbeResult['tool'], + label: string, + result: ProbeResult, + report: EnvironmentReport + ) { + const i18n = this.getStrings(); + const guidance = this.getSlideExportToolGuidance(tool, report); + const row = parent.createDiv({ + cls: result.installed + ? 'notemd-slide-export-env-tool is-installed' + : 'notemd-slide-export-env-tool is-missing' + }); + const header = row.createDiv({ cls: 'notemd-slide-export-env-tool-header' }); + header.createEl('span', { text: label, cls: 'notemd-slide-export-env-tool-name' }); + header.createEl('span', { + text: result.installed ? i18n.slideExport.installedStatus : i18n.slideExport.missingStatus, + cls: 'notemd-slide-export-env-tool-status' + }); + row.createEl('div', { + text: result.installed + ? (result.version || i18n.slideExport.availableShort) + : (result.error || i18n.slideExport.missingStatus), + cls: 'notemd-slide-export-env-tool-detail' + }); + + if (!result.installed) { + const command = row.createEl('code', { + text: guidance.command, + cls: 'notemd-slide-export-env-command' + }); + command.setAttr('data-command', guidance.command); + const footer = row.createDiv({ cls: 'notemd-slide-export-env-tool-footer' }); + const copyButton = footer.createEl('button', { + text: i18n.slideExport.copyInstallCommand, + cls: 'notemd-slide-export-env-copy-button' + }) as HTMLButtonElement; + copyButton.onclick = () => this.copySlideExportInstallCommand(guidance.command); + + const link = footer.createEl('a', { + text: guidance.websiteLabel, + cls: 'notemd-slide-export-env-link' + }); + link.setAttr('href', guidance.websiteUrl); + link.setAttr('target', '_blank'); + link.setAttr('rel', 'noopener noreferrer'); + + if (tool === 'slidev' || tool === 'playwright') { + const installButton = footer.createEl('button', { + text: i18n.slideExport.probeInstallBtn, + cls: 'notemd-slide-export-env-install-button' + }) as HTMLButtonElement; + installButton.onclick = () => this.installSlideExportTool(tool); + } + } + } + + private getSlideExportToolGuidance(tool: ProbeResult['tool'], report: EnvironmentReport): { command: string; websiteUrl: string; websiteLabel: string } { + const i18n = this.getStrings(); + switch (tool) { + case 'node': + return { + command: 'node --version', + websiteUrl: 'https://nodejs.org/en/download', + websiteLabel: i18n.slideExport.nodeWebsiteLabel + }; + case 'slidev': + return { + command: 'corepack enable\npnpm add -D @slidev/cli', + websiteUrl: 'https://sli.dev/builtin/cli', + websiteLabel: i18n.slideExport.slidevWebsiteLabel + }; + case 'playwright': + return { + command: 'npx playwright install chromium', + websiteUrl: 'https://playwright.dev/docs/intro', + websiteLabel: i18n.slideExport.playwrightWebsiteLabel + }; + case 'ffmpeg': + return { + command: FFMPEG_INSTALL_HINTS[report.platform] || FFMPEG_INSTALL_HINTS.unknown, + websiteUrl: 'https://ffmpeg.org/download.html', + websiteLabel: i18n.slideExport.ffmpegWebsiteLabel + }; + } + } + + private copySlideExportInstallCommand(command: string): void { + const i18n = this.getStrings(); + navigator.clipboard + .writeText(command) + .then( + () => new Notice(i18n.slideExport.copyInstallCommandSuccess), + () => new Notice(i18n.slideExport.copyInstallCommandFailed) + ); + } + + private async installSlideExportTool(tool: 'slidev' | 'playwright'): Promise { + const i18n = this.getStrings(); + if (this.isProcessing || this.plugin.getIsBusy()) { + new Notice(i18n.notices.processingAlreadyRunning); + return; + } + + this.startProcessing(formatI18n(i18n.slideExport.installingTool, { + tool: tool === 'slidev' ? 'Slidev CLI' : 'Playwright Chromium' + })); + const reporter = this.createReporterProxy(); + try { + const { autoInstallSlidev, autoInstallPlaywright, probeEnvironment } = await import('../slideExport'); + const result = tool === 'slidev' + ? await autoInstallSlidev((phase, detail) => reporter.log(detail ? `${phase}: ${detail}` : phase)) + : await autoInstallPlaywright((phase, detail) => reporter.log(detail ? `${phase}: ${detail}` : phase)); + if (result.exitCode !== 0) { + throw new Error(result.stderr || result.error?.message || i18n.common.unknownError); + } + reporter.log(i18n.slideExport.installComplete); + const report = await probeEnvironment(); + this.renderSlideExportEnvironmentReport(report); + this.logSlideExportEnvironmentSummary(report); + this.updateStatus(i18n.slideExport.environmentCheckComplete, 100); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + reporter.log(formatI18n(i18n.slideExport.installFailed, { message })); + this.updateStatus(formatI18n(i18n.slideExport.installFailed, { message }), -1); + } finally { + this.finishProcessing(); + } + } + + private ensureSlideExportEnvironmentPanel(): HTMLElement { + if (this.slideExportEnvironmentPanelEl) { + return this.slideExportEnvironmentPanelEl; + } + throw new Error('Slide export environment panel is not mounted.'); + } + + private logSlideExportEnvironmentSummary(report: EnvironmentReport) { + const i18n = this.getStrings(); + const missingTools = [report.node, report.slidev, report.playwright, report.ffmpeg] + .filter(result => !result.installed) + .map(result => result.tool); + const availableFormats = SLIDE_EXPORT_FORMATS.filter(format => report.capabilities[format]).join(', ') || i18n.slideExport.noneShort; + this.log(formatI18n(i18n.slideExport.environmentAvailableFormats, { formats: availableFormats })); + if (missingTools.length > 0) { + this.log(formatI18n(i18n.slideExport.environmentMissingTools, { tools: missingTools.join(', ') })); + } + } + private buildDiagramIntentSelector(parent: HTMLElement) { const i18n = this.getStrings(); const row = parent.createDiv({ cls: 'notemd-inline-control' }); @@ -1295,12 +1740,12 @@ export class NotemdSidebarView extends ItemView implements ProgressReporter { ACTION_CATEGORY_CONFIG[category].openByDefault ); - defs.forEach(def => { - this.createActionButton(body, def.id, def.category); - }); - if (category === 'export') { - this.buildSlideExportFormatSelector(body); + this.buildSlideExportControls(body); + } else { + defs.forEach(def => { + this.createActionButton(body, def.id, def.category); + }); } if (category === 'generation') { @@ -1418,6 +1863,12 @@ export class NotemdSidebarView extends ItemView implements ProgressReporter { this.cancelButton = null; this.languageSelector = null; this.clearApiLivenessTimer(); + this.slideExportFormatSelector = null; + this.slideExportOutlineToggleButton = null; + this.slideExportDirectActionsEl = null; + this.slideExportOutlineActionsEl = null; + this.slideExportControlsSectionEl = null; + this.slideExportEnvironmentPanelEl = null; this.expandedApiActivityRequestIds.clear(); this.actionButtons.clear(); this.workflowButtons = []; diff --git a/styles.css b/styles.css index d2366b00..65ea3251 100644 --- a/styles.css +++ b/styles.css @@ -940,6 +940,308 @@ font-size: 12px; } +.notemd-slide-export-probe-button, +.notemd-slide-export-direct-button { + min-height: 44px; +} + +.notemd-slide-export-secondary-button { + border-color: color-mix(in srgb, var(--notemd-border) 82%, var(--interactive-accent) 18%); + background: color-mix(in srgb, var(--background-secondary) 86%, var(--background-primary) 14%); + color: var(--text-normal); + box-shadow: none; +} + +.notemd-slide-export-secondary-button:hover { + border-color: color-mix(in srgb, var(--interactive-accent) 36%, var(--notemd-border) 64%); + background: color-mix(in srgb, var(--background-modifier-hover) 70%, var(--background-primary) 30%); +} + +.notemd-slide-export-outline-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.68em; + width: 100%; + min-height: 44px; + padding: 0.52em 0.62em; + border: 1px solid color-mix(in srgb, var(--notemd-border) 78%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--background-primary) 88%, var(--background-secondary) 12%); + color: var(--text-normal); + font-size: 12px; + font-weight: 650; + line-height: 1.35; + cursor: pointer; + transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease; +} + +.notemd-slide-export-outline-toggle:hover { + border-color: color-mix(in srgb, var(--notemd-accent) 62%, var(--notemd-border) 38%); + background: color-mix(in srgb, var(--background-modifier-hover) 64%, var(--background-primary) 36%); +} + +.notemd-slide-export-outline-toggle.is-enabled { + border-color: color-mix(in srgb, var(--interactive-accent) 54%, var(--notemd-border) 46%); + background: color-mix(in srgb, var(--interactive-accent) 10%, var(--background-primary) 90%); +} + +.notemd-slide-export-outline-toggle-track { + position: relative; + flex: 0 0 auto; + width: 36px; + height: 20px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--notemd-border) 86%, transparent); + background: color-mix(in srgb, var(--background-secondary) 86%, var(--text-muted) 14%); + transition: background-color 160ms ease, border-color 160ms ease; +} + +.notemd-slide-export-outline-toggle-track::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 999px; + background: var(--background-primary); + box-shadow: 0 1px 3px color-mix(in srgb, #0f172a 26%, transparent); + transition: transform 160ms ease; +} + +.notemd-slide-export-outline-toggle.is-enabled .notemd-slide-export-outline-toggle-track { + border-color: color-mix(in srgb, var(--interactive-accent) 62%, var(--notemd-border) 38%); + background: color-mix(in srgb, var(--interactive-accent) 44%, var(--background-secondary) 56%); +} + +.notemd-slide-export-outline-toggle.is-enabled .notemd-slide-export-outline-toggle-track::before { + transform: translateX(16px); +} + +.notemd-slide-export-outline-toggle-label { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; +} + +.notemd-slide-export-outline-toggle:focus-visible, +.notemd-slide-export-env-install-button:focus-visible, +.notemd-slide-export-env-copy-button:focus-visible, +.notemd-slide-export-env-link:focus-visible { + outline: 2px solid var(--notemd-focus); + outline-offset: 2px; +} + +.notemd-slide-export-direct-actions, +.notemd-slide-export-outline-actions { + display: grid; + grid-template-columns: 1fr; + gap: 0.52em; +} + +.notemd-slide-export-step-button { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + align-items: center; + gap: 0.58em; + min-height: 44px; +} + +.notemd-slide-export-step-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 999px; + background: color-mix(in srgb, var(--interactive-accent) 14%, var(--background-secondary) 86%); + color: color-mix(in srgb, var(--text-normal) 86%, var(--interactive-accent) 14%); + font-size: 11px; + font-weight: 800; + font-variant-numeric: tabular-nums; +} + +.notemd-slide-export-step-label { + min-width: 0; + overflow-wrap: anywhere; +} + +.notemd-slide-export-env-panel { + border: 1px solid color-mix(in srgb, var(--notemd-border) 74%, var(--interactive-accent) 26%); + border-radius: 8px; + padding: 0.72em; + background: color-mix(in srgb, var(--background-primary) 88%, var(--background-secondary) 12%); + box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 32%, transparent); +} + +.notemd-slide-export-env-heading, +.notemd-slide-export-env-tool-header, +.notemd-slide-export-env-tool-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.55em; +} + +.notemd-slide-export-env-title { + color: var(--text-normal); + font-size: 12px; + font-weight: 800; + line-height: 1.35; +} + +.notemd-slide-export-env-desc { + margin: 0.35em 0 0; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; +} + +.notemd-slide-export-env-desc.is-error { + color: var(--text-error); +} + +.notemd-slide-export-env-badge { + flex: 0 0 auto; + border: 1px solid color-mix(in srgb, var(--notemd-border) 70%, var(--interactive-accent) 30%); + border-radius: 8px; + padding: 0.18em 0.5em; + background: color-mix(in srgb, var(--interactive-accent) 10%, var(--background-secondary) 90%); + color: var(--text-muted); + font-size: 10px; + font-weight: 750; + line-height: 1.35; +} + +.notemd-slide-export-env-tool-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.5em; + margin-top: 0.65em; +} + +.notemd-slide-export-env-tool { + border: 1px solid var(--notemd-border); + border-radius: 8px; + padding: 0.56em; + background: color-mix(in srgb, var(--background-secondary) 90%, var(--background-primary) 10%); +} + +.notemd-slide-export-env-tool.is-installed { + border-color: color-mix(in srgb, #10b981 28%, var(--notemd-border) 72%); +} + +.notemd-slide-export-env-tool.is-missing { + border-color: color-mix(in srgb, #f59e0b 32%, var(--notemd-border) 68%); +} + +.notemd-slide-export-env-tool-name { + color: var(--text-normal); + font-size: 11px; + font-weight: 800; + line-height: 1.35; +} + +.notemd-slide-export-env-tool-status { + color: var(--text-muted); + font-size: 10px; + font-weight: 750; + line-height: 1.35; +} + +.notemd-slide-export-env-tool-detail { + margin-top: 0.22em; + color: var(--text-muted); + font-size: 10px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.notemd-slide-export-env-command { + display: block; + margin-top: 0.45em; + padding: 0.48em 0.54em; + border: 1px solid color-mix(in srgb, var(--notemd-border) 76%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--background-primary) 88%, #0f172a 12%); + color: var(--text-normal); + font-family: var(--font-monospace); + font-size: 10px; + line-height: 1.45; + white-space: pre-wrap; + user-select: text; + -webkit-user-select: text; +} + +.notemd-slide-export-env-tool-footer { + margin-top: 0.5em; + flex-wrap: wrap; +} + +.notemd-slide-export-env-link { + color: var(--link-color); + font-size: 10px; + font-weight: 700; + text-decoration: none; +} + +.notemd-slide-export-env-link:hover { + text-decoration: underline; +} + +.notemd-slide-export-env-install-button, +.notemd-slide-export-env-copy-button { + min-height: 32px; + border: 1px solid color-mix(in srgb, var(--interactive-accent) 44%, var(--notemd-border) 56%); + border-radius: 8px; + padding: 0.24em 0.62em; + background: color-mix(in srgb, var(--interactive-accent) 12%, var(--background-secondary) 88%); + color: var(--text-normal); + font-size: 10px; + font-weight: 750; + cursor: pointer; +} + +.notemd-slide-export-env-copy-button { + background: var(--background-secondary); + border-color: color-mix(in srgb, var(--notemd-border) 82%, var(--interactive-accent) 18%); +} + +.notemd-slide-export-env-capabilities { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.42em; + margin-top: 0.65em; +} + +.notemd-slide-export-env-capability { + display: inline-flex; + align-items: center; + gap: 0.35em; + min-width: 0; + border-radius: 6px; + padding: 0.32em 0.44em; + background: var(--background-secondary); + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + line-height: 1.35; +} + +.notemd-slide-export-env-capability.is-available { + color: color-mix(in srgb, var(--text-normal) 78%, #10b981 22%); +} + +.notemd-slide-export-env-capability.is-unavailable { + color: color-mix(in srgb, var(--text-muted) 78%, #f59e0b 22%); +} + +.notemd-slide-export-env-capability-state { + font-size: 9px; + font-weight: 800; +} + .notemd-provider-callout { border: 1px solid var(--notemd-border); border-radius: 12px; @@ -1460,17 +1762,15 @@ } .notemd-progress-area .notemd-progress-bar-container.mod-sidebar.is-idle::after { - content: 'Standby'; + content: ''; position: absolute; inset: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: color-mix(in srgb, var(--text-normal) 68%, var(--interactive-accent) 32%); + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--interactive-accent) 10%, transparent) 50%, + transparent 100% + ); pointer-events: none; } @@ -1573,6 +1873,9 @@ @media (prefers-reduced-motion: reduce) { .notemd-action-button, .notemd-cancel-button, + .notemd-slide-export-outline-toggle, + .notemd-slide-export-outline-toggle-track, + .notemd-slide-export-outline-toggle-track::before, .notemd-copy-log-button, .notemd-progress-bar-fill, .notemd-section-summary::before {