Files
hyperframes/packages/studio/src/player/components/useTimelineGeometry.ts
T

133 lines
5.6 KiB
TypeScript

import { useEffect, useMemo, useRef, type RefObject } from "react";
import { usePlayerStore, type TimelineElement, type ZoomMode } from "../store/playerStore";
import { getTimelinePixelsPerSecond } from "./timelineZoom";
import {
DRAG_EXTEND_MARGIN_PX,
getTimelineDisplayContentWidth,
getTimelineFitPps,
} from "./timelineLayout";
import type { DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
interface UseTimelineGeometryInput {
viewportWidth: number;
effectiveDuration: number;
zoomMode: ZoomMode;
manualZoomPercent: number;
ppsRef: RefObject<number>;
fitPpsRef: RefObject<number>;
draggedClip: DraggedClipState | null;
resizingClip: ResizingClipState | null;
expandedElements: TimelineElement[];
isDragging: RefObject<boolean>;
scrollRef: RefObject<HTMLDivElement | null>;
lastScrollLeftRef: RefObject<number>;
contentOrigin: number;
}
// Derive the timeline's horizontal geometry from the viewport, zoom, and any live
// drag/resize preview: the pixels-per-second scale, the rendered content width
// (CapCut-style over-extension while dragging/trimming), and the version key that
// re-triggers dependent effects after an edit re-derives the elements.
export function useTimelineGeometry({
viewportWidth,
effectiveDuration,
zoomMode,
manualZoomPercent,
ppsRef,
fitPpsRef,
draggedClip,
resizingClip,
expandedElements,
isDragging,
scrollRef,
lastScrollLeftRef,
contentOrigin,
}: UseTimelineGeometryInput) {
// Fit pps maps at least MIN_TIMELINE_EXTENT_S onto the viewport, so short
// comps show a 60s ruler with usable empty space (see getTimelineFitPps).
const fitPps = getTimelineFitPps(viewportWidth, effectiveDuration, contentOrigin);
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
ppsRef.current = pps;
const trackContentWidth = Math.max(0, effectiveDuration * pps);
// Drag-to-extend: while a clip is dragged, keep the rendered extent a margin
// past the ghost's end. Holding the pointer in the right edge zone then keeps
// auto-scroll stepping (scrollWidth grows with the ghost), so the timeline
// extends at auto-scroll pace — placing a clip farther than the timeline
// currently shows. Growth is bounded per frame by AUTO_SCROLL_MAX_SPEED (no
// fling); leaving the edge zone stops it; the extra width collapses when the
// drag ends (the composition itself only grows on commit, content-driven).
const dragGhostEndPx = draggedClip?.started
? (draggedClip.previewStart + draggedClip.element.duration) * pps + DRAG_EXTEND_MARGIN_PX
: 0;
// Trim-to-extend: same mechanic for a right-edge RESIZE — the rendered extent
// tracks the trim preview's end so the edge auto-scroll zone always has room
// to keep stepping while the trim grows past the current timeline width.
const resizeGhostEndPx = resizingClip?.started
? (resizingClip.previewStart + resizingClip.previewDuration) * pps + DRAG_EXTEND_MARGIN_PX
: 0;
// The timeline canvas always fills at least the viewport width AND the
// MIN_TIMELINE_EXTENT_S floor: the ruler + empty track lanes keep going into
// the space instead of leaving dead black — CapCut-style. Only the RENDERED
// extent grows; clip positions/durations are untouched.
const displayContentWidth = getTimelineDisplayContentWidth({
trackContentWidth,
viewportWidth,
contentOrigin,
pps,
dragGhostEndPx,
resizeGhostEndPx,
});
const displayDuration = pps > 0 ? displayContentWidth / pps : effectiveDuration;
const clipStateVersion = useMemo(
() =>
expandedElements
.map((el) => `${el.key ?? el.id}:${el.start}:${el.duration}:${el.track}`)
.join("|"),
[expandedElements],
);
const zoomModeRef = useRef(zoomMode);
zoomModeRef.current = zoomMode;
const manualZoomPercentRef = useRef(manualZoomPercent);
manualZoomPercentRef.current = manualZoomPercent;
fitPpsRef.current = fitPps;
// Restore the horizontal scroll offset after an edit re-derives the elements
// (clipStateVersion changes) so the reload doesn't jump the view. Only in manual
// (pinned) mode — fit mode hides the x-scrollbar (scrollLeft is always 0) — and
// never mid-drag (auto-scroll owns the offset then). rAF waits for the new layout
// so the clamp reads the post-resync scrollWidth. zoomMode is a legitimate dep:
// re-running on a mode flip is a no-op thanks to the guard.
useEffect(() => {
if (zoomMode !== "manual" || isDragging.current) return;
const el = scrollRef.current;
const target = lastScrollLeftRef.current;
if (!el || target <= 0) return;
const raf = requestAnimationFrame(() => {
const max = Math.max(0, el.scrollWidth - el.clientWidth);
const next = Math.min(target, max);
if (Math.abs(el.scrollLeft - next) > 0.5) el.scrollLeft = next;
});
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clipStateVersion, zoomMode]);
// Publish the live scale so edit handlers OUTSIDE <Timeline> (the keyboard-delete
// path) can pin the zoom via pinTimelineZoomToCurrent without threading geometry.
// In a useEffect (not the render body) so React-18 concurrent replay — Suspense
// retry, transitions, StrictMode double-invoke — can't double-publish. The write is
// idempotent (same pps/fitPps → same fields), so this is behavior-preserving; the
// effect placement is just the strictly-correct shape.
useEffect(() => {
usePlayerStore.getState().setTimelineScale(pps, fitPps);
}, [pps, fitPps]);
return {
pps,
fitPps,
displayContentWidth,
displayDuration,
clipStateVersion,
zoomModeRef,
manualZoomPercentRef,
};
}