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 | overflow-y: auto; | 88 | overflow-y: auto; |
| 89 | overflow-x: hidden; | 89 | overflow-x: hidden; |
| 90 | padding: 0 5px 15px; | 90 | padding: 0 5px 15px; |
| 91 | position: relative; | ||
| 91 | } | 92 | } |
| 92 | 93 | ||
| 93 | #bg_menu_content, | 94 | #bg_menu_content, |
| @@ -219,6 +220,44 @@ | |||
| 219 | background-color: rgba(255, 255, 255, 0.2); | 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 | .thumbnail-clipper { | 261 | .thumbnail-clipper { |
| 223 | position: absolute; | 262 | position: absolute; |
| 224 | top: -2px; | 263 | top: -2px; |
| @@ -5351,6 +5351,9 @@ | |||
| 5351 | <form id="form_bg_upload" style="display: none;"> | 5351 | <form id="form_bg_upload" style="display: none;"> |
| 5352 | <input type="file" id="add_bg_button" name="avatar" accept="image/jpeg,image/png,image/gif,image/bmp,image/svg+xml,video/*"> | 5352 | <input type="file" id="add_bg_button" name="avatar" accept="image/jpeg,image/png,image/gif,image/bmp,image/svg+xml,video/*"> |
| 5353 | </form> | 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 | </div> | 5357 | </div> |
| 5355 | </div> | 5358 | </div> |
| 5356 | <div id="extensions-settings-button" class="drawer"> | 5359 | <div id="extensions-settings-button" class="drawer"> |
| @@ -3,7 +3,7 @@ import { chat_metadata, eventSource, event_types, generateQuietPrompt, getCurren | |||
| 3 | import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js'; | 3 | import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js'; |
| 4 | import { SlashCommand } from './slash-commands/SlashCommand.js'; | 4 | import { SlashCommand } from './slash-commands/SlashCommand.js'; |
| 5 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; | 5 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| 6 | import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce } from './utils.js'; | 6 | import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop } from './utils.js'; |
| 7 | import { debounce_timeout } from './constants.js'; | 7 | import { debounce_timeout } from './constants.js'; |
| 8 | import { t } from './i18n.js'; | 8 | import { t } from './i18n.js'; |
| 9 | import { Popup } from './popup.js'; | 9 | import { Popup } from './popup.js'; |
| @@ -838,4 +838,10 @@ export function initBackgrounds() { | |||
| 838 | await getBackgrounds(); | 838 | await getBackgrounds(); |
| 839 | await onChatChanged(); | 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 | DOMPurify, | 3 | DOMPurify, |
| 4 | Readability, | 4 | Readability, |
| 5 | isProbablyReaderable, | 5 | isProbablyReaderable, |
| 6 | lodash, | ||
| 6 | } from '../lib.js'; | 7 | } from '../lib.js'; |
| 7 | 8 | ||
| 8 | import { getContext } from './extensions.js'; | 9 | import { getContext } from './extensions.js'; |
| @@ -2562,3 +2563,53 @@ export function textValueMatcher(params, data) { | |||
| 2562 | export function versionCompare(srcVersion, minVersion) { | 2563 | export function versionCompare(srcVersion, minVersion) { |
| 2563 | return (srcVersion || '0.0.0').localeCompare(minVersion, undefined, { numeric: true, sensitivity: 'base' }) > -1; | 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 | } | ||