import { cloneElement, isValidElement, useCallback, useContext, useEffect, useRef, useState } from 'react' import PropTypes from 'prop-types' import { Layout, Splitter } from 'antd' import { useMediaQuery } from 'react-responsive' import { ElectronContext } from '../context/ElectronContext' const COLLAPSE_BELOW_PX = 150 const DEFAULT_SIDEBAR_PX = 300 const MAX_SIDEBAR_PX = 400 const COLLAPSED_WIDTH_PX = 80 const ELECTRON_COLLAPSED_WIDTH_PX = 55 const EXPAND_DRAG_PX = 10 const SIZE_TRANSITION_MS = 150 const COLLAPSE_STORAGE_KEY = 'DashboardSidebar_collapseState' const SIZE_STORAGE_KEY = 'DashboardSidebar_width' const readStoredBoolean = (key, fallback) => { try { const saved = sessionStorage.getItem(key) return saved ? JSON.parse(saved) : fallback } catch { return fallback } } const readStoredNumber = (key, fallback) => { try { const saved = sessionStorage.getItem(key) const value = saved ? JSON.parse(saved) : fallback return typeof value === 'number' && !Number.isNaN(value) ? value : fallback } catch { return fallback } } const clampExpandedSize = (size) => Math.min(MAX_SIDEBAR_PX, Math.max(size, COLLAPSE_BELOW_PX)) const DashboardSidebarSplitter = ({ sidebar, children }) => { const isMobile = useMediaQuery({ maxWidth: 768 }) const { isElectron } = useContext(ElectronContext) const collapsedWidth = isElectron ? ELECTRON_COLLAPSED_WIDTH_PX : COLLAPSED_WIDTH_PX const [collapsed, setCollapsed] = useState(() => readStoredBoolean(COLLAPSE_STORAGE_KEY, false) ) const [sidebarSize, setSidebarSize] = useState(() => { if (readStoredBoolean(COLLAPSE_STORAGE_KEY, false)) { return isElectron ? ELECTRON_COLLAPSED_WIDTH_PX : COLLAPSED_WIDTH_PX } return clampExpandedSize( readStoredNumber(SIZE_STORAGE_KEY, DEFAULT_SIDEBAR_PX) ) }) const collapsedRef = useRef(collapsed) collapsedRef.current = collapsed const lastExpandedSizeRef = useRef( clampExpandedSize(readStoredNumber(SIZE_STORAGE_KEY, DEFAULT_SIDEBAR_PX)) ) const sidebarSizeRef = useRef(sidebarSize) sidebarSizeRef.current = sidebarSize const collapseSizeRafRef = useRef([]) const [sizeTransitioning, setSizeTransitioning] = useState(false) const sizeTransitionTimeoutRef = useRef(null) const [resizeLocked, setResizeLocked] = useState(false) const resizeLockedRef = useRef(false) const suppressGestureRef = useRef(false) const lockUntilPointerUpRef = useRef(false) const lockTimeoutRef = useRef(null) const pointerUnlockRef = useRef(null) const ignoreResizeEndRef = useRef(false) const setResizeLockedState = useCallback((locked) => { resizeLockedRef.current = locked setResizeLocked(locked) }, []) const clearPointerUnlock = useCallback(() => { if (!pointerUnlockRef.current) return window.removeEventListener('pointerup', pointerUnlockRef.current) window.removeEventListener('pointercancel', pointerUnlockRef.current) pointerUnlockRef.current = null }, []) const attachPointerUnlock = useCallback(() => { if (pointerUnlockRef.current) return const handler = () => { clearPointerUnlock() suppressGestureRef.current = false if (lockUntilPointerUpRef.current) { lockUntilPointerUpRef.current = false setResizeLockedState(false) } } pointerUnlockRef.current = handler window.addEventListener('pointerup', handler) window.addEventListener('pointercancel', handler) }, [clearPointerUnlock, setResizeLockedState]) const startSizeTransition = useCallback(() => { setSizeTransitioning(true) if (sizeTransitionTimeoutRef.current) { clearTimeout(sizeTransitionTimeoutRef.current) } sizeTransitionTimeoutRef.current = setTimeout(() => { setSizeTransitioning(false) sizeTransitionTimeoutRef.current = null }, SIZE_TRANSITION_MS) }, []) const cancelCollapseSizeAnimation = useCallback(() => { collapseSizeRafRef.current.forEach((id) => cancelAnimationFrame(id)) collapseSizeRafRef.current = [] }, []) const animateCollapsedSize = useCallback(() => { cancelCollapseSizeAnimation() const ids = [] ids.push( requestAnimationFrame(() => { ids.push( requestAnimationFrame(() => { collapseSizeRafRef.current = [] setSidebarSize(collapsedWidth) }) ) }) ) collapseSizeRafRef.current = ids }, [cancelCollapseSizeAnimation, collapsedWidth]) const lockResizeUntilPointerUp = useCallback(() => { ignoreResizeEndRef.current = true suppressGestureRef.current = true lockUntilPointerUpRef.current = true setResizeLockedState(true) attachPointerUnlock() }, [attachPointerUnlock, setResizeLockedState]) const lockResizeFor = useCallback( (ms) => { ignoreResizeEndRef.current = true suppressGestureRef.current = true setResizeLockedState(true) attachPointerUnlock() if (lockTimeoutRef.current) { clearTimeout(lockTimeoutRef.current) } lockTimeoutRef.current = setTimeout(() => { lockTimeoutRef.current = null if (!lockUntilPointerUpRef.current) { setResizeLockedState(false) } }, ms) }, [attachPointerUnlock, setResizeLockedState] ) useEffect( () => () => { if (sizeTransitionTimeoutRef.current) { clearTimeout(sizeTransitionTimeoutRef.current) } if (lockTimeoutRef.current) { clearTimeout(lockTimeoutRef.current) } cancelCollapseSizeAnimation() clearPointerUnlock() }, [cancelCollapseSizeAnimation, clearPointerUnlock] ) const persistCollapsed = (nextCollapsed) => { sessionStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(nextCollapsed)) } const persistSize = (size) => { sessionStorage.setItem(SIZE_STORAGE_KEY, JSON.stringify(size)) } const collapseSidebar = useCallback( (expandedSize) => { if (expandedSize >= COLLAPSE_BELOW_PX) { lastExpandedSizeRef.current = expandedSize persistSize(expandedSize) } collapsedRef.current = true persistCollapsed(true) startSizeTransition() setSidebarSize(sidebarSizeRef.current) setCollapsed(true) animateCollapsedSize() }, [animateCollapsedSize, startSizeTransition] ) const expandSidebar = useCallback(() => { cancelCollapseSizeAnimation() collapsedRef.current = false setCollapsed(false) persistCollapsed(false) startSizeTransition() setSidebarSize(DEFAULT_SIDEBAR_PX) persistSize(DEFAULT_SIDEBAR_PX) lastExpandedSizeRef.current = DEFAULT_SIDEBAR_PX }, [cancelCollapseSizeAnimation, startSizeTransition]) const handleSidebarCollapse = useCallback( (nextCollapsed) => { if (nextCollapsed) { collapseSidebar(sidebarSize) } else { expandSidebar() } lockResizeFor(SIZE_TRANSITION_MS) }, [collapseSidebar, expandSidebar, lockResizeFor, sidebarSize] ) const handleResizeStart = useCallback(() => { if (!resizeLockedRef.current) { ignoreResizeEndRef.current = false } }, []) const handleResize = useCallback( (sizes) => { if (resizeLockedRef.current || suppressGestureRef.current) { if (collapsedRef.current) { setSidebarSize(collapsedWidth) } return } const size = sizes[0] if (collapsedRef.current) { if (size - collapsedWidth > EXPAND_DRAG_PX) { expandSidebar() lockResizeUntilPointerUp() return } setSidebarSize(Math.max(collapsedWidth, size)) return } if (size < COLLAPSE_BELOW_PX) { collapseSidebar(lastExpandedSizeRef.current) lockResizeUntilPointerUp() return } setSidebarSize(size) lastExpandedSizeRef.current = clampExpandedSize(size) }, [ collapseSidebar, collapsedWidth, expandSidebar, lockResizeUntilPointerUp ] ) const handleResizeEnd = useCallback( (sizes) => { if ( resizeLockedRef.current || suppressGestureRef.current || ignoreResizeEndRef.current ) { ignoreResizeEndRef.current = false if (collapsedRef.current) { startSizeTransition() setSidebarSize(collapsedWidth) } return } if (collapsedRef.current) { if (sizes[0] - collapsedWidth > EXPAND_DRAG_PX) { expandSidebar() lockResizeFor(SIZE_TRANSITION_MS) } else { startSizeTransition() setSidebarSize(collapsedWidth) } return } const next = clampExpandedSize(sizes[0]) lastExpandedSizeRef.current = next setSidebarSize(next) persistSize(next) }, [collapsedWidth, expandSidebar, lockResizeFor, startSizeTransition] ) const sidebarNode = isValidElement(sidebar) ? cloneElement(sidebar, { collapsed, collapsedWidth, onCollapse: handleSidebarCollapse }) : sidebar if (isMobile) { return ( {sidebarNode} {children} ) } return ( {sidebarNode} {children} ) } DashboardSidebarSplitter.propTypes = { sidebar: PropTypes.node.isRequired, children: PropTypes.node.isRequired } export default DashboardSidebarSplitter