[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
+12 -112
View File
@@ -16,7 +16,8 @@
"node-edge-tts": "^1.2.10",
"node-pty": "^1.1.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3"
"socket.io-client": "^4.8.3",
"vue-virtual-scroller": "^3.0.4"
},
"bin": {
"hermes-web-ui": "bin/hermes-web-ui.mjs"
@@ -157,7 +158,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -167,7 +167,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -177,7 +176,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
@@ -193,7 +191,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
@@ -1401,7 +1398,6 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@@ -1659,9 +1655,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1683,9 +1676,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1707,9 +1697,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1731,9 +1718,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1755,9 +1739,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1779,9 +1760,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2012,9 +1990,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2032,9 +2007,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2052,9 +2024,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2072,9 +2041,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2092,9 +2058,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2112,9 +2075,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2293,9 +2253,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2310,9 +2267,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2327,9 +2281,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2344,9 +2295,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2361,9 +2309,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2378,9 +2323,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2395,9 +2337,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2412,9 +2351,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2429,9 +2365,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2446,9 +2379,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2463,9 +2393,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2480,9 +2407,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2497,9 +2421,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3562,7 +3483,6 @@
"version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz",
"integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
@@ -3576,7 +3496,6 @@
"version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz",
"integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.35",
@@ -3587,7 +3506,6 @@
"version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz",
"integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
@@ -3605,7 +3523,6 @@
"version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz",
"integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.35",
@@ -3681,7 +3598,6 @@
"version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz",
"integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.35"
@@ -3691,7 +3607,6 @@
"version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz",
"integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.35",
@@ -3702,7 +3617,6 @@
"version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz",
"integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.35",
@@ -3715,7 +3629,6 @@
"version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz",
"integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.35",
@@ -3729,7 +3642,6 @@
"version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz",
"integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==",
"dev": true,
"license": "MIT"
},
"node_modules/@vue/test-utils": {
@@ -4687,7 +4599,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cytoscape": {
@@ -5613,7 +5524,6 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -5751,7 +5661,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/etag": {
@@ -7265,9 +7174,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7289,9 +7195,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7313,9 +7216,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7337,9 +7237,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7470,7 +7367,6 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -7879,7 +7775,6 @@
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -8343,7 +8238,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -8525,7 +8419,6 @@
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -9659,7 +9552,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -10363,7 +10255,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -10957,7 +10849,6 @@
"version": "3.5.35",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz",
"integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.35",
@@ -11051,6 +10942,15 @@
"typescript": ">=5.0.0"
}
},
"node_modules/vue-virtual-scroller": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-3.0.4.tgz",
"integrity": "sha512-3qh3c9VUVysuXynaa4fVZ3ncx3VgD7EPRiQcj+jUVZl5u/TTkD3c27XvSEu3JGJfsJt/vVTVziZ3djiiHtW4cQ==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.3.0"
}
},
"node_modules/vueuc": {
"version": "0.4.65",
"resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.65.tgz",
+3 -2
View File
@@ -63,7 +63,8 @@
"node-edge-tts": "^1.2.10",
"node-pty": "^1.1.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3"
"socket.io-client": "^4.8.3",
"vue-virtual-scroller": "^3.0.4"
},
"devDependencies": {
"@koa/bodyparser": "^5.0.0",
@@ -123,4 +124,4 @@
"vue-tsc": "^3.2.8",
"ws": "^8.20.0"
}
}
}
@@ -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>