1- import { useRef , useMemo , useCallback , useState , useEffect , memo } from "react" ;
1+ import { useRef , useMemo , useCallback , useState , memo } from "react" ;
22import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis" ;
33import { isMusicTrack } from "../../utils/timelineInspector" ;
44import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions" ;
55import { usePlayerStore , type TimelineElement } from "../store/playerStore" ;
66import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements" ;
7- import { useMountEffect } from "../../hooks/useMountEffect" ;
87import { defaultTimelineTheme } from "./timelineTheme" ;
98import { useTimelineRangeSelection } from "./useTimelineRangeSelection" ;
109import { useTimelinePlayhead } from "./useTimelinePlayhead" ;
@@ -16,14 +15,12 @@ import { TimelineCanvas } from "./TimelineCanvas";
1615import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu" ;
1716import { useTimelineClipDrag } from "./useTimelineClipDrag" ;
1817import { TimelineOverlays } from "./TimelineOverlays" ;
19- import { animationContributesLane } from "./TimelinePropertyLanes" ;
2018import { useTimelineEditPinning } from "./useTimelineEditPinning" ;
2119import { useTimelineStackingSync } from "./useTimelineStackingSync" ;
2220import { useTimelineGeometry } from "./useTimelineGeometry" ;
2321import { 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" ;
2523import { useTimelineScrollViewport } from "./useTimelineScrollViewport" ;
26- import { STUDIO_PREVIEW_FPS } from "../lib/time" ;
2724import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks" ;
2825import type { TimelineProps } from "./TimelineTypes" ;
2926import {
@@ -37,6 +34,14 @@ import { useTrackGapMenu } from "./useTrackGapMenu";
3734import { useTimelineGapHighlights } from "./useTimelineGapHighlights" ;
3835import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext" ;
3936import { 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.
4247export {
@@ -117,13 +122,7 @@ export const Timeline = memo(function Timeline({
117122 // Label mode = comp has keyframed clips (not just when expanded): keeps the layer
118123 // disclosure + property column visible and reserves a GUTTER before 0s (Figma).
119124 const hasKeyframedClips = useMemo (
120- ( ) =>
121- Array . from ( gsapAnimations . values ( ) ) . some ( ( list ) =>
122- // Same lane-contribution predicate the layout uses: real keyframes OR a
123- // synthesizable flat tween. Checking animation.keyframes alone left a
124- // flat-tween-only comp without its reserved label column.
125- list . some ( ( animation ) => animationContributesLane ( animation ) ) ,
126- ) ,
125+ ( ) => hasKeyframedTimelineClips ( gsapAnimations ) ,
127126 [ gsapAnimations ] ,
128127 ) ;
129128 const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips ;
@@ -142,20 +141,7 @@ export const Timeline = memo(function Timeline({
142141 const activeTool = usePlayerStore ( ( s ) => s . activeTool ) ;
143142 const [ hoveredClip , setHoveredClip ] = useState < string | null > ( null ) ;
144143 const isDragging = useRef ( false ) ;
145- const [ shiftHeld , setShiftHeld ] = useState ( false ) ;
146-
147- useMountEffect ( ( ) => {
148- const key = ( e : KeyboardEvent ) => e . key === "Shift" && setShiftHeld ( e . type === "keydown" ) ;
149- const blur = ( ) => setShiftHeld ( false ) ;
150- window . addEventListener ( "keydown" , key ) ;
151- window . addEventListener ( "keyup" , key ) ;
152- window . addEventListener ( "blur" , blur ) ;
153- return ( ) => {
154- window . removeEventListener ( "keydown" , key ) ;
155- window . removeEventListener ( "keyup" , key ) ;
156- window . removeEventListener ( "blur" , blur ) ;
157- } ;
158- } ) ;
144+ const shiftHeld = useTimelineShiftModifier ( ) ;
159145
160146 const [ showPopover , setShowPopover ] = useState ( false ) ;
161147 const [ kfContextMenu , setKfContextMenu ] = useState < KeyframeDiamondContextMenuState | null > ( null ) ;
@@ -172,12 +158,10 @@ export const Timeline = memo(function Timeline({
172158 // Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom).
173159 const lastScrollLeftRef = useRef ( 0 ) ;
174160
175- const effectiveDuration = useMemo ( ( ) => {
176- const safeDur = Number . isFinite ( duration ) ? duration : 0 ;
177- if ( rawElements . length === 0 ) return safeDur ;
178- const result = Math . max ( safeDur , ...rawElements . map ( ( el ) => el . start + el . duration ) ) ;
179- return Number . isFinite ( result ) ? result : safeDur ;
180- } , [ rawElements , duration ] ) ;
161+ const effectiveDuration = useMemo (
162+ ( ) => getEffectiveTimelineDuration ( duration , rawElements ) ,
163+ [ duration , rawElements ] ,
164+ ) ;
181165
182166 const keyframeCache = usePlayerStore ( ( s ) => s . keyframeCache ) ;
183167 useAutoExpandKeyframedClips ( gsapAnimations ) ;
@@ -295,14 +279,6 @@ export const Timeline = memo(function Timeline({
295279 toggleSelectedKeyframe,
296280 } ) ;
297281
298- const selectedElement = useMemo (
299- ( ) =>
300- expandedElements . find ( ( element ) => ( element . key ?? element . id ) === selectedElementId ) ?? null ,
301- [ expandedElements , selectedElementId ] ,
302- ) ;
303- const selectedElementRef = useRef < TimelineElement | null > ( selectedElement ) ;
304- selectedElementRef . current = selectedElement ;
305-
306282 const {
307283 pps,
308284 fitPps,
@@ -401,41 +377,15 @@ export const Timeline = memo(function Timeline({
401377 } ) ;
402378 setRangeSelectionRef . current = setRangeSelection ; // stable ref consumed by useTimelineClipDrag
403379
404- const prevSelectedRef = useRef ( selectedElementRef . current ) ;
405- // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
406- useEffect ( ( ) => {
407- const prev = prevSelectedRef . current ;
408- const curr = selectedElementRef . current ;
409- prevSelectedRef . current = curr ;
410- if ( prev && ! curr ) {
411- setShowPopover ( false ) ;
412- setRangeSelection ( null ) ;
413- }
414- } ) ;
415-
416- // Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames.
417- const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined ;
418- const { major, minor } = useMemo (
419- ( ) => generateTicks ( displayDuration , pps , tickFps ) ,
420- [ displayDuration , pps , tickFps ] ,
380+ useTimelineSelectionLifecycle ( expandedElements , selectedElementId , setShowPopover , ( ) =>
381+ setRangeSelection ( null ) ,
421382 ) ;
383+
384+ const { major, minor } = useTimelineTicks ( displayDuration , pps , timeDisplayMode ) ;
422385 const majorTickInterval = major . length >= 2 ? major [ 1 ] - major [ 0 ] : effectiveDuration ;
423386
424387 const getPreviewElement = useCallback (
425- ( element : TimelineElement ) : TimelineElement => {
426- if (
427- resizingClip &&
428- ( resizingClip . element . key ?? resizingClip . element . id ) === ( element . key ?? element . id )
429- ) {
430- return {
431- ...element ,
432- start : resizingClip . previewStart ,
433- duration : resizingClip . previewDuration ,
434- playbackStart : resizingClip . previewPlaybackStart ,
435- } ;
436- }
437- return element ;
438- } ,
388+ ( element : TimelineElement ) : TimelineElement => getTimelinePreviewElement ( element , resizingClip ) ,
439389 [ resizingClip ] ,
440390 ) ;
441391
0 commit comments