Files
SoulStream/src/lib/scrollbar.js
T
KeelrLeek a667b4b28e Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
2026-07-22 17:57:00 -05:00

212 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Floating overlay scrollbar, applied with `use:floatScroll` on any element
// that scrolls. It hides the native scrollbar entirely — so the bar never
// reserves layout width and can never reflow the content when it appears —
// and draws a thin thumb that floats over the container's right padding,
// fading in while scrolling and out again when idle. No track, no arrow
// buttons; just the one bar. Reusable across the whole app.
//
// When the scroller has a floating glass header pinned to its top (marked
// `[data-scrollhead]`, either a descendant or a direct sibling), the thumb's
// travel starts one PAD *below* that header, so it never rides up over the
// header and shows the same PAD gap top and bottom.
//
// ARCHITECTURE — read before changing how the thumb is positioned.
// The thumb lives inside a zero-height `position: sticky` rail, so the
// COMPOSITOR keeps it pinned in the scrollport; JS only slides it along the
// track. The obvious simpler design — an absolute thumb counter-scrolled per
// frame (top = scrollTop + trackPos) — jitters as soon as the scroller gets
// composited (threaded) scrolling, which the artist page's sticky
// backdrop-filter header forces: the compositor scrolls the content ahead of
// the main thread, the counter-scroll commits one frame late, and the thumb
// visibly oscillates by the full per-frame scroll delta. Pages that scroll on
// the main thread never show this, which is why the bug looked artist-page-
// only. With the sticky rail, a late frame only mis-places the thumb along the
// track by scrollDelta × (trackH / scrollH) — a few percent — imperceptible.
const PAD = 8; // gap between the thumb's travel and the top header / bottom edge
const HOVER_ZONE = 22; // px in from the right edge that reveals the thumb on hover
export function floatScroll(node) {
node.classList.add("float-scroll");
node.style.scrollbarWidth = "none"; // inline: beats scoped component rules
if (getComputedStyle(node).position === "static") node.style.position = "relative";
// Zero-height sticky rail as the scroller's FIRST child (sticky can only
// hold an element back from scrolling up past `top: 0`, so it must sit at
// the top of the flow). The thumb hangs off it, absolutely positioned.
const rail = document.createElement("div");
rail.className = "float-rail";
const thumb = document.createElement("div");
thumb.className = "float-thumb";
rail.appendChild(thumb);
node.prepend(rail);
let hideTimer = 0;
let dragging = false;
let measureRaf = 0;
// Cached layout, refreshed only by measure() (never during a scroll).
let active = false;
let topInset = PAD;
let trackH = 0;
let thumbH = 24;
let maxScroll = 0;
let padTop = 0;
let boxRight = 0; // viewport x of the scroller's right edge (for hover)
let boxTop = 0; // viewport y of the scroller's top edge
// The `show` state (scrolling) fades on its own after a beat; the separate
// `hover` state below is independent and controls its own visibility, so a
// deliberate grab only needs `dragging` to hold the thumb up.
function scheduleHide() {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (!dragging) thumb.classList.remove("show");
}, 500);
}
function show() {
thumb.classList.add("show");
scheduleHide();
}
// Recompute the cached geometry. Runs on init and whenever the layout might
// have changed (resize, content grow/shrink, a view swapping a header in or
// out) — but deliberately NOT on scroll: the per-frame path below reads
// only scrollTop and forces no layout.
function measure() {
measureRaf = 0;
const viewH = node.clientHeight;
const scrollH = node.scrollHeight;
const box = node.getBoundingClientRect();
boxRight = box.right;
boxTop = box.top;
const cs = getComputedStyle(node);
padTop = parseFloat(cs.paddingTop) || 0;
// The rail is an in-flow (zero-height) child. In a flex/grid scroller
// it would still contribute one row-gap before the real first child —
// cancel it so adding the rail never shifts the content.
const rowGap = parseFloat(cs.rowGap) || 0;
rail.style.marginBottom = rowGap ? `-${rowGap}px` : "0";
// The rail spans the content box; the thumb should sit 3px inside the
// PADDING box's right edge (where it always lived), so pull it back out
// through the right padding.
const padRight = parseFloat(cs.paddingRight) || 0;
thumb.style.right = `${3 - padRight}px`;
// A `[data-scrollhead]` glass header pinned to the top of this scroller:
// a descendant (the artist page's Results bar inside the content pane)
// or a direct sibling (the Now Playing header sits beside its scroller).
const head =
node.querySelector("[data-scrollhead]") ||
node.parentElement?.querySelector(":scope > [data-scrollhead]");
const headH = head ? head.getBoundingClientRect().height : 0;
active = scrollH > viewH + 1;
if (!active) {
thumb.style.display = "none";
return;
}
topInset = headH + PAD;
trackH = Math.max(0, viewH - topInset - PAD);
thumbH = Math.max(24, Math.min(trackH, (viewH / scrollH) * trackH));
maxScroll = scrollH - viewH;
thumb.style.display = "block";
thumb.style.height = `${thumbH}px`;
place();
}
function scheduleMeasure() {
if (!measureRaf) measureRaf = requestAnimationFrame(measure);
}
// Per-frame, cheap: read only scrollTop, write one composited transform.
// The engine pins the sticky rail at the scroller's CONTENT-box top (i.e.
// `padTop` below the visual top edge, verified empirically in WebView2) for
// the entire scroll, so the rail-relative offset is a constant correction.
function place() {
if (!active) return;
const ratio = maxScroll > 0 ? node.scrollTop / maxScroll : 0;
const y = topInset - padTop + ratio * (trackH - thumbH);
thumb.style.transform = `translateY(${y}px)`;
}
function onScroll() {
place();
show();
}
// The thumb appears while scrolling; separately, moving the pointer into the
// narrow strip along the scroller's right edge reveals it faintly (the
// `hover` state) so it can be found and grabbed without scrolling first.
node.addEventListener("scroll", onScroll, { passive: true });
let nearEdge = false;
function setHover(on) {
if (on === nearEdge) return;
nearEdge = on;
thumb.classList.toggle("hover", on);
}
function onPointerMove(e) {
setHover(
active &&
e.clientX >= boxRight - HOVER_ZONE &&
e.clientX <= boxRight + 2 &&
e.clientY >= boxTop + topInset - 4,
);
}
function onPointerLeave() {
setHover(false);
}
node.addEventListener("pointermove", onPointerMove, { passive: true });
node.addEventListener("pointerleave", onPointerLeave);
const ro = new ResizeObserver(scheduleMeasure);
ro.observe(node);
// Content growing/shrinking (queue changes, sections toggling) changes
// scrollHeight; childList catches that — and a view swapping a header in or
// out — without firing on every text tick.
const mo = new MutationObserver(scheduleMeasure);
mo.observe(node, { childList: true, subtree: true });
// Drag the thumb.
let dragY0 = 0;
let scroll0 = 0;
thumb.addEventListener("pointerdown", (e) => {
dragging = true;
dragY0 = e.clientY;
scroll0 = node.scrollTop;
thumb.classList.add("dragging");
thumb.setPointerCapture(e.pointerId);
e.preventDefault();
});
thumb.addEventListener("pointermove", (e) => {
if (!dragging) return;
const track = trackH - thumbH;
node.scrollTop = scroll0 + (track > 0 ? ((e.clientY - dragY0) / track) * maxScroll : 0);
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
thumb.classList.remove("dragging");
if (thumb.hasPointerCapture(e.pointerId)) thumb.releasePointerCapture(e.pointerId);
scheduleHide();
}
thumb.addEventListener("pointerup", endDrag);
thumb.addEventListener("pointercancel", endDrag);
measure();
return {
destroy() {
clearTimeout(hideTimer);
if (measureRaf) cancelAnimationFrame(measureRaf);
node.removeEventListener("scroll", onScroll);
node.removeEventListener("pointermove", onPointerMove);
node.removeEventListener("pointerleave", onPointerLeave);
// The thumb's own pointer listeners go away with rail.remove().
ro.disconnect();
mo.disconnect();
rail.remove();
},
};
}