2026-02-09 10:48:43 -05:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
|
|
|
|
|
|
const SIDEBAR_WIDTH_COOKIE_NAME = "sidebar_width";
|
|
|
|
|
const SIDEBAR_WIDTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
|
|
|
|
|
|
|
|
|
export const SIDEBAR_MIN_WIDTH = 240;
|
|
|
|
|
export const SIDEBAR_MAX_WIDTH = 480;
|
|
|
|
|
|
|
|
|
|
interface UseSidebarResizeReturn {
|
|
|
|
|
sidebarWidth: number;
|
2026-05-04 01:47:17 +05:30
|
|
|
handlePointerDown: (e: React.PointerEvent<HTMLElement>) => void;
|
2026-02-09 10:48:43 -05:00
|
|
|
isDragging: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 01:47:17 +05:30
|
|
|
function setGlobalDragCursor(active: boolean) {
|
|
|
|
|
const html = document.documentElement;
|
|
|
|
|
const body = document.body;
|
|
|
|
|
if (active) {
|
|
|
|
|
html.style.cursor = "col-resize";
|
|
|
|
|
body.style.cursor = "col-resize";
|
|
|
|
|
html.style.userSelect = "none";
|
|
|
|
|
body.style.userSelect = "none";
|
|
|
|
|
} else {
|
|
|
|
|
html.style.cursor = "";
|
|
|
|
|
body.style.cursor = "";
|
|
|
|
|
html.style.userSelect = "";
|
|
|
|
|
body.style.userSelect = "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 10:48:43 -05:00
|
|
|
export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarResizeReturn {
|
|
|
|
|
const [sidebarWidth, setSidebarWidth] = useState(defaultWidth);
|
|
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
|
|
|
|
|
|
const startXRef = useRef(0);
|
|
|
|
|
const startWidthRef = useRef(defaultWidth);
|
2026-05-04 01:47:17 +05:30
|
|
|
const widthRef = useRef(defaultWidth);
|
|
|
|
|
const pointerIdRef = useRef<number | null>(null);
|
|
|
|
|
const captureTargetRef = useRef<HTMLElement | null>(null);
|
2026-02-09 10:48:43 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
try {
|
|
|
|
|
const match = document.cookie.match(/(?:^|; )sidebar_width=([^;]+)/);
|
|
|
|
|
if (match) {
|
|
|
|
|
const parsed = Number(match[1]);
|
|
|
|
|
if (!Number.isNaN(parsed) && parsed >= SIDEBAR_MIN_WIDTH && parsed <= SIDEBAR_MAX_WIDTH) {
|
|
|
|
|
setSidebarWidth(parsed);
|
2026-05-04 01:47:17 +05:30
|
|
|
widthRef.current = parsed;
|
2026-02-09 10:48:43 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const persistWidth = useCallback((width: number) => {
|
|
|
|
|
try {
|
2026-05-02 14:01:33 +05:30
|
|
|
// biome-ignore lint/suspicious/noDocumentCookie: SSR-readable preference, not security-sensitive
|
2026-02-09 10:48:43 -05:00
|
|
|
document.cookie = `${SIDEBAR_WIDTH_COOKIE_NAME}=${width}; path=/; max-age=${SIDEBAR_WIDTH_COOKIE_MAX_AGE}`;
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore cookie write errors
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-04 01:47:17 +05:30
|
|
|
const releaseCapture = useCallback(() => {
|
|
|
|
|
const target = captureTargetRef.current;
|
|
|
|
|
const pointerId = pointerIdRef.current;
|
|
|
|
|
if (target && pointerId !== null) {
|
|
|
|
|
try {
|
|
|
|
|
if (target.hasPointerCapture(pointerId)) {
|
|
|
|
|
target.releasePointerCapture(pointerId);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
captureTargetRef.current = null;
|
|
|
|
|
pointerIdRef.current = null;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handlePointerDown = useCallback(
|
|
|
|
|
(e: React.PointerEvent<HTMLElement>) => {
|
|
|
|
|
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
|
|
|
|
2026-02-09 10:48:43 -05:00
|
|
|
e.preventDefault();
|
2026-05-04 01:47:17 +05:30
|
|
|
const target = e.currentTarget;
|
|
|
|
|
try {
|
|
|
|
|
target.setPointerCapture(e.pointerId);
|
|
|
|
|
} catch {
|
|
|
|
|
}
|
|
|
|
|
captureTargetRef.current = target;
|
|
|
|
|
pointerIdRef.current = e.pointerId;
|
2026-02-09 10:48:43 -05:00
|
|
|
startXRef.current = e.clientX;
|
2026-05-04 01:47:17 +05:30
|
|
|
startWidthRef.current = widthRef.current;
|
2026-02-09 10:48:43 -05:00
|
|
|
setIsDragging(true);
|
2026-05-04 01:47:17 +05:30
|
|
|
setGlobalDragCursor(true);
|
2026-02-09 10:48:43 -05:00
|
|
|
},
|
2026-05-04 01:47:17 +05:30
|
|
|
[]
|
2026-02-09 10:48:43 -05:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isDragging) return;
|
|
|
|
|
|
2026-05-04 01:47:17 +05:30
|
|
|
const handlePointerMove = (e: PointerEvent) => {
|
|
|
|
|
if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return;
|
2026-02-09 10:48:43 -05:00
|
|
|
const delta = e.clientX - startXRef.current;
|
|
|
|
|
const newWidth = Math.min(
|
|
|
|
|
SIDEBAR_MAX_WIDTH,
|
|
|
|
|
Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
|
|
|
|
|
);
|
2026-05-04 01:47:17 +05:30
|
|
|
if (newWidth !== widthRef.current) {
|
|
|
|
|
widthRef.current = newWidth;
|
|
|
|
|
setSidebarWidth(newWidth);
|
|
|
|
|
}
|
2026-02-09 10:48:43 -05:00
|
|
|
};
|
|
|
|
|
|
2026-05-04 01:47:17 +05:30
|
|
|
const stop = (e: PointerEvent) => {
|
|
|
|
|
if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return;
|
|
|
|
|
releaseCapture();
|
2026-02-09 10:48:43 -05:00
|
|
|
setIsDragging(false);
|
2026-05-04 01:47:17 +05:30
|
|
|
setGlobalDragCursor(false);
|
|
|
|
|
persistWidth(widthRef.current);
|
2026-02-09 10:48:43 -05:00
|
|
|
};
|
|
|
|
|
|
2026-05-04 01:47:17 +05:30
|
|
|
window.addEventListener("pointermove", handlePointerMove);
|
|
|
|
|
window.addEventListener("pointerup", stop);
|
|
|
|
|
window.addEventListener("pointercancel", stop);
|
2026-02-09 10:48:43 -05:00
|
|
|
|
|
|
|
|
return () => {
|
2026-05-04 01:47:17 +05:30
|
|
|
window.removeEventListener("pointermove", handlePointerMove);
|
|
|
|
|
window.removeEventListener("pointerup", stop);
|
|
|
|
|
window.removeEventListener("pointercancel", stop);
|
|
|
|
|
setGlobalDragCursor(false);
|
|
|
|
|
releaseCapture();
|
2026-02-09 10:48:43 -05:00
|
|
|
};
|
2026-05-04 01:47:17 +05:30
|
|
|
}, [isDragging, persistWidth, releaseCapture]);
|
2026-02-09 10:48:43 -05:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
sidebarWidth,
|
2026-05-04 01:47:17 +05:30
|
|
|
handlePointerDown,
|
2026-02-09 10:48:43 -05:00
|
|
|
isDragging,
|
|
|
|
|
};
|
|
|
|
|
}
|