鸿蒙 PC Markdown 编辑器即时渲染语法矩阵:结构降级、离线图片与光标可编辑性 鸿蒙 PC Markdown 编辑器即时渲染语法矩阵结构降级、离线图片与光标可编辑性即时渲染最容易被误解为“把 Markdown 变成富文本”。如果实现只追求视觉效果确实可以先把正文渲染成 HTML再让用户编辑 DOM最后反向生成 Markdown。但是这条路线会把空格、换行、标记风格、引用缩进和链接写法重新排列。对于把 Markdown 文件交给 Git、静态站点、团队仓库或其他编辑器继续处理的用户这种重排不是小瑕疵而是文本事实来源发生了变化。本文讨论另一条路线在鸿蒙 PC 编辑器中始终保留同一个 CodeMirrorEditorState借助 Lezer Markdown 语法树和 Decoration 改变屏幕呈现。用户看到的是标题、链接、引用、列表、代码和图片预览磁盘中保存的仍然是原始 Markdown。光标进入结构后必要标记立即恢复语法未闭合、结构有歧义或超出支持范围时局部直接显示源码。对应工程仓库为 https://gitcode.com/VON-/codex_md_oh本文基于提交1bf4b62。代码、测试数字和截图均来自该提交的实际实现。本文是一篇独立技术文章不要求读者先了解项目的其他阶段。即时渲染的正确验收对象是文本不变量“标题看起来更大”只能证明 CSS 生效不能证明编辑器可靠。即时渲染真正需要守住的不变量至少包括五项。第一源码、即时、分栏和预览必须共享同一个文本缓冲区。模式变化不创建另一份可写正文也不能从预览 DOM 回填 Markdown。第二Decoration 只能改变显示不能作为编辑事务写入正文。切换模式前后调用getDocument()返回内容必须完全一致。第三光标必须能进入结构。如果链接目的地、强调标记或图片语法被永久隐藏用户将无法修改它们。隐藏必须以当前选择区是否接触结构为条件。第四未闭合语法必须回退源码。解析器没有确认完整边界时编辑器不能凭猜测隐藏字符。例如[链接](target.md缺少右括号三个反引号代码围栏缺少结束标记都应原样显示。第五大文档保护策略优先于视觉能力。精确 10 MiB 文档进入保护模式后即时 Decoration 和隐藏的专业预览都必须关闭保证保存和源码阅读仍可执行。这五项约束决定了本次实现不会建立“富文本模型 Markdown 模型”的双向同步也不会在一个功能步骤里覆盖所有 Markdown 方言。语法矩阵按结构逐项扩展每一项都包含正常、嵌套、光标进入和错误降级边界。用语法树定义可以安全隐藏的边界实现入口位于web-editor/src/instant-rendering.ts。插件读取 CodeMirror 当前状态中的 Lezer 语法树只遍历可见范围避免长文档滚动时为不可见节点创建无意义 DOM。functioncreateInstantDecorations(view:EditorView,options:InstantRenderingOptions):DecorationSet{if(!view.state.field(instantRenderingEnabled)){returnDecoration.none;}constranges:ArrayRangeDecoration[];consttreesyntaxTree(view.state);for(constvisibleRangeofview.visibleRanges){tree.iterate({from:visibleRange.from,to:visibleRange.to,enter:(node){// 每种结构只在解析器确认的节点边界内生成 Decoration。}});}returnDecoration.set(ranges,true);}这里没有把 Markdown 交给正则表达式逐行替换。正则很难正确处理嵌套强调、引用中的列表、链接标题、转义字符和未闭合结构。语法树节点已经给出了结构名称、起止偏移和父子关系Decoration 只需要在这些边界内工作。选择区判断同样保持简单。只要任意选择范围与节点相交就视为用户正在编辑该结构不隐藏必要标记。functionselectionTouches(view:EditorView,from:number,to:number):boolean{returnview.state.selection.ranges.some((range)range.fromtorange.tofrom);}这个规则故意偏保守。即使光标落在结构边界也宁可多显示一次源码标记不能让用户无法定位。即时渲染的价值是减少视觉噪声不是剥夺源码编辑能力。ATX 与 Setext 标题需要不同的显示策略ATX 标题使用行首#Setext 标题使用下一行的或---。两者在语法树中分别表现为ATXHeading1至ATXHeading6、SetextHeading1和SetextHeading2。统一的层级解析如下。functionheadingLevel(nodeName:string):number|undefined{constmatch/^(?:ATX|Setext)Heading([1-6])$/.exec(nodeName);returnmatch?Number.parseInt(match[1],10):undefined;}标题正文通过行 Decoration 获得字号、字重和分隔线HeaderMark只有在选择区没有接触标题时才隐藏。Setext 的标记单独占一行如果简单设置成零高度编辑器 gutter 中的行号会重叠。设备视觉验收第一次就发现了这个问题6 像素的折叠高度无法容纳行号。最终版本保留 18 像素稳定高度既降低标记行存在感也不破坏行号定位。.cm-line.cm-instant-setext-marker, .cm-line.cm-instant-code-fence{min-height:18px;height:18px;overflow:hidden;line-height:18px;}这说明桌面编辑器不能只看正文 DOM。源码行号、折叠标记、滚动定位和选择映射都属于同一交互系统。过度压缩某一行视觉上可能“更像排版软件”却会制造定位重叠和点击误差。链接只隐藏已经确认完整的目标普通行内链接[文本](target.md)、带标题链接和显式引用链接可以安全收起目标部分但未闭合链接不能处理。Lezer 对[链接](target.md可能只识别出前面的[链接]节点如果看到两个LinkMark就直接隐藏左方括号会消失而后面的不完整目标仍留在屏幕上。最终实现增加了完整目标判断行内链接必须拥有完整的括号标记引用链接必须拥有LinkLabel。只有条件成立时才隐藏开头方括号以及从文本闭括号到节点末尾的目标部分。constlinkMarks[];lethasReferenceLabelfalse;letchildnode.node.firstChild;while(child){if(child.nameLinkMark){linkMarks.push(child);}elseif(child.nameLinkLabel){hasReferenceLabeltrue;}childchild.nextSibling;}consthasCompleteTargetlinkMarks.length4||hasReferenceLabel;if(linkMarks.length2hasCompleteTarget!selectionTouches(view,node.from,node.to)){ranges.push(Decoration.replace({inclusive:false}).range(linkMarks[0].from,linkMarks[0].to));ranges.push(Decoration.mark({class:cm-instant-link}).range(linkMarks[0].to,linkMarks[1].from));ranges.push(Decoration.replace({inclusive:false}).range(linkMarks[1].from,node.to));}自动链接https://example.com隐藏两侧尖括号并保留 URL普通裸 URL 只增加链接颜色和下划线不改写内容。链接在即时模式中只是显示为链接不直接发起网络访问。预览中的本地跳转仍经既有原生命令和授权边界处理外部链接保持受限。下图来自 MateBook Pro 2in1 鸿蒙模拟器。光标位于空白行时Setext 标记、链接目标、引用标记、代码围栏和图片语法按规则收起行号没有重叠。当光标进入链接结构时完整[本地文档](docs/guide.md)立即恢复其他结构仍保持即时显示。这不是单独维护的“编辑视图”只是同一语法树在选择变化后重新计算 Decoration。图片预览复用受限 Bridge 而不是开放网络图片是即时渲染中风险最高的结构之一。直接把 Markdown 的src写入img会带来两个问题本地用户路径不能被 ArkWeb 任意读取远程 URL 又可能在用户不知情时发出网络请求。本次实现只复用已有的受限图片链路。Markdown 图片节点被替换为固定尺寸 Widget如果当前文档会话已经缓存了对应 Blob URL就显示真实图片。没有缓存时只向requestImageSource提交原始 Markdown 路径。主模块继续执行两段式相对路径校验、授权目录读取、图片 MIME 白名单、8 MiB 上限、64 KiB 分块传输和会话隔离。exportinterfaceInstantRenderingOptions{resolveImageSource?:(markdownPath:string)string|undefined;requestImageSource?:(markdownPath:string)void;}constpreviewUrloptions.resolveImageSource?.(source);if(!previewUrl){options.requestImageSource?.(source);}ranges.push(Decoration.replace({inclusive:false,widget:newInstantImageWidget(source,altText,previewUrl,node.from,node.to)}).range(node.from,node.to));外部 URL 无法通过相对路径校验因此不会进入 Bridge也不会加载网络图片。Widget 显示固定的替代文本占位截图中的“外部图片”就是该安全降级。对于有效的本地工作区图片Bridge 返回分块数据后创建 Blob URL再通过显式状态 effect 刷新即时 Decoration。functionrefreshInstantImages(sessionId:string):void{if(sessionIdactiveSessionIdcurrentModeinstant){editor.dispatch({effects:refreshInstantRendering.of(null)});}}Widget 使用固定的 520 x 220 像素上限图片以object-fit: contain显示。这样加载完成前后不会突然把编辑器内容推开。用户点击图片 Widget 时选择区移动到图片源码内部下一次 Decoration 计算会撤掉 Widget并恢复![alt](path)从而继续编辑替代文本和路径。引用与列表优先保留结构语义引用节点的视觉目标不是生成第二份 HTML blockquote而是在编辑器行上增加左边界和文字颜色。每一个QuoteMark在结构未被选择时隐藏Blockquote覆盖的行获得同一类名。嵌套强调、链接和行内代码仍由它们自己的节点处理。列表则采用更保守的策略。ListItem提供轻微纵向间距ListMark保留在文本中并使用主题色和加粗。无序列表没有把-替换为私有项目符号有序列表也不伪造自动编号。用户仍能清楚看到源文件使用了哪一种标记同时获得比源码模式更稳定的层级视觉。这种保守处理还有一个现实理由列表延续、Tab 缩进和任务列表编辑属于后续结构化编辑事务。当前阶段只改变显示不应提前改变 Enter、Tab 或撤销语义。视觉 Decoration 和结构编辑命令分开验收可以明显缩小数据损坏的风险面。行内代码与围栏代码块采用两级降级完整的行内代码节点拥有两个CodeMark。结构未被选择时隐藏反引号内容使用等宽字体、浅色背景和细边框光标进入时反引号恢复。未闭合反引号没有完整节点因此保持源码。围栏代码块首先确认至少存在开始和结束两个CodeMark。如果只有开始围栏整个节点不生成即时样式。完整结构的所有行获得统一等宽背景开始行的语言信息与结束围栏在光标离开时隐藏标记行保留 18 像素稳定高度。光标进入代码块后围栏和语言标识全部恢复。if(node.nameFencedCode){constcodeMarks[];letchildnode.node.firstChild;while(child){if(child.nameCodeMark){codeMarks.push(child);}childchild.nextSibling;}if(codeMarks.length2){returnfalse;}addLineRangeDecorations(ranges,view,node.from,node.to,cm-instant-code-block);}即时编辑器没有在代码块内部运行 Highlight.js。专业高亮仍属于预览管线带有异步取消和二次净化。即时模式选择低成本等宽样式避免每次输入都启动另一套代码高亮 DOM也避免隐藏编辑器内核本身的 Markdown 语法状态。为什么需要显式刷新图片而不刷新全部预览CodeMirror 插件在模式变化、正文变化、选择变化和视口变化时重算可见 Decoration。本地图片读取是异步事件完成时正文没有变化所以增加了专用refreshInstantRenderingeffect。这个 effect 只让 Decoration 重算不修改文档也不进入撤销历史。constrefreshRequestedupdate.transactions.some((transaction)transaction.effects.some((effect)effect.is(refreshInstantRendering)));if(modeChanged||refreshRequested||update.docChanged||update.selectionSet||update.viewportChanged){this.decorationscreateInstantDecorations(update.view,options);}即时模式仍然不会在每次输入时生成隐藏的 markdown-it、KaTeX、Mermaid 和 Highlight.js 完整预览。只有分栏和阅读模式需要专业预览。这个边界对鸿蒙 PC 的输入延迟和 ArkWeb 内存尤为重要用户选择即时写作不应在后台承担双栏渲染的全部成本。自动化语料覆盖正常、交互与失败路径本轮新增三项 Playwright 用例使 Web 全量从 47 项增加到 50 项。第一项使用 Setext 标题、行内链接、自动链接、引用、无序列表、有序列表、行内代码和 TypeScript 围栏代码块验证对应类名、隐藏结果和getDocument()完全相等。第二项使用一个本地图片和一个远程图片。测试 Bridge 只收到本地Note.assets/instant.png本地图片最终使用blob:URL远程图片只显示替代文本。点击本地 Widget 后图片源码恢复而文档内容不变。第三项输入未闭合链接与未闭合代码围栏验证两者都显示源码且不存在代码块即时类名。这项用例直接防止“解析一半也隐藏一半”的错误。定向测试最终为 6/6全量 Playwright 为 50/50。精确 10 MiB Chromium 保护模式回归本轮记录 321 ms远低于三秒门槛但这个数字只代表当前本机 Web 环境不能替代鸿蒙 PC Release 真机的读取、Bridge、输入 P95 和内存结果。鸿蒙模拟器验证暴露了自动化看不到的行号问题最终 Debug HAP 安装到 MateBook Pro 2in1 模拟器。通过设备 UI 自动化输入精确多行 Markdown切换“源码/即时”把光标分别放在空白行和链接结构中再截取 3120 x 2080 应用画面。第一次视觉检查发现隐藏 Setext 标记行和代码围栏行只有 6 像素正文虽然没有重叠gutter 中相邻行号却挤在一起。实现随后把稳定高度调整为 18 像素重新执行全量构建、安装和截图。最终截图中第 14、15、16 行保持清晰代码背景、图片占位和状态栏也没有相互遮挡。最终./scripts/verify-local.sh通过包含 Playwright 50/50、生产单 HTML、Debug HAP、ArkTSUnitTestBuild和差异检查。最终 ohosTest HAP 重新构建、安装并执行结果为 11/11Failure 0、Error 0总耗时 2520 ms。产物事实如下Debug HAP8,558,158 字节SHA-256ba78366e06b46e490f96174c883f3a07fcccdb052e4a7526b278b50a176a7473。ohosTest HAP9,335,601 字节SHA-256d2cc792a4da230274a7f5d09c4b7ecce8da6543a7775a72847bc6bdd4b10689f。语法矩阵截图SHA-25635ba49beb53b3a0b458109205361b7e613cff3786604ff710b7bf7a52a138dd3。链接显标截图SHA-25649cc97f2c33d0311bf1da7b8d949d2c4d150cddd9f835c03b944b428560d6eae。当前语法矩阵的明确边界当前即时模式已经覆盖 ATX H1-H6、Setext H1-H2、粗体、斜体、完整行内链接、显式引用链接、自动链接、裸 URL、图片、引用、无序/有序列表、行内代码和完整围栏代码块。它没有把表格、任务复选框、删除线、Front Matter、HTML 块、公式和 Mermaid 伪装成已完成能力。这些结构继续显示源码专业排版可在分栏或阅读模式查看。即使已覆盖的类型遇到未闭合或无法确认边界也会局部回退源码。本地图片可以显示真实 Blob 预览外部图片默认不会联网只显示稳定占位。图片点击显标已经由自动化覆盖鸿蒙模拟器截图展示的是外部图片安全降级。真实工作区图片、物理键盘、触控板、输入法组合和 Release 性能仍需要鸿蒙 PC 真机矩阵复核。工程结论即时渲染的竞争力不在于“隐藏了多少 Markdown 符号”而在于隐藏之后仍能可靠编辑、撤销、保存和跨软件交换原文件。基于同一EditorState的 Decoration 路线把显示层与文本事实来源分开使每种语法都可以独立增加、独立降级和独立测试。对鸿蒙 PC 编辑器而言这种路线还保留了平台侧优势ArkUI 继续负责窗口、文件授权和系统能力ArkWeb 只处理本地编辑器图片仍通过最小权限 Bridge 读取远程内容不会因为即时显示而突破离线策略大文档仍能回到源码模式。下一阶段将从“结构显示”进入“结构编辑辅助”重点是列表延续、缩进、自动配对和表格行列命令。届时验收对象不再只是 Decoration而是每个操作能否形成一次可整体撤销的 CodeMirror 事务并保持空格、换行和 Markdown 标记不被无意重排。