From b8308782f8e12a6158bcdd43ab216638111faf0f Mon Sep 17 00:00:00 2001 From: Ruben Carlo Benante Date: Wed, 1 Jul 2026 10:05:52 -0300 Subject: [PATCH] bar from scratch Assets --- Asset/css/shrink-vertically.css | 59 ++++++------- Asset/js/top-scrollbar.js | 148 +++++++++++++++++++++++++------- 2 files changed, 147 insertions(+), 60 deletions(-) diff --git a/Asset/css/shrink-vertically.css b/Asset/css/shrink-vertically.css index ab0cc95..20a8c3b 100644 --- a/Asset/css/shrink-vertically.css +++ b/Asset/css/shrink-vertically.css @@ -34,39 +34,40 @@ } /* - * Optional top horizontal scrollbar (added by Asset/js/top-scrollbar.js when the - * "Add a top horizontal scrollbar" setting is enabled). The inner element is given a - * width equal to the board's scrollable width by the script; overflow-x: auto then - * produces a scrollbar whose range mirrors the board's own bottom scrollbar. The 1px - * inner height keeps the bar to just the scrollbar itself. + * Optional custom top horizontal scrollbar (built by Asset/js/top-scrollbar.js when the + * "Add a top horizontal scrollbar" setting is enabled). It is a track + draggable thumb, + * not a native scrollbar, so it renders and can be sized identically in every browser + * (Chrome, Firefox, Safari) and on touch. Thickness follows the --sv-bar-size variable + * (Settings -> Shrink Vertically), injected in the page head; falls back to 25px. + * touch-action: none lets a finger drag the thumb without scrolling the page. */ .sv-top-scroll { - overflow-x: auto; - overflow-y: hidden; -} - -.sv-top-scroll-inner { - height: 1px; -} - -/* - * Make the top scrollbar thick enough to grab easily (touch / big fingers). - * WebKit/Blink (most desktop browsers and mobile Chrome/Safari) let us size the - * scrollbar; styling the thumb/track is needed for it to stay visible once customised. - * Thickness comes from the --sv-bar-size variable (Settings -> Shrink Vertically), - * injected in the page head; falls back to 25px. Chromium/WebKit only (Chrome/Edge/ - * Safari, mobile Chrome/Safari); Firefox ignores ::-webkit-scrollbar and keeps its - * default thin bar -- accepted for now. - */ -.sv-top-scroll::-webkit-scrollbar { + position: relative; + width: 100%; height: var(--sv-bar-size, 25px); + background: rgba(0, 0, 0, 0.06); + border-radius: 4px; + overflow: hidden; + touch-action: none; } -.sv-top-scroll::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.05); -} - -.sv-top-scroll::-webkit-scrollbar-thumb { +.sv-top-scroll-thumb { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 100%; background: rgba(0, 0, 0, 0.35); - border-radius: 6px; + border-radius: 4px; + cursor: grab; + touch-action: none; +} + +.sv-top-scroll.sv-dragging .sv-top-scroll-thumb { + cursor: grabbing; + background: rgba(0, 0, 0, 0.5); +} + +.sv-dragging { + user-select: none; } diff --git a/Asset/js/top-scrollbar.js b/Asset/js/top-scrollbar.js index 341e039..4a243b3 100644 --- a/Asset/js/top-scrollbar.js +++ b/Asset/js/top-scrollbar.js @@ -1,16 +1,23 @@ /* - * ShrinkVertically -- optional horizontal scrollbar above the board. + * ShrinkVertically -- optional custom horizontal scrollbar above the board. * - * Kanboard's board scrolls horizontally inside #board-container (overflow-x: auto), - * so the only horizontal scrollbar sits at the very bottom of the board. On a wide - * board you have to scroll all the way down to reach it. This adds a second, mirrored - * scrollbar just above the columns and keeps the two scroll positions in sync. + * Kanboard's board scrolls horizontally inside #board-container (overflow-x: auto), so the + * only native horizontal scrollbar sits at the very bottom of the board. On a wide board + * you have to scroll all the way down to reach it. This adds a second scrollbar just above + * the columns and keeps it in sync with the board. + * + * Unlike a native scrollbar (whose thickness cannot be sized in Firefox), this is a custom + * track + thumb widget: it renders and behaves the same in every browser (Chrome, Firefox, + * Safari) and on touch, and its thickness follows the --sv-bar-size setting. * * Loaded only when the "Add a top horizontal scrollbar" setting is enabled. */ (function () { "use strict"; + // Keep the thumb wide enough to grab, even on a very wide board. + var MIN_THUMB = 24; + function init() { var container = document.getElementById("board-container"); @@ -19,45 +26,124 @@ return; } - var bar = document.createElement("div"); - bar.className = "sv-top-scroll"; + var track = document.createElement("div"); + track.className = "sv-top-scroll"; - var inner = document.createElement("div"); - inner.className = "sv-top-scroll-inner"; - bar.appendChild(inner); + var thumb = document.createElement("div"); + thumb.className = "sv-top-scroll-thumb"; + track.appendChild(thumb); - container.parentNode.insertBefore(bar, container); + container.parentNode.insertBefore(track, container); - function sync() { - // Match the dummy bar's content width to the board's scrollable width - // so its scrollbar has the same range as the board's own scrollbar. - inner.style.width = container.scrollWidth + "px"; + var dragging = false; + var dragStartX = 0; + var dragStartScrollLeft = 0; + + function metrics() { + return { + viewport: container.clientWidth, + content: container.scrollWidth, + trackW: track.clientWidth + }; } - var lock = false; + function positionThumb(m, thumbW) { + var maxScroll = m.content - m.viewport; + var maxThumb = m.trackW - thumbW; + var left = maxScroll > 0 ? (container.scrollLeft / maxScroll) * maxThumb : 0; + thumb.style.transform = "translateX(" + Math.round(left) + "px)"; + } - bar.addEventListener("scroll", function () { - if (lock) { return; } - lock = true; - container.scrollLeft = bar.scrollLeft; - lock = false; - }); + // Recompute thumb width/position; hide the whole bar when nothing to scroll. + function layout() { + var m = metrics(); + if (m.content <= m.viewport || m.trackW <= 0) { + track.style.display = "none"; + return; + } + + track.style.display = ""; + + var thumbW = Math.round(m.trackW * m.viewport / m.content); + if (thumbW < MIN_THUMB) { thumbW = MIN_THUMB; } + if (thumbW > m.trackW) { thumbW = m.trackW; } + + thumb.style.width = thumbW + "px"; + positionThumb(m, thumbW); + } + + // Board scrolled by any other means -> keep the thumb in sync. container.addEventListener("scroll", function () { - if (lock) { return; } - lock = true; - bar.scrollLeft = container.scrollLeft; - lock = false; + if (dragging) { return; } + var m = metrics(); + if (m.content <= m.viewport) { return; } + positionThumb(m, thumb.offsetWidth); }); - window.addEventListener("resize", sync); + // Drag the thumb (mouse + touch via Pointer Events). + thumb.addEventListener("pointerdown", function (e) { + e.preventDefault(); + e.stopPropagation(); + dragging = true; + dragStartX = e.clientX; + dragStartScrollLeft = container.scrollLeft; + track.classList.add("sv-dragging"); + if (thumb.setPointerCapture) { + thumb.setPointerCapture(e.pointerId); + } + }); - // The board can be redrawn by the AJAX polling/refresh; keep the width current. - if (window.MutationObserver) { - new MutationObserver(sync).observe(container, { childList: true, subtree: true }); + thumb.addEventListener("pointermove", function (e) { + if (!dragging) { return; } + var m = metrics(); + var maxThumb = m.trackW - thumb.offsetWidth; + var maxScroll = m.content - m.viewport; + if (maxThumb <= 0) { return; } + container.scrollLeft = dragStartScrollLeft + (e.clientX - dragStartX) * (maxScroll / maxThumb); + positionThumb(m, thumb.offsetWidth); + }); + + function endDrag(e) { + if (!dragging) { return; } + dragging = false; + track.classList.remove("sv-dragging"); + if (thumb.releasePointerCapture && e && e.pointerId != null) { + try { thumb.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ } + } } - sync(); + thumb.addEventListener("pointerup", endDrag); + thumb.addEventListener("pointercancel", endDrag); + + // Click on the empty track -> jump so the thumb centers under the pointer. + track.addEventListener("pointerdown", function (e) { + if (e.target === thumb || dragging) { return; } + var m = metrics(); + if (m.content <= m.viewport) { return; } + + var thumbW = thumb.offsetWidth; + var maxThumb = m.trackW - thumbW; + var maxScroll = m.content - m.viewport; + if (maxThumb <= 0) { return; } + + var clickX = e.clientX - track.getBoundingClientRect().left; + var targetLeft = clickX - thumbW / 2; + if (targetLeft < 0) { targetLeft = 0; } + if (targetLeft > maxThumb) { targetLeft = maxThumb; } + + container.scrollLeft = (targetLeft / maxThumb) * maxScroll; + positionThumb(m, thumbW); + }); + + window.addEventListener("resize", layout); + + // The board can be redrawn by AJAX polling / drag-and-drop; keep sizes current. + if (window.MutationObserver) { + new MutationObserver(layout).observe(container, { childList: true, subtree: true }); + } + + layout(); } if (document.readyState === "loading") {