鸿蒙 PC Markdown 编辑器查找系统:大小写、整词与循环定位
鸿蒙 PC Markdown 编辑器查找系统大小写、整词与循环定位查找替换看起来像一个输入框加两个箭头但在代码编辑器和 Markdown 编辑器里它实际连接着文本模型、选区、滚动、撤销历史、正则表达式、输入焦点以及原生外壳。桌面用户会连续执行“输入查询、回车定位、向前返回、切换整词、修改替换式、全部替换、撤销”这一整条路径任何一步的语义不一致都会让工具显得不可靠。本文以鸿蒙 PC 原生应用 OhMarkdown 为例说明如何在 ArkUI 中组织搜索面板在 ArkWeb 中复用 CodeMirror 6 的搜索游标并把查找、当前替换与全部替换设计成可预测的编辑事务。完整代码位于 https://gitcode.com/VON-/codex_md_oh搜索逻辑应该靠近文本模型OhMarkdown 的工作台由 ArkUI 构建正文编辑由 ArkWeb 中的 CodeMirror 6 承担。搜索面板当然可以在 ArkUI 中扫描documentContent但这样会产生两个问题。第一原生层保存的正文可能只是最近一次 Bridge 同步快照而不是当前按键后的精确状态。第二即使原生层计算出字符索引最终仍要通知 CodeMirror 设置选区和滚动逻辑被拆成两半容易出现偏移不一致。因此查询执行放在 Web 编辑器内部ArkUI 只维护用户选项、显示匹配数和发出命令。这个边界让搜索始终面对editor.state.doc也让选择匹配、滚动到可视区和替换事务都在同一个文本坐标系中完成。搜索结果使用最小结构表示interfaceSearchMatch{from:number;to:number;replacement:string;}from和to使用 CodeMirror 文档偏移区间为左闭右开。即使执行的是普通查找也预先保存replacement字段这样当前替换和全部替换可以共享同一条匹配收集路径。正则模式下每个匹配的替换文本可能因为捕获组不同而不同不能等到应用变更时再统一填写。普通文本和正则表达式使用不同游标CodeMirror 的codemirror/search提供SearchCursor与RegExpCursor。普通查找使用SearchCursor避免手工对整篇文档调用indexOf并处理分块文档正则查找使用RegExpCursor让匹配偏移与 CodeMirror 文档结构保持一致。核心收集函数如下functioncollectSearchMatches(query:string,replacement:string,matchCase:boolean,wholeWord:boolean,regexp:boolean):ArraySearchMatch{if(query.length0){return[];}constmatches:ArraySearchMatch[];constdocumenteditor.state.doc;try{if(regexp){constcursornewRegExpCursor(document,query,{ignoreCase:!matchCase});for(constresultofcursor){if((!wholeWord||isWholeWordMatch(document,result.from,result.to))result.toresult.from){constvaluedocument.sliceString(result.from,result.to);matches.push({from:result.from,to:result.to,replacement:applyRegularExpressionReplacement(value,query,replacement,matchCase)});}}}else{constnormalizematchCase?undefined:(value:string):stringvalue.toLocaleLowerCase();constcursornewSearchCursor(document,query,0,document.length,normalize);for(constresultofcursor){if(!wholeWord||isWholeWordMatch(document,result.from,result.to)){matches.push({from:result.from,to:result.to,replacement});}}}}catch(_){return[];}returnmatches;}空查询直接返回空数组这不仅避免无意义扫描也定义了 UI 语义空输入不会把每个字符间隙当成匹配。正则分支还拒绝零长度匹配即result.to result.from。零长度正则并非没有用途例如行首^可以用于批量插入但如果当前替换后仍停留在同一偏移很容易形成无法前进的循环。Alpha 阶段选择只支持非空匹配先保证交互不会锁死若未来支持零宽替换需要引入“每次至少前进一个 Unicode 码点”的专门规则。大小写不敏感的普通查找通过toLocaleLowerCase归一化。它比仅处理 ASCII 的自定义转换覆盖更广但仍然不是完整的语言学大小写折叠。例如某些语言存在一个字符映射为多个字符的情况归一化后的长度可能变化。Markdown 编辑器常见查询以代码标识符、英文和中文为主当前实现满足主路径若产品进入多语言专业写作场景应增加土耳其语、希腊语和组合字符语料而不是假设一次小写转换适用于所有文字。捕获异常后返回空数组能够防止非法正则表达式穿透 Bridge 导致页面异常。但它也意味着“正则无匹配”和“正则语法错误”暂时显示相同结果。更完整的产品体验应让收集函数返回带错误字段的结构例如{ matches, error }原生面板可以把错误定位到输入框而不是只显示No matches。整词匹配必须检查边界普通字符串alpha会出现在alpha、alphabet和my_alpha中。整词开关的目标是排除更长标识符内部的片段。OhMarkdown 把 Unicode 字母、数字和下划线视为词字符functionisWordCharacter(character:string):boolean{returncharacter.length0/[\p{L}\p{N}_]/u.test(character);}functionisWholeWordMatch(document:Text,from:number,to:number):boolean{constbeforefrom0?document.sliceString(from-1,from):;constaftertodocument.length?document.sliceString(to,to1):;return!isWordCharacter(before)!isWordCharacter(after);}边界判断发生在匹配区间之外。文档开头和结尾使用空字符串自然被视为非词字符。下划线按代码标识符处理所以查询name不会把user_name中的后半段认作整词连字符不属于词字符beta-1中的beta可以作为完整词匹配。这种规则比 JavaScript 的\b更适合混合 Markdown 和代码内容因为传统\b对非 ASCII 字母的语义经常不符合用户预期。需要注意CodeMirror 偏移与 JavaScript 字符串一样以 UTF-16 码元计数而这里向前、向后各取一个码元。基本多文种平面内的中文、英文和数字没有问题代理对字符和扩展字素簇仍需专门测试。真正要做到 Unicode 文本编辑器级别的“整词”还应考虑Intl.Segmenter、组合附加符号和语言分词。当前实现选择了可解释、可测试的边界而不是宣称已经解决所有自然语言分词。循环定位要以当前选区为锚点查找下一个不能每次都返回第一项。程序先获取当前主选区再选择其后的匹配到达末尾后回到第一项。反向查找则从数组尾部向前扫描到达开头后回到最后一项。functionfind(query:string,matchCase:booleanfalse,wholeWord:booleanfalse,regexp:booleanfalse,backwards:booleanfalse):number{constmatchescollectSearchMatches(query,,matchCase,wholeWord,regexp);if(matches.length0){return0;}constselectioneditor.state.selection.main;lettargetbackwards?matches[matches.length-1]:matches[0];if(backwards){for(letindexmatches.length-1;index0;index-1){if(matches[index].toselection.to||(matches[index].fromselection.frommatches[index].to!selection.to)){targetmatches[index];break;}}}else{targetmatches.find((match)match.fromselection.from||(match.fromselection.frommatch.to!selection.to))??matches[0];}selectSearchMatch(target);returnmatches.length;}判断条件不能只比较from。用户可能把光标放在某个匹配的开头但选区尚未覆盖整个词第一次查找应选择当前匹配第二次才前进。实现同时比较起点和终点区分单光标与已经选中的匹配。选择匹配时把选区和滚动放在同一个 dispatch 中functionselectSearchMatch(match:SearchMatch):void{editor.dispatch({selection:{anchor:match.from,head:match.to},effects:EditorView.scrollIntoView(match.from,{y:center})});editor.focus();}scrollIntoView使用垂直居中让用户看到匹配上下文而不是把目标贴在窗口最底部。随后恢复编辑器焦点用户可以立即输入替换、复制或继续快捷键操作。若只在原生侧更新匹配计数、不把焦点还给 CodeMirror桌面工作流会被迫在鼠标和键盘之间反复切换。当前替换与全部替换的事务语义不同当前替换先尝试替换当前选中的匹配如果选区不是匹配则选择选区之后的第一项末尾则循环到第一项。替换后重新收集匹配并自动选中下一项functionreplaceCurrent(query:string,replacement:string,matchCase:booleanfalse,wholeWord:booleanfalse,regexp:booleanfalse):number{constmatchescollectSearchMatches(query,replacement,matchCase,wholeWord,regexp);if(matches.length0){return0;}constselectioneditor.state.selection.main;constselectedmatches.find((match)match.fromselection.frommatch.toselection.to)??matches.find((match)match.fromselection.to)??matches[0];editor.dispatch({changes:{from:selected.from,to:selected.to,insert:selected.replacement}});constremainingcollectSearchMatches(query,replacement,matchCase,wholeWord,regexp);if(remaining.length0){constnextremaining.find((match)match.fromselected.fromselected.replacement.length)??remaining[0];selectSearchMatch(next);}returnremaining.length;}替换会改变后续偏移所以不能沿用替换前的匹配数组定位下一项。重新扫描虽然有成本却能保证插入文本长度变化、替换文本仍包含查询词等情况下的正确性。对于普通文档这种确定性比微小优化更重要超大文档若需要连续替换性能可以在后续引入增量范围更新。全部替换则把所有 changes 放入一次 CodeMirror 事务editor.dispatch({changes:matches.map((match)({from:match.from,to:match.to,insert:match.replacement}))});一次 dispatch 的关键价值是整体撤销。用户执行Replace All后按一次撤销应该恢复替换前文档而不是只撤销最后一处。CodeMirror 会基于原始文档坐标统一应用不重叠变更也避免从前向后逐次替换时偏移不断漂移。正则捕获组由applyRegularExpressionReplacement展开functionapplyRegularExpressionReplacement(value:string,query:string,replacement:string,matchCase:boolean):string{try{returnvalue.replace(newRegExp(query,matchCase?:i),replacement);}catch(_){returnreplacement;}}例如查询beta-(\d)替换为item-$1两处匹配会分别得到item-1与item-2。不能把统一的replacement字面量直接写进所有变更否则$1会原样进入文档。ArkUI 面板只负责交互状态原生侧维护查询、替换式、匹配数和三个布尔开关大小写、整词、正则。执行时使用JSON.stringify构造脚本参数constresultawaitthis.editorController.runJavaScript((window.OhMarkdownEditor?.find(${JSON.stringify(this.searchQuery)},${JSON.stringify(this.searchCaseSensitive)},${JSON.stringify(this.searchWholeWord)},${JSON.stringify(this.searchRegularExpression)},${JSON.stringify(backwards)}) ?? 0));this.searchMatchCountNumber.parseInt(result);参数不能通过手工引号拼接。查询可能包含引号、反斜杠、换行或正则转义JSON.stringify能生成合法 JavaScript 字面量也能降低脚本注入风险。返回值是数字字符串原生层解析后更新面板计数和状态栏。异常会变成Search failed不会让未捕获 Promise 破坏页面状态。打开搜索面板后延迟一小段时间请求输入框焦点privateopenSearchPanel():void{this.activePanelsearch;this.sidebarOpentrue;setTimeout((){focusControl.requestFocus(search-query-input);},100);}延迟是为了等待声明式布局完成。如果在activePanel赋值的同一个同步栈中请求焦点输入框可能尚未挂载Ctrl/CmdF 看似打开面板却仍把后续键盘输入送进编辑器。桌面效率功能的验收必须包含“快捷键后直接输入”不能只看面板是否可见。鸿蒙 PC 模拟器中的搜索界面下图为 MateBook Pro 2in1 模拟器中的实际应用。左侧搜索面板查询alpha顶部与状态栏均显示两个匹配编辑区中的两处文本被定位。面板同时提供向前、向后、大小写、整词、正则、当前替换与全部替换。这张图还揭示了一个重要测试原则不要只准备排版完美的示例文档。截图中的首行故意包含普通文本与标题标记混合的边缘输入后面还有不同级别标题。搜索必须把 Markdown 当成源码文本不应因为预览语义而跳过标记或重写偏移。自动化用例覆盖了大小写、整词、正则和捕获组host.OhMarkdownEditor.setDocument(alpha Alpha alphabet beta-1 beta-2);expect(host.OhMarkdownEditor.find(alpha,false,true)).toBe(2);expect(host.OhMarkdownEditor.find(Alpha,true,true)).toBe(1);expect(host.OhMarkdownEditor.replaceCurrent(Alpha,ALPHA,true,true)).toBe(0);expect(host.OhMarkdownEditor.replaceAll(beta-(\\d),item-$1,true,false,true)).toBe(2);最终正文必须是alpha ALPHA alphabet item-1 item-2。这个断言同时证明整词没有修改alphabet大小写开关有效当前替换返回剩余数量正则捕获组被正确展开。性能与后续边界当前实现每次查询都会收集全部匹配因此时间复杂度约为一次全文扫描空间复杂度与匹配数线性相关。普通 Markdown 文档和当前五兆大文档边界内可接受但“单字符查询加百万次匹配”会产生大量对象。后续可以设置显示计数上限、仅保留当前附近匹配或让游标按方向惰性查找。不过优化不能破坏 Replace All 的一次事务语义也不能让显示的总数与实际替换数量不一致。另一个边界是多标签全局搜索。本文实现明确属于当前文档查找它操作活动EditorState不会扫描工作区其他文件。全局搜索需要不同架构遍历 Core File Kit URI、限制并发读取、尊重编码、返回文件和行号、处理未保存缓冲区覆盖磁盘内容。把当前文档搜索循环套在多个标签上既不完整也会漏掉未打开文件。查找替换的可靠性来自一组清晰边界文本运算靠近 CodeMirrorArkUI 负责桌面交互所有偏移使用同一文本模型循环定位锚定当前选区全部替换是一笔事务Bridge 参数使用结构化转义非法输入被隔离但保留改进空间。完成这些细节后搜索面板才不只是“有这个按钮”而是能够进入用户每天反复使用的编辑主路径。