"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}
);
}
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"}`}
);
}