'use client'; import * as React from 'react'; import { useRef, useState, type CSSProperties } from 'react'; import { Eye, EyeOff, Trash2 } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import type { ToolcraftTimelineKeyframe, ToolcraftTimelineKeyframeEasing, ToolcraftTimelineKeyframeGroup, } from '../../state/types'; import { clampToolcraftTimelineTime, getToolcraftTimelineKeyframeId, roundToolcraftTimelineKeyframeTime, } from '../../state/timeline-values'; import { TimelineIconButton } from './timeline-icon-button'; import { TimelineKeyframeEasingPopover } from './timeline-easing-popover'; import { getTimelineCalcPositionStyle, timelineKeyframePresenceTransition, timelineKeyframeRowHeightPx, timelineTrackEndInsetPx, timelineTrackStartVisualOffsetPx, } from './timeline-panel-layout'; type TimelineKeyframeDragState = { controlId: string; didMove: boolean; initialTimeSeconds: number; keyframeId: string; latestTimeSeconds: number; pointerId: number; trackElement: HTMLElement; wasSelectedOnPointerDown: boolean; }; type TimelineKeyframeRowProps = { durationSeconds: number; group: ToolcraftTimelineKeyframeGroup; isScrubbing: boolean; onChangeKeyframeEasing: (keyframeId: string, easing: ToolcraftTimelineKeyframeEasing) => void; onDeleteControlKeyframes: (controlId: string) => void; onKeyframeDragStart: () => void; onMoveKeyframe: (keyframeId: string, timeSeconds: number) => string | null; onSelectedKeyframeChange: (keyframeId: string | null) => void; selectedKeyframeId: string | null; }; function cn(...classNames: Array): string { return classNames.filter(Boolean).join(' '); } function formatTimelineSeconds(value: number): string { return value.toFixed(2); } function getTimelineKeyframePositionStyle( timeSeconds: number, durationSeconds: number, ): CSSProperties { const ratio = Math.max(0, Math.min(1, timeSeconds / durationSeconds)); return getTimelineCalcPositionStyle(ratio, timelineTrackStartVisualOffsetPx * (1 - ratio)); } function getTimelineTrackTimeFromClientX({ clientX, durationSeconds, trackElement, }: { clientX: number; durationSeconds: number; trackElement: HTMLElement; }): number { const rect = trackElement.getBoundingClientRect(); const trackLeft = rect.left + timelineTrackStartVisualOffsetPx; const trackWidth = Math.max( 1, rect.width - timelineTrackStartVisualOffsetPx - timelineTrackEndInsetPx, ); const ratio = Math.max(0, Math.min(1, (clientX - trackLeft) / trackWidth)); return roundToolcraftTimelineKeyframeTime( clampToolcraftTimelineTime(durationSeconds * ratio, durationSeconds), ); } export function TimelineKeyframeRow({ durationSeconds, group, isScrubbing, onChangeKeyframeEasing, onDeleteControlKeyframes, onKeyframeDragStart, onMoveKeyframe, onSelectedKeyframeChange, selectedKeyframeId, }: TimelineKeyframeRowProps): React.JSX.Element { const [isVisible, setIsVisible] = useState(true); const [draftKeyframeTimes, setDraftKeyframeTimes] = useState>({}); const keyframeDragRef = useRef(null); const keyframeClickIntentRef = useRef<{ didMove: boolean; keyframeId: string; wasSelectedOnPointerDown: boolean; } | null>(null); const selectedGroupKeyframe = group.keyframes.find( (keyframe) => keyframe.id === selectedKeyframeId, ); const getKeyframeTrackElement = (target: Element): HTMLElement | null => target.closest('[data-slot="timeline-keyframe-track"]'); const handleKeyframePointerDown = ( event: React.PointerEvent, keyframe: ToolcraftTimelineKeyframe, ): void => { const trackElement = getKeyframeTrackElement(event.currentTarget); if (!trackElement) { return; } event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture?.(event.pointerId); onSelectedKeyframeChange(keyframe.id); onKeyframeDragStart(); const wasSelectedOnPointerDown = keyframe.id === selectedKeyframeId; keyframeClickIntentRef.current = { didMove: false, keyframeId: keyframe.id, wasSelectedOnPointerDown, }; keyframeDragRef.current = { controlId: keyframe.controlId, didMove: false, initialTimeSeconds: keyframe.timeSeconds, keyframeId: keyframe.id, latestTimeSeconds: keyframe.timeSeconds, pointerId: event.pointerId, trackElement, wasSelectedOnPointerDown, }; }; const handleKeyframePointerMove = (event: React.PointerEvent): void => { const dragState = keyframeDragRef.current; if (!dragState || dragState.pointerId !== event.pointerId) { return; } event.preventDefault(); event.stopPropagation(); const nextTimeSeconds = getTimelineTrackTimeFromClientX({ clientX: event.clientX, durationSeconds, trackElement: dragState.trackElement, }); dragState.latestTimeSeconds = nextTimeSeconds; const didMove = nextTimeSeconds !== dragState.initialTimeSeconds; dragState.didMove = didMove; if (keyframeClickIntentRef.current?.keyframeId === dragState.keyframeId) { keyframeClickIntentRef.current.didMove = didMove; } setDraftKeyframeTimes((currentDrafts) => currentDrafts[dragState.keyframeId] === nextTimeSeconds ? currentDrafts : { ...currentDrafts, [dragState.keyframeId]: nextTimeSeconds }, ); }; const endKeyframeDrag = (event: React.PointerEvent): void => { const dragState = keyframeDragRef.current; if (!dragState || dragState.pointerId !== event.pointerId) { return; } event.preventDefault(); event.stopPropagation(); if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } if (dragState.didMove) { const nextSelectedKeyframeId = onMoveKeyframe( dragState.keyframeId, dragState.latestTimeSeconds, ); onSelectedKeyframeChange( nextSelectedKeyframeId ?? getToolcraftTimelineKeyframeId(dragState.controlId, dragState.latestTimeSeconds), ); } setDraftKeyframeTimes((currentDrafts) => { const nextDrafts = { ...currentDrafts }; delete nextDrafts[dragState.keyframeId]; return nextDrafts; }); keyframeDragRef.current = null; }; return (
setIsVisible((currentValue) => !currentValue)} size="icon-sm" tooltipSide="top" > {isVisible ? ( ) : ( )} {group.label} {selectedGroupKeyframe ? ( onChangeKeyframeEasing(selectedGroupKeyframe.id, nextEasing) } /> ) : null}
{group.keyframes.map((keyframe) => { const isSelected = keyframe.id === selectedKeyframeId; const displayTimeSeconds = draftKeyframeTimes[keyframe.id] ?? keyframe.timeSeconds; return ( { event.preventDefault(); event.stopPropagation(); const clickIntent = keyframeClickIntentRef.current; keyframeClickIntentRef.current = null; if (clickIntent?.didMove) { return; } if (clickIntent?.keyframeId === keyframe.id) { onSelectedKeyframeChange( clickIntent.wasSelectedOnPointerDown ? null : keyframe.id, ); return; } onSelectedKeyframeChange(isSelected ? null : keyframe.id); }} onPointerCancel={endKeyframeDrag} onPointerDown={(event) => handleKeyframePointerDown(event, keyframe)} onPointerMove={handleKeyframePointerMove} onPointerUp={endKeyframeDrag} style={getTimelineKeyframePositionStyle(displayTimeSeconds, durationSeconds)} title={keyframe.valueLabel} transition={timelineKeyframePresenceTransition} type="button" > ); })}
{ onSelectedKeyframeChange(null); onDeleteControlKeyframes(group.controlId); }} size="icon-sm" tooltipSide="top" >
); }