复制粘贴保真攻坚:鸿蒙编辑器剪贴板的三个坑、五段代码

我认为复制粘贴格式丢失是块硬骨头,讲用这一整篇来讲解解决方案,按技术复盘的讲法来:每个问题按「现象 → 分析定位 → 修复代码 → 验证结果」走一遍,代码全部取自 MarkPin 真实源码,可对照。

一、问题定义:剪贴板是双向断供的

现象很朴素:从网页/WPS 复制内容粘进 MarkPin,表格变纯文本、加粗变星号;反过来,在所见即所得的渲染态里复制标题,拿到的文字丢了 # 号。

把链路拆开,这是两个独立问题:

  • 粘贴方向:编辑器内核 CodeMirror 6 的粘贴默认只取 text/plain,网页带来的 HTML 结构在入口处就被丢弃;
  • 复制方向:所见即所得的实现方式是"源码一直在、语法符号视觉上隐藏",渲染态选中的是格式内容区,sliceDoc 切出来的本来就是没有标记的片段。

两个方向共享同一组底层约束:鸿蒙应用内是 ArkWeb 混合运行时(Web 内核跑编辑器、ArkTS 原生层管文件与系统服务),剪贴板要跨这层边界。后面三个坑全部出在这条边界上。

二、先讲方法:这次攻坚用到的四个定位手段

比结论更值得分享的是怎么定位的。四板斧:

  1. 纯函数 + 用例先行。转换器设计成纯函数(HTML 进、Markdown 出,不碰编辑器状态),先写 jsdom 用例再实现。后来每次返工,这套用例都是护栏;
  2. 装机 CDP 取证。Chrome 里正常、真机(ArkWeb)异常的问题是这个项目的常态。做法是真机开启 Web 调试端口,CDP 直连在真 ArkWeb 里下断言、抓行为,而不是靠猜;
  3. 写侧读侧同抓。剪贴板问题必须两端同时布点:复制时写入了什么记录、粘贴时读到了什么视图,对不上号的差值才是真相;
  4. 合成事件不作为结论依据。自动化注入的假按键事件(CDP 合成 paste/copy)行为不稳定,同一份代码一次成功一次失败。教训固化成规则:剪贴板类问题只有真实键盘复测才能定案——这条规则下面救过我们两次。

三、坑一:粘贴格式全丢——分流器与转换器

分析定位

第一个结论很快拿到:CM6 的粘贴拦截有两条通道,最初挂在 inputHandler 上的拦截在 doPaste 路径根本不经过(实测不触发),必须用 DOM 事件层 domEventHandlers({paste})。这是通道级错误,改挂载点就解决。

但分流设计才是核心:一次粘贴进来,可能是外部富文本(网页/WPS),可能是应用内复制的内容,可能是 Excel 的 TSV 纯文本,也可能用户只想要纯文本。最终落成六分支优先级链,按"保真语义 > 结构转换 > 兜底"排序:

// editor-build/src/edit/pasteIntercept.ts(节选,注释从简)
export function pasteHtmlExtension(): Extension {
  return EditorView.domEventHandlers({
    paste: (event: ClipboardEvent, view: EditorView): boolean => {
      const text: string = event.clipboardData?.getData('text/plain') ?? '';
      const html: string = event.clipboardData?.getData('text/html') ?? '';

      // ① Ctrl/Cmd+Shift+V:纯文本兜底
      if ((mod.ctrlKey || mod.metaKey) && mod.shiftKey) {
        return dispatchPasteInsert(view, event, text, false, []);
      }
      // ② 应用内来源标记 <!--markpin-->:直插源码原文(零转换零损失)
      //    indexOf 而非 startsWith:Chromium 会注入 <meta charset> 前缀
      const markIdx: number = html.indexOf(RICH_COPY_MARK);
      if (markIdx >= 0 && text.length > 0) {
        return dispatchPasteInsert(view, event, text, true, [], true);
      }
      // ③ 外部富文本:白名单 HTML → Markdown
      let result = html.length > 0 ? convertHtmlToMarkdown(html) : null;
      // ④ TSV 兜底(Excel/WPS 复制区域无 text/html 的场景)
      if (result === null && text.length > 0) {
        const tsv = convertTsvToMarkdown(text);
        if (tsv !== null) result = { markdown: tsv, hasStructure: true, images: [] };
      }
      if (result !== null) {
        return dispatchPasteInsert(view, event, result.markdown, true, result.images);
      }
      // ⑤ web 层读不到剪贴板(见坑二):preventDefault 后请原生层读
      event.preventDefault();
      bridge.onFileCommand('pasteHtmlProbe', JSON.stringify({ plainLen: text.length }));
      setTimeout(() => { fallbackPlainInsert(view); }, NATIVE_PROBE_TIMEOUT); // 600ms
      return true;
    }
  });
}

修复代码:转换器的核心——表格网格展开

转换器是白名单制:认得的标签转换,不认识的递归下钻取文字,script/style 这类噪声直接剔除。行内五件套和标题列表都是直来直去,真正有算法含量的是表格:HTML 的 colspan/rowspan 在 GFM 表格里没有对应物,我们的决策是内容展开(信息不丢优先),实现是"占据即复制"的网格算法:

// editor-build/src/edit/pasteConvert.ts(表格网格展开,节选)
// 网格展开:colspan/rowspan 内容直接写入目标格(占据即复制);
// 列游标推进查 grid[r](rowspan 从上方占据的格已非 null,跳过)
const grid: (string | null)[][] = [];
for (let r = 0; r < rowEls.length; r++) {
  let col: number = 0;
  const cells: HTMLCollection = rowEls[r].children;
  for (let c = 0; c < cells.length; c++) {
    const cellEl = cells.item(c);
    const tag = cellEl?.tagName.toLowerCase();
    if (tag !== 'td' && tag !== 'th') continue;
    // 跳过被占据的格(rowspan 展开 / 本行先前单元格写入)
    while (col < grid[r].length && grid[r][col] !== null) {
      col++;
    }
    const colspan = this.spanAttr(cellEl, 'colspan');
    const rowspan = this.spanAttr(cellEl, 'rowspan');
    const text = this.convertCell(cellEl as HTMLElement);
    for (let dc = 0; dc < colspan; dc++) {
      for (let dr = 0; dr < rowspan; dr++) {
        while (grid.length <= r + dr) grid.push([]);
        while (grid[r + dr].length <= col + dc) grid[r + dr].push(null);
        grid[r + dr][col + dc] = text;   // 展开的内容复制进每个被合并的格
      }
    }
    col += colspan;
  }
}
// 列数取最大、短行补空,再按表头对齐生成 |:---:| 分隔行

另一个高频场景是 Excel/WPS:复制区域根本没有 text/html,只有制表符分隔的纯文本。兜底转换器设了严格准入条件,防止把普通多行文本误转成表格:

// editor-build/src/edit/pasteConvert.ts(TSV 兜底,节选)
export function convertTsvToMarkdown(text: string): string | null {
  const lines = normalized.split('\n').filter(l => l.trim().length > 0);
  if (lines.length < 2) return null;          // 至少两行
  const colCount = lines[0].split('\t').length;
  if (colCount < 2) return null;              // 至少两列
  for (const l of lines) {
    if (l.split('\t').length !== colCount) return null;  // 每行列数必须一致
  }
  // 通过后:单元格内竖线转义 \|,按最宽列对齐输出 GFM 表格
}

验证结果

转换器 45 个 jsdom 用例全绿(37 个结构转换 + 8 个标记往返);真机模拟器实测:网页表格粘贴成 GFM 表格、Excel 区域自动成表、合并单元格展开后列数对齐。

四、坑二:ArkWeb 里剪贴板"时隐时现"

分析定位

这是整个攻坚里最硬的平台坑。现象:分流器分支 ③ 在真机上经常拿不到 text/html——外部应用复制的富文本,web 层的 clipboardData.types 是空的。

第一轮装机 CDP 取证的结论:原生 pasteboard API 写入的剪贴板内容,web 层完全不可见(连 text/plain 都空),只有 web 内核自写的内容能读回。按这个结论设计了补救架构——web 层拦截粘贴后不依赖自己的 clipboardData,改为请原生层出面读系统剪贴板再回传:

// entry/src/main/ets/pages/Index.ets(原生探测,节选)
private async doPasteHtmlProbe(): Promise<void> {
  let html = ''; let text = ''; let markpinMd = '';
  const pasteData = await pasteboard.getSystemPasteboard().getData();
  html = pasteData.getPrimaryHtml();       // 主记录 HTML(无则空串)
  text = pasteData.getPrimaryText();
  if (pasteData.getPrimaryMimeType() === Index.MARKPIN_MD_MIME) {
    markpinMd = pasteData.getPrimaryText(); // 应用内保真主记录(见坑三)
  }
  sender.send('pasteHtmlData', JSON.stringify({ html, text, markpinMd }));
}

web 侧收到回执后按优先级落盘插入:

// editor-build/src/edit/pasteIntercept.ts(回执处理,节选)
if (markpinMd.length > 0) {
  // 最高优先:自定义 MIME 主记录 = markdown 源码原文,零混染零往返损失
  source = normalizePasteBlock(view, from, to, markpinMd);
} else if (html.indexOf(RICH_COPY_MARK) >= 0) {
  // 旧版原生降级:剥标记强制转换自产 HTML(跳过结构判定)
  const forced = convertHtmlToMarkdown(html.slice(markPos + RICH_COPY_MARK.length), true);
  source = forced !== null ? normalizePasteBlock(view, from, to, forced.markdown) : text;
} else {
  const result = html.length > 0 ? convertHtmlToMarkdown(html) : null;
  source = result !== null ? result.markdown : nativeProbeFallback; // 600ms 超时的纯文本兜底
}

转折:自己的结论被自己推翻

几个版本后再取证,同样的读法又能读到完整内容了。回看两轮数据,最终认知修正为:ArkWeb 的剪贴板透传行为随版本和写入方漂移,单点实测结论有保质期

修复不是删掉 probe(它在"读不到"的场景依然是唯一通路),而是让两条路并存:web 直读路径保留为快路径,probe 作为兜底,600ms 超时自动回退纯文本——哪个能用用哪个,赌单点才是 bug

验证结果

双通道架构经装机两轮回归:直读路径与 probe 路径分别注入验证均正确落盘,超时回退路径无残留。

五、坑三:复制 bbb,粘出 **bbb**bbb

分析定位

最有戏剧性的一个。开启「复制包含格式」后,渲染态复制一个加粗词 bbb,粘贴出来 bbb**bbb**,凭空多一截。

按第二节的方法论走:先疑"粘贴执行了两次"——否掉,落盘只有一次;再疑"自动化合成按键的伪影"——换真实键盘复测,还在,只是症状变成 **bbb**bbb。两次直觉判断都被证伪,停止猜测,装机挂日志,写侧读侧同抓,实锤:

  • 复制端写入:text/html = <p><strong>bbb</strong></p>text/plain = **bbb**(源码);
  • 粘贴端读到:text/plain = **bbb**bbb——剪贴板桥为 HTML 记录自动派生了一份纯文本备选(bbb),和附加的源码记录(**bbb**)在文本视图里拼接

结论:公共 MIME(text/plain)不可承载保真源,系统会在背后动它。

修复代码:给剪贴板开一条"专线"

// entry/src/main/ets/pages/Index.ets(复制端,节选)
private static readonly MARKPIN_MD_MIME: string = 'markpin/markdown';

if (html.length > 0) {
  // 主记录改自定义 MIME(markdown 源码原文)——应用内粘贴保真通道。
  // 实证:text/html 会被剪贴板桥派生文本混染(text 视图 = 附加 text +
  // HTML 派生文本拼接);自定义 MIME 不受派生影响。
  // text/html 与 text/plain 供外部应用消费。
  const pasteData = pasteboard.createData(Index.MARKPIN_MD_MIME, text);
  pasteData.addRecord(pasteboard.MIMETYPE_TEXT_HTML, html);
  pasteData.addRecord(pasteboard.MIMETYPE_TEXT_PLAIN, text);
  await this.setDataWithRetry(systemPasteboard, pasteData, text);
}

逻辑一句话:自定义 MIME 系统不认识,所以不会去派生拼接,是唯一零污染通道;外部应用不认识这个格式,自动忽略,各自拿自己吃得下的记录。读端 getPrimaryMimeType() 命中即直插源码(见坑二回执代码第一分支)。顺手修掉一个连带问题:公式在 HTML 往返中会失真(渲染管线实例把 $..$ 转成了占位元素),复制端换用无公式规则的干净转换实例后无损。

验证结果

修复版本装机复测:渲染态复制 bbb → 粘贴 **bbb** 渲染粗体,保真成立;8 个标记往返用例(粗体/链接/代码块/公式字面等)全绿。

六、补一刀:渲染态复制丢 #

复制端还有最后一个缺口:拖选标题文字只能得到裸文字。根因清晰——渲染态选中区域不含被隐藏的语法符号。修复是一个纯函数"吸附":选区边界落在格式单元内部时,扩展到完整单元;带 src 参数时再做行级吸附(标题/列表/引用吸附到整行,含 # - [x] > 前缀):

// editor-build/src/render/mdParser.ts(选区吸附,节选)
export function expandRangeToTokens(tokens, from, to, src?) {
  const expandable = t =>
    t.type === 'bold' || t.type === 'italic' || t.type === 'strikethrough' ||
    t.type === 'code_inline' || t.type === 'link' || t.type === 'image' || t.type === 'math_inline';
  for (const t of tokens) {
    if (!expandable(t)) continue;
    let fullFrom = t.from, fullTo = t.to;
    for (const m of t.markerRanges) {       // 语法符号区间并入完整范围
      fullFrom = Math.min(fullFrom, m.from);
      fullTo = Math.max(fullTo, m.to);
    }
    if (start > fullFrom && start < fullTo) start = fullFrom;  // 两端单调收敛
    if (end > fullFrom && end < fullTo) end = fullTo;
  }
  // src 在场时块级吸附:heading/list_item/task_list/blockquote → 吸到所在行行首..行尾
  ...
}

这个函数从 14 个用例(行内吸附)扩到 34 个(加块级吸附),全程无回归——纯函数 + 用例先行的红利。

七、验证体系与最终行为

最终验证状态:转换 45/45、吸附 34/34、10 个测试套件 302 用例全绿;模拟器全链路通过;真机 30 条验证清单在册待跑(表格/结构/图片/协同/兜底五组)——这是本篇的连载钩子。

产品语义最终定稿成一张表:

场景行为
应用内复制 → 应用内粘贴源码保真(自定义 MIME 专线,零混染零损失)
开「复制包含格式」→ 粘到外部应用带格式(text/html 记录),WPS/邮件不丢样式
关开关复制纯源码切片
渲染态复制带格式内容任何部分吸附为完整格式单元(**bbb** / # 标题

八、能力边界表

事项AI 表现我的结论
转换器/吸附等纯函数实现一次成型,测试全绿"纯函数 + 用例先行"是本轮最大正资产
平台剪贴板行为预判两次给出当时合理、后被推翻的结论文档和单次实测都不背书,关键路径双通道兜底
根因定位(混染拼贴等)直觉判断两轮均错,插桩取证后命中别让 AI 猜,让 AI 加日志拿证据
方案取舍(引库 vs 自研、合并单元格策略)给利弊,人拍板可控性与产品语义是人的判断
回归保护稳定302 个用例守住十几次返工

九、心得收尾

  1. 跨运行时边界的功能,设计时就画双通道。"读得到"和"读不到"我们都遇到过,两条路并存不是冗余,是生存策略;
  2. 剪贴板是共享资源,别信任它的公共视图。系统会为 HTML 派生文本、会拼接记录,保真数据走自定义 MIME 专线;
  3. 疑难杂症的正确姿势是取证不是推理。两次直觉根因被证伪之后,写侧读侧同抓的日志十分钟钉死真凶。

如果你也在试 AI 开发鸿蒙,或者想看后续,关注专栏,所有踩坑都会持续更新。

Logo

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

更多推荐