Skip to content

整段复制

虚拟滚动只保留视口附近的 DOM。用户选中的范围跨越了未渲染区域时,浏览器能复制到的 只有那几十个还活着的节点——中间成百上千项在剪贴板里凭空消失,而用户完全看不出来, 因为选区高亮是连续的。

这是虚拟滚动最容易被忽略的一处正确性缺口,而「复制整段回答」恰好是 AI 产品的核心操作。

微应用尚未挂载。

怎么看出差别

点「选中全部对话」,再按 Cmd/Ctrl+C,粘贴到任意编辑器里:开着开关能拿到全部 400 条,关掉之后只剩视口附近的十几条。工具栏右侧会显示这次实际复制了多少字、 其中多少来自未渲染的项。

开启方式

给出 copyText,返回这一项对应的纯文本:

js
const vl = new VirtList(container, {
  list,
  itemKey: 'id',
  estimatedSize: 80,
  // 给出即开启;不给则完全不挂 copy 监听,行为与从前一致
  copyText: (item) => `${item.role === 'user' ? '我' : 'AI'}:${blocksToMarkdown(item.blocks)}`,
  // 各项之间的分隔符,默认换行
  copySeparator: '\n\n',
  renderItem: (item) => { /* ... */ },
});

运行时可以开关它:vl.updateOptions({ copyText: undefined }) 会把监听摘掉。

复制时发生了什么

拦到 copy 事件后,按选区的实际范围拼装文本:

位置取值方式
首项选区起点到该项末尾(用户可能从半句话中间开始选)
中间项一律走 copyText,从数据取
末项该项开头到选区终点

中间项即使恰好还在 DOM 里(缓冲区里的项)也走数据。否则同一次复制里一部分 来自 DOM、一部分来自数据,格式会不一致——而哪些项还在 DOM 里,对使用方是不可见的。

首尾两项若是整个落在选区里(全选、或者选区从页面别处一直拖过来), 同样走数据:只有「端点确实落在某一项内部」时才需要按 DOM 截取。

选区盖住整个列表时,边界取的是数据的首尾,而不是「第一个 / 最后一个已渲染项」。 后者会把结果限制在渲染窗口内——那正好就是「只复制到视口那一小段」, 等于这个功能没生效。

什么情况下不介入

以下情形一律放行浏览器的默认行为,不改剪贴板:

  • 选区为空;
  • 选区与这个列表没有交集(用户在选页面上别的东西);
  • 两端落在同一项内——这一项完整存在于 DOM,浏览器复制的就是对的。

解不出结果时既不 preventDefault 也不写剪贴板,最坏情况是回到没有这个功能时的 表现,不会因为拦截失败而复制不到东西。

复制的文本要和看到的一致

copyText 与列表项的渲染是两条独立的渠道,库不会去推断它们的关系。 如果渲染时带了发送者名字、时间戳,而 copyText 只返回正文, 那么用户复制到的内容就会与看到的不同。让两者取自同一个格式化函数是最省事的做法。

一个做不到的场景

如果用户按住鼠标从列表顶部一直拖到底部,途中起点那一项的 DOM 可能已经被回收—— 浏览器会自己收缩选区,此时连「选区起点在哪」都不复存在了。

所以「跨很多屏的拖选」在任何虚拟滚动实现里都不可靠,这与本节的补齐无关。 可靠的入口是全选Cmd/Ctrl+A 或一个「选中全部」按钮): 它一次性给出覆盖整个列表的选区,不依赖拖动过程。

让渲染与复制共用一个格式化函数

js
const format = (item) => `${item.role === 'user' ? '我' : 'AI'}:${blocksToMarkdown(item.blocks)}`;

const vl = new VirtList(container, {
  // ...
  copyText: format,
  renderItem: (item) => {
    const el = document.createElement('div');
    el.textContent = format(item);
    return el;
  },
});