diff --git a/surfsense_web/components/layout/hooks/useSidebarResize.ts b/surfsense_web/components/layout/hooks/useSidebarResize.ts index 35ef7bf8b..5df6be378 100644 --- a/surfsense_web/components/layout/hooks/useSidebarResize.ts +++ b/surfsense_web/components/layout/hooks/useSidebarResize.ts @@ -10,18 +10,36 @@ export const SIDEBAR_MAX_WIDTH = 480; interface UseSidebarResizeReturn { sidebarWidth: number; - handleMouseDown: (e: React.MouseEvent) => void; + handlePointerDown: (e: React.PointerEvent) => void; isDragging: boolean; } +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 = ""; + } +} + 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); + const widthRef = useRef(defaultWidth); + const pointerIdRef = useRef(null); + const captureTargetRef = useRef(null); - // Initialize from cookie on mount useEffect(() => { try { const match = document.cookie.match(/(?:^|; )sidebar_width=([^;]+)/); @@ -29,14 +47,13 @@ export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarRe const parsed = Number(match[1]); if (!Number.isNaN(parsed) && parsed >= SIDEBAR_MIN_WIDTH && parsed <= SIDEBAR_MAX_WIDTH) { setSidebarWidth(parsed); + widthRef.current = parsed; } } } catch { - // Ignore cookie read errors } }, []); - // Persist width to cookie const persistWidth = useCallback((width: number) => { try { // biome-ignore lint/suspicious/noDocumentCookie: SSR-readable preference, not security-sensitive @@ -46,57 +63,81 @@ export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarRe } }, []); - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - startXRef.current = e.clientX; - startWidthRef.current = sidebarWidth; - setIsDragging(true); + 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; + }, []); - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (e.pointerType === "mouse" && e.button !== 0) return; + + e.preventDefault(); + const target = e.currentTarget; + try { + target.setPointerCapture(e.pointerId); + } catch { + } + captureTargetRef.current = target; + pointerIdRef.current = e.pointerId; + startXRef.current = e.clientX; + startWidthRef.current = widthRef.current; + setIsDragging(true); + setGlobalDragCursor(true); }, - [sidebarWidth] + [] ); useEffect(() => { if (!isDragging) return; - const handleMouseMove = (e: MouseEvent) => { + const handlePointerMove = (e: PointerEvent) => { + if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return; const delta = e.clientX - startXRef.current; const newWidth = Math.min( SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta) ); - setSidebarWidth(newWidth); + if (newWidth !== widthRef.current) { + widthRef.current = newWidth; + setSidebarWidth(newWidth); + } }; - const handleMouseUp = () => { + const stop = (e: PointerEvent) => { + if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return; + releaseCapture(); setIsDragging(false); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - - // Persist the final width - setSidebarWidth((currentWidth) => { - persistWidth(currentWidth); - return currentWidth; - }); + setGlobalDragCursor(false); + persistWidth(widthRef.current); }; - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", stop); + window.addEventListener("pointercancel", stop); return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", stop); + window.removeEventListener("pointercancel", stop); + setGlobalDragCursor(false); + releaseCapture(); }; - }, [isDragging, persistWidth]); + }, [isDragging, persistWidth, releaseCapture]); return { sidebarWidth, - handleMouseDown, + handlePointerDown, isDragging, }; } diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 01bc4255c..1af8dcbc5 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -11,7 +11,11 @@ import type { InboxItem } from "@/hooks/use-inbox"; import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { SidebarProvider, useSidebarState } from "../../hooks"; -import { useSidebarResize } from "../../hooks/useSidebarResize"; +import { + SIDEBAR_MAX_WIDTH, + SIDEBAR_MIN_WIDTH, + useSidebarResize, +} from "../../hooks/useSidebarResize"; import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; import { Header } from "../header"; import { IconRail } from "../icon-rail"; @@ -25,7 +29,6 @@ import { MobileSidebarTrigger, Sidebar, } from "../sidebar"; -import { SidebarCollapseButton } from "../sidebar/SidebarCollapseButton"; import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel"; import { TabBar } from "../tabs/TabBar"; @@ -123,13 +126,11 @@ function MainContentPanel({ isChatPage, onTabSwitch, onNewChat, - leftActions, children, }: { isChatPage: boolean; onTabSwitch?: (tab: Tab) => void; onNewChat?: () => void; - leftActions?: React.ReactNode; children: React.ReactNode; }) { const activeTab = useAtomValue(activeTabAtom); @@ -140,7 +141,6 @@ function MainContentPanel({ } className="min-w-0" /> @@ -214,7 +214,7 @@ export function LayoutShell({ const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed); const { sidebarWidth, - handleMouseDown: onResizeMouseDown, + handlePointerDown: onResizePointerDown, isDragging: isResizing, } = useSidebarResize(); @@ -382,10 +382,7 @@ export function LayoutShell({ onSearchSpaceDelete={onSearchSpaceDelete} onSearchSpaceSettings={onSearchSpaceSettings} onAddSearchSpace={onAddSearchSpace} - isSingleRailMode={isCollapsed} - onNewChat={onNewChat} - navItems={navItems} - onNavItemClick={onNavItemClick} + isSingleRailMode={false} user={user} onUserSettings={onUserSettings} onAnnouncements={onAnnouncements} @@ -397,58 +394,54 @@ export function LayoutShell({ {/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */} -