type OverlayApi = { destroy: () => void; refresh: () => void; }; const SELECTOR = ".desk-detail-scroll, .desk-tree, .p-datatable-table-container"; const BOUND = "__overlayScrollBound"; function attachOverlayScrollbar(el: HTMLElement): OverlayApi { if ((el as HTMLElement & { [BOUND]?: boolean })[BOUND]) { return { destroy: () => undefined, refresh: () => undefined }; } (el as HTMLElement & { [BOUND]?: boolean })[BOUND] = true; const parent = el.parentElement; if (!parent) { delete (el as HTMLElement & { [BOUND]?: boolean })[BOUND]; return { destroy: () => undefined, refresh: () => undefined }; } const host = document.createElement("div"); host.className = "overlay-scroll-host"; parent.insertBefore(host, el); host.appendChild(el); const railY = document.createElement("div"); railY.className = "overlay-scroll-rail overlay-scroll-rail-y"; railY.setAttribute("aria-hidden", "true"); const thumbY = document.createElement("div"); thumbY.className = "overlay-scroll-thumb"; railY.appendChild(thumbY); const railX = document.createElement("div"); railX.className = "overlay-scroll-rail overlay-scroll-rail-x"; railX.setAttribute("aria-hidden", "true"); const thumbX = document.createElement("div"); thumbX.className = "overlay-scroll-thumb"; railX.appendChild(thumbX); host.appendChild(railY); host.appendChild(railX); let dragging: "x" | "y" | null = null; let dragStart = 0; let dragStartScroll = 0; let hideTimer = 0; let frame = 0; function show() { window.clearTimeout(hideTimer); host.classList.add("is-overlay-scroll-visible"); } function scheduleHide() { if (dragging) return; window.clearTimeout(hideTimer); hideTimer = window.setTimeout(() => { host.classList.remove("is-overlay-scroll-visible"); host.classList.remove("is-overlay-scroll-dragging"); }, 280); } function sync() { frame = 0; const viewH = el.clientHeight; const viewW = el.clientWidth; const totalH = el.scrollHeight; const totalW = el.scrollWidth; const maxY = totalH - viewH; const maxX = totalW - viewW; const both = maxY > 1 && maxX > 1; host.classList.toggle("has-overlay-scroll-both", both); if (maxY <= 1) { railY.style.visibility = "hidden"; } else { railY.style.visibility = ""; const railH = Math.max(0, railY.clientHeight); const thumbH = Math.max(24, Math.round((viewH / totalH) * railH)); const track = Math.max(1, railH - thumbH); const top = (el.scrollTop / Math.max(1, maxY)) * track; thumbY.style.height = `${thumbH}px`; thumbY.style.width = ""; thumbY.style.transform = `translateY(${top}px)`; } if (maxX <= 1) { railX.style.visibility = "hidden"; } else { railX.style.visibility = ""; const railW = Math.max(0, railX.clientWidth); const thumbW = Math.max(24, Math.round((viewW / totalW) * railW)); const track = Math.max(1, railW - thumbW); const left = (el.scrollLeft / Math.max(1, maxX)) * track; thumbX.style.width = `${thumbW}px`; thumbX.style.height = ""; thumbX.style.transform = `translateX(${left}px)`; } } function requestSync() { if (frame) return; frame = window.requestAnimationFrame(sync); } function onScroll() { show(); requestSync(); scheduleHide(); } function onPointerEnter() { show(); requestSync(); } function onPointerLeave() { scheduleHide(); } function bindThumb( axis: "x" | "y", thumb: HTMLElement, rail: HTMLElement, ) { function onThumbDown(event: PointerEvent) { event.preventDefault(); event.stopPropagation(); dragging = axis; dragStart = axis === "y" ? event.clientY : event.clientX; dragStartScroll = axis === "y" ? el.scrollTop : el.scrollLeft; host.classList.add("is-overlay-scroll-dragging"); thumb.setPointerCapture(event.pointerId); show(); } function onThumbMove(event: PointerEvent) { if (dragging !== axis) return; if (axis === "y") { const view = el.clientHeight; const total = el.scrollHeight; const maxScroll = Math.max(1, total - view); const railSize = Math.max(1, rail.clientHeight); const thumbSize = thumb.offsetHeight; const track = Math.max(1, railSize - thumbSize); const delta = event.clientY - dragStart; el.scrollTop = dragStartScroll + (delta / track) * maxScroll; } else { const view = el.clientWidth; const total = el.scrollWidth; const maxScroll = Math.max(1, total - view); const railSize = Math.max(1, rail.clientWidth); const thumbSize = thumb.offsetWidth; const track = Math.max(1, railSize - thumbSize); const delta = event.clientX - dragStart; el.scrollLeft = dragStartScroll + (delta / track) * maxScroll; } } function onThumbUp(event: PointerEvent) { if (dragging !== axis) return; dragging = null; host.classList.remove("is-overlay-scroll-dragging"); try { thumb.releasePointerCapture(event.pointerId); } catch { /* ignore */ } scheduleHide(); } function onRailDown(event: PointerEvent) { if (event.target !== rail) return; const rect = rail.getBoundingClientRect(); if (axis === "y") { const thumbSize = thumb.offsetHeight; const view = el.clientHeight; const total = el.scrollHeight; const maxScroll = Math.max(1, total - view); const railSize = Math.max(1, rail.clientHeight); const track = Math.max(1, railSize - thumbSize); const y = event.clientY - rect.top - thumbSize / 2; el.scrollTop = (Math.min(Math.max(y, 0), track) / track) * maxScroll; } else { const thumbSize = thumb.offsetWidth; const view = el.clientWidth; const total = el.scrollWidth; const maxScroll = Math.max(1, total - view); const railSize = Math.max(1, rail.clientWidth); const track = Math.max(1, railSize - thumbSize); const x = event.clientX - rect.left - thumbSize / 2; el.scrollLeft = (Math.min(Math.max(x, 0), track) / track) * maxScroll; } show(); } thumb.addEventListener("pointerdown", onThumbDown); thumb.addEventListener("pointermove", onThumbMove); thumb.addEventListener("pointerup", onThumbUp); thumb.addEventListener("pointercancel", onThumbUp); rail.addEventListener("pointerdown", onRailDown); } bindThumb("y", thumbY, railY); bindThumb("x", thumbX, railX); const resizeObserver = new ResizeObserver(() => requestSync()); resizeObserver.observe(el); resizeObserver.observe(host); el.addEventListener("scroll", onScroll, { passive: true }); host.addEventListener("pointerenter", onPointerEnter); host.addEventListener("pointerleave", onPointerLeave); requestSync(); return { refresh: requestSync, destroy() { window.clearTimeout(hideTimer); if (frame) window.cancelAnimationFrame(frame); resizeObserver.disconnect(); el.removeEventListener("scroll", onScroll); host.removeEventListener("pointerenter", onPointerEnter); host.removeEventListener("pointerleave", onPointerLeave); parent.insertBefore(el, host); host.remove(); delete (el as HTMLElement & { [BOUND]?: boolean })[BOUND]; }, }; } export default defineNuxtPlugin(() => { if (!import.meta.client) return; const apis = new Map(); function bindAll(root: ParentNode = document) { root.querySelectorAll(SELECTOR).forEach((el) => { if (apis.has(el)) { apis.get(el)?.refresh(); return; } apis.set(el, attachOverlayScrollbar(el)); }); } const mo = new MutationObserver((mutations) => { let needsBind = false; for (const mutation of mutations) { mutation.addedNodes.forEach((node) => { if (!(node instanceof HTMLElement)) return; if ( node.classList?.contains("overlay-scroll-host") || node.classList?.contains("overlay-scroll-rail") ) return; if (node.matches?.(SELECTOR) || node.querySelector?.(SELECTOR)) needsBind = true; }); } if (needsBind) bindAll(); }); const start = () => { bindAll(); mo.observe(document.body, { childList: true, subtree: true }); }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", start, { once: true }); } else { start(); } });