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>

7f9cbb04c6d1b2045f65d1fc77d94657fe3d2419

L <123923688+Vibecoder9000@users.noreply.github.com>

Signed
4 files changed, +100 -1Ignore whitespace
public/css/backgrounds.css+39 -0
@@ -88,6 +88,7 @@
8888 overflow-y: auto;
8989 overflow-x: hidden;
9090 padding: 0 5px 15px;
91+ position: relative;
9192}
9293
9394#bg_menu_content,
@@ -219,6 +220,44 @@
219220 background-color: rgba(255, 255, 255, 0.2);
220221}
221222
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+
222261.thumbnail-clipper {
223262 position: absolute;
224263 top: -2px;
public/index.html+3 -0
@@ -5351,6 +5351,9 @@
53515351 <form id="form_bg_upload" style="display: none;">
53525352 <input type="file" id="add_bg_button" name="avatar" accept="image/jpeg,image/png,image/gif,image/bmp,image/svg+xml,video/*">
53535353 </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>
53545357 </div>
53555358 </div>
53565359 <div id="extensions-settings-button" class="drawer">
public/scripts/backgrounds.js+7 -1
@@ -3,7 +3,7 @@ import { chat_metadata, eventSource, event_types, generateQuietPrompt, getCurren
33import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js';
44import { SlashCommand } from './slash-commands/SlashCommand.js';
55import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
66import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop } from './utils.js';
77import { debounce_timeout } from './constants.js';
88import { t } from './i18n.js';
99import { Popup } from './popup.js';
@@ -838,4 +838,10 @@ export function initBackgrounds() {
838838 await getBackgrounds();
839839 await onChatChanged();
840840 });
841+
842+ setupScrollToTop({
843+ scrollContainerId: 'bg-scrollable-content',
844+ buttonId: 'bg-scroll-top',
845+ drawerId: 'Backgrounds',
846+ });
841847}
public/scripts/utils.js+51 -0
@@ -3,6 +3,7 @@ import {
33 DOMPurify,
44 Readability,
55 isProbablyReaderable,
6+ lodash,
67} from '../lib.js';
78
89import { getContext } from './extensions.js';
@@ -2562,3 +2563,53 @@ export function textValueMatcher(params, data) {
25622563export function versionCompare(srcVersion, minVersion) {
25632564 return (srcVersion || '0.0.0').localeCompare(minVersion, undefined, { numeric: true, sensitivity: 'base' }) > -1;
25642565}
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+}