panels-origin/web-panel/plugins/overlay-scrollbar.client.ts
2026-08-21 16:55:01 -05:00

273 lines
8.5 KiB
TypeScript

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<HTMLElement, OverlayApi>();
function bindAll(root: ParentNode = document) {
root.querySelectorAll<HTMLElement>(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();
}
});