370 lines
10 KiB
JavaScript
370 lines
10 KiB
JavaScript
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 (
|
|
<Layout>
|
|
{sidebarNode}
|
|
{children}
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Layout style={{ height: '100%', minHeight: 0 }}>
|
|
<Splitter
|
|
className={`farmcontrol-splitter dashboard-splitter${
|
|
sizeTransitioning ? ' is-size-transitioning' : ''
|
|
}${collapsed ? ' is-sidebar-collapsed' : ''}`}
|
|
style={{ height: '100%' }}
|
|
onResizeStart={handleResizeStart}
|
|
onResize={handleResize}
|
|
onResizeEnd={handleResizeEnd}
|
|
>
|
|
<Splitter.Panel
|
|
className={`dashboard-splitter-sidebar-panel${
|
|
collapsed ? ' is-collapsed' : ''
|
|
}`}
|
|
size={sidebarSize}
|
|
min={collapsedWidth}
|
|
max={
|
|
collapsed && resizeLocked && !sizeTransitioning
|
|
? collapsedWidth
|
|
: MAX_SIDEBAR_PX
|
|
}
|
|
resizable={!resizeLocked}
|
|
style={{
|
|
overflow: 'visible',
|
|
'--dashboard-splitter-sidebar-size': `${sidebarSize}px`
|
|
}}
|
|
>
|
|
{sidebarNode}
|
|
</Splitter.Panel>
|
|
<Splitter.Panel min={320} className='dashboard-splitter-content-panel'>
|
|
{children}
|
|
</Splitter.Panel>
|
|
</Splitter>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
DashboardSidebarSplitter.propTypes = {
|
|
sidebar: PropTypes.node.isRequired,
|
|
children: PropTypes.node.isRequired
|
|
}
|
|
|
|
export default DashboardSidebarSplitter
|