Marksmith / 墨匠:结构感知、本地优先的 Markdown 工作台。|作者个人站:https://softwarescience.top/projects/marksmith
| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
| 23 天前 | ||
| 2 天前 | ||
| 2 天前 | ||
| 2 天前 | ||
| 27 天前 | ||
| 28 天前 | ||
| 2 天前 | ||
| 27 天前 | ||
| 27 天前 | ||
| 27 天前 | ||
| 2 天前 | ||
| 27 天前 | ||
| 27 天前 | ||
| 27 天前 | ||
| 27 天前 |
Marksmith / 墨匠 — 项目设计方案(Tauri 2 + TypeScript)
作者个人站:https://softwarescience.top/ · 本项目主页:https://softwarescience.top/projects/marksmith(版本、截图与下载都在那儿)
一、产品定位
Marksmith / 墨匠:结构感知、本地优先的 Markdown 工作台。
核心卖点:
- 结构感知删除(Delete Intent Resolver):删除表格/图片/代码块等结构边界时,弹窗让用户选择删除语义
- 所见即所得:基于 TipTap(ProseMirror)的块级 WYSIWYG + 行内半 WYSIWYG
- 图表原生渲染:Mermaid 和 PlantUML 在编辑区直接渲染为 SVG
- 本地优先:全部文件在本地,可选离线渲染,无强制联网
- 轻量原生:Tauri 2 打包,安装包 ~10–20MB,内存占用远低于 Electron
二、技术栈总览
| 层次 | 技术 | 说明 |
|---|---|---|
| 桌面壳 | Tauri 2 | Rust 后端 + 系统 WebView |
| 前端框架 | Svelte 5(Runes) | 轻量、响应式、编译时优化 |
| 构建工具 | Vite | 与 Tauri 集成成熟 |
| 编辑器内核 | TipTap 3(ProseMirror) | 无头、扩展式富文本编辑器 |
| Markdown 解析 | remark / unified / mdast | ProseMirror ↔ Markdown 双向转换 |
| 代码高亮 | Shiki | 精确语法高亮 |
| 数学公式 | KaTeX | 行内与块级公式 |
| 图表渲染 | Mermaid.js + PlantUML(本地 jar) | SVG 输出 |
| 样式 | Tailwind CSS 4 + shadcn-svelte | 快速搭建 UI |
| Rust 后端 | tauri-plugin-fs / dialog / updater / clipboard-manager | 文件、对话框、更新、剪贴板 |
| Git 集成 | git2 crate(或调用 git CLI) | 可选,版本控制 |
Svelte 5 + TipTap 的集成已有多个成熟案例,例如 editable-kit 和 tipex 都提供了 Svelte 5 Runes 原生的 TipTap 封装。TipTap 本身是框架无关的,Svelte 绑定的成熟度已足够支撑生产使用。
三、总体架构
┌─────────────────────────────────────────────────────┐
│ Tauri 2 Shell │
│ ┌───────────────────────────────────────────────┐ │
│ │ 前端 (Svelte 5 + Vite) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ │
│ │ │ Toolbar │ │ TipTap │ │ Preview │ │ │
│ │ │ │ │ Editor │ │ Panel │ │ │
│ │ └──────────┘ └──────────┘ └────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ TipTap Extension Layer │ │ │
│ │ │ ┌────────┐ ┌────────┐ ┌──────────────┐ │ │ │
│ │ │ │ Table │ │ Image │ │ DeleteIntent │ │ │ │
│ │ │ │ Ext. │ │ Ext. │ │ Plugin │ │ │ │
│ │ │ └────────┘ └────────┘ └──────────────┘ │ │ │
│ │ │ ┌────────┐ ┌────────┐ ┌──────────────┐ │ │ │
│ │ │ │ Mermaid│ │PlantUML│ │ Markdown │ │ │ │
│ │ │ │ Node │ │ Node │ │ (de)serial. │ │ │ │
│ │ │ └────────┘ └────────┘ └──────────────┘ │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ State Management │ │ │
│ │ │ Editor Store / File Store / Settings │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └───────────────────┬───────────────────────────┘ │
│ │ invoke() / listen() │
│ ┌───────────────────▼───────────────────────────┐ │
│ │ Rust 后端 (src-tauri) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ File I/O │ │ Watcher │ │ Image Proc │ │ │
│ │ └──────────┘ └──────────┘ └──────────────┘ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ Mermaid │ │PlantUML │ │ PDF/HTML │ │ │
│ │ │ Renderer │ │ Renderer │ │ Exporter │ │ │
│ │ └──────────┘ └──────────┘ └──────────────┘ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ Git Ops │ │ Cache │ │ Updater │ │ │
│ │ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
核心设计原则:
- 前端负责编辑体验:TipTap 管理文档模型、选区、撤销重做、输入法
- Rust 负责重活:文件 I/O、文件监听、进程调用(Mermaid CLI / PlantUML)、图片处理、PDF 导出
- 通过 Tauri Command 通信:前端用
invoke()调用 Rust,Rust 用emit()推送事件
四、项目目录结构
marksmith/
├── package.json
├── vite.config.ts
├── tsconfig.json
├── tailwind.config.ts
├── index.html
├── src/ # 前端 (Svelte 5)
│ ├── main.ts
│ ├── App.svelte
│ ├── lib/
│ │ ├── editor/ # 编辑器核心
│ │ │ ├── Editor.svelte # TipTap 编辑器组件
│ │ │ ├── extensions/ # TipTap 扩展
│ │ │ │ ├── table.ts # 表格扩展配置
│ │ │ │ ├── image.ts # 图片节点
│ │ │ │ ├── link.ts # 超链接
│ │ │ │ ├── mermaid.ts # Mermaid 节点
│ │ │ │ ├── plantuml.ts # PlantUML 节点
│ │ │ │ ├── code-block.ts # 代码块 (Shiki)
│ │ │ │ ├── math.ts # KaTeX 公式
│ │ │ │ └── delete-intent.ts # ★ 删除意图解析器
│ │ │ ├── markdown/ # Markdown 序列化
│ │ │ │ ├── parser.ts # mdast → ProseMirror
│ │ │ │ └── serializer.ts # ProseMirror → mdast
│ │ │ └── commands.ts # 自定义命令
│ │ ├── components/ # UI 组件
│ │ │ ├── Toolbar.svelte
│ │ │ ├── BubbleMenu.svelte
│ │ │ ├── TableMenu.svelte
│ │ │ ├── DeleteConflictDialog.svelte # ★ 删除冲突弹窗
│ │ │ ├── ImageBlock.svelte
│ │ │ ├── MermaidBlock.svelte
│ │ │ ├── PlantUmlBlock.svelte
│ │ │ ├── Sidebar.svelte
│ │ │ ├── StatusBar.svelte
│ │ │ └── SettingsPanel.svelte
│ │ ├── stores/ # Svelte 状态
│ │ │ ├── editor.svelte.ts
│ │ │ ├── document.svelte.ts
│ │ │ └── settings.svelte.ts
│ │ ├── bridge/ # Tauri 通信层
│ │ │ ├── fs.ts # 文件读写
│ │ │ ├── render.ts # 图表渲染
│ │ │ ├── export.ts # 导出 PDF/HTML
│ │ │ └── events.ts # 事件监听
│ │ └── utils/
│ │ ├── debounce.ts
│ │ └── hash.ts
│ └── styles/
│ ├── editor.css
│ └── prose.css
├── src-tauri/ # Rust 后端
│ ├── Cargo.toml
│ ├── tauri.conf.json
│ ├── capabilities/
│ │ └── default.json
│ └── src/
│ ├── main.rs
│ ├── lib.rs
│ ├── commands/ # Tauri Commands
│ │ ├── mod.rs
│ │ ├── file.rs # 文件读写
│ │ ├── render.rs # Mermaid/PlantUML 渲染
│ │ ├── export.rs # PDF/HTML 导出
│ │ ├── image.rs # 图片处理
│ │ └── git.rs # Git 操作(可选)
│ ├── services/ # 业务逻辑
│ │ ├── mod.rs
│ │ ├── watcher.rs # 文件监听 (notify)
│ │ ├── cache.rs # 渲染缓存
│ │ ├── mermaid.rs # mmdc 进程管理
│ │ ├── plantuml.rs # PlantUML 进程管理
│ │ └── renderer.rs # 通用渲染服务
│ └── state.rs # 全局状态
├── sidecar/ # 外部二进制
│ ├── mmdc # Mermaid CLI
│ └── plantuml.jar # PlantUML
└── tests/
├── e2e/
└── unit/
五、编辑器核心设计
5.1 TipTap Schema 定义
TipTap 的文档模型就是 ProseMirror Schema,所有内容都是节点(Node):
// 核心节点类型
const schema = {
nodes: {
// 标准
doc, paragraph, heading, text, hardBreak,
// 行内标记
bold, italic, strike, code, link, highlight,
// 块级
bulletList, orderedList, listItem,
blockquote, codeBlock, horizontalRule,
// 结构
table, tableRow, tableCell, tableHeader,
image, mermaid, plantuml,
// 数学
mathInline, mathBlock,
}
}
Markdown 与 ProseMirror 的双向转换通过 mdast 中间层实现:导入时 Markdown → mdast → ProseMirror JSON,导出时反向。TipTap 社区已有 tiptap-markdown 等成熟方案,也可以基于 unified 自建管线。
5.2 Markdown 往返保真策略
interface BlockMeta {
id: string
source: string | null // 未修改块的原始 Markdown 源码
revision: number // 修改版本号
}
未修改的块在保存时直接输出原始 source,修改过的块重新序列化。这样能最大程度保留用户原始格式(如表格对齐、列表符号风格、链接写法)。
5.3 编辑器 UI 交互模式
建议采用块级 WYSIWYG + 行内半 WYSIWYG:
- 块级:段落、标题、列表、引用、代码块都是独立渲染的块,光标进入时激活编辑态
- 行内:
**粗体**在非光标行显示为粗体且淡化标记,光标所在行显示完整 Markdown - 表格:自定义表格组件,支持拖拽列宽、单元格 Tab 导航、右键行列操作
- 图表:Mermaid/PlantUML 作为卡片展示,双击进入源码编辑
六、删除意图解析器(核心差异化功能)
这是整个项目的核心创新点。TipTap 提供了多种机制来实现结构感知删除:
6.1 技术方案
在 ProseMirror 中,所有删除操作都是 Transaction。ProseMirror 的 filterTransaction 插件允许在 Transaction 应用前拦截和检查删除范围。通过遍历 transaction.mapping.maps 中的 step map,可以精确获取被删除的文档区间,判断是否命中结构性节点:
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
export const DeleteIntentPlugin = new Plugin({
key: new PluginKey('deleteIntent'),
filterTransaction(tr, state) {
// 只处理删除操作
if (!tr.docChanged) return true
const deletedRanges: DeletedRange[] = []
tr.mapping.maps.forEach((map, mapIndex) => {
map.forEach((oldStart, oldEnd, newStart, newEnd) => {
if (oldStart === oldEnd) return // 纯插入,跳过
// 遍历被删除范围内的节点
tr.docs[mapIndex].nodesBetween(
oldStart, oldEnd,
(node, pos, parent) => {
if (isStructuralNode(node.type.name)) {
deletedRanges.push({
nodeType: node.type.name,
from: pos,
to: pos + node.nodeSize,
parentType: parent?.type.name,
})
}
}
)
})
})
if (deletedRanges.length > 0) {
// 暂存冲突信息,阻止事务
deleteConflictStore.set({
ranges: deletedRanges,
transaction: tr,
})
return false // 拦截,等用户选择
}
return true
},
props: {
decorations(state) {
// 高亮待删除结构
const conflict = deleteConflictStore.get()
if (!conflict) return DecorationSet.empty
const decos = conflict.ranges.map(r =>
Decoration.node(r.from, r.to, {
class: `delete-highlight delete-highlight--${r.nodeType}`,
'data-delete-target': r.nodeType,
})
)
return DecorationSet.create(state.doc, decos)
},
},
})
6.2 触发场景与候选选项
当 deleteConflictStore 被设置后,Svelte 组件 DeleteConflictDialog.svelte 弹出,高亮目标节点并展示候选操作:
| 触发场景 | 高亮目标 | 候选选项 |
|---|---|---|
| 表格后段首按 Backspace | 整个表格(红框)+ 最后一行(黄底) | ① 删除整个表格 ② 删除最后一行数据 ③ 仅删空行 ④ 按文本合并 ⑤ 取消 |
| 表格前段尾按 Delete | 整个表格 + 第一行 | ① 删除整个表格 ② 删除第一行 ③ 仅删空行 ④ 按文本合并 ⑤ 取消 |
| 列表空项按 Backspace | 当前列表项 | ① 删除此项 ② 提升层级 ③ 合并到上一项 ④ 取消 |
| 代码块后段首按 Backspace | 代码块(红框) | ① 删除整个代码块 ② 删除最后一行 ③ 合并 ④ 取消 |
| 图片后按 Backspace | 图片 | ① 删除图片 ② 按文本删除 ③ 取消 |
| Mermaid 块后按 Backspace | Mermaid 卡片 | ① 删除整个图表 ② 编辑源码 ③ 取消 |
6.3 用户选择后的执行
async function resolveDeleteConflict(
choice: DeleteChoice,
conflict: DeleteConflict
) {
const { state, view } = editor
const tr = state.tr
switch (choice) {
case 'delete-entire-table':
// 删除整个 Table 节点
tr.delete(conflict.ranges[0].from, conflict.ranges[0].to)
break
case 'delete-last-row': {
// 只删除表格的最后一行
const tableNode = state.doc.nodeAt(conflict.ranges[0].from)
const lastRow = tableNode.lastChild
const rowStart = conflict.ranges[0].to - lastRow.nodeSize
tr.delete(rowStart, conflict.ranges[0].to)
break
}
case 'delete-empty-line':
// 仅删除表格与段落之间的空行
tr.delete(conflict.ranges[0].to, conflict.ranges[0].to + 1)
break
case 'merge-as-text':
// 将表格序列化为 Markdown 文本,然后按纯文本合并
const mdText = serializeTableToMarkdown(tableNode)
tr.replaceWith(conflict.ranges[0].from, conflict.ranges[0].to,
state.schema.text(mdText))
break
case 'cancel':
return
}
view.dispatch(tr)
deleteConflictStore.clear()
}
6.4 设置项
- 总是询问 / 仅结构删除询问 / 关闭提示
- 记住上次选择(同类场景 3 秒内自动应用)
- 可配置哪些结构需要询问(表格 / 图片 / 代码块 / 列表 / 链接)
七、图表渲染管线
7.1 Mermaid
Mermaid 是纯 JavaScript 库,可以在前端直接渲染,无需外部进程:
// 前端直接渲染
import mermaid from 'mermaid'
async function renderMermaid(code: string, theme: string): Promise<string> {
mermaid.initialize({ startOnLoad: false, theme })
const { svg } = await mermaid.render(`mermaid-${hash(code)}`, code)
return svg // 插入编辑器节点作为预览
}
但考虑到大图表性能和缓存一致性,建议将渲染结果缓存到 Rust 侧:
// src-tauri/src/commands/render.rs
#[tauri::command]
async fn render_mermaid(
code: String,
theme: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let key = format!("mermaid:{}:{}", theme, sha256(&code));
if let Some(cached) = state.cache.get(&key) {
return Ok(cached);
}
let svg = tokio::task::spawn_blocking(move || {
// 方案 A: 调用 mmdc CLI
let output = Command::new("mmdc")
.args(["-i", "-", "-o", "-", "-t", &theme])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
// ...
}).await??;
state.cache.set(&key, &svg);
Ok(svg)
}
Mermaid 的渲染是异步的,需要防止旧结果覆盖新结果。建议在 Rust 侧对每个渲染任务分配递增 ID,前端只接受最新 ID 的结果。PlantUML 的渲染任务必须跑在 UI 线程之外以保持窗口响应。
7.2 PlantUML
PlantUML 需要 Java 运行时和 plantuml.jar:
#[tauri::command]
async fn render_plantuml(
code: String,
jar_path: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let key = format!("plantuml:{}", sha256(&code));
if let Some(cached) = state.cache.get(&key) {
return Ok(cached);
}
let svg = tokio::task::spawn_blocking(move || {
let output = Command::new("java")
.args(["-jar", &jar_path, "-tsvg", "-pipe"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?
.wait_with_output()?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).to_string());
}
Ok(String::from_utf8(output.stdout)?)
}).await??;
state.cache.set(&key, &svg);
Ok(svg)
}
渲染管线设计要点:
- 防抖:编辑时 400–500ms 防抖后再触发渲染
- 缓存:以
语言 + 代码 hash + 主题为 key,缓存到磁盘 - 异步:所有渲染在 Rust 的
spawn_blocking中执行,不阻塞 UI - 超时:设置 10s 超时,避免进程挂死
- 错误回退:渲染失败时保留源码并显示错误信息
- 取消:前端切换内容时取消旧任务
7.3 前端展示
<!-- MermaidBlock.svelte -->
<script lang="ts">
import { onMount } from 'svelte'
import { invoke } from '@tauri-apps/api/core'
let { code, theme = 'default', editable = false } = $props()
let svg = $state('')
let error = $state('')
let loading = $state(false)
async function render() {
loading = true
error = ''
try {
svg = await invoke('render_mermaid', { code, theme })
} catch (e) {
error = String(e)
} finally {
loading = false
}
}
// 防抖
let timer: number
$effect(() => {
code; theme // 依赖
clearTimeout(timer)
timer = setTimeout(render, 400)
})
</script>
<div class="diagram-card" ondblclick={() => editable = true}>
{#if loading}
<div class="skeleton" />
{:else if error}
<div class="error">
<pre>{error}</pre>
<button onclick={() => editable = true}>编辑源码</button>
</div>
{:else}
<div class="svg-container">{@html svg}</div>
{/if}
</div>
八、工具栏与交互设计
8.1 顶部工具栏
┌──────────────────────────────────────────────────────────────┐
│ 文件 ▾ │ 撤销 重做 │ H1 H2 H3 │ B I S Code │ ≡ • ☑ │ 表格 图片 链接 │ Mermaid PlantUML │ 导出 ▾ │
└──────────────────────────────────────────────────────────────┘
8.2 浮动工具栏(BubbleMenu)
选中文本时出现:加粗、斜体、删除线、代码、链接、高亮。 选中表格时出现:插入行/列、删除行/列、删除表格、对齐、合并单元格。
8.3 表格交互
TipTap 的 Table 扩展提供了完整的表格操作 API:
// 行列操作
editor.chain().focus()
.addRowBefore()
.addColumnAfter()
.deleteRow()
.deleteColumn()
.deleteTable()
.mergeCells()
.splitCell()
.run()
// 自定义扩展:列宽拖拽
const ResizableTable = Table.extend({
addProseMirrorPlugins() {
return [
...this.parent?.() ?? [],
columnResizing({ handleWidth: 5, cellMinWidth: 80 }),
]
},
})
表格与删除意图的结合是核心:在表格边界按 Backspace 时,DeleteIntentPlugin 拦截并弹窗。
8.4 图片处理
- 拖拽:监听
drop事件,读取文件,调用 Rust 侧copy_image_to_assets()复制到项目assets/目录,插入相对路径 - 粘贴:监听
paste事件,检测剪贴板图片,通过 Tauri 的clipboard-manager插件读取图片数据 - URL 图片:支持直接输入 URL
- 尺寸控制:扩展语法
{width=50%}或通过图片节点属性
// 图片节点扩展
const ImageNode = Node.create({
name: 'image',
inline: false,
group: 'block',
draggable: true,
addAttributes() {
return {
src: { default: null },
alt: { default: null },
title: { default: null },
width: { default: null }, // 新增
align: { default: 'center' }, // 新增
}
},
})
8.5 超链接
Ctrl+K插入/编辑链接- 弹窗输入 URL 和 title
Ctrl+Click用系统默认浏览器打开- 右键菜单:复制链接、打开、移除链接、编辑
九、Rust 后端设计
9.1 Tauri Commands 总览
| Command | 功能 | 参数 |
|---|---|---|
read_file |
读取 Markdown 文件 | path: String |
write_file |
写入 Markdown 文件 | path: String, content: String |
watch_file |
开始监听文件变化 | path: String |
unwatch_file |
停止监听 | path: String |
render_mermaid |
渲染 Mermaid 为 SVG | code, theme |
render_plantuml |
渲染 PlantUML 为 SVG | code, jar_path |
copy_image_to_assets |
复制图片到资源目录 | source, project_dir |
export_pdf |
导出 PDF | html, output_path |
export_html |
导出 HTML | html, output_path |
git_status |
获取 Git 状态 | repo_path |
git_commit |
提交更改 | repo_path, message |
clear_cache |
清理渲染缓存 | — |
get_cache_size |
获取缓存大小 | — |
9.2 文件监听
使用 notify crate 监听文件变化,当外部修改时通过 Tauri event 通知前端:
use notify::{Watcher, RecursiveMode, Event};
pub fn start_watching(
path: PathBuf,
app_handle: AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = notify::recommended_watcher(tx)?;
watcher.watch(&path, RecursiveMode::NonRecursive)?;
std::thread::spawn(move || {
for res in rx {
if let Ok(event) = res {
if let EventKind::Modify(_) = event.kind {
app_handle.emit("file-changed", &path).ok();
}
}
}
});
Ok(())
}
前端收到 file-changed 事件后,比较文件内容 hash 是否变化,若变化则提示用户是否重新加载。
9.3 PDF 导出
Tauri 本身不直接支持 PDF 导出,推荐以下方案:
方案 A(推荐) :使用 react-pdf-engine 的 sidecar 模式,在 Rust 侧运行 Chromium 渲染 HTML 并输出 PDF。
方案 B:使用 jsPDF + Tauri 的 fs API,在前端生成 PDF 二进制后由 Rust 写入文件。
方案 C:使用 Typst(纯 Rust PDF 引擎),将 Markdown 转为 Typst 标记后编译。这是最“原生”的方案,不依赖 Chromium,体积小,但需要维护一套 Markdown → Typst 的转换器。
建议:方案 A 为主,方案 C 作为后续优化。
HTML 导出则简单得多,直接将 TipTap 的 HTML 输出 + 内联 CSS + 已渲染的 SVG 图表打包为单文件 HTML。
9.4 自动更新
使用 tauri-plugin-updater,通过更新服务器或静态 JSON 实现自动更新:
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(|app| {
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let update = handle.updater()
.check()
.await
.expect("Failed to check for updates");
if let Some(update) = update {
update.download_and_install(|_, _| {}, || {}).await.ok();
}
});
Ok(())
})
9.5 Git 集成(可选)
Git 操作可以在 Rust 侧使用 git2 crate 或直接调用 git CLI。建议调用 CLI,因为:
git2需要编译 libgit2,增加构建复杂度- CLI 行为与用户终端一致,更可预测
- 通过
tokio::process::Command异步调用
#[tauri::command]
async fn git_status(repo_path: String) -> Result<Vec<FileStatus>, String> {
let output = tokio::process::Command::new("git")
.args(["-C", &repo_path, "status", "--porcelain"])
.output()
.await
.map_err(|e| e.to_string())?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_git_status(&stdout))
}
十、状态管理与撤销重做
10.1 前端状态(Svelte 5 Runes)
// stores/document.svelte.ts
class DocumentStore {
path = $state<string | null>(null)
content = $state('')
dirty = $state(false)
lastSaved = $state<Date | null>(null)
get title() {
return this.path?.split('/').pop()?.replace('.md', '') ?? '未命名'
}
markDirty() { this.dirty = true }
markSaved() { this.dirty = false; this.lastSaved = new Date() }
}
export const documentStore = new DocumentStore()
10.2 撤销重做
TipTap / ProseMirror 内置了成熟的撤销重做机制,基于 Transaction 的历史栈。所有删除意图解析器的操作也作为 Transaction 进入历史栈,Ctrl+Z 一次性恢复。
// 自定义历史扩展配置
const History = Extension.create({
addProseMirrorPlugins() {
return [history({ depth: 200, newGroupDelay: 500 })]
},
})
关键原则:不要在 ProseMirror 之外维护独立的 undo 栈。所有文档修改都走 Transaction,才能保证撤销重做的一致性。
十一、开发路线
M0 — 项目骨架(1–2 周)
- Tauri 2 + Svelte 5 + Vite 项目初始化
- 基础窗口、菜单、文件打开/保存
- TipTap 编辑器集成,基础 Markdown 渲染
remark/unified管线搭建
M1 — 编辑器核心(3–4 周)
- TipTap 完整 Schema:标题、列表、引用、代码块
- Markdown 双向序列化(mdast 中间层)
- 浮动工具栏(BubbleMenu)
- 撤销重做、快捷键
M2 — 表格与删除意图(3–4 周)
- TipTap Table 扩展配置 + 列宽拖拽
DeleteIntentPlugin实现DeleteConflictDialog组件- 高亮装饰(Decoration)系统
- 所有边界场景的测试覆盖
M3 — 图片与链接(2 周)
- 图片拖拽、粘贴、URL 插入
- Rust 侧图片处理(
imagecrate) - 链接插入/编辑/打开
- 剪贴板集成
M4 — 图表渲染(3–4 周)
- Mermaid 前端渲染 + Rust 缓存
- PlantUML 本地渲染(Java + jar)
- 异步渲染管线、防抖、取消
- 缓存管理 UI
M5 — 导出与文件管理(2–3 周)
- HTML 导出(内联 CSS + SVG)
- PDF 导出(sidecar 或 Typst)
- 文件监听、自动保存
- 设置面板
M6 — 打磨与发布(2–3 周)
- 主题(亮/暗/自定义)
- 专注模式、打字机滚动
- 自动更新
- 多平台打包(Windows / macOS / Linux)
十二、关键技术风险与对策
| 风险 | 影响 | 对策 |
|---|---|---|
| Markdown 往返保真 | 用户原始格式丢失 | 未修改块保留 source,修改块重新序列化 |
| 删除意图插件误触发 | 干扰正常编辑 | 严格限定触发条件(仅在结构边界、仅在特定按键) |
| Mermaid 渲染大图表卡顿 | UI 阻塞 | 异步渲染 + 防抖 + 缓存 + 超时 |
| PlantUML 依赖 Java | 用户环境缺失 | 启动时检测,提供下载引导,或改用 Kroki 远程渲染 |
| PDF 导出跨平台差异 | Windows/macOS/Linux 输出不一致 | 优先使用 sidecar Chromium,统一渲染引擎 |
| ProseMirror 与 Markdown 语义差异 | 某些 Markdown 特性无法映射 | 维护自定义 Node/Mark,必要时降级为源码块 |
| Svelte 5 生态成熟度 | TipTap Svelte 绑定不完善 | 已有 editable-kit、tipex 等成熟案例,必要时直接使用 ProseMirror API |
十三、一句话总结
Marksmith / 墨匠 = Tauri 2(原生壳与文件/进程能力) + Svelte 5(轻量 UI) + TipTap 3 / ProseMirror(编辑器内核与删除意图插件) + Mermaid/PlantUML(图表渲染) + Rust 后端(异步渲染、缓存、导出、Git)。
核心差异化是 Delete Intent Resolver——基于 ProseMirror filterTransaction 实现的结构感知删除,让 Markdown 编辑从“删字符”升级为“删语义”。
十四、实现状态(本次开发落地记录)
14.1 已实现
| 模块 | 状态 | 说明 |
|---|---|---|
| 桌面壳 / 窗口 / 打开保存 | ✅ | Tauri 2 + Svelte 5 + Vite;原生对话框开合、最近文件、文件夹浏览(侧栏文件树)、自动保存、外部修改监听 |
| Markdown 往返 | ✅ | remark/unified + mdast 中间层;未修改的块按原始源码逐字节输出,改动过的块才重新序列化;GFM 表格/任务列表/数学/frontmatter/裸 HTML 全覆盖 |
| 所见即所得编辑 | ✅ | TipTap 3 + ProseMirror;标题/列表/任务/引用/代码块/分隔线/表格/图片/链接/公式/Mermaid/裸块 |
| 删除意图解析器 | ✅ | 契约 §6 全场景:表格首尾边界、列表项、代码块、图片、图表;候选动作、高亮装饰、记住上次选择(3 秒内自动应用)、模式与结构白名单可配 |
| 结构还原 | ✅ | 「按文本合并」降级成源码后不是单向门:在源码上右键 →「还原为表格 / 图表 / 代码块 / 公式」,用 Markdown 往返管线原位还原。既认段落里的源码文本(GFM 表格、```mermaid 围栏、其它围栏、$$…$$),也认代码块里的裸源码(靠 language=mermaid 判断);段里夹着源码时给出「先选中源码」的引导 |
| 表格 | ✅ | 列宽拖拽、Tab 导航、单元格选中、行/列/表头/合并拆分/对齐菜单 |
| 图片 | ✅ | 高清原图显示(不降采样)、懒加载 + 异步解码、点击放大灯箱(滚轮缩放 10%–1000%、拖拽平移、双击切换适应/1:1)、拖拽改宽、alt 编辑、粘贴/拖放落盘到内容寻址的资源目录 |
| 图表 | ✅ | Mermaid 前端渲染 + Rust 磁盘缓存(kind:theme:sha256(code))+ 可选 mmdc sidecar;渲染防抖、超时、错误回退显示源码;图表可点击进入同一缩放查看器,default 主题跟随应用明暗 |
| 正文缩放 | ✅ | Ctrl + 滚轮按 50%–300% 等比缩放整篇内容(文字/图片/图表/间距),状态栏显示比例,Ctrl+0 复位 |
| 宽屏 / 专注 | ✅ | 状态栏一键宽屏(栏宽 47rem → 76rem);专注模式淡出非当前块并把栏宽收窄到 40rem |
| 目录 | ✅ | 侧栏实时大纲(H1–H6)按层级缩进、整块或逐级折叠;点击条目锚定到标题;每行可新建同级/子标题并直接输入 |
| Markdown 渲染 | ✅ | 粘贴 Markdown 原文直接转结构;停手 0.6 秒自动渲染块级 Markdown(可关);选中工具栏可手动「渲染 / 取消渲染」,取消后的源码段落保留换行 |
| 复制为 Markdown | ✅ | Ctrl+C / 右键「复制 Markdown」/ 剪切都把纯文本剪贴板写成 Markdown 源码(标题、粗体、链接、表格、图表围栏等),HTML 剪贴板仍保留富文本 |
| 导出 | ✅ | 单文件 HTML(内联 CSS + SVG)、PDF(本机 Edge/Chrome headless --print-to-pdf)、复制 Markdown 源码 |
| Git 集成 | ✅ | 走 git CLI(状态/分支/领先落后/变更列表/diff/提交),非仓库与无改动有明确中文提示 |
| 主题与设计系统 | ✅ | 亮/暗/跟随系统;语义令牌 + 原语类 + lucide 图标;文档区固定行长排版(字号可调,设置即时生效) |
| 右键菜单 | ✅ | 自绘上下文菜单(链接/图片/图表/公式/表格/文本各有专属动作),并屏蔽 WebView2 原生菜单(输入框保留系统菜单) |
| 最近文件 | ✅ | 侧栏只列最近 5 个,「更多最近文件」窗口支持按文件名/路径筛选、打开、单条移除与清空 |
14.2 与设计的差异(有意为之)
- PlantUML 不实现(用户确认暂不支持):图表侧只做 Mermaid。相关依赖(Java + plantuml.jar)与 sidecar 逻辑不引入。
- 不引入 shadcn-svelte:UI 用 Tailwind 4 + 自研原语类(
src/styles/app.css)+lucide-svelte图标,避免生成式组件的运行期体积与主题二次封装。 - 无预览面板:编辑器本身即 WYSIWYG,预览面板会变成双份维护。
- 未做「光标行显示完整 Markdown 标记」:ProseMirror 的原生行内 WYSIWYG 已隐藏标记,做到「光标行显示源码」需要额外的装饰层并与输入法/选区语义冲突,收益不抵风险。
- 自动更新未接入:
tauri-plugin-updater需要签名密钥与可用的发布端点,二者当前都不存在;宁可不留一个假的更新端点。 - Git 用 CLI 而非 git2 crate:与设计一致(§9.5),并回避 libgit2 构建成本。
14.3 踩过的坑(改动原因写在这里)
trailingNode的默认节点取自schema.topNodeType.contentMatch.defaultType(@tiptap/extensions源码),而该值由节点priority顺序决定。mermaid 节点曾用priority: 1001抢输入规则顺序,结果 schema 里 mermaid 排在 paragraph 之前 → 每次文档变化都在文末插入一个空 mermaid 节点。现在:输入规则拆成独立的纯规则扩展MermaidFence(priority 1001),节点本身保持默认优先级,并在StarterKit.configure({ trailingNode: { node: "paragraph" } })显式钉死补位节点。- 删除意图插件必须放行「无按键的整篇替换」:打开文件/载入 Markdown/清空文档走的是
tr.replaceWith(0, docSize, newDoc),旧文档里的表格/图片/图表都会被视作「被删除」而被拦下 —— 表现为「打开文件后编辑器毫无变化且没有任何报错」。现在按「是否由 Backspace/Delete 触发」区分:无按键放行,有按键照旧询问。 - Tailwind 4 的 utilities 层排在自研原语之后(
@layer theme, base, components, utilities):未分层的.ms-*原语会压掉max-w-*之类的工具类。原语统一放进@layer components,组件里才能用工具类覆盖。 - 本机 Windows 保留了 1348–1447 与 9034–9133 端口段:Tauri 默认的开发端口 1420 与常用的调试端口 9333 都在保留段内,绑定会报
already in use。现用开发端口 5199、CDP 调试端口 9222,并在 vite 里显式绑127.0.0.1(默认的localhost在本机只监听 IPv6)。 - 字号设置写错了作用对象:设置写在编辑器容器上靠继承,而
prose.css里.ProseMirror又有固定font-size,继承值永远被覆盖 —— 表现就是「设置显示 16px,正文实际 15px」。现在字号走 CSS 变量--ms-doc-font-size(容器写入、.ProseMirror读取),改设置立刻生效,行高按相对值随之缩放。 - 浮动菜单没有视口夹取:气泡菜单/表格菜单原先用「锚点上沿中点 + 自身
translate(-50%,-100%)」,锚点靠顶或靠边时菜单跑到屏幕外。现在统一走$lib/utils/floating.ts的placeFloating(先按偏好放一侧 → 放不下翻转 → 水平/竖直夹进视口),纯函数部分有单测(tests/unit/floating.test.ts)。 - 没有右键菜单:右击弹出的是 WebView2 原生菜单(英文、含 Reload/Save as),与整体观感割裂。现在编辑器内自绘
ContextMenu(按上下文给动作,删除类动作带DELETE_INTENT_META不再二次询问),非编辑区屏蔽原生菜单、输入框保留系统菜单。 - 导出菜单太窄导致文案折行:
.ms-popover的固定min-width不够放「标签 + 右侧提示」,菜单项被折成两三行。现在浮层宽度按内容撑开(width: max-content)且菜单项white-space: nowrap;导出项右侧改为显示输出格式.html / .pdf / .md,不再出现headless这种实现术语。 - Vite 监听 tests/ 会打断端到端:e2e 脚本每步都写轨迹文件(
tests/e2e/*.json),被 Vite 监听到就触发整页重载,正在跑的断言会拿到空 DOM。现在server.watch.ignored忽略**/tests/**。 - 多实例共用 WebView2 用户数据目录:第二个实例会把参数交给已在运行的浏览器进程,自己那个
--remote-debugging-port根本不会监听(表现为「连不上 CDP」)。e2e 的startApp现在按端口给每个实例分配独立WEBVIEW2_USER_DATA_FOLDER,多个脚本可以并行跑。 - 全局快捷键被正文编辑器自己拦掉:
App.svelte判断「焦点在输入框里就让位」时用了closest('[contenteditable="true"]'),而.ProseMirror本身就是 contenteditable —— 光标在正文里时Ctrl+N / Ctrl+O / Ctrl+Shift+F全部失效(Ctrl+K因为编辑器侧另有 keymap 才侥幸可用)。现在只让给真正的表单控件与编辑器内部的可编辑控件。 - 窗口标题永远不更新:
setTitle需要core:window:allow-set-title权限,capabilities 里漏了,控制台一直 warn、标题栏看不到文件名与脏标记。补权限,并让页面title与原生标题同步(可被端到端断言)。 - 状态栏缓存计数滞后:只在挂载时读一次缓存统计,渲染出新图表后计数不动。现在 Rust 的
put_cached_render/clear_cache广播render-cache-changed,前端订阅即时刷新(不必轮询、也不必重载页面)。 - 「按文本合并」曾经是单向门:把表格/图表降级成源码文本后,界面上没有任何入口变回结构(用户问到才发现)。现在源码上右键 →「还原为表格 / 图表 / 代码块 / 公式」,靠
$lib/editor/structure-restore.ts识别源码类型并用 Markdown 往返管线原位替换;合并后弹窗也会提示可以还原。
14.4 开发与验证命令
bun install # 依赖(本机无 node/npm,一律用 bun)
bun run dev # 前端开发服务器(5199)
bun run tauri:dev # 桌面应用(开发模式)
bun run check # svelte-check 类型检查
bun run test # 前端单测(vitest,104 个)
cd src-tauri && cargo test # Rust 单测(71 个)
# 端到端:驱动真实窗口(WebView2 + CDP)
cd src-tauri && cargo build # 先出 debug 产物
bun tests/e2e/smoke.mjs # 核心行为:往返保真/删除意图/灯箱/图表/缩放/导出/落盘/目录/Markdown 渲染与复制/宽屏与专注(29 步)
bun tests/e2e/ui-menus.mjs # 浮层与面板:字号生效、气泡/表格/右键菜单几何与动作、原生菜单屏蔽、导出菜单、最近文件(16 步)
bun tests/e2e/behavior.mjs # 编辑器行为(11 步):工具栏格式化、撤销重做、快捷键、Ctrl+S 落盘与窗口标题、字号、主题持久化、容错、外部修改重载、缓存、Git IPC、点 X 关窗(无修改直接退出 / 继续编辑 / 保存并关闭 / 保存失败后仍能退出)
bun tests/e2e/screenshot.mjs # 视觉走查:18 张界面截图 + 主题状态
bun tests/e2e/release-smoke.mjs [exe] # 发布产物:命令行传入 .md 能真的打开(等价双击关联文件)+ 点 X 关窗后进程退出
所有 e2e 脚本共用 tests/e2e/harness.mjs(拉起 vite/应用、按 PID 收尾、运行锁与实例隔离);
调试端口默认 9222(可用 MARKSMITH_CDP_PORT 覆盖),多个脚本可并行跑。
bun tests/e2e/screenshot.mjs 的输出目录会打印在终端(Windows 默认在 %TEMP%\marksmith-shots),改动样式后建议先看这 18 张图再提交。
14.5 打包发布
bunx tauri build # 前端构建 + release 编译 + 生成 NSIS 安装包
# 产物:src-tauri/target/release/bundle/nsis/Marksmith_0.9.0_x64-setup.exe
安装包特性(src-tauri/tauri.conf.json 的 bundle 段):
- 按用户安装(
installMode: currentUser,不弹 UAC)、可选安装语言(简体中文 / English); - WebView2 缺失时自动引导安装(
downloadBootstrapper,静默); - 注册
.md/.markdown文件关联:双击文档启动应用并直接打开该文件(启动参数 →take_startup_file命令 → 前端打开,取一次即清空); - 图标、版权、分类、许可文件、简介均写入安装包元数据。
注意:仓库在本机 D: 盘,该盘常只剩几百 MB(Rust 构建树在 src-tauri/target,几个 GB)。空间不足时把构建目录指到别的盘:
CARGO_TARGET_DIR=J:/marksmith-target bunx tauri build
.gitignore 已忽略 **/target/ 与 src-tauri/gen/schemas/,构建产物不会进仓库。
14.6 版本历史(CHANGELOG)
分发物命名规则:NSIS 安装包 <productName>_<version>_x64-setup.exe,文件名里的版本号必须与
package.json / src-tauri/tauri.conf.json / src-tauri/Cargo.toml 三处同号。
| 版本 | 分发物 | 标签 |
|---|---|---|
| 0.1.0 | Marksmith_0.1.0_x64-setup.exe |
v0.1.0 |
| 0.2.0 | Marksmith_0.2.0_x64-setup.exe |
v0.2.0 |
| 0.2.1 | Marksmith_0.2.1_x64-setup.exe |
v0.2.1 |
| 0.3.0 | Marksmith_0.3.0_x64-setup.exe |
v0.3.0 |
| 0.4.0 | Marksmith_0.4.0_x64-setup.exe |
v0.4.0 |
| 0.4.1 | Marksmith_0.4.1_x64-setup.exe |
v0.4.1 |
| 0.4.2 | Marksmith_0.4.2_x64-setup.exe |
v0.4.2 |
| 0.5.0 | Marksmith_0.5.0_x64-setup.exe |
v0.5.0 |
| 0.6.0 | Marksmith_0.6.0_x64-setup.exe |
v0.6.0 |
| 0.7.0 | Marksmith_0.7.0_x64-setup.exe |
v0.7.0 |
| 0.8.0 | Marksmith_0.8.0_x64-setup.exe |
v0.8.0 |
| 0.9.0 | Marksmith_0.9.0_x64-setup.exe |
v0.9.0 |
0.9.0(小版本:宽屏展示 + 专注模式修复)
- 宽屏展示:新增「宽屏」开关(状态栏按钮 + 设置面板),正文栏宽从 47rem 放宽到 76rem;窗口够宽时 正文会铺满可用区域,表格 / 图表较多的文档不再挤在中间一条窄栏里。设置持久化,默认关闭以保持原有版式。
- 专注模式修复:此前只把栏宽从 47rem 改成 40rem,所谓「弱化非当前段落」并没有实现,看起来像没生效。
现在新增
FocusModeHighlight扩展,给光标所在的顶层块加ms-focus-current装饰类,专注模式下其它块 透明度降到 0.34、当前块保持 1,切换段落即刻更新;栏宽仍收窄到 40rem。 - 两个模式可同时开启:宽屏放宽栏宽,专注模式优先收窄并淡出其它块;状态栏按钮带
aria-pressed。 - 验证:smoke 增 1 步(专注模式下当前块透明 1 / 其它块 0.34;宽屏宽度 752 → 1032);截图新增宽屏 对照图(17 → 18 张);设置面板与状态栏截图已走查。
- 为什么:用户反馈「文档能不能宽屏展示」以及「专注模式好像不能用」;根因是专注模式缺了真正的高亮层。
0.8.0(小版本:复制选区为 Markdown 源码)
- 复制不再丢结构:以前 Ctrl+C / 右键「复制」写入的是选区表面文字,粗体、标题、链接、表格等结构
全被抹平;粘贴到 Markdown 编辑器还得重新标记。现在纯文本剪贴板统一走 Markdown 序列化:选中一段
带粗体和链接的文字,复制得到
**粗体** 与 [链接](https://example.com);整段复制保留标题层级, 整节点复制表格 / 图表 / 公式 / 代码块得到对应源码。 - 实现位置:编辑器的
clipboardTextSerializer覆盖 ProseMirror 默认的纯文本序列化(Ctrl+C / 剪切 都生效);右键菜单的「复制」改名为「复制 Markdown」,与「剪切」共用同一套selectionToMarkdown。 - HTML 剪贴板仍由 ProseMirror 生成,粘进富文本编辑器时保留格式;粘进纯文本 / Markdown 编辑器时得到源码。
- 验证:单测新增 3 条(整段源码、表格/图表节点、普通文本不变形);smoke 增 1 步,同时覆盖 Ctrl+C 与
右键复制,并验证剪贴板里确实包含
# 标题/**粗体**/[链接](…)。 - 为什么:用户反馈「选择文本复制时只复制到表面文字」,复制到别处无法还原 Markdown 结构。
0.7.0(小版本:Markdown 原文自动渲染 + 手动渲染 / 取消渲染)
- 粘贴 Markdown 直接变结构:以前纯文本粘贴会被 ProseMirror 按换行拆成一段段原文(
#、-、|都只是普通字符),必须手动重敲。现在粘贴时先检测纯文本,若像 Markdown 就走解析管线直接插入标题 / 列表 / 表格 / 图表等节点;即使剪贴板同时带 HTML,也优先按 Markdown 源码渲染。 - 停手自动渲染(防抖):编辑停顿 0.6 秒后检查当前顶层段落,块级 Markdown 自动转结构;只从块级 开头识别,正文里夹着表格源码不会整段重排;设置面板新增「自动渲染 Markdown」开关,关闭后不自动转换, 仍可手动渲染。
- 选中工具栏双向按钮:选中的文本像 Markdown 时出现「渲染 Markdown」;整节点选中表格 / 图表 / 图片 / 公式 / 代码块时出现「取消渲染」。右键菜单也补了「取消渲染(转为 Markdown 源码)」。
- 取消渲染不会被自动转回:转回的源码段落带
mdSource标记,自动渲染跳过;源码段落用等宽字体并 保留换行显示。「按文本合并」的降级段落同样带这个标记,不会被自动渲染抵消。 - 验证:单测新增 4 条(块级 / 行内识别、正文夹源码不误伤、严格开头判断);smoke 从 23 步增到 27 步 (粘贴、防抖自动渲染与开关、手动渲染行内 Markdown、取消渲染后保持源码并可手动转回);截图 16 → 17 张。
- 为什么:用户反馈「复制了一段 markdown 进来,程序无法自动识别渲染,只当成特殊文本」;粘贴与停手识别 覆盖最常用的两条路径,手动按钮给不想自动转换的用户留出明确控制。
0.6.0(小版本:文档目录 / 大纲)
- 侧栏新增「目录」:从当前文档实时提取 H1–H6 标题树,按层级缩进;整个目录可收成一行标题, 有子标题的条目也能单独折叠/展开。折叠状态按文档切换重置,不会串到另一篇文档。
- 锚定页面位置:点击目录条目把光标放进对应标题,并滚动到视口中央;光标在正文移动时, 目录里当前标题会高亮。
- 直接在目录上新建标题:每行「+」或右键提供「新建同级标题 / 新建子标题」,在当前小节末尾 插入空标题并聚焦,用户直接输入文字即可;目录为空时提供「新建一级标题」。插入位置始终在小节边界, 不会把已有正文误划进新标题。
- 验证:新增 6 条单测(标题树、折叠、当前标题、同级/子标题插入位置);smoke 从 21 步增到 23 步 (目录提取/锚定/两级折叠/新建同级与子标题);前端单测 97 项全过。
- 为什么:长文档没有全局导航,只能在正文里反复滚动找章节;目录把结构变成可点击的锚点, 同时把「新增章节」从滚动定位 + 手敲 Markdown 变成目录上的一次操作。
0.5.0(小版本:图片/图表查看器 + 全文等比缩放 + 图表明暗跟随外观)
- 图片与编译图表共用查看器:原来只有
image节点能开灯箱,Mermaid 渲染出的 SVG 只能挤在正文宽度里看。 现在灯箱同时支持位图与原位 SVG:滚轮缩放、拖拽平移、双击切换适应/原始大小、+/-/0/1快捷键、Esc关闭;图表卡片正文点击、右上角放大按钮、右键「放大查看」都能进入。SVG 内联渲染,放大后线条仍然清晰。 - Ctrl + 滚轮等比缩放正文:整篇内容(文字、图片、图表、间距)通过
zoom一起缩放,不是只改字号; 范围 50%–300%,状态栏显示当前比例并支持一键复位,Ctrl+0也能复位;设置面板新增「内容缩放」滑杆。 缩放值持久化到settings.json,滚轮连续触发时内存即时更新、落盘合并成一次(250ms)。 - 图表明暗自动适配:Mermaid 设置的
default现在表示「跟随外观」——深色外观下自动用dark主题, 避免深色画布上出现黑色连线;切换外观会触发图表重渲染,卡片上的data-svg-theme标记当前 SVG 实际用的主题。 用户显式选的dark/neutral/forest仍保持不变。 - 多图文档首屏更轻:正文图片改为
loading="lazy"+decoding="async",列表滚动到时才解码,查看器仍按原图加载。 - 验证:单测新增 7 条(主题解析、SVG viewBox 尺寸);smoke 从 18 步增到 21 步(图表灯箱、Ctrl+滚轮缩放 + 缩放后输入、深色线条亮度);视觉走查 14 → 16 张(新增图表灯箱与深色图表)。
- 为什么:用户反馈「图多、挤、渲染慢」以及「深色主题下黑色连线看不见」;查看器和等比缩放让密集内容可读, 主题跟随消除了深色下的不可见问题。
0.4.2(补丁:修「打开项目/文档后黑框一闪」)
- 黑框一闪的根因:Windows 上 GUI 子系统进程没有控制台,它启动控制台子程序时系统会默认给子进程
新建一个可见的控制台窗口。应用里
git status由 Git 面板每 1.5 秒轮询一次,于是打开项目或文档后 就不断有 cmd 窗口闪一下。mermaid 旁路渲染(mmdc是.cmd)和导出 PDF 的 headless 浏览器同理。 - 修法:新增
services::hide_console_window,Windows 下给所有子进程加CREATE_NO_WINDOW(0x0800_0000),非 Windows 空操作;git/mmdc/ headless 浏览器三处调用点统一走它。 验证:同一 GUI 父进程启动控制台子程序,不加标志时子进程GetConsoleWindow() != 0(有黑框), 加标志后为 0;cargo test71 项全过。 - 为什么:用户反馈「打开项目和文档之后会莫名其妙跳命令行窗口然后快速关闭」,这是 GUI 子系统 spawn 控制台程序未抑制控制台的典型症状,而不是应用逻辑在跑命令。
0.4.1(补丁:修「点 X 关不掉程序」)
- 窗口关不掉:关闭请求由前端
onCloseRequested接管,确认之后要靠destroy销毁窗口,而 capabilities 里漏了core:window:allow-destroy(core:window:default不含它,只有allow-set-title这类只读权限)。结果是点标题栏 X 不会关:没有未保存修改时完全没反应;有修改时点了「放弃修改并关闭」 同样原地不动,控制台只留一句window.destroy not allowed——也就是用户看到的「关的时候报错」。 - 关窗前能保存了:原来的关闭确认是系统
confirm,只有「放弃修改并关闭 / 继续编辑」两个按钮, 用户在关窗前没有保存这条路。改成应用内弹窗三选一:保存并关闭 / 不保存并关闭 / 继续编辑 (Enter保存并关闭、Esc继续编辑);保存失败、或「另存为」被取消时窗口保持打开,原因写到状态栏。 - 不再丢最后一击键:编辑器到文档 store 的同步有 200ms 防抖,在这一窗口内点 X 会因
dirty还是 false 而直接关掉(丢字)。关闭/换文档前先 flush 待同步改动,再判断是否真的未保存。 - 补 e2e(
tests/e2e/behavior.mjs第 11 步,10 步 → 11 步):发真实 WM_CLOSE,走通 「继续编辑 → 窗口还在且仍是脏的」→「保存并关闭 → 内容落盘 + 进程退出」→ 重启后 「不保存并关闭 → 进程退出且不写盘」;视觉走查(screenshot.mjs)加入这张弹窗截图 (13 张 → 14 张)。顺带修掉 e2e 在 Windows 上自起 Vite 失败(bun是bun.cmd,spawn直接调用报 EINVAL,改走 shell)。 - 为什么:用户反馈「程序在关闭的时候报错,无法保存文件,也无法通过不保存文件退出」——三条都出在 同一个关闭路径上:权限漏了导致关不掉,而弹窗又没给保存的机会。
0.4.0(小版本:把「结构还原」的适用范围补全)
- 代码块里的裸源码现在也能还原:
language=mermaid的代码块(从别处粘来、或围栏输入规则没触发时 留下的形态)内容里没有围栏,之前识别不到、右键没有入口。现在按语言判断,并自动补围栏后解析。 - 段里夹着源码时给出引导:整段不是源码、但里面夹着表格/围栏源码时,右键会出现一条 「还原为结构(先选中源码)」的提示项,而不是让用户对着菜单找不到入口;选中源码那几行再右键即可还原。
- 补测试:单测 9 条(识别规则、语言判断、裸 mermaid 自动补围栏、
containsStructureSource反例); e2e 加两条(代码块源码还原为图表;段里夹源码的引导 + 选区还原且不吞正文),smoke 从 16 步增到 18 步。 - 为什么:上一版只覆盖「整段就是源码」,用户问到「怎么变回表格/图表」时这两条正好是漏掉的路径。
0.3.0(小版本:补上「结构还原」)
- 「删除意图 → 按文本合并」原本是单向门:表格/图表被降级成一段 Markdown 源码文本后, 界面上再没有任何入口把它变回结构。
- 现在增加还原入口:在源码上右键 →「还原为表格 / 还原为图表 / 还原为代码块 / 还原为公式」,
用 Markdown 往返管线解析后原位替换;识别 GFM 表格(含带冒号的对齐行)、
mermaid 围栏、 其它围栏 与块级$$…$$,普通段落不会误出这个入口。 - 合并后弹窗会提示「已按文本合并并保留 Markdown 源码;在这段源码上右键可选『还原为表格 / 图表』」, 让用户知道这不是终点。
- 为什么:用户问到「展示原始代码之后,怎么再变回表格或图表」,而当时的答案是「没有办法」—— 这是旗舰功能的自然闭环,缺了它「按文本合并」就等于数据降级的死路。
0.2.1(补丁:修用户能踩到的缺陷)
- 正文里按
Ctrl+N/Ctrl+O/Ctrl+Shift+F全部失效:判断「是否让位给输入框」时用了closest('[contenteditable="true"]'),而正文编辑器.ProseMirror本身就是 contenteditable。 - 窗口标题永远不更新:
setTitle需要core:window:allow-set-title权限而 capabilities 里漏了, 标题栏看不到文件名与脏标记。 - 状态栏渲染缓存计数滞后:只在挂载时读一次,渲染新图表后不动;改为 Rust 广播
render-cache-changed,前端订阅即时刷新。 - 为什么:这三条都是新补的行为端到端(
tests/e2e/behavior.mjs)跑出来的,修掉后固化为回归断言。
0.2.0(小版本:修观感缺陷 + 加能力)
- 字号设置不生效(设置显示 16px、正文实际 15px):字号写在容器上靠继承,被
.ProseMirror的固定font-size覆盖;改走 CSS 变量后即时生效。 - 气泡/表格菜单没有视口夹取,靠顶或靠边时跑到屏幕外;统一走
placeFloating(翻转 + 夹取)。 - 右键没有自绘菜单,弹出的是 WebView2 原生菜单(英文、含 Reload),与整体观感割裂;
新增按上下文(链接/图片/图表/公式/表格/文本)给动作的
ContextMenu,非编辑区屏蔽原生菜单。 - 新增「最近文件」窗口:侧栏只列最近 5 个,其余进可筛选窗口(打开 / 单条移除 / 清空,均持久化)。
- 导出菜单文案折行与
headless术语修正,右侧改显示输出格式,默认落在当前文档目录。 - 新增
tests/e2e/ui-menus.mjs(16 步)与浮层几何单测;e2e 基建加运行锁、实例隔离、 Vite 忽略**/tests/**。 - 为什么:用户反馈界面/菜单观感差、导出选项格式不对、最近文件能力不足。
0.1.0(首个可用版本)
- Tauri 2 + Svelte 5(Runes)+ TipTap 3 的本地优先 Markdown 工作台主体:Markdown 往返保真 (未修改块按原始源码输出)、删除意图解析器(结构感知删除 + 候选语义 + 高亮 + 记住选择)、 表格、图片高清显示与点击放大灯箱、KaTeX 公式、Mermaid 渲染与磁盘缓存、单文件 HTML 与 PDF 导出、Git 面板、亮暗主题与设计系统。
- Rust 后端:文件读写/监听、图片内容寻址落盘、渲染缓存、导出、Git CLI 封装、启动参数
(双击关联的
.md直接打开)。 - NSIS 安装包:中英安装语言、按用户安装、
.md/.markdown关联、WebView2 引导。 - 为什么:仓库此前只有设计方案,需要一个能跑、能打包、核心差异点(结构感知删除)可验证的实现。
关于
这个项目由 CodeLife Leon 独立开发与维护。更多项目、各项目发版信息、赞赏与联系方式都在个人站:
- 个人站:https://softwarescience.top/
- 本项目主页:https://softwarescience.top/projects/marksmith
- 下载与发版:https://softwarescience.top/releases/marksmith
- 赞赏支持:https://softwarescience.top/sponsor(自愿,不影响任何版本的发布)
问题反馈与需求建议:优先在仓库提 Issue,也可以邮件 virgil_lgp@163.com。
项目介绍
Marksmith / 墨匠:结构感知、本地优先的 Markdown 工作台。|作者个人站:https://softwarescience.top/projects/marksmith
https://softwarescience.top/projects/marksmith定制我的领域