summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/timeline/use-timeline-zoom.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/hooks/timeline/use-timeline-zoom.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/hooks/timeline/use-timeline-zoom.ts')
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-zoom.ts297
1 files changed, 0 insertions, 297 deletions
diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
deleted file mode 100644
index 1f79a5b2..00000000
--- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts
+++ /dev/null
@@ -1,297 +0,0 @@
-import {
- type WheelEvent as ReactWheelEvent,
- type RefObject,
- useCallback,
- useEffect,
- useLayoutEffect,
- useRef,
- useState,
-} from "react";
-import {
- TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD,
-} from "@/components/editor/panels/timeline/interaction";
-import {
- TIMELINE_ZOOM_MAX,
- TIMELINE_ZOOM_MIN,
-} from "@/lib/timeline/scale";
-import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
-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_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, setZoomLevelRaw] = useState(() => {
- if (initialZoom !== undefined) {
- hasInitializedRef.current = true;
- return Math.max(
- minZoom,
- Math.min(TIMELINE_ZOOM_MAX, initialZoom),
- );
- }
- return minZoom;
- });
- const previousZoomRef = useRef(zoomLevel);
- const hasRestoredScrollRef = useRef(false);
- const preZoomScrollLeftRef = useRef(0);
- const prePlayheadAnchorScrollLeftRef = useRef(0);
- const isInPlayheadAnchorModeRef = useRef(false);
-
- const setZoomLevel = useCallback(
- (updater: number | ((prev: number) => number)) => {
- const scrollElement = tracksScrollRef.current;
- if (scrollElement) {
- preZoomScrollLeftRef.current = scrollElement.scrollLeft;
- }
- setZoomLevelRaw(updater);
- },
- [tracksScrollRef],
- );
-
- 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 normalizedDelta =
- event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
- const cappedDelta =
- Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
- const zoomFactor = Math.exp(-cappedDelta / 300);
- setZoomLevel((prev) => {
- const nextZoom = Math.max(
- minZoom,
- Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor),
- );
- return nextZoom;
- });
- return;
- }
- },
- [minZoom, setZoomLevel],
- );
-
- useEffect(() => {
- if (initialZoom !== undefined && !hasInitializedRef.current) {
- hasInitializedRef.current = true;
- setZoomLevel(
- Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)),
- );
- return;
- }
- setZoomLevel((prev) => {
- if (prev < minZoom) {
- return minZoom;
- }
- return prev;
- });
- }, [minZoom, initialZoom, setZoomLevel]);
-
- 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_ZOOM_MAX, nextZoom),
- );
- return clampedZoom;
- });
- },
- [minZoom, setZoomLevel],
- );
-
- useLayoutEffect(() => {
- const previousZoom = previousZoomRef.current;
- if (previousZoom === zoomLevel) return;
-
- const scrollElement = tracksScrollRef.current;
- if (!scrollElement) {
- previousZoomRef.current = zoomLevel;
- return;
- }
-
- const currentScrollLeft = preZoomScrollLeftRef.current;
- const playheadTime = editor.playback.getCurrentTime();
- const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
- const previousSliderPercent = zoomToSlider({
- zoomLevel: previousZoom,
- minZoom,
- });
- const isCrossingThresholdUp =
- previousSliderPercent <
- TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
- sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
- const isCrossingThresholdDown =
- previousSliderPercent >=
- TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
- sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
-
- const syncScroll = (scrollLeft: number) => {
- scrollElement.scrollLeft = scrollLeft;
- if (rulerScrollRef.current) {
- rulerScrollRef.current.scrollLeft = scrollLeft;
- }
- };
-
- const clampScrollLeft = (scrollLeft: number) => {
- const maxScrollLeft =
- scrollElement.scrollWidth - scrollElement.clientWidth;
- return Math.max(0, Math.min(maxScrollLeft, scrollLeft));
- };
-
- if (isCrossingThresholdUp) {
- prePlayheadAnchorScrollLeftRef.current = currentScrollLeft;
- isInPlayheadAnchorModeRef.current = true;
- }
-
- if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
- const playheadPixelsBefore = timelineTimeToPixels({ time: playheadTime, zoomLevel: previousZoom });
- const playheadPixelsAfter = timelineTimeToPixels({ time: playheadTime, zoomLevel });
-
- const viewportOffset = playheadPixelsBefore - currentScrollLeft;
- const newScrollLeft = playheadPixelsAfter - viewportOffset;
-
- syncScroll(clampScrollLeft(newScrollLeft));
- } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) {
- syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current));
- isInPlayheadAnchorModeRef.current = false;
- }
-
- 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();
- 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,
- };
-}