feat(stage5): 文档主题系统/自定义 CSS/导出 HTML/打印/导入 HTML 与 Word

This commit is contained in:
Muse
2026-11-13 22:58:35 +08:00
parent c8fc67104b
commit 83cedbc5f3
11 changed files with 1604 additions and 14 deletions

View File

@@ -5,8 +5,8 @@ Tauri 2 + React + TypeScript + Milkdown(ProseMirror)。
## 当前进度
**阶段 0–4 已完成(自动化验证全绿),待 Windows 真机验收清单确认。**
阶段 4:SQLite 落库、自动保存、崩溃恢复、外部修改检测、工作区文件管理,报告见 `docs/08-stage4-report.md`。
**阶段 0–5 已完成(自动化验证全绿),待 Windows 真机验收清单确认。**
阶段 5:7 套文档主题、自定义 CSS、导出 HTML、打印/PDF、导入 HTML/Word,报告见 `docs/09-stage5-report.md`。
阶段 1:Tauri 桌面骨架 + 打开/编辑/保存全链路,报告见 `docs/05-stage1-report.md` §4 清单。
在 Windows 上运行桌面版:

36
docs/09-stage5-report.md Normal file
View File

@@ -0,0 +1,36 @@
# 阶段 5 · 主题系统与导入导出报告(实测记录)
> 日期:2026-11-13 · 范围(需求 §23 阶段 5):内置主题、自定义 CSS、导出 HTML/PDF、导入导出与打印。
## 1. 本阶段实现内容
- **文档主题系统**(`src/themes.ts` + styles.css 变量层):7 套内置主题——默认、GitHub、Notion、简约蓝、深色护眼(自带深色纸面)、专注写作(衬线窄栏)、经典纸张。主题只作用于编辑区排版(字体/行距/标题/链接/引用/代码底/纸面色),经 CSS 变量切换,与 UI 框架明暗相互独立。新增顶部「主题」菜单可直接切换(带勾选态)。
- **当前文档临时主题**(需求 §14.3):外观弹窗里可为当前标签单独指定主题,覆盖全局、仅本会话有效,不写进文档文件。
- **自定义 CSS**(需求 §14.2):外观弹窗内编辑、即时注入生效、随偏好落库;弹窗同时补齐了阶段 4 遗留的自动保存间隔设置(开关 + 10–600 秒)。
- **导出 HTML**(`src/services/exportHtml.ts`):独立管线 unified/remark(gfm+math)→ rehype → rehype-katex 公式渲染 → 代码高亮 → Mermaid 渲染 SVG → 独立 HTML 文档(内联与主题同源的导出样式、`<title>` 取文档名)。图片两模式:保持相对路径 / 内嵌 base64 单文件(Rust 新增 `read_file_base64`,base64 实现已用标准向量实跑验证;相对路径按文档目录解析,含 `..` 归一化)。
- **高亮引擎决策**:Shiki 默认的 Oniguruma WASM 加载依赖 fetch/stream,在 jsdom 实测直接失败(真实 WebView 亦有同类风险),已换 **shiki/core + JavaScript 正则引擎**(无 WASM、处处可跑),24 种常用语言,导出测试断言了着色 span 真实产出。
- **复制为 HTML**:ClipboardItem 同时写 text/html 与 text/plain(降级 writeText),可直接粘贴到公众号/网页富文本编辑器。
- **打印 / 导出 PDF**:Ctrl+P 与文件菜单;打印样式表隐藏全部框架(标题栏/菜单/标签/侧栏/状态栏/弹窗),只输出文档区并设定 @page 边距;PDF 经系统打印对话框「另存为 PDF」完成。
- **导入**(文件菜单):HTML 与 Word(.docx) 转 Markdown 新建标签——HTML 走 turndown(ATX 标题/围栏代码块),DOCX 走 mammoth 先转 HTML 再 turndown;两个库均懒加载。Web 降级模式同样可导入。
## 2. 构建与测试结果(实跑)
| 项 | 结果 |
|---|---|
| 类型检查 `tsc -b` | ✅ 0 错误 |
| 自动化测试 | ✅ **53/53**(11 文件;本阶段新增 5:主题注册表、导出管线结构/公式/表格、Shiki 真高亮、图片内嵌替换、HTML 导入) |
| Rust | ✅ base64 函数标准向量全过(空/1/2/3 字节/中文/高位字节);`read_file_base64` 命令包装随 Windows 构建验 |
| 生产构建 | ✅ 通过(导出/导入相关库全部懒加载分包,不进首屏) |
| dev 冒烟 | ✅ HTTP 200 |
## 3. 已知问题与边界
1. **导出 Word 未做**:DOCX 生成需要 pandoc 级别的转换器,本阶段只做了导入方向;导出 Word 留待后续评估(可考虑集成 pandoc 二进制或 docx 生成库,排版保真另说)。EPUB 导入/导出同样未做。
2. 导出 HTML 的 KaTeX 样式走 CDN 链接:联网时公式显示完美;完全离线时公式退化为 MathML 原生渲染(Chromium/WebView2 可显示,样式略简)。把 KaTeX 字体内嵌进导出文件可做到全离线,但单文件体积会涨约 300KB,未默认启用。
3. Mermaid 导出 SVG 在 jsdom 无法真验(同阶段 3 边界),失败时自动保留代码块原文,不丢内容。
4. 打印效果(分页、页眉页脚)取决于 WebView2 打印对话框与驱动,真机验;打印样式已按类名核对过框架元素(titlebar/menubar/tabbar/sidebar/statusbar 均实存)。
5. 文档临时主题与自定义 CSS 是会话/偏好级能力:临时主题不跨重启(按需求 §14.3 的「临时」定位),自定义 CSS 跨重启保留(在偏好里)。
## 4. 下一阶段(阶段 6)预告
编辑体验与性能:专注模式、打字机模式、文档内查找替换、全文搜索(工作区)、快捷键自定义中心、多窗口/多开、性能优化与大文档实测(100KB/1MB/5MB 打开与输入延迟,WebView2 真机口径)。

805
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,18 @@
"@codemirror/theme-one-dark": "6.1.3",
"@milkdown/plugin-math": "7.5.9",
"katex": "0.16.11",
"mermaid": "12.3.0"
"mermaid": "12.3.0",
"unified": "11.0.5",
"remark-parse": "11.0.0",
"remark-gfm": "4.0.1",
"remark-math": "6.0.0",
"remark-rehype": "11.1.2",
"rehype-stringify": "10.0.1",
"rehype-katex": "7.0.1",
"unist-util-visit": "5.1.0",
"shiki": "4.6.0",
"turndown": "7.2.2",
"mammoth": "1.11.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.2.0",
@@ -21,7 +32,9 @@
"jsdom": "25.0.1",
"typescript": "5.6.3",
"vite": "5.4.11",
"vitest": "2.1.5"
"vitest": "2.1.5",
"@types/turndown": "5.0.6",
"@types/hast": "3.0.5"
},
"name": "new-markdown",
"private": true,

View File

@@ -22,10 +22,14 @@ import {
pickAndReadWebFile, downloadTextFile,
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets,
statFile, createTextFile, renamePath, deleteToTrash, type DirEntry, type RecoveryEntry,
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
} from './services/platform'
import * as store from './services/store'
import { recoveryKey, planRecovery } from './utils/recovery'
import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
import { AppearanceModal, ExportModal } from './components/Stage5Modals'
import { DOC_THEMES } from './themes'
import { markdownToExportHtml, htmlToMarkdown, docxToMarkdown, type ImageMode } from './services/exportHtml'
const WELCOME_MD = `# 欢迎使用 new-markdown
@@ -47,9 +51,9 @@ const UI_KEY = 'new-markdown:ui'
function loadRecent(): string[] {
try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] }
}
interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number }
interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string }
function loadUi(): UiState {
const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30 }
const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '' }
try {
return { ...defaults, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') }
} catch { return defaults }
@@ -86,6 +90,9 @@ export default function App() {
const [promptSpec, setPromptSpec] = useState<{ title: string; label: string; initial: string; submit: (v: string) => void } | null>(null)
const [confirmSpec, setConfirmSpec] = useState<{ title: string; message: string; confirmLabel: string; onOk: () => void } | null>(null)
const [treeRefresh, setTreeRefresh] = useState(0)
const [docOverrides, setDocOverrides] = useState<Record<string, string>>({})
const [appearanceOpen, setAppearanceOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
/** 文件基线(外部修改检测):tabId -> 上次确认的磁盘状态 */
const baselinesRef = useRef(new Map<string, { mtimeMs: number; size: number }>())
const conflictSeenRef = useRef(new Set<string>())
@@ -110,6 +117,21 @@ export default function App() {
store.saveUiPref(JSON.stringify(ui))
}, [ui])
const effectiveDocTheme = (tab ? docOverrides[tab.id] : undefined) ?? ui.docTheme
useEffect(() => {
document.documentElement.dataset.docTheme = effectiveDocTheme
}, [effectiveDocTheme])
useEffect(() => {
let el = document.getElementById('new-markdown-custom-css') as HTMLStyleElement | null
if (!el) {
el = document.createElement('style')
el.id = 'new-markdown-custom-css'
document.head.appendChild(el)
}
el.textContent = ui.customCss
}, [ui.customCss])
const pushRecent = useCallback((path: string) => {
setRecent((prev) => [path, ...prev.filter((p) => p !== path)].slice(0, 10))
store.addRecent(path, baseName(path))
@@ -486,6 +508,7 @@ export default function App() {
else if (key === 'w' && tab) { e.preventDefault(); closeTab(tab.id) }
else if (key === 'l' && e.shiftKey) { e.preventDefault(); setUi((u) => ({ ...u, sidebar: !u.sidebar })) }
else if (key === 'k' && !e.shiftKey && !inTextField) { e.preventDefault(); setModal('link') }
else if (key === 'p' && !e.shiftKey) { e.preventDefault(); printRef.current() }
else if (key === 't' && !e.shiftKey && !inTextField) { e.preventDefault(); withEditor((ed) => editorCommands.insertTable(ed)) }
else if (key === 'i' && e.shiftKey) { e.preventDefault(); void insertImage() }
else if (key === 'q' && e.shiftKey) { e.preventDefault(); withEditor(editorCommands.blockquote) }
@@ -562,6 +585,107 @@ export default function App() {
} catch (e) { setError(String(e)) }
}, [tab, withEditor])
// ——— 阶段 5:导出 / 打印 / 导入 ———
const resolveExportImage = useCallback((docPath: string | null) => {
if (!docPath) return undefined
const dir = docPath.replace(/[\\/][^\\/]*$/, '')
return async (src: string): Promise<string | null> => {
try {
const isAbsolute = /^[a-zA-Z]:[\\/]/.test(src) || src.startsWith('/') || src.startsWith('\\')
const raw = isAbsolute ? src : `${dir}/${src}`
const drive = raw.match(/^[a-zA-Z]:/)?.[0] ?? ''
const segments: string[] = []
for (const part of raw.slice(drive.length).replace(/\\/g, '/').split('/')) {
if (part === '..') segments.pop()
else if (part !== '.' && part !== '') segments.push(part)
}
const joined = drive + '/' + segments.join('/')
const ext = (src.split('.').pop() ?? '').toLowerCase()
const mime = ext === 'png' ? 'image/png' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
: ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : ext === 'svg' ? 'image/svg+xml' : 'application/octet-stream'
const base64 = await readFileBase64(joined)
return `data:${mime};base64,${base64}`
} catch {
return null
}
}
}, [])
const buildExportHtml = useCallback(async (target: DocTab, imageMode: ImageMode): Promise<string> => {
return markdownToExportHtml(target.content, {
title: target.title,
themeId: docOverrides[target.id] ?? ui.docTheme,
imageMode,
resolveImage: tauriMode ? resolveExportImage(target.path) : undefined,
})
}, [docOverrides, ui.docTheme, tauriMode, resolveExportImage])
const exportHtmlFile = useCallback(async (imageMode: ImageMode) => {
if (!tab) return
setExportOpen(false)
try {
const html = await buildExportHtml(tab, imageMode)
if (tauriMode) {
const target = await pickSaveHtmlFile(`${tab.title}.html`)
if (!target) return
await writeTextFile(target, html, false)
flash(`已导出 HTML:${baseName(target)}`)
} else {
downloadTextFile(`${tab.title}.html`, html, 'text/html;charset=utf-8')
flash('已导出 HTML(浏览器下载)')
}
} catch (e) { setError(String(e)) }
}, [tab, buildExportHtml, tauriMode, flash])
const copyHtml = useCallback(async () => {
if (!tab) return
setExportOpen(false)
try {
const html = await buildExportHtml(tab, 'embed')
try {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([tab.content], { type: 'text/plain' }),
}),
])
} catch {
await navigator.clipboard.writeText(html)
}
flash('已复制为 HTML,可粘贴到富文本编辑器')
} catch (e) { setError(String(e)) }
}, [tab, buildExportHtml, flash])
const printDoc = useCallback(() => {
if (!tab) return
if (sourceMode) toggleSourceMode()
flash('在打印对话框中可选择「另存为 PDF」导出 PDF')
window.setTimeout(() => window.print(), 60)
}, [tab, sourceMode, toggleSourceMode, flash])
const printRef = useRef(printDoc)
printRef.current = printDoc
const importFileFlow = useCallback(async () => {
try {
let name = ''
let markdown = ''
if (tauriMode) {
const path = await pickImportFile()
if (!path) return
name = baseName(path).replace(/\.(html?|docx)$/i, '')
if (/\.docx$/i.test(path)) markdown = await docxToMarkdown(await readBinaryFile(path))
else markdown = await htmlToMarkdown((await readTextFile(path)).content)
} else {
const picked = await pickAndReadWebImport()
if (!picked) return
name = picked.name.replace(/\.(html?|docx)$/i, '')
markdown = picked.buffer ? await docxToMarkdown(picked.buffer) : await htmlToMarkdown(picked.text ?? '')
}
dispatch({ type: 'open', tab: createTab({ title: name || '导入文档', content: markdown }) })
flash(`已导入《${name}》并转换为 Markdown`)
} catch (e) { setError(String(e)) }
}, [tauriMode, flash])
const outline = useMemo(() => (tab ? extractOutline(tab.content) : []), [tab])
const menus: MenuDef[] = useMemo(() => [
@@ -577,6 +701,10 @@ export default function App() {
{ label: '保存', shortcut: 'Ctrl+S', disabled: !tab, onClick: () => tab && void saveTab(tab, false) },
{ label: '另存为…', shortcut: 'Ctrl+Shift+S', disabled: !tab, onClick: () => tab && void saveTab(tab, true) },
{ label: '关闭标签页', shortcut: 'Ctrl+W', disabled: !tab, onClick: () => tab && closeTab(tab.id) },
{ divider: true },
{ label: '导入 HTML / Word…', onClick: () => void importFileFlow() },
{ label: '导出…', disabled: !tab, onClick: () => setExportOpen(true) },
{ label: '打印 / 导出 PDF…', shortcut: 'Ctrl+P', disabled: !tab, onClick: printDoc },
],
},
{
@@ -638,6 +766,17 @@ export default function App() {
{ label: '恢复默认缩放', onClick: () => setZoom(1) },
],
},
{
label: '主题',
items: [
...DOC_THEMES.map((t) => ({
label: t.name, checked: ui.docTheme === t.id,
onClick: () => setUi((u) => ({ ...u, docTheme: t.id })),
})),
{ divider: true },
{ label: '外观与自定义 CSS…', onClick: () => setAppearanceOpen(true) },
],
},
{
label: '帮助',
items: [
@@ -645,7 +784,7 @@ export default function App() {
{ label: '关于 new-markdown', onClick: () => setModal('about') },
],
},
], [tab, recent, sourceMode, ui, sidebarMode, tauriMode, newDoc, openFile, openFolder, openPath, saveTab, closeTab, withEditor, toggleSourceMode, insertImage, insertAttachment, pasteFromClipboard])
], [tab, recent, sourceMode, ui, sidebarMode, tauriMode, newDoc, openFile, openFolder, openPath, saveTab, closeTab, withEditor, toggleSourceMode, insertImage, insertAttachment, pasteFromClipboard, importFileFlow, printDoc])
const pendingTitles = pendingClose
? state.tabs.filter((t) => pendingClose.ids.includes(t.id) && isDirty(t)).map((t) => t.title).join('、')
@@ -743,6 +882,38 @@ export default function App() {
tauriMode={tauriMode}
zoom={zoom}
/>
{appearanceOpen && (
<AppearanceModal
docTheme={ui.docTheme}
override={tab ? (docOverrides[tab.id] ?? null) : null}
customCss={ui.customCss}
autosave={ui.autosave}
autosaveSeconds={ui.autosaveSeconds}
hasTab={Boolean(tab)}
onDocTheme={(id) => setUi((u) => ({ ...u, docTheme: id }))}
onOverride={(id) => {
if (!tab) return
setDocOverrides((prev) => {
const next = { ...prev }
if (id) next[tab.id] = id
else delete next[tab.id]
return next
})
}}
onCustomCss={(css) => setUi((u) => ({ ...u, customCss: css }))}
onAutosave={(patch) => setUi((u) => ({ ...u, ...patch }))}
onClose={() => setAppearanceOpen(false)}
/>
)}
{exportOpen && tab && (
<ExportModal
title={tab.title}
onExportHtml={(mode) => void exportHtmlFile(mode)}
onCopyHtml={() => void copyHtml()}
onPrint={() => { setExportOpen(false); printDoc() }}
onClose={() => setExportOpen(false)}
/>
)}
{notice && <div className="app-notice">{notice}</div>}
{recoveryOffer.length > 0 && !recoveryDismissed && (
<RecoveryModal

View File

@@ -0,0 +1,112 @@
import { DOC_THEMES } from '../themes'
/** 外观与设置(阶段 5):文档主题、当前文档临时主题、自定义 CSS、自动保存间隔。 */
export function AppearanceModal({ docTheme, override, customCss, autosave, autosaveSeconds, hasTab, onDocTheme, onOverride, onCustomCss, onAutosave, onClose }: {
docTheme: string
override: string | null
customCss: string
autosave: boolean
autosaveSeconds: number
hasTab: boolean
onDocTheme: (id: string) => void
onOverride: (id: string | null) => void
onCustomCss: (css: string) => void
onAutosave: (patch: { autosave?: boolean; autosaveSeconds?: number }) => void
onClose: () => void
}) {
return (
<div className="modal-backdrop">
<div className="modal" style={{ width: 560 }}>
<h3>外观与设置</h3>
<div className="setting-label">文档主题(作用于编辑区排版)</div>
<div className="theme-grid">
{DOC_THEMES.map((t) => (
<button
key={t.id}
className={`theme-card${docTheme === t.id ? ' active' : ''}`}
onClick={() => onDocTheme(t.id)}
>
<strong>{t.name}</strong>
<span>{t.description}</span>
</button>
))}
</div>
<label className="modal-field">
<span>当前文档临时主题{hasTab ? '' : '(未打开文档)'}</span>
<select
className="input"
disabled={!hasTab}
value={override ?? ''}
onChange={(e) => onOverride(e.target.value === '' ? null : e.target.value)}
>
<option value="">跟随全局主题</option>
{DOC_THEMES.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</label>
<label className="modal-field">
<span>自定义 CSS(即时生效;建议以 .milkdown 开头限定作用范围)</span>
<textarea
className="input css-input"
rows={6}
spellCheck={false}
value={customCss}
placeholder={'.milkdown .ProseMirror h1 { color: #b3261e; }'}
onChange={(e) => onCustomCss(e.target.value)}
/>
</label>
<div className="setting-row">
<label>
<input
type="checkbox"
checked={autosave}
onChange={(e) => onAutosave({ autosave: e.target.checked })}
/>
自动保存(仅已落盘文档)
</label>
<label>
间隔
<input
className="input seconds-input"
type="number"
min={10}
max={600}
value={autosaveSeconds}
onChange={(e) => onAutosave({ autosaveSeconds: Math.min(600, Math.max(10, Number(e.target.value) || 30)) })}
/>
秒
</label>
</div>
<div className="modal-actions">
<button className="primary" onClick={onClose}>完成</button>
</div>
</div>
</div>
)
}
/** 输出(阶段 5):导出 HTML(两种图片模式)、复制为 HTML、打印/导出 PDF。 */
export function ExportModal({ title, onExportHtml, onCopyHtml, onPrint, onClose }: {
title: string
onExportHtml: (imageMode: 'relative' | 'embed') => void
onCopyHtml: () => void
onPrint: () => void
onClose: () => void
}) {
return (
<div className="modal-backdrop">
<div className="modal">
<h3>导出《{title}》</h3>
<div className="modal-actions column left">
<button onClick={() => onExportHtml('relative')}>导出 HTML(图片保持相对路径)</button>
<button onClick={() => onExportHtml('embed')}>导出 HTML(图片内嵌,单文件)</button>
<button onClick={onCopyHtml}>复制为 HTML(粘贴到公众号/网页编辑器)</button>
<button onClick={onPrint}>打印 / 导出 PDF…</button>
</div>
<p className="modal-note">导出 HTML 为独立文件:公式以 KaTeX 样式呈现(样式表走 CDN,离线时公式退化为 MathML 显示),Mermaid 图表渲染为 SVG,代码块带语法高亮。</p>
<div className="modal-actions">
<button onClick={onClose}>取消</button>
</div>
</div>
</div>
)
}

230
src/services/exportHtml.ts Normal file
View File

@@ -0,0 +1,230 @@
// Markdown → 独立 HTML 导出(阶段 5)。
// 管线:remark(gfm + math)→ rehype → rehype-katex 公式渲染 → Shiki 代码高亮(懒加载)→
// Mermaid 代码块替换为 SVG(懒加载)→ 图片按模式处理(保持相对路径 / 内嵌 base64)。
// 本模块只做纯转换;文件选择与落盘在 App 层。
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import remarkRehype from 'remark-rehype'
import rehypeKatex from 'rehype-katex'
import rehypeStringify from 'rehype-stringify'
import { visit } from 'unist-util-visit'
import type { Root, Element, Text } from 'hast'
export type ImageMode = 'relative' | 'embed'
export interface ExportOptions {
/** 文档标题(<title> 与文件名来源) */
title: string
/** 文档主题 id(决定导出样式变体) */
themeId: string
imageMode: ImageMode
/** embed 模式下把相对图片地址读成 data URL(读不到时返回 null 保持原样) */
resolveImage?: (src: string) => Promise<string | null>
/** 是否做 Shiki 高亮(测试可关) */
highlight?: boolean
/** 是否渲染 Mermaid 为 SVG(测试可关) */
renderMermaid?: boolean
}
interface CodeJob {
parent: Element | Root
index: number
lang: string
code: string
}
/** 给 <pre><code class="language-x"> 做 Shiki 高亮与 Mermaid 替换的 rehype 后处理。 */
async function postProcess(tree: Root, opts: ExportOptions): Promise<void> {
const jobs: CodeJob[] = []
const images: { node: Element }[] = []
visit(tree, 'element', (node: Element, index, parent) => {
if (node.tagName === 'img') images.push({ node })
if (node.tagName !== 'pre' || index == null || !parent) return
const codeEl = node.children.find(
(c): c is Element => c.type === 'element' && c.tagName === 'code',
)
if (!codeEl) return
const classes = (codeEl.properties?.className as string[] | undefined) ?? []
const langClass = classes.find((c) => c.startsWith('language-'))
if (!langClass) return
const lang = langClass.slice('language-'.length)
const code = codeEl.children
.filter((c): c is Text => c.type === 'text')
.map((c) => c.value)
.join('')
jobs.push({ parent: parent as Element | Root, index: (parent as Element).children.indexOf(node), lang, code })
})
// 图片内嵌
if (opts.imageMode === 'embed' && opts.resolveImage) {
for (const { node } of images) {
const src = node.properties?.src
if (typeof src === 'string' && !/^(https?:|data:)/i.test(src)) {
const data = await opts.resolveImage(src)
if (data) node.properties.src = data
}
}
}
// 代码块替换(从后往前,避免索引漂移)
const mermaidJobs = jobs.filter((j) => j.lang === 'mermaid')
const codeJobs = jobs.filter((j) => j.lang !== 'mermaid')
let highlighter: Awaited<ReturnType<typeof loadHighlighter>> | null = null
if (opts.highlight !== false && codeJobs.length > 0) {
try {
highlighter = await loadHighlighter()
} catch {
highlighter = null // 高亮不可用时保留普通代码块,不阻塞导出
}
}
let mermaidApi: (typeof import('mermaid'))['default'] | null = null
if (opts.renderMermaid !== false && mermaidJobs.length > 0) {
try {
mermaidApi = (await import('mermaid')).default
mermaidApi.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'strict' })
} catch {
mermaidApi = null
}
}
const all = [...jobs].sort((a, b) => b.index - a.index)
for (const job of all) {
let rawHtml: string | null = null
if (job.lang === 'mermaid' && mermaidApi) {
try {
const { svg } = await mermaidApi.render(`export-mermaid-${Math.abs(hashCode(job.code))}`, job.code)
rawHtml = `<div class="mermaid-diagram">${svg}</div>`
} catch {
rawHtml = null
}
} else if (job.lang !== 'mermaid' && highlighter) {
try {
rawHtml = highlighter.codeToHtml(job.code, {
lang: job.lang,
theme: 'github-light',
})
} catch {
rawHtml = null // 未知语言保留原样
}
}
if (rawHtml) {
const rawNode = { type: 'raw', value: rawHtml } as unknown as Element
;(job.parent as Element).children.splice(job.index, 1, rawNode)
}
}
}
function hashCode(text: string): number {
let h = 0
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0
return h
}
let highlighterPromise: Promise<import('shiki').HighlighterCore> | null = null
const EXPORT_LANGS = [
'javascript', 'typescript', 'tsx', 'jsx', 'json', 'html', 'css', 'markdown',
'bash', 'shell', 'powershell', 'python', 'java', 'go', 'rust', 'c', 'cpp',
'csharp', 'sql', 'yaml', 'xml', 'diff', 'ini',
] as const
/**
* Shiki 高亮器:用 core + JavaScript 正则引擎(不用 Oniguruma WASM——
* WASM 加载依赖 fetch/stream,在部分 WebView/测试环境会失败,JS 引擎 everywhere 可用)。
*/
function loadHighlighter(): Promise<import('shiki').HighlighterCore> {
if (!highlighterPromise) {
highlighterPromise = (async () => {
const { createHighlighterCore } = await import('shiki/core')
const { createJavaScriptRegexEngine } = await import('shiki/engine/javascript')
const theme = (await import('@shikijs/themes/github-light')).default
const langs = await Promise.all(
EXPORT_LANGS.map(async (name) => (await import(`@shikijs/langs/${name}`)).default),
)
return createHighlighterCore({
themes: [theme],
langs,
engine: createJavaScriptRegexEngine(),
})
})()
}
return highlighterPromise
}
/** 导出用样式:与应用内文档主题同源的精简版(按 themeId 给纸面/字体变体)。 */
export function exportCss(themeId: string): string {
const base = `
body { margin: 0; background: #fff; }
main { max-width: 820px; margin: 0 auto; padding: 42px 26px 70px; font-family: var(--f); color: var(--ink); line-height: 1.75; font-size: 16.5px; }
h1 { font-size: 1.9em; border-bottom: 1px solid var(--hb); padding-bottom: 8px; line-height: 1.3; }
h2 { font-size: 1.5em; } h3 { font-size: 1.25em; }
a { color: var(--link); }
code { background: var(--cb); border-radius: 6px; padding: 2px 5px; font-family: "Cascadia Code", Consolas, monospace; font-size: 0.88em; }
pre { background: #f6f8fa; border-radius: 8px; padding: 14px 16px; overflow-x: auto; }
pre code { background: transparent; padding: 0; }
blockquote { border-left: 3px solid var(--hb); margin-left: 0; padding-left: 14px; color: #667085; }
table { border-collapse: collapse; margin: 12px 0; }
th, td { border: 1px solid #d8dee4; padding: 6px 12px; }
img { max-width: 100%; }
.mermaid-diagram { text-align: center; }
.mermaid-diagram svg { max-width: 100%; height: auto; }
`
const variants: Record<string, string> = {
default: `:root{--f:"Segoe UI","Microsoft YaHei UI",sans-serif;--ink:#1d2433;--link:#43536a;--cb:#eef1f5;--hb:#dde2ea;}`,
github: `:root{--f:-apple-system,"Segoe UI",sans-serif;--ink:#1f2328;--link:#0969da;--cb:#eff1f3;--hb:#d8dee4;}`,
notion: `:root{--f:ui-sans-serif,"Segoe UI",sans-serif;--ink:#37352f;--link:#37352f;--cb:#f1f1ef;--hb:#e9e9e7;} body{background:#fbfbfa;}`,
'simple-blue': `:root{--f:"Segoe UI",sans-serif;--ink:#1d2433;--link:#2563eb;--cb:#eef3fd;--hb:#dbe7fb;} h1,h2{color:#1d4ed8;}`,
'dark-eye': `:root{--f:"Segoe UI",sans-serif;--ink:#c9d1c0;--link:#9ec49a;--cb:#2a322a;--hb:#39412f;} body{background:#20261f;} pre{background:#262d24;}`,
focus: `:root{--f:Georgia,"Songti SC",SimSun,serif;--ink:#26221c;--link:#6b4f2a;--cb:#f3efe6;--hb:transparent;} main{max-width:720px;line-height:1.95;}`,
paper: `:root{--f:Georgia,"Songti SC",SimSun,serif;--ink:#433422;--link:#8a5a2b;--cb:#efe6d0;--hb:#e0d3ae;} body{background:#f7f1e3;}`,
}
return (variants[themeId] ?? variants.default) + base
}
export async function markdownToExportHtml(markdown: string, opts: ExportOptions): Promise<string> {
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeKatex)
const tree = (await processor.run(processor.parse(markdown))) as unknown as Root
await postProcess(tree, opts)
const body = await unified()
.use(rehypeStringify, { allowDangerousHtml: true })
.stringify(tree as never)
const katexCssLink = '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">'
return [
'<!DOCTYPE html>',
'<html lang="zh-CN">',
'<head>',
'<meta charset="utf-8">',
`<title>${escapeHtml(opts.title)}</title>`,
katexCssLink,
`<style>${exportCss(opts.themeId)}</style>`,
'</head>',
'<body>',
`<main>${String(body)}</main>`,
'</body>',
'</html>',
].join('\n')
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** HTML → Markdown(导入用,懒加载 turndown)。 */
export async function htmlToMarkdown(html: string): Promise<string> {
const TurndownService = (await import('turndown')).default
const service = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' })
return service.turndown(html)
}
/** DOCX → Markdown(mammoth 先转 HTML 再走 turndown,懒加载)。 */
export async function docxToMarkdown(arrayBuffer: ArrayBuffer): Promise<string> {
const mammoth = await import('mammoth')
const result = await mammoth.convertToHtml({ arrayBuffer })
return htmlToMarkdown(result.value)
}

View File

@@ -130,8 +130,8 @@ export function pickAndReadWebFile(): Promise<{ name: string; content: string }
}
/** Web 降级:把内容作为下载交付(等价于另存为)。 */
export function downloadTextFile(name: string, content: string): void {
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
export function downloadTextFile(name: string, content: string, mime = 'text/markdown;charset=utf-8'): void {
const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@@ -224,3 +224,53 @@ export async function dbRecoverySave(entry: RecoveryEntry): Promise<void> {
export async function dbRecoveryDelete(docKey: string): Promise<void> {
return invoke<void>('db_recovery_delete', { docKey })
}
/** 导出 HTML 时另存路径选择。 */
export async function pickSaveHtmlFile(defaultName: string): Promise<string | null> {
if (!isTauri()) return null
const { save } = await import('@tauri-apps/plugin-dialog')
const picked = await save({ defaultPath: defaultName, filters: [{ name: 'HTML', extensions: ['html', 'htm'] }] })
return typeof picked === 'string' ? picked : null
}
/** 导入文件选择(HTML / Word)。 */
export async function pickImportFile(): Promise<string | null> {
if (!isTauri()) return null
const { open } = await import('@tauri-apps/plugin-dialog')
const picked = await open({
multiple: false, directory: false,
filters: [{ name: '可导入文件', extensions: ['html', 'htm', 'docx'] }],
})
return typeof picked === 'string' ? picked : null
}
/** 读文件并返回 base64(导出图片内嵌用)。 */
export async function readFileBase64(path: string): Promise<string> {
return invoke<string>('read_file_base64', { path })
}
/** 读二进制文件(DOCX 导入用,Web 降级不可用时抛错)。 */
export async function readBinaryFile(path: string): Promise<ArrayBuffer> {
const base64 = await readFileBase64(path)
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return bytes.buffer
}
/** Web 降级:选 HTML/DOCX 文件并读入(导入用)。 */
export function pickAndReadWebImport(): Promise<{ name: string; text?: string; buffer?: ArrayBuffer } | null> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.html,.htm,.docx'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return resolve(null)
if (/\.docx$/i.test(file.name)) resolve({ name: file.name, buffer: await file.arrayBuffer() })
else resolve({ name: file.name, text: await file.text() })
}
input.oncancel = () => resolve(null)
input.click()
})
}

View File

@@ -160,3 +160,109 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.tree-context-menu button { display: block; width: 100%; text-align: left; border: 0; background: transparent; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; }
.tree-context-menu button:hover { background: var(--accent-weak); }
.tree-context-menu button.danger { color: #b3261e; }
/* ——— 阶段 5:文档主题(变量层,[data-doc-theme] 挂在 html 上) ——— */
:root {
--doc-font: "Segoe UI", "Microsoft YaHei UI", sans-serif;
--doc-h-font: var(--doc-font);
--doc-lh: 1.78;
--doc-link: var(--accent);
--doc-quote-border: var(--border);
--doc-quote-color: var(--text-dim);
--doc-paper: var(--bg-editor);
--doc-ink: var(--text);
--doc-code-bg: var(--code-bg);
--doc-h-border: var(--border);
}
:root[data-doc-theme='github'] {
--doc-font: -apple-system, "Segoe UI", "Microsoft YaHei UI", sans-serif;
--doc-lh: 1.62;
--doc-link: #0969da;
--doc-code-bg: #f6f8fa;
--doc-h-border: #d8dee4;
}
:root[data-doc-theme='notion'] {
--doc-font: ui-sans-serif, "Segoe UI", "Microsoft YaHei UI", sans-serif;
--doc-lh: 1.66;
--doc-paper: #fbfbfa;
--doc-quote-border: #e3e2de;
--doc-code-bg: #f7f6f3;
--doc-h-border: transparent;
}
:root[data-doc-theme='simple-blue'] {
--doc-lh: 1.74;
--doc-link: #2563eb;
--doc-h-border: #dbe7fb;
--doc-quote-border: #bcd2f7;
}
:root[data-doc-theme='dark-eye'] {
--doc-lh: 1.8;
--doc-paper: #20261f;
--doc-ink: #c9d1c0;
--doc-link: #9ec49a;
--doc-code-bg: #2a322a;
--doc-quote-border: #43503f;
--doc-quote-color: #9aa78f;
--doc-h-border: #39412f;
}
:root[data-doc-theme='focus'] {
--doc-font: Georgia, "Times New Roman", "Songti SC", SimSun, serif;
--doc-h-font: Georgia, "Songti SC", SimSun, serif;
--doc-lh: 1.95;
--doc-h-border: transparent;
}
:root[data-doc-theme='paper'] {
--doc-font: Georgia, "Songti SC", SimSun, serif;
--doc-lh: 1.85;
--doc-paper: #f7f1e3;
--doc-ink: #433422;
--doc-link: #8a5a2b;
--doc-code-bg: #efe6d0;
--doc-quote-border: #d9c9a3;
--doc-quote-color: #7a6748;
--doc-h-border: #e0d3ae;
}
.milkdown .ProseMirror {
font-family: var(--doc-font);
line-height: var(--doc-lh);
color: var(--doc-ink);
}
.editor-scroll { background: var(--doc-paper); }
.milkdown .ProseMirror h1, .milkdown .ProseMirror h2, .milkdown .ProseMirror h3,
.milkdown .ProseMirror h4, .milkdown .ProseMirror h5, .milkdown .ProseMirror h6 {
font-family: var(--doc-h-font);
border-bottom-color: var(--doc-h-border);
}
.milkdown .ProseMirror a { color: var(--doc-link); }
.milkdown .ProseMirror code { background: var(--doc-code-bg); }
.milkdown .ProseMirror blockquote { border-left-color: var(--doc-quote-border); color: var(--doc-quote-color); }
:root[data-doc-theme='github'] .milkdown .ProseMirror h1 { font-size: 1.9em; }
:root[data-doc-theme='github'] .milkdown .ProseMirror h2 { border-bottom: 1px solid var(--doc-h-border); padding-bottom: 6px; }
:root[data-doc-theme='notion'] .milkdown .ProseMirror h1 { font-size: 1.65em; }
:root[data-doc-theme='notion'] .milkdown .ProseMirror h2 { font-size: 1.38em; }
:root[data-doc-theme='simple-blue'] .milkdown .ProseMirror h1,
:root[data-doc-theme='simple-blue'] .milkdown .ProseMirror h2 { color: #1d4ed8; }
:root[data-doc-theme='focus'] .editor-pane-wrap, :root[data-doc-theme='focus'] .source-cm { max-width: 760px; margin: 0 auto; }
:root[data-doc-theme='dark-eye'] .milkdown .ProseMirror table th,
:root[data-doc-theme='dark-eye'] .milkdown .ProseMirror table td { border-color: #39412f; }
:root[data-doc-theme='dark-eye'] .mermaid-block, :root[data-doc-theme='paper'] .mermaid-block { border-color: var(--doc-h-border); }
/* ——— 阶段 5:打印(只打文档区,隐藏全部框架) ——— */
@media print {
.titlebar, .menubar, .tabbar, .sidebar, .statusbar, .modal-backdrop, .app-notice, .context-menu,
.table-toolbar, .tree-context-menu { display: none !important; }
.app, .main, .editor-scroll { display: block !important; height: auto !important; overflow: visible !important; }
.editor-scroll { background: #fff !important; }
.milkdown .ProseMirror { font-size: 11.5pt; }
@page { margin: 18mm 16mm; }
}
.setting-label { font-size: 13px; font-weight: 600; margin: 6px 0 8px; }
.theme-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
.theme-card { display: flex; flex-direction: column; gap: 3px; align-items: flex-start; text-align: left; border: 1px solid var(--border); background: var(--bg); border-radius: 8px; padding: 9px 11px; cursor: pointer; }
.theme-card.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.theme-card span { color: var(--text-dim); font-size: 12px; line-height: 1.45; }
.css-input { font-family: "Cascadia Code", Consolas, monospace; font-size: 12.5px; resize: vertical; }
.setting-row { display: flex; gap: 22px; align-items: center; margin-top: 12px; font-size: 13px; }
.seconds-input { width: 72px; margin: 0 6px; }
.modal-actions.left { align-items: stretch; }
.modal-actions.left button { text-align: left; }

23
src/themes.ts Normal file
View File

@@ -0,0 +1,23 @@
// 文档主题注册表(阶段 5):主题只影响编辑区排版(字体/字号/行距/标题/链接/引用/代码底色/纸面色),
// 具体数值在 styles.css 里以 [data-doc-theme='<id>'] 变量与规则落地;UI 框架的明暗仍由 ui.theme 独立控制。
export interface DocTheme {
id: string
name: string
description: string
/** 该主题自带深色纸面(编辑区强制深色,不随 UI 明暗) */
darkPaper?: boolean
}
export const DOC_THEMES: DocTheme[] = [
{ id: 'default', name: '默认', description: '石板中性、适度行距,当前默认排版' },
{ id: 'github', name: 'GitHub', description: 'GitHub 文档风:标题下边线、浅灰代码底、蓝色链接' },
{ id: 'notion', name: 'Notion', description: 'Notion 风:紧凑标题、暖灰纸面、低对比分隔' },
{ id: 'simple-blue', name: '简约蓝', description: '蓝色标题与链接,清爽的轻量排版' },
{ id: 'dark-eye', name: '深色护眼', description: '墨绿深灰纸面,低亮度长时阅读', darkPaper: true },
{ id: 'focus', name: '专注写作', description: '衬线正文、加宽行距、窄栏,适合长文写作' },
{ id: 'paper', name: '经典纸张', description: '米色纸面、衬线字体、棕褐文字,纸书感' },
]
export function docThemeById(id: string): DocTheme {
return DOC_THEMES.find((t) => t.id === id) ?? DOC_THEMES[0]
}

54
tests/stage5.test.ts Normal file
View File

@@ -0,0 +1,54 @@
// 阶段 5 验收:主题注册表完整性、导出 HTML 管线(结构/表格/公式/代码)、HTML 导入转换。
import { describe, it, expect } from 'vitest'
import { DOC_THEMES, docThemeById } from '../src/themes'
import { markdownToExportHtml, htmlToMarkdown, exportCss } from '../src/services/exportHtml'
describe('文档主题注册表', () => {
it('7 套主题 id 唯一且可查', () => {
expect(DOC_THEMES).toHaveLength(7)
expect(new Set(DOC_THEMES.map((t) => t.id)).size).toBe(7)
expect(docThemeById('paper').name).toBe('经典纸张')
expect(docThemeById('不存在').id).toBe('default')
for (const t of DOC_THEMES) expect(exportCss(t.id)).toContain('--f:')
})
})
describe('导出 HTML 管线', () => {
it('标题/表格/行内公式/代码块都转成对应 HTML(关高亮与 Mermaid 时走纯管线)', async () => {
const md = '# 导出标题\n\n正文含 $E = mc^2$ 公式。\n\n| A | B |\n| --- | --- |\n| 1 | 2 |\n\n```ts\nconst v = 1;\n```\n'
const html = await markdownToExportHtml(md, {
title: '导出标题', themeId: 'github', imageMode: 'relative', highlight: false, renderMermaid: false,
})
expect(html).toContain('<!DOCTYPE html>')
expect(html).toContain('<h1>导出标题</h1>')
expect(html).toContain('<table>')
expect(html).toContain('katex') // 公式经 rehype-katex 渲染(含 MathML)
expect(html).toContain('const v = 1;')
expect(html).toContain('<title>导出标题</title>')
})
it('Shiki 高亮开启时代码块带着色 span', async () => {
const html = await markdownToExportHtml('```ts\nconst v: number = 42;\n```\n', {
title: 't', themeId: 'default', imageMode: 'relative', renderMermaid: false,
})
expect(html).toContain('style="color:')
expect(html).toContain('42')
})
it('图片内嵌模式用 resolveImage 替换为 data URL', async () => {
const html = await markdownToExportHtml('![示意图](文章.assets/a.png)\n', {
title: 't', themeId: 'default', imageMode: 'embed', highlight: false, renderMermaid: false,
resolveImage: async (src) => (src.endsWith('a.png') ? 'data:image/png;base64,QUJD' : null),
})
expect(html).toContain('src="data:image/png;base64,QUJD"')
})
})
describe('导入转换', () => {
it('HTML 转 Markdown 保留标题与列表', async () => {
const md = await htmlToMarkdown('<h1>标题</h1><ul><li>第一项</li><li>第二项</li></ul><p>正文<strong>加粗</strong></p>')
expect(md).toContain('# 标题')
expect(md).toMatch(/-\s+第一项/)
expect(md).toContain('**加粗**')
})
})