"use client"; import { Scissors, Trash2, Copy, Search, RefreshCw, EyeOff, Eye, Volume2, VolumeX, ArrowUpDown, } from "lucide-react"; import { useEditor } from "@/hooks/use-editor"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import AudioWaveform from "./audio-waveform"; import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { getTrackClasses, getTrackHeight, canElementHaveAudio, canElementBeHidden, hasMediaId, } from "@/lib/timeline"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "../../ui/context-menu"; import type { TimelineElement as TimelineElementType, TimelineTrack, ElementDragState, } from "@/types/timeline"; import { MediaAsset } from "@/types/assets"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; import { type TAction, invokeAction } from "@/lib/actions"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; interface TimelineElementProps { element: TimelineElementType; track: TimelineTrack; zoomLevel: number; isSelected: boolean; onSnapPointChange?: (snapPoint: SnapPoint | null) => void; onResizeStateChange?: (params: { isResizing: boolean }) => void; onElementMouseDown: ( e: React.MouseEvent, element: TimelineElementType, ) => void; onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; dragState: ElementDragState; } export function TimelineElement({ element, track, zoomLevel, isSelected, onSnapPointChange, onResizeStateChange, onElementMouseDown, onElementClick, dragState, }: TimelineElementProps) { const editor = useEditor(); const { selectedElements } = useElementSelection(); const { requestRevealMedia } = useAssetsPanelStore(); const mediaAssets = editor.media.getAssets(); let mediaAsset: MediaAsset | null = null; if (hasMediaId(element)) { mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; } const hasAudio = mediaSupportsAudio({ media: mediaAsset }); const { handleResizeStart, isResizing, currentStartTime, currentDuration } = useTimelineElementResize({ element, track, zoomLevel, onSnapPointChange, onResizeStateChange, }); const isCurrentElementSelected = selectedElements.some( (selected) => selected.elementId === element.id && selected.trackId === track.id, ); const isBeingDragged = dragState.elementId === element.id; const dragOffsetY = isBeingDragged && dragState.isDragging ? dragState.currentMouseY - dragState.startMouseY : 0; const elementStartTime = isBeingDragged && dragState.isDragging ? dragState.currentTime : element.startTime; const displayedStartTime = isResizing ? currentStartTime : elementStartTime; const displayedDuration = isResizing ? currentDuration : element.duration; const elementWidth = displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const elementLeft = displayedStartTime * 50 * zoomLevel; const handleAction = ({ action, event, }: { action: TAction; event: React.MouseEvent; }) => { event.stopPropagation(); invokeAction(action); }; const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { requestRevealMedia(element.mediaId); } }; const isMuted = canElementHaveAudio(element) && element.muted === true; return (
handleAction({ action: "split-selected", event })} > {selectedElements.length > 1 && isCurrentElementSelected ? `Split ${selectedElements.length} elements at playhead` : "Split at playhead"} 1} isCurrentElementSelected={isCurrentElementSelected} selectedCount={selectedElements.length} onClick={(event) => handleAction({ action: "copy-selected", event })} /> {canElementHaveAudio(element) && hasAudio && ( 1} isCurrentElementSelected={isCurrentElementSelected} isMuted={isMuted} selectedCount={selectedElements.length} onClick={(event) => handleAction({ action: "toggle-elements-muted-selected", event }) } /> )} {canElementBeHidden(element) && ( 1} isCurrentElementSelected={isCurrentElementSelected} selectedCount={selectedElements.length} onClick={(event) => handleAction({ action: "toggle-elements-visibility-selected", event, }) } /> )} {selectedElements.length === 1 && ( handleAction({ action: "duplicate-selected", event }) } > Duplicate {element.type === "text" ? "text" : "clip"} )} Move to track (Coming soon) {selectedElements.length === 1 && hasMediaId(element) && ( <> handleRevealInMedia({ event })} > Reveal in media Replace clip (Coming soon) )} 1} isCurrentElementSelected={isCurrentElementSelected} elementType={element.type} selectedCount={selectedElements.length} onClick={(event) => handleAction({ action: "delete-selected", event }) } />
); } function ElementInner({ element, track, isSelected, isBeingDragged, hasAudio, isMuted, mediaAssets, onElementClick, onElementMouseDown, handleResizeStart, }: { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; isBeingDragged: boolean; hasAudio: boolean; isMuted: boolean; mediaAssets: MediaAsset[]; onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; onElementMouseDown: ( e: React.MouseEvent, element: TimelineElementType, ) => void; handleResizeStart: (params: { e: React.MouseEvent; elementId: string; side: "left" | "right"; }) => void; }) { return (
onElementClick(e, element)} onMouseDown={(e) => onElementMouseDown(e, element)} onContextMenu={(e) => onElementMouseDown(e, element)} >
{(hasAudio ? isMuted : canElementBeHidden(element) && element.hidden) && (
{hasAudio ? ( ) : ( )}
)} {isSelected && ( <> )}
); } function ResizeHandle({ side, elementId, handleResizeStart, }: { side: "left" | "right"; elementId: string; handleResizeStart: (params: { e: React.MouseEvent; elementId: string; side: "left" | "right"; }) => void; }) { const isLeft = side === "left"; return (
handleResizeStart({ e, elementId, side })} >
); } function ElementContent({ element, track, isSelected, mediaAssets, }: { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; mediaAssets: MediaAsset[]; }) { if (element.type === "text") { return (
{element.content}
); } if (element.type === "sticker") { return (
{element.name} {element.name}
); } if (element.type === "audio") { const audioBuffer = element.sourceType === "library" ? element.buffer : undefined; const audioUrl = element.sourceType === "library" ? element.sourceUrl : mediaAssets.find((asset) => asset.id === element.mediaId)?.url; if (audioBuffer || audioUrl) { return (
); } return ( {element.name} ); } const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); if (!mediaAsset) { return ( {element.name} ); } if ( mediaAsset.type === "image" || (mediaAsset.type === "video" && mediaAsset.thumbnailUrl) ) { const trackHeight = getTrackHeight({ type: track.type }); const tileWidth = trackHeight * (16 / 9); const imageUrl = mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl; return (
); } return ( {element.name} ); } function CopyMenuItem({ isMultipleSelected, isCurrentElementSelected, selectedCount, onClick, }: { isMultipleSelected: boolean; isCurrentElementSelected: boolean; selectedCount: number; onClick: (e: React.MouseEvent) => void; }) { return ( {isMultipleSelected && isCurrentElementSelected ? `Copy ${selectedCount} elements` : "Copy element"} ); } function MuteMenuItem({ element, isMultipleSelected, isCurrentElementSelected, isMuted, selectedCount, onClick, }: { element: TimelineElementType; isMultipleSelected: boolean; isCurrentElementSelected: boolean; isMuted: boolean; selectedCount: number; onClick: (e: React.MouseEvent) => void; }) { const getIcon = () => { if (isMultipleSelected && isCurrentElementSelected) { return ; } return isMuted ? ( ) : ( ); }; const getLabel = () => { if (isMultipleSelected && isCurrentElementSelected) { return `Toggle mute ${selectedCount} elements`; } const suffix = element.type === "text" ? "text" : "clip"; return isMuted ? `Unmute ${suffix}` : `Mute ${suffix}`; }; return ( {getIcon()} {getLabel()} ); } function VisibilityMenuItem({ element, isMultipleSelected, isCurrentElementSelected, selectedCount, onClick, }: { element: TimelineElementType; isMultipleSelected: boolean; isCurrentElementSelected: boolean; selectedCount: number; onClick: (e: React.MouseEvent) => void; }) { const isHidden = canElementBeHidden(element) && element.hidden; const getIcon = () => { if (isMultipleSelected && isCurrentElementSelected) { return ; } return isHidden ? ( ) : ( ); }; const getLabel = () => { if (isMultipleSelected && isCurrentElementSelected) { return `Toggle visibility ${selectedCount} elements`; } const suffix = element.type === "text" ? "text" : "clip"; return isHidden ? `Show ${suffix}` : `Hide ${suffix}`; }; return ( {getIcon()} {getLabel()} ); } function DeleteMenuItem({ isMultipleSelected, isCurrentElementSelected, elementType, selectedCount, onClick, }: { isMultipleSelected: boolean; isCurrentElementSelected: boolean; elementType: TimelineElementType["type"]; selectedCount: number; onClick: (e: React.MouseEvent) => void; }) { return ( {isMultipleSelected && isCurrentElementSelected ? `Delete ${selectedCount} elements` : `Delete ${elementType === "text" ? "text" : "clip"}`} ); }