OpenCut/apps/web/src/hooks/timeline/use-timeline-zoom.ts

255 lines
6.9 KiB
TypeScript

import {
type WheelEvent as ReactWheelEvent,
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEditor } from "@/hooks/use-editor";
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement | null>;
minZoom?: number;
initialZoom?: number;
initialScrollLeft?: number;
initialPlayheadTime?: number;
tracksScrollRef: RefObject<HTMLDivElement | null>;
rulerScrollRef: RefObject<HTMLDivElement | null>;
}
interface UseTimelineZoomReturn {
zoomLevel: number;
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
handleWheel: (event: ReactWheelEvent) => void;
saveScrollPosition: () => void;
}
export function useTimelineZoom({
containerRef,
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
initialZoom,
initialScrollLeft,
initialPlayheadTime,
tracksScrollRef,
rulerScrollRef,
}: UseTimelineZoomProps): UseTimelineZoomReturn {
const editor = useEditor();
const hasInitializedRef = useRef(false);
const hasRestoredPlayheadRef = useRef(false);
const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const [zoomLevel, setZoomLevel] = useState(() => {
if (initialZoom !== undefined) {
hasInitializedRef.current = true;
return Math.max(
minZoom,
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom),
);
}
return minZoom;
});
const previousZoomRef = useRef(zoomLevel);
const hasRestoredScrollRef = useRef(false);
const handleWheel = useCallback(
(event: ReactWheelEvent) => {
const isZoomGesture = event.ctrlKey || event.metaKey;
const isHorizontalScrollGesture =
event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
if (isHorizontalScrollGesture) {
return;
}
// pinch-zoom (ctrl/meta + wheel)
if (isZoomGesture) {
const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1;
setZoomLevel((prev) => {
const nextZoom = Math.max(
minZoom,
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
);
return nextZoom;
});
// for horizontal scrolling (when shift is held or horizontal wheel movement),
// let the event bubble up to allow ScrollArea to handle it
return;
}
},
[minZoom],
);
useEffect(() => {
if (initialZoom !== undefined && !hasInitializedRef.current) {
hasInitializedRef.current = true;
setZoomLevel(
Math.max(minZoom, Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom)),
);
return;
}
setZoomLevel((prev) => {
if (prev < minZoom) {
return minZoom;
}
return prev;
});
}, [minZoom, initialZoom]);
const wrappedSetZoomLevel = useCallback(
(zoomLevelOrUpdater: number | ((prev: number) => number)) => {
setZoomLevel((prev) => {
const nextZoom =
typeof zoomLevelOrUpdater === "function"
? zoomLevelOrUpdater(prev)
: zoomLevelOrUpdater;
const clampedZoom = Math.max(
minZoom,
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, nextZoom),
);
return clampedZoom;
});
},
[minZoom],
);
useLayoutEffect(() => {
const previousZoom = previousZoomRef.current;
if (previousZoom === zoomLevel) return;
const scrollElement = tracksScrollRef.current;
if (!scrollElement) {
previousZoomRef.current = zoomLevel;
return;
}
const currentScrollLeft = scrollElement.scrollLeft;
const playheadTime = editor.playback.getCurrentTime();
const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
const playheadPixelsBefore =
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom;
const playheadPixelsAfter =
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
const newScrollLeft = playheadPixelsAfter - viewportOffset;
const maxScrollLeft =
scrollElement.scrollWidth - scrollElement.clientWidth;
const clampedScrollLeft = Math.max(
0,
Math.min(maxScrollLeft, newScrollLeft),
);
scrollElement.scrollLeft = clampedScrollLeft;
if (rulerScrollRef.current) {
rulerScrollRef.current.scrollLeft = clampedScrollLeft;
}
}
previousZoomRef.current = zoomLevel;
editor.project.setTimelineViewState({
viewState: {
zoomLevel,
scrollLeft: scrollElement.scrollLeft,
playheadTime,
},
});
}, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]);
// biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
const saveScrollPosition = useCallback(() => {
if (scrollSaveTimeoutRef.current) {
clearTimeout(scrollSaveTimeoutRef.current);
}
scrollSaveTimeoutRef.current = setTimeout(() => {
const scrollElement = tracksScrollRef.current;
if (scrollElement) {
editor.project.setTimelineViewState({
viewState: {
zoomLevel,
scrollLeft: scrollElement.scrollLeft,
playheadTime: editor.playback.getCurrentTime(),
},
});
}
}, 300);
}, [zoomLevel, editor]);
// biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable
useEffect(() => {
if (initialScrollLeft === undefined) return;
if (hasRestoredScrollRef.current) return;
const scrollElement = tracksScrollRef.current;
if (!scrollElement) return;
const restoreScroll = () => {
scrollElement.scrollLeft = initialScrollLeft;
if (rulerScrollRef.current) {
rulerScrollRef.current.scrollLeft = initialScrollLeft;
}
hasRestoredScrollRef.current = true;
};
if (scrollElement.scrollWidth > 0) {
restoreScroll();
} else {
const observer = new ResizeObserver(() => {
if (scrollElement.scrollWidth > 0) {
restoreScroll();
hasRestoredScrollRef.current = true;
observer.disconnect();
}
});
observer.observe(scrollElement);
return () => observer.disconnect();
}
}, [initialScrollLeft]);
useEffect(() => {
if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) {
hasRestoredPlayheadRef.current = true;
editor.playback.seek({ time: initialPlayheadTime });
}
}, [initialPlayheadTime, editor]);
// prevent browser zoom in the timeline
useEffect(() => {
const preventZoom = (event: WheelEvent) => {
const isZoomKeyPressed = event.ctrlKey || event.metaKey;
const isInContainer = containerRef.current?.contains(
event.target as Node,
);
// only check isInContainer, not isInTimeline state - the state check
// causes race conditions where the closure captures stale state
if (isZoomKeyPressed && isInContainer) {
event.preventDefault();
}
};
document.addEventListener("wheel", preventZoom, {
passive: false,
capture: true,
});
return () => {
document.removeEventListener("wheel", preventZoom, { capture: true });
};
}, [containerRef]);
return {
zoomLevel,
setZoomLevel: wrappedSetZoomLevel,
handleWheel,
saveScrollPosition,
};
}