feat(stage5): 文档主题系统/自定义 CSS/导出 HTML/打印/导入 HTML 与 Word
This commit is contained in:
@@ -5,8 +5,8 @@ Tauri 2 + React + TypeScript + Milkdown(ProseMirror)。
|
|||||||
|
|
||||||
## 当前进度
|
## 当前进度
|
||||||
|
|
||||||
**阶段 0–4 已完成(自动化验证全绿),待 Windows 真机验收清单确认。**
|
**阶段 0–5 已完成(自动化验证全绿),待 Windows 真机验收清单确认。**
|
||||||
阶段 4:SQLite 落库、自动保存、崩溃恢复、外部修改检测、工作区文件管理,报告见 `docs/08-stage4-report.md`。
|
阶段 5:7 套文档主题、自定义 CSS、导出 HTML、打印/PDF、导入 HTML/Word,报告见 `docs/09-stage5-report.md`。
|
||||||
阶段 1:Tauri 桌面骨架 + 打开/编辑/保存全链路,报告见 `docs/05-stage1-report.md` §4 清单。
|
阶段 1:Tauri 桌面骨架 + 打开/编辑/保存全链路,报告见 `docs/05-stage1-report.md` §4 清单。
|
||||||
|
|
||||||
在 Windows 上运行桌面版:
|
在 Windows 上运行桌面版:
|
||||||
|
|||||||
36
docs/09-stage5-report.md
Normal file
36
docs/09-stage5-report.md
Normal 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
805
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -11,7 +11,18 @@
|
|||||||
"@codemirror/theme-one-dark": "6.1.3",
|
"@codemirror/theme-one-dark": "6.1.3",
|
||||||
"@milkdown/plugin-math": "7.5.9",
|
"@milkdown/plugin-math": "7.5.9",
|
||||||
"katex": "0.16.11",
|
"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": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.2.0",
|
"@tauri-apps/cli": "^2.2.0",
|
||||||
@@ -21,7 +32,9 @@
|
|||||||
"jsdom": "25.0.1",
|
"jsdom": "25.0.1",
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
"vite": "5.4.11",
|
"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",
|
"name": "new-markdown",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
177
src/App.tsx
177
src/App.tsx
@@ -22,10 +22,14 @@ import {
|
|||||||
pickAndReadWebFile, downloadTextFile,
|
pickAndReadWebFile, downloadTextFile,
|
||||||
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets,
|
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets,
|
||||||
statFile, createTextFile, renamePath, deleteToTrash, type DirEntry, type RecoveryEntry,
|
statFile, createTextFile, renamePath, deleteToTrash, type DirEntry, type RecoveryEntry,
|
||||||
|
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
|
||||||
} from './services/platform'
|
} from './services/platform'
|
||||||
import * as store from './services/store'
|
import * as store from './services/store'
|
||||||
import { recoveryKey, planRecovery } from './utils/recovery'
|
import { recoveryKey, planRecovery } from './utils/recovery'
|
||||||
import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
|
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
|
const WELCOME_MD = `# 欢迎使用 new-markdown
|
||||||
|
|
||||||
@@ -47,9 +51,9 @@ const UI_KEY = 'new-markdown:ui'
|
|||||||
function loadRecent(): string[] {
|
function loadRecent(): string[] {
|
||||||
try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] }
|
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 {
|
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 {
|
try {
|
||||||
return { ...defaults, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') }
|
return { ...defaults, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') }
|
||||||
} catch { return defaults }
|
} 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 [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 [confirmSpec, setConfirmSpec] = useState<{ title: string; message: string; confirmLabel: string; onOk: () => void } | null>(null)
|
||||||
const [treeRefresh, setTreeRefresh] = useState(0)
|
const [treeRefresh, setTreeRefresh] = useState(0)
|
||||||
|
const [docOverrides, setDocOverrides] = useState<Record<string, string>>({})
|
||||||
|
const [appearanceOpen, setAppearanceOpen] = useState(false)
|
||||||
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
/** 文件基线(外部修改检测):tabId -> 上次确认的磁盘状态 */
|
/** 文件基线(外部修改检测):tabId -> 上次确认的磁盘状态 */
|
||||||
const baselinesRef = useRef(new Map<string, { mtimeMs: number; size: number }>())
|
const baselinesRef = useRef(new Map<string, { mtimeMs: number; size: number }>())
|
||||||
const conflictSeenRef = useRef(new Set<string>())
|
const conflictSeenRef = useRef(new Set<string>())
|
||||||
@@ -110,6 +117,21 @@ export default function App() {
|
|||||||
store.saveUiPref(JSON.stringify(ui))
|
store.saveUiPref(JSON.stringify(ui))
|
||||||
}, [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) => {
|
const pushRecent = useCallback((path: string) => {
|
||||||
setRecent((prev) => [path, ...prev.filter((p) => p !== path)].slice(0, 10))
|
setRecent((prev) => [path, ...prev.filter((p) => p !== path)].slice(0, 10))
|
||||||
store.addRecent(path, baseName(path))
|
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 === 'w' && tab) { e.preventDefault(); closeTab(tab.id) }
|
||||||
else if (key === 'l' && e.shiftKey) { e.preventDefault(); setUi((u) => ({ ...u, sidebar: !u.sidebar })) }
|
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 === '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 === 't' && !e.shiftKey && !inTextField) { e.preventDefault(); withEditor((ed) => editorCommands.insertTable(ed)) }
|
||||||
else if (key === 'i' && e.shiftKey) { e.preventDefault(); void insertImage() }
|
else if (key === 'i' && e.shiftKey) { e.preventDefault(); void insertImage() }
|
||||||
else if (key === 'q' && e.shiftKey) { e.preventDefault(); withEditor(editorCommands.blockquote) }
|
else if (key === 'q' && e.shiftKey) { e.preventDefault(); withEditor(editorCommands.blockquote) }
|
||||||
@@ -562,6 +585,107 @@ export default function App() {
|
|||||||
} catch (e) { setError(String(e)) }
|
} catch (e) { setError(String(e)) }
|
||||||
}, [tab, withEditor])
|
}, [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 outline = useMemo(() => (tab ? extractOutline(tab.content) : []), [tab])
|
||||||
|
|
||||||
const menus: MenuDef[] = useMemo(() => [
|
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+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+Shift+S', disabled: !tab, onClick: () => tab && void saveTab(tab, true) },
|
||||||
{ label: '关闭标签页', shortcut: 'Ctrl+W', disabled: !tab, onClick: () => tab && closeTab(tab.id) },
|
{ 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: '恢复默认缩放', 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: '帮助',
|
label: '帮助',
|
||||||
items: [
|
items: [
|
||||||
@@ -645,7 +784,7 @@ export default function App() {
|
|||||||
{ label: '关于 new-markdown', onClick: () => setModal('about') },
|
{ 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
|
const pendingTitles = pendingClose
|
||||||
? state.tabs.filter((t) => pendingClose.ids.includes(t.id) && isDirty(t)).map((t) => t.title).join('、')
|
? 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}
|
tauriMode={tauriMode}
|
||||||
zoom={zoom}
|
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>}
|
{notice && <div className="app-notice">{notice}</div>}
|
||||||
{recoveryOffer.length > 0 && !recoveryDismissed && (
|
{recoveryOffer.length > 0 && !recoveryDismissed && (
|
||||||
<RecoveryModal
|
<RecoveryModal
|
||||||
|
|||||||
112
src/components/Stage5Modals.tsx
Normal file
112
src/components/Stage5Modals.tsx
Normal 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
230
src/services/exportHtml.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
@@ -130,8 +130,8 @@ export function pickAndReadWebFile(): Promise<{ name: string; content: string }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Web 降级:把内容作为下载交付(等价于另存为)。 */
|
/** Web 降级:把内容作为下载交付(等价于另存为)。 */
|
||||||
export function downloadTextFile(name: string, content: string): void {
|
export function downloadTextFile(name: string, content: string, mime = 'text/markdown;charset=utf-8'): void {
|
||||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
|
const blob = new Blob([content], { type: mime })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
@@ -224,3 +224,53 @@ export async function dbRecoverySave(entry: RecoveryEntry): Promise<void> {
|
|||||||
export async function dbRecoveryDelete(docKey: string): Promise<void> {
|
export async function dbRecoveryDelete(docKey: string): Promise<void> {
|
||||||
return invoke<void>('db_recovery_delete', { docKey })
|
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
106
src/styles.css
106
src/styles.css
@@ -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 { 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:hover { background: var(--accent-weak); }
|
||||||
.tree-context-menu button.danger { color: #b3261e; }
|
.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
23
src/themes.ts
Normal 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
54
tests/stage5.test.ts
Normal 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('\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('**加粗**')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user