当前位置: 首页 > news >正文

鸿蒙 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;}

fromto使用 CodeMirror 文档偏移,区间为左闭右开。即使执行的是普通查找,也预先保存replacement字段,这样当前替换和全部替换可以共享同一条匹配收集路径。正则模式下,每个匹配的替换文本可能因为捕获组不同而不同,不能等到应用变更时再统一填写。

普通文本和正则表达式使用不同游标

CodeMirror 的@codemirror/search提供SearchCursorRegExpCursor。普通查找使用SearchCursor,避免手工对整篇文档调用indexOf并处理分块文档;正则查找使用RegExpCursor,让匹配偏移与 CodeMirror 文档结构保持一致。

核心收集函数如下:

functioncollectSearchMatches(query:string,replacement:string,matchCase:boolean,wholeWord:boolean,regexp:boolean):Array<SearchMatch>{if(query.length===0){return[];}constmatches:Array<SearchMatch>=[];constdocument=editor.state.doc;try{if(regexp){constcursor=newRegExpCursor(document,query,{ignoreCase:!matchCase});for(constresultofcursor){if((!wholeWord||isWholeWordMatch(document,result.from,result.to))&&result.to>result.from){constvalue=document.sliceString(result.from,result.to);matches.push({from:result.from,to:result.to,replacement:applyRegularExpressionReplacement(value,query,replacement,matchCase)});}}}else{constnormalize=matchCase?undefined:(value:string):string=>value.toLocaleLowerCase();constcursor=newSearchCursor(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会出现在alphaalphabetmy_alpha中。整词开关的目标是排除更长标识符内部的片段。OhMarkdown 把 Unicode 字母、数字和下划线视为词字符:

functionisWordCharacter(character:string):boolean{returncharacter.length>0&&/[\p{L}\p{N}_]/u.test(character);}functionisWholeWordMatch(document:Text,from:number,to:number):boolean{constbefore=from>0?document.sliceString(from-1,from):'';constafter=to<document.length?document.sliceString(to,to+1):'';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:boolean=false,wholeWord:boolean=false,regexp:boolean=false,backwards:boolean=false):number{constmatches=collectSearchMatches(query,'',matchCase,wholeWord,regexp);if(matches.length===0){return0;}constselection=editor.state.selection.main;lettarget=backwards?matches[matches.length-1]:matches[0];if(backwards){for(letindex=matches.length-1;index>=0;index-=1){if(matches[index].to<selection.to||(matches[index].from===selection.from&&matches[index].to!==selection.to)){target=matches[index];break;}}}else{target=matches.find((match)=>match.from>selection.from||(match.from===selection.from&&match.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:boolean=false,wholeWord:boolean=false,regexp:boolean=false):number{constmatches=collectSearchMatches(query,replacement,matchCase,wholeWord,regexp);if(matches.length===0){return0;}constselection=editor.state.selection.main;constselected=matches.find((match)=>match.from===selection.from&&match.to===selection.to)??matches.find((match)=>match.from>=selection.to)??matches[0];editor.dispatch({changes:{from:selected.from,to:selected.to,insert:selected.replacement}});constremaining=collectSearchMatches(query,replacement,matchCase,wholeWord,regexp);if(remaining.length>0){constnext=remaining.find((match)=>match.from>=selected.from+selected.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-1item-2。不能把统一的replacement字面量直接写进所有变更,否则$1会原样进入文档。

ArkUI 面板只负责交互状态

原生侧维护查询、替换式、匹配数和三个布尔开关:大小写、整词、正则。执行时使用JSON.stringify构造脚本参数:

constresult=awaitthis.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.searchMatchCount=Number.parseInt(result);

参数不能通过手工引号拼接。查询可能包含引号、反斜杠、换行或正则转义,JSON.stringify能生成合法 JavaScript 字面量,也能降低脚本注入风险。返回值是数字字符串,原生层解析后更新面板计数和状态栏。异常会变成Search failed,不会让未捕获 Promise 破坏页面状态。

打开搜索面板后延迟一小段时间请求输入框焦点:

privateopenSearchPanel():void{this.activePanel='search';this.sidebarOpen=true;setTimeout(()=>{focusControl.requestFocus('search-query-input');},100);}

延迟是为了等待声明式布局完成。如果在activePanel赋值的同一个同步栈中请求焦点,输入框可能尚未挂载,Ctrl/Cmd+F 看似打开面板,却仍把后续键盘输入送进编辑器。桌面效率功能的验收必须包含“快捷键后直接输入”,不能只看面板是否可见。

鸿蒙 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、限制并发读取、尊重编码、返回文件和行号、处理未保存缓冲区覆盖磁盘内容。把当前文档搜索循环套在多个标签上既不完整,也会漏掉未打开文件。

查找替换的可靠性来自一组清晰边界:文本运算靠近 CodeMirror,ArkUI 负责桌面交互;所有偏移使用同一文本模型;循环定位锚定当前选区;全部替换是一笔事务;Bridge 参数使用结构化转义;非法输入被隔离但保留改进空间。完成这些细节后,搜索面板才不只是“有这个按钮”,而是能够进入用户每天反复使用的编辑主路径。

http://www.jsqmd.com/news/1222761/

相关文章:

  • 2026年湖南Aigc培训机构推荐排行榜单汇总 - 互联网科技品牌测评
  • ThinkingCap-Qwen3.6-27B-mlx-6Bit模型深度解析:6位量化如何实现高效AI推理
  • 江诗丹顿中国官方售后服务中心|网点地址及售后服务热线权威信息声明(2026年7月最新) - 江诗丹顿服务中心
  • 模型服务访问限制应对:API稳定性与架构弹性设计指南
  • C++网络编程模型详解
  • 2026年7月最新江诗丹顿佛山王府井紫薇港维修保养服务电话 - 江诗丹顿官方服务中心
  • Vim-wordy终极指南:10个技巧提升你的写作质量
  • 2026年7月最新卡地亚天津滨海吾悦广场维修保养服务电话 - 卡地亚官方售后中心
  • ExusData:机器人触觉感知数据集完全指南
  • 还在为论文头秃?这5个AI论文工具让你效率翻倍!
  • 2026年Q3四川集装箱定制厂家深度行业分析及权威推荐榜单 - 甄选服务推荐
  • 真力时客服中心电话售后维修保养服务热线权威公示(2026年7月最新) - 亨得利官方服务中心
  • 这里有惊喜,千问新用户免费领8元优惠券 输:新用户福利020738,激活领取入口
  • Go 零基础入门万字笔记整理|语法规范、数据类型、指针、运算符、流程控制、函数全梳理
  • C++ 游戏开发框架:从入门到选型指南
  • 2026年7月最新浪琴福州仓山万达广场维修保养服务电话 - 浪琴官方售后服务中心
  • 大语言模型J空间机制:从全局工作空间理论到AI安全监控
  • 亲身到店探访北京浪琴官方售后服务中心|最新电话与详细地址(2026年7月最新) - 浪琴服务中心
  • 2026 南京 GEO 优化行业实时盘点:企业 AI 营销核心伙伴选型全指南 - 品牌前沿专家
  • GripMock实战案例:电商系统微服务测试的完整应用场景
  • AI Agent时代Skill安全防护全解析
  • 26面主课 07笔记
  • Nemo Skills安全最佳实践:确保LLM评估的安全性和可靠性
  • Windows Terminal Quake:让任何应用秒变Quake式下拉窗口的终极工具
  • 阿里安全AGI一次发布3款LLM,8B多维度领先GPT-5.4
  • c语言占位符
  • 重磅通知!江诗丹顿香港2026年7月官方售后网点地址更新|客服电话一键查询 - 江诗丹顿服务中心
  • 为什么选择DataStructures.jl?探索Julia中高效数据结构的10大核心优势
  • 百达翡丽官方保养价格查询|电话及服务网点地址权威信息公告(2026年7月最新) - 百达翡丽官方售后中心
  • 鸿蒙 ArkTS 实战:Soup Timer 从煲汤计时器到餐厨体验工具完整解析