在 Markdown 编辑器里放一个「实时 / 源码」开关,看着像一次 v-if 的事。真做起来,它是两个编辑器内核在抢同一份文档:实时视图是 ProseMirror(Milkdown Crepe 那套),源码视图是 CodeMirror 6,两者的文档模型、选区表示、滚动容器、undo 历史全都不一样。切换那一刻要销毁一个、建起另一个,还得保证内容不丢、光标不跳、脏标记不误判、撤销栈不炸。
这篇拆的就是这条链路,外加它周围的三块:图片落盘与一个上游 bug 造成的静默丢图、查找与多文件搜索、导出 HTML / PDF。代码在 yyzTools 的内置 Markdown 模块(src/web/markdown),依赖 @milkdown/crepe 7.22.2 + @milkdown/kit 7.22.2 + Vue 3.5,宿主是 WebView2。
契约面先收窄:两个内核只对齐两个接口
lib/source-editor.js 的文件头把这件事写成了注释:
arduino
// 与 crepe(实时视图)平行的第二内核:只对齐两个接口------
// getValue()(= getMarkdown)与 onUpdated 回调(= markdown-updated 事件),
// App 的 dirty 判定 / 字数统计 / autosave / 保存链路即可在两种内核间复用。
外层对"现在跑的是哪个内核"完全无感知,这是让双模式不失控的前提。否则 App 里会长出一堆 if (mode === 'source'),而模式恰恰是最容易在异步流程中间变化的东西。
第二条约束更硬:内核实例一律存在闭包里,绝不进 ref / reactive。
csharp
// 关键约束:两个内核实例一律存在闭包里(不放进 ref/reactive),
// 绝不被 Vue 的 Proxy 包裹,避免 milkdown / CM 内部对象被响应式污染。
let crepe = null
let pmView = null
let src = null
把编辑器塞进 ref() 是太顺手的事了,代价是 ProseMirror 的 state、插件实例、DOM 引用全被 Proxy 包一层------view !== ref.value.view 这类"同一个对象比较不相等"的怪事只是轻的,重的是内部不变量被破坏后行为不可预期。
第三条来自多标签:每个 tab 一个 EditorView 实例,用 v-show 切换而不是 v-if。实例保活,undo 历史跟着 PM state 一起留着;v-if 一销毁,切回去历史就空了。
顺手记一个源码视图的小坑:minimalSetup 的 defaultKeymap 故意不绑 Tab ------CodeMirror 把缩进留给应用自己绑(indentWithTab 就是为此存在),不补上,源码视图里按 Tab 焦点直接飞出编辑器,嵌套列表没法缩进,而实时视图的代码块里 Tab 是能缩进的------两边手感不一致。@codemirror/commands(indentWithTab 所在包)不是本工程的直接依赖,于是手写了等价命令:空选区插 4 空格,多行选区整行缩进 / 反缩进(反缩进剥一个 tab 或至多 4 空格)。
切换即销毁重建,于是有了竞态
模式切换没有"共享一份文档模型"这种省事做法,只能销旧建新。而 crepe.create() 是异步的、要几百毫秒。这段时间里用户完全可能再点一次开关,或者直接切走标签页。
做法是一个代际计数器,每个异步入口领号,每个 await 之后对号:
javascript
async function mountEditor(md) {
const gen = ++switchGen
...
const host = document.createElement('div')
rootEl.value.appendChild(host)
const c = createCrepe(host, md, { ... })
await c.create()
if (gen !== switchGen) {
// 已被更新的操作取代:销毁刚建好的实例并摘除自己的容器,不接手、不 pushText
try { await c.destroy() } catch (e) { console.error('[markdown] destroy:', e) }
host.remove()
return gen
}
crepe = c
pmView = c.editor.action((ctx) => ctx.get(editorViewCtx))
}
三个细节值得单独说。
每次挂载用自己的独立容器 ,销毁走 host.remove(),不是 rootEl.innerHTML = ''。并发挂载时胜者的 DOM 可能已经进来了,把宿主整个清空就是白屏------实例活着、界面空了,比叠屏更难查。
mountEditor 返回它认领的那个 gen,调用方拿它再核一次:
scss
const mg = await mountEditor(text)
if (mg !== switchGen || !pmView) return
pmPlaceAnchor(pmView, anchor)
少了这一步,切模式前取到的光标锚会落进已经换好的新文档里------文档换了,锚点还在按旧文档的文本找块。
onBeforeUnmount 第一件事是 switchGen++ 。组件都卸载了,在途流程还在等 create(),放它跑完会在游离 DOM 上建出一个没有任何卸载钩子能销毁的编辑器:每命中一次,泄漏一整个实例。
对应的真实故障长这样:两个 crepe 并发创建、双双 append 进容器,界面叠显双份文档;先建者被覆盖引用、永不 destroy,而它的 markdown-updated 还在往外发,脏判定跟着一起被污染。
什么时候才允许建内核
有两个时机约束是踩过才知道的。
第一条:不要在 display:none 的容器里创建编辑器 。measure 直接失效,focus() 和 DOM 选区静默失败------不报错,就是不生效。所以切换先翻 activeMode、等 nextTick 让 v-show 落地,再建内核:
arduino
// 时序要点:先翻 activeMode 等 nextTick(v-show 落地)再建内核------在 display:none
// 容器里创建的编辑器 measure 失效,且 focus()/DOM 选区会静默失败(实测踩过)。
多标签的懒挂载是同一件事的另一面:新开的后台标签不建内核,setMarkdown 只把内容暂存,首次激活时由 ensureMounted() 消费。
javascript
async setMarkdown(md) {
const text = md || ''
lastText = text
if (!props.active || !booted) {
pendingSet = text
// 排队调用共享同一个 Promise(挂到真正 mount 完成才结算)
if (!pendingPromise) pendingPromise = new Promise((res) => { pendingResolve = res })
return pendingPromise
}
...
}
这里共享同一个 pendingPromise 不是风格问题。如果每次调用各 new 一个,第二次及以后的调用会拿到 undefined,调用方(loadTabContent)就在还没归一化的文本上建了 baseline------结果是打开即误脏,接着自动保存顺手把换行符改写一遍。一个 Promise 复用不当,能变成"文件内容被悄悄改了"。
第二条:watch(() => props.active, ...) 必须带 immediate: true。首帧就激活的标签(新建、双击打开)不会经历 false → true 的翻转,不加 immediate,内容走了暂存路径却没人消费,装载流程死等。
光标跟着人走:两个文档模型之间的近似锚点
切换模式后光标跳到文档开头,是最容易被用户感知到的失礼。而 PM 和 CM 的坐标没法换算 :markdown 的语法标记(##、**、[]())在 PM 的 textContent 里根本不存在,同一个视觉位置在两边是完全不同的序号。
lib/mode-anchor.js 的解法是绕开坐标,用内容当锚:
arduino
// 无法直接换算坐标(markdown 语法标记在 PM textContent 中不存在),因此用
// 「光标所在块的纯文本内容 + 块内偏移」做锚点互相定位:
// 取锚(切换前)→ 销旧建新 → 落锚(切换后,找不到则静默留在默认位置)。
CM 侧取锚时先把行语法前缀和标记剥掉,得到能和 PM textContent 比对的纯文本:
javascript
function normalizeLine(raw) {
return raw
.replace(LINE_PREFIX, '')
.replace(/![([^]]*)]([^)]*)/g, '$1') // 图片 → alt
.replace(/[([^]]*)]([^)]*)/g, '$1') // 链接 → 文本
.replace(/[*_~`]+/g, '') // 加粗/斜体/删除线/行内代码符号
}
两个模型之间的空隙得单独补:markdown 里块与块之间的空行,在 PM 里没有对应空段。光标停在空行上时,CM 侧向上找最近一个非空块,偏移给一个 1e9,由落锚侧 clamp 到块尾。
这套匹配是近似的,注释里也这么写着:光标落在含行内标记的行里会有几字符偏差,长文档里的重复文本可能命中第一处。它的目标只是"切换后光标别跳到文档开头",不是像素级还原------把目标定成这样,实现才能这么薄。
落锚后还有一步校正。scrollIntoView 只保证最小可见(贴顶或贴底),光标恰好在屏幕边缘时人是看不清的,所以下一帧再做垂直居中:
csharp
function pmScrollHost(pmView) {
let el = pmView.dom && pmView.dom.parentElement
while (el) {
if (el.scrollHeight > el.clientHeight) {
const oy = getComputedStyle(el).overflowY
if (oy === 'auto' || oy === 'scroll') return el
}
el = el.parentElement
}
return null
}
滚动父级是沿 DOM 祖先链找"真正可滚的那个"(scrollHeight > clientHeight 且 overflowY 为 auto/scroll),而不是假定某个固定的 scrollDOM------找错了对象,居中就是空转。
图片:三处入口、blob URL,和一次静默丢图
图片有三个入口:粘贴(实时视图由 crepe 的 onUpload 接,源码视图自己挂 paste 事件)、OS 文件拖入、图片块的上传按钮 / 斜杠菜单。三条路最后都汇到同一个落盘函数:
javascript
export async function saveImageBlob(file, docDir) {
if (!docDir) throw new Error('NO_DOC_DIR')
const ext = extOf(file)
const name = `image-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.${ext}`
const buf = await file.arrayBuffer()
const b64 = arrayBufferToBase64(buf)
const abs = `${docDir}/assets/${name}`
const r = await ZenAPI.writeFile(abs, b64, { binary: true })
if (!ZenAPI.isOk(r)) throw new Error('WRITE_FAILED: ' + (r.errorMsg || r.error))
return { rel: `assets/${name}`, abs } // 文档里只写相对路径
}
写进去的是相对路径 assets/xx.png,<img> 实际加载的却是 blob URL。因为页面本身跑在 http 源上(生产是虚拟主机,开发是 localhost:5173),http 页面不允许加载 file:// 子资源 ,dev 下直接 ERR_UNKNOWN_URL_SCHEME。相对路径 → 绝对路径 → 读盘 → URL.createObjectURL 这层转换挂在 crepe 的 proxyDomURL 上,结果按绝对路径缓存,且不主动 revoke :图片还在文档里时回收 URL,<img> 当场破图。
OS 拖入这条链路最近改过一次。原来是 appendMarkdown() 文末追加------它走的是"整档重建",undo 历史和光标位置一起没了。现在改成光标处插入,语义与 crepe 自己的粘贴上传对齐:
csharp
const nodeType = view.state.schema.nodes['image-block']
const node = nodeType ? nodeType.createAndFill({ src: rel }) : null
if (node) {
view.dispatch(view.state.tr.replaceSelectionWith(node).scrollIntoView())
view.focus()
return
}
replaceSelectionWith 会自动拆分段落、进 undo 栈、可撤销。源码视图侧走 replaceSelection(''),同一个函数按当前内核分流。
然后是那次丢图。现象很脏:粘贴图片,界面上有,保存之后重新打开,图没了------而且文件里那一行 markdown 也没了。查下去是三层叠加:
arduino
// 上游 bug 修复(milkdown 7.22.2):mdast 里无 title 的图片是 title:null,而
// image / image-block schema 声明了 validate:'string',prosemirror 属性校验对 null
// 直接抛错,ParserState.addNode 又把异常吞掉 → 重新打开文件时图片节点被静默丢弃,
// 自动保存随即把图片从文件里抹掉。
三段都能在 node_modules 里对上号。@milkdown/preset-commonmark 的 image schema 里 title: { default: '', validate: 'string' },parseMarkdown 的 runner 把 node.title 原样传进 state.addNode;prosemirror-model 的 validateType 明确把 null 当 "null" 比对,RangeError 抛出;而 @milkdown/transformer 的 ParserState.addNode 是这么写的:
kotlin
this.addNode = (nodeType, attrs, content) => {
try { this.#addNodeAndPush(nodeType, attrs, content); }
catch (e) { console.error(e); }
return this;
};
节点被吞掉,文档少一张图,自动保存把"少一张图"当成用户的最新意图写回磁盘。修在 remark 阶段,把 null 的 title / alt 归一成空串:
javascript
const normalizeImageAttrs = (ctx) => {
const remarkPlugin = () => (tree) => { /* 遍历 mdast,null → '' */ }
ctx.update(remarkPluginsCtx, (rp) => [...rp, { plugin: remarkPlugin, options: {} }])
return () => {}
}
这个补丁本身又踩了两个坑,都是"为什么不生效"级别的问题。第一,插件必须以工厂 形态 push:unified 的 use(plugin, options) 会先调 plugin(options) 拿真正的变换器,直接把变换函数塞进去,它会被当成工厂、收到的"tree"其实是 options 空对象。第二,必须在插件的同步阶段 写 remarkPluginsCtx------schema 插件在 InitReady 之后一次性读这个列表组装 processor,而 $remark 的实现是等 InitReady 才注册,永远晚于读取时机。
查找:一套匹配语义,两处消费
单文件查找(Ctrl+F 的浮层)和侧栏多文件搜索(Ctrl+Shift+F)是两套 UI、两种数据源,但匹配语义必须逐字符一致 ,否则用户会遇到"这里搜得到那里搜不到"。所以大小写 / 全字 / 正则三个开关被抽进 lib/find-match.js 的 createMatcher,两边共用。
中文全字匹配是个真问题:\b 的词边界定义在 \w 上,CJK 字符不在 \w 里,无脑加 \b 会让所有中文查询在全字模式下搜不到。解法是只在 ASCII 一侧锚定:
javascript
const lead = wholeWord && /^\w/.test(query) ? '\b' : ''
const tail = wholeWord && /\w$/.test(query) ? '\b' : ''
查询词首 / 尾是词字符才加 \b------跟 VSCode 一致,中文查询词在全字模式下仍按子串命中。替换串同理:字面模式下 $ 不做组引用解释(replacement.replace(/$/g, '$$$$')),只有正则模式才展开 $1..$9。
实时视图侧是一个标准 ProseMirror 插件:命中全部用 Decoration.inline 标出来,当前命中换一个类名;匹配只在单个文本节点内做,不跨块(跨块的命中在 WYSIWYG 里没有稳定的替换语义)。跳转时 initialActive 从光标位置起算而不是文档开头------Ctrl+F 预填了选区,就该从选区那处命中开始,而不是跳回文档第一处。替换全部则倒序执行,避免前面的替换把后面的位置挪掉:
css
st.matches.slice().sort((a, b) => b.from - a.from).forEach((m) => { ... })
防抖两边都有,量级不一样。查找条 150ms:setQuery 每次都要全文档扫描 + 全量重建 DecorationSet,逐键直发在大文档上明显掉帧;Enter 和替换动作先 pushQueryNow() 强制结算再导航。侧栏多文件搜索 300ms,且把输入、三个开关切换(含 Alt+C/W/R 快捷键路径)统一挂到一个 watch 上调度:
scss
function scheduleSearch() {
clearTimeout(searchTimer)
if (!searchQuery.value.trim()) { runSearch(); return } // 空查询立即清结果
searching.value = true // 防抖窗口内先亮「搜索中」,避免旧空态闪「无结果」
searchTimer = setTimeout(runSearch, SEARCH_DEBOUNCE_MS)
}
多文件搜索的取消比防抖更要命,因为它要遍历目录树。同样是一个代号护栏,外加两处提前止损:
ini
if (searchRegex.value && !createMatcher(q, searchOpts())) {
searchInvalid.value = true; ... return // 否则要走完树遍历(最多 3000 次 readDir IPC)才报「无效的正则」
}
...
r = await searchFiles(files, q, () => gen !== searchGen, searchOpts())
读盘按 8 个一批并发(CHUNK = 8),每批之间查一次取消标志;结果侧三个上限:最多 200 个文件、每行文本截到 160 字符、按命中数降序排。先用 matcher.findAll(text, 1) 探一次有没有命中,有才进逐行扫描------一次正则执行的开销,换掉一整轮字符串遍历。
导出:一份 HTML,两种落盘
导出 HTML 和导出 PDF 共用同一个渲染核 buildExportHtml(md, { title, dir }):marked 解析 + CodeMirror 6 highlightTree 出代码高亮 + mermaid 渲染成内联 SVG,产出一份自包含的 HTML 文档串。HTML 走前端 writeFile 落盘,PDF 把同一串 HTML 交给原生------两种格式所见即所得地一致,因为只有一份渲染实现。
导出物要脱离宿主,所以三件事必须在导出那一刻做完:
- 主题快照 :浅 / 深两套具体色值都内嵌进导出文档,按
<html data-theme>切换。不能依赖--zen-*变量------脱离宿主没人注入变量。 <base href="file:///<md 所在目录>/">:文档里的相对路径图片才能解析。这里有个纯字符串坑:encodeURI不转义#和?,而这两个字符在 Windows 文件名里合法------目录名带#时 base 从#处被当 fragment 截断,导出文件里所有相对图片一起失效。得手动补.replace(/#/g, '%23')。- 活动内容清洗 :marked 默认放行 markdown 里嵌的原始 HTML。宿主内的 milkdown 不执行脚本,但导出物在浏览器里打开会执行。剥掉
script/iframe/object/embed、所有on*属性和javascript:的 href,最小化清洗,不为此引 DOMPurify。
PDF 的最后一跳在原生侧:写临时 HTML → 开一个看不见的 WebView2 → 加载 → PrintToPdf。

这条链路上有五个坑:
打印边距必须为 0。 物理页边距区域永远画不出文档背景,只吃 UA 画布色------深色主题近黑、浅色纯白,先后出现过黑边和白边。全出血让根元素背景铺满整页,视觉留白交给导出 CSS 的 @media print padding 承担;深色主题还要 ShouldPrintBackgrounds = TRUE 才印得出底色。同理,背景必须挂在根元素上而不是 body:Chromium 打印的画布背景从根元素取,只挂 body 时画布会退回 UA 底色,页边距区露黑边。
表格在打印语境下要改回真表格盒。 屏幕端是 display: block; overflow-x: auto 的横向滚动容器,打印时它是个不可分割的单体盒,叠上 break-inside: avoid 会让长表整块推到下一页、前一页大面积留白。打印改回 display: table + 允许跨页(thead 每页自动重复),只禁止在单行内部断开。
隐藏窗不能是 0×0。 WS_POPUP 不带 WS_VISIBLE、永不 ShowWindow,但尺寸要给足(800×1200),否则宿主把子窗裁成零尺寸会引出布局边角问题;精确视口再由 put_Bounds 按 A4 的 CSS 尺寸 × 窗口 DPI 校正,这样 width: 100% 的元素是按纸面宽度布局的。
不能用虚拟主机名。 主窗口的 SetVirtualHostNameToFolderMapping 是 per-WebView 的设置:隐藏窗复用同一个 environment,但不继承映射表,走 https://yyztoolsX.localhost/... 必 ERR_NAME_NOT_RESOLVED。只能 file:/// 直载,且盘符冒号不参与 URL 编码(编码成 %3A 后 file URL 认不出盘符)。
导航完成事件不带 URI,只能按 NavigationId 认领。 控制器初始文档 about:blank 被 Navigate 打断时也会触发一次 NavigationCompleted(FALSE),不核对 id 就会拿这个失败态去判"加载失败"。最后,隐藏窗上挂一个 90 秒看门狗定时器:控制器创建 / 导航 / 打印任一环卡死都要有兜底,否则前端那个 await 永久悬挂。收尾时回调必须是最后一步------回调里可能释放持有本对象的 shared_ptr,之后不能再碰任何成员。
主题与按需加载的几条硬约束
Crepe 的主题只引 classic(浅)/ classic-dark(深)两套,但必须先引 theme/common/style.css :common 聚合了斜杠菜单、浮动工具栏、link tooltip、block 手柄的定位布局,classic.css 只有颜色变量。漏了 common,浮动层没有 position,斜杠菜单直接退化成一个列表流进正文------功能全在,就是排版塌了。
深色表的选择器提升靠 Vite 的 ?inline:
javascript
import darkCssText from '@milkdown/crepe/theme/classic-dark.css?inline'
darkStyle.textContent = darkCssText.replaceAll('.milkdown', 'html[data-theme="dark"] .milkdown')
由宿主 C++ 注入的 documentElement.dataset.theme 驱动,编辑器内部不放主题开关。外壳在 .milkdown 上成对设置 background-color: var(--crepe-color-background),禁止硬编码白底 ------否则深色模式下就是白字白底。源码视图、代码高亮、设置项字号同理,全部消费 CSS 变量(--zen-* 配色 + --md-* 设置),换主题和改设置都不需要重建编辑器。
按需加载决定了首屏体积。构建产物里首屏那块 markdown-*.js 是 919 KB(gzip 273 KB),KaTeX 与 mermaid 都在异步 chunk 里:KaTeX 那份 gzip 约 75 KB,mermaid.core 约 129 KB,只有真的写进数学公式或点开 mermaid 预览才会拉。
mermaid 预览有个上游行为要处理:code-block 对 [text, language] 的 watch(immediate)在每个字符输入后 都会带一个新 applyPreview 闭包重调 renderPreview,逐键跑 mermaid.render(几十到上百毫秒)会闪得没法看。这里做 180ms 尾随合并,同块连打靠内容前后缀近似度认进同一个 pending 槽(因为闭包每键都换、不能当 key),渲染本体串成一条链(mermaid.render 并发历史上脆弱)。渲染出的 SVG 颜色是快照、跟不了 CSS 变量,所以主题翻转要按登记的存活预览统一重画,并用一个代数号丢弃过期结果。
如果换你来写,这几条能直接搬走
编辑器内核实例不进响应式,这是 Vue 项目里引第三方编辑器最容易忽略的一条。
异步挂载要有代际令牌,而且每个 await 之后都要校验;组件卸载时先 bump 再谈别的,否则泄漏的是整个编辑器。别在 display:none 的容器里创建编辑器,它不报错,只是不生效。
跨文档模型定位光标,用「内容 + 块内偏移」而不是坐标换算;把目标定成"别跳到文档开头",实现就能只有 140 行。
上游把异常吞掉时,修正在更早的阶段做(这里是 remark),并且检查你的注册时机是不是晚于它的读取时机------$remark 就是这么安静地不生效的。
打印的边距区域画不出背景,导出物的样式要自包含;encodeURI 不转义 # 和 ?,路径进 URL 时得自己补。
附
以上实现出自 yyzTools 1.1.2(Windows 10/11 x64,官网 yyztools.com,免费),本文仅作技术笔记存档,接口与阈值以源码为准。