fix(slidev-export): refine sidebar workflow

This commit is contained in:
Jacobinwwey 2026-06-20 07:49:31 -05:00
parent a2ecbcd866
commit 71c77ed26e
18 changed files with 2288 additions and 70 deletions

View 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 服务的人仍可在设置中切换。

View 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。

View 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 detectionfallback 通过不能当作 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` 隐藏到无法检查,但也不应在没有明确要求时作为测试文件提交。

View 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
View 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。

View file

@ -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);

View file

@ -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...',

View file

@ -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: '正在导出演示…',

View file

@ -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();
}
}
}

View file

@ -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';

View file

@ -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);

View file

@ -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);
}

View file

@ -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);
});
});

View file

@ -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();

View file

@ -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');

View file

@ -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/)
);
});
});

View file

@ -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 = [];

View file

@ -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 {