@@ -228,8 +228,6 @@ const TOC_BAR_HEIGHT = 3.5
228228const TOC_SECTION_WIDTH = 38
229229const TOC_SUBSECTION_WIDTH = 22
230230const TOC_BAR_GAP = 12
231- const TOC_PERSPECTIVE = 200
232- const TOC_MAX_ROTATE_DEG = 60
233231const TOC_STRIP_WIDTH = 56
234232
235233/** Scroll main content to a TOC entry (section or subsection). */
@@ -270,6 +268,10 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
270268 const [ isHoveringStrip , setIsHoveringStrip ] = useState ( false )
271269 const rafRef = useRef < number > ( 0 )
272270 const wheelAccRef = useRef ( 0 )
271+ const [ clickedIdx , setClickedIdx ] = useState < number | null > ( null )
272+ const scrollTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
273+ const dragRef = useRef < { startY : number ; startIdx : number } | null > ( null )
274+ const dragIdxRef = useRef < number | null > ( null )
273275
274276 /* Compute fractional index from main content scroll position */
275277 useEffect ( ( ) => {
@@ -310,23 +312,29 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
310312 const onScroll = ( ) => {
311313 cancelAnimationFrame ( rafRef . current )
312314 rafRef . current = requestAnimationFrame ( computeFractional )
315+ /* Clear click lock after scroll animation settles */
316+ if ( scrollTimeoutRef . current ) clearTimeout ( scrollTimeoutRef . current )
317+ scrollTimeoutRef . current = setTimeout ( ( ) => setClickedIdx ( null ) , 600 )
313318 }
314319
315320 computeFractional ( )
316321 container . addEventListener ( "scroll" , onScroll , { passive : true } )
317322 return ( ) => {
318323 container . removeEventListener ( "scroll" , onScroll )
319324 cancelAnimationFrame ( rafRef . current )
325+ if ( scrollTimeoutRef . current ) clearTimeout ( scrollTimeoutRef . current )
320326 }
321327 } , [ entries , contentScrollRef ] )
322328
323329 const activeEntryIndex = useMemo ( ( ) => {
324330 if ( ! activeEntryId ) return - 1
325331 return entries . findIndex ( ( entry ) => entry . id === activeEntryId )
326332 } , [ activeEntryId , entries ] )
327- const visualFractionalIndex = activeEntryIndex >= 0 ? activeEntryIndex : fractionalIndex
333+ const visualFractionalIndex = clickedIdx !== null ? clickedIdx : activeEntryIndex >= 0 ? activeEntryIndex : fractionalIndex
328334
329- const handleBarClick = useCallback ( ( entry : TocEntry ) => {
335+ const handleBarClick = useCallback ( ( entry : TocEntry , idx : number ) => {
336+ setClickedIdx ( idx )
337+ if ( scrollTimeoutRef . current ) clearTimeout ( scrollTimeoutRef . current )
330338 scrollContentToEntry ( entry , contentScrollRef )
331339 } , [ contentScrollRef ] )
332340
@@ -338,13 +346,42 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
338346 if ( Math . abs ( wheelAccRef . current ) >= threshold ) {
339347 const direction = wheelAccRef . current > 0 ? 1 : - 1
340348 wheelAccRef . current = 0
341- const currentIdx = Math . round ( visualFractionalIndex )
349+ const currentIdx = clickedIdx !== null ? clickedIdx : Math . round ( visualFractionalIndex )
342350 const nextIdx = Math . max ( 0 , Math . min ( entries . length - 1 , currentIdx + direction ) )
343351 if ( entries [ nextIdx ] ) {
352+ setClickedIdx ( nextIdx )
353+ if ( scrollTimeoutRef . current ) clearTimeout ( scrollTimeoutRef . current )
344354 scrollContentToEntry ( entries [ nextIdx ] , contentScrollRef )
345355 }
346356 }
347- } , [ visualFractionalIndex , entries , contentScrollRef ] )
357+ } , [ visualFractionalIndex , clickedIdx , entries , contentScrollRef ] )
358+
359+ /* iOS picker drag: pointer down/move/up on strip to scroll through entries */
360+ const handleDragStart = useCallback ( ( e : React . PointerEvent ) => {
361+ const idx = clickedIdx ?? Math . round ( fractionalIndex )
362+ dragRef . current = { startY : e . clientY , startIdx : idx }
363+ dragIdxRef . current = idx
364+ ; ( e . currentTarget as HTMLElement ) . setPointerCapture ( e . pointerId )
365+ } , [ clickedIdx , fractionalIndex ] )
366+
367+ const handleDragMove = useCallback ( ( e : React . PointerEvent ) => {
368+ if ( ! dragRef . current ) return
369+ const deltaY = e . clientY - dragRef . current . startY
370+ if ( Math . abs ( deltaY ) < 5 ) return
371+ const step = TOC_BAR_HEIGHT + TOC_BAR_GAP
372+ const deltaIdx = Math . round ( - deltaY / step )
373+ const newIdx = Math . max ( 0 , Math . min ( entries . length - 1 , dragRef . current . startIdx + deltaIdx ) )
374+ if ( entries [ newIdx ] && newIdx !== dragIdxRef . current ) {
375+ dragIdxRef . current = newIdx
376+ setClickedIdx ( newIdx )
377+ scrollContentToEntry ( entries [ newIdx ] , contentScrollRef )
378+ }
379+ } , [ entries , contentScrollRef ] )
380+
381+ const handleDragEnd = useCallback ( ( ) => {
382+ dragRef . current = null
383+ dragIdxRef . current = null
384+ } , [ ] )
348385
349386 if ( entries . length < 2 ) return null
350387
@@ -376,6 +413,9 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
376413 onMouseEnter = { ( ) => setIsHoveringStrip ( true ) }
377414 onMouseLeave = { ( ) => { setIsHoveringStrip ( false ) ; setHoveredIdx ( null ) } }
378415 onWheel = { handleWheel }
416+ onPointerDown = { handleDragStart }
417+ onPointerMove = { handleDragMove }
418+ onPointerUp = { handleDragEnd }
379419 style = { {
380420 position : "fixed" ,
381421 right : 0 ,
@@ -400,7 +440,7 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
400440 boxShadow : stripShadow ,
401441 backdropFilter : "blur(16px)" ,
402442 WebkitBackdropFilter : "blur(16px)" ,
403- opacity : isHoveringStrip ? 1 : 0 ,
443+ opacity : isHoveringStrip ? 1 : 0.5 ,
404444 transition : "opacity 0.3s ease" ,
405445 pointerEvents : "none" ,
406446 } }
@@ -451,7 +491,7 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
451491 transition : "transform 0.4s cubic-bezier(0.23,1,0.32,1)" ,
452492 cursor : "pointer" ,
453493 } }
454- onClick = { ( ) => handleBarClick ( entry ) }
494+ onClick = { ( ) => handleBarClick ( entry , idx ) }
455495 onMouseEnter = { ( ) => setHoveredIdx ( idx ) }
456496 onMouseLeave = { ( ) => setHoveredIdx ( null ) }
457497 onMouseDown = { ( e ) => e . preventDefault ( ) }
@@ -468,8 +508,7 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
468508 right : 0 ,
469509 bottom : 0 ,
470510 width : TOC_STRIP_WIDTH - 8 ,
471- transformStyle : "preserve-3d" ,
472- perspective : TOC_PERSPECTIVE ,
511+ overflow : "hidden" ,
473512 pointerEvents : "none" ,
474513 } }
475514 >
@@ -479,30 +518,25 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
479518 const isSection = entry . isSection
480519 const barWidth = isSection ? TOC_SECTION_WIDTH : TOC_SUBSECTION_WIDTH
481520
482- /* 3D cylinder rotation */
483- const rotateDeg = Math . min ( absOffset * 16 , TOC_MAX_ROTATE_DEG ) * Math . sign ( offset )
484521 /* Position: offset from active entry (active = center) */
485522 const yFromCenter = offset * rowStep
486523
487524 const isActive = absOffset < 0.5
488525 const isHovered = hoveredIdx === idx
489526
490- /* Opacity: when not hovering strip, only active bar visible;
491- on hover, all nearby bars revealed clearly */
527+ /* Opacity: non-hover shows dimmed bars (image 1); hover shows all bars evenly (image 2) */
492528 const opacity = isActive
493529 ? 1
494- : isHoveringStrip
495- ? Math . max ( 0.15 , 1 - absOffset * 0.18 )
496- : Math . max ( 0 , 0.1 - absOffset * 0.025 )
530+ : isHoveringStrip ? 0.55 : 0.3
497531
498532 /* Color: gold active, bright on hover, muted otherwise */
499533 const barColor = isActive
500534 ? "#C47500"
501535 : isHovered
502536 ? ( isDark ? "rgba(255,255,255,0.75)" : "rgba(0,0,0,0.55)" )
503537 : isDark
504- ? ` rgba(255,255,255,${ Math . max ( 0.08 , opacity * 0.55 ) } )`
505- : ` rgba(0,0,0,${ Math . max ( 0.06 , opacity * 0.45 ) } )`
538+ ? " rgba(255,255,255,0.5)"
539+ : " rgba(0,0,0,0.4)"
506540
507541 /* Hover: bar grows wider + thicker */
508542 const hoverWidthBoost = isHovered ? 10 : 0
@@ -527,7 +561,7 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
527561 borderRadius : 2 ,
528562 background : barColor ,
529563 boxShadow : barShadow ,
530- transform : `translateY(${ yFromCenter - TOC_BAR_HEIGHT / 2 } px) rotateX( ${ rotateDeg } deg) ` ,
564+ transform : `translateY(${ yFromCenter - TOC_BAR_HEIGHT / 2 } px)` ,
531565 transition : "transform 0.4s cubic-bezier(0.23,1,0.32,1), opacity 0.35s, width 0.2s ease, height 0.2s ease, background 0.25s, box-shadow 0.25s" ,
532566 opacity,
533567 transformOrigin : "center center" ,
0 commit comments