From 2517f77d42c223b807fd4e97a63ab343dbac9afb Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 21 Jul 2025 18:49:30 +0200 Subject: refactor: move timeline components to /timeline --- .../editor/timeline/timeline-element.tsx | 392 +++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 apps/web/src/components/editor/timeline/timeline-element.tsx (limited to 'apps/web/src/components/editor/timeline/timeline-element.tsx') diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx new file mode 100644 index 00000000..7cc72b10 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -0,0 +1,392 @@ +"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, + removeElementFromTrackWithRipple, + dragState, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + addElementToTrack, + replaceElementMedia, + rippleEditingEnabled, + } = 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; + + // Element should always be positioned at startTime - trimStart only affects content, not position + 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 = () => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(track.id, element.id); + } else { + 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 ( +
+ + {element.content} + +
+ ); + } + + // Render media element -> + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + if (!mediaItem) { + return ( + + {element.name} + + ); + } + + 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 ( +
+
+ {/* Background with tiled images */} +
+ {/* Overlay with vertical borders */} +
+
+
+ ); + } + + 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 ( +
+
+ {/* Background with tiled thumbnails */} +
+ {/* Overlay with vertical borders */} +
+
+ {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( +
+ {element.name} +
+ ) : ( + + {element.name} + + )} +
+ ); + } + + // Render audio element -> + if (mediaItem.type === "audio") { + return ( +
+
+ +
+
+ ); + } + + return ( + + {element.name} + + ); + }; + + const handleElementMouseDown = (e: React.MouseEvent) => { + if (onElementMouseDown) { + onElementMouseDown(e, element); + } + }; + + return ( + + +
+
onElementClick && onElementClick(e, element)} + onMouseDown={handleElementMouseDown} + onContextMenu={(e) => + onElementMouseDown && onElementMouseDown(e, element) + } + > +
+ {renderElementContent()} +
+ + {isSelected && ( + <> +
handleResizeStart(e, element.id, "left")} + /> +
handleResizeStart(e, element.id, "right")} + /> + + )} +
+
+ + + + + Split at playhead + + + + Duplicate {element.type === "text" ? "text" : "clip"} + + {element.type === "media" && ( + + + Replace clip + + )} + + + + Delete {element.type === "text" ? "text" : "clip"} + + + + ); +} -- cgit v1.3.1