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

发布时间:2026/7/24 5:44:16

鸿蒙 PC Markdown 编辑器结构化编辑:列表事务、自动配对与表格行列命令 鸿蒙 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 仍然把 CodeMirrorEditorState中的 Markdown 字符串作为编辑期唯一事实来源ArkUI 只负责桌面工作台、系统能力与设置预览 DOM 也不会反向生成 Markdown。基于这个前提G4-04 给结构化命令规定了几条不可退让的不变量每次用户命令只生成一个可撤销事务。源码模式与即时模式执行同一个命令得到相同正文。表格命令不能因为单元格中出现转义管道或代码片段里的管道而误切列。LF 与 CRLF 的显示、跳转和保存语义必须一致。自动配对必须可关闭并且关闭状态要跨编辑器状态重建与应用重启保存。大文档保护模式不加载结构化语法服务继续优先保证输入和保存。无法可靠识别的结构返回false保留普通编辑行为不猜测用户意图。这几条约束决定了实现形态。列表行为优先复用 CodeMirror 官方 Markdown 命令表格修改建立在 Lezer 语法树确认过的Table节点上行内的列边界扫描只承担“定位不可被语法树直接给出的分隔符”这一项职责。设置则通过 ArkUI、Preferences 与受限 Web API 形成闭环。为什么不能在 keydown 里直接拼字符串最直接的写法是在 DOMkeydown事件中判断 Enter然后读取当前行并向文本框塞入-。这种写法在简单演示中能工作但在桌面产品里会迅速失控。首先CodeMirror 自己维护选择区、组合输入、历史和多光标。绕过它直接改 DOM中文输入法合成状态可能被打断撤销也可能分成多个步骤。其次有序列表不只是复制1.还要正确生成下一项编号空项目再次按 Enter 应退出列表引用与列表嵌套时需要保留层级。最后源码和即时模式共享一个状态如果事件层另外维护一套文本变化规则两个模式会逐渐产生行为差异。因此列表延续直接使用codemirror/lang-markdown提供的insertNewlineContinueMarkup。它是一个StateCommand只在 Markdown 上下文成立时处理失败时返回false后续普通 Enter 命令仍可接管。核心键位注册如下consteditingKeymapPrec.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()正文必须完整回到原值。这样验证的是事务边界而不是“多按几次撤销最终也能恢复”。awaitpage.keyboard.press(Enter);expect(awaitgetDocument()).toBe(${source}\r\n2.);expect(awaitundo()).toBe(true);expect(awaitgetDocument()).toBe(source);Tab 只在列表结构里生效缩进使用 CodeMirror 的indentMore与indentLess但执行前会检查选择区覆盖的每一条非空行。检查依据不是行首正则而是 Markdown 语法树中的ListItem节点。只有所有相关非空行都属于列表项Tab 或 ShiftTab 才会进入结构事务。functionindentList(view:EditorView,decrease:boolean):boolean{if(!selectionIsInList(view.state)){returnfalse;}constcommanddecrease?indentLess:indentMore;returncommand({state:view.state,dispatch:(transaction)view.dispatch(transaction)});}这段限制解决了两个问题。第一普通正文里的 Tab 不被结构模块抢占桌面焦点导航仍有机会工作。第二多行选择不会因为其中一行碰巧是列表项就整体缩进每条非空行都必须满足条件。自动化在即时模式中把第二个列表项缩进为两空格层级然后单次撤销再执行 Tab 与 ShiftTab 往返最终正文必须逐字符等于初始值。即时渲染在这里仍只是显示层。列表命令修改同一个EditorState没有“源码命令”和“即时命令”两套实现所以模式切换不会造成标记漂移。自动配对为什么要做成状态而不是全局变量CodeMirror 的basicSetup已包含成熟的 close-brackets 输入处理。本阶段没有重新实现括号插入、选择区包裹、闭合符跳过与成对删除而是通过语言数据控制允许配对的标记集合。默认集合增加了反引号覆盖 Markdown 行内代码常用输入星号和下划线没有加入因为在行首输入*时自动生成**会破坏列表输入语义。constpairConfigurationPrec.high(EditorState.languageData.of((state)[{closeBrackets:{brackets:state.field(automaticPairsEnabled)?[(,[,{,,,]:[]}}]));开关状态存放在StateFieldboolean中ArkUI 修改设置时通过StateEffect更新。这样输入处理每次都从当前编辑器状态读取配置不依赖不可追踪的 DOM 属性。另一个容易遗漏的点是打开新文件或切换到一个尚未创建的标签时应用会重建EditorState。因此 Web 层还保存automaticPairsPreference创建新状态时把当前偏好传给扩展避免设置在新文档中突然恢复默认值。自动化验证了选择区包裹选择“鸿蒙”后输入[结果必须是[鸿蒙]并保持选择语义关闭自动配对、重建文档状态后输入[结果只能有单个左方括号。鸿蒙 ArkUI 设置闭环只有 Web API 还不算完整产品能力。用户需要在应用内找到开关设置要跨重启保留旧版本没有该字段时要稳定迁移。OhMarkdown 在设置与导出侧栏中加入了符合二元设置语义的 Toggle并把内容区改为可滚动容器避免新增设置后在较矮自由窗口中截断底部图片与分享选项。偏好使用 ArkData Preferences缺失或异常类型统一回退为开启exportfunctionparseAutomaticPairs(value:preferences.ValueType):boolean{returntypeofvalueboolean?value:true;}exportasyncfunctionsaveAutomaticPairs(context:Context,enabled:boolean):Promisevoid{constsettingsawaitpreferences.getPreferences(context,ohmarkdown-settings);awaitsettings.put(automatic-pairs,enabled);awaitsettings.flush();}模拟器验证没有停留在“按钮能点”。实际操作先把 Toggle 从开启切为关闭通过 UI 语义树确认checkedfalse随后强制停止并重新启动应用处理恢复提示后再次打开设置开关仍为关闭。验证结束前又把它恢复为默认开启避免把测试偏好留给后续使用。ohosTest 新增纯解析断言覆盖true、false和旧版本异常值回退。表格命令为什么先问语法树Markdown 表格不能只靠“当前行包含竖线”判断。普通段落、代码围栏与行内代码都可能出现|。结构模块先从当前光标位置向上查找 Lezer 的Table节点只有语法树确认这是 GFM 表格命令才继续。选择区非空时首版直接拒绝表格行列命令避免不明确的多单元格语义。确认表格后还要知道当前列。Lezer 为表头和正文行提供TableCell但对齐分隔行整体表现为TableDelimiter。为了让三类行使用同一列坐标模块实现了一个有边界的小型行扫描器只识别未转义、且不在反引号代码跨度内的管道符。它不负责判断某段文本是不是表格表格身份已经由语法树确认扫描器只负责把同一表格的源行映射为列边界。if(character!|||codeFenceLength!0){continue;}letprecedingBackslashes0;for(letcursorindex-1;cursor0text[cursor]\\;cursor-1){precedingBackslashes1;}if(precedingBackslashes%20){positions.push(index);}这段逻辑专门覆盖A\|B和x|y。前者的管道被反斜线转义后者属于代码内容两者都不能当成列边界。自动化语料同时包含这两种情况并在插入、删除和撤销后检查完整字符串。插入一列必须是一个 ChangeSet插入表格列会同时修改表头、对齐行和全部正文行。如果逐行调用dispatch用户需要按多次 CtrlZ 才能撤销而且中途会看到一个结构不完整的表格。正确做法是先收集每一行的变更再生成单个ChangeSet最后一次分发。constchanges[];for(letlineNumbercontext.firstLineNumber;lineNumbercontext.lastLineNumber;lineNumber1){constlineview.state.doc.line(lineNumber);constrowparseTableRow(line.text);if(!row)returnfalse;constinsertioncolumnInsertion(row,context.columnIndex,lineNumbercontext.firstLineNumber1);changes.push({from:line.frominsertion.offset,insert:insertion.insert});}constchangeSetview.state.changes(changes);view.dispatch({changes:changeSet,selection:{anchor:changeSet.mapPos(view.state.selection.main.head,1)},userEvent:input});对齐行插入---普通行插入空单元格。现有单元格内容、对齐冒号、转义字符和代码跨度原样保留。删除列采用同样的多行单事务策略删除正文行也只生成一个事务并禁止删除表头和对齐行。插入正文行时如果光标在表头或对齐行就把新行放在对齐行之后如果光标在正文行则插在当前行之后。命令面板是桌面操作入口表格行列操作没有塞进四个永久工具栏按钮。它们属于上下文命令只有光标位于有效表格中时才启用更适合进入已有命令面板。中英文命令分别是“插入表格行、删除表格行、插入表格列、删除表格列”和对应英文名称命令搜索、方向键选择、Enter 执行与 Escape 关闭继续复用现有桌面路由。MateBook Pro 2in1 模拟器中通过CtrlShiftP打开命令面板搜索并执行“插入表格列”表头、对齐行和两条正文行一次增加空列。随后按一次CtrlZ整列在所有行中一起消失证明设备上的键盘路由与事务边界一致。截图中第三列为空对齐行已经自动补入---。这不是预览表格而是仍可逐字符编辑的 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 与 ShiftTab 往返后全文完全相等。插入表格行后一次撤销恢复原表。删除表格行后一次撤销恢复原表。插入表格列后一次撤销恢复所有行。删除表格列后一次撤销恢复所有行。开关自动配对不修改正文也不污染正文历史。源码与即时模式调用相同命令不生成模式专属 Markdown。这里的“完全相等”是字符串级比较不是渲染结果看起来相同。空格、反斜线、反引号、对齐冒号和换行格式都属于 Markdown 源码的一部分。自动化与鸿蒙模拟器结果最终./scripts/verify-local.sh全部通过Playwright 从 50 项增加到 55 项结果55/55精确 10 MiB Chromium 保护模式本轮测得 232 msVite 生产单 HTML 为 7,687.04 kBgzip 3,430.49 kBDebug HAP 构建成功ArkTSUnitTestBuild成功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/12Failure 0、Error 0总耗时 2897 ms。设备交互另外完成以下路径中文有序列表按 Enter 自动生成下一编号。设置侧栏能完整显示自动配对开关与底部图片选项。自动配对关闭后强停重启Preferences 状态仍为关闭。CtrlShiftP打开中文命令面板并执行插入表格列。单次CtrlZ撤销整列表格变化。三张应用截图均为 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中完成的不是一组字符串快捷操作而是一套以源码保真、事务原子性和桌面键盘路径为中心的结构化编辑基线。后续功能可以更丰富但这几条底层原则不能退让。

相关新闻