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"/>
|
<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 |
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====================
|
// ====================
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user