From 40c7fbb4f86130c83fbd9029e83c5e7550e3219d Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 6 Jul 2025 20:45:29 +0200 Subject: refactor: move to a typed-tracks system and add support for text --- .../web/src/components/editor/timeline-element.tsx | 408 +++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 apps/web/src/components/editor/timeline-element.tsx (limited to 'apps/web/src/components/editor/timeline-element.tsx') diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx new file mode 100644 index 00000000..fe2f6bcc --- /dev/null +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "../ui/button"; +import { + MoreVertical, + Scissors, + Trash2, + SplitSquareHorizontal, + Music, + ChevronRight, + ChevronLeft, + Type, +} 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, ResizeState, TrackType } from "@/types/timeline"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "../ui/dropdown-menu"; + +export function TimelineElement({ + element, + track, + zoomLevel, + isSelected, + onElementMouseDown, + onElementClick, +}: TimelineElementProps) { + const { mediaItems } = useMediaStore(); + const { + updateElementTrim, + removeElementFromTrack, + dragState, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + } = useTimelineStore(); + const { currentTime } = usePlaybackStore(); + + const [resizing, setResizing] = useState(null); + const [elementMenuOpen, setElementMenuOpen] = useState(false); + + const effectiveDuration = + element.duration - element.trimStart - element.trimEnd; + const elementWidth = Math.max(80, effectiveDuration * 50 * 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 getTrackColor = (type: TrackType) => { + switch (type) { + case "media": + return "bg-blue-500/20 border-blue-500/30"; + case "text": + return "bg-purple-500/20 border-purple-500/30"; + case "audio": + return "bg-green-500/20 border-green-500/30"; + default: + return "bg-gray-500/20 border-gray-500/30"; + } + }; + + // Resize handles for trimming elements + const handleResizeStart = ( + e: React.MouseEvent, + elementId: string, + side: "left" | "right" + ) => { + e.stopPropagation(); + e.preventDefault(); + + setResizing({ + elementId, + side, + startX: e.clientX, + initialTrimStart: element.trimStart, + initialTrimEnd: element.trimEnd, + }); + }; + + const updateTrimFromMouseMove = (e: { clientX: number }) => { + if (!resizing) return; + + const deltaX = e.clientX - resizing.startX; + const deltaTime = deltaX / (50 * zoomLevel); + + if (resizing.side === "left") { + const newTrimStart = Math.max( + 0, + Math.min( + element.duration - element.trimEnd - 0.1, + resizing.initialTrimStart + deltaTime + ) + ); + updateElementTrim(track.id, element.id, newTrimStart, element.trimEnd); + } else { + const newTrimEnd = Math.max( + 0, + Math.min( + element.duration - element.trimStart - 0.1, + resizing.initialTrimEnd - deltaTime + ) + ); + updateElementTrim(track.id, element.id, element.trimStart, newTrimEnd); + } + }; + + const handleResizeMove = (e: React.MouseEvent) => { + updateTrimFromMouseMove(e); + }; + + const handleResizeEnd = () => { + setResizing(null); + }; + + 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 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} + + ); + } + + if (mediaItem.type === "image") { + return ( +
+ {mediaItem.name} +
+ ); + } + + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + return ( +
+
+ {mediaItem.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 operations - only available when playhead is within element */} + + + + Split + + + + + Split at Playhead + + + + Split and Keep Left + + + + Split and Keep Right + + + + + {/* Audio separation - only available for video elements */} + {canSeparateAudio() && ( + <> + + + + Separate Audio + + + )} + + + + + Delete {element.type === "text" ? "text" : "clip"} + + + +
+
+
+ ); +} -- cgit v1.3.1