Improve Chat scroll-to-bottom reliability with ResizeObserver
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m1s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m1s
Replace ad-hoc nextTick and media load handlers with a ResizeObserver on an inner content wrapper, which fires after layout for all content changes (new messages, image/video loads, window resize). Add scroll position tracking so auto-scroll only triggers when user is near bottom, and conditionally show the Bottom button only when scrolled up. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,21 +11,47 @@ const authStore = useAuthStore();
|
||||
const messages = computed(() => messagesStore.messages);
|
||||
const messageInput = ref("");
|
||||
const messagesContainer = ref(null);
|
||||
const messagesInner = ref(null);
|
||||
const fileInput = ref(null);
|
||||
|
||||
const isNearBottom = ref(true);
|
||||
const SCROLL_THRESHOLD = 100;
|
||||
let resizeObserver = null;
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(messages, scrollToBottom, { deep: true });
|
||||
function scrollToBottomIfNear() {
|
||||
if (isNearBottom.value) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (!messagesContainer.value) return;
|
||||
const { scrollHeight, scrollTop, clientHeight } = messagesContainer.value;
|
||||
isNearBottom.value = scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD;
|
||||
}
|
||||
|
||||
function goToBottom() {
|
||||
isNearBottom.value = true;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => messages.value.length,
|
||||
() => {
|
||||
nextTick(scrollToBottomIfNear);
|
||||
},
|
||||
);
|
||||
|
||||
function sendMessage() {
|
||||
const text = messageInput.value.trim();
|
||||
if (!text) return;
|
||||
isNearBottom.value = true;
|
||||
messagesStore.sendMessage(text);
|
||||
messageInput.value = "";
|
||||
}
|
||||
@@ -33,6 +59,7 @@ function sendMessage() {
|
||||
async function onFileSelected(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
isNearBottom.value = true;
|
||||
await messagesStore.uploadAndSendFile(file);
|
||||
fileInput.value.value = "";
|
||||
}
|
||||
@@ -73,9 +100,30 @@ function parseMessageParts(text) {
|
||||
|
||||
onMounted(() => {
|
||||
messagesStore.connect();
|
||||
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.addEventListener("scroll", onScroll, { passive: true });
|
||||
}
|
||||
|
||||
if (messagesInner.value) {
|
||||
resizeObserver = new ResizeObserver(scrollToBottomIfNear);
|
||||
resizeObserver.observe(messagesInner.value);
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
messagesStore.disconnect();
|
||||
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.removeEventListener("scroll", onScroll);
|
||||
}
|
||||
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -83,6 +131,7 @@ onUnmounted(() => {
|
||||
<div class="chat-root flex-col flex min-h-0">
|
||||
<Header>Chat</Header>
|
||||
<div ref="messagesContainer" class="flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-2 min-w-0">
|
||||
<div ref="messagesInner">
|
||||
<p v-for="message in messages" :key="message.id" class="break-words min-w-0 w-full">
|
||||
<span class="text-tertiary">{{ message.authorId }}:</span>
|
||||
<template
|
||||
@@ -104,14 +153,12 @@ onUnmounted(() => {
|
||||
v-if="isImageUrl(message.fileUrl)"
|
||||
:src="message.fileUrl"
|
||||
class="w-full max-w-full max-h-48 rounded block"
|
||||
@load="scrollToBottom"
|
||||
/>
|
||||
<video
|
||||
v-else-if="isVideoUrl(message.fileUrl)"
|
||||
:src="message.fileUrl"
|
||||
controls
|
||||
class="w-full max-w-full max-h-48 rounded block"
|
||||
@loadedmetadata="scrollToBottom"
|
||||
/>
|
||||
<Link v-else bare :href="message.fileUrl" target="_blank" class="underline break-all">{{
|
||||
message.fileUrl.split("/").pop()
|
||||
@@ -119,6 +166,7 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input v-model="messageInput" @keyup.enter="sendMessage" />
|
||||
<input
|
||||
@@ -135,7 +183,7 @@ onUnmounted(() => {
|
||||
@click="fileInput.click()"
|
||||
>Attach</Button
|
||||
>
|
||||
<Button class="flex-1" @click="scrollToBottom">Bottom</Button>
|
||||
<Button v-if="!isNearBottom" class="flex-1" @click="goToBottom">Bottom</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user