fix scrolling and use custom hook for interaction

This commit is contained in:
Josh Hawkins 2025-10-07 12:32:20 -05:00
parent fcb28cf1c2
commit ddd029b7d9
3 changed files with 84 additions and 52 deletions

View File

@ -3,6 +3,8 @@ import { ObjectLifecycleSequence } from "@/types/timeline";
import { LifecycleIcon } from "@/components/overlay/detail/ObjectLifecycle"; import { LifecycleIcon } from "@/components/overlay/detail/ObjectLifecycle";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { useActivityStream } from "@/contexts/ActivityStreamContext"; import { useActivityStream } from "@/contexts/ActivityStreamContext";
import scrollIntoView from "scroll-into-view-if-needed";
import useUserInteraction from "@/hooks/use-user-interaction";
type ActivityStreamProps = { type ActivityStreamProps = {
timelineData: ObjectLifecycleSequence[]; timelineData: ObjectLifecycleSequence[];
@ -20,6 +22,11 @@ export default function ActivityStream({
const effectiveTime = currentTime + annotationOffset; 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) // group activities by timestamp (within 1 second resolution window)
const groupedActivities = useMemo(() => { const groupedActivities = useMemo(() => {
const groups: { [key: number]: ObjectLifecycleSequence[] } = {}; const groups: { [key: number]: ObjectLifecycleSequence[] } = {};
@ -63,7 +70,7 @@ export default function ActivityStream({
// Auto-scroll to current time // Auto-scroll to current time
useEffect(() => { useEffect(() => {
if (!scrollRef.current) return; if (!scrollRef.current || userInteracting) return;
// Find the last group where effectiveTimestamp <= currentTime + annotationOffset // Find the last group where effectiveTimestamp <= currentTime + annotationOffset
let currentGroupIndex = -1; let currentGroupIndex = -1;
@ -75,17 +82,24 @@ export default function ActivityStream({
} }
if (currentGroupIndex !== -1) { if (currentGroupIndex !== -1) {
const element = scrollRef.current.children[ const element = scrollRef.current.querySelector(
currentGroupIndex `[data-timestamp="${filteredGroups[currentGroupIndex].timestamp}"]`,
] as HTMLElement; ) as HTMLElement;
if (element) { if (element) {
element.scrollIntoView({ setProgrammaticScroll();
scrollIntoView(element, {
scrollMode: "if-needed",
behavior: "smooth", behavior: "smooth",
block: "center",
}); });
} }
} }
}, [filteredGroups, effectiveTime, annotationOffset]); }, [
filteredGroups,
effectiveTime,
annotationOffset,
userInteracting,
setProgrammaticScroll,
]);
return ( return (
<div <div
@ -127,6 +141,7 @@ function ActivityGroup({ group, isCurrent, onSeek }: ActivityGroupProps) {
return ( return (
<div <div
data-timestamp={group.timestamp}
className={`cursor-pointer rounded-lg border p-3 transition-colors ${ className={`cursor-pointer rounded-lg border p-3 transition-colors ${
isCurrent isCurrent
? "border-primary/20 bg-primary/10" ? "border-primary/20 bg-primary/10"

View File

@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTimelineUtils } from "./use-timeline-utils"; import { useTimelineUtils } from "./use-timeline-utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { useDateLocale } from "./use-date-locale"; import { useDateLocale } from "./use-date-locale";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useUserInteraction from "./use-user-interaction";
type DraggableElementProps = { type DraggableElementProps = {
contentRef: React.RefObject<HTMLElement>; contentRef: React.RefObject<HTMLElement>;
@ -71,9 +72,9 @@ function useDraggableElement({
// track user interaction and adjust scrolling behavior // track user interaction and adjust scrolling behavior
const [userInteracting, setUserInteracting] = useState(false); const { userInteracting } = useUserInteraction({
const interactionTimeout = useRef<NodeJS.Timeout>(); elementRef: timelineRef,
const isProgrammaticScroll = useRef(false); });
const draggingAtTopEdge = useMemo(() => { const draggingAtTopEdge = useMemo(() => {
if (clientYPosition && timelineRef.current && scrollEdgeSize) { if (clientYPosition && timelineRef.current && scrollEdgeSize) {
@ -507,47 +508,6 @@ function useDraggableElement({
} }
}, [timelineRef, segmentsRef, segments]); }, [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 }; return { handleMouseDown, handleMouseUp, handleMouseMove };
} }

View File

@ -0,0 +1,57 @@
import { useCallback, useEffect, useRef, useState } from "react";
type UseUserInteractionProps = {
elementRef: React.RefObject<HTMLElement>;
};
function useUserInteraction({ elementRef }: UseUserInteractionProps) {
const [userInteracting, setUserInteracting] = useState(false);
const interactionTimeout = useRef<NodeJS.Timeout>();
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;