鸿蒙 PC Markdown 编辑器结构化编辑:列表事务、自动配对与表格行列命令

在桌面端 Markdown 编辑器里,“结构化编辑”很容易被低估。看起来它只是按下 Enter 后补一个列表标记,或者在表格里增加一列;真正实现时,它却同时碰到语法判断、光标映射、撤销历史、CRLF、中文输入、键盘焦点、设置持久化与源码保真。任何一个环节处理得不严谨,用户都会遇到一种很典型的桌面编辑器问题:界面声称替你节省操作,结果却悄悄改坏了文档。

本文以鸿蒙 PC 优先的 OhMarkdown 为例,完整说明一套不引入第二文本模型的结构化编辑实现。项目仓库地址是 https://gitcode.com/VON-/codex_md_oh,本文对应功能提交为 1fffbbd。文章中的代码来自该提交,设备图片来自 HarmonyOS 6.1.1 / API 24 的 MateBook Pro 2in1 模拟器。由于当前没有鸿蒙 PC 真机,本文只把自动化与模拟器结果写成工程证据,不把它包装成真机性能或行业领先结论。

结构化编辑首先是一组不变量

结构化编辑不是富文本编辑。OhMarkdown 仍然把 CodeMirror EditorState 中的 Markdown 字符串作为编辑期唯一事实来源,ArkUI 只负责桌面工作台、系统能力与设置,预览 DOM 也不会反向生成 Markdown。基于这个前提,G4-04 给结构化命令规定了几条不可退让的不变量:

  1. 每次用户命令只生成一个可撤销事务。
  2. 源码模式与即时模式执行同一个命令,得到相同正文。
  3. 表格命令不能因为单元格中出现转义管道或代码片段里的管道而误切列。
  4. LF 与 CRLF 的显示、跳转和保存语义必须一致。
  5. 自动配对必须可关闭,并且关闭状态要跨编辑器状态重建与应用重启保存。
  6. 大文档保护模式不加载结构化语法服务,继续优先保证输入和保存。
  7. 无法可靠识别的结构返回 false,保留普通编辑行为,不猜测用户意图。

这几条约束决定了实现形态。列表行为优先复用 CodeMirror 官方 Markdown 命令,表格修改建立在 Lezer 语法树确认过的 Table 节点上,行内的列边界扫描只承担“定位不可被语法树直接给出的分隔符”这一项职责。设置则通过 ArkUI、Preferences 与受限 Web API 形成闭环。

为什么不能在 keydown 里直接拼字符串

最直接的写法是在 DOM keydown 事件中判断 Enter,然后读取当前行并向文本框塞入 - 。这种写法在简单演示中能工作,但在桌面产品里会迅速失控。

首先,CodeMirror 自己维护选择区、组合输入、历史和多光标。绕过它直接改 DOM,中文输入法合成状态可能被打断,撤销也可能分成多个步骤。其次,有序列表不只是复制 1.,还要正确生成下一项编号;空项目再次按 Enter 应退出列表;引用与列表嵌套时需要保留层级。最后,源码和即时模式共享一个状态,如果事件层另外维护一套文本变化规则,两个模式会逐渐产生行为差异。

因此列表延续直接使用 @codemirror/lang-markdown 提供的 insertNewlineContinueMarkup。它是一个 StateCommand,只在 Markdown 上下文成立时处理,失败时返回 false,后续普通 Enter 命令仍可接管。核心键位注册如下:

const editingKeymap = Prec.high(keymap.of([
  { key: 'Enter', run: insertNewlineContinueMarkup },
  { key: 'Tab', run: (view) => indentList(view, false) },
  { key: 'Shift-Tab', run: (view) => indentList(view, true) }
]));

Prec.high 让 Markdown 结构命令先得到处理机会,但命令返回 false 时不会吞掉普通按键。这一点尤其重要:Tab 只应该在列表上下文调整层级,光标位于普通段落时,应用不能无条件插入空格,更不能把用户困在 ArkWeb 中形成键盘焦点陷阱。

列表延续和空列表退出

有序列表的设备语料很简单:输入 1. 鸿蒙 PC 第一项,按 Enter,再输入“第二项”。实际应用会自动生成 2. ,并保持中文输入法继续工作。
在这里插入图片描述

这张图不是静态设计稿。它来自最终 Debug HAP 在 MateBook Pro 2in1 模拟器中的真实编辑界面,第二行编号由 Enter 命令生成。浏览器自动化还覆盖了另一个容易被忽略的分支:当正文只有 - 且光标位于标记末尾时,再按 Enter 会删除空列表标记并退出列表,而不是无限生成空项目。

撤销测试没有把“按 Enter”和后续输入混在一起。测试先只执行 Enter,确认正文从一行变成带 2. 的两行,然后立刻调用一次 undo(),正文必须完整回到原值。这样验证的是事务边界,而不是“多按几次撤销最终也能恢复”。

await page.keyboard.press('Enter');
expect(await getDocument()).toBe(`${source}\r\n2. `);
expect(await undo()).toBe(true);
expect(await getDocument()).toBe(source);

Tab 只在列表结构里生效

缩进使用 CodeMirror 的 indentMoreindentLess,但执行前会检查选择区覆盖的每一条非空行。检查依据不是行首正则,而是 Markdown 语法树中的 ListItem 节点。只有所有相关非空行都属于列表项,Tab 或 Shift+Tab 才会进入结构事务。

function indentList(view: EditorView, decrease: boolean): boolean {
  if (!selectionIsInList(view.state)) {
    return false;
  }
  const command = decrease ? indentLess : indentMore;
  return command({
    state: view.state,
    dispatch: (transaction) => view.dispatch(transaction)
  });
}

这段限制解决了两个问题。第一,普通正文里的 Tab 不被结构模块抢占,桌面焦点导航仍有机会工作。第二,多行选择不会因为其中一行碰巧是列表项就整体缩进;每条非空行都必须满足条件。自动化在即时模式中把第二个列表项缩进为两空格层级,然后单次撤销,再执行 Tab 与 Shift+Tab 往返,最终正文必须逐字符等于初始值。

即时渲染在这里仍只是显示层。列表命令修改同一个 EditorState,没有“源码命令”和“即时命令”两套实现,所以模式切换不会造成标记漂移。

自动配对为什么要做成状态而不是全局变量

CodeMirror 的 basicSetup 已包含成熟的 close-brackets 输入处理。本阶段没有重新实现括号插入、选择区包裹、闭合符跳过与成对删除,而是通过语言数据控制允许配对的标记集合。默认集合增加了反引号,覆盖 Markdown 行内代码常用输入;星号和下划线没有加入,因为在行首输入 * 时自动生成 ** 会破坏列表输入语义。

const pairConfiguration = Prec.high(
  EditorState.languageData.of((state) => [{
    closeBrackets: {
      brackets: state.field(automaticPairsEnabled)
        ? ['(', '[', '{', "'", '"', '`']
        : []
    }
  }])
);

开关状态存放在 StateField<boolean> 中,ArkUI 修改设置时通过 StateEffect 更新。这样输入处理每次都从当前编辑器状态读取配置,不依赖不可追踪的 DOM 属性。另一个容易遗漏的点是:打开新文件或切换到一个尚未创建的标签时,应用会重建 EditorState。因此 Web 层还保存 automaticPairsPreference,创建新状态时把当前偏好传给扩展,避免设置在新文档中突然恢复默认值。

自动化验证了选择区包裹:选择“鸿蒙”后输入 [,结果必须是 [鸿蒙],并保持选择语义;关闭自动配对、重建文档状态后输入 [,结果只能有单个左方括号。

鸿蒙 ArkUI 设置闭环

只有 Web API 还不算完整产品能力。用户需要在应用内找到开关,设置要跨重启保留,旧版本没有该字段时要稳定迁移。OhMarkdown 在设置与导出侧栏中加入了符合二元设置语义的 Toggle,并把内容区改为可滚动容器,避免新增设置后在较矮自由窗口中截断底部图片与分享选项。

在这里插入图片描述

偏好使用 ArkData Preferences,缺失或异常类型统一回退为开启:

export function parseAutomaticPairs(value: preferences.ValueType): boolean {
  return typeof value === 'boolean' ? value : true;
}

export async function saveAutomaticPairs(
  context: Context,
  enabled: boolean
): Promise<void> {
  const settings = await preferences.getPreferences(
    context,
    'ohmarkdown-settings'
  );
  await settings.put('automatic-pairs', enabled);
  await settings.flush();
}

模拟器验证没有停留在“按钮能点”。实际操作先把 Toggle 从开启切为关闭,通过 UI 语义树确认 checked=false,随后强制停止并重新启动应用,处理恢复提示后再次打开设置,开关仍为关闭。验证结束前又把它恢复为默认开启,避免把测试偏好留给后续使用。ohosTest 新增纯解析断言,覆盖 truefalse 和旧版本异常值回退。

表格命令为什么先问语法树

Markdown 表格不能只靠“当前行包含竖线”判断。普通段落、代码围栏与行内代码都可能出现 |。结构模块先从当前光标位置向上查找 Lezer 的 Table 节点,只有语法树确认这是 GFM 表格,命令才继续。选择区非空时首版直接拒绝表格行列命令,避免不明确的多单元格语义。

确认表格后,还要知道当前列。Lezer 为表头和正文行提供 TableCell,但对齐分隔行整体表现为 TableDelimiter。为了让三类行使用同一列坐标,模块实现了一个有边界的小型行扫描器:只识别未转义、且不在反引号代码跨度内的管道符。它不负责判断某段文本是不是表格,表格身份已经由语法树确认;扫描器只负责把同一表格的源行映射为列边界。

if (character !== '|' || codeFenceLength !== 0) {
  continue;
}
let precedingBackslashes = 0;
for (let cursor = index - 1;
  cursor >= 0 && text[cursor] === '\\'; cursor -= 1) {
  precedingBackslashes += 1;
}
if (precedingBackslashes % 2 === 0) {
  positions.push(index);
}

这段逻辑专门覆盖 A\|B`x|y`。前者的管道被反斜线转义,后者属于代码内容,两者都不能当成列边界。自动化语料同时包含这两种情况,并在插入、删除和撤销后检查完整字符串。

插入一列必须是一个 ChangeSet

插入表格列会同时修改表头、对齐行和全部正文行。如果逐行调用 dispatch,用户需要按多次 Ctrl+Z 才能撤销,而且中途会看到一个结构不完整的表格。正确做法是先收集每一行的变更,再生成单个 ChangeSet,最后一次分发。

const changes = [];
for (let lineNumber = context.firstLineNumber;
  lineNumber <= context.lastLineNumber; lineNumber += 1) {
  const line = view.state.doc.line(lineNumber);
  const row = parseTableRow(line.text);
  if (!row) return false;
  const insertion = columnInsertion(
    row,
    context.columnIndex,
    lineNumber === context.firstLineNumber + 1
  );
  changes.push({
    from: line.from + insertion.offset,
    insert: insertion.insert
  });
}
const changeSet = view.state.changes(changes);
view.dispatch({
  changes: changeSet,
  selection: { anchor: changeSet.mapPos(view.state.selection.main.head, 1) },
  userEvent: 'input'
});

对齐行插入 ---,普通行插入空单元格。现有单元格内容、对齐冒号、转义字符和代码跨度原样保留。删除列采用同样的多行单事务策略;删除正文行也只生成一个事务,并禁止删除表头和对齐行。插入正文行时,如果光标在表头或对齐行,就把新行放在对齐行之后;如果光标在正文行,则插在当前行之后。

命令面板是桌面操作入口

表格行列操作没有塞进四个永久工具栏按钮。它们属于上下文命令,只有光标位于有效表格中时才启用,更适合进入已有命令面板。中英文命令分别是“插入表格行、删除表格行、插入表格列、删除表格列”和对应英文名称;命令搜索、方向键选择、Enter 执行与 Escape 关闭继续复用现有桌面路由。

MateBook Pro 2in1 模拟器中,通过 Ctrl+Shift+P 打开命令面板,搜索并执行“插入表格列”,表头、对齐行和两条正文行一次增加空列。随后按一次 Ctrl+Z,整列在所有行中一起消失,证明设备上的键盘路由与事务边界一致。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

截图中第三列为空,对齐行已经自动补入 ---。这不是预览表格,而是仍可逐字符编辑的 Markdown 源码,所以用户可以继续输入列名和内容,也可以立即撤销。

CRLF 不是保存时才处理的问题

G4-04 的 CRLF 回归暴露了一个更底层的问题。CodeMirror 内部文本位置把一条换行视为一个位置,但 getDocument() 会按 EditorState.lineSeparator 输出 \r\n。来自 ArkUI 搜索、大纲或链接诊断的偏移基于序列化正文,因此每经过一条 CRLF,就比 CodeMirror 内部位置多一个字符。如果直接把外部偏移交给 EditorSelection,光标会逐行偏移,严重时跳转会被判定越界。

本阶段增加了序列化偏移到编辑器位置的映射。LF 文档保持 O(1) 返回;CRLF 文档在明确跳转时按换行折算,拒绝落在 \r\n 中间的非法位置。列表自动化用包含 CRLF 的两行语料,把光标跳到第二行末尾,再按 Enter 验证生成的下一行仍是 CRLF。表格行插入也使用 state.lineBreak,最终 getDocument() 的换行格式保持不变。

这项修复说明结构化编辑不能只测试视觉。一个看似正确的第二行编号,如果保存后把 CRLF 变成 LF,或者外部跳转选中了错误位置,仍然是不合格的桌面编辑体验。

源码保真与撤销验收

G4-04 为每种变化都设置了明确的回退检查:

  • 有序列表 Enter 后一次撤销回到原始一行。
  • Tab 缩进后一次撤销回到原层级。
  • Tab 与 Shift+Tab 往返后全文完全相等。
  • 插入表格行后一次撤销恢复原表。
  • 删除表格行后一次撤销恢复原表。
  • 插入表格列后一次撤销恢复所有行。
  • 删除表格列后一次撤销恢复所有行。
  • 开关自动配对不修改正文,也不污染正文历史。
  • 源码与即时模式调用相同命令,不生成模式专属 Markdown。

这里的“完全相等”是字符串级比较,不是渲染结果看起来相同。空格、反斜线、反引号、对齐冒号和换行格式都属于 Markdown 源码的一部分。

自动化与鸿蒙模拟器结果

最终 ./scripts/verify-local.sh 全部通过:Playwright 从 50 项增加到 55 项,结果 55/55;精确 10 MiB Chromium 保护模式本轮测得 232 ms;Vite 生产单 HTML 为 7,687.04 kB,gzip 3,430.49 kB;Debug HAP 构建成功;ArkTS UnitTestBuild 成功;git diff --check 成功。

最终 Debug HAP 大小为 8,572,467 字节,SHA-256 是 ee91589b1c718b071bdbdedeab345a12f0a2ed722732b321e1077db496846188。最终 ohosTest HAP 大小为 9,368,131 字节,SHA-256 是 c3bea02e558fa2ca8d6765d50d9edeeecfe0697cd486b9e409bc80620961bf18

最终两个 HAP 都重新安装到 MateBook Pro 2in1 模拟器。ohosTest 从 11 项增加为 12 项,结果 12/12,Failure 0、Error 0,总耗时 2897 ms。设备交互另外完成以下路径:

  1. 中文有序列表按 Enter 自动生成下一编号。
  2. 设置侧栏能完整显示自动配对开关与底部图片选项。
  3. 自动配对关闭后强停重启,Preferences 状态仍为关闭。
  4. Ctrl+Shift+P 打开中文命令面板并执行插入表格列。
  5. 单次 Ctrl+Z 撤销整列表格变化。

三张应用截图均为 3120 x 2080。它们分别对应设置闭环、列表延续和表格事务,而不是只用一张泛化首页代替功能证据。

没有被本阶段假装解决的问题

当前结构化编辑仍有清晰边界。表格命令首版只处理单光标,不定义跨多个表格或矩形选择的行列语义;表格单元格宽度不会自动格式化对齐,因为自动重排会制造大面积无意义 diff;任务列表的复选框切换还没有专用事务;列表重新编号、标题升降级、引用层级命令也没有提前塞入 G4-04。

模拟器能够证明 ArkUI 设置、ArkWeb 键盘路由、中文输入与命令面板在当前 HarmonyOS 环境中成立,但不能替代鸿蒙 PC 真机上的物理键盘布局、触控板焦点、输入法候选窗、Release 性能和长时间稳定性。竞品统一任务也尚未执行,因此竞争优势记分仍然保持谨慎,不把“功能已实现”等同于“已经领先”。

为什么这套设计适合继续扩展

这一实现保持在既有 Level 2 架构与 D2 设计复杂度内。structured-editing.ts 是 Web 编辑器内部的明确扩展边界;ArkUI 只新增一个持久化设置与受限调用;没有引入数据库、通用事件总线、第二文本模型或开放式 Bridge。后续增加任务列表切换、表格对齐命令或标题层级调整时,可以继续遵循同一约束:先用语法树确认上下文,收集完整变化,一次分发事务,再用字符串级不变量和设备键盘路径验收。

G4-04 完成后,项目下一步进入有界版本历史。版本历史会触及快照格式、清理上限、文档身份和外部冲突,需要先形成独立 ADR,再开始实现。结构化编辑留下的单事务边界也会成为版本历史的基础:历史系统应该记录用户能理解的一次操作,而不是表格每一行各自留下一个噪声版本。

结语

一个成熟的鸿蒙 PC Markdown 编辑器,不应只做到“能输入 Markdown”。桌面用户真正依赖的是连续、可预测、可撤销的编辑动作。列表延续要理解空项目和编号,Tab 要尊重焦点与语法上下文,自动配对要允许用户关闭并跨重启保存,表格行列修改要在所有源码行上形成一个事务,CRLF 偏移还要在系统层和编辑器层之间正确换算。

这些能力单独看都不华丽,却直接决定用户是否敢把真实文档交给编辑器。OhMarkdown 在 1fffbbd 中完成的不是一组字符串快捷操作,而是一套以源码保真、事务原子性和桌面键盘路径为中心的结构化编辑基线。后续功能可以更丰富,但这几条底层原则不能退让。

Logo

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

更多推荐