From 5f0c6de79a61100abbf6e6218a8c541be74740a5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 19 Jul 2026 00:06:22 +0200 Subject: [PATCH] refactor(studio): decompose timeline orchestration --- .../studio/src/player/components/Timeline.tsx | 97 ++++--------------- .../player/components/timelineViewModel.ts | 43 ++++++++ .../useTimelineSelectionLifecycle.ts | 27 ++++++ .../components/useTimelineShiftModifier.ts | 20 ++++ .../src/player/components/useTimelineTicks.ts | 16 +++ .../src/player/hooks/timelinePlayerSync.ts | 20 ++++ .../src/player/hooks/useTimelinePlayer.ts | 21 +--- 7 files changed, 148 insertions(+), 96 deletions(-) create mode 100644 packages/studio/src/player/components/timelineViewModel.ts create mode 100644 packages/studio/src/player/components/useTimelineSelectionLifecycle.ts create mode 100644 packages/studio/src/player/components/useTimelineShiftModifier.ts create mode 100644 packages/studio/src/player/components/useTimelineTicks.ts create mode 100644 packages/studio/src/player/hooks/timelinePlayerSync.ts diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 329cfbb4db..352b7a2313 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1,10 +1,9 @@ -import { useRef, useMemo, useCallback, useState, useEffect, memo } from "react"; +import { useRef, useMemo, useCallback, useState, memo } from "react"; import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis"; import { isMusicTrack } from "../../utils/timelineInspector"; import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements"; -import { useMountEffect } from "../../hooks/useMountEffect"; import { defaultTimelineTheme } from "./timelineTheme"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; @@ -16,19 +15,12 @@ import { TimelineCanvas } from "./TimelineCanvas"; import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { TimelineOverlays } from "./TimelineOverlays"; -import { animationContributesLane } from "./TimelinePropertyLanes"; import { useTimelineEditPinning } from "./useTimelineEditPinning"; import { useTimelineStackingSync } from "./useTimelineStackingSync"; import { useTimelineGeometry } from "./useTimelineGeometry"; import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips"; -import { - GUTTER, - LABEL_COL_W, - generateTicks, - getTimelineContentXFromClient, -} from "./timelineLayout"; +import { GUTTER, LABEL_COL_W, getTimelineContentXFromClient } from "./timelineLayout"; import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; -import { STUDIO_PREVIEW_FPS } from "../lib/time"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; import { @@ -40,6 +32,14 @@ import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers"; import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; import { useTrackGapMenu } from "./useTrackGapMenu"; import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; +import { + getEffectiveTimelineDuration, + getTimelinePreviewElement, + hasKeyframedTimelineClips, +} from "./timelineViewModel"; +import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle"; +import { useTimelineShiftModifier } from "./useTimelineShiftModifier"; +import { useTimelineTicks } from "./useTimelineTicks"; // Re-export pure utilities so existing imports from "./Timeline" still resolve. export { @@ -115,13 +115,7 @@ export const Timeline = memo(function Timeline({ // Label mode = comp has keyframed clips (not just when expanded): keeps the layer // disclosure + property column visible and reserves a GUTTER before 0s (Figma). const hasKeyframedClips = useMemo( - () => - Array.from(gsapAnimations.values()).some((list) => - // Same lane-contribution predicate the layout uses: real keyframes OR a - // synthesizable flat tween. Checking animation.keyframes alone left a - // flat-tween-only comp without its reserved label column. - list.some((animation) => animationContributesLane(animation)), - ), + () => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations], ); const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips; @@ -137,22 +131,9 @@ export const Timeline = memo(function Timeline({ const activeTool = usePlayerStore((s) => s.activeTool); const [hoveredClip, setHoveredClip] = useState(null); const isDragging = useRef(false); - const [shiftHeld, setShiftHeld] = useState(false); + const shiftHeld = useTimelineShiftModifier(); const [razorGuideX, setRazorGuideX] = useState(null); - useMountEffect(() => { - const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown"); - const blur = () => setShiftHeld(false); - window.addEventListener("keydown", key); - window.addEventListener("keyup", key); - window.addEventListener("blur", blur); - return () => { - window.removeEventListener("keydown", key); - window.removeEventListener("keyup", key); - window.removeEventListener("blur", blur); - }; - }); - const [showPopover, setShowPopover] = useState(false); const [kfContextMenu, setKfContextMenu] = useState(null); const [clipContextMenu, setClipContextMenu] = useState<{ @@ -168,12 +149,10 @@ export const Timeline = memo(function Timeline({ // Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom). const lastScrollLeftRef = useRef(0); - const effectiveDuration = useMemo(() => { - const safeDur = Number.isFinite(duration) ? duration : 0; - if (rawElements.length === 0) return safeDur; - const result = Math.max(safeDur, ...rawElements.map((el) => el.start + el.duration)); - return Number.isFinite(result) ? result : safeDur; - }, [rawElements, duration]); + const effectiveDuration = useMemo( + () => getEffectiveTimelineDuration(duration, rawElements), + [duration, rawElements], + ); const keyframeCache = usePlayerStore((s) => s.keyframeCache); useAutoExpandKeyframedClips(gsapAnimations); @@ -287,14 +266,6 @@ export const Timeline = memo(function Timeline({ toggleSelectedKeyframe, }); - const selectedElement = useMemo( - () => - expandedElements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null, - [expandedElements, selectedElementId], - ); - const selectedElementRef = useRef(selectedElement); - selectedElementRef.current = selectedElement; - const { pps, fitPps, @@ -385,41 +356,15 @@ export const Timeline = memo(function Timeline({ }); setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag - const prevSelectedRef = useRef(selectedElementRef.current); - // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps - useEffect(() => { - const prev = prevSelectedRef.current; - const curr = selectedElementRef.current; - prevSelectedRef.current = curr; - if (prev && !curr) { - setShowPopover(false); - setRangeSelection(null); - } - }); - - // Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames. - const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined; - const { major, minor } = useMemo( - () => generateTicks(displayDuration, pps, tickFps), - [displayDuration, pps, tickFps], + useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () => + setRangeSelection(null), ); + + const { major, minor } = useTimelineTicks(displayDuration, pps, timeDisplayMode); const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration; const getPreviewElement = useCallback( - (element: TimelineElement): TimelineElement => { - if ( - resizingClip && - (resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id) - ) { - return { - ...element, - start: resizingClip.previewStart, - duration: resizingClip.previewDuration, - playbackStart: resizingClip.previewPlaybackStart, - }; - } - return element; - }, + (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip), [resizingClip], ); diff --git a/packages/studio/src/player/components/timelineViewModel.ts b/packages/studio/src/player/components/timelineViewModel.ts new file mode 100644 index 0000000000..86b1860399 --- /dev/null +++ b/packages/studio/src/player/components/timelineViewModel.ts @@ -0,0 +1,43 @@ +import type { TimelineElement } from "../store/playerStore"; +import type { ResizingClipState } from "./timelineClipDragTypes"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { animationContributesLane } from "./TimelinePropertyLanes"; + +export function hasKeyframedTimelineClips( + animationsByElement: ReadonlyMap, +): boolean { + return Array.from(animationsByElement.values()).some((animations) => + animations.some(animationContributesLane), + ); +} + +export function getEffectiveTimelineDuration( + duration: number, + elements: readonly TimelineElement[], +): number { + const safeDuration = Number.isFinite(duration) ? duration : 0; + if (elements.length === 0) return safeDuration; + const result = Math.max( + safeDuration, + ...elements.map((element) => element.start + element.duration), + ); + return Number.isFinite(result) ? result : safeDuration; +} + +export function getTimelinePreviewElement( + element: TimelineElement, + resizingClip: ResizingClipState | null, +): TimelineElement { + if ( + resizingClip && + (resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id) + ) { + return { + ...element, + start: resizingClip.previewStart, + duration: resizingClip.previewDuration, + playbackStart: resizingClip.previewPlaybackStart, + }; + } + return element; +} diff --git a/packages/studio/src/player/components/useTimelineSelectionLifecycle.ts b/packages/studio/src/player/components/useTimelineSelectionLifecycle.ts new file mode 100644 index 0000000000..3781c87b2f --- /dev/null +++ b/packages/studio/src/player/components/useTimelineSelectionLifecycle.ts @@ -0,0 +1,27 @@ +import { useEffect, useMemo, useRef } from "react"; +import type { TimelineElement } from "../store/playerStore"; + +export function useTimelineSelectionLifecycle( + elements: TimelineElement[], + selectedElementId: string | null, + setShowPopover: (show: boolean) => void, + clearRangeSelection: () => void, +): void { + const selectedElement = useMemo( + () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null, + [elements, selectedElementId], + ); + const selectedElementRef = useRef(selectedElement); + selectedElementRef.current = selectedElement; + const previousSelectedRef = useRef(selectedElementRef.current); + // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps + useEffect(() => { + const previous = previousSelectedRef.current; + const current = selectedElementRef.current; + previousSelectedRef.current = current; + if (previous && !current) { + setShowPopover(false); + clearRangeSelection(); + } + }); +} diff --git a/packages/studio/src/player/components/useTimelineShiftModifier.ts b/packages/studio/src/player/components/useTimelineShiftModifier.ts new file mode 100644 index 0000000000..7c334e4989 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineShiftModifier.ts @@ -0,0 +1,20 @@ +import { useState } from "react"; +import { useMountEffect } from "../../hooks/useMountEffect"; + +export function useTimelineShiftModifier(): boolean { + const [shiftHeld, setShiftHeld] = useState(false); + useMountEffect(() => { + const handleKey = (event: KeyboardEvent) => + event.key === "Shift" && setShiftHeld(event.type === "keydown"); + const handleBlur = () => setShiftHeld(false); + window.addEventListener("keydown", handleKey); + window.addEventListener("keyup", handleKey); + window.addEventListener("blur", handleBlur); + return () => { + window.removeEventListener("keydown", handleKey); + window.removeEventListener("keyup", handleKey); + window.removeEventListener("blur", handleBlur); + }; + }); + return shiftHeld; +} diff --git a/packages/studio/src/player/components/useTimelineTicks.ts b/packages/studio/src/player/components/useTimelineTicks.ts new file mode 100644 index 0000000000..86c1caad56 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineTicks.ts @@ -0,0 +1,16 @@ +import { useMemo } from "react"; +import { STUDIO_PREVIEW_FPS } from "../lib/time"; +import { generateTicks } from "./timelineLayout"; + +export function useTimelineTicks( + duration: number, + pixelsPerSecond: number, + timeDisplayMode: "time" | "frame", +): { major: number[]; minor: number[] } { + const frameRate = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined; + const { major, minor } = useMemo( + () => generateTicks(duration, pixelsPerSecond, frameRate), + [duration, frameRate, pixelsPerSecond], + ); + return { major, minor }; +} diff --git a/packages/studio/src/player/hooks/timelinePlayerSync.ts b/packages/studio/src/player/hooks/timelinePlayerSync.ts new file mode 100644 index 0000000000..cf937f8882 --- /dev/null +++ b/packages/studio/src/player/hooks/timelinePlayerSync.ts @@ -0,0 +1,20 @@ +import type { TimelineElement } from "../store/playerStore"; + +/** Whether a derived timeline changes any field that affects rendering. */ +export function timelineElementsChanged( + previous: TimelineElement[], + next: TimelineElement[], +): boolean { + if (next.length !== previous.length) return true; + return next.some((element, index) => { + const prior = previous[index]; + return ( + !prior || + element.id !== prior.id || + element.start !== prior.start || + element.duration !== prior.duration || + element.track !== prior.track || + element.sourceDuration !== prior.sourceDuration + ); + }); +} diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 44bb17717d..bc1dbd7f37 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -47,26 +47,7 @@ import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/ import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek"; import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore"; import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol"; - -/** - * Whether the derived elements differ from the current ones in any field that - * affects rendering (identity, timing, track, or source length) — used to skip - * redundant store writes. - */ -function timelineElementsChanged(prev: TimelineElement[], next: TimelineElement[]): boolean { - if (next.length !== prev.length) return true; - return next.some((el, i) => { - const p = prev[i]; - return ( - !p || - el.id !== p.id || - el.start !== p.start || - el.duration !== p.duration || - el.track !== p.track || - el.sourceDuration !== p.sourceDuration - ); - }); -} +import { timelineElementsChanged } from "./timelinePlayerSync"; export function useTimelinePlayer() { const iframeRef = useRef(null);