- TRA-77 - Scroll behaviour in the Message History adapted to support both scrolling by the end user, and ensuring the last message is shown when new messages are added, or resizing is done.
This commit is contained in:
@@ -1,45 +1,3 @@
|
|||||||
|
|
||||||
/* Chat App Container Layout */
|
|
||||||
.chat-app-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 0; /* Belangrijk voor flexbox overflow */
|
|
||||||
padding: 20px; /* Algemene padding voor alle kanten */
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gemeenschappelijke container voor consistente breedte */
|
|
||||||
.chat-component-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1000px; /* Optimale breedte */
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1; /* Neemt beschikbare verticale ruimte in */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Message Area - neemt alle beschikbare ruimte */
|
|
||||||
.chat-messages-area {
|
|
||||||
flex: 1; /* Neemt alle beschikbare ruimte */
|
|
||||||
overflow: hidden; /* Voorkomt dat het groter wordt dan container */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0; /* Belangrijk voor nested flexbox */
|
|
||||||
margin-bottom: 20px; /* Ruimte tussen messages en input */
|
|
||||||
border-radius: 15px;
|
|
||||||
background: var(--history-background);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1000px; /* Optimale breedte */
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto; /* Horizontaal centreren */
|
|
||||||
align-self: center; /* Extra centrering in flexbox context */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chat Input - altijd onderaan */
|
/* Chat Input - altijd onderaan */
|
||||||
.chat-input-area {
|
.chat-input-area {
|
||||||
flex: none; /* Neemt alleen benodigde ruimte */
|
flex: none; /* Neemt alleen benodigde ruimte */
|
||||||
@@ -56,14 +14,6 @@
|
|||||||
align-self: center; /* Extra centrering in flexbox context */
|
align-self: center; /* Extra centrering in flexbox context */
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 10px; /* Ruimte voor scrollbar */
|
|
||||||
margin-right: -10px; /* Compenseer voor scrollbar */
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chat Input styling */
|
/* Chat Input styling */
|
||||||
|
|
||||||
.chat-input {
|
.chat-input {
|
||||||
|
|||||||
@@ -99,15 +99,6 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chat layout */
|
|
||||||
.chat-container {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
background-color: var(--sidebar-background);
|
background-color: var(--sidebar-background);
|
||||||
|
|||||||
@@ -549,20 +549,40 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.chat-app-container {
|
.chat-app-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages-area {
|
.chat-messages-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 20px; /* Ruimte tussen messages en input */
|
||||||
|
border-radius: 15px;
|
||||||
|
background: var(--history-background);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px; /* Optimale breedte */
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto; /* Horizontaal centreren */
|
||||||
|
align-self: center; /* Extra centrering in flexbox context */
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-area {
|
.chat-input-area {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ export default {
|
|||||||
isAtBottom: true,
|
isAtBottom: true,
|
||||||
showScrollButton: false,
|
showScrollButton: false,
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
languageChangeHandler: null
|
languageChangeHandler: null,
|
||||||
|
_prevSnapshot: { length: 0, firstId: null, lastId: null },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -98,29 +99,41 @@ export default {
|
|||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
messages: {
|
messages: {
|
||||||
handler(newMessages, oldMessages) {
|
async handler(newMessages, oldMessages) {
|
||||||
const hasNewMessages = newMessages.length > (oldMessages?.length || 0);
|
const prev = this._prevSnapshot || { length: 0, firstId: null, lastId: null };
|
||||||
|
const curr = this.makeSnapshot(newMessages);
|
||||||
|
const container = this.$refs.messagesContainer;
|
||||||
|
|
||||||
// Always auto-scroll when new messages are added (regardless of current scroll position)
|
const lengthIncreased = curr.length > prev.length;
|
||||||
if (this.autoScroll && hasNewMessages) {
|
const lengthDecreased = curr.length < prev.length; // reset/trim
|
||||||
// Double $nextTick for better DOM update synchronization
|
const appended = lengthIncreased && curr.lastId !== prev.lastId;
|
||||||
this.$nextTick(() => {
|
const prepended = lengthIncreased && curr.firstId !== prev.firstId && curr.lastId === prev.lastId;
|
||||||
this.$nextTick(() => {
|
const mutatedLastSameLength = curr.length === prev.length && curr.lastId === prev.lastId;
|
||||||
this.scrollToBottom(true);
|
|
||||||
});
|
if (prepended && container) {
|
||||||
});
|
// Load-more bovenaan: positie behouden
|
||||||
|
const before = container.scrollHeight;
|
||||||
|
await this.nextFrame();
|
||||||
|
await this.nextFrame();
|
||||||
|
this.preserveScrollOnPrepend(before);
|
||||||
|
} else if (appended) {
|
||||||
|
// Nieuw bericht onderaan: altijd naar beneden (eis)
|
||||||
|
await this.scrollToBottom(true, { smooth: true, retries: 2 });
|
||||||
|
} else if (mutatedLastSameLength) {
|
||||||
|
// Laatste bericht groeit (AI streaming/media). Alleen sticky als we al onderaan waren of autoScroll actief is
|
||||||
|
if (this.autoScroll || this.isAtBottom) {
|
||||||
|
await this.scrollToBottom(false, { smooth: true, retries: 2 });
|
||||||
}
|
}
|
||||||
|
} else if (lengthDecreased && this.autoScroll) {
|
||||||
|
// Lijst verkort (reset): terug naar onderen
|
||||||
|
await this.scrollToBottom(true, { smooth: false, retries: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
this._prevSnapshot = curr;
|
||||||
},
|
},
|
||||||
deep: true,
|
deep: true,
|
||||||
immediate: false
|
immediate: false,
|
||||||
},
|
},
|
||||||
isTyping(newVal) {
|
|
||||||
if (newVal && this.autoScroll) {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.scrollToBottom();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
// Maak een benoemde handler voor betere cleanup
|
// Maak een benoemde handler voor betere cleanup
|
||||||
@@ -136,24 +149,35 @@ export default {
|
|||||||
mounted() {
|
mounted() {
|
||||||
this.setupScrollListener();
|
this.setupScrollListener();
|
||||||
|
|
||||||
// Initial scroll to bottom
|
|
||||||
if (this.autoScroll) {
|
if (this.autoScroll) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => this.scrollToBottom(true, { smooth: false, retries: 2 }));
|
||||||
this.scrollToBottom();
|
}
|
||||||
|
|
||||||
|
this._onResize = this.debounce(() => {
|
||||||
|
if (this.autoScroll || this.isAtBottom) {
|
||||||
|
this.scrollToBottom(false, { smooth: true, retries: 1 });
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
window.addEventListener('resize', this._onResize);
|
||||||
|
|
||||||
|
const wrapper = this.$el.querySelector('.messages-wrapper');
|
||||||
|
if (window.ResizeObserver && wrapper) {
|
||||||
|
this._resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (this.autoScroll || this.isAtBottom) {
|
||||||
|
this.scrollToBottom(false, { smooth: true, retries: 1 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
this._resizeObserver.observe(wrapper);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
// Cleanup scroll listener
|
|
||||||
const container = this.$refs.messagesContainer;
|
const container = this.$refs.messagesContainer;
|
||||||
if (container) {
|
if (container) container.removeEventListener('scroll', this.handleScroll);
|
||||||
container.removeEventListener('scroll', this.handleScroll);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup language change listener
|
|
||||||
if (this.languageChangeHandler) {
|
if (this.languageChangeHandler) {
|
||||||
document.removeEventListener('language-changed', this.languageChangeHandler);
|
document.removeEventListener('language-changed', this.languageChangeHandler);
|
||||||
}
|
}
|
||||||
|
if (this._onResize) window.removeEventListener('resize', this._onResize);
|
||||||
|
if (this._resizeObserver) this._resizeObserver.disconnect();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async handleLanguageChange(event) {
|
async handleLanguageChange(event) {
|
||||||
@@ -197,17 +221,52 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
scrollToBottom(force = false) {
|
async nextFrame() {
|
||||||
|
await this.$nextTick();
|
||||||
|
await new Promise(r => requestAnimationFrame(r));
|
||||||
|
},
|
||||||
|
|
||||||
|
async scrollToBottom(force = false, { smooth = true, retries = 2 } = {}) {
|
||||||
const container = this.$refs.messagesContainer;
|
const container = this.$refs.messagesContainer;
|
||||||
if (container) {
|
if (!container) return;
|
||||||
// Use requestAnimationFrame for better timing
|
|
||||||
requestAnimationFrame(() => {
|
const doScroll = (instant = false) => {
|
||||||
|
const behavior = instant ? 'auto' : (smooth ? 'smooth' : 'auto');
|
||||||
|
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wacht enkele frames om late layout/afbeeldingen te vangen
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
await this.nextFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probeer smooth naar onder
|
||||||
|
doScroll(false);
|
||||||
|
|
||||||
|
// Forceer desnoods na nog een frame een harde correctie
|
||||||
|
if (force) {
|
||||||
|
await this.nextFrame();
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
this.isAtBottom = true;
|
this.isAtBottom = true;
|
||||||
this.showScrollButton = false;
|
this.showScrollButton = false;
|
||||||
this.unreadCount = 0;
|
this.unreadCount = 0;
|
||||||
});
|
},
|
||||||
}
|
|
||||||
|
makeSnapshot(list) {
|
||||||
|
const length = list.length;
|
||||||
|
const firstId = length ? list[0].id : null;
|
||||||
|
const lastId = length ? list[length - 1].id : null;
|
||||||
|
return { length, firstId, lastId };
|
||||||
|
},
|
||||||
|
|
||||||
|
preserveScrollOnPrepend(beforeHeight) {
|
||||||
|
const container = this.$refs.messagesContainer;
|
||||||
|
if (!container) return;
|
||||||
|
const afterHeight = container.scrollHeight;
|
||||||
|
const delta = afterHeight - beforeHeight;
|
||||||
|
container.scrollTop = container.scrollTop + delta;
|
||||||
},
|
},
|
||||||
|
|
||||||
setupScrollListener() {
|
setupScrollListener() {
|
||||||
@@ -217,19 +276,21 @@ export default {
|
|||||||
container.addEventListener('scroll', this.handleScroll);
|
container.addEventListener('scroll', this.handleScroll);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
debounce(fn, wait = 150) {
|
||||||
|
let t;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(() => fn.apply(this, args), wait);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
handleScroll() {
|
handleScroll() {
|
||||||
const container = this.$refs.messagesContainer;
|
const container = this.$refs.messagesContainer;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
const threshold = 80; // was 50
|
||||||
const threshold = 50; // Reduced threshold for better detection
|
|
||||||
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
||||||
|
|
||||||
this.isAtBottom = isNearBottom;
|
this.isAtBottom = isNearBottom;
|
||||||
|
if (container.scrollTop === 0) this.$emit('load-more');
|
||||||
// Load more messages when scrolled to top
|
|
||||||
if (container.scrollTop === 0) {
|
|
||||||
this.$emit('load-more');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
handleImageLoaded() {
|
handleImageLoaded() {
|
||||||
@@ -276,33 +337,37 @@ export default {
|
|||||||
.message-history-container {
|
.message-history-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0; /* Laat kinderen scrollen */
|
||||||
padding: 20px; /* Interne padding voor MessageHistory */
|
padding: 20px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1000px; /* Optimale breedte */
|
max-width: 1000px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto; /* Horizontaal centreren */
|
margin-right: auto;
|
||||||
overflow: hidden;
|
/* overflow: hidden; // mag weg of blijven; met stap 1 clipt dit niet meer */
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 10px;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
|
|
||||||
/* Bottom-aligned messages implementation */
|
scrollbar-gutter: stable both-edges; /* houdt ruimte vrij voor scrollbar */
|
||||||
display: flex;
|
padding-right: 0; /* haal de hack weg */
|
||||||
flex-direction: column;
|
margin-right: 0;
|
||||||
justify-content: flex-end;
|
|
||||||
min-height: 100%;
|
/* Belangrijk: haal min-height: 100% weg en vervang */
|
||||||
|
/* min-height: 100%; */
|
||||||
|
min-height: 0; /* toestaan dat het kind krimpt voor overflow */
|
||||||
|
-webkit-overflow-scrolling: touch; /* betere iOS scroll */
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-wrapper {
|
.messages-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-height: 100%;
|
||||||
gap: 10px; /* Space between messages */
|
gap: 10px; /* Space between messages */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 75vh;
|
height: 75vh;
|
||||||
|
min-height: 0;
|
||||||
/*max-height: 100vh;*/
|
/*max-height: 100vh;*/
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user