Add scroll button and auto-scroll
This commit is contained in:
@@ -29,9 +29,15 @@ class Odidere {
|
||||
this.isRecording = false;
|
||||
this.isMuted = false;
|
||||
this.isResetPending = false;
|
||||
this.isScrollPending = false;
|
||||
this.isStopPending = false;
|
||||
this.mediaRecorder = null;
|
||||
|
||||
// Auto-scroll: enabled by default, disabled when user scrolls up,
|
||||
// re-enabled when they scroll back to bottom or send a new message.
|
||||
this.isAutoScrollEnabled = true;
|
||||
this._lastScrollTop = 0;
|
||||
|
||||
// DOM Elements
|
||||
this.$attach = document.getElementById('attach');
|
||||
this.$attachments = document.getElementById('attachments');
|
||||
@@ -41,6 +47,7 @@ class Odidere {
|
||||
this.$model = document.getElementById('model');
|
||||
this.$ptt = document.getElementById('ptt');
|
||||
this.$reset = document.getElementById('reset');
|
||||
this.$scroll = document.getElementById('scroll');
|
||||
this.$send = document.getElementById('send');
|
||||
this.$textInput = document.getElementById('text-input');
|
||||
this.$voice = document.getElementById('voice');
|
||||
@@ -97,6 +104,8 @@ class Odidere {
|
||||
|
||||
this.#setLoadingState(false);
|
||||
this.history = [];
|
||||
this.isAutoScrollEnabled = true;
|
||||
this._lastScrollTop = 0;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
this.$chat.innerHTML = '';
|
||||
this.#clearAttachments();
|
||||
@@ -151,7 +160,12 @@ class Odidere {
|
||||
this.$reset.addEventListener('click', () => this.#handleResetClick());
|
||||
this.document.addEventListener('click', (e) => this.#handleResetCancel(e));
|
||||
|
||||
// File attachment
|
||||
// Scroll button: two-press safety, swaps between scroll-to-bottom and
|
||||
// scroll-to-top.
|
||||
this.$scroll.addEventListener('click', () => this.#handleScrollClick());
|
||||
this.document.addEventListener('click', (e) => this.#handleScrollCancel(e));
|
||||
|
||||
// File attachment.
|
||||
this.$attach.addEventListener('click', () => this.$fileInput.click());
|
||||
this.$fileInput.addEventListener('change', (e) =>
|
||||
this.#handleAttachments(e.target.files),
|
||||
@@ -167,6 +181,17 @@ class Odidere {
|
||||
// Mute button
|
||||
this.$mute.addEventListener('click', () => this.#toggleMute());
|
||||
|
||||
// Scroll button: update icon as user scrolls
|
||||
this.$chat.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
this.#updateScrollIcon();
|
||||
this.#handleChatScroll();
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
this.#updateScrollIcon();
|
||||
|
||||
// Settings modal
|
||||
this.$settings.addEventListener('click', () => this.openSettings());
|
||||
this.$settingsClose.addEventListener('click', () => this.closeSettings());
|
||||
@@ -359,6 +384,95 @@ class Odidere {
|
||||
this.$reset.classList.remove('footer__toolbar-btn--pending');
|
||||
}
|
||||
|
||||
/**
|
||||
* #handleScrollClick toggles the pending scroll state on first press,
|
||||
* executes the scroll on second press. At bottom → scroll to top,
|
||||
* elsewhere → scroll to bottom.
|
||||
*/
|
||||
#handleScrollClick() {
|
||||
if (this.isScrollPending) {
|
||||
this.isScrollPending = false;
|
||||
this.$scroll.classList.remove('footer__toolbar-btn--pending');
|
||||
if (this.#isAtBottom()) {
|
||||
this.$chat.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
this.$chat.scrollTo({
|
||||
top: this.$chat.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
this.#updateScrollIcon();
|
||||
} else {
|
||||
this.isScrollPending = true;
|
||||
this.$scroll.classList.add('footer__toolbar-btn--pending');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #handleScrollCancel clears the pending scroll state when the user
|
||||
* clicks anywhere outside the scroll button.
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
#handleScrollCancel(event) {
|
||||
if (!this.isScrollPending) return;
|
||||
if (this.$scroll.contains(event.target)) return;
|
||||
this.isScrollPending = false;
|
||||
this.$scroll.classList.remove('footer__toolbar-btn--pending');
|
||||
}
|
||||
|
||||
/**
|
||||
* #handleChatScroll detects whether the user scrolled up or down and
|
||||
* toggles auto-scroll accordingly. Scrolling up disables it; scrolling
|
||||
* back to the bottom re-enables it.
|
||||
*/
|
||||
#handleChatScroll() {
|
||||
const { scrollTop, scrollHeight, clientHeight } = this.$chat;
|
||||
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
|
||||
const isAtBottom = distanceFromBottom < 10;
|
||||
|
||||
if (scrollTop < this._lastScrollTop && !isAtBottom) {
|
||||
// User scrolled up and is not at the bottom → disable auto-scroll.
|
||||
this.isAutoScrollEnabled = false;
|
||||
} else if (isAtBottom) {
|
||||
// User scrolled back to the bottom → re-enable auto-scroll.
|
||||
this.isAutoScrollEnabled = true;
|
||||
}
|
||||
|
||||
this._lastScrollTop = scrollTop;
|
||||
}
|
||||
|
||||
/**
|
||||
* #isAtBottom checks if the chat is scrolled within 10px of the bottom.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#isAtBottom() {
|
||||
const { scrollTop, scrollHeight, clientHeight } = this.$chat;
|
||||
return scrollHeight - clientHeight - scrollTop < 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* #updateScrollIcon toggles a CSS class to show the appropriate arrow
|
||||
* and updates the aria-label. No DOM nodes are created on scroll.
|
||||
*/
|
||||
#updateScrollIcon() {
|
||||
const atBottom = this.#isAtBottom();
|
||||
this.$scroll.classList.toggle('scrolled-up', !atBottom);
|
||||
this.$scroll.setAttribute(
|
||||
'aria-label',
|
||||
atBottom ? 'Scroll to top' : 'Scroll to bottom',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #scrollToBottom scrolls the chat to the bottom if auto-scroll is
|
||||
* enabled (i.e., the user is following along and hasn't scrolled up).
|
||||
*/
|
||||
#scrollToBottom() {
|
||||
if (this.isAutoScrollEnabled) {
|
||||
this.$chat.scrollTop = this.$chat.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #handleSendClick routes to either send or stop based on loading state.
|
||||
*/
|
||||
@@ -651,6 +765,8 @@ class Odidere {
|
||||
*/
|
||||
async #sendRequest({ audio = null, text = '' }) {
|
||||
this.#setLoadingState(true);
|
||||
// Re-enable auto-scroll when the user sends a new message.
|
||||
this.isAutoScrollEnabled = true;
|
||||
this.currentController = new AbortController();
|
||||
|
||||
try {
|
||||
@@ -1296,7 +1412,7 @@ class Odidere {
|
||||
);
|
||||
|
||||
this.$chat.appendChild($msg);
|
||||
this.$chat.scrollTop = this.$chat.scrollHeight;
|
||||
this.#scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1372,7 +1488,7 @@ class Odidere {
|
||||
);
|
||||
|
||||
this.$chat.appendChild($msg);
|
||||
this.$chat.scrollTop = this.$chat.scrollHeight;
|
||||
this.#scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1385,7 +1501,7 @@ class Odidere {
|
||||
$msg.querySelector('.message__content').textContent = message;
|
||||
|
||||
this.$chat.appendChild($msg);
|
||||
this.$chat.scrollTop = this.$chat.scrollHeight;
|
||||
this.#scrollToBottom();
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
Reference in New Issue
Block a user