Skip to content

Commit 5ae57b1

Browse files
cale-bradburyclaudefunwithtriangles
authored
Timeline: Input display and auto track selection (#663)
* individual timeline display and track selection * Fix timeline track selection issues from Copilot review - Coalesce selectedTimelineTrackId to null when loading a project file saved before this field existed, so it can't leak undefined into state. - Require both selectedTrackId and setSelectedTrackId before Timeline enters controlled mode, so passing only the setter can't strand selection at null. - Route [i]/[x] keydown handling through a shared stack so only the most recently mounted Timeline instance responds, preventing double-inserts/deletes when the global and per-input panels are both mounted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * lint fix * selectedTimelineComponentId so keyboard events only fire once --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Alex Kempton <alex@funwithtriangles.net>
1 parent d4159f9 commit 5ae57b1

11 files changed

Lines changed: 265 additions & 60 deletions

File tree

apps/desktop/src/renderer/engine.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { MidiInput, MidiInputPanel, MidiGlobalPanel } from '@hedron-gl/midi-inpu
55
import { GamepadInput, GamepadInputPanel, GamepadGlobalPanel } from '@hedron-gl/gamepad-input'
66
import { LFOInput, LFOInputPanel } from '@hedron-gl/lfo-input'
77
import { AudioInput, AudioInputPanel, AudioGlobalPanel } from '@hedron-gl/audio-input'
8-
import { TimelineInput, TimelineGlobalPanel } from '@hedron-gl/timeline'
8+
import { TimelineInput, TimelineGlobalPanel, TimelineInputPanel } from '@hedron-gl/timeline'
99
import { SceneControlPlugin, SceneControlGlobalPanel } from '@hedron-gl/scene-control'
1010

1111
export const performanceMonitor = new Stats()
@@ -36,6 +36,7 @@ export const pluginViews = {
3636
lfo: LFOInputPanel,
3737
audio: AudioInputPanel,
3838
gamepad: GamepadInputPanel,
39+
['timeline-track']: TimelineInputPanel,
3940
},
4041
globalPanel: {
4142
['audio-input']: AudioGlobalPanel,

packages/app-store/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"devDependencies": {
4747
"@types/node": "^25.0.0",
4848
"tsup": "^8.3.6",
49-
"typescript": "^5.6.2"
49+
"typescript": "^5.9.3"
5050
},
5151
"peerDependencies": {
5252
"react": "^18.3.1"

packages/app-store/src/appStore.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export interface AppState {
4848
selectedSketches: ProjectData['app']['selectedSketches'] // TODO: should be part of ProjectData
4949
selectedNodes: ProjectData['app']['selectedNodes']
5050
selectedInputs: ProjectData['app']['selectedInputs']
51+
5152
openedControlGroups: ProjectData['app']['openedControlGroups']
5253
sketchesDir: string | null
5354
globalDialogId: DialogId | null
@@ -66,6 +67,15 @@ export interface AppState {
6667
removeFromSaveList: (path: string) => void
6768
setSketchesServerBuildResult: (result: BuildResult | null) => void
6869
cleanupStaleReferences: (engineData: EngineData) => void
70+
71+
/** @deprecated -- this state handler will be moved to the timeline plugin */
72+
setActiveTimelineComponentId: (id: string | null) => void
73+
/** @deprecated -- this state will be moved to the timeline plugin */
74+
activeTimelineComponentId: string | null
75+
/** @deprecated -- this state handler will be moved to the timeline plugin */
76+
setSelectedTimelineTrackId: (trackId: string | null) => void
77+
/** @deprecated -- this state will be moved to the timeline plugin */
78+
selectedTimelineTrackId: string | null
6979
}
7080

7181
export type SetState = StoreApi<AppState>['setState']
@@ -93,6 +103,8 @@ export const createAppStore = () =>
93103
currentSavePath: null,
94104
selectedNodes: {},
95105
selectedInputs: {},
106+
activeTimelineComponentId: null,
107+
selectedTimelineTrackId: null,
96108
openedControlGroups: {},
97109
saveList: [],
98110
sketchesServerBuildResult: null,
@@ -150,6 +162,16 @@ export const createAppStore = () =>
150162
state.selectedInputs[nodeId] = inputId
151163
})
152164
},
165+
setActiveTimelineComponentId: (id) => {
166+
set((state) => {
167+
state.activeTimelineComponentId = id
168+
})
169+
},
170+
setSelectedTimelineTrackId: (id) => {
171+
set((state) => {
172+
state.selectedTimelineTrackId = id
173+
})
174+
},
153175
setOpenedControlGroup: (sketchId: string, groupIndex: number, isOpen: boolean) => {
154176
set((state) => {
155177
if (!state.openedControlGroups[sketchId]) {

packages/timeline/src/components/Timeline/Timeline.tsx

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,69 @@
1-
import { useCallback, useEffect, useRef, useState } from 'react'
1+
import { useCallback, useEffect, useId, useRef, useState } from 'react'
22
import { usePlayheadScrub } from './usePlayheadScrub'
33
import c from './Timeline.module.css'
44
import { TimelineTrack } from './TimelineTrack'
55
import { TimelineManagerTrack } from '@/types'
6+
import { findTrackById } from '@/utils/findTrackById'
67

78
export interface TimelineProps {
89
/** Timeline data */
910
timeline: {
1011
durationMs: number
1112
tracks: TimelineManagerTrack[]
1213
}
13-
/** Current playhead position in milliseconds */
14-
playheadPositionMs?: number
15-
/** Called when the user clicks on the track area to set the playhead */
16-
onPlayheadChange?: (time: number) => void
17-
/** Called when a keyframe should be deleted */
18-
onKeyframeDelete?: (keyframeId: string) => void
19-
/** Called when a keyframe should be inserted on a track at a given time */
20-
onKeyframeInsert?: (trackId: string, time: number) => void
14+
playheadPositionMs: number
15+
activeTimelineComponentId: string | null
16+
selectedTrackId: string | null
17+
setSelectedTrackId: (trackId: string | null) => void
18+
setActiveTimelineComponentId: (id: string | null) => void
19+
componentId?: string
20+
onPlayheadChange: (time: number) => void
21+
onKeyframeDelete: (keyframeId: string) => void
22+
onKeyframeInsert: (trackId: string, time: number) => void
2123
}
2224

2325
export function Timeline({
2426
timeline,
2527
playheadPositionMs = 0,
28+
activeTimelineComponentId,
29+
selectedTrackId: _selectedTrackId,
30+
setSelectedTrackId: _setSelectedTrackId,
31+
componentId: _componentId,
32+
setActiveTimelineComponentId,
2633
onPlayheadChange,
2734
onKeyframeDelete,
2835
onKeyframeInsert,
2936
}: TimelineProps) {
3037
const { durationMs, tracks } = timeline
3138
const rulerAreaRef = useRef<HTMLDivElement>(null)
32-
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
33-
const [selectedKeyframes, setSelectedKeyframes] = useState<string[] | null>(null)
34-
35-
const findTrackById = useCallback(
36-
(trackList: TimelineManagerTrack[], trackId: string): TimelineManagerTrack | null => {
37-
for (const track of trackList) {
38-
if (track.id === trackId) {
39-
return track
40-
}
4139

42-
if (track.trackType === 'vector') {
43-
const childTrack = findTrackById(track.childTracks, trackId)
44-
if (childTrack) {
45-
return childTrack
46-
}
47-
}
48-
}
40+
// To prevent clashing of keyboard events, we need to keep track of which component is the active one
41+
const fallbackId = useId()
42+
const componentId = _componentId ?? fallbackId
43+
44+
const isActiveComponent = componentId === activeTimelineComponentId
45+
46+
const [_selectedKeyframes, _setSelectedKeyframes] = useState<string[] | null>(null)
47+
48+
const selectedKeyframes = isActiveComponent ? _selectedKeyframes : null
49+
const selectedTrackId = isActiveComponent ? _selectedTrackId : null
50+
51+
const setSelectedKeyframes = useCallback(
52+
(keyframes: string[] | null) => {
53+
_setSelectedKeyframes(keyframes)
4954

50-
return null
55+
setActiveTimelineComponentId(componentId)
5156
},
52-
[],
57+
[componentId, setActiveTimelineComponentId],
58+
)
59+
60+
const setSelectedTrackId = useCallback(
61+
(trackId: string | null) => {
62+
_setSelectedTrackId(trackId)
63+
64+
setActiveTimelineComponentId(componentId)
65+
},
66+
[_setSelectedTrackId, componentId, setActiveTimelineComponentId],
5367
)
5468

5569
useEffect(() => {
@@ -67,19 +81,21 @@ export function Timeline({
6781

6882
const handleKeyDown = (e: KeyboardEvent) => {
6983
const target = e.target as HTMLElement | null
70-
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable) {
84+
if (
85+
target?.tagName === 'INPUT' ||
86+
target?.tagName === 'TEXTAREA' ||
87+
target?.isContentEditable
88+
) {
7189
return
7290
}
7391
if (e.key === 'x' && selectedKeyframes) {
7492
selectedKeyframes.forEach((keyframeId) => {
75-
onKeyframeDelete?.(keyframeId)
93+
onKeyframeDelete(keyframeId)
7694
})
7795

7896
setSelectedKeyframes(null)
7997
}
8098
if (e.key === 'i' && selectedTrackId) {
81-
if (!onKeyframeInsert) return
82-
8399
const track = findTrackById(tracks, selectedTrackId)
84100
if (!track) return
85101

@@ -89,15 +105,17 @@ export function Timeline({
89105
}
90106
}
91107
window.addEventListener('keydown', handleKeyDown)
92-
return () => window.removeEventListener('keydown', handleKeyDown)
108+
return () => {
109+
window.removeEventListener('keydown', handleKeyDown)
110+
}
93111
}, [
94112
selectedKeyframes,
95113
onKeyframeDelete,
96114
selectedTrackId,
97115
playheadPositionMs,
98116
onKeyframeInsert,
99117
tracks,
100-
findTrackById,
118+
setSelectedKeyframes,
101119
])
102120

103121
usePlayheadScrub(durationMs, rulerAreaRef, playheadPositionMs, onPlayheadChange)

packages/timeline/src/components/Timeline/TimelineTrack.tsx

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
1-
import {
2-
collapseOpenIcon,
3-
collapseCloseIcon,
4-
Icon,
5-
useIsItemOpened,
6-
useToggleStore,
7-
} from '@hedron-gl/ui-core'
1+
import { collapseOpenIcon, collapseCloseIcon, Icon } from '@hedron-gl/ui-core'
2+
import { useState } from 'react'
83
import c from './Timeline.module.css'
94
import { TrackKeyframes } from './TrackKeyframes'
105
import { Keyframe } from './Keyframe'
@@ -58,8 +53,7 @@ export const TimelineTrack = ({
5853
selectedKeyframes,
5954
setSelectedKeyframes,
6055
}: TimelineTrackProps) => {
61-
const isExpanded = useIsItemOpened(track.id)
62-
const setIsExpanded = useToggleStore((state) => state.toggleItem)
56+
const [isExpanded, setIsExpanded] = useState<boolean>(false)
6357
const isSelected = track.id === selectedTrackId
6458

6559
return (
@@ -70,7 +64,7 @@ export const TimelineTrack = ({
7064
<Icon
7165
className={c.collapseIcon}
7266
name={isExpanded ? collapseCloseIcon : collapseOpenIcon}
73-
onClick={() => setIsExpanded(track.id)}
67+
onClick={() => setIsExpanded(!isExpanded)}
7468
/>
7569
)}
7670
<button className={c.trackTitle} onClick={() => setSelectedTrackId(track.id)}>

packages/timeline/src/components/TimelineGlobalPanel/TimelineGlobalPanel.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
NodeContainer,
44
useNodeOptionNodes,
55
useParamValue,
6+
useAppStore,
67
ControlGrid,
78
} from '@hedron-gl/ui-core'
89
import { useTimelineData } from './useTimelineData'
@@ -25,6 +26,11 @@ export const TimelineGlobalPanel = ({ engine }: TimelineGlobalPanelProps) => {
2526
manager,
2627
})
2728

29+
const activeTimelineComponentId = useAppStore((state) => state.activeTimelineComponentId)
30+
const setActiveTimelineComponentId = useAppStore((state) => state.setActiveTimelineComponentId)
31+
const selectedTrackId = useAppStore((state) => state.selectedTimelineTrackId)
32+
const setSelectedTrackId = useAppStore((state) => state.setSelectedTimelineTrackId)
33+
2834
const optionNodes = useNodeOptionNodes<TimelineOptionNodes>(DEFAULT_TIMELINE_ID)
2935
const isPlayingNode = optionNodes['isPlaying']!
3036
const playHeadPositionNode = optionNodes['playheadPositionMs']!
@@ -44,6 +50,10 @@ export const TimelineGlobalPanel = ({ engine }: TimelineGlobalPanelProps) => {
4450
<Timeline
4551
timeline={timeline}
4652
playheadPositionMs={playheadPositionMs}
53+
activeTimelineComponentId={activeTimelineComponentId}
54+
setActiveTimelineComponentId={setActiveTimelineComponentId}
55+
selectedTrackId={selectedTrackId}
56+
setSelectedTrackId={setSelectedTrackId}
4757
onPlayheadChange={handlePlayheadChange}
4858
onKeyframeDelete={handleKeyframeDelete}
4959
onKeyframeInsert={handleKeyframeInsert}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useEffect, useId } from 'react'
2+
import { HedronEngine, InputNode } from '@hedron-gl/engine'
3+
import { useNodeOptionNodes, useParamValue, useAppStore } from '@hedron-gl/ui-core'
4+
import { useTimelineData } from '@/components/TimelineGlobalPanel/useTimelineData'
5+
import { useTimelineHandlers } from '@/components/TimelineGlobalPanel/useTimelineHandlers'
6+
import { useTimelineManager } from '@/components/TimelineGlobalPanel/useTimelineManager'
7+
import { DEFAULT_TIMELINE_ID } from '@/constants'
8+
import { Timeline } from '@/components/Timeline/Timeline'
9+
import { TimelineOptionNodes } from '@/TimelineInput'
10+
import { findTrackById } from '@/utils/findTrackById'
11+
12+
interface TimelineInputPanelProps {
13+
input: InputNode
14+
engine: HedronEngine
15+
}
16+
17+
/**
18+
* Per-node view for a timeline-track input: the same editor as the global timeline panel
19+
* (header, playhead, keyframes), scoped down to just this input's own track.
20+
*/
21+
export const TimelineInputPanel = ({ input, engine }: TimelineInputPanelProps) => {
22+
const timeline = useTimelineData()
23+
const manager = useTimelineManager(DEFAULT_TIMELINE_ID)
24+
25+
const { handlePlayheadChange, handleKeyframeDelete, handleKeyframeInsert } = useTimelineHandlers({
26+
engine,
27+
manager,
28+
})
29+
30+
const activeTimelineComponentId = useAppStore((state) => state.activeTimelineComponentId)
31+
const setActiveTimelineComponentId = useAppStore((state) => state.setActiveTimelineComponentId)
32+
const selectedTrackId = useAppStore((state) => state.selectedTimelineTrackId)
33+
const setSelectedTrackId = useAppStore((state) => state.setSelectedTimelineTrackId)
34+
35+
const timelineComponentId = useId()
36+
37+
// Viewing this input's track makes it the selected one
38+
useEffect(() => {
39+
setSelectedTrackId(input.id)
40+
setActiveTimelineComponentId(timelineComponentId)
41+
}, [input.id, setSelectedTrackId, setActiveTimelineComponentId, timelineComponentId])
42+
43+
const optionNodes = useNodeOptionNodes<TimelineOptionNodes>(DEFAULT_TIMELINE_ID)
44+
const playHeadPositionNode = optionNodes['playheadPositionMs']!
45+
const playheadPositionMs = useParamValue<number>(playHeadPositionNode.id)
46+
47+
const track = findTrackById(timeline.tracks, input.id)
48+
49+
return (
50+
<Timeline
51+
timeline={{ durationMs: timeline.durationMs, tracks: track ? [track] : [] }}
52+
playheadPositionMs={playheadPositionMs}
53+
activeTimelineComponentId={activeTimelineComponentId}
54+
setActiveTimelineComponentId={setActiveTimelineComponentId}
55+
selectedTrackId={selectedTrackId}
56+
componentId={timelineComponentId}
57+
setSelectedTrackId={setSelectedTrackId}
58+
onPlayheadChange={handlePlayheadChange}
59+
onKeyframeDelete={handleKeyframeDelete}
60+
onKeyframeInsert={handleKeyframeInsert}
61+
/>
62+
)
63+
}

packages/timeline/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export { TimelineManager } from './TimelineManager'
99
export type { TrackValues, OnUpdateCallback } from './TimelineManager'
1010
export { TimelineInput } from './TimelineInput'
1111
export { TimelineGlobalPanel } from './components/TimelineGlobalPanel/TimelineGlobalPanel'
12+
export { TimelineInputPanel } from './components/TimelineInputPanel/TimelineInputPanel'

0 commit comments

Comments
 (0)