This commit is contained in:
2026-08-08 14:17:24 +02:00
parent e9fe51363f
commit 0d8c86af16
12 changed files with 835 additions and 151 deletions
+138 -30
View File
@@ -1,7 +1,6 @@
<script lang="ts" setup>
import 'highlight.js/styles/github-dark.css'
import {computed, nextTick, onMounted, ref, watch} from 'vue';
import {useRoute} from 'vue-router';
import {computed, nextTick, ref, watch} from 'vue';
import {storeToRefs} from 'pinia';
import {useMessageStore} from '@/stores/message';
import {useUserStore} from "@/stores/user.ts";
@@ -13,25 +12,129 @@ const props = defineProps<{
}>();
const channelId = computed(() => props.channelId);
const route = useRoute();
const messageStore = useMessageStore();
const userStore = useUserStore();
const {renderMarkdown} = useMarkdown()
// Référence vers l'élément scrollable
const messageContainer = ref<HTMLElement | null>(null);
// "messages" ici est une référence réactive liée au store
const {messages, loading} = storeToRefs(messageStore);
const {messages, loading, loadingBefore, loadingAfter} = storeToRefs(messageStore);
const newMessage = ref('');
const SCROLL_LOAD_THRESHOLD = 120;
const SCROLL_BOTTOM_TOLERANCE = 4;
const paginationLock = ref<'before' | 'after' | null>(null);
const paginationLockScrollTop = ref(0);
const lastScrollTop = ref(0);
interface ScrollAnchor {
id: string;
top: number;
}
const scrollToBottom = async () => {
await nextTick();
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
messageStore.setAtBottom(true);
}
};
const getMessageElements = () => Array.from(
messageContainer.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? [],
);
const captureAnchor = (edge: 'top' | 'bottom'): ScrollAnchor | null => {
const container = messageContainer.value;
if (!container) return null;
const containerRect = container.getBoundingClientRect();
const visible = getMessageElements().filter(element => {
const rect = element.getBoundingClientRect();
return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
});
const element = edge === 'top' ? visible[0] : visible[visible.length - 1];
if (!element?.dataset.messageId) return null;
return {
id: element.dataset.messageId,
top: element.getBoundingClientRect().top,
};
};
const restoreAnchor = async (anchor: ScrollAnchor | null) => {
if (!anchor || !messageContainer.value) return;
await nextTick();
const element = getMessageElements().find(item => item.dataset.messageId === anchor.id);
if (element) {
messageContainer.value.scrollTop += element.getBoundingClientRect().top - anchor.top;
}
};
const updateScrollState = () => {
const container = messageContainer.value;
if (!container) return;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
messageStore.setAtBottom(distanceFromBottom <= SCROLL_BOTTOM_TOLERANCE);
};
const setPaginationLock = (direction: 'before' | 'after') => {
if (!messageContainer.value) return;
paginationLock.value = direction;
paginationLockScrollTop.value = messageContainer.value.scrollTop;
lastScrollTop.value = messageContainer.value.scrollTop;
};
const loadBefore = async () => {
const anchor = captureAnchor('top');
const change = await messageStore.fetchBefore(channelId.value);
if (change) {
await restoreAnchor(anchor);
setPaginationLock('before');
}
};
const loadAfter = async () => {
const anchor = captureAnchor('bottom');
const change = await messageStore.fetchAfter(channelId.value);
if (change) {
await restoreAnchor(anchor);
setPaginationLock('after');
}
};
const handleScroll = async () => {
const container = messageContainer.value;
if (!container) return;
const currentScrollTop = container.scrollTop;
const scrollDelta = currentScrollTop - lastScrollTop.value;
lastScrollTop.value = currentScrollTop;
if (paginationLock.value === 'after') {
if (scrollDelta < -1 || currentScrollTop > paginationLockScrollTop.value + 2) {
paginationLock.value = null;
} else {
return;
}
} else if (paginationLock.value === 'before') {
if (scrollDelta > 1 || currentScrollTop < paginationLockScrollTop.value - 2) {
paginationLock.value = null;
} else {
return;
}
}
updateScrollState();
if (container.scrollTop <= SCROLL_LOAD_THRESHOLD && !loadingBefore.value) {
await loadBefore();
} else {
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
if (distanceFromBottom <= SCROLL_LOAD_THRESHOLD && !loadingAfter.value) {
await loadAfter();
}
}
};
@@ -39,47 +142,53 @@ const sendMessage = async () => {
if (!newMessage.value.trim()) return;
const content = newMessage.value;
const wasAtBottom = messageStore.isAtBottom;
try {
await messageStore.sendMessage(channelId.value, content);
newMessage.value = ''; // On vide le champ après succès
await scrollToBottom();
newMessage.value = '';
if (wasAtBottom) await scrollToBottom();
} catch (e) {
// Gérer l'erreur (ex: notification toast)
console.error('Erreur lors de l\'envoi du message:', e);
}
};
onMounted(() => {
if (channelId.value) {
messageStore.fetchMessages(channelId.value);
// Only explicit initial loads and realtime messages received while at the
// bottom request an automatic scroll. Pagination restores its own anchor.
watch(messages, async () => {
if (messageStore.consumeScrollToBottomRequest()) {
await scrollToBottom();
}
});
}, {deep: true, flush: 'post'});
watch(channelId, (newChannelId) => {
watch(channelId, async (newChannelId) => {
if (newChannelId) {
messageStore.fetchMessages(newChannelId);
await messageStore.fetchMessages(newChannelId);
}
}, {immediate: true})
// Scroll automatique quand la liste des messages change (nouveaux messages reçus)
watch(messages, () => {
scrollToBottom();
}, {deep: true});
</script>
<template>
<!-- Conteneur principal prenant toute la hauteur -->
<v-container class="pa-0 fill-height d-flex flex-column" fluid>
<!-- Zone des messages (scrollable) -->
<div
ref="messageContainer"
class="flex-grow-1 overflow-y-auto w-100 message-container"
@scroll.passive="handleScroll"
>
<v-progress-linear v-if="loadingBefore" color="primary" indeterminate />
<v-progress-circular
v-if="loading && !messages.length"
class="d-block mx-auto mt-4"
color="primary"
indeterminate
/>
<v-list bg-color="transparent" lines="three">
<v-list-item
v-for="msg in messages"
:key="msg.id"
:data-message-id="msg.id"
class="px-4 py-1"
>
<template v-slot:prepend>
@@ -98,9 +207,10 @@ watch(messages, () => {
</div>
</v-list-item>
</v-list>
<v-progress-linear v-if="loadingAfter" color="primary" indeterminate />
</div>
<!-- Zone de saisie fixe en bas -->
<v-sheet class="pa-4 flex-shrink-0" width="100%">
<v-textarea
v-model="newMessage"
@@ -126,13 +236,11 @@ watch(messages, () => {
</template>
</v-textarea>
</v-sheet>
</v-container>
</template>
<style scoped>
.message-container {
/* Assure que la zone gère son scroll indépendamment */
height: 0;
}
@@ -155,4 +263,4 @@ watch(messages, () => {
margin: 8px 0;
overflow-x: auto;
}
</style>
</style>