鸿蒙 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,也让选择匹配、滚动到可视区和替换事务都在同一个文本坐标系中完成。
搜索结果使用最小结构表示:
interface SearchMatch {
from: number;
to: number;
replacement: string;
}
from 和 to 使用 CodeMirror 文档偏移,区间为左闭右开。即使执行的是普通查找,也预先保存 replacement 字段,这样当前替换和全部替换可以共享同一条匹配收集路径。正则模式下,每个匹配的替换文本可能因为捕获组不同而不同,不能等到应用变更时再统一填写。
普通文本和正则表达式使用不同游标
CodeMirror 的 @codemirror/search 提供 SearchCursor 与 RegExpCursor。普通查找使用 SearchCursor,避免手工对整篇文档调用 indexOf 并处理分块文档;正则查找使用 RegExpCursor,让匹配偏移与 CodeMirror 文档结构保持一致。
核心收集函数如下:
function collectSearchMatches(
query: string,
replacement: string,
matchCase: boolean,
wholeWord: boolean,
regexp: boolean
): Array<SearchMatch> {
if (query.length === 0) {
return [];
}
const matches: Array<SearchMatch> = [];
const document = editor.state.doc;
try {
if (regexp) {
const cursor = new RegExpCursor(document, query, {
ignoreCase: !matchCase
});
for (const result of cursor) {
if ((!wholeWord || isWholeWordMatch(document, result.from, result.to)) &&
result.to > result.from) {
const value = document.sliceString(result.from, result.to);
matches.push({
from: result.from,
to: result.to,
replacement: applyRegularExpressionReplacement(
value, query, replacement, matchCase
)
});
}
}
} else {
const normalize = matchCase ? undefined :
(value: string): string => value.toLocaleLowerCase();
const cursor = new SearchCursor(
document, query, 0, document.length, normalize
);
for (const result of cursor) {
if (!wholeWord || isWholeWordMatch(document, result.from, result.to)) {
matches.push({
from: result.from,
to: result.to,
replacement
});
}
}
}
} catch (_) {
return [];
}
return matches;
}
空查询直接返回空数组,这不仅避免无意义扫描,也定义了 UI 语义:空输入不会把每个字符间隙当成匹配。正则分支还拒绝零长度匹配,即 result.to > result.from。零长度正则并非没有用途,例如行首 ^ 可以用于批量插入,但如果当前替换后仍停留在同一偏移,很容易形成无法前进的循环。Alpha 阶段选择只支持非空匹配,先保证交互不会锁死;若未来支持零宽替换,需要引入“每次至少前进一个 Unicode 码点”的专门规则。
大小写不敏感的普通查找通过 toLocaleLowerCase 归一化。它比仅处理 ASCII 的自定义转换覆盖更广,但仍然不是完整的语言学大小写折叠。例如某些语言存在一个字符映射为多个字符的情况,归一化后的长度可能变化。Markdown 编辑器常见查询以代码标识符、英文和中文为主,当前实现满足主路径;若产品进入多语言专业写作场景,应增加土耳其语、希腊语和组合字符语料,而不是假设一次小写转换适用于所有文字。
捕获异常后返回空数组,能够防止非法正则表达式穿透 Bridge 导致页面异常。但它也意味着“正则无匹配”和“正则语法错误”暂时显示相同结果。更完整的产品体验应让收集函数返回带错误字段的结构,例如 { matches, error },原生面板可以把错误定位到输入框,而不是只显示 No matches。
整词匹配必须检查边界
普通字符串 alpha 会出现在 alpha、alphabet 和 my_alpha 中。整词开关的目标是排除更长标识符内部的片段。OhMarkdown 把 Unicode 字母、数字和下划线视为词字符:
function isWordCharacter(character: string): boolean {
return character.length > 0 && /[\p{L}\p{N}_]/u.test(character);
}
function isWholeWordMatch(document: Text, from: number, to: number): boolean {
const before = from > 0 ? document.sliceString(from - 1, from) : '';
const after = 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、组合附加符号和语言分词。当前实现选择了可解释、可测试的边界,而不是宣称已经解决所有自然语言分词。
循环定位要以当前选区为锚点
查找下一个不能每次都返回第一项。程序先获取当前主选区,再选择其后的匹配;到达末尾后回到第一项。反向查找则从数组尾部向前扫描,到达开头后回到最后一项。
function find(
query: string,
matchCase: boolean = false,
wholeWord: boolean = false,
regexp: boolean = false,
backwards: boolean = false
): number {
const matches = collectSearchMatches(
query, '', matchCase, wholeWord, regexp
);
if (matches.length === 0) {
return 0;
}
const selection = editor.state.selection.main;
let target = backwards ? matches[matches.length - 1] : matches[0];
if (backwards) {
for (let index = 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);
return matches.length;
}
判断条件不能只比较 from。用户可能把光标放在某个匹配的开头,但选区尚未覆盖整个词;第一次查找应选择当前匹配,第二次才前进。实现同时比较起点和终点,区分单光标与已经选中的匹配。
选择匹配时把选区和滚动放在同一个 dispatch 中:
function selectSearchMatch(match: SearchMatch): void {
editor.dispatch({
selection: { anchor: match.from, head: match.to },
effects: EditorView.scrollIntoView(match.from, { y: 'center' })
});
editor.focus();
}
scrollIntoView 使用垂直居中,让用户看到匹配上下文,而不是把目标贴在窗口最底部。随后恢复编辑器焦点,用户可以立即输入替换、复制或继续快捷键操作。若只在原生侧更新匹配计数、不把焦点还给 CodeMirror,桌面工作流会被迫在鼠标和键盘之间反复切换。
当前替换与全部替换的事务语义不同
当前替换先尝试替换当前选中的匹配;如果选区不是匹配,则选择选区之后的第一项;末尾则循环到第一项。替换后重新收集匹配,并自动选中下一项:
function replaceCurrent(
query: string,
replacement: string,
matchCase: boolean = false,
wholeWord: boolean = false,
regexp: boolean = false
): number {
const matches = collectSearchMatches(
query, replacement, matchCase, wholeWord, regexp
);
if (matches.length === 0) {
return 0;
}
const selection = editor.state.selection.main;
const selected = 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
}
});
const remaining = collectSearchMatches(
query, replacement, matchCase, wholeWord, regexp
);
if (remaining.length > 0) {
const next = remaining.find((match) =>
match.from >= selected.from + selected.replacement.length
) ?? remaining[0];
selectSearchMatch(next);
}
return remaining.length;
}
替换会改变后续偏移,所以不能沿用替换前的匹配数组定位下一项。重新扫描虽然有成本,却能保证插入文本长度变化、替换文本仍包含查询词等情况下的正确性。对于普通文档,这种确定性比微小优化更重要;超大文档若需要连续替换性能,可以在后续引入增量范围更新。
全部替换则把所有 changes 放入一次 CodeMirror 事务:
editor.dispatch({
changes: matches.map((match) => ({
from: match.from,
to: match.to,
insert: match.replacement
}))
});
一次 dispatch 的关键价值是整体撤销。用户执行 Replace All 后按一次撤销,应该恢复替换前文档,而不是只撤销最后一处。CodeMirror 会基于原始文档坐标统一应用不重叠变更,也避免从前向后逐次替换时偏移不断漂移。
正则捕获组由 applyRegularExpressionReplacement 展开:
function applyRegularExpressionReplacement(
value: string,
query: string,
replacement: string,
matchCase: boolean
): string {
try {
return value.replace(
new RegExp(query, matchCase ? '' : 'i'),
replacement
);
} catch (_) {
return replacement;
}
}
例如查询 beta-(\d),替换为 item-$1,两处匹配会分别得到 item-1 与 item-2。不能把统一的 replacement 字面量直接写进所有变更,否则 $1 会原样进入文档。
ArkUI 面板只负责交互状态
原生侧维护查询、替换式、匹配数和三个布尔开关:大小写、整词、正则。执行时使用 JSON.stringify 构造脚本参数:
const result = await this.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 破坏页面状态。
打开搜索面板后延迟一小段时间请求输入框焦点:
private openSearchPanel(): 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 参数使用结构化转义;非法输入被隔离但保留改进空间。完成这些细节后,搜索面板才不只是“有这个按钮”,而是能够进入用户每天反复使用的编辑主路径。
更多推荐

所有评论(0)