Files
frigate/web/src/hooks/use-draggable-element.ts
T

516 lines
15 KiB
TypeScript
Raw Normal View History

2025-10-16 08:24:14 -05:00
import { useCallback, useEffect, useMemo, useState } from "react";
2024-03-18 15:58:54 -05:00
import { useTimelineUtils } from "./use-timeline-utils";
2024-06-02 12:00:59 -05:00
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
2024-11-04 07:07:57 -07:00
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
2025-04-22 16:50:21 -05:00
import { useDateLocale } from "./use-date-locale";
2026-03-29 14:03:07 -05:00
import { useTimeFormat } from "./use-date-utils";
2025-04-22 16:50:21 -05:00
import { useTranslation } from "react-i18next";
2025-10-16 08:24:14 -05:00
import useUserInteraction from "./use-user-interaction";
2024-02-20 17:22:59 -06:00
2024-03-18 15:58:54 -05:00
type DraggableElementProps = {
2026-03-05 08:42:38 -06:00
contentRef: React.RefObject<HTMLElement | null>;
timelineRef: React.RefObject<HTMLDivElement | null>;
segmentsRef: React.RefObject<HTMLDivElement | null>;
draggableElementRef: React.RefObject<HTMLDivElement | null>;
2024-02-20 17:22:59 -06:00
segmentDuration: number;
2024-03-18 15:58:54 -05:00
showDraggableElement: boolean;
draggableElementTime?: number;
draggableElementEarliestTime?: number;
draggableElementLatestTime?: number;
setDraggableElementTime?: React.Dispatch<React.SetStateAction<number>>;
2024-03-28 10:03:06 -05:00
alignSetTimeToSegment?: boolean;
initialScrollIntoViewOnly?: boolean;
2024-03-18 15:58:54 -05:00
draggableElementTimeRef: React.MutableRefObject<HTMLDivElement | null>;
2024-02-20 17:22:59 -06:00
timelineDuration: number;
timelineCollapsed?: boolean;
2024-03-08 16:49:10 -06:00
timelineStartAligned: number;
2024-02-20 17:22:59 -06:00
isDragging: boolean;
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;
2024-03-18 15:58:54 -05:00
setDraggableElementPosition?: React.Dispatch<React.SetStateAction<number>>;
2024-03-28 10:03:06 -05:00
dense: boolean;
2025-02-09 15:13:32 -06:00
segments: number[];
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
2024-03-01 09:36:13 -06:00
};
2024-02-20 17:22:59 -06:00
2024-03-18 15:58:54 -05:00
function useDraggableElement({
2024-02-20 17:22:59 -06:00
contentRef,
timelineRef,
2024-04-03 20:20:47 -05:00
segmentsRef,
2024-03-18 15:58:54 -05:00
draggableElementRef,
2024-02-20 17:22:59 -06:00
segmentDuration,
2024-03-18 15:58:54 -05:00
showDraggableElement,
draggableElementTime,
draggableElementEarliestTime,
draggableElementLatestTime,
setDraggableElementTime,
2024-03-28 10:03:06 -05:00
alignSetTimeToSegment = false,
initialScrollIntoViewOnly,
2024-03-18 15:58:54 -05:00
draggableElementTimeRef,
2024-02-20 17:22:59 -06:00
timelineDuration,
timelineCollapsed,
2024-03-08 16:49:10 -06:00
timelineStartAligned,
2024-02-20 17:22:59 -06:00
isDragging,
setIsDragging,
2024-03-18 15:58:54 -05:00
setDraggableElementPosition,
2024-03-28 10:03:06 -05:00
dense,
2025-02-09 15:13:32 -06:00
segments,
scrollToSegment,
2024-03-18 15:58:54 -05:00
}: DraggableElementProps) {
2024-06-02 12:00:59 -05:00
const { data: config } = useSWR<FrigateConfig>("config");
const [clientYPosition, setClientYPosition] = useState<number | null>(null);
2024-03-08 10:13:42 -06:00
const [initialClickAdjustment, setInitialClickAdjustment] = useState(0);
const [elementScrollIntoView, setElementScrollIntoView] = useState(true);
const [scrollEdgeSize, setScrollEdgeSize] = useState<number>();
2024-03-26 16:36:28 -05:00
const [fullTimelineHeight, setFullTimelineHeight] = useState<number>();
2024-03-28 10:03:06 -05:00
const { alignStartDateToTimeline, getCumulativeScrollTop, segmentHeight } =
useTimelineUtils({
segmentDuration: segmentDuration,
timelineDuration: timelineDuration,
timelineRef,
2024-03-28 10:03:06 -05:00
});
2024-09-04 11:48:45 -05:00
// track user interaction and adjust scrolling behavior
2025-10-16 08:24:14 -05:00
const { userInteracting } = useUserInteraction({
elementRef: timelineRef,
});
2024-09-04 11:48:45 -05:00
const draggingAtTopEdge = useMemo(() => {
if (clientYPosition && timelineRef.current && scrollEdgeSize) {
2024-03-28 10:03:06 -05:00
const timelineRect = timelineRef.current.getBoundingClientRect();
const timelineTopAbsolute = timelineRect.top;
return (
2024-03-28 10:03:06 -05:00
clientYPosition - timelineTopAbsolute < scrollEdgeSize && isDragging
);
}
}, [clientYPosition, timelineRef, isDragging, scrollEdgeSize]);
const draggingAtBottomEdge = useMemo(() => {
if (clientYPosition && timelineRef.current && scrollEdgeSize) {
2024-03-28 10:03:06 -05:00
const timelineRect = timelineRef.current.getBoundingClientRect();
const timelineTopAbsolute = timelineRect.top;
const timelineHeightAbsolute = timelineRect.height;
return (
2024-03-28 10:03:06 -05:00
timelineTopAbsolute + timelineHeightAbsolute - clientYPosition <
scrollEdgeSize && isDragging
);
}
}, [clientYPosition, timelineRef, isDragging, scrollEdgeSize]);
const getClientYPosition = useCallback(
2024-03-21 09:00:04 -05:00
(e: MouseEvent | TouchEvent) => {
let clientY;
2024-05-06 13:35:18 -05:00
if ("TouchEvent" in window && e instanceof TouchEvent) {
2024-03-21 09:00:04 -05:00
clientY = e.touches[0].clientY;
} else if (e instanceof MouseEvent) {
clientY = e.clientY;
}
if (clientY) {
setClientYPosition(clientY);
}
},
[setClientYPosition],
);
2024-02-20 17:22:59 -06:00
const handleMouseDown = useCallback(
(
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
) => {
2024-03-18 15:58:54 -05:00
// prevent default only for mouse events
// to avoid chrome/android issues
if (e.nativeEvent instanceof MouseEvent) {
e.preventDefault();
}
2024-02-20 17:22:59 -06:00
e.stopPropagation();
setIsDragging(true);
2024-03-08 10:13:42 -06:00
2024-03-18 15:58:54 -05:00
let clientY;
2024-05-06 13:35:18 -05:00
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
2024-03-18 15:58:54 -05:00
clientY = e.nativeEvent.touches[0].clientY;
} else if (e.nativeEvent instanceof MouseEvent) {
clientY = e.nativeEvent.clientY;
}
if (clientY && draggableElementRef.current) {
2024-03-18 15:58:54 -05:00
const draggableElementRect =
draggableElementRef.current.getBoundingClientRect();
if (!isDragging) {
setInitialClickAdjustment(clientY - draggableElementRect.top);
}
setClientYPosition(clientY);
2024-03-08 10:13:42 -06:00
}
2024-02-20 17:22:59 -06:00
},
2024-03-18 15:58:54 -05:00
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[setIsDragging, draggableElementRef],
2024-02-20 17:22:59 -06:00
);
const handleMouseUp = useCallback(
2024-03-21 09:00:04 -05:00
(e: MouseEvent | TouchEvent) => {
2024-02-20 17:22:59 -06:00
e.preventDefault();
e.stopPropagation();
if (isDragging) {
setIsDragging(false);
2024-03-08 10:13:42 -06:00
setInitialClickAdjustment(0);
2024-02-20 17:22:59 -06:00
}
},
2024-02-28 15:23:56 -07:00
[isDragging, setIsDragging],
2024-02-20 17:22:59 -06:00
);
2024-03-18 15:58:54 -05:00
const timestampToPixels = useCallback(
(time: number) => {
return ((timelineStartAligned - time) / segmentDuration) * segmentHeight;
},
2024-03-28 10:03:06 -05:00
[segmentDuration, timelineStartAligned, segmentHeight],
2024-03-18 15:58:54 -05:00
);
2025-04-22 16:50:21 -05:00
const { t } = useTranslation(["common"]);
const locale = useDateLocale();
2026-03-29 14:03:07 -05:00
const timeFormat = useTimeFormat(config);
2025-04-22 16:50:21 -05:00
const format = useMemo(() => {
const formatKey = `time.${
segmentDuration < 60 && !dense
? "formattedTimestampHourMinuteSecond"
: "formattedTimestampHourMinute"
}.${timeFormat}`;
return t(formatKey);
}, [t, timeFormat, segmentDuration, dense]);
2024-11-04 07:07:57 -07:00
const getFormattedTimestamp = useCallback(
(segmentStartTime: number) => {
return formatUnixTimestampToDateTime(segmentStartTime, {
timezone: config?.ui.timezone,
2025-04-22 16:50:21 -05:00
date_format: format,
locale,
2024-11-04 07:07:57 -07:00
});
},
2025-04-22 16:50:21 -05:00
[config?.ui.timezone, format, locale],
2024-11-04 07:07:57 -07:00
);
2024-03-18 15:58:54 -05:00
const updateDraggableElementPosition = useCallback(
(
2024-03-18 15:58:54 -05:00
newElementPosition: number,
segmentStartTime: number,
scrollTimeline: boolean,
updateHandle: boolean,
) => {
2024-03-18 15:58:54 -05:00
const thumb = draggableElementRef.current;
2024-03-01 09:36:13 -06:00
if (thumb) {
requestAnimationFrame(() => {
2024-03-18 15:58:54 -05:00
thumb.style.top = `${newElementPosition}px`;
if (setDraggableElementPosition) {
setDraggableElementPosition(newElementPosition);
}
if (draggableElementTimeRef.current) {
2024-11-04 07:07:57 -07:00
draggableElementTimeRef.current.textContent =
getFormattedTimestamp(segmentStartTime);
2024-09-04 11:48:45 -05:00
if (scrollTimeline && !userInteracting) {
2025-02-09 15:13:32 -06:00
scrollToSegment(segmentStartTime);
}
2024-03-01 09:36:13 -06:00
}
});
2024-03-18 15:58:54 -05:00
if (setDraggableElementTime && updateHandle) {
setDraggableElementTime(segmentStartTime);
2024-03-01 09:36:13 -06:00
}
}
},
2024-03-18 15:58:54 -05:00
[
draggableElementTimeRef,
draggableElementRef,
setDraggableElementTime,
setDraggableElementPosition,
2024-11-04 07:07:57 -07:00
getFormattedTimestamp,
2024-09-04 11:48:45 -05:00
userInteracting,
2025-02-09 15:13:32 -06:00
scrollToSegment,
2024-03-18 15:58:54 -05:00
],
2024-03-01 09:36:13 -06:00
);
2024-02-20 17:22:59 -06:00
const handleMouseMove = useCallback(
2024-03-21 09:00:04 -05:00
(e: MouseEvent | TouchEvent) => {
2024-02-21 11:58:41 -06:00
if (
!contentRef.current ||
!timelineRef.current ||
2024-03-18 15:58:54 -05:00
!draggableElementRef.current
2024-02-21 11:58:41 -06:00
) {
2024-02-20 17:22:59 -06:00
return;
}
getClientYPosition(e);
},
2024-03-18 15:58:54 -05:00
[contentRef, draggableElementRef, timelineRef, getClientYPosition],
);
2024-02-20 17:22:59 -06:00
useEffect(() => {
let animationFrameId: number | null = null;
const handleScroll = () => {
if (
timelineRef.current &&
2024-03-18 15:58:54 -05:00
showDraggableElement &&
isDragging &&
clientYPosition &&
2025-02-09 15:13:32 -06:00
segments.length > 0 &&
2024-03-26 16:36:28 -05:00
fullTimelineHeight
) {
2024-03-26 16:36:28 -05:00
const { scrollTop: scrolled } = timelineRef.current;
2024-02-20 17:22:59 -06:00
const parentScrollTop = getCumulativeScrollTop(timelineRef.current);
2024-03-18 15:58:54 -05:00
// bottom of timeline
const elementEarliest = draggableElementEarliestTime
? timestampToPixels(draggableElementEarliestTime)
2024-03-26 16:36:28 -05:00
: fullTimelineHeight - segmentHeight * 1.5;
2024-03-18 15:58:54 -05:00
// top of timeline - default 2 segments added for draggableElement visibility
const elementLatest = draggableElementLatestTime
? timestampToPixels(draggableElementLatestTime)
: segmentHeight * 1.5;
2024-03-18 15:58:54 -05:00
const timelineRect = timelineRef.current.getBoundingClientRect();
const timelineTopAbsolute = timelineRect.top;
2024-03-18 15:58:54 -05:00
const newElementPosition = Math.min(
elementEarliest,
2024-02-20 17:22:59 -06:00
Math.max(
2024-03-18 15:58:54 -05:00
elementLatest,
2024-03-08 10:13:42 -06:00
// current Y position
clientYPosition -
timelineTopAbsolute +
2024-03-08 10:13:42 -06:00
parentScrollTop -
initialClickAdjustment,
2024-02-28 15:23:56 -07:00
),
2024-02-20 17:22:59 -06:00
);
if (
newElementPosition >= elementEarliest ||
newElementPosition <= elementLatest
) {
return;
}
2025-02-09 15:13:32 -06:00
const start = Math.max(0, Math.floor(scrolled / segmentHeight));
2025-02-09 15:13:32 -06:00
const relativePosition = newElementPosition - scrolled;
const segmentIndex =
Math.floor(relativePosition / segmentHeight) + start + 1;
2025-02-09 15:13:32 -06:00
const targetSegmentTime = segments[segmentIndex];
if (targetSegmentTime === undefined) return;
const segmentStart = segmentIndex * segmentHeight - scrolled;
const offset = Math.min(segmentStart - relativePosition, segmentHeight);
2024-02-20 17:22:59 -06:00
if ((draggingAtTopEdge || draggingAtBottomEdge) && scrollEdgeSize) {
if (draggingAtTopEdge) {
const intensity = Math.max(
0,
(scrollEdgeSize - (clientYPosition - timelineTopAbsolute)) /
scrollEdgeSize,
);
timelineRef.current.scrollTop -= segmentHeight * intensity;
}
if (draggingAtBottomEdge) {
const intensity = Math.max(
0,
(clientYPosition -
timelineTopAbsolute -
(timelineRef.current.getBoundingClientRect().height -
scrollEdgeSize)) /
scrollEdgeSize,
);
2024-03-26 16:36:28 -05:00
const newScrollTop = Math.min(
fullTimelineHeight - segmentHeight,
timelineRef.current.scrollTop + segmentHeight * intensity,
);
timelineRef.current.scrollTop = newScrollTop;
}
}
const setTime = alignSetTimeToSegment
2025-02-09 15:13:32 -06:00
? targetSegmentTime
: targetSegmentTime + segmentDuration * (offset / segmentHeight);
2024-03-18 15:58:54 -05:00
updateDraggableElementPosition(
newElementPosition,
setTime,
false,
false,
2024-03-01 09:36:13 -06:00
);
2024-03-18 15:58:54 -05:00
if (setDraggableElementTime) {
setDraggableElementTime(
2025-02-09 15:13:32 -06:00
targetSegmentTime + segmentDuration * (offset / segmentHeight),
);
}
if (draggingAtTopEdge || draggingAtBottomEdge) {
animationFrameId = requestAnimationFrame(handleScroll);
}
2024-02-20 17:22:59 -06:00
}
};
const startScroll = () => {
if (isDragging) {
handleScroll();
}
};
const stopScroll = () => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
};
startScroll();
return stopScroll;
2024-02-28 15:23:56 -07:00
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
clientYPosition,
segmentDuration,
2024-03-08 16:49:10 -06:00
timelineStartAligned,
timelineDuration,
timelineRef,
draggingAtTopEdge,
draggingAtBottomEdge,
2024-03-18 15:58:54 -05:00
showDraggableElement,
2025-02-09 15:13:32 -06:00
segments,
]);
2024-02-20 17:22:59 -06:00
2024-02-22 21:15:50 -06:00
useEffect(() => {
2024-03-01 09:36:13 -06:00
if (
timelineRef.current &&
2024-03-18 15:58:54 -05:00
draggableElementRef.current &&
showDraggableElement &&
draggableElementTime &&
!isDragging &&
segments.length > 0
2024-03-01 09:36:13 -06:00
) {
const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime);
2025-02-09 15:13:32 -06:00
if (!userInteracting) {
scrollToSegment(alignedSegmentTime);
}
2024-03-01 09:36:13 -06:00
2025-02-09 15:13:32 -06:00
const segmentIndex = segments.findIndex(
(time) => time === alignedSegmentTime,
2024-03-18 15:58:54 -05:00
);
2025-02-09 15:13:32 -06:00
if (segmentIndex >= 0) {
const segmentStart = segmentIndex * segmentHeight;
const offset =
((draggableElementTime - alignedSegmentTime) / segmentDuration) *
segmentHeight;
// subtract half the height of the handlebar cross bar (4px) for pixel perfection
2025-02-09 15:13:32 -06:00
const newElementPosition = segmentStart - offset - 2;
updateDraggableElementPosition(
newElementPosition,
draggableElementTime,
elementScrollIntoView,
true,
);
if (initialScrollIntoViewOnly) {
setElementScrollIntoView(false);
}
}
2024-02-22 21:15:50 -06:00
}
2024-03-01 09:36:13 -06:00
// we know that these deps are correct
2024-02-28 15:23:56 -07:00
// eslint-disable-next-line react-hooks/exhaustive-deps
2024-03-18 15:58:54 -05:00
}, [
draggableElementTime,
timelineDuration,
segmentDuration,
2024-03-18 15:58:54 -05:00
showDraggableElement,
draggableElementRef,
timelineStartAligned,
timelineRef,
timelineCollapsed,
initialScrollIntoViewOnly,
segments,
2024-03-18 15:58:54 -05:00
]);
2024-02-22 21:15:50 -06:00
2025-02-09 15:13:32 -06:00
const findNextAvailableSegment = useCallback(
(startTime: number) => {
let searchTime = startTime;
while (searchTime < timelineStartAligned + timelineDuration) {
if (segments.includes(searchTime)) {
return searchTime;
}
searchTime += segmentDuration;
}
return null;
},
[segments, timelineStartAligned, timelineDuration, segmentDuration],
);
useEffect(() => {
2024-04-01 09:23:57 -05:00
if (
timelineRef.current &&
2024-04-03 20:20:47 -05:00
segmentsRef.current &&
2024-04-01 09:23:57 -05:00
draggableElementTime &&
timelineCollapsed &&
2025-02-09 15:13:32 -06:00
segments.length > 0
2024-04-01 09:23:57 -05:00
) {
2024-04-03 20:20:47 -05:00
setFullTimelineHeight(
Math.min(
timelineRef.current.scrollHeight,
segmentsRef.current.scrollHeight,
),
);
2025-02-09 15:13:32 -06:00
const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime);
2025-02-09 15:13:32 -06:00
if (segments.includes(alignedSegmentTime)) {
scrollToSegment(alignedSegmentTime);
} else {
// segment not found, maybe we collapsed over a collapsible segment
2025-02-09 15:13:32 -06:00
const nextAvailableSegment =
findNextAvailableSegment(alignedSegmentTime);
2024-03-26 16:36:28 -05:00
2025-02-09 15:13:32 -06:00
if (nextAvailableSegment !== null) {
scrollToSegment(nextAvailableSegment);
2024-04-01 09:23:57 -05:00
if (setDraggableElementTime) {
2025-02-09 15:13:32 -06:00
setDraggableElementTime(nextAvailableSegment);
2024-04-01 09:23:57 -05:00
}
} else {
2025-02-09 15:13:32 -06:00
// segment still not found, just start at the beginning of the timeline or at now()
const firstAvailableSegment = segments[0] || timelineStartAligned;
scrollToSegment(firstAvailableSegment);
2024-04-01 09:23:57 -05:00
if (setDraggableElementTime) {
2025-02-09 15:13:32 -06:00
setDraggableElementTime(firstAvailableSegment);
}
}
}
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
2024-04-01 09:23:57 -05:00
}, [timelineCollapsed, segments]);
useEffect(() => {
2024-04-03 20:20:47 -05:00
if (timelineRef.current && segments && segmentsRef.current) {
setScrollEdgeSize(timelineRef.current.clientHeight * 0.03);
2024-04-03 20:20:47 -05:00
setFullTimelineHeight(
Math.min(
timelineRef.current.scrollHeight,
segmentsRef.current.scrollHeight,
),
);
}
2024-04-03 20:20:47 -05:00
}, [timelineRef, segmentsRef, segments]);
2024-02-20 17:22:59 -06:00
return { handleMouseDown, handleMouseUp, handleMouseMove };
}
2024-03-18 15:58:54 -05:00
export default useDraggableElement;