サイド・バーの幅を可変に (#385) #396

マージ済み
みてるぞ が 4 個のコミットを feature/385 から main へマージ 2026-07-05 03:38:01 +09:00
5個のファイルの変更396行の追加44行の削除
コミット e0aa14f0b5 の変更だけを表示してゐます - すべてのコミットを表示
+1 -1
ファイルの表示
@@ -489,7 +489,7 @@ const MaterialSidebar: FC = () => {
</div> </div>
<div className="hidden md:block"> <div className="hidden md:block">
<SidebarComponent> <SidebarComponent sidebarKey="materials">
<div className="space-y-4"> <div className="space-y-4">
<MaterialFilterButtons <MaterialFilterButtons
materialFilter={materialFilter} materialFilter={materialFilter}
+1 -1
ファイルの表示
@@ -278,7 +278,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
}, [baseTags]) }, [baseTags])
return ( return (
<SidebarComponent> <SidebarComponent sidebarKey="post-detail-tags">
<TagSearch/> <TagSearch/>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
+1 -1
ファイルの表示
@@ -96,7 +96,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
</>) </>)
return ( return (
<SidebarComponent> <SidebarComponent sidebarKey="posts-index-tags">
<TagSearch/> <TagSearch/>
<div className="hidden md:block mt-4"> <div className="hidden md:block mt-4">
+316 -4
ファイルの表示
@@ -1,16 +1,304 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import type { FC, ReactNode } from 'react' import { cn } from '@/lib/utils'
type Props = { children: ReactNode } 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 SidebarComponent: FC<Props> = ({ children }) => ( 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 clampSidebarWidth = (width: number, containerWidth: number): number =>
Math.min (getSidebarMaxWidth (containerWidth), Math.max (MIN_SIDEBAR_WIDTH, width))
const readStoredSidebarWidth = (
sidebarKey: string,
side: SidebarSide,
containerWidth: number): 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)
? clampSidebarWidth (width, containerWidth)
: clampSidebarWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth)
}
const SidebarComponent: FC<Props> = ({
children,
className,
maxWidth,
onWidthChange,
sidebarKey,
side = 'left',
}) => {
const rootRef = useRef<HTMLDivElement | null> (null)
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 dynamicMaxWidth = getSidebarMaxWidth (containerWidth)
const maxAllowedWidth =
maxWidth == null
? dynamicMaxWidth
: Math.max (MIN_SIDEBAR_WIDTH, Math.min (dynamicMaxWidth, maxWidth))
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
}, [maxWidth])
const [width, setWidth] = useState (() => (
typeof window === 'undefined'
? DEFAULT_SIDEBAR_WIDTH
: getClampedWidth (
readStoredSidebarWidth (sidebarKey, side, window.innerWidth),
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 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)
widthRef.current = nextWidth
setWidth (nextWidth)
}, [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)
widthRef.current = nextWidth
setWidth (nextWidth)
window.localStorage.removeItem (storageKeyForSidebar (sidebarKey, side))
}, [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),
containerWidth)
widthRef.current = nextWidth
setWidth (nextWidth)
}, [getClampedWidth, getContainerWidth, sidebarKey, side])
useEffect (() => {
if (typeof window === 'undefined')
return
const handleResize = () => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
if (nextWidth === widthRef.current)
return
widthRef.current = nextWidth
setWidth (nextWidth)
}
window.addEventListener ('resize', handleResize)
return () => window.removeEventListener ('resize', handleResize)
}, [getClampedWidth, getContainerWidth])
useEffect (() => {
onWidthChange?.(width)
}, [onWidthChange, width])
useEffect (() => () => {
clearLongPressTimer ()
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle])
const style = {
'--sidebar-width': `${ width }px` } as CSSProperties
return (
<motion.div <motion.div
ref={rootRef}
layout="position" layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="p-4 w-full md:w-64 md:h-full md:overflow-y-auto sidebar"> style={style}
className={cn (
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
className)}>
<Helmet> <Helmet>
<style> <style>
{` {`
@@ -26,7 +314,31 @@ const SidebarComponent: FC<Props> = ({ children }) => (
</style> </style>
</Helmet> </Helmet>
<div className="h-full overflow-y-auto p-4 sidebar">
{children} {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>) </motion.div>)
}
export default SidebarComponent export default SidebarComponent
+63 -23
ファイルの表示
@@ -10,6 +10,7 @@ import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import { useDialogue } from '@/components/dialogues/DialogueProvider' import { useDialogue } from '@/components/dialogues/DialogueProvider'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
@@ -52,6 +53,8 @@ const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
const LAYOUT_STORAGE_KEY = 'theatre-layout-mode' const LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
const MIN_MAIN_WIDTH = 360
const SIDEBAR_GAP_WIDTH = 16
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = { const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
threeColumns: '3 列', threeColumns: '3 列',
@@ -211,11 +214,16 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const commentsRef = useRef<HTMLDivElement> (null) const commentsRef = useRef<HTMLDivElement> (null)
const embedRef = useRef<NiconicoViewerHandle> (null) const embedRef = useRef<NiconicoViewerHandle> (null)
const loadingRef = useRef (false) const loadingRef = useRef (false)
const sidebarContainerRef = useRef<HTMLDivElement> (null)
const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO) const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO)
const theatreInfoReceivedAtRef = useRef (performance.now ()) const theatreInfoReceivedAtRef = useRef (performance.now ())
const videoLengthRef = useRef (0) const videoLengthRef = useRef (0)
const lastCommentNoRef = useRef (0) const lastCommentNoRef = useRef (0)
const [leftSidebarWidth, setLeftSidebarWidth] = useState (256)
const [rightSidebarWidth, setRightSidebarWidth] = useState (256)
const [sidebarContainerWidth, setSidebarContainerWidth] = useState (
() => typeof window === 'undefined' ? 0 : window.innerWidth)
const [comments, setComments] = useState<TheatreComment[]> ([]) const [comments, setComments] = useState<TheatreComment[]> ([])
const [content, setContent] = useState ('') const [content, setContent] = useState ('')
const [editingPost, setEditingPost] = useState<Post | null> (null) const [editingPost, setEditingPost] = useState<Post | null> (null)
@@ -324,6 +332,17 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
lastCommentNoRef.current = comments[0]?.no ?? 0 lastCommentNoRef.current = comments[0]?.no ?? 0
}, [comments]) }, [comments])
useEffect (() => {
const updateSidebarContainerWidth = () => {
const nextWidth = sidebarContainerRef.current?.clientWidth ?? window.innerWidth
setSidebarContainerWidth (nextWidth)
}
updateSidebarContainerWidth ()
window.addEventListener ('resize', updateSidebarContainerWidth)
return () => window.removeEventListener ('resize', updateSidebarContainerWidth)
}, [])
useEffect (() => { useEffect (() => {
if (!(id)) if (!(id))
return return
@@ -639,6 +658,26 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (status >= 400) if (status >= 400)
return <ErrorScreen status={status}/> return <ErrorScreen status={status}/>
const singleSidebarMaxWidth = Math.max (
192,
sidebarContainerWidth - MIN_MAIN_WIDTH - SIDEBAR_GAP_WIDTH)
const leftSidebarMaxWidth = layoutMode === 'threeColumns'
? Math.max (
192,
sidebarContainerWidth
- rightSidebarWidth
- MIN_MAIN_WIDTH
- SIDEBAR_GAP_WIDTH * 2)
: singleSidebarMaxWidth
const rightSidebarMaxWidth = layoutMode === 'threeColumns'
? Math.max (
192,
sidebarContainerWidth
- leftSidebarWidth
- MIN_MAIN_WIDTH
- SIDEBAR_GAP_WIDTH * 2)
: singleSidebarMaxWidth
const tagPanel = ( const tagPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800"> <section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
@@ -806,30 +845,26 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>} {theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
</Helmet> </Helmet>
<div className={cn ( <div
'grid min-h-full gap-4 overflow-visible md:h-full md:overflow-hidden', ref={sidebarContainerRef}
(layoutMode === 'threeColumns' data-sidebar-container
&& ['md:grid-cols-[16rem_minmax(0,1fr)_22rem]', className="flex min-h-full flex-col gap-4 overflow-visible md:h-full md:flex-row
'xl:grid-cols-[18rem_minmax(0,1fr)_24rem]']), md:overflow-hidden">
(layoutMode === 'tagsBottom'
&& 'md:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_24rem]'),
(layoutMode === 'commentsBottom'
&& 'md:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[18rem_minmax(0,1fr)]'))}>
{layoutMode !== 'tagsBottom' && ( {layoutMode !== 'tagsBottom' && (
<motion.aside <SidebarComponent
layout="position" sidebarKey="theatre-tags"
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto side="left"
md:[direction:rtl]"> maxWidth={leftSidebarMaxWidth}
<div className="md:[direction:ltr]"> onWidthChange={setLeftSidebarWidth}
className="hidden md:block">
{tagPanel} {tagPanel}
</div> </SidebarComponent>)}
</motion.aside>)}
<motion.main <motion.main
layout="position" layout="position"
className={cn ('order-1 min-w-0 space-y-4 md:order-none md:overflow-y-auto', className="order-1 min-w-0 flex-1 space-y-4 md:order-none
layoutMode === 'tagsBottom' && 'md:[direction:rtl]')}> md:min-w-[360px] md:overflow-y-auto">
<div className={cn ('space-y-4', layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}> <div className="space-y-4">
<section className="overflow-hidden rounded border-zinc-300 <section className="overflow-hidden rounded border-zinc-300
dark:border-zinc-800"> dark:border-zinc-800">
<div className="flex flex-wrap items-center justify-between gap-3 <div className="flex flex-wrap items-center justify-between gap-3
@@ -965,12 +1000,17 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</motion.main> </motion.main>
{layoutMode !== 'commentsBottom' && ( {layoutMode !== 'commentsBottom' && (
<motion.aside <SidebarComponent
layout="position" sidebarKey="theatre-comments"
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto"> side="right"
maxWidth={rightSidebarMaxWidth}
onWidthChange={setRightSidebarWidth}
className="hidden md:block">
<div className="space-y-4">
{commentsPanel} {commentsPanel}
{participantsPanel} {participantsPanel}
</motion.aside>)} </div>
</SidebarComponent>)}
</div> </div>
</motion.div>) </motion.div>)
} }