"use client"; 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 { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; import { getElementKeyframes } from "@/lib/animation"; import { getTrackClasses, getTrackHeight, canElementHaveAudio, canElementBeHidden, hasMediaId, timelineTimeToPixels, timelineTimeToSnappedPixels, } from "@/lib/timeline"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; import type { TimelineElement as TimelineElementType, TimelineTrack, ElementDragState, } from "@/types/timeline"; import type { MediaAsset } from "@/types/assets"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { resolveStickerId } from "@/lib/stickers"; import Image from "next/image"; import { ScissorIcon, Delete02Icon, Copy01Icon, ViewIcon, ViewOffSlashIcon, VolumeHighIcon, VolumeOffIcon, VolumeMute02Icon, Search01Icon, Exchange01Icon, KeyframeIcon, MagicWand05Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; import type { ComponentProps, ReactNode } from "react"; import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation"; import { cn } from "@/utils/ui"; const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; interface KeyframeIndicator { time: number; offsetPx: number; keyframes: SelectedKeyframeRef[]; } function buildKeyframeIndicator({ keyframe, trackId, elementId, displayedStartTime, zoomLevel, elementLeft, }: { keyframe: ElementKeyframe; trackId: string; elementId: string; displayedStartTime: number; zoomLevel: number; elementLeft: number; }): { time: number; offsetPx: number; keyframeRef: SelectedKeyframeRef; } { const keyframeRef = { trackId, elementId, propertyPath: keyframe.propertyPath, keyframeId: keyframe.id, }; const keyframeLeft = timelineTimeToSnappedPixels({ time: displayedStartTime + keyframe.time, zoomLevel, }); return { time: keyframe.time, offsetPx: keyframeLeft - elementLeft, keyframeRef, }; } function getKeyframeIndicators({ keyframes, trackId, elementId, displayedStartTime, zoomLevel, elementLeft, elementWidth, }: { keyframes: ElementKeyframe[]; trackId: string; elementId: string; displayedStartTime: number; zoomLevel: number; elementLeft: number; elementWidth: number; }): KeyframeIndicator[] { if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { return []; } const keyframesByTime = new Map(); for (const keyframe of keyframes) { const indicator = buildKeyframeIndicator({ keyframe, trackId, elementId, displayedStartTime, zoomLevel, elementLeft, }); const existingIndicator = keyframesByTime.get(indicator.time); if (!existingIndicator) { keyframesByTime.set(indicator.time, { time: indicator.time, offsetPx: indicator.offsetPx, keyframes: [indicator.keyframeRef], }); continue; } existingIndicator.keyframes.push(indicator.keyframeRef); } return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); } function getDisplayShortcut(action: TAction) { const { defaultShortcuts } = getActionDefinition(action); if (!defaultShortcuts?.length) { return ""; } return uppercase({ string: defaultShortcuts[0].replace("+", " "), }); } 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; isDropTarget?: boolean; } export function TimelineElement({ element, track, zoomLevel, isSelected, onSnapPointChange, onResizeStateChange, onElementMouseDown, onElementClick, dragState, isDropTarget = false, }: 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 = timelineTimeToPixels({ time: displayedDuration, zoomLevel, }); const elementLeft = timelineTimeToSnappedPixels({ time: displayedStartTime, zoomLevel, }); const keyframeIndicators = isSelected ? getKeyframeIndicators({ keyframes: getElementKeyframes({ animations: element.animations }), trackId: track.id, elementId: element.id, displayedStartTime, zoomLevel, elementLeft, elementWidth, }) : []; const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { requestRevealMedia(element.mediaId); } }; const isMuted = canElementHaveAudio(element) && element.muted === true; return (
{isSelected && }
} > Split {canElementHaveAudio(element) && hasAudio && ( 1} isCurrentElementSelected={isCurrentElementSelected} isMuted={isMuted} /> )} {canElementBeHidden(element) && ( 1} isCurrentElementSelected={isCurrentElementSelected} /> )} {selectedElements.length === 1 && ( } > Duplicate )} {selectedElements.length === 1 && hasMediaId(element) && ( <> } onClick={(event: React.MouseEvent) => handleRevealInMedia({ event }) } > Reveal media } disabled > Replace media )} 1} isCurrentElementSelected={isCurrentElementSelected} elementType={element.type} selectedCount={selectedElements.length} />
); } function ElementInner({ element, track, isSelected, hasAudio, isMuted, mediaAssets, onElementClick, onElementMouseDown, handleResizeStart, isDropTarget = false, }: { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; hasAudio: boolean; isMuted: boolean; mediaAssets: MediaAsset[]; onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; onElementMouseDown: ( e: React.MouseEvent, element: TimelineElementType, ) => void; handleResizeStart: (params: { event: React.MouseEvent; elementId: string; side: "left" | "right"; }) => void; isDropTarget?: boolean; }) { const opacityClass = (canElementBeHidden(element) && element.hidden) || isDropTarget ? "opacity-50" : ""; return (
{isSelected && ( <> )}
); } function ResizeHandle({ side, elementId, handleResizeStart, }: { side: "left" | "right"; elementId: string; handleResizeStart: (params: { event: React.MouseEvent; elementId: string; side: "left" | "right"; }) => void; }) { const isLeft = side === "left"; return ( ); } function KeyframeIndicators({ indicators, }: { indicators: KeyframeIndicator[]; }) { const { isKeyframeSelected, toggleKeyframeSelection, selectKeyframeRange } = useKeyframeSelection(); const orderedKeyframes = indicators.flatMap( (indicator) => indicator.keyframes, ); const handleKeyframeMouseDown = ({ event }: { event: React.MouseEvent }) => { event.preventDefault(); event.stopPropagation(); }; const handleKeyframeClick = ({ event, keyframes, }: { event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; }) => { event.stopPropagation(); if (event.shiftKey) { selectKeyframeRange({ orderedKeyframes, targetKeyframes: keyframes, isAdditive: event.metaKey || event.ctrlKey, }); return; } toggleKeyframeSelection({ keyframes, isMultiKey: event.metaKey || event.ctrlKey, }); }; return indicators.map((indicator) => { const isIndicatorSelected = indicator.keyframes.some((keyframe) => isKeyframeSelected({ keyframe }), ); return ( ); }); } interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; mediaAssets: MediaAsset[]; } type ElementContentRenderer = (props: ElementContentProps) => ReactNode; const ELEMENT_CONTENT_RENDERERS: Record< TimelineElementType["type"], ElementContentRenderer > = { text: ({ element }) => { const textElement = element as Extract< TimelineElementType, { type: "text" } >; return (
{textElement.content}
); }, effect: ({ element }) => (
{element.name}
), sticker: ({ element }) => { const stickerElement = element as Extract< TimelineElementType, { type: "sticker" } >; return (
{stickerElement.name} {stickerElement.name}
); }, audio: ({ element, mediaAssets }) => { const audioElement = element as Extract< TimelineElementType, { type: "audio" } >; const audioBuffer = audioElement.sourceType === "library" ? audioElement.buffer : undefined; const audioUrl = audioElement.sourceType === "library" ? audioElement.sourceUrl : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url; if (audioBuffer || audioUrl) { return (
); } return ( {audioElement.name} ); }, video: ({ element, track, isSelected, mediaAssets }) => { const videoElement = element as Extract< TimelineElementType, { type: "video" } >; const mediaAsset = mediaAssets.find( (asset) => asset.id === videoElement.mediaId, ); if (!mediaAsset) { return ( {videoElement.name} ); } if (mediaAsset.thumbnailUrl) { const trackHeight = getTrackHeight({ type: track.type }); const tileWidth = trackHeight * (16 / 9); return (
); } return ( {videoElement.name} ); }, image: ({ element, track, isSelected, mediaAssets }) => { const imageElement = element as Extract< TimelineElementType, { type: "image" } >; const mediaAsset = mediaAssets.find( (asset) => asset.id === imageElement.mediaId, ); if (!mediaAsset?.url) { return ( {imageElement.name} ); } const trackHeight = getTrackHeight({ type: track.type }); const tileWidth = trackHeight * (16 / 9); return (
); }, }; function ElementContent(props: ElementContentProps) { const renderer = ELEMENT_CONTENT_RENDERERS[props.element.type]; return <>{renderer(props)}; } function CopyMenuItem() { return ( } > Copy ); } function MuteMenuItem({ isMultipleSelected, isCurrentElementSelected, isMuted, }: { isMultipleSelected: boolean; isCurrentElementSelected: boolean; isMuted: boolean; }) { const getIcon = () => { if (isMultipleSelected && isCurrentElementSelected) { return ; } return isMuted ? ( ) : ( ); }; return ( {isMuted ? "Unmute" : "Mute"} ); } function VisibilityMenuItem({ element, isMultipleSelected, isCurrentElementSelected, }: { element: TimelineElementType; isMultipleSelected: boolean; isCurrentElementSelected: boolean; }) { const isHidden = canElementBeHidden(element) && element.hidden; const getIcon = () => { if (isMultipleSelected && isCurrentElementSelected) { return ; } return isHidden ? ( ) : ( ); }; return ( {isHidden ? "Show" : "Hide"} ); } function DeleteMenuItem({ isMultipleSelected, isCurrentElementSelected, elementType, selectedCount, }: { isMultipleSelected: boolean; isCurrentElementSelected: boolean; elementType: TimelineElementType["type"]; selectedCount: number; }) { return ( } > {isMultipleSelected && isCurrentElementSelected ? `Delete ${selectedCount} elements` : `Delete ${elementType === "text" ? "text" : "clip"}`} ); } function ActionMenuItem({ action, children, ...props }: Omit, "onClick" | "textRight"> & { action: TAction; children: ReactNode; }) { return ( { event.stopPropagation(); invokeAction(action); }} textRight={getDisplayShortcut(action)} {...props} > {children} ); }