[codex] try vue virtual scroller for messages (#1103)
* try vue virtual scroller for messages * hide inactive virtual scroller rows
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user