Marksmith:基于 Tauri 2、Svelte 5 与 TipTap 的 Markdown 工作台项目

Marksmith / 墨匠:结构感知、本地优先的 Markdown 工作台。|作者个人站:https://softwarescience.top/projects/marksmith

分支1Tags12
文件最后提交记录最后更新时间
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 侧图片处理(image crate)
  • 链接插入/编辑/打开
  • 剪贴板集成

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 踩过的坑(改动原因写在这里)

  1. trailingNode 的默认节点取自 schema.topNodeType.contentMatch.defaultType(@tiptap/extensions 源码),而该值由节点 priority 顺序决定。mermaid 节点曾用 priority: 1001 抢输入规则顺序,结果 schema 里 mermaid 排在 paragraph 之前 → 每次文档变化都在文末插入一个空 mermaid 节点。现在:输入规则拆成独立的纯规则扩展 MermaidFence(priority 1001),节点本身保持默认优先级,并在 StarterKit.configure({ trailingNode: { node: "paragraph" } }) 显式钉死补位节点。
  2. 删除意图插件必须放行「无按键的整篇替换」:打开文件/载入 Markdown/清空文档走的是 tr.replaceWith(0, docSize, newDoc),旧文档里的表格/图片/图表都会被视作「被删除」而被拦下 —— 表现为「打开文件后编辑器毫无变化且没有任何报错」。现在按「是否由 Backspace/Delete 触发」区分:无按键放行,有按键照旧询问。
  3. Tailwind 4 的 utilities 层排在自研原语之后(@layer theme, base, components, utilities):未分层的 .ms-* 原语会压掉 max-w-* 之类的工具类。原语统一放进 @layer components,组件里才能用工具类覆盖。
  4. 本机 Windows 保留了 1348–1447 与 9034–9133 端口段:Tauri 默认的开发端口 1420 与常用的调试端口 9333 都在保留段内,绑定会报 already in use。现用开发端口 5199、CDP 调试端口 9222,并在 vite 里显式绑 127.0.0.1(默认的 localhost 在本机只监听 IPv6)。
  5. 字号设置写错了作用对象:设置写在编辑器容器上靠继承,而 prose.css 里 .ProseMirror 又有固定 font-size,继承值永远被覆盖 —— 表现就是「设置显示 16px,正文实际 15px」。现在字号走 CSS 变量 --ms-doc-font-size(容器写入、.ProseMirror 读取),改设置立刻生效,行高按相对值随之缩放。
  6. 浮动菜单没有视口夹取:气泡菜单/表格菜单原先用「锚点上沿中点 + 自身 translate(-50%,-100%)」,锚点靠顶或靠边时菜单跑到屏幕外。现在统一走 $lib/utils/floating.ts 的 placeFloating(先按偏好放一侧 → 放不下翻转 → 水平/竖直夹进视口),纯函数部分有单测(tests/unit/floating.test.ts)。
  7. 没有右键菜单:右击弹出的是 WebView2 原生菜单(英文、含 Reload/Save as),与整体观感割裂。现在编辑器内自绘 ContextMenu(按上下文给动作,删除类动作带 DELETE_INTENT_META 不再二次询问),非编辑区屏蔽原生菜单、输入框保留系统菜单。
  8. 导出菜单太窄导致文案折行:.ms-popover 的固定 min-width 不够放「标签 + 右侧提示」,菜单项被折成两三行。现在浮层宽度按内容撑开(width: max-content)且菜单项 white-space: nowrap;导出项右侧改为显示输出格式 .html / .pdf / .md,不再出现 headless 这种实现术语。
  9. Vite 监听 tests/ 会打断端到端:e2e 脚本每步都写轨迹文件(tests/e2e/*.json),被 Vite 监听到就触发整页重载,正在跑的断言会拿到空 DOM。现在 server.watch.ignored 忽略 **/tests/**。
  10. 多实例共用 WebView2 用户数据目录:第二个实例会把参数交给已在运行的浏览器进程,自己那个 --remote-debugging-port 根本不会监听(表现为「连不上 CDP」)。e2e 的 startApp 现在按端口给每个实例分配独立 WEBVIEW2_USER_DATA_FOLDER,多个脚本可以并行跑。
  11. 全局快捷键被正文编辑器自己拦掉:App.svelte 判断「焦点在输入框里就让位」时用了 closest('[contenteditable="true"]'),而 .ProseMirror 本身就是 contenteditable —— 光标在正文里时 Ctrl+N / Ctrl+O / Ctrl+Shift+F 全部失效(Ctrl+K 因为编辑器侧另有 keymap 才侥幸可用)。现在只让给真正的表单控件与编辑器内部的可编辑控件。
  12. 窗口标题永远不更新:setTitle 需要 core:window:allow-set-title 权限,capabilities 里漏了,控制台一直 warn、标题栏看不到文件名与脏标记。补权限,并让页面 title 与原生标题同步(可被端到端断言)。
  13. 状态栏缓存计数滞后:只在挂载时读一次缓存统计,渲染出新图表后计数不动。现在 Rust 的 put_cached_render / clear_cache 广播 render-cache-changed,前端订阅即时刷新(不必轮询、也不必重载页面)。
  14. 「按文本合并」曾经是单向门:把表格/图表降级成源码文本后,界面上没有任何入口变回结构(用户问到才发现)。现在源码上右键 →「还原为表格 / 图表 / 代码块 / 公式」,靠 $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 test 71 项全过。
  • 为什么:用户反馈「打开项目和文档之后会莫名其妙跳命令行窗口然后快速关闭」,这是 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 独立开发与维护。更多项目、各项目发版信息、赞赏与联系方式都在个人站:

问题反馈与需求建议:优先在仓库提 Issue,也可以邮件 virgil_lgp@163.com。

项目介绍

Marksmith / 墨匠:结构感知、本地优先的 Markdown 工作台。|作者个人站:https://softwarescience.top/projects/marksmith

https://softwarescience.top/projects/marksmith定制我的领域