diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index 903916b97..fa6fdbd80 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -1,14 +1,7 @@ import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils"; import { ReviewSegment } from "@/types/review"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import scrollIntoView from "scroll-into-view-if-needed"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { MinimapBounds, Tick, Timestamp } from "./segment-metadata"; import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils"; import { isMobile } from "react-device-detect"; @@ -27,6 +20,7 @@ type MotionSegmentProps = { minimapStartTime?: number; minimapEndTime?: number; setHandlebarTime?: React.Dispatch>; + scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void; dense: boolean; }; @@ -42,6 +36,7 @@ export function MotionSegment({ minimapStartTime, minimapEndTime, setHandlebarTime, + scrollToSegment, dense, }: MotionSegmentProps) { const severityType = "all"; @@ -72,7 +67,10 @@ export function MotionSegment({ ); const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]); - const segmentKey = useMemo(() => segmentTime, [segmentTime]); + const segmentKey = useMemo( + () => `${segmentTime}_${segmentDuration}`, + [segmentTime, segmentDuration], + ); const maxSegmentWidth = useMemo(() => { return isMobile ? 30 : 50; @@ -122,10 +120,7 @@ export function MotionSegment({ // Check if the first segment is out of view const firstSegment = firstMinimapSegmentRef.current; if (firstSegment && showMinimap && isFirstSegmentInMinimap) { - scrollIntoView(firstSegment, { - scrollMode: "if-needed", - behavior: "smooth", - }); + scrollToSegment(alignedMinimapStartTime); } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps @@ -163,44 +158,6 @@ export function MotionSegment({ } }, [segmentTime, setHandlebarTime]); - const [segmentRendered, setSegmentRendered] = useState(false); - const segmentObserverRef = useRef(null); - const segmentRef = useRef(null); - - useEffect(() => { - const segmentObserver = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting && !segmentRendered) { - setSegmentRendered(true); - } - }, - { threshold: 0 }, - ); - - if (segmentRef.current) { - segmentObserver.observe(segmentRef.current); - } - - segmentObserverRef.current = segmentObserver; - - return () => { - if (segmentObserverRef.current) { - segmentObserverRef.current.disconnect(); - } - }; - }, [segmentRendered]); - - if (!segmentRendered) { - return ( -
- ); - } - return ( <> {(((firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0) && @@ -209,8 +166,7 @@ export function MotionSegment({ !motionOnly) && (
; + segments: number[]; + events: ReviewSegment[]; + motion_events: MotionData[]; + segmentDuration: number; + timestampSpread: number; + showMinimap: boolean; + minimapStartTime?: number; + minimapEndTime?: number; + contentRef: React.RefObject; + setHandlebarTime?: React.Dispatch>; + dense: boolean; + motionOnly: boolean; + getMotionSegmentValue: (timestamp: number) => number; +} + +export interface VirtualizedMotionSegmentsRef { + scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void; +} + +const SEGMENT_HEIGHT = 8; +const OVERSCAN_COUNT = 20; + +export const VirtualizedMotionSegments = forwardRef< + VirtualizedMotionSegmentsRef, + VirtualizedMotionSegmentsProps +>( + ( + { + timelineRef, + segments, + events, + segmentDuration, + timestampSpread, + showMinimap, + minimapStartTime, + minimapEndTime, + setHandlebarTime, + dense, + motionOnly, + getMotionSegmentValue, + }, + ref, + ) => { + const [visibleRange, setVisibleRange] = useState({ start: 0, end: 0 }); + const containerRef = useRef(null); + + const updateVisibleRange = useCallback(() => { + if (timelineRef.current) { + const { scrollTop, clientHeight } = timelineRef.current; + const start = Math.max( + 0, + Math.floor(scrollTop / SEGMENT_HEIGHT) - OVERSCAN_COUNT, + ); + const end = Math.min( + segments.length, + Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) + + OVERSCAN_COUNT, + ); + setVisibleRange({ start, end }); + } + }, [segments.length, timelineRef]); + + useEffect(() => { + const container = timelineRef.current; + if (container) { + const handleScroll = () => { + window.requestAnimationFrame(updateVisibleRange); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + window.addEventListener("resize", updateVisibleRange); + + updateVisibleRange(); + + return () => { + container.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", updateVisibleRange); + }; + } + }, [updateVisibleRange, timelineRef]); + + const scrollToSegment = useCallback( + (segmentTime: number, ifNeeded: boolean = true) => { + const segmentIndex = segments.findIndex((time) => time === segmentTime); + if ( + segmentIndex !== -1 && + containerRef.current && + timelineRef.current + ) { + const timelineHeight = timelineRef.current.clientHeight; + const targetScrollTop = segmentIndex * SEGMENT_HEIGHT; + const centeredScrollTop = + targetScrollTop - timelineHeight / 2 + SEGMENT_HEIGHT / 2; + + const isVisible = + segmentIndex > visibleRange.start + OVERSCAN_COUNT && + segmentIndex < visibleRange.end - OVERSCAN_COUNT; + + if (!ifNeeded || !isVisible) { + timelineRef.current.scrollTo({ + top: Math.max(0, centeredScrollTop), + behavior: "smooth", + }); + } + updateVisibleRange(); + } + }, + [segments, timelineRef, visibleRange, updateVisibleRange], + ); + + useImperativeHandle(ref, () => ({ + scrollToSegment, + })); + + const totalHeight = segments.length * SEGMENT_HEIGHT; + const visibleSegments = segments.slice( + visibleRange.start, + visibleRange.end, + ); + + return ( +
+
+ {visibleRange.start > 0 && ( + + ); + }, +);