"use client"; import { useEditor } from "@/hooks/use-editor"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import { AudioWaveform } from "./audio-waveform"; import { useElementPreview } from "@/hooks/use-element-preview"; import { useKeyframeDrag, type KeyframeDragState, } from "@/hooks/timeline/element/use-keyframe-drag"; import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; import { useKeyframeBoxSelect } from "@/hooks/timeline/element/use-keyframe-box-select"; import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; import { SelectionBox } from "@/lib/selection/selection-box"; import { getElementKeyframes } from "@/lib/animation"; import { canElementHaveAudio, canElementBeHidden, hasElementEffects, hasMediaId, timelineTimeToPixels, timelineTimeToSnappedPixels, } from "@/lib/timeline"; import { getTrackHeight } from "./track-layout"; import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; import type { TimelineElement as TimelineElementType, TimelineTrack, ElementDragState, VideoElement, ImageElement, AudioElement, } from "@/lib/timeline"; import type { MediaAsset } from "@/lib/media/types"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; import { canToggleSourceAudio, getSourceAudioActionLabel, isSourceAudioSeparated, } from "@/lib/timeline/audio-separation"; import { getActionDefinition, type TAction, type TActionWithOptionalArgs, invokeAction, } from "@/lib/actions"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { resolveStickerId } from "@/lib/stickers"; import { buildGraphicPreviewUrl } from "@/lib/graphics"; 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 { useMemo, type ComponentProps, type ReactNode } from "react"; import type { SelectedKeyframeRef, ElementKeyframe, } from "@/lib/animation/types"; import { cn } from "@/utils/ui"; import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility"; import { useTimelineStore } from "@/stores/timeline-store"; import { KEYFRAME_LANE_HEIGHT_PX } from "./layout"; import { getExpandedRows, getExpansionHeight, type ExpandedRow, } from "./expanded-layout"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; const ELEMENT_RING_WIDTH_PX = 1.5; const THUMBNAIL_ASPECT_RATIO = 16 / 9; interface KeyframeIndicator { time: number; offsetPx: number; keyframes: SelectedKeyframeRef[]; } export 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, }; } export 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); } export function getDisplayShortcut({ action }: { 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: ( event: React.MouseEvent, element: TimelineElementType, ) => void; onElementClick: ( event: 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 mediaAssets = useEditor((e) => e.media.getAssets()); const { selectedElements } = useElementSelection(); const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia); const { renderElement } = useElementPreview({ trackId: track.id, elementId: element.id, fallback: element, }); const { currentDuration, currentStartTime, handleResizeStart, isResizing, } = useTimelineElementResize({ element, track, zoomLevel, onSnapPointChange, onResizeStateChange, }); let mediaAsset: MediaAsset | null = null; if (hasMediaId(element)) { mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; } const hasAudio = mediaSupportsAudio({ media: mediaAsset }); const isCurrentElementSelected = selectedElements.some( (selected) => selected.elementId === element.id && selected.trackId === track.id, ); const isBeingDragged = dragState.dragElementIds.includes(element.id); const dragOffsetY = isBeingDragged && dragState.isDragging ? dragState.currentMouseY - dragState.startMouseY : 0; const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0; const elementStartTime = isBeingDragged && dragState.isDragging ? dragState.currentTime + dragTimeOffset : isResizing ? currentStartTime : renderElement.startTime; const displayedStartTime = elementStartTime; const displayedDuration = isResizing ? currentDuration : renderElement.duration; const elementWidth = timelineTimeToPixels({ time: displayedDuration, zoomLevel, }); const elementLeft = timelineTimeToSnappedPixels({ time: displayedStartTime, zoomLevel, }); const handleElementResizeStart = ({ event, element, side, }: { event: React.MouseEvent; element: TimelineElementType; track: TimelineTrack; side: "left" | "right"; }) => { handleResizeStart({ event, elementId: element.id, side, }); }; const keyframeIndicators = isSelected ? getKeyframeIndicators({ keyframes: getElementKeyframes({ animations: element.animations }), trackId: track.id, elementId: element.id, displayedStartTime, zoomLevel, elementLeft, elementWidth, }) : []; const { keyframeDragState, handleKeyframeMouseDown, handleKeyframeClick, getVisualOffsetPx, } = useKeyframeDrag({ zoomLevel, element, displayedStartTime }); const elementKeyframes = getElementKeyframes({ animations: element.animations, }); const isExpanded = useTimelineStore((s) => s.expandedElementIds.has(element.id), ); const toggleElementExpanded = useTimelineStore( (s) => s.toggleElementExpanded, ); const expandedRows = useMemo( () => isExpanded ? getExpandedRows({ animations: element.animations }) : [], [isExpanded, element.animations], ); const { containerRef: expandedLanesRef, selectionBox: keyframeSelectionBox, isBoxSelecting: isKeyframeBoxSelecting, handleExpandedAreaMouseDown, handleExpandedAreaClick, } = useKeyframeBoxSelect({ trackId: track.id, elementId: element.id, rows: expandedRows, keyframes: elementKeyframes, displayedStartTime, zoomLevel, elementLeft, }); const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { requestRevealMedia(element.mediaId); } }; const isMuted = canElementHaveAudio(element) && element.muted === true; const canToggleCurrentSourceAudio = selectedElements.length === 1 && isCurrentElementSelected && canToggleSourceAudio(element, mediaAsset); const sourceAudioLabel = element.type === "video" ? getSourceAudioActionLabel({ element }) : "Extract audio"; const isElementSourceAudioSeparated = element.type === "video" && isSourceAudioSeparated({ element }); const hasKeyframes = elementKeyframes.length > 0; const expansionHeight = getExpansionHeight({ rows: expandedRows }); const baseTrackHeight = getTrackHeight({ type: track.type }); const expandedContent = isExpanded && expandedRows.length > 0 ? ( ) : null; return (
0 ? `${baseTrackHeight + expansionHeight}px` : "100%", transform: isBeingDragged && dragState.isDragging ? `translate3d(0, ${dragOffsetY}px, 0)` : undefined, }} > 0} baseTrackHeight={baseTrackHeight} expandedContent={expandedContent} onElementClick={onElementClick} onElementMouseDown={onElementMouseDown} onResizeStart={handleElementResizeStart} isDropTarget={isDropTarget} /> {isSelected && (
)}
} > Split {selectedElements.length === 1 && ( } > Duplicate )} {canElementHaveAudio(element) && hasAudio && ( 1} isCurrentElementSelected={isCurrentElementSelected} isMuted={isMuted} /> )} {canToggleCurrentSourceAudio && ( } onClick={(event: React.MouseEvent) => { event.stopPropagation(); invokeAction("toggle-source-audio"); }} > {sourceAudioLabel} )} {canElementBeHidden(element) && ( 1} isCurrentElementSelected={isCurrentElementSelected} /> )} {hasKeyframes && ( } onClick={(event: React.MouseEvent) => { event.stopPropagation(); toggleElementExpanded(element.id); }} > {isExpanded ? "Collapse keyframes" : "Expand keyframes"} )} {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, isExpanded, baseTrackHeight, expandedContent, onElementClick, onElementMouseDown, onResizeStart, isDropTarget = false, }: { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; isExpanded: boolean; baseTrackHeight: number; expandedContent: React.ReactNode; onElementClick: ( event: React.MouseEvent, element: TimelineElementType, ) => void; onElementMouseDown: ( event: React.MouseEvent, element: TimelineElementType, ) => void; onResizeStart: (params: { event: React.MouseEvent; element: TimelineElementType; track: TimelineTrack; side: "left" | "right"; }) => void; isDropTarget?: boolean; }) { const isReducedOpacity = (canElementBeHidden(element) && element.hidden) || isDropTarget; return (
{isSelected && ( <> )}
); } function ResizeHandle({ side, element, track, onResizeStart, }: { side: "left" | "right"; element: TimelineElementType; track: TimelineTrack; onResizeStart: (params: { event: React.MouseEvent; element: TimelineElementType; track: TimelineTrack; side: "left" | "right"; }) => void; }) { const isLeft = side === "left"; return ( ); } function KeyframeIndicators({ indicators, dragState, displayedStartTime, elementLeft, onKeyframeMouseDown, onKeyframeClick, getVisualOffsetPx, }: { indicators: KeyframeIndicator[]; dragState: KeyframeDragState; displayedStartTime: number; elementLeft: number; onKeyframeMouseDown: (params: { event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; }) => void; onKeyframeClick: (params: { event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; indicatorTime: number; }) => void; getVisualOffsetPx: (params: { indicatorTime: number; indicatorOffsetPx: number; isBeingDragged: boolean; displayedStartTime: number; elementLeft: number; }) => number; }) { const { isKeyframeSelected } = useKeyframeSelection(); const orderedKeyframes = indicators.flatMap( (indicator) => indicator.keyframes, ); return indicators.map((indicator) => { const isIndicatorSelected = indicator.keyframes.some((keyframe) => isKeyframeSelected({ keyframe }), ); const isBeingDragged = indicator.keyframes.some((keyframe) => dragState.draggingKeyframeIds.has(keyframe.keyframeId), ); const visualOffsetPx = getVisualOffsetPx({ indicatorTime: indicator.time, indicatorOffsetPx: indicator.offsetPx, isBeingDragged, displayedStartTime, elementLeft, }); return ( ); }); } function ExpandedKeyframeLanes({ rows, keyframes, trackId, elementId, displayedStartTime, zoomLevel, elementLeft, keyframeDragState, onKeyframeMouseDown, onKeyframeClick, getVisualOffsetPx, containerRef, onLaneMouseDown, onLaneClick, selectionBox, isBoxSelecting, }: { rows: ExpandedRow[]; keyframes: ElementKeyframe[]; trackId: string; elementId: string; displayedStartTime: number; zoomLevel: number; elementLeft: number; keyframeDragState: KeyframeDragState; onKeyframeMouseDown: (params: { event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; }) => void; containerRef: React.RefObject; onLaneMouseDown: (event: React.MouseEvent) => void; onLaneClick: (event: React.MouseEvent) => void; selectionBox: { startPos: { x: number; y: number }; currentPos: { x: number; y: number }; isActive: boolean; } | null; isBoxSelecting: boolean; onKeyframeClick: (params: { event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; indicatorTime: number; }) => void; getVisualOffsetPx: (params: { indicatorTime: number; indicatorOffsetPx: number; isBeingDragged: boolean; displayedStartTime: number; elementLeft: number; }) => number; }) { const { isKeyframeSelected } = useKeyframeSelection(); const orderedKeyframes = useMemo( () => [...keyframes] .sort( (a, b) => a.time - b.time || a.propertyPath.localeCompare(b.propertyPath), ) .map((kf) => ({ trackId, elementId, propertyPath: kf.propertyPath, keyframeId: kf.id, })), [keyframes, trackId, elementId], ); return (
{rows.map((row) => { const laneKeyframes = keyframes.filter( (kf) => kf.propertyPath === row.propertyPath, ); return (
{laneKeyframes.map((kf) => { const keyframeRef: SelectedKeyframeRef = { trackId, elementId, propertyPath: row.propertyPath, keyframeId: kf.id, }; const isBeingDragged = keyframeDragState.draggingKeyframeIds.has(kf.id); const kfLeft = timelineTimeToSnappedPixels({ time: displayedStartTime + kf.time, zoomLevel, }); const offsetPx = kfLeft - elementLeft; const visualOffset = getVisualOffsetPx({ indicatorTime: kf.time, indicatorOffsetPx: offsetPx, isBeingDragged, displayedStartTime, elementLeft, }); const isSelected = isKeyframeSelected({ keyframe: keyframeRef, }); return ( ); })}
); })} {selectionBox && ( )}
); } interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; } function TextElementContent({ element, }: { element: Extract; }) { return (
{element.content}
); } function EffectElementContent({ element, }: { element: Extract; }) { return (
{element.name}
); } function StickerElementContent({ element, }: { element: Extract; }) { return (
{element.name} {element.name}
); } function GraphicElementContent({ element, }: { element: Extract; }) { return (
{element.name} {element.name}
); } function AudioElementContent({ element }: { element: AudioElement }) { const mediaAssets = useEditor((e) => e.media.getAssets()); const mediaAsset = element.sourceType === "upload" ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null) : null; const audioBuffer = element.sourceType === "library" ? element.buffer : undefined; const audioUrl = element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url; const mediaLabel = mediaAsset?.name ?? element.name; if (audioBuffer || audioUrl) { return (
); } return ( {element.name} ); } function EffectsButton({ element, track, }: { element: VideoElement | ImageElement; track: TimelineTrack; }) { const editor = useEditor(); const setActiveTab = usePropertiesStore((s) => s.setActiveTab); const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); editor.selection.setSelectedElements({ elements: [{ trackId: track.id, elementId: element.id }], }); setActiveTab(element.type, "effects"); }; return ( ); } function TiledMediaContent({ element, track, }: { element: VideoElement | ImageElement; track: TimelineTrack; }) { const mediaAssets = useEditor((e) => e.media.getAssets()); const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); const imageUrl = element.type === "video" ? mediaAsset?.thumbnailUrl : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url); if (!imageUrl) { return ( {element.name} ); } const trackHeight = getTrackHeight({ type: track.type }); const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; return ( <>
) : null } hasFade={true} /> ); } function MediaElementHeader({ name, leading, hasFade, }: { name?: string | null; leading?: ReactNode; hasFade?: boolean; }) { if (!name && !leading) { return null; } return (
{leading &&
{leading}
} {name && ( {name} )}
); } function ElementContent({ element, track }: ElementContentProps) { switch (element.type) { case "text": return ; case "effect": return ; case "sticker": return ; case "graphic": return ; case "audio": return ; case "video": case "image": return ; } } 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: TActionWithOptionalArgs; children: ReactNode; }) { return ( { event.stopPropagation(); invokeAction(action); }} textRight={getDisplayShortcut({ action })} {...props} > {children} ); }