OpenCut/apps/web/src/components/editor/panels/timeline/timeline-element.tsx

1281 lines
31 KiB
TypeScript

"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<number, KeyframeIndicator>();
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 ? (
<ExpandedKeyframeLanes
rows={expandedRows}
keyframes={elementKeyframes}
trackId={track.id}
elementId={element.id}
displayedStartTime={displayedStartTime}
zoomLevel={zoomLevel}
elementLeft={elementLeft}
keyframeDragState={keyframeDragState}
onKeyframeMouseDown={handleKeyframeMouseDown}
onKeyframeClick={handleKeyframeClick}
getVisualOffsetPx={getVisualOffsetPx}
containerRef={expandedLanesRef}
onLaneMouseDown={handleExpandedAreaMouseDown}
onLaneClick={handleExpandedAreaClick}
selectionBox={keyframeSelectionBox}
isBoxSelecting={isKeyframeBoxSelecting}
/>
) : null;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className="absolute top-0 select-none"
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
height:
expandedRows.length > 0
? `${baseTrackHeight + expansionHeight}px`
: "100%",
transform:
isBeingDragged && dragState.isDragging
? `translate3d(0, ${dragOffsetY}px, 0)`
: undefined,
}}
>
<ElementInner
element={element}
track={track}
isSelected={isSelected}
isExpanded={expandedRows.length > 0}
baseTrackHeight={baseTrackHeight}
expandedContent={expandedContent}
onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown}
onResizeStart={handleElementResizeStart}
isDropTarget={isDropTarget}
/>
{isSelected && (
<div
className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden"
style={{ height: `${baseTrackHeight}px` }}
>
<KeyframeIndicators
indicators={keyframeIndicators}
dragState={keyframeDragState}
displayedStartTime={displayedStartTime}
elementLeft={elementLeft}
onKeyframeMouseDown={handleKeyframeMouseDown}
onKeyframeClick={handleKeyframeClick}
getVisualOffsetPx={getVisualOffsetPx}
/>
</div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-64">
<ActionMenuItem
action="split"
icon={<HugeiconsIcon icon={ScissorIcon} />}
>
Split
</ActionMenuItem>
<CopyMenuItem />
{selectedElements.length === 1 && (
<ActionMenuItem
action="duplicate-selected"
icon={<HugeiconsIcon icon={Copy01Icon} />}
>
Duplicate
</ActionMenuItem>
)}
{canElementHaveAudio(element) && hasAudio && (
<MuteMenuItem
isMultipleSelected={selectedElements.length > 1}
isCurrentElementSelected={isCurrentElementSelected}
isMuted={isMuted}
/>
)}
{canToggleCurrentSourceAudio && (
<ContextMenuItem
icon={
<HugeiconsIcon
icon={isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon}
/>
}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
invokeAction("toggle-source-audio");
}}
>
{sourceAudioLabel}
</ContextMenuItem>
)}
{canElementBeHidden(element) && (
<VisibilityMenuItem
element={element}
isMultipleSelected={selectedElements.length > 1}
isCurrentElementSelected={isCurrentElementSelected}
/>
)}
{hasKeyframes && (
<ContextMenuItem
icon={<HugeiconsIcon icon={KeyframeIcon} />}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
toggleElementExpanded(element.id);
}}
>
{isExpanded ? "Collapse keyframes" : "Expand keyframes"}
</ContextMenuItem>
)}
{selectedElements.length === 1 && hasMediaId(element) && (
<>
<ContextMenuItem
icon={<HugeiconsIcon icon={Search01Icon} />}
onClick={(event: React.MouseEvent) =>
handleRevealInMedia({ event })
}
>
Reveal media
</ContextMenuItem>
<ContextMenuItem
icon={<HugeiconsIcon icon={Exchange01Icon} />}
disabled
>
Replace media
</ContextMenuItem>
</>
)}
<ContextMenuSeparator />
<DeleteMenuItem
isMultipleSelected={selectedElements.length > 1}
isCurrentElementSelected={isCurrentElementSelected}
elementType={element.type}
selectedCount={selectedElements.length}
/>
</ContextMenuContent>
</ContextMenu>
);
}
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 (
<div
className="absolute top-0 bottom-0"
style={{
left: `${ELEMENT_RING_WIDTH_PX}px`,
right: `${ELEMENT_RING_WIDTH_PX}px`,
}}
>
<div
className="absolute inset-0 rounded-sm"
style={
isSelected
? {
boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`,
}
: undefined
}
>
<div
className={cn(
"absolute inset-0 overflow-hidden rounded-sm",
isExpanded && "bg-background",
)}
>
<button
type="button"
tabIndex={-1}
className="absolute inset-0 size-full flex flex-col"
onClick={(event) => onElementClick(event, element)}
onMouseDown={(event) => onElementMouseDown(event, element)}
>
<div
className={cn(
"flex shrink-0 items-center overflow-hidden",
getTimelineElementClassName({
type: getTrackTypeForElementType({
elementType: element.type,
}),
}),
isReducedOpacity && "opacity-50",
)}
style={{ height: `${baseTrackHeight}px` }}
>
<div className="flex flex-1 min-h-0 items-center overflow-hidden">
<ElementContent element={element} track={track} />
</div>
</div>
{expandedContent}
</button>
</div>
</div>
{isSelected && (
<>
<ResizeHandle
side="left"
element={element}
track={track}
onResizeStart={onResizeStart}
/>
<ResizeHandle
side="right"
element={element}
track={track}
onResizeStart={onResizeStart}
/>
</>
)}
</div>
);
}
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 (
<button
type="button"
className={cn(
"absolute top-0 bottom-0 w-2",
isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
)}
onMouseDown={(event) => onResizeStart({ event, element, track, side })}
onClick={(event) => event.stopPropagation()}
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
></button>
);
}
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 (
<button
key={indicator.time}
type="button"
className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab mr-0.5"
style={{ left: visualOffsetPx }}
onMouseDown={(event) =>
onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
}
onClick={(event) =>
onKeyframeClick({
event,
keyframes: indicator.keyframes,
orderedKeyframes,
indicatorTime: indicator.time,
})
}
aria-label="Select keyframe"
>
<HugeiconsIcon
icon={KeyframeIcon}
className={cn(
"size-3.5 text-black",
isIndicatorSelected ? "fill-primary" : "fill-white",
)}
strokeWidth={1.5}
/>
</button>
);
});
}
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<HTMLDivElement | null>;
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 (
<div
ref={containerRef}
className="relative flex flex-col"
onMouseDown={onLaneMouseDown}
onClick={onLaneClick}
>
{rows.map((row) => {
const laneKeyframes = keyframes.filter(
(kf) => kf.propertyPath === row.propertyPath,
);
return (
<div
key={row.propertyPath}
className={cn(
"relative flex items-center bg-muted/50",
)}
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
>
{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 (
<button
key={kf.id}
type="button"
className={cn(
"pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab",
isBoxSelecting && "pointer-events-none",
)}
style={{ left: visualOffset }}
onMouseDown={(event) => {
event.stopPropagation();
onKeyframeMouseDown({
event,
keyframes: [keyframeRef],
});
}}
onClick={(event) => {
event.stopPropagation();
onKeyframeClick({
event,
keyframes: [keyframeRef],
orderedKeyframes,
indicatorTime: kf.time,
});
}}
aria-label="Select keyframe"
>
<HugeiconsIcon
icon={KeyframeIcon}
className={cn(
"size-3.5 text-black mr-1",
isSelected
? "fill-primary"
: "fill-white",
)}
strokeWidth={1.5}
/>
</button>
);
})}
</div>
);
})}
{selectionBox && (
<SelectionBox
startPos={selectionBox.startPos}
currentPos={selectionBox.currentPos}
containerRef={containerRef}
isActive={selectionBox.isActive}
/>
)}
</div>
);
}
interface ElementContentProps {
element: TimelineElementType;
track: TimelineTrack;
}
function TextElementContent({
element,
}: {
element: Extract<TimelineElementType, { 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>
);
}
function EffectElementContent({
element,
}: {
element: Extract<TimelineElementType, { type: "effect" }>;
}) {
return (
<div className="flex size-full items-center justify-start gap-1 pl-2">
<HugeiconsIcon
icon={MagicWand05Icon}
className="size-4 shrink-0 text-white"
/>
<span className="truncate text-xs text-white">{element.name}</span>
</div>
);
}
function StickerElementContent({
element,
}: {
element: Extract<TimelineElementType, { type: "sticker" }>;
}) {
return (
<div className="flex size-full items-center gap-2 pl-2">
<Image
src={resolveStickerId({
stickerId: element.stickerId,
options: { width: 20, height: 20 },
})}
alt={element.name}
className="size-4 shrink-0"
width={20}
height={20}
unoptimized
/>
<span className="truncate text-xs text-white">{element.name}</span>
</div>
);
}
function GraphicElementContent({
element,
}: {
element: Extract<TimelineElementType, { type: "graphic" }>;
}) {
return (
<div className="flex size-full items-center gap-2 pl-2">
<Image
src={buildGraphicPreviewUrl({
definitionId: element.definitionId,
params: element.params,
size: 20,
})}
alt={element.name}
className="size-4 shrink-0"
width={20}
height={20}
unoptimized
/>
<span className="truncate text-xs text-white">{element.name}</span>
</div>
);
}
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 (
<div className="relative size-full">
<AudioWaveform
audioBuffer={audioBuffer}
audioUrl={audioUrl}
color={TIMELINE_TRACK_THEME.audio.waveformColor}
/>
<MediaElementHeader name={mediaLabel} hasFade={false} />
</div>
);
}
return (
<span className="text-foreground/80 truncate text-xs">{element.name}</span>
);
}
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 (
<button
type="button"
className="flex shrink-0 justify-center text-white cursor-pointer"
onMouseDown={(event) => event.stopPropagation()}
onClick={handleClick}
>
<HugeiconsIcon icon={MagicWand05Icon} size={12} />
</button>
);
}
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 (
<span className="text-foreground/80 truncate text-xs">
{element.name}
</span>
);
}
const trackHeight = getTrackHeight({ type: track.type });
const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO;
return (
<>
<div
className="absolute inset-0"
style={{
backgroundColor: "var(--muted)",
backgroundImage: `url(${imageUrl})`,
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
/>
<MediaElementHeader
name={mediaAsset?.name}
leading={
hasElementEffects({ element }) ? (
<EffectsButton element={element} track={track} />
) : null
}
hasFade={true}
/>
</>
);
}
function MediaElementHeader({
name,
leading,
hasFade,
}: {
name?: string | null;
leading?: ReactNode;
hasFade?: boolean;
}) {
if (!name && !leading) {
return null;
}
return (
<div
className={cn(
"absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1",
hasFade && "from-black/30 to-transparent",
)}
>
{leading && <div className="pl-1">{leading}</div>}
{name && (
<span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75">
{name}
</span>
)}
</div>
);
}
function ElementContent({ element, track }: ElementContentProps) {
switch (element.type) {
case "text":
return <TextElementContent element={element} />;
case "effect":
return <EffectElementContent element={element} />;
case "sticker":
return <StickerElementContent element={element} />;
case "graphic":
return <GraphicElementContent element={element} />;
case "audio":
return <AudioElementContent element={element} />;
case "video":
case "image":
return <TiledMediaContent element={element} track={track} />;
}
}
function CopyMenuItem() {
return (
<ActionMenuItem
action="copy-selected"
icon={<HugeiconsIcon icon={Copy01Icon} />}
>
Copy
</ActionMenuItem>
);
}
function MuteMenuItem({
isMultipleSelected,
isCurrentElementSelected,
isMuted,
}: {
isMultipleSelected: boolean;
isCurrentElementSelected: boolean;
isMuted: boolean;
}) {
const getIcon = () => {
if (isMultipleSelected && isCurrentElementSelected) {
return <HugeiconsIcon icon={VolumeMute02Icon} />;
}
return isMuted ? (
<HugeiconsIcon icon={VolumeOffIcon} />
) : (
<HugeiconsIcon icon={VolumeHighIcon} />
);
};
return (
<ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}>
{isMuted ? "Unmute" : "Mute"}
</ActionMenuItem>
);
}
function VisibilityMenuItem({
element,
isMultipleSelected,
isCurrentElementSelected,
}: {
element: TimelineElementType;
isMultipleSelected: boolean;
isCurrentElementSelected: boolean;
}) {
const isHidden = canElementBeHidden(element) && element.hidden;
const getIcon = () => {
if (isMultipleSelected && isCurrentElementSelected) {
return <HugeiconsIcon icon={ViewOffSlashIcon} />;
}
return isHidden ? (
<HugeiconsIcon icon={ViewIcon} />
) : (
<HugeiconsIcon icon={ViewOffSlashIcon} />
);
};
return (
<ActionMenuItem
action="toggle-elements-visibility-selected"
icon={getIcon()}
>
{isHidden ? "Show" : "Hide"}
</ActionMenuItem>
);
}
function DeleteMenuItem({
isMultipleSelected,
isCurrentElementSelected,
elementType,
selectedCount,
}: {
isMultipleSelected: boolean;
isCurrentElementSelected: boolean;
elementType: TimelineElementType["type"];
selectedCount: number;
}) {
return (
<ActionMenuItem
action="delete-selected"
variant="destructive"
icon={<HugeiconsIcon icon={Delete02Icon} />}
>
{isMultipleSelected && isCurrentElementSelected
? `Delete ${selectedCount} elements`
: `Delete ${elementType === "text" ? "text" : "clip"}`}
</ActionMenuItem>
);
}
function ActionMenuItem({
action,
children,
...props
}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
action: TActionWithOptionalArgs;
children: ReactNode;
}) {
return (
<ContextMenuItem
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
invokeAction(action);
}}
textRight={getDisplayShortcut({ action })}
{...props}
>
{children}
</ContextMenuItem>
);
}