- 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-area {
|
||||
flex: none; /* Neemt alleen benodigde ruimte */
|
||||
@@ -56,14 +14,6 @@
|
||||
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 {
|
||||
|
||||
@@ -99,15 +99,6 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Chat layout */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background-color: var(--sidebar-background);
|
||||
|
||||
@@ -549,20 +549,40 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.chat-app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
max-width: 1000px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-messages-area {
|
||||
flex: 1;
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
|
||||
@@ -87,7 +87,8 @@ export default {
|
||||
isAtBottom: true,
|
||||
showScrollButton: false,
|
||||
unreadCount: 0,
|
||||
languageChangeHandler: null
|
||||
languageChangeHandler: null,
|
||||
_prevSnapshot: { length: 0, firstId: null, lastId: null },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -98,29 +99,41 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
messages: {
|
||||
handler(newMessages, oldMessages) {
|
||||
const hasNewMessages = newMessages.length > (oldMessages?.length || 0);
|
||||
|
||||
// Always auto-scroll when new messages are added (regardless of current scroll position)
|
||||
if (this.autoScroll && hasNewMessages) {
|
||||
// Double $nextTick for better DOM update synchronization
|
||||
this.$nextTick(() => {
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom(true);
|
||||
});
|
||||
});
|
||||
async handler(newMessages, oldMessages) {
|
||||
const prev = this._prevSnapshot || { length: 0, firstId: null, lastId: null };
|
||||
const curr = this.makeSnapshot(newMessages);
|
||||
const container = this.$refs.messagesContainer;
|
||||
|
||||
const lengthIncreased = curr.length > prev.length;
|
||||
const lengthDecreased = curr.length < prev.length; // reset/trim
|
||||
const appended = lengthIncreased && curr.lastId !== prev.lastId;
|
||||
const prepended = lengthIncreased && curr.firstId !== prev.firstId && curr.lastId === prev.lastId;
|
||||
const mutatedLastSameLength = curr.length === prev.length && curr.lastId === prev.lastId;
|
||||
|
||||
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,
|
||||
immediate: false
|
||||
immediate: false,
|
||||
},
|
||||
isTyping(newVal) {
|
||||
if (newVal && this.autoScroll) {
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// Maak een benoemde handler voor betere cleanup
|
||||
@@ -135,25 +148,36 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.setupScrollListener();
|
||||
|
||||
// Initial scroll to bottom
|
||||
|
||||
if (this.autoScroll) {
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
this.$nextTick(() => this.scrollToBottom(true, { smooth: false, retries: 2 }));
|
||||
}
|
||||
|
||||
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() {
|
||||
// Cleanup scroll listener
|
||||
const container = this.$refs.messagesContainer;
|
||||
if (container) {
|
||||
container.removeEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
|
||||
// Cleanup language change listener
|
||||
if (container) container.removeEventListener('scroll', this.handleScroll);
|
||||
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: {
|
||||
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;
|
||||
if (container) {
|
||||
// Use requestAnimationFrame for better timing
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
this.isAtBottom = true;
|
||||
this.showScrollButton = false;
|
||||
this.unreadCount = 0;
|
||||
});
|
||||
if (!container) return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
this.isAtBottom = true;
|
||||
this.showScrollButton = false;
|
||||
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() {
|
||||
@@ -217,19 +276,21 @@ export default {
|
||||
container.addEventListener('scroll', this.handleScroll);
|
||||
},
|
||||
|
||||
debounce(fn, wait = 150) {
|
||||
let t;
|
||||
return (...args) => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => fn.apply(this, args), wait);
|
||||
};
|
||||
},
|
||||
|
||||
handleScroll() {
|
||||
const container = this.$refs.messagesContainer;
|
||||
if (!container) return;
|
||||
|
||||
const threshold = 50; // Reduced threshold for better detection
|
||||
const threshold = 80; // was 50
|
||||
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
||||
|
||||
this.isAtBottom = isNearBottom;
|
||||
|
||||
// Load more messages when scrolled to top
|
||||
if (container.scrollTop === 0) {
|
||||
this.$emit('load-more');
|
||||
}
|
||||
if (container.scrollTop === 0) this.$emit('load-more');
|
||||
},
|
||||
|
||||
handleImageLoaded() {
|
||||
@@ -274,35 +335,39 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.message-history-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 20px; /* Interne padding voor MessageHistory */
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 1000px; /* Optimale breedte */
|
||||
margin-left: auto;
|
||||
margin-right: auto; /* Horizontaal centreren */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0; /* Laat kinderen scrollen */
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
/* overflow: hidden; // mag weg of blijven; met stap 1 clipt dit niet meer */
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
/* Bottom-aligned messages implementation */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 100%;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
scrollbar-gutter: stable both-edges; /* houdt ruimte vrij voor scrollbar */
|
||||
padding-right: 0; /* haal de hack weg */
|
||||
margin-right: 0;
|
||||
|
||||
/* 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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 100%;
|
||||
gap: 10px; /* Space between messages */
|
||||
}
|
||||
|
||||
|
||||
@@ -89,18 +89,19 @@
|
||||
|
||||
/* General Styles */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 75vh;
|
||||
/*max-height: 100vh;*/
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: var(--chat-bg);
|
||||
font-family: var(--font-family); /* Apply the default font family */
|
||||
color: var(--font-color); /* Apply the default font color */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 75vh;
|
||||
min-height: 0;
|
||||
/*max-height: 100vh;*/
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: var(--chat-bg);
|
||||
font-family: var(--font-family); /* Apply the default font family */
|
||||
color: var(--font-color); /* Apply the default font color */
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
|
||||
Reference in New Issue
Block a user