"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 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 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, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; import type { ComponentProps } from "react"; 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; } 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 handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { requestRevealMedia(element.mediaId); } }; const isMuted = canElementHaveAudio(element) && element.muted === true; return (
} > 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) => 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, }: { 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; }) { 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 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() { 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; }) { return ( { event.stopPropagation(); invokeAction(action); }} textRight={getDisplayShortcut(action)} {...props} > {children} ); }