Files
dbx/apps/desktop/src/composables/useTabScroll.ts
T

148 lines
5.0 KiB
TypeScript

import { onBeforeUnmount, ref, watch, type Ref } from "vue";
export function useTabScroll(tabsContainerRef: Ref<HTMLElement | null>) {
const hasTabOverflow = ref(false);
const scrollThumbLeftPercent = ref(0);
const scrollThumbWidthPercent = ref(100);
const isScrollbarDragging = ref(false);
let resizeObserver: ResizeObserver | null = null;
let updateFrame = 0;
let dragState: {
trackRect: DOMRect;
thumbOffsetPx: number;
} | null = null;
function updateScrollButtons() {
const el = tabsContainerRef.value;
if (!el) {
hasTabOverflow.value = false;
scrollThumbLeftPercent.value = 0;
scrollThumbWidthPercent.value = 100;
return;
}
const maxScrollLeft = Math.max(0, el.scrollWidth - el.clientWidth);
hasTabOverflow.value = maxScrollLeft > 1;
const rawThumbWidth = el.scrollWidth > 0 ? (el.clientWidth / el.scrollWidth) * 100 : 100;
const thumbWidth = Math.min(100, Math.max(8, rawThumbWidth));
const thumbTravel = Math.max(0, 100 - thumbWidth);
scrollThumbWidthPercent.value = thumbWidth;
scrollThumbLeftPercent.value = maxScrollLeft > 0 ? (el.scrollLeft / maxScrollLeft) * thumbTravel : 0;
}
function scheduleScrollButtonUpdate() {
if (updateFrame) return;
const update = () => {
updateFrame = 0;
updateScrollButtons();
};
if (typeof requestAnimationFrame === "function") {
updateFrame = requestAnimationFrame(update);
} else {
window.setTimeout(update, 0);
}
}
function onTabsWheel(event: WheelEvent) {
const el = tabsContainerRef.value;
if (!el) return;
const maxScrollLeft = Math.max(0, el.scrollWidth - el.clientWidth);
if (maxScrollLeft <= 1) return;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (delta === 0) return;
const previousScrollLeft = el.scrollLeft;
el.scrollLeft = Math.min(maxScrollLeft, Math.max(0, previousScrollLeft + delta));
if (el.scrollLeft !== previousScrollLeft) {
event.preventDefault();
updateScrollButtons();
}
}
function applyScrollbarDrag(clientX: number) {
const el = tabsContainerRef.value;
if (!el || !dragState) return;
const maxScrollLeft = Math.max(0, el.scrollWidth - el.clientWidth);
if (maxScrollLeft <= 1) return;
const thumbWidthPx = dragState.trackRect.width * (scrollThumbWidthPercent.value / 100);
const maxThumbLeftPx = Math.max(1, dragState.trackRect.width - thumbWidthPx);
const thumbLeftPx = Math.min(maxThumbLeftPx, Math.max(0, clientX - dragState.trackRect.left - dragState.thumbOffsetPx));
el.scrollLeft = (thumbLeftPx / maxThumbLeftPx) * maxScrollLeft;
updateScrollButtons();
}
function onScrollbarPointerMove(event: PointerEvent) {
if (!dragState) return;
event.preventDefault();
applyScrollbarDrag(event.clientX);
}
function stopScrollbarDrag() {
if (!dragState) return;
dragState = null;
isScrollbarDragging.value = false;
window.removeEventListener("pointermove", onScrollbarPointerMove, true);
window.removeEventListener("pointerup", stopScrollbarDrag, true);
window.removeEventListener("pointercancel", stopScrollbarDrag, true);
document.body.style.userSelect = "";
}
function startScrollbarDrag(event: PointerEvent) {
const el = tabsContainerRef.value;
const track = event.currentTarget as HTMLElement | null;
if (!el || !track || !hasTabOverflow.value) return;
const trackRect = track.getBoundingClientRect();
const thumbLeftPx = trackRect.width * (scrollThumbLeftPercent.value / 100);
const thumbWidthPx = trackRect.width * (scrollThumbWidthPercent.value / 100);
const pointerX = event.clientX - trackRect.left;
const pointerInsideThumb = pointerX >= thumbLeftPx && pointerX <= thumbLeftPx + thumbWidthPx;
dragState = {
trackRect,
thumbOffsetPx: pointerInsideThumb ? pointerX - thumbLeftPx : thumbWidthPx / 2,
};
isScrollbarDragging.value = true;
document.body.style.userSelect = "none";
window.addEventListener("pointermove", onScrollbarPointerMove, true);
window.addEventListener("pointerup", stopScrollbarDrag, true);
window.addEventListener("pointercancel", stopScrollbarDrag, true);
event.preventDefault();
applyScrollbarDrag(event.clientX);
}
watch(
tabsContainerRef,
(el) => {
resizeObserver?.disconnect();
resizeObserver = null;
if (el && typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(scheduleScrollButtonUpdate);
resizeObserver.observe(el);
}
scheduleScrollButtonUpdate();
},
{ flush: "post" },
);
onBeforeUnmount(() => {
resizeObserver?.disconnect();
stopScrollbarDrag();
if (updateFrame && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(updateFrame);
}
});
return {
hasTabOverflow,
scrollThumbLeftPercent,
scrollThumbWidthPercent,
isScrollbarDragging,
updateScrollButtons,
onTabsWheel,
startScrollbarDrag,
};
}