diff options
Diffstat (limited to 'apps/web/src')
16 files changed, 2696 insertions, 2845 deletions
diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx index 846d0bbb..83a1f678 100644 --- a/apps/web/src/components/editor/snap-indicator.tsx +++ b/apps/web/src/components/editor/snap-indicator.tsx @@ -40,7 +40,7 @@ export function SnapIndicator({ return ( <div - className="absolute pointer-events-none z-[90] ml-48" + className="absolute pointer-events-none z-[90]" style={{ left: `${leftPosition}px`, top: 0, diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index 012d0dc7..71b2cd26 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -1,351 +1,470 @@ -"use client"; - -import { Scissors, Trash2, Copy, RefreshCw } from "lucide-react"; -import { useMediaStore } from "@/stores/media-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { usePlaybackStore } from "@/stores/playback-store"; -import AudioWaveform from "@/components/editor/audio-waveform"; -import { toast } from "sonner"; -import { TimelineElementProps } from "@/types/timeline"; -import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; -import { - getTrackElementClasses, - TIMELINE_CONSTANTS, - getTrackHeight, -} from "@/constants/timeline-constants"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; - -export function TimelineElement({ - element, - track, - zoomLevel, - isSelected, - onElementMouseDown, - onElementClick, -}: TimelineElementProps) { - const { mediaItems } = useMediaStore(); - const { - updateElementTrim, - updateElementDuration, - removeElementFromTrack, - dragState, - splitElement, - addElementToTrack, - replaceElementMedia, - } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); - - const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } = - useTimelineElementResize({ - element, - track, - zoomLevel, - onUpdateTrim: updateElementTrim, - onUpdateDuration: updateElementDuration, - }); - - const effectiveDuration = - element.duration - element.trimStart - element.trimEnd; - const elementWidth = Math.max( - TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH, - effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel - ); - - // Use real-time position during drag, otherwise use stored position - const isBeingDragged = dragState.elementId === element.id; - const elementStartTime = - isBeingDragged && dragState.isDragging - ? dragState.currentTime - : element.startTime; - const elementLeft = elementStartTime * 50 * zoomLevel; - - const handleElementSplitContext = () => { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const secondElementId = splitElement(track.id, element.id, currentTime); - if (!secondElementId) { - toast.error("Failed to split element"); - } - } else { - toast.error("Playhead must be within element to split"); - } - }; - - const handleElementDuplicateContext = () => { - const { id, ...elementWithoutId } = element; - addElementToTrack(track.id, { - ...elementWithoutId, - name: element.name + " (copy)", - startTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1, - }); - }; - - const handleElementDeleteContext = () => { - removeElementFromTrack(track.id, element.id); - }; - - const handleReplaceClip = () => { - if (element.type !== "media") { - toast.error("Replace is only available for media clips"); - return; - } - - // Create a file input to select replacement media - const input = document.createElement("input"); - input.type = "file"; - input.accept = "video/*,audio/*,image/*"; - input.onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; - - try { - const success = await replaceElementMedia(track.id, element.id, file); - if (success) { - toast.success("Clip replaced successfully"); - } else { - toast.error("Failed to replace clip"); - } - } catch (error) { - toast.error("Failed to replace clip"); - console.log( - JSON.stringify({ error: "Failed to replace clip", details: error }) - ); - } - }; - input.click(); - }; - - const renderElementContent = () => { - if (element.type === "text") { - return ( - <div className="w-full h-full flex items-center justify-start pl-2"> - <span className="text-xs text-foreground/80 truncate"> - {element.content} - </span> - </div> - ); - } - - // Render media element -> - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); - if (!mediaItem) { - return ( - <span className="text-xs text-foreground/80 truncate"> - {element.name} - </span> - ); - } - - const TILE_ASPECT_RATIO = 16 / 9; - - if (mediaItem.type === "image") { - // Calculate tile size based on 16:9 aspect ratio - const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - 8; // Account for padding - const tileWidth = tileHeight * TILE_ASPECT_RATIO; - - return ( - <div className="w-full h-full flex items-center justify-center"> - <div className="bg-[#004D52] py-3 w-full h-full relative"> - {/* Background with tiled images */} - <div - className="absolute top-3 bottom-3 left-0 right-0" - style={{ - backgroundImage: mediaItem.url - ? `url(${mediaItem.url})` - : "none", - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${tileHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - aria-label={`Tiled background of ${mediaItem.name}`} - /> - {/* Overlay with vertical borders */} - <div - className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" - style={{ - backgroundImage: `repeating-linear-gradient( - to right, - transparent 0px, - transparent ${tileWidth - 1}px, - rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, - rgba(255, 255, 255, 0.6) ${tileWidth}px - )`, - backgroundPosition: "left center", - }} - /> - </div> - </div> - ); - } - - const VIDEO_TILE_PADDING = 16; - const OVERLAY_SPACE_MULTIPLIER = 1.5; - - if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { - const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - VIDEO_TILE_PADDING; - const tileWidth = tileHeight * TILE_ASPECT_RATIO; - - return ( - <div className="w-full h-full flex items-center gap-2"> - <div className="flex-1 h-full relative overflow-hidden"> - {/* Background with tiled thumbnails */} - <div - className="absolute inset-0" - style={{ - backgroundImage: mediaItem.thumbnailUrl - ? `url(${mediaItem.thumbnailUrl})` - : "none", - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${tileHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - aria-label={`Tiled thumbnail of ${mediaItem.name}`} - /> - {/* Overlay with vertical borders */} - <div - className="absolute inset-0 pointer-events-none" - style={{ - backgroundImage: `repeating-linear-gradient( - to right, - transparent 0px, - transparent ${tileWidth - 1}px, - rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, - rgba(255, 255, 255, 0.6) ${tileWidth}px - )`, - backgroundPosition: "left center", - }} - /> - </div> - {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( - <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate"> - {element.name} - </div> - ) : ( - <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]"> - {element.name} - </span> - )} - </div> - ); - } - - // Render audio element -> - if (mediaItem.type === "audio") { - return ( - <div className="w-full h-full flex items-center gap-2"> - <div className="flex-1 min-w-0"> - <AudioWaveform - audioUrl={mediaItem.url || ""} - height={24} - className="w-full" - /> - </div> - </div> - ); - } - - return ( - <span className="text-xs text-foreground/80 truncate"> - {element.name} - </span> - ); - }; - - const handleElementMouseDown = (e: React.MouseEvent) => { - if (onElementMouseDown) { - onElementMouseDown(e, element); - } - }; - - return ( - <ContextMenu> - <ContextMenuTrigger asChild> - <div - className={`absolute top-0 h-full select-none timeline-element ${ - isBeingDragged ? "z-50" : "z-10" - }`} - style={{ - left: `${elementLeft}px`, - width: `${elementWidth}px`, - }} - data-element-id={element.id} - data-track-id={track.id} - onMouseMove={resizing ? handleResizeMove : undefined} - onMouseUp={resizing ? handleResizeEnd : undefined} - onMouseLeave={resizing ? handleResizeEnd : undefined} - > - <div - className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses( - track.type - )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ - isBeingDragged ? "z-50" : "z-10" - }`} - onClick={(e) => onElementClick && onElementClick(e, element)} - onMouseDown={handleElementMouseDown} - onContextMenu={(e) => - onElementMouseDown && onElementMouseDown(e, element) - } - > - <div className="absolute inset-0 flex items-center h-full"> - {renderElementContent()} - </div> - - {isSelected && ( - <> - <div - className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" - onMouseDown={(e) => handleResizeStart(e, element.id, "left")} - /> - <div - className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50" - onMouseDown={(e) => handleResizeStart(e, element.id, "right")} - /> - </> - )} - </div> - </div> - </ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem onClick={handleElementSplitContext}> - <Scissors className="h-4 w-4 mr-2" /> - Split at playhead - </ContextMenuItem> - <ContextMenuItem onClick={handleElementDuplicateContext}> - <Copy className="h-4 w-4 mr-2" /> - Duplicate {element.type === "text" ? "text" : "clip"} - </ContextMenuItem> - {element.type === "media" && ( - <ContextMenuItem onClick={handleReplaceClip}> - <RefreshCw className="h-4 w-4 mr-2" /> - Replace clip - </ContextMenuItem> - )} - <ContextMenuSeparator /> - <ContextMenuItem - onClick={handleElementDeleteContext} - className="text-destructive focus:text-destructive" - > - <Trash2 className="h-4 w-4 mr-2" /> - Delete {element.type === "text" ? "text" : "clip"} - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ); -} +"use client";
+
+import { useState } from "react";
+import { Button } from "../ui/button";
+import {
+ MoreVertical,
+ Scissors,
+ Trash2,
+ SplitSquareHorizontal,
+ Music,
+ ChevronRight,
+ ChevronLeft,
+ Type,
+ Copy,
+ RefreshCw,
+} from "lucide-react";
+import { useMediaStore } from "@/stores/media-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import AudioWaveform from "./audio-waveform";
+import { toast } from "sonner";
+import { TimelineElementProps, TrackType } from "@/types/timeline";
+import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
+import {
+ getTrackElementClasses,
+ TIMELINE_CONSTANTS,
+ getTrackHeight,
+} from "@/constants/timeline-constants";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+} from "../ui/dropdown-menu";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "../ui/context-menu";
+
+export function TimelineElement({
+ element,
+ track,
+ zoomLevel,
+ isSelected,
+ onElementMouseDown,
+ onElementClick,
+}: TimelineElementProps) {
+ const { mediaItems } = useMediaStore();
+ const {
+ updateElementTrim,
+ updateElementDuration,
+ removeElementFromTrack,
+ dragState,
+ splitElement,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ separateAudio,
+ addElementToTrack,
+ replaceElementMedia,
+ } = useTimelineStore();
+ const { currentTime } = usePlaybackStore();
+
+ const [elementMenuOpen, setElementMenuOpen] = useState(false);
+
+ const {
+ resizing,
+ isResizing,
+ handleResizeStart,
+ handleResizeMove,
+ handleResizeEnd,
+ } = useTimelineElementResize({
+ element,
+ track,
+ zoomLevel,
+ onUpdateTrim: updateElementTrim,
+ onUpdateDuration: updateElementDuration,
+ });
+
+ const effectiveDuration =
+ element.duration - element.trimStart - element.trimEnd;
+ const elementWidth = Math.max(
+ TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
+ effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
+ );
+
+ // Use real-time position during drag, otherwise use stored position
+ const isBeingDragged = dragState.elementId === element.id;
+ const elementStartTime =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentTime
+ : element.startTime;
+ const elementLeft = elementStartTime * 50 * zoomLevel;
+
+ const handleDeleteElement = () => {
+ removeElementFromTrack(track.id, element.id);
+ setElementMenuOpen(false);
+ };
+
+ const handleSplitElement = () => {
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within element to split");
+ return;
+ }
+
+ const secondElementId = splitElement(track.id, element.id, currentTime);
+ if (!secondElementId) {
+ toast.error("Failed to split element");
+ }
+ setElementMenuOpen(false);
+ };
+
+ const handleSplitAndKeepLeft = () => {
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within element");
+ return;
+ }
+
+ splitAndKeepLeft(track.id, element.id, currentTime);
+ setElementMenuOpen(false);
+ };
+
+ const handleSplitAndKeepRight = () => {
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within element");
+ return;
+ }
+
+ splitAndKeepRight(track.id, element.id, currentTime);
+ setElementMenuOpen(false);
+ };
+
+ const handleSeparateAudio = () => {
+ if (element.type !== "media") {
+ toast.error("Audio separation only available for media elements");
+ return;
+ }
+
+ const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
+ if (!mediaItem || mediaItem.type !== "video") {
+ toast.error("Audio separation only available for video elements");
+ return;
+ }
+
+ const audioElementId = separateAudio(track.id, element.id);
+ if (!audioElementId) {
+ toast.error("Failed to separate audio");
+ }
+ setElementMenuOpen(false);
+ };
+
+ const canSplitAtPlayhead = () => {
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ return currentTime > effectiveStart && currentTime < effectiveEnd;
+ };
+
+ const canSeparateAudio = () => {
+ if (element.type !== "media") return false;
+ const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
+ return mediaItem?.type === "video" && track.type === "media";
+ };
+
+ const handleElementSplitContext = () => {
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (currentTime > effectiveStart && currentTime < effectiveEnd) {
+ const secondElementId = splitElement(track.id, element.id, currentTime);
+ if (!secondElementId) {
+ toast.error("Failed to split element");
+ }
+ } else {
+ toast.error("Playhead must be within element to split");
+ }
+ };
+
+ const handleElementDuplicateContext = () => {
+ const { id, ...elementWithoutId } = element;
+ addElementToTrack(track.id, {
+ ...elementWithoutId,
+ name: element.name + " (copy)",
+ startTime:
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1,
+ });
+ };
+
+ const handleElementDeleteContext = () => {
+ removeElementFromTrack(track.id, element.id);
+ };
+
+ const handleReplaceClip = () => {
+ if (element.type !== "media") {
+ toast.error("Replace is only available for media clips");
+ return;
+ }
+
+ // Create a file input to select replacement media
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = "video/*,audio/*,image/*";
+ input.onchange = async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+
+ try {
+ const success = await replaceElementMedia(track.id, element.id, file);
+ if (success) {
+ toast.success("Clip replaced successfully");
+ } else {
+ toast.error("Failed to replace clip");
+ }
+ } catch (error) {
+ toast.error("Failed to replace clip");
+ console.log(
+ JSON.stringify({ error: "Failed to replace clip", details: error })
+ );
+ }
+ };
+ input.click();
+ };
+
+ const renderElementContent = () => {
+ if (element.type === "text") {
+ return (
+ <div className="w-full h-full flex items-center justify-start pl-2">
+ <span className="text-xs text-foreground/80 truncate">
+ {element.content}
+ </span>
+ </div>
+ );
+ }
+
+ // Render media element ->
+ const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
+ if (!mediaItem) {
+ return (
+ <span className="text-xs text-foreground/80 truncate">
+ {element.name}
+ </span>
+ );
+ }
+
+ const TILE_ASPECT_RATIO = 16 / 9;
+
+ if (mediaItem.type === "image") {
+ // Calculate tile size based on 16:9 aspect ratio
+ const trackHeight = getTrackHeight(track.type);
+ const tileHeight = trackHeight - 8; // Account for padding
+ const tileWidth = tileHeight * TILE_ASPECT_RATIO;
+
+ return (
+ <div className="w-full h-full flex items-center justify-center">
+ <div className="bg-[#004D52] py-3 w-full h-full relative">
+ {/* Background with tiled images */}
+ <div
+ className="absolute top-3 bottom-3 left-0 right-0"
+ style={{
+ backgroundImage: mediaItem.url
+ ? `url(${mediaItem.url})`
+ : "none",
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${tileHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ }}
+ aria-label={`Tiled background of ${mediaItem.name}`}
+ />
+ {/* Overlay with vertical borders */}
+ <div
+ className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
+ style={{
+ backgroundImage: `repeating-linear-gradient(
+ to right,
+ transparent 0px,
+ transparent ${tileWidth - 1}px,
+ rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
+ rgba(255, 255, 255, 0.6) ${tileWidth}px
+ )`,
+ backgroundPosition: "left center",
+ }}
+ />
+ </div>
+ </div>
+ );
+ }
+
+ const VIDEO_TILE_PADDING = 16;
+ const OVERLAY_SPACE_MULTIPLIER = 1.5;
+
+ if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
+ const trackHeight = getTrackHeight(track.type);
+ const tileHeight = trackHeight - VIDEO_TILE_PADDING;
+ const tileWidth = tileHeight * TILE_ASPECT_RATIO;
+
+ return (
+ <div className="w-full h-full flex items-center gap-2">
+ <div className="flex-1 h-full relative overflow-hidden">
+ {/* Background with tiled thumbnails */}
+ <div
+ className="absolute inset-0"
+ style={{
+ backgroundImage: mediaItem.thumbnailUrl
+ ? `url(${mediaItem.thumbnailUrl})`
+ : "none",
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${tileHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ }}
+ aria-label={`Tiled thumbnail of ${mediaItem.name}`}
+ />
+ {/* Overlay with vertical borders */}
+ <div
+ className="absolute inset-0 pointer-events-none"
+ style={{
+ backgroundImage: `repeating-linear-gradient(
+ to right,
+ transparent 0px,
+ transparent ${tileWidth - 1}px,
+ rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
+ rgba(255, 255, 255, 0.6) ${tileWidth}px
+ )`,
+ backgroundPosition: "left center",
+ }}
+ />
+ </div>
+ {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
+ <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate">
+ {element.name}
+ </div>
+ ) : (
+ <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]">
+ {element.name}
+ </span>
+ )}
+ </div>
+ );
+ }
+
+ // Render audio element ->
+ if (mediaItem.type === "audio") {
+ return (
+ <div className="w-full h-full flex items-center gap-2">
+ <div className="flex-1 min-w-0">
+ <AudioWaveform
+ audioUrl={mediaItem.url || ""}
+ height={24}
+ className="w-full"
+ />
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <span className="text-xs text-foreground/80 truncate">
+ {element.name}
+ </span>
+ );
+ };
+
+ const handleElementMouseDown = (e: React.MouseEvent) => {
+ if (onElementMouseDown) {
+ onElementMouseDown(e, element);
+ }
+ };
+
+ return (
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ className={`absolute top-0 h-full select-none timeline-element ${
+ isBeingDragged ? "z-50" : "z-10"
+ }`}
+ style={{
+ left: `${elementLeft}px`,
+ width: `${elementWidth}px`,
+ }}
+ data-element-id={element.id}
+ data-track-id={track.id}
+ onMouseMove={resizing ? handleResizeMove : undefined}
+ onMouseUp={resizing ? handleResizeEnd : undefined}
+ onMouseLeave={resizing ? handleResizeEnd : undefined}
+ >
+ <div
+ className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
+ track.type
+ )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
+ isBeingDragged ? "z-50" : "z-10"
+ }`}
+ onClick={(e) => onElementClick && onElementClick(e, element)}
+ onMouseDown={handleElementMouseDown}
+ onContextMenu={(e) =>
+ onElementMouseDown && onElementMouseDown(e, element)
+ }
+ >
+ <div className="absolute inset-0 flex items-center h-full">
+ {renderElementContent()}
+ </div>
+
+ {isSelected && (
+ <>
+ <div
+ className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
+ onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
+ />
+ <div
+ className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
+ onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
+ />
+ </>
+ )}
+ </div>
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem onClick={handleElementSplitContext}>
+ <Scissors className="h-4 w-4 mr-2" />
+ Split at playhead
+ </ContextMenuItem>
+ <ContextMenuItem onClick={handleElementDuplicateContext}>
+ <Copy className="h-4 w-4 mr-2" />
+ Duplicate {element.type === "text" ? "text" : "clip"}
+ </ContextMenuItem>
+ {element.type === "media" && (
+ <ContextMenuItem onClick={handleReplaceClip}>
+ <RefreshCw className="h-4 w-4 mr-2" />
+ Replace clip
+ </ContextMenuItem>
+ )}
+ <ContextMenuSeparator />
+ <ContextMenuItem
+ onClick={handleElementDeleteContext}
+ className="text-destructive focus:text-destructive"
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ Delete {element.type === "text" ? "text" : "clip"}
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx index e27bfee0..78aed3a8 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline-playhead.tsx @@ -1,115 +1,116 @@ -"use client"; - -import { useRef } from "react"; -import { TimelineTrack } from "@/types/timeline"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead"; - -interface TimelinePlayheadProps { - currentTime: number; - duration: number; - zoomLevel: number; - tracks: TimelineTrack[]; - seek: (time: number) => void; - rulerRef: React.RefObject<HTMLDivElement>; - rulerScrollRef: React.RefObject<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLDivElement>; - trackLabelsRef?: React.RefObject<HTMLDivElement>; - timelineRef: React.RefObject<HTMLDivElement>; - playheadRef?: React.RefObject<HTMLDivElement>; - isSnappingToPlayhead?: boolean; -} - -export function TimelinePlayhead({ - currentTime, - duration, - zoomLevel, - tracks, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - trackLabelsRef, - timelineRef, - playheadRef: externalPlayheadRef, - isSnappingToPlayhead = false, -}: TimelinePlayheadProps) { - const internalPlayheadRef = useRef<HTMLDivElement>(null); - const playheadRef = externalPlayheadRef || internalPlayheadRef; - const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - // Get dynamic track labels width, fallback to 192px (ml-48) if no tracks or no ref - const trackLabelsWidth = 192; // Fixed width from grid layout - const leftPosition = - playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - - return ( - <> - {/* Playhead line container */} - <div - ref={playheadRef} - className="absolute pointer-events-auto z-[95]" - style={{ - left: `${trackLabelsWidth + leftPosition}px`, - top: 0, - bottom: 0, - width: "2px", // Slightly wider for better click target - }} - onMouseDown={handlePlayheadMouseDown} - > - {/* The playhead line spanning full height */} - <div - className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`} - /> - </div> - - {/* Playhead dot indicator - separate container with highest z-index */} - <div - className="absolute pointer-events-none z-[100]" - style={{ - left: `${trackLabelsWidth + leftPosition}px`, - top: 0, - bottom: 0, - }} - > - <div - className={`sticky top-1 left-1 -translate-x-[40%] transform w-3 h-3 rounded-full shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`} - /> - </div> - </> - ); -} - -// Also export a hook for getting ruler handlers -export function useTimelinePlayheadRuler({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, -}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) { - const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - return { handleRulerMouseDown, isDraggingRuler }; -} +"use client";
+
+import { useRef } from "react";
+import { TimelineTrack } from "@/types/timeline";
+import {
+ TIMELINE_CONSTANTS,
+ getTotalTracksHeight,
+} from "@/constants/timeline-constants";
+import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
+
+interface TimelinePlayheadProps {
+ currentTime: number;
+ duration: number;
+ zoomLevel: number;
+ tracks: TimelineTrack[];
+ seek: (time: number) => void;
+ rulerRef: React.RefObject<HTMLDivElement>;
+ rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ trackLabelsRef?: React.RefObject<HTMLDivElement>;
+ timelineRef: React.RefObject<HTMLDivElement>;
+ playheadRef?: React.RefObject<HTMLDivElement>;
+ isSnappingToPlayhead?: boolean;
+}
+
+export function TimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ tracks,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ trackLabelsRef,
+ timelineRef,
+ playheadRef: externalPlayheadRef,
+ isSnappingToPlayhead = false,
+}: TimelinePlayheadProps) {
+ const internalPlayheadRef = useRef<HTMLDivElement>(null);
+ const playheadRef = externalPlayheadRef || internalPlayheadRef;
+ const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ // Use timeline container height minus a few pixels for breathing room
+ const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
+ const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
+
+ // Get dynamic track labels width, fallback to 0 if no tracks or no ref
+ const trackLabelsWidth =
+ tracks.length > 0 && trackLabelsRef?.current
+ ? trackLabelsRef.current.offsetWidth
+ : 0;
+ const leftPosition =
+ trackLabelsWidth +
+ playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+
+ return (
+ <div
+ ref={playheadRef}
+ className="absolute pointer-events-auto z-[100]"
+ style={{
+ left: `${leftPosition}px`,
+ top: 0,
+ height: `${totalHeight}px`,
+ width: "2px", // Slightly wider for better click target
+ }}
+ onMouseDown={handlePlayheadMouseDown}
+ >
+ {/* The playhead line spanning full height */}
+ <div
+ className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
+ />
+
+ {/* Playhead dot indicator at the top (in ruler area) */}
+ <div
+ className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
+ />
+ </div>
+ );
+}
+
+// Also export a hook for getting ruler handlers
+export function useTimelinePlayheadRuler({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
+ const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ return { handleRulerMouseDown, isDraggingRuler };
+}
+
+export { TimelinePlayhead as default };
diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index 523a9f54..2aa1c2b0 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -1,1097 +1,1097 @@ -"use client"; - -import { useRef, useState, useEffect } from "react"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useMediaStore } from "@/stores/media-store"; -import { toast } from "sonner"; -import { TimelineElement } from "./timeline-element"; -import { - TimelineTrack, - getMainTrack, - canElementGoOnTrack, -} from "@/types/timeline"; -import { usePlaybackStore } from "@/stores/playback-store"; -import type { - TimelineElement as TimelineElementType, - DragData, -} from "@/types/timeline"; -import { - snapTimeToFrame, - TIMELINE_CONSTANTS, -} from "@/constants/timeline-constants"; -import { useProjectStore } from "@/stores/project-store"; -import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping"; - -export function TimelineTrackContent({ - track, - zoomLevel, - onSnapPointChange, -}: { - track: TimelineTrack; - zoomLevel: number; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}) { - const { mediaItems } = useMediaStore(); - const { - tracks, - addTrack, - moveElementToTrack, - updateElementStartTime, - addElementToTrack, - selectedElements, - selectElement, - dragState, - startDrag: startDragAction, - updateDragTime, - endDrag: endDragAction, - clearSelectedElements, - insertTrackAt, - snappingEnabled, - } = useTimelineStore(); - - const { currentTime } = usePlaybackStore(); - - // Initialize snapping hook - const { snapElementEdge } = useTimelineSnapping({ - snapThreshold: 10, - enableElementSnapping: snappingEnabled, - enablePlayheadSnapping: snappingEnabled, - }); - - // Helper function for drop snapping that tries both edges - const getDropSnappedTime = ( - dropTime: number, - elementDuration: number, - excludeElementId?: string - ) => { - if (!snappingEnabled) { - // Use frame snapping if project has FPS, otherwise use decimal snapping - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - return snapTimeToFrame(dropTime, projectFps); - } - - // Try snapping both start and end edges for drops - const startSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - true // snap to start edge - ); - - const endSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - false // snap to end edge - ); - - // Choose the snap result with the smaller distance (closer snap) - let bestSnapResult = startSnapResult; - if ( - endSnapResult.snapPoint && - (!startSnapResult.snapPoint || - endSnapResult.snapDistance < startSnapResult.snapDistance) - ) { - bestSnapResult = endSnapResult; - } - - return bestSnapResult.snappedTime; - }; - - const timelineRef = useRef<HTMLDivElement>(null); - const [isDropping, setIsDropping] = useState(false); - const [dropPosition, setDropPosition] = useState<number | null>(null); - const [wouldOverlap, setWouldOverlap] = useState(false); - const dragCounterRef = useRef(0); - const [mouseDownLocation, setMouseDownLocation] = useState<{ - x: number; - y: number; - } | null>(null); - - // Set up mouse event listeners for drag - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseMove = (e: MouseEvent) => { - if (!timelineRef.current) return; - - // On first mouse move during drag, ensure the element is selected - if (dragState.elementId && dragState.trackId) { - const isSelected = selectedElements.some( - (c) => - c.trackId === dragState.trackId && - c.elementId === dragState.elementId - ); - - if (!isSelected) { - // Select this element (replacing other selections) since we're dragging it - selectElement(dragState.trackId, dragState.elementId, false); - } - } - - const timelineRect = timelineRef.current.getBoundingClientRect(); - const mouseX = e.clientX - timelineRect.left; - const mouseTime = Math.max( - 0, - mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) - ); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - - // Apply snapping if enabled - let finalTime = adjustedTime; - let snapPoint = null; - if (snappingEnabled) { - // Find the element being dragged to get its duration - let elementDuration = 5; // fallback duration - if (dragState.elementId && dragState.trackId) { - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const element = sourceTrack?.elements.find( - (e) => e.id === dragState.elementId - ); - if (element) { - elementDuration = - element.duration - element.trimStart - element.trimEnd; - } - } - - // Try snapping both start and end edges - const startSnapResult = snapElementEdge( - adjustedTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - dragState.elementId || undefined, - true // snap to start edge - ); - - const endSnapResult = snapElementEdge( - adjustedTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - dragState.elementId || undefined, - false // snap to end edge - ); - - // Choose the snap result with the smaller distance (closer snap) - let bestSnapResult = startSnapResult; - if ( - endSnapResult.snapPoint && - (!startSnapResult.snapPoint || - endSnapResult.snapDistance < startSnapResult.snapDistance) - ) { - bestSnapResult = endSnapResult; - } - - finalTime = bestSnapResult.snappedTime; - snapPoint = bestSnapResult.snapPoint; - - // Notify parent component about snap point change - onSnapPointChange?.(snapPoint); - } else { - // Use frame snapping if project has FPS, otherwise use decimal snapping - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - finalTime = snapTimeToFrame(adjustedTime, projectFps); - - // Clear snap point when not snapping - onSnapPointChange?.(null); - } - - updateDragTime(finalTime); - }; - - const handleMouseUp = (e: MouseEvent) => { - if (!dragState.elementId || !dragState.trackId) return; - - // If this track initiated the drag, we should handle the mouse up regardless of where it occurs - const isTrackThatStartedDrag = dragState.trackId === track.id; - - const timelineRect = timelineRef.current?.getBoundingClientRect(); - if (!timelineRect) { - if (isTrackThatStartedDrag) { - updateElementStartTime( - track.id, - dragState.elementId, - dragState.currentTime - ); - endDragAction(); - // Clear snap point when drag ends - onSnapPointChange?.(null); - } - return; - } - - const isMouseOverThisTrack = - e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom; - - if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return; - - const finalTime = dragState.currentTime; - - if (isMouseOverThisTrack) { - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const movingElement = sourceTrack?.elements.find( - (c) => c.id === dragState.elementId - ); - - if (movingElement) { - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const movingElementEnd = finalTime + movingElementDuration; - - const targetTrack = tracks.find((t) => t.id === track.id); - const hasOverlap = targetTrack?.elements.some((existingElement) => { - if ( - dragState.trackId === track.id && - existingElement.id === dragState.elementId - ) { - return false; - } - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return finalTime < existingEnd && movingElementEnd > existingStart; - }); - - if (!hasOverlap) { - if (dragState.trackId === track.id) { - updateElementStartTime(track.id, dragState.elementId, finalTime); - } else { - moveElementToTrack( - dragState.trackId, - track.id, - dragState.elementId - ); - requestAnimationFrame(() => { - updateElementStartTime( - track.id, - dragState.elementId!, - finalTime - ); - }); - } - } - } - } else if (isTrackThatStartedDrag) { - // Mouse is not over this track, but this track started the drag - // This means user released over ruler/outside - update position within same track - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const movingElement = sourceTrack?.elements.find( - (c) => c.id === dragState.elementId - ); - - if (movingElement) { - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const movingElementEnd = finalTime + movingElementDuration; - - const hasOverlap = track.elements.some((existingElement) => { - if (existingElement.id === dragState.elementId) { - return false; - } - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return finalTime < existingEnd && movingElementEnd > existingStart; - }); - - if (!hasOverlap) { - updateElementStartTime(track.id, dragState.elementId, finalTime); - } - } - } - - if (isTrackThatStartedDrag) { - endDragAction(); - // Clear snap point when drag ends - onSnapPointChange?.(null); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [ - dragState.isDragging, - dragState.clickOffsetTime, - dragState.elementId, - dragState.trackId, - dragState.currentTime, - zoomLevel, - tracks, - track.id, - updateDragTime, - updateElementStartTime, - moveElementToTrack, - endDragAction, - selectedElements, - selectElement, - onSnapPointChange, - ]); - - const handleElementMouseDown = ( - e: React.MouseEvent, - element: TimelineElementType - ) => { - setMouseDownLocation({ x: e.clientX, y: e.clientY }); - - // Detect right-click (button 2) and handle selection without starting drag - const isRightClick = e.button === 2; - const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey; - - if (isRightClick) { - // Handle right-click selection - const isSelected = selectedElements.some( - (c) => c.trackId === track.id && c.elementId === element.id - ); - - // If element is not selected, select it (keep other selections if multi-select) - if (!isSelected) { - selectElement(track.id, element.id, isMultiSelect); - } - // If element is already selected, keep it selected - - // Don't start drag action for right-clicks - return; - } - - // Handle multi-selection for left-click with modifiers - if (isMultiSelect) { - selectElement(track.id, element.id, true); - } - - // Calculate the offset from the left edge of the element to where the user clicked - const elementElement = e.currentTarget as HTMLElement; - const elementRect = elementElement.getBoundingClientRect(); - const clickOffsetX = e.clientX - elementRect.left; - const clickOffsetTime = - clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - - startDragAction( - element.id, - track.id, - e.clientX, - element.startTime, - clickOffsetTime - ); - }; - - const handleElementClick = ( - e: React.MouseEvent, - element: TimelineElementType - ) => { - e.stopPropagation(); - - // Check if mouse moved significantly - if (mouseDownLocation) { - const deltaX = Math.abs(e.clientX - mouseDownLocation.x); - const deltaY = Math.abs(e.clientY - mouseDownLocation.y); - // If it moved more than a few pixels, consider it a drag and not a click. - if (deltaX > 5 || deltaY > 5) { - setMouseDownLocation(null); // Reset for next interaction - return; - } - } - - // Skip selection logic for multi-selection (handled in mousedown) - if (e.metaKey || e.ctrlKey || e.shiftKey) { - return; - } - - // Handle single selection - const isSelected = selectedElements.some( - (c) => c.trackId === track.id && c.elementId === element.id - ); - - if (!isSelected) { - // If element is not selected, select it (replacing other selections) - selectElement(track.id, element.id, false); - } - // If element is already selected, keep it selected (do nothing) - }; - - const handleTrackDragOver = (e: React.DragEvent) => { - e.preventDefault(); - - // Handle both timeline elements and media items - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - // Calculate drop position for overlap checking - const trackContainer = e.currentTarget.querySelector( - ".track-elements-container" - ) as HTMLElement; - let dropTime = 0; - if (trackContainer) { - const rect = trackContainer.getBoundingClientRect(); - const mouseX = Math.max(0, e.clientX - rect.left); - dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - } - - // Check for potential overlaps and show appropriate feedback - let wouldOverlap = false; - - if (hasMediaItem) { - try { - const mediaItemData = e.dataTransfer.getData( - "application/x-media-item" - ); - if (mediaItemData) { - const dragData: DragData = JSON.parse(mediaItemData); - - if (dragData.type === "text") { - // Text elements have default duration of 5 seconds - const newElementDuration = 5; - const snappedTime = getDropSnappedTime( - dropTime, - newElementDuration - ); - const newElementEnd = snappedTime + newElementDuration; - - wouldOverlap = track.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return snappedTime < existingEnd && newElementEnd > existingStart; - }); - } else { - // Media elements - const mediaItem = mediaItems.find( - (item) => item.id === dragData.id - ); - if (mediaItem) { - const newElementDuration = mediaItem.duration || 5; - const snappedTime = getDropSnappedTime( - dropTime, - newElementDuration - ); - const newElementEnd = snappedTime + newElementDuration; - - wouldOverlap = track.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return ( - snappedTime < existingEnd && newElementEnd > existingStart - ); - }); - } - } - } - } catch (error) { - // Continue with default behavior - } - } else if (hasTimelineElement) { - try { - const timelineElementData = e.dataTransfer.getData( - "application/x-timeline-element" - ); - if (timelineElementData) { - const { elementId, trackId: fromTrackId } = - JSON.parse(timelineElementData); - const sourceTrack = tracks.find( - (t: TimelineTrack) => t.id === fromTrackId - ); - const movingElement = sourceTrack?.elements.find( - (c: any) => c.id === elementId - ); - - if (movingElement) { - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const snappedTime = getDropSnappedTime( - dropTime, - movingElementDuration, - elementId - ); - const movingElementEnd = snappedTime + movingElementDuration; - - wouldOverlap = track.elements.some((existingElement) => { - if (fromTrackId === track.id && existingElement.id === elementId) - return false; - - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return ( - snappedTime < existingEnd && movingElementEnd > existingStart - ); - }); - } - } - } catch (error) { - // Continue with default behavior - } - } - - if (wouldOverlap) { - e.dataTransfer.dropEffect = "none"; - setWouldOverlap(true); - // Use default duration for position indicator - setDropPosition(getDropSnappedTime(dropTime, 5)); - return; - } - - e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy"; - setWouldOverlap(false); - // Use default duration for position indicator - setDropPosition(getDropSnappedTime(dropTime, 5)); - }; - - const handleTrackDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - dragCounterRef.current++; - setIsDropping(true); - }; - - const handleTrackDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - dragCounterRef.current--; - - if (dragCounterRef.current === 0) { - setIsDropping(false); - setWouldOverlap(false); - setDropPosition(null); - } - }; - - const handleTrackDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // Debug logging - console.log( - JSON.stringify({ - message: "Drop event started in timeline track", - dataTransferTypes: Array.from(e.dataTransfer.types), - trackId: track.id, - trackType: track.type, - }) - ); - - // Reset all drag states - dragCounterRef.current = 0; - setIsDropping(false); - setWouldOverlap(false); - - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - const trackContainer = e.currentTarget.querySelector( - ".track-elements-container" - ) as HTMLElement; - if (!trackContainer) return; - - const rect = trackContainer.getBoundingClientRect(); - const mouseX = Math.max(0, e.clientX - rect.left); - const mouseY = e.clientY - rect.top; // Get Y position relative to this track - const newStartTime = - mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - const snappedTime = snapTimeToFrame(newStartTime, projectFps); - - // Calculate drop position relative to tracks - const currentTrackIndex = tracks.findIndex((t) => t.id === track.id); - - // Determine drop zone within the track (top 20px, middle 20px, bottom 20px) - let dropPosition: "above" | "on" | "below"; - if (mouseY < 20) { - dropPosition = "above"; - } else if (mouseY > 40) { - dropPosition = "below"; - } else { - dropPosition = "on"; - } - - try { - if (hasTimelineElement) { - // Handle timeline element movement - const timelineElementData = e.dataTransfer.getData( - "application/x-timeline-element" - ); - if (!timelineElementData) return; - - const { - elementId, - trackId: fromTrackId, - clickOffsetTime = 0, - } = JSON.parse(timelineElementData); - - // Find the element being moved - const sourceTrack = tracks.find( - (t: TimelineTrack) => t.id === fromTrackId - ); - const movingElement = sourceTrack?.elements.find( - (c: TimelineElementType) => c.id === elementId - ); - - if (!movingElement) { - toast.error("Element not found"); - return; - } - - // Check for overlaps with existing elements (excluding the moving element itself) - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - - // Adjust position based on where user clicked on the element - const adjustedStartTime = newStartTime - clickOffsetTime; - const snappedStartTime = getDropSnappedTime( - adjustedStartTime, - movingElementDuration, - elementId - ); - const finalStartTime = Math.max(0, snappedStartTime); - const movingElementEnd = finalStartTime + movingElementDuration; - - const hasOverlap = track.elements.some((existingElement) => { - // Skip the element being moved if it's on the same track - if (fromTrackId === track.id && existingElement.id === elementId) - return false; - - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - - // Check if elements overlap - return ( - finalStartTime < existingEnd && movingElementEnd > existingStart - ); - }); - - if (hasOverlap) { - toast.error( - "Cannot move element here - it would overlap with existing elements" - ); - return; - } - - if (fromTrackId === track.id) { - // Moving within same track - updateElementStartTime(track.id, elementId, finalStartTime); - } else { - // Moving to different track - moveElementToTrack(fromTrackId, track.id, elementId); - requestAnimationFrame(() => { - updateElementStartTime(track.id, elementId, finalStartTime); - }); - } - } else if (hasMediaItem) { - // Handle media item drop - const mediaItemData = e.dataTransfer.getData( - "application/x-media-item" - ); - if (!mediaItemData) return; - - const dragData: DragData = JSON.parse(mediaItemData); - - if (dragData.type === "text") { - let targetTrackId = track.id; - let targetTrack = track; - - // Handle position-aware track creation for text - if (track.type !== "text" || dropPosition !== "on") { - // Text tracks should go above the main track - const mainTrack = getMainTrack(tracks); - let insertIndex: number; - - if (dropPosition === "above") { - insertIndex = currentTrackIndex; - } else if (dropPosition === "below") { - insertIndex = currentTrackIndex + 1; - } else { - // dropPosition === "on" but track is not text type - // Insert above main track if main track exists, otherwise at top - if (mainTrack) { - const mainTrackIndex = tracks.findIndex( - (t) => t.id === mainTrack.id - ); - insertIndex = mainTrackIndex; - } else { - insertIndex = 0; // Top of timeline - } - } - - targetTrackId = insertTrackAt("text", insertIndex); - // Get the updated tracks array after creating the new track - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } - - // Check for overlaps with existing elements in target track - const newElementDuration = 5; // Default text duration - const textSnappedTime = getDropSnappedTime( - newStartTime, - newElementDuration - ); - const newElementEnd = textSnappedTime + newElementDuration; - - const hasOverlap = targetTrack.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - - // Check if elements overlap - return ( - textSnappedTime < existingEnd && newElementEnd > existingStart - ); - }); - - if (hasOverlap) { - toast.error( - "Cannot place element here - it would overlap with existing elements" - ); - return; - } - - addElementToTrack(targetTrackId, { - type: "text", - name: dragData.name || "Text", - content: dragData.content || "Default Text", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: textSnappedTime, - trimStart: 0, - trimEnd: 0, - fontSize: 48, - fontFamily: "Arial", - color: "#ffffff", - backgroundColor: "transparent", - textAlign: "center", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - x: 0, - y: 0, - rotation: 0, - opacity: 1, - }); - } else { - // Handle media items - const mediaItem = mediaItems.find((item) => item.id === dragData.id); - - if (!mediaItem) { - toast.error("Media item not found"); - return; - } - - let targetTrackId = track.id; - - // Check if track type is compatible - const isVideoOrImage = - dragData.type === "video" || dragData.type === "image"; - const isAudio = dragData.type === "audio"; - const isCompatible = isVideoOrImage - ? canElementGoOnTrack("media", track.type) - : isAudio - ? canElementGoOnTrack("media", track.type) - : false; - - let targetTrack = tracks.find((t) => t.id === targetTrackId); - - // Handle position-aware track creation for media elements - if (!isCompatible || dropPosition !== "on") { - if (isVideoOrImage) { - // For video/image, check if we need a main track or additional media track - const mainTrack = getMainTrack(tracks); - - if (!mainTrack) { - // No main track exists, create it - targetTrackId = addTrack("media"); - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } else if ( - mainTrack.elements.length === 0 && - dropPosition === "on" - ) { - // Main track exists and is empty, use it - targetTrackId = mainTrack.id; - targetTrack = mainTrack; - } else { - // Create new media track - let insertIndex: number; - - if (dropPosition === "above") { - insertIndex = currentTrackIndex; - } else if (dropPosition === "below") { - insertIndex = currentTrackIndex + 1; - } else { - // Insert above main track - const mainTrackIndex = tracks.findIndex( - (t) => t.id === mainTrack.id - ); - insertIndex = mainTrackIndex; - } - - targetTrackId = insertTrackAt("media", insertIndex); - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } - } else if (isAudio) { - // Audio tracks go at the bottom - const mainTrack = getMainTrack(tracks); - let insertIndex: number; - - if (dropPosition === "above") { - insertIndex = currentTrackIndex; - } else if (dropPosition === "below") { - insertIndex = currentTrackIndex + 1; - } else { - // Insert after main track (bottom area) - if (mainTrack) { - const mainTrackIndex = tracks.findIndex( - (t) => t.id === mainTrack.id - ); - insertIndex = mainTrackIndex + 1; - } else { - insertIndex = tracks.length; // Bottom of timeline - } - } - - targetTrackId = insertTrackAt("audio", insertIndex); - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } - } - - if (!targetTrack) return; - - // Check for overlaps with existing elements in target track - const newElementDuration = mediaItem.duration || 5; - const mediaSnappedTime = getDropSnappedTime( - newStartTime, - newElementDuration - ); - const newElementEnd = mediaSnappedTime + newElementDuration; - - const hasOverlap = targetTrack.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - - // Check if elements overlap - return ( - mediaSnappedTime < existingEnd && newElementEnd > existingStart - ); - }); - - if (hasOverlap) { - toast.error( - "Cannot place element here - it would overlap with existing elements" - ); - return; - } - - addElementToTrack(targetTrackId, { - type: "media", - mediaId: mediaItem.id, - name: mediaItem.name, - duration: mediaItem.duration || 5, - startTime: mediaSnappedTime, - trimStart: 0, - trimEnd: 0, - }); - } - } - } catch (error) { - console.error("Error handling drop:", error); - toast.error("Failed to add media to track"); - } - }; - - return ( - <div - className="w-full h-full hover:bg-muted/20" - onClick={(e) => { - // If clicking empty area (not on an element), deselect all elements - if (!(e.target as HTMLElement).closest(".timeline-element")) { - clearSelectedElements(); - } - }} - onDragOver={handleTrackDragOver} - onDragEnter={handleTrackDragEnter} - onDragLeave={handleTrackDragLeave} - onDrop={handleTrackDrop} - > - <div - ref={timelineRef} - className="h-full relative track-elements-container min-w-full" - > - {track.elements.length === 0 ? ( - <div - className={`h-full w-full border-inset border-b border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${ - isDropping - ? wouldOverlap - ? "rounded-sm border-red-500 bg-red-500/10 text-red-600" - : "rounded-sm border-blue-500 bg-blue-500/10 text-blue-600" - : "border-background" - }`} - > - {isDropping - ? wouldOverlap - ? "Cannot drop - would overlap" - : "Drop element here" - : ""} - </div> - ) : ( - <div - className={`h-full w-full border-b border-dashed border-background flex items-center justify-center text-xs text-muted-foreground transition-colors`} - > - {track.elements.map((element) => { - const isSelected = selectedElements.some( - (c) => c.trackId === track.id && c.elementId === element.id - ); - - const handleElementSplit = () => { - const { currentTime } = usePlaybackStore(); - const { splitElement } = useTimelineStore(); - const splitTime = currentTime; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (splitTime > effectiveStart && splitTime < effectiveEnd) { - const secondElementId = splitElement( - track.id, - element.id, - splitTime - ); - if (!secondElementId) { - toast.error("Failed to split element"); - } - } else { - toast.error("Playhead must be within element to split"); - } - }; - - const handleElementDuplicate = () => { - const { addElementToTrack } = useTimelineStore.getState(); - const { id, ...elementWithoutId } = element; - addElementToTrack(track.id, { - ...elementWithoutId, - name: element.name + " (copy)", - startTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1, - }); - }; - - const handleElementDelete = () => { - const { removeElementFromTrack } = useTimelineStore.getState(); - removeElementFromTrack(track.id, element.id); - }; - - return ( - <TimelineElement - key={element.id} - element={element} - track={track} - zoomLevel={zoomLevel} - isSelected={isSelected} - onElementMouseDown={handleElementMouseDown} - onElementClick={handleElementClick} - /> - ); - })} - </div> - )} - </div> - </div> - ); -} +"use client";
+
+import { useRef, useState, useEffect } from "react";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { toast } from "sonner";
+import { TimelineElement } from "./timeline-element";
+import {
+ TimelineTrack,
+ sortTracksByOrder,
+ ensureMainTrack,
+ getMainTrack,
+ canElementGoOnTrack,
+} from "@/types/timeline";
+import { usePlaybackStore } from "@/stores/playback-store";
+import type {
+ TimelineElement as TimelineElementType,
+ DragData,
+} from "@/types/timeline";
+import {
+ snapTimeToFrame,
+ TIMELINE_CONSTANTS,
+} from "@/constants/timeline-constants";
+import { useProjectStore } from "@/stores/project-store";
+import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
+
+export function TimelineTrackContent({
+ track,
+ zoomLevel,
+ onSnapPointChange,
+}: {
+ track: TimelineTrack;
+ zoomLevel: number;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}) {
+ const { mediaItems } = useMediaStore();
+ const {
+ tracks,
+ addTrack,
+ moveElementToTrack,
+ updateElementStartTime,
+ addElementToTrack,
+ selectedElements,
+ selectElement,
+ dragState,
+ startDrag: startDragAction,
+ updateDragTime,
+ endDrag: endDragAction,
+ clearSelectedElements,
+ insertTrackAt,
+ snappingEnabled,
+ } = useTimelineStore();
+
+ const { currentTime } = usePlaybackStore();
+
+ // Initialize snapping hook
+ const { snapElementPosition, snapElementEdge } = useTimelineSnapping({
+ snapThreshold: 10,
+ enableElementSnapping: snappingEnabled,
+ enablePlayheadSnapping: snappingEnabled,
+ });
+
+ // Helper function for drop snapping that tries both edges
+ const getDropSnappedTime = (
+ dropTime: number,
+ elementDuration: number,
+ excludeElementId?: string
+ ) => {
+ if (!snappingEnabled) {
+ // Use frame snapping if project has FPS, otherwise use decimal snapping
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ return snapTimeToFrame(dropTime, projectFps);
+ }
+
+ // Try snapping both start and end edges for drops
+ const startSnapResult = snapElementEdge(
+ dropTime,
+ elementDuration,
+ tracks,
+ currentTime,
+ zoomLevel,
+ excludeElementId,
+ true // snap to start edge
+ );
+
+ const endSnapResult = snapElementEdge(
+ dropTime,
+ elementDuration,
+ tracks,
+ currentTime,
+ zoomLevel,
+ excludeElementId,
+ false // snap to end edge
+ );
+
+ // Choose the snap result with the smaller distance (closer snap)
+ let bestSnapResult = startSnapResult;
+ if (
+ endSnapResult.snapPoint &&
+ (!startSnapResult.snapPoint ||
+ endSnapResult.snapDistance < startSnapResult.snapDistance)
+ ) {
+ bestSnapResult = endSnapResult;
+ }
+
+ return bestSnapResult.snappedTime;
+ };
+
+ const timelineRef = useRef<HTMLDivElement>(null);
+ const [isDropping, setIsDropping] = useState(false);
+ const [dropPosition, setDropPosition] = useState<number | null>(null);
+ const [wouldOverlap, setWouldOverlap] = useState(false);
+ const dragCounterRef = useRef(0);
+ const [mouseDownLocation, setMouseDownLocation] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+
+ // Set up mouse event listeners for drag
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!timelineRef.current) return;
+
+ // On first mouse move during drag, ensure the element is selected
+ if (dragState.elementId && dragState.trackId) {
+ const isSelected = selectedElements.some(
+ (c) =>
+ c.trackId === dragState.trackId &&
+ c.elementId === dragState.elementId
+ );
+
+ if (!isSelected) {
+ // Select this element (replacing other selections) since we're dragging it
+ selectElement(dragState.trackId, dragState.elementId, false);
+ }
+ }
+
+ const timelineRect = timelineRef.current.getBoundingClientRect();
+ const mouseX = e.clientX - timelineRect.left;
+ const mouseTime = Math.max(
+ 0,
+ mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
+ );
+ const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
+
+ // Apply snapping if enabled
+ let finalTime = adjustedTime;
+ let snapPoint = null;
+ if (snappingEnabled) {
+ // Find the element being dragged to get its duration
+ let elementDuration = 5; // fallback duration
+ if (dragState.elementId && dragState.trackId) {
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const element = sourceTrack?.elements.find(
+ (e) => e.id === dragState.elementId
+ );
+ if (element) {
+ elementDuration =
+ element.duration - element.trimStart - element.trimEnd;
+ }
+ }
+
+ // Try snapping both start and end edges
+ const startSnapResult = snapElementEdge(
+ adjustedTime,
+ elementDuration,
+ tracks,
+ currentTime,
+ zoomLevel,
+ dragState.elementId || undefined,
+ true // snap to start edge
+ );
+
+ const endSnapResult = snapElementEdge(
+ adjustedTime,
+ elementDuration,
+ tracks,
+ currentTime,
+ zoomLevel,
+ dragState.elementId || undefined,
+ false // snap to end edge
+ );
+
+ // Choose the snap result with the smaller distance (closer snap)
+ let bestSnapResult = startSnapResult;
+ if (
+ endSnapResult.snapPoint &&
+ (!startSnapResult.snapPoint ||
+ endSnapResult.snapDistance < startSnapResult.snapDistance)
+ ) {
+ bestSnapResult = endSnapResult;
+ }
+
+ finalTime = bestSnapResult.snappedTime;
+ snapPoint = bestSnapResult.snapPoint;
+
+ // Notify parent component about snap point change
+ onSnapPointChange?.(snapPoint);
+ } else {
+ // Use frame snapping if project has FPS, otherwise use decimal snapping
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ finalTime = snapTimeToFrame(adjustedTime, projectFps);
+
+ // Clear snap point when not snapping
+ onSnapPointChange?.(null);
+ }
+
+ updateDragTime(finalTime);
+ };
+
+ const handleMouseUp = (e: MouseEvent) => {
+ if (!dragState.elementId || !dragState.trackId) return;
+
+ // If this track initiated the drag, we should handle the mouse up regardless of where it occurs
+ const isTrackThatStartedDrag = dragState.trackId === track.id;
+
+ const timelineRect = timelineRef.current?.getBoundingClientRect();
+ if (!timelineRect) {
+ if (isTrackThatStartedDrag) {
+ updateElementStartTime(
+ track.id,
+ dragState.elementId,
+ dragState.currentTime
+ );
+ endDragAction();
+ // Clear snap point when drag ends
+ onSnapPointChange?.(null);
+ }
+ return;
+ }
+
+ const isMouseOverThisTrack =
+ e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
+
+ if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
+
+ const finalTime = dragState.currentTime;
+
+ if (isMouseOverThisTrack) {
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const movingElement = sourceTrack?.elements.find(
+ (c) => c.id === dragState.elementId
+ );
+
+ if (movingElement) {
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const movingElementEnd = finalTime + movingElementDuration;
+
+ const targetTrack = tracks.find((t) => t.id === track.id);
+ const hasOverlap = targetTrack?.elements.some((existingElement) => {
+ if (
+ dragState.trackId === track.id &&
+ existingElement.id === dragState.elementId
+ ) {
+ return false;
+ }
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return finalTime < existingEnd && movingElementEnd > existingStart;
+ });
+
+ if (!hasOverlap) {
+ if (dragState.trackId === track.id) {
+ updateElementStartTime(track.id, dragState.elementId, finalTime);
+ } else {
+ moveElementToTrack(
+ dragState.trackId,
+ track.id,
+ dragState.elementId
+ );
+ requestAnimationFrame(() => {
+ updateElementStartTime(
+ track.id,
+ dragState.elementId!,
+ finalTime
+ );
+ });
+ }
+ }
+ }
+ } else if (isTrackThatStartedDrag) {
+ // Mouse is not over this track, but this track started the drag
+ // This means user released over ruler/outside - update position within same track
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const movingElement = sourceTrack?.elements.find(
+ (c) => c.id === dragState.elementId
+ );
+
+ if (movingElement) {
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const movingElementEnd = finalTime + movingElementDuration;
+
+ const hasOverlap = track.elements.some((existingElement) => {
+ if (existingElement.id === dragState.elementId) {
+ return false;
+ }
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return finalTime < existingEnd && movingElementEnd > existingStart;
+ });
+
+ if (!hasOverlap) {
+ updateElementStartTime(track.id, dragState.elementId, finalTime);
+ }
+ }
+ }
+
+ if (isTrackThatStartedDrag) {
+ endDragAction();
+ // Clear snap point when drag ends
+ onSnapPointChange?.(null);
+ }
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [
+ dragState.isDragging,
+ dragState.clickOffsetTime,
+ dragState.elementId,
+ dragState.trackId,
+ dragState.currentTime,
+ zoomLevel,
+ tracks,
+ track.id,
+ updateDragTime,
+ updateElementStartTime,
+ moveElementToTrack,
+ endDragAction,
+ selectedElements,
+ selectElement,
+ onSnapPointChange,
+ ]);
+
+ const handleElementMouseDown = (
+ e: React.MouseEvent,
+ element: TimelineElementType
+ ) => {
+ setMouseDownLocation({ x: e.clientX, y: e.clientY });
+
+ // Detect right-click (button 2) and handle selection without starting drag
+ const isRightClick = e.button === 2;
+ const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
+
+ if (isRightClick) {
+ // Handle right-click selection
+ const isSelected = selectedElements.some(
+ (c) => c.trackId === track.id && c.elementId === element.id
+ );
+
+ // If element is not selected, select it (keep other selections if multi-select)
+ if (!isSelected) {
+ selectElement(track.id, element.id, isMultiSelect);
+ }
+ // If element is already selected, keep it selected
+
+ // Don't start drag action for right-clicks
+ return;
+ }
+
+ // Handle multi-selection for left-click with modifiers
+ if (isMultiSelect) {
+ selectElement(track.id, element.id, true);
+ }
+
+ // Calculate the offset from the left edge of the element to where the user clicked
+ const elementElement = e.currentTarget as HTMLElement;
+ const elementRect = elementElement.getBoundingClientRect();
+ const clickOffsetX = e.clientX - elementRect.left;
+ const clickOffsetTime =
+ clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+
+ startDragAction(
+ element.id,
+ track.id,
+ e.clientX,
+ element.startTime,
+ clickOffsetTime
+ );
+ };
+
+ const handleElementClick = (
+ e: React.MouseEvent,
+ element: TimelineElementType
+ ) => {
+ e.stopPropagation();
+
+ // Check if mouse moved significantly
+ if (mouseDownLocation) {
+ const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
+ const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
+ // If it moved more than a few pixels, consider it a drag and not a click.
+ if (deltaX > 5 || deltaY > 5) {
+ setMouseDownLocation(null); // Reset for next interaction
+ return;
+ }
+ }
+
+ // Skip selection logic for multi-selection (handled in mousedown)
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
+ return;
+ }
+
+ // Handle single selection
+ const isSelected = selectedElements.some(
+ (c) => c.trackId === track.id && c.elementId === element.id
+ );
+
+ if (!isSelected) {
+ // If element is not selected, select it (replacing other selections)
+ selectElement(track.id, element.id, false);
+ }
+ // If element is already selected, keep it selected (do nothing)
+ };
+
+ const handleTrackDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Handle both timeline elements and media items
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ // Calculate drop position for overlap checking
+ const trackContainer = e.currentTarget.querySelector(
+ ".track-elements-container"
+ ) as HTMLElement;
+ let dropTime = 0;
+ if (trackContainer) {
+ const rect = trackContainer.getBoundingClientRect();
+ const mouseX = Math.max(0, e.clientX - rect.left);
+ dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+ }
+
+ // Check for potential overlaps and show appropriate feedback
+ let wouldOverlap = false;
+
+ if (hasMediaItem) {
+ try {
+ const mediaItemData = e.dataTransfer.getData(
+ "application/x-media-item"
+ );
+ if (mediaItemData) {
+ const dragData: DragData = JSON.parse(mediaItemData);
+
+ if (dragData.type === "text") {
+ // Text elements have default duration of 5 seconds
+ const newElementDuration = 5;
+ const snappedTime = getDropSnappedTime(
+ dropTime,
+ newElementDuration
+ );
+ const newElementEnd = snappedTime + newElementDuration;
+
+ wouldOverlap = track.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return snappedTime < existingEnd && newElementEnd > existingStart;
+ });
+ } else {
+ // Media elements
+ const mediaItem = mediaItems.find(
+ (item) => item.id === dragData.id
+ );
+ if (mediaItem) {
+ const newElementDuration = mediaItem.duration || 5;
+ const snappedTime = getDropSnappedTime(
+ dropTime,
+ newElementDuration
+ );
+ const newElementEnd = snappedTime + newElementDuration;
+
+ wouldOverlap = track.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return (
+ snappedTime < existingEnd && newElementEnd > existingStart
+ );
+ });
+ }
+ }
+ }
+ } catch (error) {
+ // Continue with default behavior
+ }
+ } else if (hasTimelineElement) {
+ try {
+ const timelineElementData = e.dataTransfer.getData(
+ "application/x-timeline-element"
+ );
+ if (timelineElementData) {
+ const { elementId, trackId: fromTrackId } =
+ JSON.parse(timelineElementData);
+ const sourceTrack = tracks.find(
+ (t: TimelineTrack) => t.id === fromTrackId
+ );
+ const movingElement = sourceTrack?.elements.find(
+ (c: any) => c.id === elementId
+ );
+
+ if (movingElement) {
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const snappedTime = getDropSnappedTime(
+ dropTime,
+ movingElementDuration,
+ elementId
+ );
+ const movingElementEnd = snappedTime + movingElementDuration;
+
+ wouldOverlap = track.elements.some((existingElement) => {
+ if (fromTrackId === track.id && existingElement.id === elementId)
+ return false;
+
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return (
+ snappedTime < existingEnd && movingElementEnd > existingStart
+ );
+ });
+ }
+ }
+ } catch (error) {
+ // Continue with default behavior
+ }
+ }
+
+ if (wouldOverlap) {
+ e.dataTransfer.dropEffect = "none";
+ setWouldOverlap(true);
+ // Use default duration for position indicator
+ setDropPosition(getDropSnappedTime(dropTime, 5));
+ return;
+ }
+
+ e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
+ setWouldOverlap(false);
+ // Use default duration for position indicator
+ setDropPosition(getDropSnappedTime(dropTime, 5));
+ };
+
+ const handleTrackDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ dragCounterRef.current++;
+ setIsDropping(true);
+ };
+
+ const handleTrackDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ dragCounterRef.current--;
+
+ if (dragCounterRef.current === 0) {
+ setIsDropping(false);
+ setWouldOverlap(false);
+ setDropPosition(null);
+ }
+ };
+
+ const handleTrackDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Debug logging
+ console.log(
+ JSON.stringify({
+ message: "Drop event started in timeline track",
+ dataTransferTypes: Array.from(e.dataTransfer.types),
+ trackId: track.id,
+ trackType: track.type,
+ })
+ );
+
+ // Reset all drag states
+ dragCounterRef.current = 0;
+ setIsDropping(false);
+ setWouldOverlap(false);
+
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ const trackContainer = e.currentTarget.querySelector(
+ ".track-elements-container"
+ ) as HTMLElement;
+ if (!trackContainer) return;
+
+ const rect = trackContainer.getBoundingClientRect();
+ const mouseX = Math.max(0, e.clientX - rect.left);
+ const mouseY = e.clientY - rect.top; // Get Y position relative to this track
+ const newStartTime =
+ mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ const snappedTime = snapTimeToFrame(newStartTime, projectFps);
+
+ // Calculate drop position relative to tracks
+ const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
+
+ // Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
+ let dropPosition: "above" | "on" | "below";
+ if (mouseY < 20) {
+ dropPosition = "above";
+ } else if (mouseY > 40) {
+ dropPosition = "below";
+ } else {
+ dropPosition = "on";
+ }
+
+ try {
+ if (hasTimelineElement) {
+ // Handle timeline element movement
+ const timelineElementData = e.dataTransfer.getData(
+ "application/x-timeline-element"
+ );
+ if (!timelineElementData) return;
+
+ const {
+ elementId,
+ trackId: fromTrackId,
+ clickOffsetTime = 0,
+ } = JSON.parse(timelineElementData);
+
+ // Find the element being moved
+ const sourceTrack = tracks.find(
+ (t: TimelineTrack) => t.id === fromTrackId
+ );
+ const movingElement = sourceTrack?.elements.find(
+ (c: TimelineElementType) => c.id === elementId
+ );
+
+ if (!movingElement) {
+ toast.error("Element not found");
+ return;
+ }
+
+ // Check for overlaps with existing elements (excluding the moving element itself)
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+
+ // Adjust position based on where user clicked on the element
+ const adjustedStartTime = newStartTime - clickOffsetTime;
+ const snappedStartTime = getDropSnappedTime(
+ adjustedStartTime,
+ movingElementDuration,
+ elementId
+ );
+ const finalStartTime = Math.max(0, snappedStartTime);
+ const movingElementEnd = finalStartTime + movingElementDuration;
+
+ const hasOverlap = track.elements.some((existingElement) => {
+ // Skip the element being moved if it's on the same track
+ if (fromTrackId === track.id && existingElement.id === elementId)
+ return false;
+
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+
+ // Check if elements overlap
+ return (
+ finalStartTime < existingEnd && movingElementEnd > existingStart
+ );
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot move element here - it would overlap with existing elements"
+ );
+ return;
+ }
+
+ if (fromTrackId === track.id) {
+ // Moving within same track
+ updateElementStartTime(track.id, elementId, finalStartTime);
+ } else {
+ // Moving to different track
+ moveElementToTrack(fromTrackId, track.id, elementId);
+ requestAnimationFrame(() => {
+ updateElementStartTime(track.id, elementId, finalStartTime);
+ });
+ }
+ } else if (hasMediaItem) {
+ // Handle media item drop
+ const mediaItemData = e.dataTransfer.getData(
+ "application/x-media-item"
+ );
+ if (!mediaItemData) return;
+
+ const dragData: DragData = JSON.parse(mediaItemData);
+
+ if (dragData.type === "text") {
+ let targetTrackId = track.id;
+ let targetTrack = track;
+
+ // Handle position-aware track creation for text
+ if (track.type !== "text" || dropPosition !== "on") {
+ // Text tracks should go above the main track
+ const mainTrack = getMainTrack(tracks);
+ let insertIndex: number;
+
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // dropPosition === "on" but track is not text type
+ // Insert above main track if main track exists, otherwise at top
+ if (mainTrack) {
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
+ insertIndex = mainTrackIndex;
+ } else {
+ insertIndex = 0; // Top of timeline
+ }
+ }
+
+ targetTrackId = insertTrackAt("text", insertIndex);
+ // Get the updated tracks array after creating the new track
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ }
+
+ // Check for overlaps with existing elements in target track
+ const newElementDuration = 5; // Default text duration
+ const textSnappedTime = getDropSnappedTime(
+ newStartTime,
+ newElementDuration
+ );
+ const newElementEnd = textSnappedTime + newElementDuration;
+
+ const hasOverlap = targetTrack.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+
+ // Check if elements overlap
+ return (
+ textSnappedTime < existingEnd && newElementEnd > existingStart
+ );
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements"
+ );
+ return;
+ }
+
+ addElementToTrack(targetTrackId, {
+ type: "text",
+ name: dragData.name || "Text",
+ content: dragData.content || "Default Text",
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: textSnappedTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: 48,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ backgroundColor: "transparent",
+ textAlign: "center",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ x: 0,
+ y: 0,
+ rotation: 0,
+ opacity: 1,
+ });
+ } else {
+ // Handle media items
+ const mediaItem = mediaItems.find((item) => item.id === dragData.id);
+
+ if (!mediaItem) {
+ toast.error("Media item not found");
+ return;
+ }
+
+ let targetTrackId = track.id;
+
+ // Check if track type is compatible
+ const isVideoOrImage =
+ dragData.type === "video" || dragData.type === "image";
+ const isAudio = dragData.type === "audio";
+ const isCompatible = isVideoOrImage
+ ? canElementGoOnTrack("media", track.type)
+ : isAudio
+ ? canElementGoOnTrack("media", track.type)
+ : false;
+
+ let targetTrack = tracks.find((t) => t.id === targetTrackId);
+
+ // Handle position-aware track creation for media elements
+ if (!isCompatible || dropPosition !== "on") {
+ if (isVideoOrImage) {
+ // For video/image, check if we need a main track or additional media track
+ const mainTrack = getMainTrack(tracks);
+
+ if (!mainTrack) {
+ // No main track exists, create it
+ targetTrackId = addTrack("media");
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ } else if (
+ mainTrack.elements.length === 0 &&
+ dropPosition === "on"
+ ) {
+ // Main track exists and is empty, use it
+ targetTrackId = mainTrack.id;
+ targetTrack = mainTrack;
+ } else {
+ // Create new media track
+ let insertIndex: number;
+
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // Insert above main track
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
+ insertIndex = mainTrackIndex;
+ }
+
+ targetTrackId = insertTrackAt("media", insertIndex);
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ }
+ } else if (isAudio) {
+ // Audio tracks go at the bottom
+ const mainTrack = getMainTrack(tracks);
+ let insertIndex: number;
+
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // Insert after main track (bottom area)
+ if (mainTrack) {
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
+ insertIndex = mainTrackIndex + 1;
+ } else {
+ insertIndex = tracks.length; // Bottom of timeline
+ }
+ }
+
+ targetTrackId = insertTrackAt("audio", insertIndex);
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ }
+ }
+
+ if (!targetTrack) return;
+
+ // Check for overlaps with existing elements in target track
+ const newElementDuration = mediaItem.duration || 5;
+ const mediaSnappedTime = getDropSnappedTime(
+ newStartTime,
+ newElementDuration
+ );
+ const newElementEnd = mediaSnappedTime + newElementDuration;
+
+ const hasOverlap = targetTrack.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+
+ // Check if elements overlap
+ return (
+ mediaSnappedTime < existingEnd && newElementEnd > existingStart
+ );
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements"
+ );
+ return;
+ }
+
+ addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: mediaItem.id,
+ name: mediaItem.name,
+ duration: mediaItem.duration || 5,
+ startTime: mediaSnappedTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }
+ }
+ } catch (error) {
+ console.error("Error handling drop:", error);
+ toast.error("Failed to add media to track");
+ }
+ };
+
+ return (
+ <div
+ className="w-full h-full hover:bg-muted/20"
+ onClick={(e) => {
+ // If clicking empty area (not on an element), deselect all elements
+ if (!(e.target as HTMLElement).closest(".timeline-element")) {
+ clearSelectedElements();
+ }
+ }}
+ onDragOver={handleTrackDragOver}
+ onDragEnter={handleTrackDragEnter}
+ onDragLeave={handleTrackDragLeave}
+ onDrop={handleTrackDrop}
+ >
+ <div
+ ref={timelineRef}
+ className="h-full relative track-elements-container min-w-full"
+ >
+ {track.elements.length === 0 ? (
+ <div
+ className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
+ isDropping
+ ? wouldOverlap
+ ? "border-red-500 bg-red-500/10 text-red-600"
+ : "border-blue-500 bg-blue-500/10 text-blue-600"
+ : "border-muted/30"
+ }`}
+ >
+ {isDropping
+ ? wouldOverlap
+ ? "Cannot drop - would overlap"
+ : "Drop element here"
+ : ""}
+ </div>
+ ) : (
+ <>
+ {track.elements.map((element) => {
+ const isSelected = selectedElements.some(
+ (c) => c.trackId === track.id && c.elementId === element.id
+ );
+
+ const handleElementSplit = () => {
+ const { currentTime } = usePlaybackStore();
+ const { splitElement } = useTimelineStore();
+ const splitTime = currentTime;
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (splitTime > effectiveStart && splitTime < effectiveEnd) {
+ const secondElementId = splitElement(
+ track.id,
+ element.id,
+ splitTime
+ );
+ if (!secondElementId) {
+ toast.error("Failed to split element");
+ }
+ } else {
+ toast.error("Playhead must be within element to split");
+ }
+ };
+
+ const handleElementDuplicate = () => {
+ const { addElementToTrack } = useTimelineStore.getState();
+ const { id, ...elementWithoutId } = element;
+ addElementToTrack(track.id, {
+ ...elementWithoutId,
+ name: element.name + " (copy)",
+ startTime:
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1,
+ });
+ };
+
+ const handleElementDelete = () => {
+ const { removeElementFromTrack } = useTimelineStore.getState();
+ removeElementFromTrack(track.id, element.id);
+ };
+
+ return (
+ <TimelineElement
+ key={element.id}
+ element={element}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onElementMouseDown={handleElementMouseDown}
+ onElementClick={handleElementClick}
+ />
+ );
+ })}
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx new file mode 100644 index 00000000..22de59c4 --- /dev/null +++ b/apps/web/src/components/editor/timeline.tsx @@ -0,0 +1,1012 @@ +"use client";
+
+import { ScrollArea } from "../ui/scroll-area";
+import { Button } from "../ui/button";
+import {
+ Scissors,
+ ArrowLeftToLine,
+ ArrowRightToLine,
+ Trash2,
+ Snowflake,
+ Copy,
+ SplitSquareHorizontal,
+ Pause,
+ Play,
+ Video,
+ Music,
+ TypeIcon,
+ Lock,
+ LockOpen,
+} from "lucide-react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+ TooltipProvider,
+} from "../ui/tooltip";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "../ui/context-menu";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { useProjectStore } from "@/stores/project-store";
+import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
+import { processMediaFiles } from "@/lib/media-processing";
+import { toast } from "sonner";
+import { useState, useRef, useEffect, useCallback } from "react";
+import { TimelineTrackContent } from "./timeline-track";
+import {
+ TimelinePlayhead,
+ useTimelinePlayheadRuler,
+} from "./timeline-playhead";
+import { SelectionBox } from "./selection-box";
+import { useSelectionBox } from "@/hooks/use-selection-box";
+import { SnapIndicator } from "./snap-indicator";
+import { SnapPoint } from "@/hooks/use-timeline-snapping";
+import type { DragData, TimelineTrack } from "@/types/timeline";
+import {
+ getTrackHeight,
+ getCumulativeHeightBefore,
+ getTotalTracksHeight,
+ TIMELINE_CONSTANTS,
+ snapTimeToFrame,
+} from "@/constants/timeline-constants";
+
+export function Timeline() {
+ // Timeline shows all tracks (video, audio, effects) and their elements.
+ // You can drag media here to add it to your project.
+ // elements can be trimmed, deleted, and moved.
+
+ const {
+ tracks,
+ addTrack,
+ addElementToTrack,
+ removeElementFromTrack,
+ getTotalDuration,
+ selectedElements,
+ clearSelectedElements,
+ setSelectedElements,
+ splitElement,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ toggleTrackMute,
+ separateAudio,
+ undo,
+ redo,
+ snappingEnabled,
+ toggleSnapping,
+ dragState,
+ } = useTimelineStore();
+ const { mediaItems, addMediaItem } = useMediaStore();
+ const { activeProject } = useProjectStore();
+ const { currentTime, duration, seek, setDuration, isPlaying, toggle } =
+ usePlaybackStore();
+ const [isDragOver, setIsDragOver] = useState(false);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const dragCounterRef = useRef(0);
+ const timelineRef = useRef<HTMLDivElement>(null);
+ const rulerRef = useRef<HTMLDivElement>(null);
+ const [isInTimeline, setIsInTimeline] = useState(false);
+
+ // Timeline zoom functionality
+ const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
+ containerRef: timelineRef,
+ isInTimeline,
+ });
+
+ // Old marquee selection removed - using new SelectionBox component instead
+
+ // Dynamic timeline width calculation based on playhead position and duration
+ const dynamicTimelineWidth = Math.max(
+ (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration
+ (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer
+ timelineRef.current?.clientWidth || 1000 // Minimum width
+ );
+
+ // Scroll synchronization and auto-scroll to playhead
+ const rulerScrollRef = useRef<HTMLDivElement>(null);
+ const tracksScrollRef = useRef<HTMLDivElement>(null);
+ const trackLabelsRef = useRef<HTMLDivElement>(null);
+ const playheadRef = useRef<HTMLDivElement>(null);
+ const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
+ const isUpdatingRef = useRef(false);
+ const lastRulerSync = useRef(0);
+ const lastTracksSync = useRef(0);
+ const lastVerticalSync = useRef(0);
+
+ // Timeline playhead ruler handlers
+ const { handleRulerMouseDown } = useTimelinePlayheadRuler({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ // Selection box functionality
+ const tracksContainerRef = useRef<HTMLDivElement>(null);
+ const {
+ selectionBox,
+ handleMouseDown: handleSelectionMouseDown,
+ isSelecting,
+ justFinishedSelecting,
+ } = useSelectionBox({
+ containerRef: tracksContainerRef,
+ playheadRef,
+ onSelectionComplete: (elements) => {
+ console.log(JSON.stringify({ onSelectionComplete: elements.length }));
+ setSelectedElements(elements);
+ },
+ });
+
+ // Calculate snap indicator state
+ const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
+ null
+ );
+ const showSnapIndicator =
+ dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
+
+ // Callback to handle snap point changes from TimelineTrackContent
+ const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
+ setCurrentSnapPoint(snapPoint);
+ }, []);
+
+ // Timeline content click to seek handler
+ const handleTimelineContentClick = useCallback(
+ (e: React.MouseEvent) => {
+ console.log(
+ JSON.stringify({
+ timelineClick: {
+ isSelecting,
+ justFinishedSelecting,
+ willReturn: isSelecting || justFinishedSelecting,
+ },
+ })
+ );
+
+ // Don't seek if this was a selection box operation
+ if (isSelecting || justFinishedSelecting) {
+ return;
+ }
+
+ // Don't seek if clicking on timeline elements, but still deselect
+ if ((e.target as HTMLElement).closest(".timeline-element")) {
+ return;
+ }
+
+ // Don't seek if clicking on playhead
+ if (playheadRef.current?.contains(e.target as Node)) {
+ return;
+ }
+
+ // Don't seek if clicking on track labels
+ if ((e.target as HTMLElement).closest("[data-track-labels]")) {
+ clearSelectedElements();
+ return;
+ }
+
+ // Clear selected elements when clicking empty timeline area
+ console.log(JSON.stringify({ clearingSelectedElements: true }));
+ clearSelectedElements();
+
+ // Determine if we're clicking in ruler or tracks area
+ const isRulerClick = (e.target as HTMLElement).closest(
+ "[data-ruler-area]"
+ );
+
+ let mouseX: number;
+ let scrollLeft = 0;
+
+ if (isRulerClick) {
+ // Calculate based on ruler position
+ const rulerContent = rulerScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ if (!rulerContent) return;
+ const rect = rulerContent.getBoundingClientRect();
+ mouseX = e.clientX - rect.left;
+ scrollLeft = rulerContent.scrollLeft;
+ } else {
+ // Calculate based on tracks content position
+ const tracksContent = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ if (!tracksContent) return;
+ const rect = tracksContent.getBoundingClientRect();
+ mouseX = e.clientX - rect.left;
+ scrollLeft = tracksContent.scrollLeft;
+ }
+
+ const rawTime = Math.max(
+ 0,
+ Math.min(
+ duration,
+ (mouseX + scrollLeft) /
+ (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
+ )
+ );
+
+ // Use frame snapping for timeline clicking
+ const projectFps = activeProject?.fps || 30;
+ const time = snapTimeToFrame(rawTime, projectFps);
+
+ seek(time);
+ },
+ [
+ duration,
+ zoomLevel,
+ seek,
+ rulerScrollRef,
+ tracksScrollRef,
+ clearSelectedElements,
+ isSelecting,
+ justFinishedSelecting,
+ ]
+ );
+
+ // Update timeline duration when tracks change
+ useEffect(() => {
+ const totalDuration = getTotalDuration();
+ setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline
+ }, [tracks, setDuration, getTotalDuration]);
+
+ // Old marquee system removed - using new SelectionBox component instead
+
+ const handleDragEnter = (e: React.DragEvent) => {
+ // When something is dragged over the timeline, show overlay
+ e.preventDefault();
+ // Don't show overlay for timeline elements - they're handled by tracks
+ if (e.dataTransfer.types.includes("application/x-timeline-element")) {
+ return;
+ }
+ dragCounterRef.current += 1;
+ if (!isDragOver) {
+ setIsDragOver(true);
+ }
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Don't update state for timeline elements - they're handled by tracks
+ if (e.dataTransfer.types.includes("application/x-timeline-element")) {
+ return;
+ }
+
+ dragCounterRef.current -= 1;
+ if (dragCounterRef.current === 0) {
+ setIsDragOver(false);
+ }
+ };
+
+ const handleDrop = async (e: React.DragEvent) => {
+ // When media is dropped, add it as a new track/element
+ e.preventDefault();
+ setIsDragOver(false);
+ dragCounterRef.current = 0;
+
+ // Ignore timeline element drags - they're handled by track-specific handlers
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ if (hasTimelineElement) {
+ return;
+ }
+
+ const itemData = e.dataTransfer.getData("application/x-media-item");
+ if (itemData) {
+ try {
+ const dragData: DragData = JSON.parse(itemData);
+
+ if (dragData.type === "text") {
+ // Always create new text track to avoid overlaps
+ useTimelineStore.getState().addTextToNewTrack(dragData);
+ } else {
+ // Handle media items
+ const mediaItem = mediaItems.find(
+ (item: any) => item.id === dragData.id
+ );
+ if (!mediaItem) {
+ toast.error("Media item not found");
+ return;
+ }
+
+ useTimelineStore.getState().addMediaToNewTrack(mediaItem);
+ }
+ } catch (error) {
+ console.error("Error parsing dropped item data:", error);
+ toast.error("Failed to add item to timeline");
+ }
+ } else if (e.dataTransfer.files?.length > 0) {
+ // Handle file drops by creating new tracks
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
+ setIsProcessing(true);
+ setProgress(0);
+ try {
+ const processedItems = await processMediaFiles(
+ e.dataTransfer.files,
+ (p) => setProgress(p)
+ );
+ for (const processedItem of processedItems) {
+ await addMediaItem(activeProject.id, processedItem);
+ const currentMediaItems = useMediaStore.getState().mediaItems;
+ const addedItem = currentMediaItems.find(
+ (item) =>
+ item.name === processedItem.name && item.url === processedItem.url
+ );
+ if (addedItem) {
+ useTimelineStore.getState().addMediaToNewTrack(addedItem);
+ }
+ }
+ } catch (error) {
+ // Show error if file processing fails
+ console.error("Error processing external files:", error);
+ toast.error("Failed to process dropped files");
+ } finally {
+ setIsProcessing(false);
+ setProgress(0);
+ }
+ }
+ };
+
+ const dragProps = {
+ onDragEnter: handleDragEnter,
+ onDragOver: handleDragOver,
+ onDragLeave: handleDragLeave,
+ onDrop: handleDrop,
+ };
+
+ // Action handlers for toolbar
+ const handleSplitSelected = () => {
+ if (selectedElements.length === 0) {
+ toast.error("No elements selected");
+ return;
+ }
+ let splitCount = 0;
+ selectedElements.forEach(({ trackId, elementId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
+ if (element && track) {
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (currentTime > effectiveStart && currentTime < effectiveEnd) {
+ const newElementId = splitElement(trackId, elementId, currentTime);
+ if (newElementId) splitCount++;
+ }
+ }
+ });
+ if (splitCount === 0) {
+ toast.error("Playhead must be within selected elements to split");
+ }
+ };
+
+ const handleDuplicateSelected = () => {
+ if (selectedElements.length === 0) {
+ toast.error("No elements selected");
+ return;
+ }
+ const canDuplicate = selectedElements.length === 1;
+ if (!canDuplicate) return;
+
+ const newSelections: { trackId: string; elementId: string }[] = [];
+
+ selectedElements.forEach(({ trackId, elementId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((el) => el.id === elementId);
+
+ if (element) {
+ const newStartTime =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1;
+
+ // Create element without id (will be generated by store)
+ const { id, ...elementWithoutId } = element;
+
+ addElementToTrack(trackId, {
+ ...elementWithoutId,
+ startTime: newStartTime,
+ });
+
+ // We can't predict the new id, so just clear selection for now
+ // TODO: addElementToTrack could return the new element id
+ }
+ });
+
+ clearSelectedElements();
+ };
+
+ const handleFreezeSelected = () => {
+ toast.info("Freeze frame functionality coming soon!");
+ };
+
+ const handleSplitAndKeepLeft = () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element");
+ return;
+ }
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
+ if (!element) return;
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within selected element");
+ return;
+ }
+ splitAndKeepLeft(trackId, elementId, currentTime);
+ };
+
+ const handleSplitAndKeepRight = () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element");
+ return;
+ }
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
+ if (!element) return;
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within selected element");
+ return;
+ }
+ splitAndKeepRight(trackId, elementId, currentTime);
+ };
+
+ const handleSeparateAudio = () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one media element to separate audio");
+ return;
+ }
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ if (!track || track.type !== "media") {
+ toast.error("Select a media element to separate audio");
+ return;
+ }
+ separateAudio(trackId, elementId);
+ };
+
+ const handleDeleteSelected = () => {
+ if (selectedElements.length === 0) {
+ toast.error("No elements selected");
+ return;
+ }
+ selectedElements.forEach(({ trackId, elementId }) => {
+ removeElementFromTrack(trackId, elementId);
+ });
+ clearSelectedElements();
+ };
+
+ // --- Scroll synchronization effect ---
+ useEffect(() => {
+ const rulerViewport = rulerScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ const tracksViewport = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+
+ if (!rulerViewport || !tracksViewport) return;
+
+ // Horizontal scroll synchronization between ruler and tracks
+ const handleRulerScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
+ lastRulerSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollLeft = rulerViewport.scrollLeft;
+ isUpdatingRef.current = false;
+ };
+ const handleTracksScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
+ lastTracksSync.current = now;
+ isUpdatingRef.current = true;
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft;
+ isUpdatingRef.current = false;
+ };
+
+ rulerViewport.addEventListener("scroll", handleRulerScroll);
+ tracksViewport.addEventListener("scroll", handleTracksScroll);
+
+ // Vertical scroll synchronization between track labels and tracks content
+ if (trackLabelsViewport) {
+ const handleTrackLabelsScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+ const handleTracksVerticalScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+
+ trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
+ tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
+
+ return () => {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ trackLabelsViewport.removeEventListener(
+ "scroll",
+ handleTrackLabelsScroll
+ );
+ tracksViewport.removeEventListener(
+ "scroll",
+ handleTracksVerticalScroll
+ );
+ };
+ }
+
+ return () => {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ };
+ }, []);
+
+ return (
+ <div
+ className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`}
+ {...dragProps}
+ onMouseEnter={() => setIsInTimeline(true)}
+ onMouseLeave={() => setIsInTimeline(false)}
+ >
+ {/* Toolbar */}
+ <div className="border-b flex items-center justify-between px-2 py-1">
+ <div className="flex items-center gap-1 w-full">
+ <TooltipProvider delayDuration={500}>
+ {/* Play/Pause Button */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={toggle}
+ className="mr-2"
+ >
+ {isPlaying ? (
+ <Pause className="h-4 w-4" />
+ ) : (
+ <Play className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {isPlaying ? "Pause (Space)" : "Play (Space)"}
+ </TooltipContent>
+ </Tooltip>
+ <div className="w-px h-6 bg-border mx-1" />
+ {/* Time Display */}
+ <div
+ className="text-xs text-muted-foreground font-mono px-2"
+ style={{ minWidth: "18ch", textAlign: "center" }}
+ >
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
+ </div>
+ {/* Test Clip Button - for debugging */}
+ {tracks.length === 0 && (
+ <>
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const trackId = addTrack("media");
+ addElementToTrack(trackId, {
+ type: "media",
+ mediaId: "test",
+ name: "Test Clip",
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }}
+ className="text-xs"
+ >
+ Add Test Clip
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ Add a test clip to try playback
+ </TooltipContent>
+ </Tooltip>
+ </>
+ )}
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitSelected}
+ >
+ <Scissors className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split element (Ctrl+S)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepLeft}
+ >
+ <ArrowLeftToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepRight}
+ >
+ <ArrowRightToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSeparateAudio}
+ >
+ <SplitSquareHorizontal className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleDuplicateSelected}
+ >
+ <Copy className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleFreezeSelected}
+ >
+ <Snowflake className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Freeze frame (F)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleDeleteSelected}
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Delete element (Delete)</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ <div className="flex items-center gap-1">
+ <TooltipProvider delayDuration={500}>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={toggleSnapping}>
+ {snappingEnabled ? (
+ <Lock className="h-4 w-4" />
+ ) : (
+ <LockOpen className="h-4 w-4 text-primary" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Auto snapping</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ </div>
+
+ {/* Timeline Container */}
+ <div
+ className="flex-1 flex flex-col overflow-hidden relative"
+ ref={timelineRef}
+ >
+ <TimelinePlayhead
+ currentTime={currentTime}
+ duration={duration}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ seek={seek}
+ rulerRef={rulerRef}
+ rulerScrollRef={rulerScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ trackLabelsRef={trackLabelsRef}
+ timelineRef={timelineRef}
+ playheadRef={playheadRef}
+ isSnappingToPlayhead={
+ showSnapIndicator && currentSnapPoint?.type === "playhead"
+ }
+ />
+ <SnapIndicator
+ snapPoint={currentSnapPoint}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ timelineRef={timelineRef}
+ trackLabelsRef={trackLabelsRef}
+ isVisible={showSnapIndicator}
+ />
+ {/* Timeline Header with Ruler */}
+ <div className="flex bg-panel sticky top-0 z-10">
+ {/* Track Labels Header */}
+ <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2">
+ {/* Empty space */}
+ <span className="text-sm font-medium text-muted-foreground opacity-0">
+ .
+ </span>
+ </div>
+
+ {/* Timeline Ruler */}
+ <div
+ className="flex-1 relative overflow-hidden h-4"
+ onWheel={handleWheel}
+ onMouseDown={handleSelectionMouseDown}
+ onClick={handleTimelineContentClick}
+ data-ruler-area
+ >
+ <ScrollArea className="w-full" ref={rulerScrollRef}>
+ <div
+ ref={rulerRef}
+ className="relative h-4 select-none cursor-default"
+ style={{
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ onMouseDown={handleRulerMouseDown}
+ >
+ {/* Time markers */}
+ {(() => {
+ // Calculate appropriate time interval based on zoom level
+ const getTimeInterval = (zoom: number) => {
+ const pixelsPerSecond =
+ TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom;
+ if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in
+ if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in
+ if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom
+ if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out
+ if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out
+ if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out
+ return 30; // Every 30s when extremely zoomed out
+ };
+
+ const interval = getTimeInterval(zoomLevel);
+ const markerCount = Math.ceil(duration / interval) + 1;
+
+ return Array.from({ length: markerCount }, (_, i) => {
+ const time = i * interval;
+ if (time > duration) return null;
+
+ const isMainMarker =
+ time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
+
+ return (
+ <div
+ key={i}
+ className={`absolute top-0 bottom-0 ${
+ isMainMarker
+ ? "border-l border-muted-foreground/40"
+ : "border-l border-muted-foreground/20"
+ }`}
+ style={{
+ left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
+ }}
+ >
+ <span
+ className={`absolute top-1 left-1 text-[0.6rem] ${
+ isMainMarker
+ ? "text-muted-foreground font-medium"
+ : "text-muted-foreground/70"
+ }`}
+ >
+ {(() => {
+ const formatTime = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`;
+ } else if (minutes > 0) {
+ return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
+ } else if (interval >= 1) {
+ return `${Math.floor(secs)}s`;
+ } else {
+ return `${secs.toFixed(1)}s`;
+ }
+ };
+ return formatTime(time);
+ })()}
+ </span>
+ </div>
+ );
+ }).filter(Boolean);
+ })()}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+
+ {/* Tracks Area */}
+ <div className="flex-1 flex overflow-hidden">
+ {/* Track Labels */}
+ {tracks.length > 0 && (
+ <div
+ ref={trackLabelsRef}
+ className="w-48 flex-shrink-0 border-r bg-panel-accent overflow-y-auto"
+ data-track-labels
+ >
+ <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>
+ <div className="flex flex-col gap-1">
+ {tracks.map((track) => (
+ <div
+ key={track.id}
+ className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5"
+ style={{ height: `${getTrackHeight(track.type)}px` }}
+ >
+ <div className="flex items-center flex-1 min-w-0">
+ <TrackIcon track={track} />
+ </div>
+ {track.muted && (
+ <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
+ Muted
+ </span>
+ )}
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+ )}
+
+ {/* Timeline Tracks Content */}
+ <div
+ className="flex-1 relative overflow-hidden"
+ onWheel={handleWheel}
+ onMouseDown={handleSelectionMouseDown}
+ onClick={handleTimelineContentClick}
+ ref={tracksContainerRef}
+ >
+ <SelectionBox
+ startPos={selectionBox?.startPos || null}
+ currentPos={selectionBox?.currentPos || null}
+ containerRef={tracksContainerRef}
+ isActive={selectionBox?.isActive || false}
+ />
+ <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
+ <div
+ className="relative flex-1"
+ style={{
+ height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`,
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ >
+ {tracks.length === 0 ? (
+ <div></div>
+ ) : (
+ <>
+ {tracks.map((track, index) => (
+ <ContextMenu key={track.id}>
+ <ContextMenuTrigger asChild>
+ <div
+ className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]"
+ style={{
+ top: `${getCumulativeHeightBefore(tracks, index)}px`,
+ height: `${getTrackHeight(track.type)}px`,
+ }}
+ onClick={(e) => {
+ // If clicking empty area (not on a element), deselect all elements
+ if (
+ !(e.target as HTMLElement).closest(
+ ".timeline-element"
+ )
+ ) {
+ clearSelectedElements();
+ }
+ }}
+ >
+ <TimelineTrackContent
+ track={track}
+ zoomLevel={zoomLevel}
+ onSnapPointChange={handleSnapPointChange}
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem
+ onClick={() => toggleTrackMute(track.id)}
+ >
+ {track.muted ? "Unmute Track" : "Mute Track"}
+ </ContextMenuItem>
+ <ContextMenuItem>
+ Track settings (soon)
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ ))}
+ </>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function TrackIcon({ track }: { track: TimelineTrack }) {
+ return (
+ <>
+ {track.type === "media" && (
+ <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
+ )}
+ {track.type === "text" && (
+ <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
+ )}
+ {track.type === "audio" && (
+ <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
+ )}
+ </>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx deleted file mode 100644 index 6d90670a..00000000 --- a/apps/web/src/components/editor/timeline/index.tsx +++ /dev/null @@ -1,193 +0,0 @@ -"use client"; - -import { useState, useRef, useEffect, useCallback } from "react"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; -import { useSelectionBox } from "@/hooks/use-selection-box"; -import { SnapPoint } from "@/hooks/use-timeline-snapping"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { TimelineToolbar } from "./timeline-toolbar"; -import { TimelineContent } from "./timeline-content"; -import { useTimelineDragHandlers } from "./timeline-drag-handlers"; -import { useTimelineActionHandlers } from "./timeline-action-handlers"; -import { useTimelineScrollSync } from "./timeline-scroll-sync"; -import { useTimelineContentClick } from "./timeline-content-click"; -import { useTimelineWheelHandler } from "./timeline-wheel-handler"; - -export function Timeline() { - // Timeline shows all tracks (video, audio, effects) and their elements. - // You can drag media here to add it to your project. - // elements can be trimmed, deleted, and moved. - - const { - tracks, - getTotalDuration, - selectedElements, - clearSelectedElements, - setSelectedElements, - dragState, - snappingEnabled, - } = useTimelineStore(); - const { currentTime, duration, seek, setDuration } = usePlaybackStore(); - const [isDragOver, setIsDragOver] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [progress, setProgress] = useState(0); - const timelineRef = useRef<HTMLDivElement>(null); - const rulerRef = useRef<HTMLDivElement>(null); - const [isInTimeline, setIsInTimeline] = useState(false); - - // Timeline zoom functionality - const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ - containerRef: timelineRef, - isInTimeline, - }); - - // Dynamic timeline width calculation based on playhead position and duration - const dynamicTimelineWidth = Math.max( - (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration - (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer - timelineRef.current?.clientWidth || 1000 // Minimum width - ); - - // Scroll synchronization and auto-scroll to playhead - const rulerScrollRef = useRef<HTMLDivElement>(null); - const tracksScrollRef = useRef<HTMLDivElement>(null); - const trackLabelsRef = useRef<HTMLDivElement>(null); - const playheadRef = useRef<HTMLDivElement>(null); - const trackLabelsScrollRef = useRef<HTMLDivElement>(null); - - // Selection box functionality - const tracksContainerRef = useRef<HTMLDivElement>(null); - const { - selectionBox, - handleMouseDown: handleSelectionMouseDown, - isSelecting, - justFinishedSelecting, - } = useSelectionBox({ - containerRef: tracksContainerRef, - playheadRef, - onSelectionComplete: (elements) => { - console.log(JSON.stringify({ onSelectionComplete: elements.length })); - setSelectedElements(elements); - }, - }); - - // Calculate snap indicator state - const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( - null - ); - const showSnapIndicator = - dragState.isDragging && snappingEnabled && currentSnapPoint !== null; - - // Callback to handle snap point changes from TimelineTrackContent - const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { - setCurrentSnapPoint(snapPoint); - }, []); - - // Timeline content click handler - const { handleTimelineContentClick } = useTimelineContentClick({ - duration, - zoomLevel, - seek, - rulerScrollRef, - tracksScrollRef, - clearSelectedElements, - isSelecting, - justFinishedSelecting, - playheadRef, - }); - - // Update timeline duration when tracks change - useEffect(() => { - const totalDuration = getTotalDuration(); - setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline - }, [tracks, setDuration, getTotalDuration]); - - // Drag handlers - const { dragProps } = useTimelineDragHandlers({ - isDragOver, - setIsDragOver, - isProcessing, - setIsProcessing, - progress, - setProgress, - }); - - // Action handlers - const { - handleSplitSelected, - handleDuplicateSelected, - handleFreezeSelected, - handleSplitAndKeepLeft, - handleSplitAndKeepRight, - handleSeparateAudio, - handleDeleteSelected, - } = useTimelineActionHandlers(); - - // Scroll synchronization - useTimelineScrollSync({ - rulerScrollRef, - tracksScrollRef, - }); - - // Wheel event handling - useTimelineWheelHandler({ - timelineRef, - isInTimeline, - handleWheel, - }); - - return ( - <div - className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`} - {...dragProps} - onMouseEnter={() => setIsInTimeline(true)} - onMouseLeave={() => setIsInTimeline(false)} - > - {/* Toolbar */} - <TimelineToolbar - handleSplitSelected={handleSplitSelected} - handleSplitAndKeepLeft={handleSplitAndKeepLeft} - handleSplitAndKeepRight={handleSplitAndKeepRight} - handleSeparateAudio={handleSeparateAudio} - handleDuplicateSelected={handleDuplicateSelected} - handleFreezeSelected={handleFreezeSelected} - handleDeleteSelected={handleDeleteSelected} - /> - - {/* Timeline Container */} - <div - ref={timelineRef} - className="relative h-full w-full overflow-auto border" - onMouseEnter={() => setIsInTimeline(true)} - onMouseLeave={() => setIsInTimeline(false)} - onMouseDown={handleSelectionMouseDown} - onClick={handleTimelineContentClick} - > - <TimelineContent - dynamicTimelineWidth={dynamicTimelineWidth} - tracks={tracks} - duration={duration} - zoomLevel={zoomLevel} - currentTime={currentTime} - seek={seek} - rulerRef={rulerRef} - rulerScrollRef={rulerScrollRef} - tracksScrollRef={tracksScrollRef} - playheadRef={playheadRef} - trackLabelsRef={trackLabelsRef} - timelineRef={timelineRef} - handleSelectionMouseDown={handleSelectionMouseDown} - handleTimelineContentClick={handleTimelineContentClick} - handleSnapPointChange={handleSnapPointChange} - clearSelectedElements={clearSelectedElements} - selectionBox={selectionBox} - tracksContainerRef={tracksContainerRef} - currentSnapPoint={currentSnapPoint} - showSnapIndicator={showSnapIndicator} - /> - </div> - </div> - ); -} diff --git a/apps/web/src/components/editor/timeline/timeline-action-handlers.tsx b/apps/web/src/components/editor/timeline/timeline-action-handlers.tsx deleted file mode 100644 index 22c1efa5..00000000 --- a/apps/web/src/components/editor/timeline/timeline-action-handlers.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { useMemo } from "react"; -import { toast } from "sonner"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { usePlaybackStore } from "@/stores/playback-store"; - -export function useTimelineActionHandlers() { - const { - tracks, - addElementToTrack, - removeElementFromTrack, - selectedElements, - clearSelectedElements, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); - - // Create optimized lookup maps for O(1) access instead of O(n) find operations - const trackMap = useMemo(() => { - const map = new Map(); - tracks.forEach((track) => { - map.set(track.id, track); - // Also create element lookup for this track - const elementMap = new Map(); - track.elements.forEach((element) => { - elementMap.set(element.id, element); - }); - map.set(`${track.id}_elements`, elementMap); - }); - return map; - }, [tracks]); - - // Helper function for O(1) track/element lookup - const findTrackAndElement = (trackId: string, elementId: string) => { - const track = trackMap.get(trackId); - const elementMap = trackMap.get(`${trackId}_elements`); - const element = elementMap?.get(elementId); - return { track, element }; - }; - - // Action handlers for toolbar - const handleSplitSelected = () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - let splitCount = 0; - selectedElements.forEach(({ trackId, elementId }) => { - const { track, element } = findTrackAndElement(trackId, elementId); - if (element && track) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const newElementId = splitElement(trackId, elementId, currentTime); - if (newElementId) splitCount++; - } - } - }); - if (splitCount === 0) { - toast.error("Playhead must be within selected elements to split"); - } - }; - - const handleDuplicateSelected = () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; - - selectedElements.forEach(({ trackId, elementId }) => { - const { element } = findTrackAndElement(trackId, elementId); - - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; - - // Create element without id (will be generated by store) - const { id, ...elementWithoutId } = element; - - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - - // We can't predict the new id, so just clear selection for now - // TODO: addElementToTrack could return the new element id - } - }); - - clearSelectedElements(); - }; - - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); - }; - - const handleSplitAndKeepLeft = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const { element } = findTrackAndElement(trackId, elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepLeft(trackId, elementId, currentTime); - }; - - const handleSplitAndKeepRight = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const { element } = findTrackAndElement(trackId, elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepRight(trackId, elementId, currentTime); - }; - - const handleSeparateAudio = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one media element to separate audio"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const { track } = findTrackAndElement(trackId, elementId); - if (!track || track.type !== "media") { - toast.error("Select a media element to separate audio"); - return; - } - separateAudio(trackId, elementId); - }; - - const handleDeleteSelected = () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - selectedElements.forEach(({ trackId, elementId }) => { - removeElementFromTrack(trackId, elementId); - }); - clearSelectedElements(); - }; - - return { - handleSplitSelected, - handleDuplicateSelected, - handleFreezeSelected, - handleSplitAndKeepLeft, - handleSplitAndKeepRight, - handleSeparateAudio, - handleDeleteSelected, - }; -} diff --git a/apps/web/src/components/editor/timeline/timeline-content-click.tsx b/apps/web/src/components/editor/timeline/timeline-content-click.tsx deleted file mode 100644 index 0498e236..00000000 --- a/apps/web/src/components/editor/timeline/timeline-content-click.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useCallback } from "react"; -import { useProjectStore } from "@/stores/project-store"; -import { - TIMELINE_CONSTANTS, - snapTimeToFrame, -} from "@/constants/timeline-constants"; - -export interface TimelineContentClickProps { - duration: number; - zoomLevel: number; - seek: (time: number) => void; - rulerScrollRef: React.RefObject<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLDivElement>; - clearSelectedElements: () => void; - isSelecting: boolean; - justFinishedSelecting: boolean; - playheadRef: React.RefObject<HTMLDivElement>; -} - -export function useTimelineContentClick({ - duration, - zoomLevel, - seek, - rulerScrollRef, - tracksScrollRef, - clearSelectedElements, - isSelecting, - justFinishedSelecting, - playheadRef, -}: TimelineContentClickProps) { - const { activeProject } = useProjectStore(); - - // Timeline content click to seek handler - const handleTimelineContentClick = useCallback( - (e: React.MouseEvent) => { - // Don't seek if this was a selection box operation - if (isSelecting || justFinishedSelecting) { - return; - } - - // Don't seek if clicking on timeline elements, but still deselect - if ((e.target as HTMLElement).closest(".timeline-element")) { - return; - } - - // Don't seek if clicking on playhead - if (playheadRef.current?.contains(e.target as Node)) { - return; - } - - // Don't seek if clicking on track labels - if ((e.target as HTMLElement).closest("[data-track-labels]")) { - clearSelectedElements(); - return; - } - - // Clear selected elements when clicking empty timeline area - clearSelectedElements(); - - // Determine if we're clicking in ruler or tracks area - const isRulerClick = (e.target as HTMLElement).closest( - "[data-ruler-area]" - ); - - let mouseX: number; - let scrollLeft = 0; - - if (isRulerClick) { - // Calculate based on ruler position - const rulerContent = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - if (!rulerContent) return; - const rect = rulerContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = rulerContent.scrollLeft; - } else { - // Calculate based on tracks content position - const tracksContent = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - if (!tracksContent) return; - const rect = tracksContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = tracksContent.scrollLeft; - } - - const rawTime = Math.max( - 0, - Math.min( - duration, - (mouseX + scrollLeft) / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) - ) - ); - - // Use frame snapping for timeline clicking - const projectFps = activeProject?.fps || 30; - const time = snapTimeToFrame(rawTime, projectFps); - - seek(time); - }, - [ - duration, - zoomLevel, - seek, - rulerScrollRef, - tracksScrollRef, - clearSelectedElements, - isSelecting, - justFinishedSelecting, - playheadRef, - activeProject?.fps, - ] - ); - - return { handleTimelineContentClick }; -} diff --git a/apps/web/src/components/editor/timeline/timeline-content.tsx b/apps/web/src/components/editor/timeline/timeline-content.tsx deleted file mode 100644 index cb9a313f..00000000 --- a/apps/web/src/components/editor/timeline/timeline-content.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { SelectionBox } from "../selection-box"; -import { TimelinePlayhead } from "./timeline-playhead"; -import { SnapIndicator } from "../snap-indicator"; -import { TimelineRuler } from "./timeline-ruler"; -import { TimelineTracksArea } from "./timeline-tracks-area"; -import type { TimelineTrack } from "@/types/timeline"; -import type { SnapPoint } from "@/hooks/use-timeline-snapping"; - -export interface TimelineContentProps { - dynamicTimelineWidth: number; - tracks: TimelineTrack[]; - duration: number; - zoomLevel: number; - currentTime: number; - seek: (time: number) => void; - rulerRef: React.RefObject<HTMLDivElement>; - rulerScrollRef: React.RefObject<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLDivElement>; - playheadRef: React.RefObject<HTMLDivElement>; - trackLabelsRef: React.RefObject<HTMLDivElement>; - timelineRef: React.RefObject<HTMLDivElement>; - handleSelectionMouseDown: (e: React.MouseEvent) => void; - handleTimelineContentClick: (e: React.MouseEvent) => void; - handleSnapPointChange: (snapPoint: SnapPoint | null) => void; - clearSelectedElements: () => void; - selectionBox: { - startPos: { x: number; y: number } | null; - currentPos: { x: number; y: number } | null; - isActive: boolean; - } | null; - tracksContainerRef: React.RefObject<HTMLDivElement>; - currentSnapPoint: SnapPoint | null; - showSnapIndicator: boolean; -} - -export function TimelineContent({ - dynamicTimelineWidth, - tracks, - duration, - zoomLevel, - currentTime, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - trackLabelsRef, - timelineRef, - handleSelectionMouseDown, - handleTimelineContentClick, - handleSnapPointChange, - clearSelectedElements, - selectionBox, - tracksContainerRef, - currentSnapPoint, - showSnapIndicator, -}: TimelineContentProps) { - return ( - <div - className="relative min-w-max min-h-max h-full" - style={{ minWidth: `${dynamicTimelineWidth}px` }} - > - <div - className={`h-full grid grid-cols-[192px_1fr] ${tracks.length > 0 ? `grid-rows-[20px_repeat(${tracks.length},minmax(0,max-content))]` : "grid-rows-[20px_1fr]"}`} - > - - - {/* Top Row (Sticky Ruler Header) */} - <TimelineRuler - duration={duration} - zoomLevel={zoomLevel} - currentTime={currentTime} - seek={seek} - rulerRef={rulerRef} - rulerScrollRef={rulerScrollRef} - tracksScrollRef={tracksScrollRef} - playheadRef={playheadRef} - handleSelectionMouseDown={handleSelectionMouseDown} - handleTimelineContentClick={handleTimelineContentClick} - /> - - {/* Track Rows */} - <TimelineTracksArea - tracks={tracks} - zoomLevel={zoomLevel} - handleSnapPointChange={handleSnapPointChange} - clearSelectedElements={clearSelectedElements} - /> - </div> - - {/* Overlay Components - positioned absolutely relative to the timeline container */} - <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} - /> - - <TimelinePlayhead - currentTime={currentTime} - duration={duration} - zoomLevel={zoomLevel} - tracks={tracks} - seek={seek} - rulerRef={rulerRef} - rulerScrollRef={rulerScrollRef} - tracksScrollRef={tracksScrollRef} - trackLabelsRef={trackLabelsRef} - timelineRef={timelineRef} - playheadRef={playheadRef} - isSnappingToPlayhead={ - showSnapIndicator && currentSnapPoint?.type === "playhead" - } - /> - - <SnapIndicator - snapPoint={currentSnapPoint} - zoomLevel={zoomLevel} - tracks={tracks} - timelineRef={timelineRef} - trackLabelsRef={trackLabelsRef} - isVisible={showSnapIndicator} - /> - </div> - ); -} diff --git a/apps/web/src/components/editor/timeline/timeline-drag-handlers.tsx b/apps/web/src/components/editor/timeline/timeline-drag-handlers.tsx deleted file mode 100644 index afb83e54..00000000 --- a/apps/web/src/components/editor/timeline/timeline-drag-handlers.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useRef } from "react"; -import { toast } from "sonner"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useMediaStore } from "@/stores/media-store"; -import { useProjectStore } from "@/stores/project-store"; -import { processMediaFiles } from "@/lib/media-processing"; -import type { DragData } from "@/types/timeline"; - -export interface TimelineDragHandlersProps { - isDragOver: boolean; - setIsDragOver: (isDragOver: boolean) => void; - isProcessing: boolean; - setIsProcessing: (isProcessing: boolean) => void; - progress: number; - setProgress: (progress: number) => void; -} - -export function useTimelineDragHandlers({ - isDragOver, - setIsDragOver, - isProcessing, - setIsProcessing, - progress, - setProgress, -}: TimelineDragHandlersProps) { - const { mediaItems, addMediaItem } = useMediaStore(); - const { activeProject } = useProjectStore(); - const dragCounterRef = useRef(0); - - const handleDragEnter = (e: React.DragEvent) => { - // When something is dragged over the timeline, show overlay - e.preventDefault(); - // Don't show overlay for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } - dragCounterRef.current += 1; - if (!isDragOver) { - setIsDragOver(true); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - - // Don't update state for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } - - dragCounterRef.current -= 1; - if (dragCounterRef.current === 0) { - setIsDragOver(false); - } - }; - - const handleDrop = async (e: React.DragEvent) => { - // When media is dropped, add it as a new track/element - e.preventDefault(); - setIsDragOver(false); - dragCounterRef.current = 0; - - // Ignore timeline element drags - they're handled by track-specific handlers - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - if (hasTimelineElement) { - return; - } - - const itemData = e.dataTransfer.getData("application/x-media-item"); - if (itemData) { - try { - const dragData: DragData = JSON.parse(itemData); - - if (dragData.type === "text") { - // Always create new text track to avoid overlaps - useTimelineStore.getState().addTextToNewTrack(dragData); - } else { - // Handle media items - const mediaItem = mediaItems.find( - (item: any) => item.id === dragData.id - ); - if (!mediaItem) { - toast.error("Media item not found"); - return; - } - - useTimelineStore.getState().addMediaToNewTrack(mediaItem); - } - } catch (error) { - console.error("Error parsing dropped item data:", error); - toast.error("Failed to add item to timeline"); - } - } else if (e.dataTransfer.files?.length > 0) { - // Handle file drops by creating new tracks - if (!activeProject) { - toast.error("No active project"); - return; - } - - setIsProcessing(true); - setProgress(0); - try { - const processedItems = await processMediaFiles( - e.dataTransfer.files, - (p) => setProgress(p) - ); - for (const processedItem of processedItems) { - await addMediaItem(activeProject.id, processedItem); - const currentMediaItems = useMediaStore.getState().mediaItems; - const addedItem = currentMediaItems.find( - (item) => - item.name === processedItem.name && item.url === processedItem.url - ); - if (addedItem) { - useTimelineStore.getState().addMediaToNewTrack(addedItem); - } - } - } catch (error) { - // Show error if file processing fails - console.error("Error processing external files:", error); - toast.error("Failed to process dropped files"); - } finally { - setIsProcessing(false); - setProgress(0); - } - } - }; - - const dragProps = { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - }; - - return { dragProps }; -} diff --git a/apps/web/src/components/editor/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/timeline/timeline-ruler.tsx deleted file mode 100644 index 7618ce7c..00000000 --- a/apps/web/src/components/editor/timeline/timeline-ruler.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useMemo } from "react"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { useTimelinePlayheadRuler } from "./timeline-playhead"; - -export interface TimelineRulerProps { - duration: number; - zoomLevel: number; - currentTime: number; - seek: (time: number) => void; - rulerRef: React.RefObject<HTMLDivElement>; - rulerScrollRef: React.RefObject<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLDivElement>; - playheadRef: React.RefObject<HTMLDivElement>; - handleSelectionMouseDown: (e: React.MouseEvent) => void; - handleTimelineContentClick: (e: React.MouseEvent) => void; -} - -export function TimelineRuler({ - duration, - zoomLevel, - currentTime, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - handleSelectionMouseDown, - handleTimelineContentClick, -}: TimelineRulerProps) { - // Timeline playhead ruler handlers - const { handleRulerMouseDown } = useTimelinePlayheadRuler({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - // Memoize the expensive ruler markers calculation - const rulerMarkers = useMemo(() => { - // Calculate appropriate time interval based on zoom level - const getTimeInterval = (zoom: number) => { - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; - if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in - if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in - if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom - if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out - if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out - if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out - return 30; // Every 30s when extremely zoomed out - }; - - const formatTime = (seconds: number, interval: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (minutes > 0) { - return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (interval >= 1) { - return `${Math.floor(secs)}s`; - } else { - return `${secs.toFixed(1)}s`; - } - }; - - const interval = getTimeInterval(zoomLevel); - const markerCount = Math.ceil(duration / interval) + 1; - const markers = []; - - for (let i = 0; i < markerCount; i++) { - const time = i * interval; - if (time > duration) break; - - const isMainMarker = - time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; - const leftPosition = - time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - - markers.push( - <div - key={i} - className={`absolute top-0 bottom-0 z-[99] ${ - isMainMarker - ? "border-l border-muted-foreground/40" - : "border-l border-muted-foreground/20" - }`} - style={{ left: `${leftPosition}px` }} - > - <span - className={`absolute top-1 left-1 text-[0.6rem] z-[99] ${ - isMainMarker - ? "text-muted-foreground font-medium" - : "text-muted-foreground/70" - }`} - > - {formatTime(time, interval)} - </span> - </div> - ); - } - - return markers; - }, [duration, zoomLevel]); // Only recalculate when duration or zoomLevel changes - - return ( - <div - className="sticky top-0 border-b border-muted/30 z-[99] w-24" - onMouseDown={handleSelectionMouseDown} - onClick={handleTimelineContentClick} - data-ruler-area - > - <div - ref={rulerRef} - className="relative h-5 select-none cursor-default pb-1 z-[99]" - onMouseDown={handleRulerMouseDown} - > - {rulerMarkers} - </div> - </div> - ); -} diff --git a/apps/web/src/components/editor/timeline/timeline-scroll-sync.tsx b/apps/web/src/components/editor/timeline/timeline-scroll-sync.tsx deleted file mode 100644 index 9938ca04..00000000 --- a/apps/web/src/components/editor/timeline/timeline-scroll-sync.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useEffect, useRef } from "react"; - -export interface TimelineScrollSyncProps { - rulerScrollRef: React.RefObject<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLDivElement>; -} - -export function useTimelineScrollSync({ - rulerScrollRef, - tracksScrollRef, -}: TimelineScrollSyncProps) { - const isUpdatingRef = useRef(false); - const lastSyncRef = useRef(0); - - // --- Horizontal scroll synchronization between ruler and tracks --- - useEffect(() => { - const rulerViewport = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - const tracksViewport = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - - if (!rulerViewport || !tracksViewport) return; - - // Throttled scroll handlers for better performance - const handleRulerScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastSyncRef.current < 16) return; // 60fps throttling - lastSyncRef.current = now; - - isUpdatingRef.current = true; - tracksViewport.scrollLeft = rulerViewport.scrollLeft; - isUpdatingRef.current = false; - }; - - const handleTracksScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastSyncRef.current < 16) return; // 60fps throttling - lastSyncRef.current = now; - - isUpdatingRef.current = true; - rulerViewport.scrollLeft = tracksViewport.scrollLeft; - isUpdatingRef.current = false; - }; - - rulerViewport.addEventListener("scroll", handleRulerScroll, { - passive: true, - }); - tracksViewport.addEventListener("scroll", handleTracksScroll, { - passive: true, - }); - - return () => { - rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); - }; - }, [rulerScrollRef, tracksScrollRef]); - - return null; -} diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx deleted file mode 100644 index eca15104..00000000 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { Button } from "../../ui/button"; -import { - Scissors, - ArrowLeftToLine, - ArrowRightToLine, - Trash2, - Snowflake, - Copy, - SplitSquareHorizontal, - Pause, - Play, - Lock, - LockOpen, -} from "lucide-react"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, - TooltipProvider, -} from "../../ui/tooltip"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; - -export interface TimelineToolbarProps { - handleSplitSelected: () => void; - handleSplitAndKeepLeft: () => void; - handleSplitAndKeepRight: () => void; - handleSeparateAudio: () => void; - handleDuplicateSelected: () => void; - handleFreezeSelected: () => void; - handleDeleteSelected: () => void; -} - -export function TimelineToolbar({ - handleSplitSelected, - handleSplitAndKeepLeft, - handleSplitAndKeepRight, - handleSeparateAudio, - handleDuplicateSelected, - handleFreezeSelected, - handleDeleteSelected, -}: TimelineToolbarProps) { - const { - tracks, - addTrack, - addElementToTrack, - snappingEnabled, - toggleSnapping, - } = useTimelineStore(); - const { currentTime, duration, isPlaying, toggle } = usePlaybackStore(); - - return ( - <div className="border-b flex items-center justify-between px-2 py-1 bg-card z-90"> - <div className="flex items-center gap-1 w-full"> - <TooltipProvider delayDuration={500}> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={toggle} - className="mr-2" - > - {isPlaying ? ( - <Pause className="h-4 w-4" /> - ) : ( - <Play className="h-4 w-4" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent> - {isPlaying ? "Pause (Space)" : "Play (Space)"} - </TooltipContent> - </Tooltip> - <div className="w-px h-6 bg-border mx-1" /> - <div - className="text-xs text-muted-foreground font-mono px-2" - style={{ minWidth: "18ch", textAlign: "center" }} - > - {currentTime.toFixed(1)}s / {duration.toFixed(1)}s - </div> - {tracks.length === 0 && ( - <> - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="outline" - size="sm" - onClick={() => { - const trackId = addTrack("media"); - addElementToTrack(trackId, { - type: "media", - mediaId: "test", - name: "Test Clip", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - }); - }} - className="text-xs" - > - Add Test Clip - </Button> - </TooltipTrigger> - <TooltipContent>Add a test clip to try playback</TooltipContent> - </Tooltip> - </> - )} - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleSplitSelected}> - <Scissors className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split element (Ctrl+S)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepLeft} - > - <ArrowLeftToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepRight} - > - <ArrowRightToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleSeparateAudio}> - <SplitSquareHorizontal className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Separate audio (Ctrl+D)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleDuplicateSelected} - > - <Copy className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleFreezeSelected}> - <Snowflake className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Freeze frame (F)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleDeleteSelected}> - <Trash2 className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Delete element (Delete)</TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - <div className="flex items-center gap-1"> - <TooltipProvider delayDuration={500}> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={toggleSnapping}> - {snappingEnabled ? ( - <Lock className="h-4 w-4" /> - ) : ( - <LockOpen className="h-4 w-4 text-primary" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent>Auto snapping</TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </div> - ); -} diff --git a/apps/web/src/components/editor/timeline/timeline-tracks-area.tsx b/apps/web/src/components/editor/timeline/timeline-tracks-area.tsx deleted file mode 100644 index e8ca1ec3..00000000 --- a/apps/web/src/components/editor/timeline/timeline-tracks-area.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Plus } from "lucide-react"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "../../ui/context-menu"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { TimelineTrackContent } from "./timeline-track"; -import { TrackIcon } from "./track-icon"; -import { getTrackHeight } from "@/constants/timeline-constants"; -import type { TimelineTrack } from "@/types/timeline"; -import type { SnapPoint } from "@/hooks/use-timeline-snapping"; - -export interface TimelineTracksAreaProps { - tracks: TimelineTrack[]; - zoomLevel: number; - handleSnapPointChange: (snapPoint: SnapPoint | null) => void; - clearSelectedElements: () => void; -} - -export function TimelineTracksArea({ - tracks, - zoomLevel, - handleSnapPointChange, - clearSelectedElements, -}: TimelineTracksAreaProps) { - const { toggleTrackMute } = useTimelineStore(); - - return ( - <> - <div className="h-full flex flex-col absolute left-0 top-0 w-24 bg-panel border-r border-black z-[100]"> - <div className="sticky left-0 h-5 border-inset z-[100]"></div> - - {tracks.map((track, index) => ( - <div - key={track.id} - className="sticky left-0 flex items-center border-b border-panel border-inset group z-[100]" - style={{ height: `${getTrackHeight(track.type)}px` }} - > - <div className="flex items-center gap-2 px-2"> - <TrackIcon track={track} /> - </div> - {track.muted && ( - <span className="text-xs text-red-500 font-semibold">Muted</span> - )} - </div> - ))} - </div> - - <div className="h-full flex flex-col pt-5 ml-24"> - {tracks.map((track, index) => ( - <ContextMenu key={track.id}> - <ContextMenuTrigger asChild> - <div - className="h-full" - style={{ height: `${getTrackHeight(track.type)}px` }} - onClick={(e) => { - if (!(e.target as HTMLElement).closest(".timeline-element")) { - clearSelectedElements(); - } - }} - > - <TimelineTrackContent - track={track} - zoomLevel={zoomLevel} - onSnapPointChange={handleSnapPointChange} - /> - </div> - </ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem onClick={() => toggleTrackMute(track.id)}> - {track.muted ? "Unmute Track" : "Mute Track"} - </ContextMenuItem> - <ContextMenuItem>Track settings (soon)</ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ))} - - <div style={{ height: `${getTrackHeight("media")}px` }}></div> - </div> - </> - ); -} diff --git a/apps/web/src/components/editor/timeline/timeline-wheel-handler.tsx b/apps/web/src/components/editor/timeline/timeline-wheel-handler.tsx deleted file mode 100644 index b7dfaf76..00000000 --- a/apps/web/src/components/editor/timeline/timeline-wheel-handler.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useEffect } from "react"; - -export interface TimelineWheelHandlerProps { - timelineRef: React.RefObject<HTMLDivElement>; - isInTimeline: boolean; - handleWheel: (e: React.WheelEvent) => void; -} - -export function useTimelineWheelHandler({ - timelineRef, - isInTimeline, - handleWheel, -}: TimelineWheelHandlerProps) { - useEffect(() => { - const timelineContainer = timelineRef.current; - if (!timelineContainer || !isInTimeline) return; - - const handleWheelCapture = (e: WheelEvent) => { - handleWheel(e as any); - }; - - timelineContainer.addEventListener("wheel", handleWheelCapture, { - passive: false, - }); - - return () => { - timelineContainer.removeEventListener("wheel", handleWheelCapture); - }; - }, [handleWheel, isInTimeline, timelineRef]); - - return null; -} diff --git a/apps/web/src/components/editor/timeline/track-icon.tsx b/apps/web/src/components/editor/timeline/track-icon.tsx deleted file mode 100644 index e250a0d8..00000000 --- a/apps/web/src/components/editor/timeline/track-icon.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Video, Music, TypeIcon } from "lucide-react"; -import type { TimelineTrack } from "@/types/timeline"; - -export function TrackIcon({ track }: { track: TimelineTrack }) { - return ( - <> - {track.type === "media" && ( - <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> - )} - {track.type === "text" && ( - <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> - )} - {track.type === "audio" && ( - <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> - )} - </> - ); -} |
