Add scroll button and auto-scroll

This commit is contained in:
dwrz
2026-06-09 18:50:25 +00:00
parent 6c8c04c804
commit 3321fce4fa
4 changed files with 144 additions and 4 deletions

View File

@@ -95,6 +95,16 @@
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/> <rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
</symbol> </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"> <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"/> <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"/> <circle cx="12" cy="12" r="3"/>

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -474,6 +474,11 @@ body {
height: 1rem; 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 { .footer__toolbar-btn--muted {
color: var(--color-red); color: var(--color-red);
border-color: var(--color-red); border-color: var(--color-red);

View File

@@ -29,9 +29,15 @@ class Odidere {
this.isRecording = false; this.isRecording = false;
this.isMuted = false; this.isMuted = false;
this.isResetPending = false; this.isResetPending = false;
this.isScrollPending = false;
this.isStopPending = false; this.isStopPending = false;
this.mediaRecorder = null; 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 // DOM Elements
this.$attach = document.getElementById('attach'); this.$attach = document.getElementById('attach');
this.$attachments = document.getElementById('attachments'); this.$attachments = document.getElementById('attachments');
@@ -41,6 +47,7 @@ class Odidere {
this.$model = document.getElementById('model'); this.$model = document.getElementById('model');
this.$ptt = document.getElementById('ptt'); this.$ptt = document.getElementById('ptt');
this.$reset = document.getElementById('reset'); this.$reset = document.getElementById('reset');
this.$scroll = document.getElementById('scroll');
this.$send = document.getElementById('send'); this.$send = document.getElementById('send');
this.$textInput = document.getElementById('text-input'); this.$textInput = document.getElementById('text-input');
this.$voice = document.getElementById('voice'); this.$voice = document.getElementById('voice');
@@ -97,6 +104,8 @@ class Odidere {
this.#setLoadingState(false); this.#setLoadingState(false);
this.history = []; this.history = [];
this.isAutoScrollEnabled = true;
this._lastScrollTop = 0;
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
this.$chat.innerHTML = ''; this.$chat.innerHTML = '';
this.#clearAttachments(); this.#clearAttachments();
@@ -151,7 +160,12 @@ class Odidere {
this.$reset.addEventListener('click', () => this.#handleResetClick()); this.$reset.addEventListener('click', () => this.#handleResetClick());
this.document.addEventListener('click', (e) => this.#handleResetCancel(e)); 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.$attach.addEventListener('click', () => this.$fileInput.click());
this.$fileInput.addEventListener('change', (e) => this.$fileInput.addEventListener('change', (e) =>
this.#handleAttachments(e.target.files), this.#handleAttachments(e.target.files),
@@ -167,6 +181,17 @@ class Odidere {
// Mute button // Mute button
this.$mute.addEventListener('click', () => this.#toggleMute()); 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 // Settings modal
this.$settings.addEventListener('click', () => this.openSettings()); this.$settings.addEventListener('click', () => this.openSettings());
this.$settingsClose.addEventListener('click', () => this.closeSettings()); this.$settingsClose.addEventListener('click', () => this.closeSettings());
@@ -359,6 +384,95 @@ class Odidere {
this.$reset.classList.remove('footer__toolbar-btn--pending'); 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. * #handleSendClick routes to either send or stop based on loading state.
*/ */
@@ -651,6 +765,8 @@ class Odidere {
*/ */
async #sendRequest({ audio = null, text = '' }) { async #sendRequest({ audio = null, text = '' }) {
this.#setLoadingState(true); this.#setLoadingState(true);
// Re-enable auto-scroll when the user sends a new message.
this.isAutoScrollEnabled = true;
this.currentController = new AbortController(); this.currentController = new AbortController();
try { try {
@@ -1296,7 +1412,7 @@ class Odidere {
); );
this.$chat.appendChild($msg); this.$chat.appendChild($msg);
this.$chat.scrollTop = this.$chat.scrollHeight; this.#scrollToBottom();
} }
/** /**
@@ -1372,7 +1488,7 @@ class Odidere {
); );
this.$chat.appendChild($msg); this.$chat.appendChild($msg);
this.$chat.scrollTop = this.$chat.scrollHeight; this.#scrollToBottom();
} }
/** /**
@@ -1385,7 +1501,7 @@ class Odidere {
$msg.querySelector('.message__content').textContent = message; $msg.querySelector('.message__content').textContent = message;
this.$chat.appendChild($msg); this.$chat.appendChild($msg);
this.$chat.scrollTop = this.$chat.scrollHeight; this.#scrollToBottom();
} }
// ==================== // ====================

View File

@@ -17,6 +17,15 @@
> >
<svg class="icon"><use href="/static/icons.svg#reset"></use></svg> <svg class="icon"><use href="/static/icons.svg#reset"></use></svg>
</button> </button>
<button
type="button"
class="footer__toolbar-btn"
id="scroll"
aria-label="Scroll to top"
>
<svg class="icon scroll-icon--up"><use href="/static/icons.svg#scroll-up"></use></svg>
<svg class="icon scroll-icon--down"><use href="/static/icons.svg#scroll-down"></use></svg>
</button>
<button <button
type="button" type="button"
class="footer__toolbar-btn" class="footer__toolbar-btn"