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 e47bcba0..a3555e94 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -1,1249 +1,1280 @@ -"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 { 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"; - -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(); - 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; - onResizeStart: (params: { - event: React.MouseEvent; - element: TimelineElementType; - track: TimelineTrack; - side: "left" | "right"; - }) => 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, - onResizeStart, - 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, - }); - - 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 - : renderElement.startTime; - const displayedStartTime = elementStartTime; - const displayedDuration = renderElement.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 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 ? ( - - ) : null; - - return ( - - -
0 - ? `${baseTrackHeight + expansionHeight}px` - : "100%", - transform: - isBeingDragged && dragState.isDragging - ? `translate3d(0, ${dragOffsetY}px, 0)` - : undefined, - }} - > - 0} - baseTrackHeight={baseTrackHeight} - expandedContent={expandedContent} - onElementClick={onElementClick} - onElementMouseDown={onElementMouseDown} - onResizeStart={onResizeStart} - isDropTarget={isDropTarget} - /> - {isSelected && ( -
- -
- )} -
-
- - } - > - Split - - - {selectedElements.length === 1 && ( - } - > - Duplicate - - )} - {canElementHaveAudio(element) && hasAudio && ( - 1} - isCurrentElementSelected={isCurrentElementSelected} - isMuted={isMuted} - /> - )} - {canToggleCurrentSourceAudio && ( - - } - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - invokeAction("toggle-source-audio"); - }} - > - {sourceAudioLabel} - - )} - {canElementBeHidden(element) && ( - 1} - isCurrentElementSelected={isCurrentElementSelected} - /> - )} - {hasKeyframes && ( - } - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - toggleElementExpanded(element.id); - }} - > - {isExpanded ? "Collapse keyframes" : "Expand keyframes"} - - )} - {selectedElements.length === 1 && hasMediaId(element) && ( - <> - } - onClick={(event: React.MouseEvent) => - handleRevealInMedia({ event }) - } - > - Reveal media - - } - disabled - > - Replace media - - - )} - - 1} - isCurrentElementSelected={isCurrentElementSelected} - elementType={element.type} - selectedCount={selectedElements.length} - /> - -
- ); -} - -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 ( -
-
-
- -
-
- - {isSelected && ( - <> - - - - )} -
- ); -} - -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 ( - - ); -} - -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 ( - - ); - }); -} - -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; - 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 ( -
- {rows.map((row) => { - const laneKeyframes = keyframes.filter( - (kf) => kf.propertyPath === row.propertyPath, - ); - return ( -
- {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 ( - - ); - })} -
- ); - })} - {selectionBox && ( - - )} -
- ); -} - -interface ElementContentProps { - element: TimelineElementType; - track: TimelineTrack; -} - -function TextElementContent({ - element, -}: { - element: Extract; -}) { - return ( -
- {element.content} -
- ); -} - -function EffectElementContent({ - element, -}: { - element: Extract; -}) { - return ( -
- - {element.name} -
- ); -} - -function StickerElementContent({ - element, -}: { - element: Extract; -}) { - return ( -
- {element.name} - {element.name} -
- ); -} - -function GraphicElementContent({ - element, -}: { - element: Extract; -}) { - return ( -
- {element.name} - {element.name} -
- ); -} - -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 ( -
- - -
- ); - } - - return ( - {element.name} - ); -} - -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 ( - - ); -} - -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 ( - - {element.name} - - ); - } - - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; - - return ( - <> -
- - ) : null - } - hasFade={true} - /> - - ); -} - -function MediaElementHeader({ - name, - leading, - hasFade, -}: { - name?: string | null; - leading?: ReactNode; - hasFade?: boolean; -}) { - if (!name && !leading) { - return null; - } - - return ( -
- {leading &&
{leading}
} - {name && ( - - {name} - - )} -
- ); -} - -function ElementContent({ element, track }: ElementContentProps) { - switch (element.type) { - case "text": - return ; - case "effect": - return ; - case "sticker": - return ; - case "graphic": - return ; - case "audio": - return ; - case "video": - case "image": - return ; - } -} - -function CopyMenuItem() { - return ( - } - > - Copy - - ); -} - -function MuteMenuItem({ - isMultipleSelected, - isCurrentElementSelected, - isMuted, -}: { - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; - isMuted: boolean; -}) { - const getIcon = () => { - if (isMultipleSelected && isCurrentElementSelected) { - return ; - } - return isMuted ? ( - - ) : ( - - ); - }; - - return ( - - {isMuted ? "Unmute" : "Mute"} - - ); -} - -function VisibilityMenuItem({ - element, - isMultipleSelected, - isCurrentElementSelected, -}: { - element: TimelineElementType; - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; -}) { - const isHidden = canElementBeHidden(element) && element.hidden; - - const getIcon = () => { - if (isMultipleSelected && isCurrentElementSelected) { - return ; - } - return isHidden ? ( - - ) : ( - - ); - }; - - return ( - - {isHidden ? "Show" : "Hide"} - - ); -} - -function DeleteMenuItem({ - isMultipleSelected, - isCurrentElementSelected, - elementType, - selectedCount, -}: { - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; - elementType: TimelineElementType["type"]; - selectedCount: number; -}) { - return ( - } - > - {isMultipleSelected && isCurrentElementSelected - ? `Delete ${selectedCount} elements` - : `Delete ${elementType === "text" ? "text" : "clip"}`} - - ); -} - -function ActionMenuItem({ - action, - children, - ...props -}: Omit, "onClick" | "textRight"> & { - action: TActionWithOptionalArgs; - children: ReactNode; -}) { - return ( - { - event.stopPropagation(); - invokeAction(action); - }} - textRight={getDisplayShortcut({ action })} - {...props} - > - {children} - - ); -} +"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(); + 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 ? ( + + ) : null; + + return ( + + +
0 + ? `${baseTrackHeight + expansionHeight}px` + : "100%", + transform: + isBeingDragged && dragState.isDragging + ? `translate3d(0, ${dragOffsetY}px, 0)` + : undefined, + }} + > + 0} + baseTrackHeight={baseTrackHeight} + expandedContent={expandedContent} + onElementClick={onElementClick} + onElementMouseDown={onElementMouseDown} + onResizeStart={handleElementResizeStart} + isDropTarget={isDropTarget} + /> + {isSelected && ( +
+ +
+ )} +
+
+ + } + > + Split + + + {selectedElements.length === 1 && ( + } + > + Duplicate + + )} + {canElementHaveAudio(element) && hasAudio && ( + 1} + isCurrentElementSelected={isCurrentElementSelected} + isMuted={isMuted} + /> + )} + {canToggleCurrentSourceAudio && ( + + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + invokeAction("toggle-source-audio"); + }} + > + {sourceAudioLabel} + + )} + {canElementBeHidden(element) && ( + 1} + isCurrentElementSelected={isCurrentElementSelected} + /> + )} + {hasKeyframes && ( + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + toggleElementExpanded(element.id); + }} + > + {isExpanded ? "Collapse keyframes" : "Expand keyframes"} + + )} + {selectedElements.length === 1 && hasMediaId(element) && ( + <> + } + onClick={(event: React.MouseEvent) => + handleRevealInMedia({ event }) + } + > + Reveal media + + } + disabled + > + Replace media + + + )} + + 1} + isCurrentElementSelected={isCurrentElementSelected} + elementType={element.type} + selectedCount={selectedElements.length} + /> + +
+ ); +} + +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 ( +
+
+
+ +
+
+ + {isSelected && ( + <> + + + + )} +
+ ); +} + +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 ( + + ); +} + +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 ( + + ); + }); +} + +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; + 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 ( +
+ {rows.map((row) => { + const laneKeyframes = keyframes.filter( + (kf) => kf.propertyPath === row.propertyPath, + ); + return ( +
+ {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 ( + + ); + })} +
+ ); + })} + {selectionBox && ( + + )} +
+ ); +} + +interface ElementContentProps { + element: TimelineElementType; + track: TimelineTrack; +} + +function TextElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ {element.content} +
+ ); +} + +function EffectElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ + {element.name} +
+ ); +} + +function StickerElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ {element.name} + {element.name} +
+ ); +} + +function GraphicElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ {element.name} + {element.name} +
+ ); +} + +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 ( +
+ + +
+ ); + } + + return ( + {element.name} + ); +} + +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 ( + + ); +} + +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 ( + + {element.name} + + ); + } + + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; + + return ( + <> +
+ + ) : null + } + hasFade={true} + /> + + ); +} + +function MediaElementHeader({ + name, + leading, + hasFade, +}: { + name?: string | null; + leading?: ReactNode; + hasFade?: boolean; +}) { + if (!name && !leading) { + return null; + } + + return ( +
+ {leading &&
{leading}
} + {name && ( + + {name} + + )} +
+ ); +} + +function ElementContent({ element, track }: ElementContentProps) { + switch (element.type) { + case "text": + return ; + case "effect": + return ; + case "sticker": + return ; + case "graphic": + return ; + case "audio": + return ; + case "video": + case "image": + return ; + } +} + +function CopyMenuItem() { + return ( + } + > + Copy + + ); +} + +function MuteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + isMuted, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + isMuted: boolean; +}) { + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return ; + } + return isMuted ? ( + + ) : ( + + ); + }; + + return ( + + {isMuted ? "Unmute" : "Mute"} + + ); +} + +function VisibilityMenuItem({ + element, + isMultipleSelected, + isCurrentElementSelected, +}: { + element: TimelineElementType; + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; +}) { + const isHidden = canElementBeHidden(element) && element.hidden; + + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return ; + } + return isHidden ? ( + + ) : ( + + ); + }; + + return ( + + {isHidden ? "Show" : "Hide"} + + ); +} + +function DeleteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + elementType, + selectedCount, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + elementType: TimelineElementType["type"]; + selectedCount: number; +}) { + return ( + } + > + {isMultipleSelected && isCurrentElementSelected + ? `Delete ${selectedCount} elements` + : `Delete ${elementType === "text" ? "text" : "clip"}`} + + ); +} + +function ActionMenuItem({ + action, + children, + ...props +}: Omit, "onClick" | "textRight"> & { + action: TActionWithOptionalArgs; + children: ReactNode; +}) { + return ( + { + event.stopPropagation(); + invokeAction(action); + }} + textRight={getDisplayShortcut({ action })} + {...props} + > + {children} + + ); +}