鸿蒙 PC Markdown 编辑器内核:在 ArkWeb 中离线运行 CodeMirror 6

本文讨论 CodeMirror 6 在 ArkWeb 中的状态模型、离线打包、文档生命周期和大文件降级,不把 Web 页面当作简单的 UI 容器。完整示例代码:https://gitcode.com/VON-/codex_md_oh

为什么是 ArkWeb 与 CodeMirror

鸿蒙 PC 编辑器的核心难点不是显示一个多行文本框,而是在中文 IME、长文本、撤销重做、选区和语法高亮之间保持正确性。CodeMirror 6 已经解决了大量文本编辑基础问题,ArkUI 则保留鸿蒙 PC 的原生窗口、文件服务和系统交互。

这种混合架构的一个前提是:编辑内核必须可以完全离线加载,不能在启动时访问 CDN,也不能把用户的 Markdown 上传到服务器。

只保留一个可写文本事实源

编辑器的真实初始化代码位于 web-editor/src/main.ts

function createEditorState(content: string): EditorState {
  const useLargeDocumentSetup = content.length >= LARGE_DOCUMENT_CHARACTER_THRESHOLD;
  const lineSeparator = content.includes('\r\n') ? '\r\n' : '\n';
  return EditorState.create({
    doc: content,
    extensions: [
      useLargeDocumentSetup ? minimalSetup : basicSetup,
      useLargeDocumentSetup ? [] : markdown({ base: markdownLanguage }),
      EditorState.lineSeparator.of(lineSeparator),
      placeholder('Start writing Markdown...'),
      useLargeDocumentSetup ? [] : EditorView.lineWrapping,
      EditorView.updateListener.of((update) => {
        if (!update.docChanged) {
          return;
        }

        documentRevision += 1;
        pendingDirty = forcedDirty || !update.state.doc.eq(baselineDocument);
        updateLargeDocumentMode(update.state.doc.length);
        if (currentMode === 'source') {
          previewDirty = true;
        } else {
          renderPreview(update.state.sliceDoc());
        }
        notifyNative(update.state.doc.length);
        scheduleRecoverySnapshot();
      }),
      EditorView.theme({
        '&': { height: '100%' },
        '.cm-scroller': { overflow: 'auto' },
        '.cm-content': { minHeight: '100%' }
      })
    ]
  });
}

代码来源:web-editor/src/main.ts

CodeMirror 的 EditorState.doc 是编辑期唯一可写文本。Source、Split 和 Preview 只是视图模式,预览由当前文本派生,不反向改写 Markdown。这个约束能避免切换模式时的源码格式化、空行丢失或不可逆转换。

将依赖内联进 HAP

Vite 的输出目录直接指向 HarmonyOS 的 rawfile 资源,并使用 vite-plugin-singlefile 将 JavaScript 和 CSS 内联:

import { defineConfig } from 'vite';
import { viteSingleFile } from 'vite-plugin-singlefile';

export default defineConfig({
  base: './',
  plugins: [viteSingleFile()],
  build: {
    outDir: '../entry/src/main/resources/rawfile/editor',
    emptyOutDir: true,
    sourcemap: false,
    target: 'es2020',
    chunkSizeWarningLimit: 800
  }
});

代码来源:web-editor/vite.config.ts

生产 HTML 不带外部 <script src> 或样式链接,ArkWeb 只需加载 $rawfile('editor/index.html')。这同时简化了资源路径、跨源策略和离线验证。

鸿蒙 PC 模拟器输入截图

下图为内联 CodeMirror 编辑器在鸿蒙 PC / 2in1 模拟器 ArkWeb 中输入 Markdown 的实际画面。

在这里插入图片描述

验证结论

当前纵切已经证明:CodeMirror、Markdown 语法扩展、markdown-it 和 DOMPurify 可以在不访问外网的前提下随 HAP 加载;文档可输入,模式可切换,撤销栈属于当前文档。真实鸿蒙 PC 上的物理输入法和长时间稳定性仍应作为发布前必测项。

选择编辑内核时真正要比较什么

桌面 Markdown 编辑器并不是给文本加一个光标那么简单。编辑内核需要同时处理多行选区、双向文本、组合输入、撤销分组、滚动虚拟化、语法树更新、快捷键和无障碍。纯 ArkUI 文本控件与 ArkWeb 编辑器的比较不能只看首屏大小,还要看这些能力需要自行实现多少、后续能否持续维护。

OhMarkdown 选择 CodeMirror 6,主要基于四点:

  1. 状态和视图分离,文档变化通过事务表达,便于管理撤销、选区和扩展。
  2. 扩展可以按场景组合,大文档时能够关闭 Markdown 解析与换行,而不用替换整套编辑器。
  3. 浏览器对中文 IME、键盘事件和文本选择已经有成熟基础,适合先验证鸿蒙 ArkWeb 兼容性。
  4. 依赖可以打入 HAP,运行时不需要访问在线编辑服务。

代价也很明确:ArkWeb 会增加进程和内存开销,原生与 JavaScript 之间需要设计 Bridge,焦点和系统能力跨越两个运行环境。初始技术验证不是证明这个方案没有代价,而是证明这些代价可测、可控,并且没有出现必须更换内核的阻断问题。

EditorState 是编辑期唯一事实源

CodeMirror 6 的重要特性是不可变 EditorState。一次输入不会原地修改字符串,而是创建描述变化的事务,EditorView 应用事务后得到新状态。撤销栈、选区和插件状态都跟随这条事务链。

项目把 EditorState.doc 设为唯一可写文本来源,解决了混合编辑器常见的“双模型漂移”:

  • ArkTS 不在每次输入后保存一份同步全文,只接收轻量状态和必要快照。
  • 预览从 sliceDoc() 得到当前 Markdown,再渲染和净化。
  • 保存由用户显式命令取得当下完整文本。
  • 切换 Source、Split、Preview 不把 HTML 转回 Markdown。

如果同时让 ArkTS 字符串和 CodeMirror 文档接受编辑,Bridge 延迟就会造成覆盖:原生侧可能用旧全文刷新 Web,CodeMirror 的新输入因此丢失。单一事实源把普通编辑留在一个运行时,只有明确的生命周期事件才跨边界。

初始化顺序决定首屏是否可靠

ArkWeb 页面加载、JavaScript 初始化、Bridge 注入和 ArkTS 文档读取不是同时完成的。编辑器必须处理两种顺序:Web 先就绪但文档尚未读取,或者文档已经准备好但 Web 还不能接收命令。

实际实现通过 onReady 通知原生侧,ArkTS 在确认控制器可执行脚本后再设置文档。新文档加载不是往 DOM 中写文本,而是创建新的 EditorState 并替换当前状态。这样可以清理上一份文档的撤销历史,避免用户打开 B 文件后按撤销却看到 A 文件内容。

页面启动还应有明确失败状态。单 HTML 找不到、脚本初始化异常、Bridge 未注入或编辑器超时,都不能只留下空白区域。桌面工具后续需要把这些失败转换为可见错误和诊断信息,同时保留新建、重新加载或关闭文档的操作入口。

为什么预览不能参与编辑

所见即所得编辑器通常把富文本结构当作主模型,Markdown 与 DOM 之间需要双向转换。OhMarkdown 的产品优先级是源码无损,因此预览只读。用户写下的空行、转义、列表缩进、引用风格和链接定义不应因为看了一次预览就被重新排版。

只读预览还有安全收益:净化后的 DOM 不需要把点击、输入等变化同步回源码;Markdown 中的原始 HTML被禁用,危险节点经过 DOMPurify;预览链接在当前阶段也不会直接发起导航。编辑面和渲染面职责分离后,保存永远读取 Markdown 文本,而不是读取可能被浏览器规范化过的 innerHTML

大文档模式依赖扩展可组合性

超过 5MiB 字符阈值后,项目使用 minimalSetup,关闭 Markdown 语言扩展、行换行和实时预览。这样做不是因为 CodeMirror 无法保存更大的字符串,而是因为多个看似独立的能力会共同放大成本:语法树需要维护节点,软换行增加布局计算,预览会生成第二份 DOM,字数统计需要扫描全文,频繁 Bridge 快照还会复制字符串。

CodeMirror 的扩展组合让降级可以在创建文档状态时决定,而不是在各处增加零散的 if。大文档仍然保留基本编辑、选区、撤销、滚动和保存,工作台则禁用 Split/Preview 并显示状态。产品层应把这称为“保护模式”,而不是让功能悄悄失效。

阈值本身不是永恒常量。后续可以根据鸿蒙 PC 真机 P95、内存和文档结构调整,也可以拆成多个级别:先关闭实时预览,再关闭语法高亮,最后限制恢复快照。但任何调整都要有固定语料和 Release 数据,不能只凭主观流畅度。

离线包仍然需要供应链管理

把依赖内联进 HAP 解决的是运行时网络依赖,不等于依赖来源天然安全。CodeMirror、markdown-it、DOMPurify 和构建工具仍来自 npm,需要锁文件、升级审查和自动化回归。尤其 DOMPurify 属于安全边界,升级时应单独验证危险标签、属性、URL 和导出结果。

单文件构建还会把源码压缩成较大的 HTML。项目应保留可审查的 TypeScript 源码和依赖清单,不能只把生成的 index.html 当作源代码。生产产物关闭 sourcemap 可以减少发布信息,但调试阶段仍需要通过浏览器测试和日志定位源模块。

离线验证至少包含:断开网络后启动应用、打开已有文档、输入、切换模式、预览、保存和再次启动。只检查首屏出现并不足以证明所有依赖都已内联,因为某些语法主题、图片或导出路径可能在使用时才尝试加载外部资源。

生命周期与内存回收

ArkWeb 不是一个永久存在且永不重载的黑盒。窗口关闭、系统回收、页面异常和未来多标签都会触发生命周期问题。编辑器需要区分三种状态:磁盘已保存基线、当前 CodeMirror 文档、用于异常恢复的最近快照。

普通重绘不应销毁 EditorView。真正替换文档时要创建新状态并重置文档相关基线;关闭页面时要释放监听器和计时器;应用进入后台前则应尽量刷新恢复快照。多标签方案不能简单创建无限多个 ArkWeb,每个标签都保持完整语法树和预览 DOM 会快速放大内存,需要基于真机数据选择活动视图复用或会话冻结策略。

如何验证内核正确而不只验证“能输入”

内核回归需要覆盖状态语义:

  • 输入中文后,组合文本只提交一次,字数和脏状态正确。
  • 连续输入可撤销,重做恢复相同内容。
  • 保存后继续编辑,再撤销回保存基线时修改标记清除。
  • 打开另一份文档后,撤销不会返回前一份文档。
  • CRLF 文档经过编辑后,sliceDoc() 仍遵循配置的行分隔符。
  • Source、Split、Preview 展示同一份内容,切换模式不改变源码。
  • 恶意 Markdown 不在预览 DOM 中生成可执行脚本。
  • 5MiB 以上文档进入降级模式,基本输入和保存仍可用。
  • 生产 HTML 不引用外部脚本和样式资源。

浏览器自动化适合验证 CodeMirror 与渲染逻辑,鸿蒙模拟器负责 ArkWeb 集成,真机负责中文输入法、物理键盘、触控板和完整进程组内存。三个层级互补,不能用 Chromium 页面通过来推断所有 ArkWeb 行为。

混合内核的边界清单

  • ArkUI 管系统窗口、用户授权 URI、文件读写、打印和原生状态。
  • CodeMirror 管当前文本、光标、选区、撤销栈和编辑扩展。
  • Markdown 预览只从源码派生,任何时候都不反向覆盖源码。
  • Bridge 使用白名单方法,普通按键不传输完整文档。
  • Web 资源随 HAP 离线分发,ArkWeb 不获得任意文件或网络能力。
  • 大文档通过关闭昂贵扩展降级,不更换数据模型。
  • 文档替换显式清理撤销历史,布局变化不重建编辑器。
  • 性能结论基于 Release HAP 的完整进程组,而不是只看 Web 页面帧率。

CodeMirror 在这里不是一块嵌入式网页装饰,而是受原生外壳约束的专业文本内核。把事实源、权限、生命周期和性能边界写清后,ArkUI 与 ArkWeb 的组合才具备长期扩展为鸿蒙 PC 桌面编辑器的条件。

事务模型如何承载编辑语义

CodeMirror 的修改通过 transaction 表达,事务中包含文本 changes、选区、效果和注解。这个模型允许多个变化作为一次用户操作提交,例如自动补全同时插入文本并移动光标。撤销管理依据事务边界分组,而不是盲目记录每一个 DOM 输入事件。

编辑器扩展功能时应尽量通过事务与 StateEffect 表达,不直接修改内部 DOM。直接操作 .cm-content 可能短暂改变显示,却不会更新 EditorState,保存、撤销和预览仍看到旧内容。任何程序化替换文档、格式化或批量编辑都要走 dispatch,并明确是否进入撤销历史。

保存基线也可以用状态比较而不是维护第二份可写字符串。当前文档与基线相等时 dirty 为 false,即使用户经历多次输入和撤销。未来大型文档若完整比较成本过高,可以在事务层维护 revision 与已保存 revision,但必须处理撤销回基线的语义,不能仅用“发生过变化”永久标脏。

扩展配置应按成本和生命周期分组

CodeMirror 6 的扩展很多,全部塞进一个数组虽然能运行,却难以在主题、语言和大文档模式之间动态调整。可以按职责分组:基础编辑与键位、Markdown 语言、视觉主题、变更监听、恢复与 Bridge、辅助功能。每组都有明确成本和关闭条件。

需要动态切换的设置适合使用 Compartment,例如主题、只读状态或行换行;替换 compartment 不必重建文档和撤销历史。文档行分隔符与初始语言结构更适合在创建状态时确定。大文件从完整配置切到最小配置时,应确认哪些扩展可重配、哪些需要创建新状态,并保留文本与选区。

扩展顺序也会影响快捷键优先级和事件处理。新增插件后必须回归中文组合输入、系统快捷键和保存命令,避免某个 keymap 在 composition 中提前消费按键。

预览调度应与可见性一致

实时预览的直接实现是在每次 docChanged 后同步运行 markdown-it、DOMPurify 并替换 innerHTML。文档变大或用户连续输入时,这会在关键输入路径创建大量 HTML 和 DOM。

当前 Source 模式只把 previewDirty 设为 true,进入 Split/Preview 时再更新;可见预览才需要实时刷新。进一步优化可以使用短延迟合并多次事务、在浏览器空闲阶段渲染,并用 revision 丢弃过期结果。无论异步到什么程度,预览必须清楚对应哪份正文,旧渲染不能晚到后覆盖新内容。

超大文档直接禁用预览比渲染截断版本更安全。截断预览容易让用户误以为文档只有显示部分,导出时又可能处理完整内容造成峰值。若未来提供部分预览,必须显式标注范围,并保证它从不参与保存。

搜索、替换和大纲如何保持单一事实源

搜索与替换应直接使用 CodeMirror 文档和事务。搜索结果保存位置时要考虑前方文本修改导致偏移,最好使用状态字段或映射变化,而不是长期缓存绝对字符索引。替换全部是一次还是多次撤销,需要明确用户语义。

大纲从 Markdown 语法树或受控解析结果派生,只保存标题文本、层级和位置。点击大纲通过 EditorView 定位选区并滚动,不重新生成正文。大文档模式关闭完整语法高亮时,大纲可以延迟、降级为后台扫描或暂时不可用,不能为了侧栏立即可见而在每次输入重新解析 10MiB 文档。

这些功能都遵循同一原则:读取 EditorState,输出视图或事务,不创建另一份可以独立编辑的内容模型。

文档切换必须清理哪些状态

替换文档不仅是设置新字符串。上一文档的撤销历史、搜索选择、语法树、预览 DOM、保存基线、恢复计时器和 Bridge revision 都可能残留。新状态创建后应重新计算格式、字数与大文件模式,预览清空或按新文档渲染。

异步任务需要会话标识。假如 A 文档预览正在渲染,用户已打开 B,A 的结果完成后不能写入 B 的预览;A 的恢复快照也不能覆盖 B 的记录。会话 ID 与 revision 组合可以拒绝迟到结果。

多标签会放大这个问题。冻结非活动标签时要保存文本、选区、滚动、撤销历史或可接受的压缩状态;销毁 EditorView 前清理 DOM 监听和定时器。简单把页面节点隐藏并无限保留会话,内存会随标签数线性增长。

ArkWeb 页面异常后的重建策略

ArkWeb 可能因为进程回收或页面错误重新加载。原生侧不能假定 Web 内存永远存在,应通过就绪握手识别新实例。重建时优先选择当前内存/恢复快照还是磁盘基线,需要结合是否 dirty 和最近 revision。

如果 ArkTS 没有最新全文,只保存了节流快照,重载窗口内的少量输入可能丢失。降低窗口可以主动在页面隐藏或系统生命周期事件前刷新;彻底解决大文档问题则需要增量会话日志。任何情况下,不要在新页面就绪后无条件加载磁盘版本覆盖已存在恢复草稿。

页面异常次数也应有限制。连续初始化失败时显示可诊断的错误界面并停止自动重载,避免循环消耗资源。错误记录包含页面版本和阶段,不包含 Markdown 正文。

编辑器主题与内容样式要隔离

CodeMirror 主题控制光标、选区、行号和编辑表面;Markdown 预览样式控制标题、列表、代码块和表格。两者不应使用宽泛选择器互相污染。应用外壳的 ArkUI 主题又是第三层,需要通过明确颜色与状态同步,而不是让 Web 读取系统任意设置。

深色模式切换适合通过受控命令和 CodeMirror compartment 更新,不重建文档。主题必须检查选区对比度、IME 组合下划线、搜索高亮和只读状态。字体变化会影响行高、滚动和大文档布局,不能只做静态颜色替换。

用户自定义 CSS 若未来支持,应限制在预览沙箱或经过解析,不能让任意样式覆盖编辑器与原生 Bridge 相关页面结构。

可访问性与浏览器能力边界

CodeMirror 对屏幕阅读器和键盘提供基础支持,但嵌入 ArkWeb 后还要验证原生焦点能进入、离开和返回编辑器。页面不能为了拦截快捷键阻止所有默认事件,Tab、方向键和组合输入要遵循编辑语义。

编辑器的可访问标签、当前行信息和选择状态需要在鸿蒙辅助功能中实测。大量文本下,屏幕阅读器可能要求不同的虚拟化策略;这类平台差异不能只根据桌面 Chrome 结论推断。

剪贴板、拖放、拼写检查和上下文菜单也属于浏览器能力。开放前逐项确定数据边界和用户价值,不因为 ArkWeb 默认提供就全部启用。尤其粘贴 HTML 应转换为纯文本或明确 Markdown 规则,不能把活动 HTML 直接进入预览。

CodeMirror 升级的回归重点

编辑内核升级通常包含解析、视图、输入和状态包的联动版本。应保持官方兼容组合,避免多个 @codemirror/state 实例导致类型相同但运行时身份不同。锁文件中出现重复核心包时要调查。

升级回归至少包含中文 IME、emoji、撤销分组、多选区、超长行、CRLF、文档切换、大文件降级、ArkWeb 重载和内存。若升级改善某项性能,也要确认没有改变 Markdown 源码、键位或保存快照。

依赖发布说明提供方向,最终结论必须来自目标 ArkWeb。浏览器 API 支持、selection 行为和 composition 事件在不同内核版本可能有差异,Release HAP 真机验证不可省略。

内核故障时必须优先保住源码

语法解析、主题或预览扩展异常时,理想降级是回到最小纯文本编辑,而不是让整个页面空白。扩展初始化可以按核心与可选能力分层:基础 EditorState、输入和保存通道属于核心,Markdown 高亮、预览和统计属于可降级层。捕获异常后记录扩展类别,保留当前正文与恢复快照,并允许用户另存。

页面若无法继续编辑,原生侧至少应检测超时或重载,读取最近有效恢复记录。不要用自动刷新无限循环覆盖仍在内存中的新输入。故障设计与正常功能同样属于编辑内核边界,因为用户最需要保护数据的时刻往往正是扩展失效时。

评估替换内核的客观条件

架构决定不是永久承诺。若目标鸿蒙 PC 上中文输入长期无法正确、进程组内存远超目标且无可行优化、ArkWeb 生命周期导致不可接受的数据丢失,或关键无障碍能力无法满足,就应重新评估纯原生或其他内核。评估必须使用相同语料、功能和设备比较总成本,不能只比较空页面内存。只要现有方案仍能通过明确门槛,继续优化比频繁重写更有工程价值。

Logo

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

更多推荐