鸿蒙 PC Markdown 编辑器即时渲染语法矩阵:结构降级、离线图片与光标可编辑性

即时渲染最容易被误解为“把 Markdown 变成富文本”。如果实现只追求视觉效果,确实可以先把正文渲染成 HTML,再让用户编辑 DOM,最后反向生成 Markdown。但是这条路线会把空格、换行、标记风格、引用缩进和链接写法重新排列。对于把 Markdown 文件交给 Git、静态站点、团队仓库或其他编辑器继续处理的用户,这种重排不是小瑕疵,而是文本事实来源发生了变化。

本文讨论另一条路线:在鸿蒙 PC 编辑器中始终保留同一个 CodeMirror EditorState,借助 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。

function createInstantDecorations(view: EditorView, options: InstantRenderingOptions): DecorationSet {
  if (!view.state.field(instantRenderingEnabled)) {
    return Decoration.none;
  }

  const ranges: Array<Range<Decoration>> = [];
  const tree = syntaxTree(view.state);
  for (const visibleRange of view.visibleRanges) {
    tree.iterate({
      from: visibleRange.from,
      to: visibleRange.to,
      enter: (node) => {
        // 每种结构只在解析器确认的节点边界内生成 Decoration。
      }
    });
  }
  return Decoration.set(ranges, true);
}

这里没有把 Markdown 交给正则表达式逐行替换。正则很难正确处理嵌套强调、引用中的列表、链接标题、转义字符和未闭合结构。语法树节点已经给出了结构名称、起止偏移和父子关系,Decoration 只需要在这些边界内工作。

选择区判断同样保持简单。只要任意选择范围与节点相交,就视为用户正在编辑该结构,不隐藏必要标记。

function selectionTouches(view: EditorView, from: number, to: number): boolean {
  return view.state.selection.ranges.some((range) =>
    range.from <= to && range.to >= from);
}

这个规则故意偏保守。即使光标落在结构边界,也宁可多显示一次源码标记,不能让用户无法定位。即时渲染的价值是减少视觉噪声,不是剥夺源码编辑能力。

ATX 与 Setext 标题需要不同的显示策略

ATX 标题使用行首 #,Setext 标题使用下一行的 ===---。两者在语法树中分别表现为 ATXHeading1ATXHeading6SetextHeading1SetextHeading2。统一的层级解析如下。

function headingLevel(nodeName: string): number | undefined {
  const match = /^(?:ATX|Setext)Heading([1-6])$/.exec(nodeName);
  return match ? 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。只有条件成立时,才隐藏开头方括号以及从文本闭括号到节点末尾的目标部分。

const linkMarks = [];
let hasReferenceLabel = false;
let child = node.node.firstChild;
while (child) {
  if (child.name === 'LinkMark') {
    linkMarks.push(child);
  } else if (child.name === 'LinkLabel') {
    hasReferenceLabel = true;
  }
  child = child.nextSibling;
}

const hasCompleteTarget = linkMarks.length >= 4 || hasReferenceLabel;
if (linkMarks.length >= 2 && hasCompleteTarget &&
  !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 分块传输和会话隔离。

export interface InstantRenderingOptions {
  resolveImageSource?: (markdownPath: string) => string | undefined;
  requestImageSource?: (markdownPath: string) => void;
}

const previewUrl = options.resolveImageSource?.(source);
if (!previewUrl) {
  options.requestImageSource?.(source);
}
ranges.push(Decoration.replace({
  inclusive: false,
  widget: new InstantImageWidget(source, altText, previewUrl, node.from, node.to)
}).range(node.from, node.to));

外部 URL 无法通过相对路径校验,因此不会进入 Bridge,也不会加载网络图片。Widget 显示固定的替代文本占位,截图中的“外部图片”就是该安全降级。对于有效的本地工作区图片,Bridge 返回分块数据后创建 Blob URL,再通过显式状态 effect 刷新即时 Decoration。

function refreshInstantImages(sessionId: string): void {
  if (sessionId === activeSessionId && currentMode === 'instant') {
    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.name === 'FencedCode') {
  const codeMarks = [];
  let child = node.node.firstChild;
  while (child) {
    if (child.name === 'CodeMark') {
      codeMarks.push(child);
    }
    child = child.nextSibling;
  }
  if (codeMarks.length < 2) {
    return false;
  }
  addLineRangeDecorations(ranges, view, node.from, node.to,
    'cm-instant-code-block');
}

即时编辑器没有在代码块内部运行 Highlight.js。专业高亮仍属于预览管线,带有异步取消和二次净化。即时模式选择低成本等宽样式,避免每次输入都启动另一套代码高亮 DOM,也避免隐藏编辑器内核本身的 Markdown 语法状态。

为什么需要显式刷新图片而不刷新全部预览

CodeMirror 插件在模式变化、正文变化、选择变化和视口变化时重算可见 Decoration。本地图片读取是异步事件,完成时正文没有变化,所以增加了专用 refreshInstantRendering effect。这个 effect 只让 Decoration 重算,不修改文档,也不进入撤销历史。

const refreshRequested = update.transactions.some((transaction) =>
  transaction.effects.some((effect) => effect.is(refreshInstantRendering)));

if (modeChanged || refreshRequested || update.docChanged ||
  update.selectionSet || update.viewportChanged) {
  this.decorations = createInstantDecorations(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、ArkTS UnitTestBuild 和差异检查。最终 ohosTest HAP 重新构建、安装并执行,结果为 11/11,Failure 0、Error 0,总耗时 2520 ms。

产物事实如下:

  • Debug HAP:8,558,158 字节,SHA-256 ba78366e06b46e490f96174c883f3a07fcccdb052e4a7526b278b50a176a7473
  • ohosTest HAP:9,335,601 字节,SHA-256 d2cc792a4da230274a7f5d09c4b7ecce8da6543a7775a72847bc6bdd4b10689f
  • 语法矩阵截图:SHA-256 35ba49beb53b3a0b458109205361b7e613cff3786604ff710b7bf7a52a138dd3
  • 链接显标截图:SHA-256 49cc97f2c33d0311bf1da7b8d949d2c4d150cddd9f835c03b944b428560d6eae

当前语法矩阵的明确边界

当前即时模式已经覆盖 ATX H1-H6、Setext H1-H2、粗体、斜体、完整行内链接、显式引用链接、自动链接、裸 URL、图片、引用、无序/有序列表、行内代码和完整围栏代码块。

它没有把表格、任务复选框、删除线、Front Matter、HTML 块、公式和 Mermaid 伪装成已完成能力。这些结构继续显示源码,专业排版可在分栏或阅读模式查看。即使已覆盖的类型遇到未闭合或无法确认边界,也会局部回退源码。

本地图片可以显示真实 Blob 预览,外部图片默认不会联网,只显示稳定占位。图片点击显标已经由自动化覆盖,鸿蒙模拟器截图展示的是外部图片安全降级。真实工作区图片、物理键盘、触控板、输入法组合和 Release 性能仍需要鸿蒙 PC 真机矩阵复核。

工程结论

即时渲染的竞争力不在于“隐藏了多少 Markdown 符号”,而在于隐藏之后仍能可靠编辑、撤销、保存和跨软件交换原文件。基于同一 EditorState 的 Decoration 路线把显示层与文本事实来源分开,使每种语法都可以独立增加、独立降级和独立测试。

对鸿蒙 PC 编辑器而言,这种路线还保留了平台侧优势:ArkUI 继续负责窗口、文件授权和系统能力,ArkWeb 只处理本地编辑器;图片仍通过最小权限 Bridge 读取,远程内容不会因为即时显示而突破离线策略;大文档仍能回到源码模式。

下一阶段将从“结构显示”进入“结构编辑辅助”,重点是列表延续、缩进、自动配对和表格行列命令。届时验收对象不再只是 Decoration,而是每个操作能否形成一次可整体撤销的 CodeMirror 事务,并保持空格、换行和 Markdown 标记不被无意重排。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐