Skip to content

Commit ecf76d7

Browse files
committed
refactor(studio): extract timeline render contracts
1 parent 5e02427 commit ecf76d7

12 files changed

Lines changed: 344 additions & 299 deletions

packages/studio/src/player/components/Timeline.tsx

Lines changed: 21 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { useRef, useMemo, useCallback, useState, useEffect, memo } from "react";
1+
import { useRef, useMemo, useCallback, useState, memo } from "react";
22
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
33
import { isMusicTrack } from "../../utils/timelineInspector";
44
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
55
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
66
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
7-
import { useMountEffect } from "../../hooks/useMountEffect";
87
import { defaultTimelineTheme } from "./timelineTheme";
98
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
109
import { useTimelinePlayhead } from "./useTimelinePlayhead";
@@ -16,14 +15,12 @@ import { TimelineCanvas } from "./TimelineCanvas";
1615
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
1716
import { useTimelineClipDrag } from "./useTimelineClipDrag";
1817
import { TimelineOverlays } from "./TimelineOverlays";
19-
import { animationContributesLane } from "./TimelinePropertyLanes";
2018
import { useTimelineEditPinning } from "./useTimelineEditPinning";
2119
import { useTimelineStackingSync } from "./useTimelineStackingSync";
2220
import { useTimelineGeometry } from "./useTimelineGeometry";
2321
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
24-
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD, generateTicks } from "./timelineLayout";
22+
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout";
2523
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
26-
import { STUDIO_PREVIEW_FPS } from "../lib/time";
2724
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
2825
import type { TimelineProps } from "./TimelineTypes";
2926
import {
@@ -37,6 +34,14 @@ import { useTrackGapMenu } from "./useTrackGapMenu";
3734
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
3835
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
3936
import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
37+
import {
38+
getEffectiveTimelineDuration,
39+
getTimelinePreviewElement,
40+
hasKeyframedTimelineClips,
41+
} from "./timelineViewModel";
42+
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
43+
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
44+
import { useTimelineTicks } from "./useTimelineTicks";
4045

4146
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
4247
export {
@@ -118,13 +123,7 @@ export const Timeline = memo(function Timeline({
118123
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
119124
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
120125
const hasKeyframedClips = useMemo(
121-
() =>
122-
Array.from(gsapAnimations.values()).some((list) =>
123-
// Same lane-contribution predicate the layout uses: real keyframes OR a
124-
// synthesizable flat tween. Checking animation.keyframes alone left a
125-
// flat-tween-only comp without its reserved label column.
126-
list.some((animation) => animationContributesLane(animation)),
127-
),
126+
() => hasKeyframedTimelineClips(gsapAnimations),
128127
[gsapAnimations],
129128
);
130129
const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips;
@@ -143,20 +142,7 @@ export const Timeline = memo(function Timeline({
143142
const activeTool = usePlayerStore((s) => s.activeTool);
144143
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
145144
const isDragging = useRef(false);
146-
const [shiftHeld, setShiftHeld] = useState(false);
147-
148-
useMountEffect(() => {
149-
const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown");
150-
const blur = () => setShiftHeld(false);
151-
window.addEventListener("keydown", key);
152-
window.addEventListener("keyup", key);
153-
window.addEventListener("blur", blur);
154-
return () => {
155-
window.removeEventListener("keydown", key);
156-
window.removeEventListener("keyup", key);
157-
window.removeEventListener("blur", blur);
158-
};
159-
});
145+
const shiftHeld = useTimelineShiftModifier();
160146

161147
const [showPopover, setShowPopover] = useState(false);
162148
const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
@@ -173,12 +159,10 @@ export const Timeline = memo(function Timeline({
173159
// Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom).
174160
const lastScrollLeftRef = useRef(0);
175161

176-
const effectiveDuration = useMemo(() => {
177-
const safeDur = Number.isFinite(duration) ? duration : 0;
178-
if (rawElements.length === 0) return safeDur;
179-
const result = Math.max(safeDur, ...rawElements.map((el) => el.start + el.duration));
180-
return Number.isFinite(result) ? result : safeDur;
181-
}, [rawElements, duration]);
162+
const effectiveDuration = useMemo(
163+
() => getEffectiveTimelineDuration(duration, rawElements),
164+
[duration, rawElements],
165+
);
182166

183167
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
184168
useAutoExpandKeyframedClips(gsapAnimations);
@@ -296,14 +280,6 @@ export const Timeline = memo(function Timeline({
296280
toggleSelectedKeyframe,
297281
});
298282

299-
const selectedElement = useMemo(
300-
() =>
301-
expandedElements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
302-
[expandedElements, selectedElementId],
303-
);
304-
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
305-
selectedElementRef.current = selectedElement;
306-
307283
const {
308284
pps,
309285
fitPps,
@@ -402,41 +378,15 @@ export const Timeline = memo(function Timeline({
402378
});
403379
setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
404380

405-
const prevSelectedRef = useRef(selectedElementRef.current);
406-
// eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
407-
useEffect(() => {
408-
const prev = prevSelectedRef.current;
409-
const curr = selectedElementRef.current;
410-
prevSelectedRef.current = curr;
411-
if (prev && !curr) {
412-
setShowPopover(false);
413-
setRangeSelection(null);
414-
}
415-
});
416-
417-
// Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames.
418-
const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
419-
const { major, minor } = useMemo(
420-
() => generateTicks(displayDuration, pps, tickFps),
421-
[displayDuration, pps, tickFps],
381+
useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () =>
382+
setRangeSelection(null),
422383
);
384+
385+
const { major, minor } = useTimelineTicks(displayDuration, pps, timeDisplayMode);
423386
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
424387

425388
const getPreviewElement = useCallback(
426-
(element: TimelineElement): TimelineElement => {
427-
if (
428-
resizingClip &&
429-
(resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
430-
) {
431-
return {
432-
...element,
433-
start: resizingClip.previewStart,
434-
duration: resizingClip.previewDuration,
435-
playbackStart: resizingClip.previewPlaybackStart,
436-
};
437-
}
438-
return element;
439-
},
389+
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
440390
[resizingClip],
441391
);
442392

packages/studio/src/player/components/TimelineCanvas.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import { type MultiDragPreviewInput } from "./timelineMultiDragPreview";
2020
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
2121
import type { Rect } from "../../utils/marqueeGeometry";
2222
import { TimelineClip } from "./TimelineClip";
23-
import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes";
23+
import { TimelineLanes } from "./TimelineLanes";
24+
import type { TimelineLaneBaseProps } from "./TimelineLaneTypes";
2425
import { renderClipChildren } from "./timelineClipChildren";
2526
import { useTimelineRevealClip } from "./useTimelineRevealClip";
2627
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2+
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
3+
import type { ReactNode } from "react";
4+
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
5+
import type { TimelineTheme } from "./timelineTheme";
6+
import type { BlockedClipState, DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
7+
import type { TrackVisualStyle } from "./timelineIcons";
8+
import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
9+
10+
/**
11+
* Props shared by the scroll container ({@link TimelineCanvas}) and its lane
12+
* renderer. TimelineCanvas passes these straight through via spread, so
13+
* they are declared once here and both prop types compose from this base — no
14+
* duplicated prop list.
15+
*/
16+
export interface TimelineLaneBaseProps {
17+
pps: number;
18+
contentOrigin: number;
19+
contentGutter: number;
20+
trackContentWidth: number;
21+
theme: TimelineTheme;
22+
displayTrackOrder: number[];
23+
rowHeights: readonly number[];
24+
trackOrder: number[];
25+
tracks: [number, TimelineElement[]][];
26+
trackStyles: Map<number, TrackVisualStyle>;
27+
laneCounts: ReadonlyMap<string, number>;
28+
selectedElementId: string | null;
29+
selectedElementIds: Set<string>;
30+
hoveredClip: string | null;
31+
draggedClip: DraggedClipState | null;
32+
blockedClipRef: React.RefObject<BlockedClipState | null>;
33+
suppressClickRef: React.RefObject<boolean>;
34+
scrollRef: React.RefObject<HTMLDivElement | null>;
35+
renderClipContent?: (
36+
element: TimelineElement,
37+
style: { clip: string; label: string },
38+
) => ReactNode;
39+
renderClipOverlay?: (element: TimelineElement) => ReactNode;
40+
onDrillDown?: (element: TimelineElement) => void;
41+
onSelectElement?: (element: TimelineElement | null) => void;
42+
setHoveredClip: (key: string | null) => void;
43+
setShowPopover: (v: boolean) => void;
44+
setRangeSelection: (v: null) => void;
45+
setResizingClip: (v: ResizingClipState | null) => void;
46+
setDraggedClip: (v: DraggedClipState | null) => void;
47+
setSelectedElementId: (id: string | null) => void;
48+
syncClipDragAutoScroll: (x: number, y: number) => void;
49+
shiftClickClipRef: React.RefObject<{
50+
element: TimelineElement;
51+
anchorX: number;
52+
anchorY: number;
53+
} | null>;
54+
getPreviewElement: (element: TimelineElement) => TimelineElement;
55+
getTrackStyle: (tag: string) => TrackVisualStyle;
56+
keyframeCache?: Map<string, KeyframeCacheEntry>;
57+
gsapAnimations: Map<string, GsapAnimation[]>;
58+
selectedKeyframes: Set<string>;
59+
currentTime: number;
60+
onSeek?: (time: number) => void;
61+
onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
62+
onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
63+
onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void;
64+
onContextMenuKeyframe?: (
65+
e: React.MouseEvent,
66+
elementId: string,
67+
target: TimelineKeyframeTarget,
68+
) => void;
69+
onMoveKeyframe?: (
70+
elementId: string,
71+
keyframe: TimelineKeyframeTarget,
72+
toClipPercentage: number,
73+
propertyGroup?: string,
74+
tweenPercentage?: number,
75+
animationId?: string,
76+
) => Promise<boolean>;
77+
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
78+
/**
79+
* Right-click on EMPTY lane space (not on a clip — those preventDefault
80+
* before this fires — not the gutter/ruler, not below the lanes). `time` is
81+
* the timeline time (seconds) under the pointer on that lane.
82+
*/
83+
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
84+
beatAnalysis?: MusicBeatAnalysis | null;
85+
}

packages/studio/src/player/components/TimelineLanes.tsx

Lines changed: 2 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,24 @@
1-
import { type ReactNode } from "react";
2-
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
31
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
42
import { TimelineClip } from "./TimelineClip";
53
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
64
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
75
import { TimelineTrackHeader } from "./TimelineTrackHeader";
86
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
9-
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
10-
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
117
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
12-
import type { TimelineTheme } from "./timelineTheme";
138
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout";
14-
import {
15-
usePlayerStore,
16-
type TimelineElement,
17-
type KeyframeCacheEntry,
18-
} from "../store/playerStore";
19-
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
9+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
2010
import {
2111
isMultiDragPassenger,
2212
multiDragPassengerOffsetPx,
2313
type MultiDragPreviewInput,
2414
} from "./timelineMultiDragPreview";
25-
import type { TrackVisualStyle } from "./timelineIcons";
2615
import type { TimelineEditCallbacks } from "./timelineCallbacks";
2716
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
2817
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
2918
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
3019
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
3120
import { renderClipChildren } from "./timelineClipChildren";
32-
33-
/**
34-
* Props shared by the scroll container ({@link TimelineCanvas}) and the lane
35-
* renderer below. TimelineCanvas passes these straight through via spread, so
36-
* they are declared once here and both prop types compose from this base — no
37-
* duplicated prop list.
38-
*/
39-
export interface TimelineLaneBaseProps {
40-
pps: number;
41-
contentOrigin: number;
42-
contentGutter: number;
43-
trackContentWidth: number;
44-
theme: TimelineTheme;
45-
displayTrackOrder: number[];
46-
rowHeights: readonly number[];
47-
trackOrder: number[];
48-
tracks: [number, TimelineElement[]][];
49-
trackStyles: Map<number, TrackVisualStyle>;
50-
laneCounts: ReadonlyMap<string, number>;
51-
selectedElementId: string | null;
52-
selectedElementIds: Set<string>;
53-
hoveredClip: string | null;
54-
draggedClip: DraggedClipState | null;
55-
blockedClipRef: React.RefObject<BlockedClipState | null>;
56-
suppressClickRef: React.RefObject<boolean>;
57-
scrollRef: React.RefObject<HTMLDivElement | null>;
58-
renderClipContent?: (
59-
element: TimelineElement,
60-
style: { clip: string; label: string },
61-
) => ReactNode;
62-
renderClipOverlay?: (element: TimelineElement) => ReactNode;
63-
onDrillDown?: (element: TimelineElement) => void;
64-
onSelectElement?: (element: TimelineElement | null) => void;
65-
setHoveredClip: (key: string | null) => void;
66-
setShowPopover: (v: boolean) => void;
67-
setRangeSelection: (v: null) => void;
68-
setResizingClip: (v: ResizingClipState | null) => void;
69-
setDraggedClip: (v: DraggedClipState | null) => void;
70-
setSelectedElementId: (id: string | null) => void;
71-
syncClipDragAutoScroll: (x: number, y: number) => void;
72-
shiftClickClipRef: React.RefObject<{
73-
element: TimelineElement;
74-
anchorX: number;
75-
anchorY: number;
76-
} | null>;
77-
getPreviewElement: (element: TimelineElement) => TimelineElement;
78-
getTrackStyle: (tag: string) => TrackVisualStyle;
79-
keyframeCache?: Map<string, KeyframeCacheEntry>;
80-
gsapAnimations: Map<string, GsapAnimation[]>;
81-
selectedKeyframes: Set<string>;
82-
currentTime: number;
83-
onSeek?: (time: number) => void;
84-
onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
85-
onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
86-
onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void;
87-
onContextMenuKeyframe?: (
88-
e: React.MouseEvent,
89-
elementId: string,
90-
target: TimelineKeyframeTarget,
91-
) => void;
92-
onMoveKeyframe?: (
93-
elementId: string,
94-
keyframe: TimelineKeyframeTarget,
95-
toClipPercentage: number,
96-
propertyGroup?: string,
97-
tweenPercentage?: number,
98-
animationId?: string,
99-
) => Promise<boolean>;
100-
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
101-
/**
102-
* Right-click on EMPTY lane space (not on a clip — those preventDefault
103-
* before this fires — not the gutter/ruler, not below the lanes). `time` is
104-
* the timeline time (seconds) under the pointer on that lane.
105-
*/
106-
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
107-
beatAnalysis?: MusicBeatAnalysis | null;
108-
}
21+
import type { TimelineLaneBaseProps } from "./TimelineLaneTypes";
10922

11023
interface TimelineLanesProps extends TimelineLaneBaseProps {
11124
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */

0 commit comments

Comments
 (0)