[codex] try vue virtual scroller for messages (#1103)

* try vue virtual scroller for messages

* hide inactive virtual scroller rows
This commit is contained in:
ekko
2026-05-28 21:59:55 +08:00
committed by GitHub
parent 5f5c5faa25
commit 74d0c3509b
3 changed files with 211 additions and 294 deletions
@@ -1,10 +1,26 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
DynamicScroller,
DynamicScrollerItem,
type DynamicScrollerExposed,
type ScrollToOptions,
} from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
type VirtualItem = {
id: string | number;
}
type AnchorAlign = "start" | "center";
type AnchorTarget = {
token: number;
index: number;
messageId: string;
anchorId: string;
align: AnchorAlign;
}
const props = withDefaults(defineProps<{
messages: VirtualItem[];
estimatedItemHeight?: number;
@@ -32,127 +48,29 @@ defineSlots<{
after?: () => any;
}>();
const scrollerRef = ref<HTMLElement | null>(null);
const hostRef = ref<HTMLElement | null>(null);
const scrollerRef = ref<DynamicScrollerExposed<VirtualItem> | null>(null);
const scrollTop = ref(0);
const viewportHeight = ref(0);
const heightVersion = ref(0);
const measuredHeights = new Map<string, number>();
const observedElements = new Map<string, HTMLElement>();
const observers = new Map<string, ResizeObserver>();
let keepBottomUntil = 0;
let bottomFrame: number | null = null;
let anchorFrame: number | null = null;
let anchorToken = 0;
let activeAnchorTarget: AnchorTarget | null = null;
const messageKeys = computed(() => props.messages.map(messageKey));
const bufferPx = computed(() => Math.max(props.estimatedItemHeight, props.estimatedItemHeight * props.overscan));
function messageKey(message: VirtualItem): string {
return String(message.id);
}
function itemHeight(key: string): number {
return measuredHeights.get(key) || props.estimatedItemHeight;
}
function measuredRowHeight(el: HTMLElement): number {
return Math.ceil(el.getBoundingClientRect().height || props.estimatedItemHeight);
}
function setMeasuredHeight(key: string, height: number) {
const oldHeight = itemHeight(key);
if (oldHeight === height) return;
const el = scrollerRef.value;
const shouldKeepBottom = !!el && (Date.now() < keepBottomUntil || isNearBottom(64));
const index = messageKeys.value.indexOf(key);
if (index >= 0 && !shouldKeepBottom) {
const rowTop = layout.value.offsets[index] || 0;
const delta = height - oldHeight;
if (el && rowTop < scrollTop.value && delta !== 0) {
el.scrollTop = Math.max(0, el.scrollTop + delta);
syncViewport();
}
}
measuredHeights.set(key, height);
heightVersion.value += 1;
if (shouldKeepBottom) scheduleScrollToBottom(2);
}
const layout = computed(() => {
heightVersion.value;
const offsets: number[] = [];
let total = 0;
for (const key of messageKeys.value) {
offsets.push(total);
total += itemHeight(key);
}
return { offsets, total };
});
const visibleRange = computed(() => {
const count = props.messages.length;
if (count === 0) return { start: 0, end: -1 };
const overscanPx = props.estimatedItemHeight * props.overscan;
const startPx = Math.max(0, scrollTop.value - overscanPx);
const endPx = scrollTop.value + viewportHeight.value + overscanPx;
const { offsets } = layout.value;
let start = 0;
let low = 0;
let high = count - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const bottom = offsets[mid] + itemHeight(messageKeys.value[mid]);
if (bottom < startPx) low = mid + 1;
else {
start = mid;
high = mid - 1;
}
}
let end = start;
while (end < count - 1 && offsets[end] < endPx) end += 1;
return { start, end };
});
const visibleMessages = computed(() => {
const { start, end } = visibleRange.value;
return end >= start ? props.messages.slice(start, end + 1) : [];
});
const topSpacerHeight = computed(() => layout.value.offsets[visibleRange.value.start] || 0);
const bottomSpacerHeight = computed(() => {
const { end } = visibleRange.value;
if (end < 0) return 0;
const nextOffset = end + 1 < props.messages.length
? layout.value.offsets[end + 1]
: layout.value.total;
return Math.max(0, layout.value.total - nextOffset);
});
function setItemRef(key: string, el: Element | ComponentPublicInstance | null) {
const existing = observedElements.get(key);
if (existing === el) return;
observers.get(key)?.disconnect();
observers.delete(key);
observedElements.delete(key);
if (!(el instanceof HTMLElement)) return;
observedElements.set(key, el);
if (typeof ResizeObserver === "undefined") {
setMeasuredHeight(key, measuredRowHeight(el));
return;
}
const observer = new ResizeObserver(() => setMeasuredHeight(key, measuredRowHeight(el)));
observer.observe(el);
observers.set(key, observer);
function getScrollerElement(): HTMLElement | null {
return hostRef.value?.querySelector<HTMLElement>(".virtual-message-list") ?? null;
}
function syncViewport() {
const el = scrollerRef.value;
const el = getScrollerElement();
if (!el) return;
scrollTop.value = el.scrollTop;
viewportHeight.value = el.clientHeight;
@@ -164,8 +82,14 @@ function handleScroll() {
if (scrollTop.value <= props.topThreshold) emit("topReach");
}
function handleResize() {
syncViewport();
if (Date.now() < keepBottomUntil || isNearBottom(64)) scheduleScrollToBottom(2);
if (activeAnchorTarget) scheduleAnchorAlignment(activeAnchorTarget.token, 4);
}
function isNearBottom(threshold = 200): boolean {
const el = scrollerRef.value;
const el = getScrollerElement();
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
}
@@ -178,9 +102,11 @@ function scrollToBottom() {
}
function setScrollToBottomNow() {
const el = scrollerRef.value;
if (!el) return;
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
const el = getScrollerElement();
scrollerRef.value?.scrollToBottom();
if (el) {
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
}
syncViewport();
}
@@ -199,18 +125,97 @@ function scheduleScrollToBottom(frames = 1) {
bottomFrame = requestAnimationFrame(() => step(frames));
}
function findTargetElement(messageId: string, anchorId: string): HTMLElement | null {
const el = getScrollerElement();
if (!el) return null;
const anchor = document.getElementById(anchorId);
if (anchor instanceof HTMLElement && el.contains(anchor)) return anchor;
const message = document.getElementById(`message-${messageId}`);
if (message instanceof HTMLElement && el.contains(message)) return message;
return null;
}
function alignElement(targetEl: HTMLElement, align: AnchorAlign) {
const el = getScrollerElement();
if (!el) return;
const scrollerRect = el.getBoundingClientRect();
const targetRect = targetEl.getBoundingClientRect();
const delta = align === "center"
? targetRect.top + targetRect.height / 2 - (scrollerRect.top + scrollerRect.height / 2)
: targetRect.top - scrollerRect.top - 24;
if (Math.abs(delta) > 1) {
el.scrollTop = Math.max(0, el.scrollTop + delta);
}
syncViewport();
}
function scrollToItem(index: number, options?: ScrollToOptions) {
scrollerRef.value?.scrollToItem(index, options);
syncViewport();
}
function scheduleAnchorAlignment(token: number, frames = 1) {
if (anchorFrame != null) cancelAnimationFrame(anchorFrame);
const step = (remaining: number) => {
const target = activeAnchorTarget;
if (!target || target.token !== token) {
anchorFrame = null;
return;
}
const targetEl = findTargetElement(target.messageId, target.anchorId);
if (targetEl) {
alignElement(targetEl, target.align);
} else {
scrollToItem(target.index, {
align: target.align,
offset: target.align === "start" ? -24 : 0,
});
}
if (remaining <= 1) {
anchorFrame = null;
activeAnchorTarget = null;
return;
}
anchorFrame = requestAnimationFrame(() => step(remaining - 1));
};
anchorFrame = requestAnimationFrame(() => step(frames));
}
function cancelAnchorAlignment() {
anchorToken += 1;
activeAnchorTarget = null;
if (anchorFrame != null) {
cancelAnimationFrame(anchorFrame);
anchorFrame = null;
}
}
function scrollToMessage(messageId: string) {
const index = props.messages.findIndex(message => String(message.id) === messageId);
if (index < 0) return;
cancelAnchorAlignment();
const token = anchorToken;
activeAnchorTarget = {
token,
index,
messageId,
anchorId: `message-${messageId}`,
align: "center",
};
nextTick(() => {
const el = scrollerRef.value;
if (!el) return;
el.scrollTop = Math.max(0, (layout.value.offsets[index] || 0) - el.clientHeight / 2);
syncViewport();
nextTick(() => {
document.getElementById(`message-${messageId}`)?.scrollIntoView({ block: "center" });
});
scrollToItem(index, { align: "center" });
scheduleAnchorAlignment(token, 8);
});
}
@@ -218,21 +223,24 @@ function scrollToAnchor(messageId: string, anchorId: string) {
const index = props.messages.findIndex(message => String(message.id) === messageId);
if (index < 0) return;
cancelAnchorAlignment();
const token = anchorToken;
activeAnchorTarget = {
token,
index,
messageId,
anchorId,
align: "start",
};
nextTick(() => {
const el = scrollerRef.value;
if (!el) return;
el.scrollTop = Math.max(0, (layout.value.offsets[index] || 0) - 24);
syncViewport();
nextTick(() => {
const target = document.getElementById(anchorId) || document.getElementById(`message-${messageId}`);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
syncViewport();
});
scrollToItem(index, { align: "start", offset: -24 });
scheduleAnchorAlignment(token, 10);
});
}
function captureScrollPosition() {
const el = scrollerRef.value;
const el = getScrollerElement();
if (!el) return null;
return {
scrollTop: el.scrollTop,
@@ -243,9 +251,11 @@ function captureScrollPosition() {
function restoreScrollPosition(snapshot: { scrollTop: number; scrollHeight: number } | null) {
if (!snapshot) return;
nextTick(() => {
const el = scrollerRef.value;
const el = getScrollerElement();
if (!el) return;
el.scrollTop = Math.max(0, el.scrollHeight - snapshot.scrollHeight + snapshot.scrollTop);
const nextScrollTop = Math.max(0, el.scrollHeight - snapshot.scrollHeight + snapshot.scrollTop);
scrollerRef.value?.scrollToPosition(nextScrollTop);
el.scrollTop = nextScrollTop;
syncViewport();
});
}
@@ -253,36 +263,24 @@ function restoreScrollPosition(snapshot: { scrollTop: number; scrollHeight: numb
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
syncViewport();
if (scrollerRef.value && typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(syncViewport);
resizeObserver.observe(scrollerRef.value);
}
nextTick(() => {
syncViewport();
const el = getScrollerElement();
if (el && typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(el);
}
});
});
onBeforeUnmount(() => {
if (bottomFrame != null) cancelAnimationFrame(bottomFrame);
if (anchorFrame != null) cancelAnimationFrame(anchorFrame);
resizeObserver?.disconnect();
for (const observer of observers.values()) observer.disconnect();
observers.clear();
observedElements.clear();
});
watch(messageKeys, keys => {
const activeKeys = new Set(keys);
for (const key of [...observedElements.keys()]) {
if (activeKeys.has(key)) continue;
observers.get(key)?.disconnect();
observers.delete(key);
observedElements.delete(key);
}
let droppedHeights = false;
for (const key of [...measuredHeights.keys()]) {
if (activeKeys.has(key)) continue;
measuredHeights.delete(key);
droppedHeights = true;
}
if (droppedHeights) heightVersion.value += 1;
watch(messageKeys, () => {
cancelAnchorAlignment();
nextTick(syncViewport);
});
@@ -298,51 +296,69 @@ defineExpose({
<template>
<div
ref="scrollerRef"
class="virtual-message-list"
ref="hostRef"
class="virtual-message-list-host"
:style="{ '--virtual-row-gap': `${rowGap}px`, '--virtual-list-padding': padding }"
@scroll="handleScroll"
>
<slot v-if="messages.length === 0" name="empty" />
<template v-else>
<slot name="before" />
<div class="virtual-spacer" :style="{ height: `${topSpacerHeight}px` }" />
<div
v-for="msg in visibleMessages"
:key="msg.id"
:ref="(el) => setItemRef(messageKey(msg), el)"
class="virtual-row"
>
<slot name="item" :message="msg" />
</div>
<div class="virtual-spacer" :style="{ height: `${bottomSpacerHeight}px` }" />
</template>
<slot name="after" />
<DynamicScroller
ref="scrollerRef"
class="virtual-message-list"
:items="messages"
key-field="id"
:min-item-size="estimatedItemHeight"
:buffer="bufferPx"
:flow-mode="true"
:prerender="overscan"
@scroll.passive="handleScroll"
@resize="handleResize"
@visible="syncViewport"
>
<template #empty>
<slot name="empty" />
</template>
<template #before>
<slot v-if="messages.length > 0" name="before" />
</template>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:index="index"
:active="active"
class="virtual-row"
>
<slot v-if="active" name="item" :message="item" />
</DynamicScrollerItem>
</template>
<template #after>
<slot v-if="messages.length > 0" name="after" />
</template>
</DynamicScroller>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.virtual-message-list-host {
flex: 1;
min-height: 0;
display: flex;
}
.virtual-message-list {
flex: 1;
overflow-y: auto;
min-height: 0;
padding: var(--virtual-list-padding);
display: flex;
flex-direction: column;
box-sizing: border-box;
background-color: $bg-card;
position: relative;
.dark & {
background-color: #333333;
}
}
.virtual-spacer {
flex: 0 0 auto;
}
.virtual-row {
box-sizing: border-box;
padding-bottom: var(--virtual-row-gap);
}
</style>