summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-27 00:55:15 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:56:48 +0200
commit4d127a038bf768eded68aae3cfa5c1b12241bdac (patch)
tree16bbb801caf6770546cff4fdb37b6408ec9ead98 /apps/web/src/components/editor/panels/timeline/timeline-element.tsx
parent82817cb50cb0487350573d6129130aec705f924b (diff)
downloadopencut-4d127a038bf768eded68aae3cfa5c1b12241bdac.tar.gz
chore: normalize line endings
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline/timeline-element.tsx')
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx1868
1 files changed, 934 insertions, 934 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index 13a31375..8960e3a6 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -1,934 +1,934 @@
-"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 {
- useKeyframeDrag,
- type KeyframeDragState,
-} from "@/hooks/timeline/element/use-keyframe-drag";
-import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
-import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import { getElementKeyframes } from "@/lib/animation";
-import {
- getElementClasses,
- getTrackHeight,
- canElementHaveAudio,
- canElementBeHidden,
- hasElementEffects,
- hasMediaId,
- timelineTimeToPixels,
- timelineTimeToSnappedPixels,
-} from "@/lib/timeline";
-import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants";
-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 {
- 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 type { ComponentProps, 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";
-
-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);
-
- 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 = timelineTimeToPixels({
- time: displayedDuration,
- zoomLevel,
- });
- const elementLeft = timelineTimeToSnappedPixels({
- time: displayedStartTime,
- zoomLevel,
- });
- 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 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"
- style={{
- left: `${elementLeft}px`,
- width: `${elementWidth}px`,
- transform:
- isBeingDragged && dragState.isDragging
- ? `translate3d(0, ${dragOffsetY}px, 0)`
- : undefined,
- }}
- >
- <ElementInner
- element={element}
- track={track}
- isSelected={isSelected}
- onElementClick={onElementClick}
- onElementMouseDown={onElementMouseDown}
- handleResizeStart={handleResizeStart}
- isDropTarget={isDropTarget}
- />
- {isSelected && (
- <div className="pointer-events-none absolute inset-0 overflow-hidden">
- <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 />
- {canElementHaveAudio(element) && hasAudio && (
- <MuteMenuItem
- isMultipleSelected={selectedElements.length > 1}
- isCurrentElementSelected={isCurrentElementSelected}
- isMuted={isMuted}
- />
- )}
- {canElementBeHidden(element) && (
- <VisibilityMenuItem
- element={element}
- isMultipleSelected={selectedElements.length > 1}
- isCurrentElementSelected={isCurrentElementSelected}
- />
- )}
- {selectedElements.length === 1 && (
- <ActionMenuItem
- action="duplicate-selected"
- icon={<HugeiconsIcon icon={Copy01Icon} />}
- >
- Duplicate
- </ActionMenuItem>
- )}
- {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,
- onElementClick,
- onElementMouseDown,
- handleResizeStart,
- isDropTarget = false,
-}: {
- element: TimelineElementType;
- track: TimelineTrack;
- isSelected: boolean;
- onElementClick: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- onElementMouseDown: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- handleResizeStart: (params: {
- event: React.MouseEvent;
- elementId: string;
- 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",
- getElementClasses({ type: track.type }),
- isReducedOpacity && "opacity-50",
- )}
- >
- <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="flex flex-1 min-h-0 items-center overflow-hidden">
- <ElementContent element={element} track={track} />
- </div>
- </button>
- </div>
- </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: {
- event: React.MouseEvent;
- elementId: string;
- 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) => handleResizeStart({ event, elementId, 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"
- 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 mt-1.5 text-black",
- isIndicatorSelected ? "fill-primary" : "fill-white",
- )}
- strokeWidth={1.5}
- />
- </button>
- );
- });
-}
-
-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={ELEMENT_TYPE_CONFIG.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>
- );
-}
+"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 {
+ useKeyframeDrag,
+ type KeyframeDragState,
+} from "@/hooks/timeline/element/use-keyframe-drag";
+import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
+import type { SnapPoint } from "@/lib/timeline/snap-utils";
+import { getElementKeyframes } from "@/lib/animation";
+import {
+ getElementClasses,
+ getTrackHeight,
+ canElementHaveAudio,
+ canElementBeHidden,
+ hasElementEffects,
+ hasMediaId,
+ timelineTimeToPixels,
+ timelineTimeToSnappedPixels,
+} from "@/lib/timeline";
+import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants";
+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 {
+ 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 type { ComponentProps, 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";
+
+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);
+
+ 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 = timelineTimeToPixels({
+ time: displayedDuration,
+ zoomLevel,
+ });
+ const elementLeft = timelineTimeToSnappedPixels({
+ time: displayedStartTime,
+ zoomLevel,
+ });
+ 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 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"
+ style={{
+ left: `${elementLeft}px`,
+ width: `${elementWidth}px`,
+ transform:
+ isBeingDragged && dragState.isDragging
+ ? `translate3d(0, ${dragOffsetY}px, 0)`
+ : undefined,
+ }}
+ >
+ <ElementInner
+ element={element}
+ track={track}
+ isSelected={isSelected}
+ onElementClick={onElementClick}
+ onElementMouseDown={onElementMouseDown}
+ handleResizeStart={handleResizeStart}
+ isDropTarget={isDropTarget}
+ />
+ {isSelected && (
+ <div className="pointer-events-none absolute inset-0 overflow-hidden">
+ <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 />
+ {canElementHaveAudio(element) && hasAudio && (
+ <MuteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ isMuted={isMuted}
+ />
+ )}
+ {canElementBeHidden(element) && (
+ <VisibilityMenuItem
+ element={element}
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ />
+ )}
+ {selectedElements.length === 1 && (
+ <ActionMenuItem
+ action="duplicate-selected"
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ >
+ Duplicate
+ </ActionMenuItem>
+ )}
+ {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,
+ onElementClick,
+ onElementMouseDown,
+ handleResizeStart,
+ isDropTarget = false,
+}: {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ onElementClick: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementMouseDown: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ handleResizeStart: (params: {
+ event: React.MouseEvent;
+ elementId: string;
+ 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",
+ getElementClasses({ type: track.type }),
+ isReducedOpacity && "opacity-50",
+ )}
+ >
+ <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="flex flex-1 min-h-0 items-center overflow-hidden">
+ <ElementContent element={element} track={track} />
+ </div>
+ </button>
+ </div>
+ </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: {
+ event: React.MouseEvent;
+ elementId: string;
+ 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) => handleResizeStart({ event, elementId, 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"
+ 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 mt-1.5 text-black",
+ isIndicatorSelected ? "fill-primary" : "fill-white",
+ )}
+ strokeWidth={1.5}
+ />
+ </button>
+ );
+ });
+}
+
+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={ELEMENT_TYPE_CONFIG.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>
+ );
+}