f5b632ed89
Reviewed-on: #397 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
365 行
10 KiB
TypeScript
365 行
10 KiB
TypeScript
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<Props> = ({
|
|
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<HTMLDivElement | null> (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<ReturnType<typeof setTimeout> | null> (null)
|
|
const pendingPressRef = useRef<PendingPressState | null> (null)
|
|
const resizeRef = useRef<ResizeState | null> (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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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 (
|
|
<motion.div
|
|
ref={rootRef}
|
|
layout={animationMode === 'off' ? false : 'position'}
|
|
transition={{ layout: layoutTransition }}
|
|
style={style}
|
|
className={cn (
|
|
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
|
|
className)}>
|
|
<Helmet>
|
|
<style>
|
|
{`
|
|
.sidebar
|
|
{
|
|
direction: rtl;
|
|
}
|
|
|
|
.sidebar > *
|
|
{
|
|
direction: ltr;
|
|
}`}
|
|
</style>
|
|
</Helmet>
|
|
|
|
<div className="h-full overflow-y-auto p-4 sidebar">
|
|
{children}
|
|
</div>
|
|
|
|
<div
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label="サイドバー幅"
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerEnd}
|
|
onPointerCancel={handlePointerEnd}
|
|
onDoubleClick={handleDoubleClick}
|
|
className={cn (
|
|
'hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
|
|
'touch-none select-none',
|
|
side === 'left' ? '-right-2' : '-left-2',
|
|
'before:absolute before:top-0 before:bottom-0 before:w-px',
|
|
'before:bg-neutral-500/0 hover:before:bg-neutral-500/30',
|
|
'before:transition-colors before:duration-150',
|
|
side === 'left'
|
|
? 'before:left-1/2'
|
|
: 'before:right-1/2',
|
|
resizing && 'before:bg-neutral-500/40')}/>
|
|
</motion.div>)
|
|
}
|
|
|
|
export default SidebarComponent
|