在这里插入图片描述

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

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

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

CodeMirror 的 @codemirror/search 提供 SearchCursorRegExpCursor。普通查找使用 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 会出现在 alphaalphabetmy_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-1item-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 参数使用结构化转义;非法输入被隔离但保留改进空间。完成这些细节后,搜索面板才不只是“有这个按钮”,而是能够进入用户每天反复使用的编辑主路径。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐