From ddd029b7d9b4518eb13fe874c855a44cef5166b8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 7 Oct 2025 12:32:20 -0500 Subject: [PATCH] fix scrolling and use custom hook for interaction --- .../components/timeline/ActivityStream.tsx | 29 +++++++--- web/src/hooks/use-draggable-element.ts | 50 ++-------------- web/src/hooks/use-user-interaction.ts | 57 +++++++++++++++++++ 3 files changed, 84 insertions(+), 52 deletions(-) create mode 100644 web/src/hooks/use-user-interaction.ts diff --git a/web/src/components/timeline/ActivityStream.tsx b/web/src/components/timeline/ActivityStream.tsx index 9aceda227..704be1081 100644 --- a/web/src/components/timeline/ActivityStream.tsx +++ b/web/src/components/timeline/ActivityStream.tsx @@ -3,6 +3,8 @@ import { ObjectLifecycleSequence } from "@/types/timeline"; import { LifecycleIcon } from "@/components/overlay/detail/ObjectLifecycle"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { useActivityStream } from "@/contexts/ActivityStreamContext"; +import scrollIntoView from "scroll-into-view-if-needed"; +import useUserInteraction from "@/hooks/use-user-interaction"; type ActivityStreamProps = { timelineData: ObjectLifecycleSequence[]; @@ -20,6 +22,11 @@ export default function ActivityStream({ const effectiveTime = currentTime + annotationOffset; + // Track user interaction and adjust scrolling behavior + const { userInteracting, setProgrammaticScroll } = useUserInteraction({ + elementRef: scrollRef, + }); + // group activities by timestamp (within 1 second resolution window) const groupedActivities = useMemo(() => { const groups: { [key: number]: ObjectLifecycleSequence[] } = {}; @@ -63,7 +70,7 @@ export default function ActivityStream({ // Auto-scroll to current time useEffect(() => { - if (!scrollRef.current) return; + if (!scrollRef.current || userInteracting) return; // Find the last group where effectiveTimestamp <= currentTime + annotationOffset let currentGroupIndex = -1; @@ -75,17 +82,24 @@ export default function ActivityStream({ } if (currentGroupIndex !== -1) { - const element = scrollRef.current.children[ - currentGroupIndex - ] as HTMLElement; + const element = scrollRef.current.querySelector( + `[data-timestamp="${filteredGroups[currentGroupIndex].timestamp}"]`, + ) as HTMLElement; if (element) { - element.scrollIntoView({ + setProgrammaticScroll(); + scrollIntoView(element, { + scrollMode: "if-needed", behavior: "smooth", - block: "center", }); } } - }, [filteredGroups, effectiveTime, annotationOffset]); + }, [ + filteredGroups, + effectiveTime, + annotationOffset, + userInteracting, + setProgrammaticScroll, + ]); return (
; @@ -71,9 +72,9 @@ function useDraggableElement({ // track user interaction and adjust scrolling behavior - const [userInteracting, setUserInteracting] = useState(false); - const interactionTimeout = useRef(); - const isProgrammaticScroll = useRef(false); + const { userInteracting } = useUserInteraction({ + elementRef: timelineRef, + }); const draggingAtTopEdge = useMemo(() => { if (clientYPosition && timelineRef.current && scrollEdgeSize) { @@ -507,47 +508,6 @@ function useDraggableElement({ } }, [timelineRef, segmentsRef, segments]); - useEffect(() => { - const handleUserInteraction = () => { - if (!isProgrammaticScroll.current) { - setUserInteracting(true); - - if (interactionTimeout.current) { - clearTimeout(interactionTimeout.current); - } - - interactionTimeout.current = setTimeout(() => { - setUserInteracting(false); - }, 3000); - } else { - isProgrammaticScroll.current = false; - } - }; - - const timelineElement = timelineRef.current; - - if (timelineElement) { - timelineElement.addEventListener("scroll", handleUserInteraction); - timelineElement.addEventListener("mousedown", handleUserInteraction); - timelineElement.addEventListener("mouseup", handleUserInteraction); - timelineElement.addEventListener("touchstart", handleUserInteraction); - timelineElement.addEventListener("touchmove", handleUserInteraction); - timelineElement.addEventListener("touchend", handleUserInteraction); - - return () => { - timelineElement.removeEventListener("scroll", handleUserInteraction); - timelineElement.removeEventListener("mousedown", handleUserInteraction); - timelineElement.removeEventListener("mouseup", handleUserInteraction); - timelineElement.removeEventListener( - "touchstart", - handleUserInteraction, - ); - timelineElement.removeEventListener("touchmove", handleUserInteraction); - timelineElement.removeEventListener("touchend", handleUserInteraction); - }; - } - }, [timelineRef]); - return { handleMouseDown, handleMouseUp, handleMouseMove }; } diff --git a/web/src/hooks/use-user-interaction.ts b/web/src/hooks/use-user-interaction.ts new file mode 100644 index 000000000..2b59a6d49 --- /dev/null +++ b/web/src/hooks/use-user-interaction.ts @@ -0,0 +1,57 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +type UseUserInteractionProps = { + elementRef: React.RefObject; +}; + +function useUserInteraction({ elementRef }: UseUserInteractionProps) { + const [userInteracting, setUserInteracting] = useState(false); + const interactionTimeout = useRef(); + const isProgrammaticScroll = useRef(false); + + const setProgrammaticScroll = useCallback(() => { + isProgrammaticScroll.current = true; + }, []); + + useEffect(() => { + const handleUserInteraction = () => { + if (!isProgrammaticScroll.current) { + setUserInteracting(true); + + if (interactionTimeout.current) { + clearTimeout(interactionTimeout.current); + } + + interactionTimeout.current = setTimeout(() => { + setUserInteracting(false); + }, 3000); + } else { + isProgrammaticScroll.current = false; + } + }; + + const element = elementRef.current; + + if (element) { + element.addEventListener("scroll", handleUserInteraction); + element.addEventListener("mousedown", handleUserInteraction); + element.addEventListener("mouseup", handleUserInteraction); + element.addEventListener("touchstart", handleUserInteraction); + element.addEventListener("touchmove", handleUserInteraction); + element.addEventListener("touchend", handleUserInteraction); + + return () => { + element.removeEventListener("scroll", handleUserInteraction); + element.removeEventListener("mousedown", handleUserInteraction); + element.removeEventListener("mouseup", handleUserInteraction); + element.removeEventListener("touchstart", handleUserInteraction); + element.removeEventListener("touchmove", handleUserInteraction); + element.removeEventListener("touchend", handleUserInteraction); + }; + } + }, [elementRef]); + + return { userInteracting, setProgrammaticScroll }; +} + +export default useUserInteraction;