mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
133 lines
5.6 KiB
TypeScript
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,
|
|
};
|
|
}
|