import { motion } from 'framer-motion' import { Helmet } from 'react-helmet-async' import { clientAnimationTransition } from '@/lib/clientAnimation' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react' import { useCallback, useEffect, useRef, useState } from 'react' type SidebarSide = 'left' | 'right' type Props = { children: ReactNode className?: string maxWidth?: number onWidthChange?: (width: number) => void sidebarKey: string side?: SidebarSide } type ResizeState = { pointerId: number startWidth: number startX: number side: SidebarSide } type PendingPressState = ResizeState & { startY: number } const DEFAULT_SIDEBAR_WIDTH = 256 const MIN_SIDEBAR_WIDTH = 192 const MIN_MAIN_WIDTH = 360 const MAX_VIEWPORT_WIDTH_RATIO = .8 const LONG_PRESS_DELAY_MS = 350 const TOUCH_MOVE_THRESHOLD_PX = 8 const storageKeyForSidebar = (sidebarKey: string, side: SidebarSide): string => `sidebar:${ sidebarKey }:${ side }:width` const getSidebarMaxWidth = (containerWidth: number): number => { const viewportWidth = typeof window === 'undefined' ? containerWidth : window.innerWidth const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO) const containerMaxWidth = Math.floor (containerWidth - MIN_MAIN_WIDTH) const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth) return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth) } const readStoredSidebarWidth = ( sidebarKey: string, side: SidebarSide): number => { if (typeof window === 'undefined') return DEFAULT_SIDEBAR_WIDTH const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side)) const width = value == null ? Number.NaN : Number (value) return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH } const SidebarComponent: FC = ({ children, className, maxWidth, onWidthChange, sidebarKey, side = 'left', }) => { const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const layoutTransition = clientAnimationTransition ( animationMode, { normal: { duration: .2, ease: 'easeOut' as const } }, ) const rootRef = useRef (null) const maxWidthRef = useRef (maxWidth) const getContainerWidth = useCallback (() => { const containerWidth = rootRef.current ?.closest ('[data-sidebar-container]') ?.clientWidth ?? window.innerWidth return containerWidth }, []) const getClampedWidth = useCallback (( nextWidth: number, containerWidth: number): number => { const viewportWidth = typeof window === 'undefined' ? containerWidth : window.innerWidth const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO) const maxAllowedWidth = maxWidthRef.current == null ? getSidebarMaxWidth (containerWidth) : Math.max ( MIN_SIDEBAR_WIDTH, Math.min (viewportMaxWidth, maxWidthRef.current)) return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth)) }, []) const [width, setWidth] = useState (() => ( typeof window === 'undefined' ? DEFAULT_SIDEBAR_WIDTH : getClampedWidth ( readStoredSidebarWidth (sidebarKey, side), window.innerWidth))) const [resizing, setResizing] = useState (false) const widthRef = useRef (width) const longPressTimerRef = useRef | null> (null) const pendingPressRef = useRef (null) const resizeRef = useRef (null) const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null) const clearLongPressTimer = useCallback (() => { if (longPressTimerRef.current == null) return clearTimeout (longPressTimerRef.current) longPressTimerRef.current = null }, []) const restoreBodyStyle = useCallback (() => { if (bodyStyleRef.current == null) return document.body.style.cursor = bodyStyleRef.current.cursor document.body.style.userSelect = bodyStyleRef.current.userSelect bodyStyleRef.current = null }, []) const startResize = useCallback ((state: ResizeState) => { clearLongPressTimer () pendingPressRef.current = null resizeRef.current = state setResizing (true) if (bodyStyleRef.current == null) { bodyStyleRef.current = { cursor: document.body.style.cursor, userSelect: document.body.style.userSelect } } document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' }, [clearLongPressTimer]) const finishResize = useCallback (() => { clearLongPressTimer () pendingPressRef.current = null const state = resizeRef.current resizeRef.current = null if (state != null) window.localStorage.setItem ( storageKeyForSidebar (sidebarKey, state.side), String (widthRef.current)) setResizing (false) restoreBodyStyle () }, [clearLongPressTimer, restoreBodyStyle, sidebarKey]) const applyWidth = useCallback ((nextWidth: number) => { if (nextWidth === widthRef.current) return widthRef.current = nextWidth setWidth (nextWidth) }, []) useEffect (() => { maxWidthRef.current = maxWidth const containerWidth = getContainerWidth () const nextWidth = getClampedWidth (widthRef.current, containerWidth) applyWidth (nextWidth) }, [applyWidth, getClampedWidth, getContainerWidth, maxWidth]) const updateWidth = useCallback ((clientX: number) => { const state = resizeRef.current if (state == null) return const containerWidth = getContainerWidth () const delta = state.side === 'left' ? clientX - state.startX : state.startX - clientX const nextWidth = getClampedWidth (state.startWidth + delta, containerWidth) applyWidth (nextWidth) }, [applyWidth, getClampedWidth, getContainerWidth]) const handlePointerDown = (ev: PointerEvent) => { if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch') return if (ev.pointerType === 'mouse' && ev.button !== 0) return ev.preventDefault () ev.stopPropagation () ev.currentTarget.setPointerCapture (ev.pointerId) const state = { pointerId: ev.pointerId, side, startWidth: widthRef.current, startX: ev.clientX } if (ev.pointerType === 'mouse') { startResize (state) return } pendingPressRef.current = { ...state, startY: ev.clientY } clearLongPressTimer () longPressTimerRef.current = setTimeout (() => { if (pendingPressRef.current != null) startResize (pendingPressRef.current) }, LONG_PRESS_DELAY_MS) } const handlePointerMove = (ev: PointerEvent) => { ev.stopPropagation () if (resizeRef.current != null) { ev.preventDefault () updateWidth (ev.clientX) return } const pendingPress = pendingPressRef.current if (pendingPress == null || pendingPress.pointerId !== ev.pointerId) return const deltaX = ev.clientX - pendingPress.startX const deltaY = ev.clientY - pendingPress.startY const distance = Math.hypot (deltaX, deltaY) if (distance >= TOUCH_MOVE_THRESHOLD_PX) { clearLongPressTimer () pendingPressRef.current = null } } const handlePointerEnd = (ev: PointerEvent) => { ev.stopPropagation () if (ev.currentTarget.hasPointerCapture (ev.pointerId)) ev.currentTarget.releasePointerCapture (ev.pointerId) finishResize () } const resetWidth = useCallback (() => { const containerWidth = getContainerWidth () const nextWidth = getClampedWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth) applyWidth (nextWidth) window.localStorage.removeItem (storageKeyForSidebar (sidebarKey, side)) }, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side]) const handleDoubleClick = (ev: MouseEvent) => { ev.preventDefault () ev.stopPropagation () resetWidth () } useEffect (() => { const containerWidth = getContainerWidth () const nextWidth = getClampedWidth ( readStoredSidebarWidth (sidebarKey, side), containerWidth) applyWidth (nextWidth) }, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side]) useEffect (() => { const containerWidth = getContainerWidth () const nextWidth = getClampedWidth (widthRef.current, containerWidth) applyWidth (nextWidth) }, [applyWidth, getClampedWidth, getContainerWidth]) useEffect (() => { if (typeof window === 'undefined') return const handleResize = () => { const containerWidth = getContainerWidth () const nextWidth = getClampedWidth (widthRef.current, containerWidth) applyWidth (nextWidth) } window.addEventListener ('resize', handleResize) return () => window.removeEventListener ('resize', handleResize) }, [applyWidth, getClampedWidth, getContainerWidth]) useEffect (() => { onWidthChange?.(width) }, [onWidthChange, width]) useEffect (() => () => { clearLongPressTimer () restoreBodyStyle () }, [clearLongPressTimer, restoreBodyStyle]) const style = { '--sidebar-width': `${ width }px` } as CSSProperties return (
{children}
) } export default SidebarComponent