RSS Git Download  Clone
Raw Blame History 10kB 264 lines
// Framework-agnostic overlay scrollbar — macOS-style thin thumb that overlays
// content with no gutter, fades in on mousemove/scroll within the viewport,
// fades out after an idle timeout. Pure vanilla DOM, no dependencies.
//
// Usage:
//   const instance = attachOverlayScroll(viewportElement)
//   ...later, when component unmounts:
//   instance.destroy()
//
// The thumb is a <div> appended to document.body with `position: fixed`,
// synced to the viewport's bounding rect via ResizeObserver + scroll listener.
// This avoids wrapping the viewport's children, so React/Vue/Angular
// reconciliation never fights with our DOM manipulation.

export interface OverlayScrollInstance {
    destroy(): void;
    update(): void;
}

let webkitStyleInjected = false;
function ensureGlobalStyles(): void {
    if (webkitStyleInjected) return;
    webkitStyleInjected = true;
    const s = document.createElement('style');
    s.setAttribute('data-p3xr-overlay-scroll', '');
    s.textContent = `
        /* Hide native scrollbars globally — overlay scroll is the only visible
           scrollbar. Third-party widgets (CodeMirror / Monaco / xterm) ship
           their own scrollbar UIs that are excluded from overlay attach, but
           we still want their native scrollbars hidden (they scroll internally
           via wheel/keyboard). */
        html, body, * { scrollbar-width: none; -ms-overflow-style: none; }
        *::-webkit-scrollbar { width: 0; height: 0; display: none; }
        .p3xr-os-viewport { scrollbar-width: none; -ms-overflow-style: none; }
        .p3xr-os-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
        .p3xr-os-thumb {
            position: fixed;
            width: 8px;
            border-radius: 4px;
            background-color: rgba(128, 128, 128, 0.5);
            opacity: 0;
            transition: opacity 0.25s ease, background-color 0.15s ease;
            pointer-events: auto;
            z-index: 9999;
            will-change: transform, opacity, height;
        }
        .p3xr-os-thumb:hover { background-color: rgba(128, 128, 128, 0.75); }
        body.p3xr-theme-dark .p3xr-os-thumb { background-color: rgba(255, 255, 255, 0.35); }
        body.p3xr-theme-dark .p3xr-os-thumb:hover { background-color: rgba(255, 255, 255, 0.6); }
        body.p3xr-theme-light .p3xr-os-thumb { background-color: rgba(0, 0, 0, 0.35); }
        body.p3xr-theme-light .p3xr-os-thumb:hover { background-color: rgba(0, 0, 0, 0.6); }
    `;
    document.head.appendChild(s);
}

export function attachOverlayScroll(viewport: HTMLElement): OverlayScrollInstance {
    ensureGlobalStyles();
    viewport.classList.add('p3xr-os-viewport');

    const thumb = document.createElement('div');
    thumb.className = 'p3xr-os-thumb';
    document.body.appendChild(thumb);

    let hideTimer: ReturnType<typeof setTimeout> | null = null;
    let isDragging = false;
    let dragStartY = 0;
    let dragStartScrollTop = 0;
    let thumbHeight = 30;

    const show = (): void => {
        thumb.style.opacity = '1';
        if (hideTimer) clearTimeout(hideTimer);
        hideTimer = setTimeout(() => { if (!isDragging) thumb.style.opacity = '0'; }, 1200);
    };

    // 4px breathing room at top and bottom of the track so the thumb never
    // feels like it's touching the content's edge (or the header/footer).
    const EDGE_PAD = 4;

    const update = (): void => {
        const rect = viewport.getBoundingClientRect();
        const { scrollTop, scrollHeight, clientHeight } = viewport;
        if (scrollHeight <= clientHeight + 1) {
            thumb.style.display = 'none';
            return;
        }
        thumb.style.display = 'block';
        thumbHeight = Math.max(30, (clientHeight / scrollHeight) * clientHeight);
        const maxScroll = scrollHeight - clientHeight;
        const trackTravel = clientHeight - thumbHeight - EDGE_PAD * 2;
        const thumbY = maxScroll > 0 ? (scrollTop / maxScroll) * trackTravel : 0;
        thumb.style.left = `${rect.right - 10}px`;
        thumb.style.top = `${rect.top + EDGE_PAD + thumbY}px`;
        thumb.style.height = `${thumbHeight}px`;
    };

    const onScroll = (): void => { update(); show(); };
    const onViewportMove = (): void => show();
    const onThumbDown = (e: MouseEvent): void => {
        e.preventDefault();
        isDragging = true;
        dragStartY = e.clientY;
        dragStartScrollTop = viewport.scrollTop;
        document.addEventListener('mousemove', onDrag);
        document.addEventListener('mouseup', onUp);
    };
    const onDrag = (e: MouseEvent): void => {
        if (!isDragging) return;
        const { scrollHeight, clientHeight } = viewport;
        const maxScroll = scrollHeight - clientHeight;
        const trackH = clientHeight - thumbHeight;
        const deltaY = e.clientY - dragStartY;
        viewport.scrollTop = dragStartScrollTop + (trackH > 0 ? deltaY / trackH : 0) * maxScroll;
        show();
    };
    const onUp = (): void => {
        isDragging = false;
        document.removeEventListener('mousemove', onDrag);
        document.removeEventListener('mouseup', onUp);
        show();
    };

    viewport.addEventListener('scroll', onScroll, { passive: true });
    viewport.addEventListener('mousemove', onViewportMove);
    thumb.addEventListener('mousedown', onThumbDown);

    const ro = new ResizeObserver(update);
    ro.observe(viewport);
    const mo = new MutationObserver(update);
    mo.observe(viewport, { childList: true, subtree: true, characterData: true });
    window.addEventListener('resize', update);

    // Keep the thumb aligned during CSS transitions (e.g. console drawer open/close
    // animates `bottom` on the layout-content for 150ms). ResizeObserver alone can
    // skip frames — run rAF for the transition window.
    let rafActive = false;
    let rafStop = 0;
    const startRafFor = (ms: number) => {
        rafStop = Date.now() + ms;
        if (rafActive) return;
        rafActive = true;
        const tick = () => {
            update();
            if (Date.now() < rafStop) {
                requestAnimationFrame(tick);
            } else {
                rafActive = false;
            }
        };
        requestAnimationFrame(tick);
    };
    const onTransition = (e: TransitionEvent) => {
        if (e.target === viewport || e.target === viewport.parentElement) startRafFor(250);
    };
    window.addEventListener('transitionrun', onTransition);

    update();

    return {
        destroy(): void {
            viewport.removeEventListener('scroll', onScroll);
            viewport.removeEventListener('mousemove', onViewportMove);
            thumb.removeEventListener('mousedown', onThumbDown);
            document.removeEventListener('mousemove', onDrag);
            document.removeEventListener('mouseup', onUp);
            window.removeEventListener('resize', update);
            window.removeEventListener('transitionrun', onTransition);
            ro.disconnect();
            mo.disconnect();
            if (hideTimer) clearTimeout(hideTimer);
            thumb.remove();
            viewport.classList.remove('p3xr-os-viewport');
        },
        update,
    };
}

// Exclude third-party widgets that ship their own scrollbar behaviour — replacing
// their native scrollbar with ours tends to fight with their internal viewport
// sizing and focus management.
const EXCLUDE_SELECTORS = [
    '.cm-scroller',             // CodeMirror
    '.xterm-viewport',          // xterm.js (in case added later)
    '.monaco-scrollable-element', // Monaco
];

function isScrollableEl(el: HTMLElement): boolean {
    const style = getComputedStyle(el);
    const oy = style.overflowY;
    const ox = style.overflowX;
    if (!(oy === 'auto' || oy === 'scroll' || ox === 'auto' || ox === 'scroll')) return false;
    // Skip zero-sized elements (not yet laid out — will be caught on next scan)
    if (el.clientHeight === 0 && el.clientWidth === 0) return false;
    return true;
}

function shouldSkip(el: HTMLElement): boolean {
    if (el.classList.contains('p3xr-os-viewport')) return true;
    for (const sel of EXCLUDE_SELECTORS) {
        if (el.matches(sel) || el.closest(sel)) return true;
    }
    return false;
}

/**
 * Auto-attach the overlay scrollbar to every scrollable element in the document.
 * Watches for DOM mutations and attaches to new scrollable elements as they appear
 * (dialogs, menus, route changes). Prunes orphaned instances whose viewport has
 * been removed from the DOM. Call the returned function to uninstall.
 */
export function installOverlayScrolls(): () => void {
    const instances = new Map<HTMLElement, OverlayScrollInstance>();

    const tryAttach = (el: HTMLElement) => {
        if (instances.has(el)) return;
        if (shouldSkip(el)) return;
        if (!isScrollableEl(el)) return;
        instances.set(el, attachOverlayScroll(el));
    };

    const scanSubtree = (root: HTMLElement) => {
        tryAttach(root);
        root.querySelectorAll<HTMLElement>('*').forEach(tryAttach);
    };

    const prune = () => {
        for (const [el, inst] of instances) {
            if (!el.isConnected) {
                inst.destroy();
                instances.delete(el);
            }
        }
    };

    scanSubtree(document.body);

    const mo = new MutationObserver((mutations) => {
        let hadRemovals = false;
        for (const m of mutations) {
            m.addedNodes.forEach((n) => {
                if (n.nodeType === 1) scanSubtree(n as HTMLElement);
            });
            if (m.removedNodes.length > 0) hadRemovals = true;
            // Re-check the target too — overflow style may have changed.
            if (m.type === 'attributes' && m.target.nodeType === 1) {
                tryAttach(m.target as HTMLElement);
            }
        }
        if (hadRemovals) prune();
    });
    mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });

    // Periodic safety net in case a node is detached without firing a mutation
    // record we saw (some legacy libs manipulate documentFragment).
    const pruneInterval = setInterval(prune, 5000);

    return () => {
        mo.disconnect();
        clearInterval(pruneInterval);
        for (const inst of instances.values()) inst.destroy();
        instances.clear();
    };
}