复制粘贴保真攻坚:鸿蒙编辑器剪贴板的三个坑、五段代码
文章目录
复制粘贴保真攻坚:鸿蒙编辑器剪贴板的三个坑、五段代码
我认为复制粘贴格式丢失是块硬骨头,讲用这一整篇来讲解解决方案,按技术复盘的讲法来:每个问题按「现象 → 分析定位 → 修复代码 → 验证结果」走一遍,代码全部取自 MarkPin 真实源码,可对照。
一、问题定义:剪贴板是双向断供的
现象很朴素:从网页/WPS 复制内容粘进 MarkPin,表格变纯文本、加粗变星号;反过来,在所见即所得的渲染态里复制标题,拿到的文字丢了 # 号。
把链路拆开,这是两个独立问题:
- 粘贴方向:编辑器内核 CodeMirror 6 的粘贴默认只取
text/plain,网页带来的 HTML 结构在入口处就被丢弃; - 复制方向:所见即所得的实现方式是"源码一直在、语法符号视觉上隐藏",渲染态选中的是格式内容区,
sliceDoc切出来的本来就是没有标记的片段。
两个方向共享同一组底层约束:鸿蒙应用内是 ArkWeb 混合运行时(Web 内核跑编辑器、ArkTS 原生层管文件与系统服务),剪贴板要跨这层边界。后面三个坑全部出在这条边界上。
二、先讲方法:这次攻坚用到的四个定位手段
比结论更值得分享的是怎么定位的。四板斧:
- 纯函数 + 用例先行。转换器设计成纯函数(HTML 进、Markdown 出,不碰编辑器状态),先写 jsdom 用例再实现。后来每次返工,这套用例都是护栏;
- 装机 CDP 取证。Chrome 里正常、真机(ArkWeb)异常的问题是这个项目的常态。做法是真机开启 Web 调试端口,CDP 直连在真 ArkWeb 里下断言、抓行为,而不是靠猜;
- 写侧读侧同抓。剪贴板问题必须两端同时布点:复制时写入了什么记录、粘贴时读到了什么视图,对不上号的差值才是真相;
- 合成事件不作为结论依据。自动化注入的假按键事件(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 个用例守住十几次返工 |
九、心得收尾
- 跨运行时边界的功能,设计时就画双通道。"读得到"和"读不到"我们都遇到过,两条路并存不是冗余,是生存策略;
- 剪贴板是共享资源,别信任它的公共视图。系统会为 HTML 派生文本、会拼接记录,保真数据走自定义 MIME 专线;
- 疑难杂症的正确姿势是取证不是推理。两次直觉根因被证伪之后,写侧读侧同抓的日志十分钟钉死真凶。
如果你也在试 AI 开发鸿蒙,或者想看后续,关注专栏,所有踩坑都会持续更新。
更多推荐


所有评论(0)