Scroll to top button (#4569) * init * implement review suggestions * remove unnecessary css variables * respect reduced motion * simplify setupScrollToTop call * power_user flag fix * simplify, more of above * remove remove button when drawer is closed logic * throttle scroll checker * Fix review comments * Make scrollToTop a shared function * Refactor logic * Replace on hover color * Prefer theme colors for button * Increase hover brightness for scroll-to-top button * Clean-up diff * Fix type annotation --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -88,6 +88,7 @@ | ||
| 88 | 88 | overflow-y: auto; |
| 89 | 89 | overflow-x: hidden; |
| 90 | 90 | padding: 0 5px 15px; |
| 91 | + position: relative; | |
| 91 | 92 | } |
| 92 | 93 | |
| 93 | 94 | #bg_menu_content, |
| @@ -219,6 +220,44 @@ | ||
| 219 | 220 | background-color: rgba(255, 255, 255, 0.2); |
| 220 | 221 | } |
| 221 | 222 | |
| 223 | +/* Scroll-to-Top Button */ | |
| 224 | +#bg-scroll-top { | |
| 225 | + position: absolute; | |
| 226 | + bottom: 20px; | |
| 227 | + right: 20px; | |
| 228 | + width: 42px; | |
| 229 | + height: 42px; | |
| 230 | + background: var(--SmartThemeBlurTintColor); | |
| 231 | + color: var(--SmartThemeBodyColor); | |
| 232 | + border: 1px solid var(--SmartThemeBorderColor); | |
| 233 | + border-radius: 50%; | |
| 234 | + cursor: pointer; | |
| 235 | + z-index: 10; | |
| 236 | + font-size: 18px; | |
| 237 | + display: inline-flex; | |
| 238 | + align-items: center; | |
| 239 | + justify-content: center; | |
| 240 | + opacity: 0; | |
| 241 | + transition: opacity var(--animation-duration) ease; | |
| 242 | + pointer-events: none; | |
| 243 | +} | |
| 244 | + | |
| 245 | +#bg-scroll-top.visible { | |
| 246 | + opacity: 1; | |
| 247 | + pointer-events: auto; | |
| 248 | +} | |
| 249 | + | |
| 250 | +#bg-scroll-top:hover { | |
| 251 | + filter: brightness(150%); | |
| 252 | + outline: 1px solid var(--interactable-outline-color); | |
| 253 | +} | |
| 254 | + | |
| 255 | +#bg-scroll-top .fa-solid { | |
| 256 | + margin: 0; | |
| 257 | + padding: 0; | |
| 258 | + line-height: 1; | |
| 259 | +} | |
| 260 | + | |
| 222 | 261 | .thumbnail-clipper { |
| 223 | 262 | position: absolute; |
| 224 | 263 | top: -2px; |
| @@ -5351,6 +5351,9 @@ | ||
| 5351 | 5351 | <form id="form_bg_upload" style="display: none;"> |
| 5352 | 5352 | <input type="file" id="add_bg_button" name="avatar" accept="image/jpeg,image/png,image/gif,image/bmp,image/svg+xml,video/*"> |
| 5353 | 5353 | </form> |
| 5354 | + <button id="bg-scroll-top" type="button" class="menu_button menu_button_icon" title="Scroll backgrounds to top" data-i18n="[title]Scroll backgrounds to top"> | |
| 5355 | + <i class="fa-solid fa-chevron-up" aria-hidden="true"></i> | |
| 5356 | + </button> | |
| 5354 | 5357 | </div> |
| 5355 | 5358 | </div> |
| 5356 | 5359 | <div id="extensions-settings-button" class="drawer"> |
| @@ -3,7 +3,7 @@ import { chat_metadata, eventSource, event_types, generateQuietPrompt, getCurren | ||
| 3 | 3 | import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js'; |
| 4 | 4 | import { SlashCommand } from './slash-commands/SlashCommand.js'; |
| 5 | 5 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| 6 | 6 | import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop } from './utils.js'; |
| 7 | 7 | import { debounce_timeout } from './constants.js'; |
| 8 | 8 | import { t } from './i18n.js'; |
| 9 | 9 | import { Popup } from './popup.js'; |
| @@ -838,4 +838,10 @@ export function initBackgrounds() { | ||
| 838 | 838 | await getBackgrounds(); |
| 839 | 839 | await onChatChanged(); |
| 840 | 840 | }); |
| 841 | + | |
| 842 | + setupScrollToTop({ | |
| 843 | + scrollContainerId: 'bg-scrollable-content', | |
| 844 | + buttonId: 'bg-scroll-top', | |
| 845 | + drawerId: 'Backgrounds', | |
| 846 | + }); | |
| 841 | 847 | } |
| @@ -3,6 +3,7 @@ import { | ||
| 3 | 3 | DOMPurify, |
| 4 | 4 | Readability, |
| 5 | 5 | isProbablyReaderable, |
| 6 | + lodash, | |
| 6 | 7 | } from '../lib.js'; |
| 7 | 8 | |
| 8 | 9 | import { getContext } from './extensions.js'; |
| @@ -2562,3 +2563,53 @@ export function textValueMatcher(params, data) { | ||
| 2562 | 2563 | export function versionCompare(srcVersion, minVersion) { |
| 2563 | 2564 | return (srcVersion || '0.0.0').localeCompare(minVersion, undefined, { numeric: true, sensitivity: 'base' }) > -1; |
| 2564 | 2565 | } |
| 2566 | + | |
| 2567 | +/** | |
| 2568 | + * Sets up the scroll-to-top button functionality. | |
| 2569 | + * @param {object} params Parameters object | |
| 2570 | + * @param {string} params.scrollContainerId Scrollable container element ID | |
| 2571 | + * @param {string} params.buttonId Button element ID | |
| 2572 | + * @param {string} params.drawerId Drawer element ID | |
| 2573 | + * @param {number} [params.visibilityThreshold] Scroll position (px) to show the button (default: 300) | |
| 2574 | + * @returns {() => void} Cleanup function to remove event listeners | |
| 2575 | + */ | |
| 2576 | +export function setupScrollToTop({ scrollContainerId, buttonId, drawerId, visibilityThreshold = 300 }) { | |
| 2577 | + const scrollContainer = document.getElementById(scrollContainerId); | |
| 2578 | + const btn = document.getElementById(buttonId); | |
| 2579 | + const drawer = document.getElementById(drawerId); | |
| 2580 | + | |
| 2581 | + if (!btn || !drawer) { | |
| 2582 | + // Not fatal; the drawer or button may not exist in some builds. Use debug level. | |
| 2583 | + console.debug('Scroll-to-top: button or drawer not found during setup.'); | |
| 2584 | + return () => { /* noop cleanup */ }; | |
| 2585 | + } | |
| 2586 | + | |
| 2587 | + if (!scrollContainer) { | |
| 2588 | + console.debug('Scroll-to-top: scroll container not found during setup.'); | |
| 2589 | + return () => { /* noop cleanup */ }; | |
| 2590 | + } | |
| 2591 | + | |
| 2592 | + const updateButtonVisibility = () => btn.classList.toggle('visible', scrollContainer.scrollTop > visibilityThreshold); | |
| 2593 | + const updateButtonVisibilityThrottled = lodash.throttle(updateButtonVisibility, debounce_timeout.standard, { leading: true, trailing: true }); | |
| 2594 | + const onScroll = () => updateButtonVisibilityThrottled(); | |
| 2595 | + scrollContainer.addEventListener('scroll', onScroll, { passive: true }); | |
| 2596 | + | |
| 2597 | + // Scroll to top on click (button semantics provide keyboard activation natively) | |
| 2598 | + const onActivate = (/** @type {MouseEvent} */ e) => { | |
| 2599 | + e.preventDefault(); | |
| 2600 | + e.stopPropagation(); | |
| 2601 | + | |
| 2602 | + const userPrefersReduced = power_user.reduced_motion; | |
| 2603 | + scrollContainer.scrollTo({ top: 0, behavior: userPrefersReduced ? 'auto' : 'smooth' }); | |
| 2604 | + }; | |
| 2605 | + btn.addEventListener('click', onActivate); | |
| 2606 | + | |
| 2607 | + // Initial state check | |
| 2608 | + updateButtonVisibility(); | |
| 2609 | + | |
| 2610 | + // Return cleanup function for caller to hold and invoke when appropriate | |
| 2611 | + return () => { | |
| 2612 | + scrollContainer.removeEventListener('scroll', onScroll); | |
| 2613 | + btn.removeEventListener('click', onActivate); | |
| 2614 | + }; | |
| 2615 | +} | |