/** * useAiVirtualScroll — AiChat 消息列表虚拟滚动(IntersectionObserver 可见窗口懒渲染)。 * * 设计取舍(UX-2025-19):选自研 IntersectionObserver 而非 vue-virtual-scroller。 * - 零新依赖(项目 package.json 无 vue-virtual-scroller)。 * - vue-virtual-scroller DynamicScroller 会接管滚动容器 + 重排子节点, * 破坏 .ai-messages 现有 flex/gap 布局、onMessagesScroll 边沿收起(wasNearBottom)、 * isNearBottom/scrollToBottom 计算等"渲染层"以外的既有逻辑。 * - 自研方案只做"渲染层裁剪":可见窗口外的 item 内容卸载、保留占位高度, * scrollHeight/scrollTop 计算与既有滚动逻辑零感知。 * * 工作机制: * 1. 每个列表项包一个 sentinel div(始终挂载,只承担高度占位 + IO 观测)。 * 2. IntersectionObserver root=滚动容器,rootMargin 上下各预加载一段(默认 600px), * 保证滚动时内容提前就位,不出现空白闪烁。 * 3. sentinel 进入预加载区→key 进 renderedKeys→内容渲染;离开→移除→内容卸载, * 但 sentinel 自身保留(其已测得的高度经 inline style 占位,scrollHeight 不塌)。 * 4. 高度测量:ResizeObserver 观测 sentinel(内容挂载时其高度=内容高度), * 卸载前把最后一次实测高度写进 inline style,卸载后 sentinel 仍占该高度。 * 5. 流式末条保活:pinnedKeys 中的 key 永远视为"可见",不虚拟化(见 AiChat.vue 调用点 * 把 streaming 末条 AI 消息 key 传入),保证流式渲染(ARC-08 块级 memo + * streamingBlocks v-for + 选区保存/恢复)不因虚拟化丢帧。 * * 不处理(留观察,见诚实拆波): * - ToolCard 嵌套内部的虚拟化(单条消息内多工具卡,当前不裁剪,首屏内条目少)。 * - 编辑光标在 user 消息上的保持(UX-09 走 input 框回填,与列表虚拟化无耦合)。 */ import { ref, onBeforeUnmount, type Ref } from 'vue' export interface AiVirtualScrollOptions { /** 滚动容器(IntersectionObserver root) */ root: Ref /** 上下预加载像素数(可见区外提前渲染的高度,防滚出空白)。 */ rootMarginPx?: number } export function useAiVirtualScroll(options: AiVirtualScrollOptions) { const rootMargin = options.rootMarginPx ?? 600 // 当前"应当渲染内容"的 key 集合(IO 判定可见 + pinned)。模板读这个判断渲染与否。 const renderedKeys = ref>(new Set()) // 永远渲染的 key(流式末条等),由调用方维护传入。 const pinnedKeys = ref>(new Set()) // 每个观测项的元数据:DOM 元素 + 最近实测高度(卸载后占位用)。 const itemMeta = new Map() let io: IntersectionObserver | null = null let ro: ResizeObserver | null = null function ensureObserver(): void { if (io) return const rootEl = options.root.value if (!rootEl) return // root 未就绪;setupOnMount(root ref 就绪后)会再调一次建观察器 io = new IntersectionObserver( (entries) => { const next = new Set(renderedKeys.value) let changed = false for (const e of entries) { const key = (e.target as HTMLElement).dataset.vscrollKey if (!key) continue const visible = e.isIntersecting const has = next.has(key) if (visible && !has) { // 重新可见:清掉卸载时锁的占位 minHeight,让内容自然撑高(RO 再回填真实高度) const meta = itemMeta.get(key) if (meta && meta.el.isConnected) meta.el.style.minHeight = '' next.add(key); changed = true } else if (!visible && has && !pinnedKeys.value.has(key)) { // 卸载前:把实测高度锁到 sentinel 的 minHeight,防内容移除后 sentinel 塌成 0 // → scrollHeight 不变 → isNearBottom/scrollToBottom/回到底部计算零感知。 // 内容重新挂载时 RO 会把真实高度回填,registerSentinel 复挂时清 minHeight 由真实高度接管。 // 防御(P0):RO 首次回调前 item 已被 IO 判不可见时 meta.height===0,直接不设 minHeight // → sentinel 塌 0 高 → 下条消息上浮重叠。此处兜底:height>0 用实测值, // height===0 用 el.offsetWidth 量得的实际渲染高度(此时 el 仍在 DOM),仍<=0 则用固定 40px。 const meta = itemMeta.get(key) if (meta && meta.el.isConnected) { const measured = meta.height > 0 ? meta.height : (meta.el.offsetHeight || 0) const placeholder = measured > 0 ? measured : 40 meta.el.style.minHeight = `${placeholder}px` } next.delete(key); changed = true } } if (changed) renderedKeys.value = next }, { root: rootEl, rootMargin: `${rootMargin}px 0px ${rootMargin}px 0px`, threshold: 0 }, ) ro = new ResizeObserver((entries) => { for (const e of entries) { const key = (e.target as HTMLElement).dataset.vscrollKey if (!key) continue const meta = itemMeta.get(key) if (meta) meta.height = e.contentRect.height } }) } /** * 模板 sentinel 注册回调(每个 v-for 项的 ref 调用一次,挂载时 el 非空、卸载时为 null)。 * 调用方在 v-for item 上用 `:ref="(el) => registerSentinel(key, el as HTMLElement)"`。 */ function registerSentinel(key: string, el: Element | null | undefined): void { const prev = itemMeta.get(key) if (el instanceof HTMLElement) { el.dataset.vscrollKey = key // 复用上次实测高度作为初始 inline 占位(再次挂载时先占位,内容挂载后 RO 再校准)。 // 防御(P0):首次挂载(无 prev / RO 未回填)h<=0 时若不设占位,sentinel 首帧塌 0 高 → // 列表首次渲染时下条上浮重叠。此处用保守默认 40px 占位,RO 回调后用真实高度覆盖。 const h = prev?.height ?? 0 const placeholder = h > 0 ? h : 40 el.style.minHeight = `${placeholder}px` itemMeta.set(key, { el, height: h }) ensureObserver() io?.observe(el) ro?.observe(el) // 新挂载/初次进入的项默认视为应渲染(IO 回调会按真实相交态修正) if (!renderedKeys.value.has(key)) { const next = new Set(renderedKeys.value) next.add(key) renderedKeys.value = next } } else if (prev) { // 卸载:停止观测,保留 height 元数据供下次复用(itemMeta 不删) io?.unobserve(prev.el) ro?.unobserve(prev.el) // el 已被 Vue 移除,记录保留 height 即可 itemMeta.set(key, { el: prev.el, height: prev.height }) } } /** 滚动容器 ref 就绪后调用(通常 onMounted),补建观察器并预渲染首批可见项。 */ function setupOnMount(): void { ensureObserver() // 不主动全量 observe——registerSentinel 在模板挂载时已逐项 observe。 } /** 是否应渲染该项内容(IO 可见 或 pinned)。 */ function shouldRender(key: string): boolean { // 🔍 诊断(P0 消息重叠):临时禁用虚拟滚动裁剪,所有消息内容恒渲染, // 隔离"历史消息打开全重叠"根因。若禁用后重叠消失→虚拟滚动时序根因; // 若仍重叠→CSS/markdown DOM 根因。定位后回退此改动。 void key return true // return renderedKeys.value.has(key) || pinnedKeys.value.has(key) } /** 更新 pinned 集合(调用方 watch 流式末条后传入新集合)。 */ function setPinned(keys: Set): void { pinnedKeys.value = keys // pinned 的 key 强制进渲染集(IO 不可见也渲染) if (keys.size > 0) { const next = new Set(renderedKeys.value) let changed = false for (const k of keys) if (!next.has(k)) { next.add(k); changed = true } if (changed) renderedKeys.value = next } } onBeforeUnmount(() => { io?.disconnect() ro?.disconnect() io = null ro = null itemMeta.clear() }) return { renderedKeys, pinnedKeys, registerSentinel, setupOnMount, shouldRender, setPinned, } }