'use client'; import * as React from 'react'; import { useCallback, useEffect, useRef, useState, type CSSProperties, } from 'react'; import { PanelSurface } from '@repo/ui'; import { motion } from 'motion/react'; import type { ToolcraftPanelState, ToolcraftState, ToolcraftTimelineKeyframeEasing, ToolcraftTimelineKeyframeGroup, } from '../../state/types'; import { isTimelineReadyForPlayback } from '../../state/timeline-readiness'; import { clampToolcraftTimelineDurationSeconds, getToolcraftTimelineKeyframeId, } from '../../state/timeline-values'; import { getTimelineEventTargetElement, isEditableTimelineEventTarget, isTimelineInteractiveElement, } from './timeline-event-targets'; import { TimelineExpandedContent } from './timeline-expanded-content'; import { findTimelineKeyframe } from './timeline-keyframes'; import { TimelinePanelHeader, TimelinePanelMask, } from './timeline-panel-header'; import { timelinePanelExpandedWidthPx, useTimelinePanelResponsiveLayout, } from './timeline-panel-responsive-layout'; import { timelineKeyframeRowHeightPx } from './timeline-panel-layout'; import { useTimelineClock, useTimelineScrubber } from './timeline-playback-hooks'; import { PanelContainer } from '../panel-host/panel-host'; import { useToolcraftPanelBinding } from '../panel-host/use-toolcraft-panel-binding'; import type { PanelPlacement, PanelStateChange } from '../panel-host/panel-host-types'; import { useToolcraftStore } from '../app-shell/toolcraft-store-context'; import { useToolcraftCommittedSelector, useToolcraftDependencySelector, } from '../app-shell/toolcraft-selectors'; import { useToolcraftDispatch } from '../app-shell/use-toolcraft'; type TimelinePanelProps = { className?: string; defaultExpanded?: boolean; framed?: boolean; onPanelStateChange?: PanelStateChange; panelPlacement?: PanelPlacement; panelState?: ToolcraftPanelState; variant?: 'compact' | 'extended'; }; function cn(...classNames: Array): string { return classNames.filter(Boolean).join(' '); } const timelinePanelCollapsedSize = { height: 36 } as const; const timelinePanelCompactWidthPx = 36; const timelinePanelCollapsedWidthPx = 256; const timelinePanelSurfaceBorderHeightPx = 2; const timelinePanelHeaderHeightPx = 36; const timelineExpandedRulerHeightPx = 36; const timelineEmptyStateHeightPx = timelineKeyframeRowHeightPx; const maxVisibleTimelineKeyframeRows = 8; const timelineKeyframeListMaxHeightPx = maxVisibleTimelineKeyframeRows * timelineKeyframeRowHeightPx; const timelinePanelExpandCollapseTransition = { damping: 34, mass: 0.85, stiffness: 330, type: 'spring', } as const; const timelinePanelResizeTransition = { duration: 0.16, ease: [0.22, 1, 0.36, 1], } as const; const playbackDependencies = [{ kind: 'playback' }] as const; const selectSchema = (state: ToolcraftState) => state.schema; const selectMediaAssets = (state: ToolcraftState) => state.mediaAssets; const selectCommittedTimeline = (state: ToolcraftState) => state.timeline; const selectCurrentTimeSeconds = (state: ToolcraftState) => state.timeline.currentTimeSeconds; function getTimelinePanelExpandedSize(keyframeGroups: readonly ToolcraftTimelineKeyframeGroup[]): { height: number; width: number; } { const rowCount = keyframeGroups.length; const rowAreaHeight = rowCount > 0 ? Math.min(rowCount * timelineKeyframeRowHeightPx, timelineKeyframeListMaxHeightPx) : timelineEmptyStateHeightPx; return { height: timelinePanelSurfaceBorderHeightPx + timelinePanelHeaderHeightPx + timelineExpandedRulerHeightPx + rowAreaHeight, width: timelinePanelExpandedWidthPx, }; } export function TimelinePanel({ className, defaultExpanded = false, framed = true, onPanelStateChange, panelPlacement, panelState, variant = 'extended', }: TimelinePanelProps): React.JSX.Element | null { const dispatch = useToolcraftDispatch(); const panelBinding = useToolcraftPanelBinding({ onPanelStateChange, panelId: 'timeline', panelState, }); const resolvedPanelState = panelBinding.panelState; const store = useToolcraftStore(); const schema = useToolcraftCommittedSelector(selectSchema); const mediaAssets = useToolcraftCommittedSelector(selectMediaAssets); const timeline = useToolcraftCommittedSelector(selectCommittedTimeline); const currentTimeSeconds = useToolcraftDependencySelector( selectCurrentTimeSeconds, Object.is, playbackDependencies, ); if (!schema.panels.timeline) { return null; } const keyframesEnabled = schema.assembly.capabilities.includes('timeline.keyframes'); const playbackReady = isTimelineReadyForPlayback(schema, mediaAssets); const { durationSeconds, expanded, isLooping, isPlaying, keyframeGroups, selectedKeyframeId, } = timeline; const [defaultExpandedPending, setDefaultExpandedPending] = useState(defaultExpanded); const [isHoverPaused, setIsHoverPaused] = useState(false); const displayedIsPlaying = playbackReady && isPlaying; const isCompact = variant === 'compact'; const isExpanded = !isCompact && keyframesEnabled && (expanded || defaultExpandedPending); const expandedPanelSize = getTimelinePanelExpandedSize(keyframeGroups); const previousIsExpandedRef = useRef(isExpanded); const isExpandCollapseTransition = previousIsExpandedRef.current !== isExpanded; const timelinePanelTransition = isExpandCollapseTransition ? timelinePanelExpandCollapseTransition : timelinePanelResizeTransition; useEffect(() => { previousIsExpandedRef.current = isExpanded; }, [isExpanded]); useEffect(() => { if (playbackReady) { return; } if (currentTimeSeconds !== 0) { dispatch({ currentTimeSeconds: 0, type: 'timeline.setCurrentTime' }); } if (isPlaying) { dispatch({ isPlaying: false, type: 'timeline.setPlaying' }); } }, [currentTimeSeconds, dispatch, isPlaying, playbackReady]); useEffect(() => { if (!defaultExpanded || !keyframesEnabled) { return; } dispatch({ expanded: true, type: 'timeline.setExpanded' }); setDefaultExpandedPending(false); }, [defaultExpanded, dispatch, keyframesEnabled]); const setCurrentTimeSeconds = useCallback( (nextValue: React.SetStateAction): void => { const currentTime = store.getState().timeline.currentTimeSeconds; const resolvedValue = typeof nextValue === 'function' ? nextValue(currentTime) : nextValue; store.dispatchTransient({ currentTimeSeconds: resolvedValue, type: 'timeline.setCurrentTime', }); }, [store], ); const commitCurrentTimeSeconds = useCallback((): void => { store.commitTransient('playback'); }, [store]); const getCurrentTimeSeconds = useCallback( (): number => store.getState().timeline.currentTimeSeconds, [store], ); const setIsPlaying = useCallback( (nextValue: React.SetStateAction): void => { const currentIsPlaying = store.getState().timeline.isPlaying; const resolvedValue = typeof nextValue === 'function' ? nextValue(currentIsPlaying) : nextValue; dispatch({ isPlaying: resolvedValue, type: 'timeline.setPlaying' }); }, [dispatch, store], ); const setSelectedKeyframeId = useCallback( (keyframeId: string | null): void => { dispatch({ keyframeId, type: 'timeline.selectKeyframe' }); }, [dispatch], ); const scrubber = useTimelineScrubber({ commitCurrentTimeSeconds, currentTimeSeconds, disabled: !playbackReady, durationSeconds, setCurrentTimeSeconds, setIsPlaying, }); const deleteKeyframe = useCallback( (keyframeId: string): void => { dispatch({ keyframeId, type: 'timeline.deleteKeyframe' }); }, [dispatch], ); const moveKeyframe = useCallback( (keyframeId: string, nextTimeSeconds: number): string | null => { const targetKeyframe = findTimelineKeyframe(keyframeGroups, keyframeId); if (!targetKeyframe) { return null; } const nextSelectedKeyframeId = getToolcraftTimelineKeyframeId( targetKeyframe.controlId, nextTimeSeconds, ); dispatch({ keyframeId, timeSeconds: nextTimeSeconds, type: 'timeline.moveKeyframe', }); return nextSelectedKeyframeId; }, [dispatch, keyframeGroups], ); useTimelineClock({ durationSeconds, getCurrentTimeSeconds, isHoverPaused, isLooping, isPlaying: displayedIsPlaying, isScrubbing: scrubber.isScrubbing, setCurrentTimeSeconds, setIsPlaying, }); useEffect(() => { if (!selectedKeyframeId || typeof document === 'undefined') { return; } const handleDocumentKeyDown = (event: KeyboardEvent): void => { if ( event.defaultPrevented || (event.key !== 'Delete' && event.key !== 'Backspace' && event.key !== 'Escape') || isEditableTimelineEventTarget(event.target) ) { return; } event.preventDefault(); if (event.key === 'Escape') { setSelectedKeyframeId(null); return; } deleteKeyframe(selectedKeyframeId); }; document.addEventListener('keydown', handleDocumentKeyDown); return () => { document.removeEventListener('keydown', handleDocumentKeyDown); }; }, [deleteKeyframe, selectedKeyframeId]); useEffect(() => { if (!selectedKeyframeId || typeof document === 'undefined') { return; } const handleDocumentPointerDown = (event: PointerEvent): void => { const targetElement = getTimelineEventTargetElement(event.target); const clickedKeyframe = targetElement?.closest('[data-slot="timeline-keyframe"]'); const clickedEasingPopover = targetElement?.closest( '[data-timeline-keyframe-easing-popover]', ); const clickedTimelinePanel = targetElement?.closest('[data-slot="timeline-panel"]'); const clickedTimelineInteractiveElement = clickedTimelinePanel && isTimelineInteractiveElement(event.target); if (!clickedKeyframe && !clickedEasingPopover && !clickedTimelineInteractiveElement) { setSelectedKeyframeId(null); } }; document.addEventListener('pointerdown', handleDocumentPointerDown, { capture: true }); return () => { document.removeEventListener('pointerdown', handleDocumentPointerDown, { capture: true }); }; }, [selectedKeyframeId]); const commitDurationValue = (nextValue: string): void => { const nextDuration = clampToolcraftTimelineDurationSeconds(Number.parseFloat(nextValue)); dispatch({ durationSeconds: nextDuration, type: 'timeline.setDuration' }); }; const deleteControlKeyframes = (controlId: string): void => { dispatch({ controlId, type: 'timeline.deleteControlKeyframes' }); }; const changeKeyframeEasing = ( keyframeId: string, nextEasing: ToolcraftTimelineKeyframeEasing, ): void => { dispatch({ easing: nextEasing, keyframeId, type: 'timeline.changeKeyframeEasing' }); }; const resolvedPanelPlacement = panelPlacement ?? (framed ? 'frame' : 'surface'); const shouldConstrainToContainer = resolvedPanelPlacement === 'surface'; const { panelRef: timelineSurfaceRef, responsiveLayout } = useTimelinePanelResponsiveLayout( isExpanded && !shouldConstrainToContainer, ); const unconstrainedTimelinePanelWidth = isCompact ? timelinePanelCompactWidthPx : isExpanded ? expandedPanelSize.width : timelinePanelCollapsedWidthPx; const timelinePanelWidth = isExpanded && responsiveLayout !== null ? Math.min(unconstrainedTimelinePanelWidth, responsiveLayout.width) : unconstrainedTimelinePanelWidth; const timelinePanelOffsetX = isExpanded && responsiveLayout !== null ? responsiveLayout.offsetX : 0; const timelinePanelLayoutStyle: CSSProperties = { transform: timelinePanelOffsetX !== 0 ? `translateX(${timelinePanelOffsetX}px)` : undefined, }; const timelinePanelAnimation = { height: isExpanded ? expandedPanelSize.height : timelinePanelCollapsedSize.height, ...(shouldConstrainToContainer ? { maxWidth: timelinePanelWidth } : { width: timelinePanelWidth }), }; const timelineSurface = ( setIsHoverPaused(true)} onPointerLeave={(event) => { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { return; } setIsHoverPaused(false); }} > {!isCompact && !isExpanded ? ( ) : null} { setDefaultExpandedPending(false); dispatch({ expanded: !isExpanded, type: 'timeline.setExpanded' }); }} onToggleLoop={() => dispatch({ type: 'timeline.toggleLoop' })} onTogglePlayback={() => { setIsHoverPaused(false); if (displayedIsPlaying) { setIsPlaying(false); return; } dispatch({ type: 'timeline.togglePlayback' }); }} stripRef={scrubber.stripRef} variant={variant} /> {isExpanded && keyframesEnabled ? ( setIsPlaying(false)} onKeyDown={scrubber.handleScrubKeyDown} onLostPointerCapture={scrubber.handleScrubLostPointerCapture} onMoveKeyframe={moveKeyframe} onPointerDown={scrubber.handleScrubPointerDown} onPointerMove={scrubber.handleScrubPointerMove} onPointerUp={scrubber.handleScrubPointerUp} onSelectedKeyframeChange={setSelectedKeyframeId} selectedKeyframeId={selectedKeyframeId} stripRef={scrubber.stripRef} /> ) : null} ); const panel = ( {timelineSurface} ); return resolvedPanelState.hidden ? ( ) : ( panel ); } export { TimelinePanel as KeyframesPanel };