diff options
Diffstat (limited to 'apps/web/src/components')
14 files changed, 830 insertions, 2306 deletions
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 9f7322c7..90ac84f0 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -4,15 +4,14 @@ import { TabBar } from "./tabbar"; import { MediaView } from "./views/media";
import { useMediaPanelStore, Tab } from "./store";
import { TextView } from "./views/text";
+import { AudioView } from "./views/audio";
export function MediaPanel() {
const { activeTab } = useMediaPanelStore();
const viewMap: Record<Tab, React.ReactNode> = {
media: <MediaView />,
- audio: (
- <div className="p-4 text-muted-foreground">Audio view coming soon...</div>
- ),
+ audio: <AudioView />,
text: <TextView />,
stickers: (
<div className="p-4 text-muted-foreground">
diff --git a/apps/web/src/components/editor/media-panel/views/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx new file mode 100644 index 00000000..7ddf465c --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/audio.tsx @@ -0,0 +1,18 @@ +"use client";
+
+import { Input } from "@/components/ui/input";
+import { useState } from "react";
+
+export function AudioView() {
+ const [search, setSearch] = useState("");
+ return (
+ <div className="h-full flex flex-col gap-2 p-4">
+ <Input
+ placeholder="Search songs and artists"
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ />
+ <div className="flex flex-col gap-2"></div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index 631fe39a..0189720f 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -208,10 +208,10 @@ export function MediaView() { {/* Search and filter controls */}
<div className="flex gap-2">
<Select value={mediaFilter} onValueChange={setMediaFilter}>
- <SelectTrigger className="w-[80px] h-full text-xs">
+ <SelectTrigger className="w-[80px] h-9 text-xs">
<SelectValue />
</SelectTrigger>
- <SelectContent className="">
+ <SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
@@ -221,7 +221,7 @@ export function MediaView() { <Input
type="text"
placeholder="Search media..."
- className="min-w-[60px] flex-1 h-full text-xs"
+ className="min-w-[60px] flex-1 h-9 text-xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
@@ -230,7 +230,7 @@ export function MediaView() { size="lg"
onClick={handleFileSelect}
disabled={isProcessing}
- className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
+ className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center h-9"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 2ecee391..2cba1374 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -16,8 +16,8 @@ import { DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; -import { Play, Pause, Expand } from "lucide-react"; -import { useState, useRef, useEffect } from "react"; +import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { FONT_CLASS_MAP } from "@/lib/font-config"; @@ -31,9 +31,9 @@ interface ActiveElement { } export function PreviewPanel() { - const { tracks } = useTimelineStore(); + const { tracks, getTotalDuration } = useTimelineStore(); const { mediaItems } = useMediaStore(); - const { currentTime } = usePlaybackStore(); + const { currentTime, toggle, setCurrentTime, isPlaying } = usePlaybackStore(); const { canvasSize } = useEditorStore(); const previewRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null); @@ -41,52 +41,51 @@ export function PreviewPanel() { width: 0, height: 0, }); + const [isExpanded, setIsExpanded] = useState(false); const { activeProject } = useProjectStore(); - // Calculate optimal preview size that fits in container while maintaining aspect ratio useEffect(() => { const updatePreviewSize = () => { if (!containerRef.current) return; - const container = containerRef.current.getBoundingClientRect(); - const computedStyle = getComputedStyle(containerRef.current); + let availableWidth, availableHeight; - // Get padding values - const paddingTop = parseFloat(computedStyle.paddingTop); - const paddingBottom = parseFloat(computedStyle.paddingBottom); - const paddingLeft = parseFloat(computedStyle.paddingLeft); - const paddingRight = parseFloat(computedStyle.paddingRight); - - // Get gap value (gap-4 = 1rem = 16px) - const gap = parseFloat(computedStyle.gap) || 16; - - // Get toolbar height if it exists - const toolbar = containerRef.current.querySelector("[data-toolbar]"); - const toolbarHeight = toolbar - ? toolbar.getBoundingClientRect().height - : 0; + if (isExpanded) { + const controlsHeight = 80; + const marginSpace = 24; + availableWidth = window.innerWidth - marginSpace; + availableHeight = window.innerHeight - controlsHeight - marginSpace; + } else { + const container = containerRef.current.getBoundingClientRect(); + const computedStyle = getComputedStyle(containerRef.current); + const paddingTop = parseFloat(computedStyle.paddingTop); + const paddingBottom = parseFloat(computedStyle.paddingBottom); + const paddingLeft = parseFloat(computedStyle.paddingLeft); + const paddingRight = parseFloat(computedStyle.paddingRight); + const gap = parseFloat(computedStyle.gap) || 16; + const toolbar = containerRef.current.querySelector("[data-toolbar]"); + const toolbarHeight = toolbar + ? toolbar.getBoundingClientRect().height + : 0; - // Calculate available space after accounting for padding, gap, and toolbar - const availableWidth = container.width - paddingLeft - paddingRight; - const availableHeight = - container.height - - paddingTop - - paddingBottom - - toolbarHeight - - (toolbarHeight > 0 ? gap : 0); + availableWidth = container.width - paddingLeft - paddingRight; + availableHeight = + container.height - + paddingTop - + paddingBottom - + toolbarHeight - + (toolbarHeight > 0 ? gap : 0); + } const targetRatio = canvasSize.width / canvasSize.height; const containerRatio = availableWidth / availableHeight; - let width, height; if (containerRatio > targetRatio) { - // Container is wider - constrain by height - height = availableHeight; + height = availableHeight * (isExpanded ? 0.95 : 1); width = height * targetRatio; } else { - // Container is taller - constrain by width - width = availableWidth; + width = availableWidth * (isExpanded ? 0.95 : 1); height = width / targetRatio; } @@ -94,16 +93,47 @@ export function PreviewPanel() { }; updatePreviewSize(); - const resizeObserver = new ResizeObserver(updatePreviewSize); if (containerRef.current) { resizeObserver.observe(containerRef.current); } + if (isExpanded) { + window.addEventListener("resize", updatePreviewSize); + } + + return () => { + resizeObserver.disconnect(); + if (isExpanded) { + window.removeEventListener("resize", updatePreviewSize); + } + }; + }, [canvasSize.width, canvasSize.height, isExpanded]); + + useEffect(() => { + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && isExpanded) { + setIsExpanded(false); + } + }; + + if (isExpanded) { + document.addEventListener("keydown", handleEscapeKey); + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + + return () => { + document.removeEventListener("keydown", handleEscapeKey); + document.body.style.overflow = ""; + }; + }, [isExpanded]); - return () => resizeObserver.disconnect(); - }, [canvasSize.width, canvasSize.height]); + const toggleExpanded = useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); - // Get active elements at current time + const hasAnyElements = tracks.some((track) => track.elements.length > 0); const getActiveElements = (): ActiveElement[] => { const activeElements: ActiveElement[] = []; @@ -116,16 +146,13 @@ export function PreviewPanel() { if (currentTime >= elementStart && currentTime < elementEnd) { let mediaItem = null; - - // Only get media item for media elements if (element.type === "media") { mediaItem = element.mediaId === "test" - ? null // Test elements don't have a real media item + ? null : mediaItems.find((item) => item.id === element.mediaId) || null; } - activeElements.push({ element, track, mediaItem }); } }); @@ -136,9 +163,6 @@ export function PreviewPanel() { const activeElements = getActiveElements(); - // Check if there are any elements in the timeline at all - const hasAnyElements = tracks.some((track) => track.elements.length > 0); - // Get media elements for blur background (video/image only) const getBlurBackgroundElements = (): ActiveElement[] => { return activeElements.filter( @@ -338,58 +362,334 @@ export function PreviewPanel() { }; return ( - <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm"> - <div - ref={containerRef} - className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0" - > - <div className="flex-1"></div> - {hasAnyElements ? ( - <div - ref={previewRef} - className="relative overflow-hidden border" - style={{ - width: previewDimensions.width, - height: previewDimensions.height, - backgroundColor: - activeProject?.backgroundType === "blur" - ? "transparent" - : activeProject?.backgroundColor || "#000000", - }} - > - {renderBlurBackground()} - {activeElements.length === 0 ? ( - <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> - No elements at current time - </div> - ) : ( - activeElements.map((elementData, index) => - renderElement(elementData, index) - ) - )} - {/* Show message when blur is selected but no media available */} - {activeProject?.backgroundType === "blur" && - blurBackgroundElements.length === 0 && - activeElements.length > 0 && ( - <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> - Add a video or image to use blur background + <> + <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm"> + <div + ref={containerRef} + className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0" + > + <div className="flex-1"></div> + {hasAnyElements ? ( + <div + ref={previewRef} + className="relative overflow-hidden border" + style={{ + width: previewDimensions.width, + height: previewDimensions.height, + backgroundColor: + activeProject?.backgroundType === "blur" + ? "transparent" + : activeProject?.backgroundColor || "#000000", + }} + > + {renderBlurBackground()} + {activeElements.length === 0 ? ( + <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> + No elements at current time </div> + ) : ( + activeElements.map((elementData, index) => + renderElement(elementData, index) + ) )} - </div> - ) : null} + {activeProject?.backgroundType === "blur" && + blurBackgroundElements.length === 0 && + activeElements.length > 0 && ( + <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> + Add a video or image to use blur background + </div> + )} + </div> + ) : null} + + <div className="flex-1"></div> + + <PreviewToolbar + hasAnyElements={hasAnyElements} + onToggleExpanded={toggleExpanded} + isExpanded={isExpanded} + currentTime={currentTime} + setCurrentTime={setCurrentTime} + toggle={toggle} + getTotalDuration={getTotalDuration} + /> + </div> + </div> + + {isExpanded && ( + <FullscreenPreview + previewDimensions={previewDimensions} + activeProject={activeProject} + renderBlurBackground={renderBlurBackground} + activeElements={activeElements} + renderElement={renderElement} + blurBackgroundElements={blurBackgroundElements} + hasAnyElements={hasAnyElements} + toggleExpanded={toggleExpanded} + currentTime={currentTime} + setCurrentTime={setCurrentTime} + toggle={toggle} + getTotalDuration={getTotalDuration} + /> + )} + </> + ); +} + +function FullscreenToolbar({ + hasAnyElements, + onToggleExpanded, + currentTime, + setCurrentTime, + toggle, + getTotalDuration, +}: { + hasAnyElements: boolean; + onToggleExpanded: () => void; + currentTime: number; + setCurrentTime: (time: number) => void; + toggle: () => void; + getTotalDuration: () => number; +}) { + const { isPlaying } = usePlaybackStore(); + const { activeProject } = useProjectStore(); + const [isDragging, setIsDragging] = useState(false); + + const totalDuration = getTotalDuration(); + const progress = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0; + + const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => { + if (!hasAnyElements) return; + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const percentage = Math.max(0, Math.min(1, clickX / rect.width)); + const newTime = percentage * totalDuration; + setCurrentTime(Math.max(0, Math.min(newTime, totalDuration))); + }; + + const handleTimelineDrag = (e: React.MouseEvent<HTMLDivElement>) => { + if (!hasAnyElements) return; + e.preventDefault(); + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + setIsDragging(true); + + const handleMouseMove = (moveEvent: MouseEvent) => { + moveEvent.preventDefault(); + const dragX = moveEvent.clientX - rect.left; + const percentage = Math.max(0, Math.min(1, dragX / rect.width)); + const newTime = percentage * totalDuration; + setCurrentTime(Math.max(0, Math.min(newTime, totalDuration))); + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.userSelect = ""; + }; + + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + handleMouseMove(e.nativeEvent); + }; + + const skipBackward = () => { + const newTime = Math.max(0, currentTime - 1); + setCurrentTime(newTime); + }; + + const skipForward = () => { + const newTime = Math.min(totalDuration, currentTime + 1); + setCurrentTime(newTime); + }; - <div className="flex-1"></div> + return ( + <div + data-toolbar + className="flex items-center gap-2 p-1 pt-2 w-full text-white" + > + <div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-white/90"> + <span className="text-primary"> + {formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)} + </span> + <span className="opacity-50">/</span> + <span> + {formatTimeCode( + totalDuration, + "HH:MM:SS:FF", + activeProject?.fps || 30 + )} + </span> + </div> - <PreviewToolbar hasAnyElements={hasAnyElements} /> + <div className="flex items-center gap-1"> + <Button + variant="text" + size="icon" + onClick={skipBackward} + disabled={!hasAnyElements} + className="h-auto p-0 text-white hover:text-white/80" + title="Skip backward 1s" + > + <SkipBack className="h-3 w-3" /> + </Button> + <Button + variant="text" + size="icon" + onClick={toggle} + disabled={!hasAnyElements} + className="h-auto p-0 text-white hover:text-white/80" + > + {isPlaying ? ( + <Pause className="h-3 w-3" /> + ) : ( + <Play className="h-3 w-3" /> + )} + </Button> + <Button + variant="text" + size="icon" + onClick={skipForward} + disabled={!hasAnyElements} + className="h-auto p-0 text-white hover:text-white/80" + title="Skip forward 1s" + > + <SkipForward className="h-3 w-3" /> + </Button> </div> + + <div className="flex-1 flex items-center gap-2"> + <div + className={cn( + "relative h-1 rounded-full cursor-pointer flex-1 bg-white/20", + !hasAnyElements && "opacity-50 cursor-not-allowed" + )} + onClick={hasAnyElements ? handleTimelineClick : undefined} + onMouseDown={hasAnyElements ? handleTimelineDrag : undefined} + style={{ userSelect: "none" }} + > + <div + className={cn( + "absolute top-0 left-0 h-full rounded-full bg-white", + !isDragging && "duration-100" + )} + style={{ width: `${progress}%` }} + /> + <div + className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-sm bg-white border border-black/20" + style={{ left: `${progress}%` }} + /> + </div> + </div> + + <Button + variant="text" + size="icon" + className="!size-4 text-white/80 hover:text-white" + onClick={onToggleExpanded} + title="Exit fullscreen (Esc)" + > + <Expand className="!size-4" /> + </Button> </div> ); } -function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { - const { isPlaying, toggle, currentTime } = usePlaybackStore(); +function FullscreenPreview({ + previewDimensions, + activeProject, + renderBlurBackground, + activeElements, + renderElement, + blurBackgroundElements, + hasAnyElements, + toggleExpanded, + currentTime, + setCurrentTime, + toggle, + getTotalDuration, +}: { + previewDimensions: { width: number; height: number }; + activeProject: any; + renderBlurBackground: () => React.ReactNode; + activeElements: ActiveElement[]; + renderElement: (elementData: ActiveElement, index: number) => React.ReactNode; + blurBackgroundElements: ActiveElement[]; + hasAnyElements: boolean; + toggleExpanded: () => void; + currentTime: number; + setCurrentTime: (time: number) => void; + toggle: () => void; + getTotalDuration: () => number; +}) { + return ( + <div className="fixed inset-0 z-[9999] flex flex-col"> + <div className="flex-1 flex items-center justify-center bg-background"> + <div + className="relative overflow-hidden border border-border m-3" + style={{ + width: previewDimensions.width, + height: previewDimensions.height, + backgroundColor: + activeProject?.backgroundType === "blur" + ? "#1a1a1a" + : activeProject?.backgroundColor || "#1a1a1a", + }} + > + {renderBlurBackground()} + {activeElements.length === 0 ? ( + <div className="absolute inset-0 flex items-center justify-center text-white/60"> + No elements at current time + </div> + ) : ( + activeElements.map((elementData, index) => + renderElement(elementData, index) + ) + )} + {activeProject?.backgroundType === "blur" && + blurBackgroundElements.length === 0 && + activeElements.length > 0 && ( + <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> + Add a video or image to use blur background + </div> + )} + </div> + </div> + <div className="p-4 bg-black"> + <FullscreenToolbar + hasAnyElements={hasAnyElements} + onToggleExpanded={toggleExpanded} + currentTime={currentTime} + setCurrentTime={setCurrentTime} + toggle={toggle} + getTotalDuration={getTotalDuration} + /> + </div> + </div> + ); +} + +function PreviewToolbar({ + hasAnyElements, + onToggleExpanded, + isExpanded, + currentTime, + setCurrentTime, + toggle, + getTotalDuration, +}: { + hasAnyElements: boolean; + onToggleExpanded: () => void; + isExpanded: boolean; + currentTime: number; + setCurrentTime: (time: number) => void; + toggle: () => void; + getTotalDuration: () => number; +}) { + const { isPlaying } = usePlaybackStore(); const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); - const { getTotalDuration } = useTimelineStore(); const { activeProject } = useProjectStore(); const { currentPreset, @@ -408,6 +708,21 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { setCanvasSizeToOriginal(aspectRatio); }; + if (isExpanded) { + return ( + <FullscreenToolbar + {...{ + hasAnyElements, + onToggleExpanded, + currentTime, + setCurrentTime, + toggle, + getTotalDuration, + }} + /> + ); + } + return ( <div data-toolbar @@ -488,6 +803,8 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { variant="text" size="icon" className="!size-4 text-muted-foreground" + onClick={onToggleExpanded} + title="Enter fullscreen" > <Expand className="!size-4" /> </Button> diff --git a/apps/web/src/components/editor/timeline-clean.tsx b/apps/web/src/components/editor/timeline-clean.tsx deleted file mode 100644 index cfb031a8..00000000 --- a/apps/web/src/components/editor/timeline-clean.tsx +++ /dev/null @@ -1,665 +0,0 @@ -"use client"; - -import { Button } from "../ui/button"; -import { - Scissors, - ArrowLeftToLine, - ArrowRightToLine, - Trash2, - Snowflake, - Copy, - SplitSquareHorizontal, - Pause, - Play, - Video, - Music, - TypeIcon, - Lock, - LockOpen, - Link, -} 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"; -import { TimelineZoomControl } from "./timeline-zoom-control"; -import { useTimelineZoomActions } from "@/hooks/use-timeline-zoom-actions"; - -export function Timeline() { - const { - tracks, - addTrack, - addElementToTrack, - removeElementFromTrack, - removeElementFromTrackWithRipple, - getTotalDuration, - calculateFitToWindowZoom, - selectedElements, - clearSelectedElements, - setSelectedElements, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - toggleTrackMute, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - 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); - - // Track mouse down/up for distinguishing clicks from drag/resize ends - const mouseTrackingRef = useRef({ - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }); - - // Fit to window function - const handleFitToWindow = useCallback(() => { - if (timelineRef.current) { - const containerWidth = timelineRef.current.clientWidth; - const optimalZoom = calculateFitToWindowZoom(containerWidth); - setZoomLevel(optimalZoom); - } - }, [calculateFitToWindowZoom]); - - // Timeline zoom functionality - const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ - containerRef: timelineRef, - isInTimeline, - onFitToWindow: handleFitToWindow, - }); - - // Dynamic timeline width calculation based on playhead position and duration - const dynamicTimelineWidth = Math.max( - (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, - (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, - timelineRef.current?.clientWidth || 1000 - ); - - // 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, - }); - - // Zoom actions - useTimelineZoomActions({ - zoomLevel, - setZoomLevel, - onFitToWindow: handleFitToWindow, - }); - - // 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); - }, []); - - // Track mouse down to distinguish real clicks from drag/resize ends - const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { - // Only track mouse down on timeline background areas (not elements) - const target = e.target as HTMLElement; - const isTimelineBackground = - !target.closest(".timeline-element") && - !playheadRef.current?.contains(target) && - !target.closest("[data-track-labels]"); - - if (isTimelineBackground) { - const now = Date.now(); - mouseTrackingRef.current = { - isMouseDown: true, - downX: e.clientX, - downY: e.clientY, - downTime: now, - }; - } - }, []); - - // Track mouse up globally to reset mouse tracking - useEffect(() => { - const handleGlobalMouseUp = () => { - mouseTrackingRef.current.isMouseDown = false; - }; - - window.addEventListener("mouseup", handleGlobalMouseUp); - return () => window.removeEventListener("mouseup", handleGlobalMouseUp); - }, []); - - // Handle clicks on timeline content (not playhead/ruler) - const handleTimelineContentClick = useCallback((e: React.MouseEvent) => { - // Only handle clicks if we didn't move much (distinguishing from drag end) - const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; - const now = Date.now(); - const distance = Math.sqrt( - Math.pow(e.clientX - downX, 2) + Math.pow(e.clientY - downY, 2) - ); - - // Not a real click if: - // - Mouse wasn't pressed down in timeline background - // - Too much movement (likely drag end) - // - Too much time passed (likely hold or slow drag) - if (!isMouseDown || distance > 5 || now - downTime > 300) { - return; - } - - // Only proceed if we're not currently selecting and didn't just finish selecting - if (isSelecting || justFinishedSelecting) { - return; - } - - // Get timeline content element and click position - const timelineContent = e.currentTarget as HTMLElement; - const rect = timelineContent.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - - // Convert click position to time - const timeFromClick = - clickX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - - // Snap to frame boundary for accurate seeking - const snappedTime = snapTimeToFrame(timeFromClick, 30); - - // Seek to the clicked time - seek(Math.max(0, snappedTime)); - }, [zoomLevel, seek, isSelecting, justFinishedSelecting]); - - // Scroll synchronization effect - useEffect(() => { - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current?.parentElement; - const trackLabelsViewport = trackLabelsScrollRef.current?.parentElement; - - 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" - 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-sm text-muted-foreground mr-2 font-mono"> - {(() => { - const formatTime = (seconds: number) => { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = Math.floor(seconds % 60); - const ms = Math.floor((seconds % 1) * 100); - return h > 0 - ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}` - : `${m}:${s.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}`; - }; - return `${formatTime(currentTime)} / ${formatTime(duration)}`; - })()} - </div> - - <div className="w-px h-6 bg-border mx-1" /> - - {/* Timeline Settings */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant={snappingEnabled ? "default" : "text"} - size="icon" - onClick={toggleSnapping} - > - <Snowflake className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent> - {snappingEnabled ? "Disable Snapping" : "Enable Snapping"} - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Button - variant={rippleEditingEnabled ? "default" : "text"} - size="icon" - onClick={toggleRippleEditing} - > - <Link className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent> - {rippleEditingEnabled ? "Disable Ripple Editing" : "Enable Ripple Editing"} - </TooltipContent> - </Tooltip> - </TooltipProvider> - - {/* Timeline Zoom Control */} - <div className="w-px h-6 bg-border mx-2" /> - <TimelineZoomControl - zoomLevel={zoomLevel} - onZoomChange={setZoomLevel} - onFitToWindow={handleFitToWindow} - className="mr-2" - /> - </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"> - <span className="text-sm font-medium text-muted-foreground opacity-0">.</span> - </div> - - {/* Timeline Ruler */} - <div - className="flex-1 relative overflow-x-auto overflow-y-hidden h-4" - onWheel={handleWheel} - onMouseDown={handleSelectionMouseDown} - onClick={handleTimelineContentClick} - data-ruler-area - ref={rulerScrollRef} - > - <div - ref={rulerRef} - className="relative h-4 select-none cursor-default" - style={{ - width: `${dynamicTimelineWidth}px`, - }} - onMouseDown={handleRulerMouseDown} - > - {/* Time markers */} - {(() => { - const getTimeInterval = (zoom: number) => { - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; - if (pixelsPerSecond >= 200) return 0.1; - if (pixelsPerSecond >= 100) return 0.5; - if (pixelsPerSecond >= 50) return 1; - if (pixelsPerSecond >= 25) return 2; - if (pixelsPerSecond >= 12) return 5; - if (pixelsPerSecond >= 6) return 10; - return 30; - }; - - 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> - </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 border-black overflow-y-auto" - data-track-labels - > - <div - ref={trackLabelsScrollRef} - className="w-full h-full" - > - <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> - </div> - </div> - )} - - {/* Timeline Tracks Content */} - <div - className="flex-1 relative overflow-auto" - onWheel={handleWheel} - onMouseDown={(e) => { - handleTimelineMouseDown(e); - handleSelectionMouseDown(e); - }} - onClick={handleTimelineContentClick} - ref={tracksContainerRef} - > - <div - ref={tracksScrollRef} - className="w-full h-full" - > - <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} - /> - <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 ( - !(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> - </div> - </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-fixed.tsx b/apps/web/src/components/editor/timeline-fixed.tsx deleted file mode 100644 index 52103bda..00000000 --- a/apps/web/src/components/editor/timeline-fixed.tsx +++ /dev/null @@ -1,1015 +0,0 @@ -"use client"; - -import { Button } from "../ui/button"; -import { - Scissors, - ArrowLeftToLine, - ArrowRightToLine, - Trash2, - Snowflake, - Copy, - SplitSquareHorizontal, - Pause, - Play, - Video, - Music, - TypeIcon, - Lock, - LockOpen, - Link, -} 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"; -import { TimelineZoomControl } from "./timeline-zoom-control"; -import { useTimelineZoomActions } from "@/hooks/use-timeline-zoom-actions"; - -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, - removeElementFromTrackWithRipple, - getTotalDuration, - calculateFitToWindowZoom, - selectedElements, - clearSelectedElements, - setSelectedElements, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - toggleTrackMute, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - 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); - - // Track mouse down/up for distinguishing clicks from drag/resize ends - const mouseTrackingRef = useRef({ - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }); - - // Fit to window function - const handleFitToWindow = useCallback(() => { - if (timelineRef.current) { - const containerWidth = timelineRef.current.clientWidth; - const optimalZoom = calculateFitToWindowZoom(containerWidth); - setZoomLevel(optimalZoom); - } - }, [calculateFitToWindowZoom]); - - // Timeline zoom functionality - const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ - containerRef: timelineRef, - isInTimeline, - onFitToWindow: handleFitToWindow, - }); - - // 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, - }); - - // Zoom actions - useTimelineZoomActions({ - zoomLevel, - setZoomLevel, - onFitToWindow: handleFitToWindow, - }); - - // 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); - }, []); - - // Track mouse down to distinguish real clicks from drag/resize ends - const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { - // Only track mouse down on timeline background areas (not elements) - const target = e.target as HTMLElement; - const isTimelineBackground = - !target.closest(".timeline-element") && - !playheadRef.current?.contains(target) && - !target.closest("[data-track-labels]"); - - if (isTimelineBackground) { - mouseTrackingRef.current = { - isMouseDown: true, - downX: e.clientX, - downY: e.clientY, - downTime: e.timeStamp, - }; - } - }, []); - - // Track mouse up globally to reset mouse tracking - useEffect(() => { - const handleGlobalMouseUp = () => { - mouseTrackingRef.current.isMouseDown = false; - }; - - window.addEventListener("mouseup", handleGlobalMouseUp); - return () => window.removeEventListener("mouseup", handleGlobalMouseUp); - }, []); - - // Handle clicks on timeline content (not playhead/ruler) - const handleTimelineContentClick = useCallback((e: React.MouseEvent) => { - // Only handle clicks if we didn't move much (distinguishing from drag end) - const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; - const now = Date.now(); - const distance = Math.sqrt( - Math.pow(e.clientX - downX, 2) + Math.pow(e.clientY - downY, 2) - ); - - // Not a real click if: - // - Mouse wasn't pressed down in timeline background - // - Too much movement (likely drag end) - // - Too much time passed (likely hold or slow drag) - if (!isMouseDown || distance > 5 || now - downTime > 300) { - return; - } - - // Only proceed if we're not currently selecting and didn't just finish selecting - if (isSelecting || justFinishedSelecting) { - return; - } - - // Get timeline content element and click position - const timelineContent = e.currentTarget as HTMLElement; - const rect = timelineContent.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - - // Convert click position to time - const timeFromClick = - clickX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - - // Snap to frame boundary for accurate seeking - const projectFps = activeProject?.fps || 30; - const snappedTime = snapTimeToFrame(timeFromClick, projectFps); - - // Seek to the clicked time - seek(Math.max(0, snappedTime)); - }, [zoomLevel, seek, isSelecting, justFinishedSelecting]); - - // Drag and drop handlers for adding media to timeline - const dragProps = { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - }; - - function handleDragEnter(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 === 1) { - 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; - } - - // Add to first available track or create new one - const targetTrack = tracks.find( - (track) => track.type === "media" && track.elements.length === 0 - ); - - if (targetTrack) { - await addElementToTrack(targetTrack.id, { - id: crypto.randomUUID(), - type: "media", - mediaId: mediaItem.id, - startTime: 0, - duration: mediaItem.duration, - trimStart: 0, - trimEnd: 0, - }); - } else { - const trackId = crypto.randomUUID(); - addTrack({ - id: trackId, - type: "media", - name: `Track ${tracks.length + 1}`, - elements: [], - muted: false, - volume: 1, - }); - await addElementToTrack(trackId, { - id: crypto.randomUUID(), - type: "media", - mediaId: mediaItem.id, - startTime: 0, - duration: mediaItem.duration, - trimStart: 0, - trimEnd: 0, - }); - } - } - } catch (error) { - console.error("Error parsing drag data:", error); - toast.error("Failed to add media to timeline"); - } - return; - } - - // Handle file drops (e.g., from desktop) - const files = Array.from(e.dataTransfer.files); - if (files.length > 0) { - setIsProcessing(true); - setProgress(0); - try { - const processedMedia = await processMediaFiles(files, (progress) => { - setProgress(progress); - }); - - for (const media of processedMedia) { - if (!activeProject) { - toast.error("No active project"); - return; - } - await addMediaItem(activeProject.id, media); - - // Add to timeline automatically - const trackId = crypto.randomUUID(); - addTrack({ - id: trackId, - type: "media", - name: `Track ${tracks.length + 1}`, - elements: [], - muted: false, - volume: 1, - }); - await addElementToTrack(trackId, { - id: crypto.randomUUID(), - type: "media", - mediaId: media.id, - startTime: 0, - duration: media.duration, - trimStart: 0, - trimEnd: 0, - }); - } - - toast.success(`Added ${processedMedia.length} media files to timeline`); - } catch (error) { - console.error("Error processing dropped files:", error); - toast.error("Failed to process some files"); - } finally { - setIsProcessing(false); - setProgress(0); - } - } - }; - - // Keyboard shortcuts for timeline actions - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Only handle shortcuts when timeline is focused or no input is focused - const isInputFocused = document.activeElement?.tagName.match(/INPUT|TEXTAREA|SELECT/); - if (isInputFocused) return; - - if (e.key === 'Delete' || e.key === 'Backspace') { - e.preventDefault(); - handleDeleteSelected(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [selectedElements, rippleEditingEnabled]); - - // Context menu handlers - const handleSplitElement = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element to split"); - return; - } - const { trackId, elementId } = selectedElements[0]; - splitElement(trackId, elementId, currentTime); - }; - - const handleSplitAndKeepLeft = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element to split"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - if (!track || !element) return; - - const effectiveStart = element.startTime + element.trimStart; - 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 to split"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - if (!track || !element) return; - - const effectiveStart = element.startTime + element.trimStart; - 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 }) => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(trackId, elementId); - } else { - removeElementFromTrack(trackId, elementId); - } - }); - clearSelectedElements(); - }; - - // --- Scroll synchronization effect --- - useEffect(() => { - const rulerViewport = rulerScrollRef.current; - 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-sm text-muted-foreground mr-2 font-mono"> - {(() => { - const formatTime = (seconds: number) => { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = Math.floor(seconds % 60); - const ms = Math.floor((seconds % 1) * 100); - return h > 0 - ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}` - : `${m}:${s.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}`; - }; - return `${formatTime(currentTime)} / ${formatTime(duration)}`; - })()} - </div> - <div className="w-px h-6 bg-border mx-1" /> - {/* Element Actions */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitElement} - disabled={selectedElements.length !== 1} - > - <Scissors className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split element at playhead (S)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepLeft} - disabled={selectedElements.length !== 1} - > - <ArrowLeftToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep left part</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepRight} - disabled={selectedElements.length !== 1} - > - <ArrowRightToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep right part</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleDeleteSelected} - disabled={selectedElements.length === 0} - > - <Trash2 className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Delete selected elements (Del)</TooltipContent> - </Tooltip> - <div className="w-px h-6 bg-border mx-1" /> - {/* Media Actions */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSeparateAudio} - disabled={ - selectedElements.length !== 1 || - !tracks - .find((t) => t.id === selectedElements[0]?.trackId) - ?.elements.find((e) => e.id === selectedElements[0]?.elementId) - } - > - <SplitSquareHorizontal className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Separate audio from video</TooltipContent> - </Tooltip> - <div className="w-px h-6 bg-border mx-1" /> - {/* Timeline Settings */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant={snappingEnabled ? "accent" : "text"} - size="icon" - onClick={toggleSnapping} - > - <Snowflake className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent> - {snappingEnabled ? "Disable Snapping" : "Enable Snapping"} - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant={rippleEditingEnabled ? "accent" : "text"} - size="icon" - onClick={toggleRippleEditing} - > - <Link className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent> - {rippleEditingEnabled - ? "Disable Ripple Editing" - : "Enable Ripple Editing"} - </TooltipContent> - </Tooltip> - </TooltipProvider> - - {/* Timeline Zoom Control */} - <div className="w-px h-6 bg-border mx-2" /> - <TimelineZoomControl - zoomLevel={zoomLevel} - onZoomChange={setZoomLevel} - onFitToWindow={handleFitToWindow} - className="mr-2" - /> - </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-x-auto overflow-y-hidden h-4" - onWheel={handleWheel} - onMouseDown={handleSelectionMouseDown} - onClick={handleTimelineContentClick} - data-ruler-area - 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> - </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 border-black overflow-y-auto" - data-track-labels - > - <div - ref={trackLabelsScrollRef} - className="w-full h-full" - > - <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> - </div> - </div> - )} - - {/* Timeline Tracks Content */} - <div - className="flex-1 relative overflow-auto" - onWheel={handleWheel} - onMouseDown={(e) => { - handleTimelineMouseDown(e); - handleSelectionMouseDown(e); - }} - onClick={handleTimelineContentClick} - ref={tracksContainerRef} - > - <div - ref={tracksScrollRef} - className="w-full h-full" - > - <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} - /> - <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> - </div> - </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-zoom-control.tsx b/apps/web/src/components/editor/timeline-zoom-control.tsx deleted file mode 100644 index ab9c57a5..00000000 --- a/apps/web/src/components/editor/timeline-zoom-control.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useState } from "react"; -import { Button } from "../ui/button"; -import { Slider } from "../ui/slider"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "../ui/tooltip"; -import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react"; - -interface TimelineZoomControlProps { - zoomLevel: number; - onZoomChange: (zoom: number) => void; - onFitToWindow?: () => void; - className?: string; -} - -export function TimelineZoomControl({ - zoomLevel, - onZoomChange, - onFitToWindow, - className, -}: TimelineZoomControlProps) { - // Convert zoom level to percentage for display - const zoomPercentage = Math.round(zoomLevel * 100); - - const handleZoomIn = () => { - onZoomChange(Math.min(10, zoomLevel + 0.2)); - }; - - const handleZoomOut = () => { - onZoomChange(Math.max(0.1, zoomLevel - 0.2)); - }; - - const handleReset = () => { - if (onFitToWindow) { - onFitToWindow(); - } else { - onZoomChange(1); - } - }; - - const handleSliderChange = (value: number[]) => { - onZoomChange(value[0]); - }; - - return ( - <div className={`flex items-center gap-1 ${className || ""}`}> - {/* Zoom Out Button */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="outline" - size="sm" - onClick={handleZoomOut} - disabled={zoomLevel <= 0.1} - className="h-7 w-7 p-0" - > - <ZoomOut className="h-3 w-3" /> - </Button> - </TooltipTrigger> - <TooltipContent> - <p>Zoom out</p> - <p className="text-xs text-muted-foreground">Ctrl + - / Ctrl + Wheel</p> - </TooltipContent> - </Tooltip> - - {/* Zoom Slider */} - <div className="flex items-center gap-2 w-20"> - <Slider - value={[zoomLevel]} - onValueChange={handleSliderChange} - min={0.1} - max={5} - step={0.1} - className="flex-1" - /> - </div> - - {/* Zoom In Button */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="outline" - size="sm" - onClick={handleZoomIn} - disabled={zoomLevel >= 5} - className="h-7 w-7 p-0" - > - <ZoomIn className="h-3 w-3" /> - </Button> - </TooltipTrigger> - <TooltipContent> - <p>Zoom in</p> - <p className="text-xs text-muted-foreground">Ctrl + + / Ctrl + Wheel</p> - </TooltipContent> - </Tooltip> - - {/* Fit to Window / Reset Button */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="outline" - size="sm" - onClick={handleReset} - className="h-7 px-2 min-w-[60px] flex items-center gap-1" - > - {onFitToWindow ? ( - <> - <Maximize2 className="h-3 w-3" /> - </> - ) : ( - <span className="text-xs font-mono">{zoomPercentage}%</span> - )} - </Button> - </TooltipTrigger> - <TooltipContent> - <p>{onFitToWindow ? "Fit to Window" : "Reset Zoom"}</p> - <p className="text-xs text-muted-foreground"> - {onFitToWindow ? "Show all track content" : "Ctrl + 0"} - </p> - </TooltipContent> - </Tooltip> - </div> - ); -} diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline/index.tsx index 84c228f0..993aa6ad 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -1,7 +1,7 @@ "use client"; -import { ScrollArea } from "../ui/scroll-area"; -import { Button } from "../ui/button"; +import { ScrollArea } from "../../ui/scroll-area"; +import { Button } from "../../ui/button"; import { Scissors, ArrowLeftToLine, @@ -18,19 +18,21 @@ import { Lock, LockOpen, Link, + ZoomIn, + ZoomOut, } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider, -} from "../ui/tooltip"; +} from "../../ui/tooltip"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, -} from "../ui/context-menu"; +} from "../../ui/context-menu"; import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; @@ -44,9 +46,9 @@ import { TimelinePlayhead, useTimelinePlayheadRuler, } from "./timeline-playhead"; -import { SelectionBox } from "./selection-box"; +import { SelectionBox } from "../selection-box"; import { useSelectionBox } from "@/hooks/use-selection-box"; -import { SnapIndicator } from "./snap-indicator"; +import { SnapIndicator } from "../snap-indicator"; import { SnapPoint } from "@/hooks/use-timeline-snapping"; import type { DragData, TimelineTrack } from "@/types/timeline"; import { @@ -56,8 +58,7 @@ import { TIMELINE_CONSTANTS, snapTimeToFrame, } from "@/constants/timeline-constants"; -import { TimelineZoomControl } from "./timeline-zoom-control"; -import { useTimelineZoomActions } from "@/hooks/use-timeline-zoom-actions"; +import { Slider } from "@/components/ui/slider"; export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their elements. @@ -66,24 +67,11 @@ export function Timeline() { const { tracks, - addTrack, - addElementToTrack, - removeElementFromTrack, - removeElementFromTrackWithRipple, getTotalDuration, - calculateFitToWindowZoom, - selectedElements, clearSelectedElements, + snappingEnabled, setSelectedElements, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, toggleTrackMute, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, dragState, } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); @@ -106,20 +94,10 @@ export function Timeline() { downTime: 0, }); - // Fit to window function - const handleFitToWindow = useCallback(() => { - if (timelineRef.current) { - const containerWidth = timelineRef.current.clientWidth; - const optimalZoom = calculateFitToWindowZoom(containerWidth); - setZoomLevel(optimalZoom); - } - }, [calculateFitToWindowZoom]); - // Timeline zoom functionality const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ containerRef: timelineRef, isInTimeline, - onFitToWindow: handleFitToWindow, }); // Old marquee selection removed - using new SelectionBox component instead @@ -154,13 +132,6 @@ export function Timeline() { playheadRef, }); - // Zoom actions - useTimelineZoomActions({ - zoomLevel, - setZoomLevel, - onFitToWindow: handleFitToWindow, - }); - // Selection box functionality const tracksContainerRef = useRef<HTMLDivElement>(null); const { @@ -450,142 +421,6 @@ export function Timeline() { 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 }) => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(trackId, elementId); - } else { - removeElementFromTrack(trackId, elementId); - } - }); - clearSelectedElements(); - }; - // --- Scroll synchronization effect --- useEffect(() => { const rulerViewport = rulerScrollRef.current?.querySelector( @@ -672,201 +507,7 @@ export function Timeline() { 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 ? ( - <LockOpen className="h-4 w-4 text-primary" /> - ) : ( - <Lock className="h-4 w-4" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent>Auto snapping</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={toggleRippleEditing} - > - <Link - className={`h-4 w-4 ${rippleEditingEnabled ? "text-primary" : ""}`} - /> - </Button> - </TooltipTrigger> - <TooltipContent> - {rippleEditingEnabled - ? "Disable Ripple Editing" - : "Enable Ripple Editing"} - </TooltipContent> - </Tooltip> - </TooltipProvider> - - {/* Timeline Zoom Control */} - <div className="w-px h-6 bg-border mx-2" /> - <TimelineZoomControl - zoomLevel={zoomLevel} - onZoomChange={setZoomLevel} - onFitToWindow={handleFitToWindow} - className="mr-2" - /> - </div> - </div> + <TimelineToolbar zoomLevel={zoomLevel} setZoomLevel={setZoomLevel} /> {/* Timeline Container */} <div @@ -909,21 +550,27 @@ export function Timeline() { {/* Timeline Ruler */} <div - className="flex-1 relative overflow-x-auto overflow-y-hidden h-4" - onWheel={handleWheel} + className="flex-1 relative overflow-hidden h-4" + onWheel={(e) => { + // Check if this is horizontal scrolling - if so, don't handle it here + if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + return; // Let ScrollArea handle horizontal scrolling + } + handleWheel(e); + }} onMouseDown={handleSelectionMouseDown} onClick={handleTimelineContentClick} data-ruler-area - ref={rulerScrollRef} > - <div - ref={rulerRef} - className="relative h-4 select-none cursor-default" - style={{ - width: `${dynamicTimelineWidth}px`, - }} - onMouseDown={handleRulerMouseDown} - > + <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 @@ -991,7 +638,8 @@ export function Timeline() { ); }).filter(Boolean); })()} - </div> + </div> + </ScrollArea> </div> </div> @@ -1004,10 +652,7 @@ export function Timeline() { className="w-48 flex-shrink-0 border-r border-black overflow-y-auto" data-track-labels > - <ScrollArea - ref={trackLabelsScrollRef} - className="w-full h-full" - > + <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> <div className="flex flex-col gap-1"> {tracks.map((track) => ( <div @@ -1032,8 +677,14 @@ export function Timeline() { {/* Timeline Tracks Content */} <div - className="flex-1 relative overflow-auto" - onWheel={handleWheel} + className="flex-1 relative overflow-hidden" + onWheel={(e) => { + // Check if this is horizontal scrolling - if so, don't handle it here + if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + return; // Let ScrollArea handle horizontal scrolling + } + handleWheel(e); + }} onMouseDown={(e) => { handleTimelineMouseDown(e); handleSelectionMouseDown(e); @@ -1041,16 +692,18 @@ export function Timeline() { onClick={handleTimelineContentClick} ref={tracksContainerRef} > - <div - ref={tracksScrollRef} + <SelectionBox + startPos={selectionBox?.startPos || null} + currentPos={selectionBox?.currentPos || null} + containerRef={tracksContainerRef} + isActive={selectionBox?.isActive || false} + /> + <ScrollArea className="w-full h-full" + ref={tracksScrollRef} + type="scroll" + showHorizontalScrollbar > - <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} - /> <div className="relative flex-1" style={{ @@ -1104,7 +757,7 @@ export function Timeline() { </> )} </div> - </div> + </ScrollArea> </div> </div> </div> @@ -1127,3 +780,340 @@ function TrackIcon({ track }: { track: TimelineTrack }) { </> ); } + +function TimelineToolbar({ + zoomLevel, + setZoomLevel, +}: { + zoomLevel: number; + setZoomLevel: (zoom: number) => void; +}) { + const { + tracks, + addTrack, + addElementToTrack, + removeElementFromTrack, + removeElementFromTrackWithRipple, + selectedElements, + clearSelectedElements, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + snappingEnabled, + toggleSnapping, + rippleEditingEnabled, + toggleRippleEditing, + } = useTimelineStore(); + const { currentTime, duration, isPlaying, toggle } = usePlaybackStore(); + + // Action handlers + const handleSplitSelected = () => { + if (selectedElements.length === 0) 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) return; + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; + + 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; + const { id, ...elementWithoutId } = element; + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + } + }); + 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) return; + selectedElements.forEach(({ trackId, elementId }) => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(trackId, elementId); + } else { + removeElementFromTrack(trackId, elementId); + } + }); + clearSelectedElements(); + }; + + // Zoom handlers + const handleZoomIn = () => { + setZoomLevel(Math.min(4, zoomLevel + 0.25)); + }; + + const handleZoomOut = () => { + setZoomLevel(Math.max(0.25, zoomLevel - 0.25)); + }; + + const handleZoomSliderChange = (values: number[]) => { + setZoomLevel(values[0]); + }; + return ( + <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 ? ( + <LockOpen className="h-4 w-4 text-primary" /> + ) : ( + <Lock className="h-4 w-4" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent>Auto snapping</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={toggleRippleEditing}> + <Link + className={`h-4 w-4 ${rippleEditingEnabled ? "text-primary" : ""}`} + /> + </Button> + </TooltipTrigger> + <TooltipContent> + {rippleEditingEnabled + ? "Disable Ripple Editing" + : "Enable Ripple Editing"} + </TooltipContent> + </Tooltip> + </TooltipProvider> + <div className="flex items-center gap-1"> + <Button variant="text" size="icon" onClick={handleZoomOut}> + <ZoomOut className="h-4 w-4" /> + </Button> + <Slider + className="w-24" + value={[zoomLevel]} + onValueChange={handleZoomSliderChange} + min={0.25} + max={4} + step={0.25} + /> + <Button variant="text" size="icon" onClick={handleZoomIn}> + <ZoomIn className="h-4 w-4" /> + </Button> + </div> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index eb9f1c01..7cc72b10 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Button } from "../ui/button"; +import { Button } from "../../ui/button"; import { MoreVertical, Scissors, @@ -17,7 +17,7 @@ import { import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; -import AudioWaveform from "./audio-waveform"; +import AudioWaveform from "../audio-waveform"; import { toast } from "sonner"; import { TimelineElementProps, TrackType } from "@/types/timeline"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; @@ -35,14 +35,14 @@ import { DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, -} from "../ui/dropdown-menu"; +} from "../../ui/dropdown-menu"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, -} from "../ui/context-menu"; +} from "../../ui/context-menu"; export function TimelineElement({ element, diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index 7a357067..7a357067 100644 --- a/apps/web/src/components/editor/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 82ca0b98..82ca0b98 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 5b7f2303..a7aa724f 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -3,7 +3,8 @@ import { motion } from "motion/react"; import Link from "next/link"; import { useEffect, useState } from "react"; -import { RiDiscordFill, RiGithubLine, RiTwitterXLine } from "react-icons/ri"; +import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri"; +import { FaGithub } from "react-icons/fa6"; import { getStars } from "@/lib/fetch-github-stars"; import Image from "next/image"; @@ -49,7 +50,7 @@ export function Footer() { target="_blank" rel="noopener noreferrer" > - <RiGithubLine className="h-5 w-5" /> + <FaGithub className="h-5 w-5" /> </Link> <Link href="https://x.com/OpenCutApp" diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index b905bbde..dba958ac 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -33,7 +33,7 @@ export function Hero() { className="mb-8 flex justify-center" > <SponsorButton - href="https://vercel.com/?utm_source=opencut" + href="https://vercel.com/home?utm_source=opencut" logo={VercelIcon} companyName="Vercel" /> diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index 6b7c3c53..5c9db727 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -7,17 +7,22 @@ import { cn } from "../../lib/utils"; const ScrollArea = React.forwardRef< React.ElementRef<typeof ScrollAreaPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { + type?: "auto" | "always" | "scroll" | "hover"; + showHorizontalScrollbar?: boolean; + } +>(({ className, children, type, showHorizontalScrollbar, ...props }, ref) => ( <ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} + type={type} {...props} > <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> {children} </ScrollAreaPrimitive.Viewport> <ScrollBar /> + {showHorizontalScrollbar && <ScrollBar orientation="horizontal" />} <ScrollAreaPrimitive.Corner /> </ScrollAreaPrimitive.Root> )); |
