mirror of
https://github.com/jacobinwwey/obsidian-NotEMD.git
synced 2026-07-22 05:48:27 +00:00
fix(slidev-export): refine sidebar workflow
This commit is contained in:
parent
a2ecbcd866
commit
71c77ed26e
18 changed files with 2288 additions and 70 deletions
91
docs/SINGLE_FILE_BUNDLER.zh-CN.md
Normal file
91
docs/SINGLE_FILE_BUNDLER.zh-CN.md
Normal file
|
|
@ -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 服务的人仍可在设置中切换。
|
||||
102
docs/SLIDEV_HTML_FIX.zh-CN.md
Normal file
102
docs/SLIDEV_HTML_FIX.zh-CN.md
Normal file
|
|
@ -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。
|
||||
182
docs/SLIDEV_SOLUTION.zh-CN.md
Normal file
182
docs/SLIDEV_SOLUTION.zh-CN.md
Normal file
|
|
@ -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/<source-basename>-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/<source-basename>-slides/index.html
|
||||
docs/export/<source-basename>-slides/start-server.sh
|
||||
docs/export/<source-basename>-slides/start-server.bat
|
||||
docs/export/<source-basename>-slides/README.md
|
||||
```
|
||||
|
||||
该模式需要通过本地 HTTP 服务查看。
|
||||
|
||||
## 当前侧边栏交互
|
||||
|
||||
导出 UI 现在遵循渐进披露:
|
||||
|
||||
1. 默认只暴露“一次性导出”主动作。
|
||||
2. “导出前先输出大纲”开关默认关闭。
|
||||
3. 打开开关后,主动作切换为两个有顺序的按钮:“先输出大纲”和“基于大纲继续导出”。
|
||||
4. 大纲文件写入 `export/_slidev-outlines/<source>.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`、局部 `<Transform>`、结构化拆分或内容级重写。
|
||||
|
||||
支持的结构化 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` 隐藏到无法检查,但也不应在没有明确要求时作为测试文件提交。
|
||||
85
docs/STANDALONE_BUNDLE_FIX.zh-CN.md
Normal file
85
docs/STANDALONE_BUNDLE_FIX.zh-CN.md
Normal file
|
|
@ -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 审计确认。
|
||||
140
docs/export/README.zh-CN.md
Normal file
140
docs/export/README.zh-CN.md
Normal file
|
|
@ -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。
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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...',
|
||||
|
|
|
|||
|
|
@ -986,6 +986,44 @@ export const STRINGS_ZH_CN: DeepPartial<NotemdEnglishStrings> = {
|
|||
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: '正在导出演示…',
|
||||
|
|
|
|||
216
src/main.ts
216
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<ProgressReporter> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<SlidevExportSource>
|
||||
): Promise<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -87,6 +87,87 @@ export async function prepareSlidevExportSource(
|
|||
};
|
||||
}
|
||||
|
||||
export async function generateSlidevExportOutline(
|
||||
app: App,
|
||||
sourceFile: TFile,
|
||||
config: SlideExportConfig,
|
||||
options: SlidevSourcePreparationOptions = {},
|
||||
onProgress?: ExportProgressCallback,
|
||||
): Promise<string> {
|
||||
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<SlidevExportSource> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Promise<void>, [any]>;
|
||||
checkAndRemoveDuplicateConceptNotesCommand: jest.Mock<Promise<void>, [any]>;
|
||||
testLlmConnectionCommand: jest.Mock<Promise<void>, [any]>;
|
||||
generateSlidevExportOutlineCommand: jest.Mock<Promise<string>, [any, any]>;
|
||||
exportSlidesCommand: jest.Mock<Promise<void>, [any, any]>;
|
||||
exportSlidesFromOutlineCommand: jest.Mock<Promise<void>, [any, any]>;
|
||||
};
|
||||
|
||||
class FakeElement {
|
||||
|
|
@ -53,6 +61,7 @@ class FakeElement {
|
|||
disabled = false;
|
||||
open = false;
|
||||
dataset: Record<string, string> = {};
|
||||
attributes: Record<string, string> = {};
|
||||
style: Record<string, string> = {};
|
||||
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<string, string>) {
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | null = null;
|
||||
private apiLivenessRequests = new Map<string, ApiActivityRequestRecord>();
|
||||
|
|
@ -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<string, HTMLButtonElement>();
|
||||
|
|
@ -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<void>
|
||||
): 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<void>
|
||||
): 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<void>
|
||||
): 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<void>
|
||||
): 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<unknown>
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<NotemdSettings['slideExportDefaultFormat'], string> = {
|
||||
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<void> {
|
||||
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 = [];
|
||||
|
|
|
|||
321
styles.css
321
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue