591 lines
16 KiB
TypeScript
591 lines
16 KiB
TypeScript
"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-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 (
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
className={`absolute top-0 h-full select-none ${isBeingDragged ? "z-30" : "z-10"
|
|
}`}
|
|
style={{
|
|
left: `${elementLeft}px`,
|
|
width: `${elementWidth}px`,
|
|
transform:
|
|
isBeingDragged && dragState.isDragging
|
|
? `translate3d(0, ${dragOffsetY}px, 0)`
|
|
: undefined,
|
|
}}
|
|
>
|
|
<ElementInner
|
|
element={element}
|
|
track={track}
|
|
isSelected={isSelected}
|
|
isBeingDragged={isBeingDragged}
|
|
hasAudio={hasAudio}
|
|
isMuted={isMuted}
|
|
mediaAssets={mediaAssets}
|
|
onElementClick={onElementClick}
|
|
onElementMouseDown={onElementMouseDown}
|
|
handleResizeStart={handleResizeStart}
|
|
/>
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent className="z-200">
|
|
<ContextMenuItem
|
|
onClick={(event) => handleAction({ action: "split-selected", event })}
|
|
>
|
|
<Scissors className="mr-2 size-4" />
|
|
{selectedElements.length > 1 && isCurrentElementSelected
|
|
? `Split ${selectedElements.length} elements at playhead`
|
|
: "Split at playhead"}
|
|
</ContextMenuItem>
|
|
<CopyMenuItem
|
|
isMultipleSelected={selectedElements.length > 1}
|
|
isCurrentElementSelected={isCurrentElementSelected}
|
|
selectedCount={selectedElements.length}
|
|
onClick={(event) => handleAction({ action: "copy-selected", event })}
|
|
/>
|
|
{canElementHaveAudio(element) && hasAudio && (
|
|
<MuteMenuItem
|
|
element={element}
|
|
isMultipleSelected={selectedElements.length > 1}
|
|
isCurrentElementSelected={isCurrentElementSelected}
|
|
isMuted={isMuted}
|
|
selectedCount={selectedElements.length}
|
|
onClick={(event) =>
|
|
handleAction({ action: "toggle-elements-muted-selected", event })
|
|
}
|
|
/>
|
|
)}
|
|
{canElementBeHidden(element) && (
|
|
<VisibilityMenuItem
|
|
element={element}
|
|
isMultipleSelected={selectedElements.length > 1}
|
|
isCurrentElementSelected={isCurrentElementSelected}
|
|
selectedCount={selectedElements.length}
|
|
onClick={(event) =>
|
|
handleAction({
|
|
action: "toggle-elements-visibility-selected",
|
|
event,
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
{selectedElements.length === 1 && (
|
|
<ContextMenuItem
|
|
onClick={(event) =>
|
|
handleAction({ action: "duplicate-selected", event })
|
|
}
|
|
>
|
|
<Copy className="mr-2 size-4" />
|
|
Duplicate {element.type === "text" ? "text" : "clip"}
|
|
</ContextMenuItem>
|
|
)}
|
|
<ContextMenuItem disabled>
|
|
<ArrowUpDown className="mr-2 size-4" />
|
|
Move to track (Coming soon)
|
|
</ContextMenuItem>
|
|
{selectedElements.length === 1 && hasMediaId(element) && (
|
|
<>
|
|
<ContextMenuItem
|
|
onClick={(event) => handleRevealInMedia({ event })}
|
|
>
|
|
<Search className="mr-2 size-4" />
|
|
Reveal in media
|
|
</ContextMenuItem>
|
|
<ContextMenuItem disabled>
|
|
<RefreshCw className="mr-2 size-4" />
|
|
Replace clip (Coming soon)
|
|
</ContextMenuItem>
|
|
</>
|
|
)}
|
|
<ContextMenuSeparator />
|
|
<DeleteMenuItem
|
|
isMultipleSelected={selectedElements.length > 1}
|
|
isCurrentElementSelected={isCurrentElementSelected}
|
|
elementType={element.type}
|
|
selectedCount={selectedElements.length}
|
|
onClick={(event) =>
|
|
handleAction({ action: "delete-selected", event })
|
|
}
|
|
/>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
|
|
{
|
|
type: track.type,
|
|
},
|
|
)} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
|
|
onClick={(e) => onElementClick(e, element)}
|
|
onMouseDown={(e) => onElementMouseDown(e, element)}
|
|
onContextMenu={(e) => onElementMouseDown(e, element)}
|
|
>
|
|
<div className="absolute inset-0 flex h-full items-center">
|
|
<ElementContent
|
|
element={element}
|
|
track={track}
|
|
isSelected={isSelected}
|
|
mediaAssets={mediaAssets}
|
|
/>
|
|
</div>
|
|
|
|
{(hasAudio ? isMuted : canElementBeHidden(element) && element.hidden) && (
|
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
|
|
{hasAudio ? (
|
|
<VolumeX className="size-6 text-white" />
|
|
) : (
|
|
<EyeOff className="size-6 text-white" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{isSelected && (
|
|
<>
|
|
<ResizeHandle
|
|
side="left"
|
|
elementId={element.id}
|
|
handleResizeStart={handleResizeStart}
|
|
/>
|
|
<ResizeHandle
|
|
side="right"
|
|
elementId={element.id}
|
|
handleResizeStart={handleResizeStart}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className={`bg-primary absolute bottom-0 top-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
|
|
onMouseDown={(e) => handleResizeStart({ e, elementId, side })}
|
|
>
|
|
<div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ElementContent({
|
|
element,
|
|
track,
|
|
isSelected,
|
|
mediaAssets,
|
|
}: {
|
|
element: TimelineElementType;
|
|
track: TimelineTrack;
|
|
isSelected: boolean;
|
|
mediaAssets: MediaAsset[];
|
|
}) {
|
|
if (element.type === "text") {
|
|
return (
|
|
<div className="flex size-full items-center justify-start pl-2">
|
|
<span className="truncate text-xs text-white">{element.content}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (element.type === "sticker") {
|
|
return (
|
|
<div className="flex size-full items-center gap-2 pl-2">
|
|
<img
|
|
src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
|
|
alt={element.name}
|
|
className="size-5 shrink-0"
|
|
/>
|
|
<span className="truncate text-xs text-white">{element.name}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="flex size-full items-center gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<AudioWaveform
|
|
audioBuffer={audioBuffer}
|
|
audioUrl={audioUrl}
|
|
height={24}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span className="text-foreground/80 truncate text-xs">
|
|
{element.name}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
|
|
if (!mediaAsset) {
|
|
return (
|
|
<span className="text-foreground/80 truncate text-xs">
|
|
{element.name}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="flex size-full items-center justify-center">
|
|
<div
|
|
className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
|
|
>
|
|
<div
|
|
className="absolute left-0 right-0"
|
|
style={{
|
|
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
|
|
backgroundRepeat: "repeat-x",
|
|
backgroundSize: `${tileWidth}px ${trackHeight}px`,
|
|
backgroundPosition: "left center",
|
|
pointerEvents: "none",
|
|
top: isSelected ? "0.25rem" : "0rem",
|
|
bottom: isSelected ? "0.25rem" : "0rem",
|
|
}}
|
|
aria-label={`Tiled ${mediaAsset.type === "image" ? "background" : "thumbnail"} of ${mediaAsset.name}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span className="text-foreground/80 truncate text-xs">{element.name}</span>
|
|
);
|
|
}
|
|
|
|
function CopyMenuItem({
|
|
isMultipleSelected,
|
|
isCurrentElementSelected,
|
|
selectedCount,
|
|
onClick,
|
|
}: {
|
|
isMultipleSelected: boolean;
|
|
isCurrentElementSelected: boolean;
|
|
selectedCount: number;
|
|
onClick: (e: React.MouseEvent) => void;
|
|
}) {
|
|
return (
|
|
<ContextMenuItem onClick={onClick}>
|
|
<Copy className="mr-2 size-4" />
|
|
{isMultipleSelected && isCurrentElementSelected
|
|
? `Copy ${selectedCount} elements`
|
|
: "Copy element"}
|
|
</ContextMenuItem>
|
|
);
|
|
}
|
|
|
|
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 <VolumeX className="mr-2 size-4" />;
|
|
}
|
|
return isMuted ? (
|
|
<Volume2 className="mr-2 size-4" />
|
|
) : (
|
|
<VolumeX className="mr-2 size-4" />
|
|
);
|
|
};
|
|
|
|
const getLabel = () => {
|
|
if (isMultipleSelected && isCurrentElementSelected) {
|
|
return `Toggle mute ${selectedCount} elements`;
|
|
}
|
|
const suffix = element.type === "text" ? "text" : "clip";
|
|
return isMuted ? `Unmute ${suffix}` : `Mute ${suffix}`;
|
|
};
|
|
|
|
return (
|
|
<ContextMenuItem onClick={onClick}>
|
|
{getIcon()}
|
|
<span>{getLabel()}</span>
|
|
</ContextMenuItem>
|
|
);
|
|
}
|
|
|
|
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 <EyeOff className="mr-2 size-4" />;
|
|
}
|
|
return isHidden ? (
|
|
<Eye className="mr-2 size-4" />
|
|
) : (
|
|
<EyeOff className="mr-2 size-4" />
|
|
);
|
|
};
|
|
|
|
const getLabel = () => {
|
|
if (isMultipleSelected && isCurrentElementSelected) {
|
|
return `Toggle visibility ${selectedCount} elements`;
|
|
}
|
|
const suffix = element.type === "text" ? "text" : "clip";
|
|
return isHidden ? `Show ${suffix}` : `Hide ${suffix}`;
|
|
};
|
|
|
|
return (
|
|
<ContextMenuItem onClick={onClick}>
|
|
{getIcon()}
|
|
<span>{getLabel()}</span>
|
|
</ContextMenuItem>
|
|
);
|
|
}
|
|
|
|
function DeleteMenuItem({
|
|
isMultipleSelected,
|
|
isCurrentElementSelected,
|
|
elementType,
|
|
selectedCount,
|
|
onClick,
|
|
}: {
|
|
isMultipleSelected: boolean;
|
|
isCurrentElementSelected: boolean;
|
|
elementType: TimelineElementType["type"];
|
|
selectedCount: number;
|
|
onClick: (e: React.MouseEvent) => void;
|
|
}) {
|
|
return (
|
|
<ContextMenuItem
|
|
onClick={onClick}
|
|
className="text-destructive focus:text-destructive"
|
|
>
|
|
<Trash2 className="mr-2 size-4" />
|
|
{isMultipleSelected && isCurrentElementSelected
|
|
? `Delete ${selectedCount} elements`
|
|
: `Delete ${elementType === "text" ? "text" : "clip"}`}
|
|
</ContextMenuItem>
|
|
);
|
|
}
|