Add scroll button and auto-scroll
This commit is contained in:
@@ -95,6 +95,16 @@
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="scroll-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v14"/>
|
||||
<path d="m19 12-7 7-7-7"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="scroll-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 19V5"/>
|
||||
<path d="m5 12 7-7 7 7"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -474,6 +474,11 @@ body {
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.scroll-icon--up { display: inline; }
|
||||
.scroll-icon--down { display: none; }
|
||||
#scroll.scrolled-up .scroll-icon--up { display: none; }
|
||||
#scroll.scrolled-up .scroll-icon--down { display: inline; }
|
||||
|
||||
.footer__toolbar-btn--muted {
|
||||
color: var(--color-red);
|
||||
border-color: var(--color-red);
|
||||
|
||||
@@ -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