diff --git a/app/components/timeline/TimelineTracks.tsx b/app/components/timeline/TimelineTracks.tsx index 00c475e..e8304c2 100644 --- a/app/components/timeline/TimelineTracks.tsx +++ b/app/components/timeline/TimelineTracks.tsx @@ -1,16 +1,14 @@ -import React, { useEffect, useState } from "react"; import { Trash2 } from "lucide-react"; +import React, { useEffect, useState } from "react"; import { Button } from "~/components/ui/button"; -import { ScrollArea } from "~/components/ui/scroll-area"; import { Scrubber } from "./Scrubber"; import { TransitionOverlay } from "./TransitionOverlay"; import { DEFAULT_TRACK_HEIGHT, - PIXELS_PER_SECOND, - type ScrubberState, type MediaBinItem, + type ScrubberState, type TimelineState, - type Transition, + type Transition } from "./types"; interface TimelineTracksProps { @@ -39,6 +37,9 @@ interface TimelineTracksProps { pixelsPerSecond: number; selectedScrubberId: string | null; onSelectScrubber: (scrubberId: string | null) => void; + // Add zoom and pan handlers + onZoom?: (delta: number, centerX: number) => void; + onPan?: (deltaX: number) => void; } export const TimelineTracks: React.FC = ({ @@ -59,6 +60,8 @@ export const TimelineTracks: React.FC = ({ pixelsPerSecond, selectedScrubberId, onSelectScrubber, + onZoom, + onPan, }) => { const [scrollTop, setScrollTop] = useState(0); @@ -91,6 +94,32 @@ export const TimelineTracks: React.FC = ({ } }, [selectedScrubberId, containerRef, onSelectScrubber]); + // Wheel event handler for zoom and pan + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault(); + + const container = containerRef.current; + if (!container || (!onZoom && !onPan) || timeline.tracks.length === 0) return; + + const containerBounds = container.getBoundingClientRect(); + const centerX = e.clientX - containerBounds.left + container.scrollLeft; + + // Vertical scroll (deltaY) for zoom + if (onZoom && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + // Zoom factor - negative deltaY means scroll up (zoom in), positive means scroll down (zoom out) + // Use smaller zoom increments for smoother zooming + const zoomFactor = e.deltaY < 0 ? 1.05 : 0.95; + onZoom(zoomFactor, centerX); + } + + // Horizontal scroll (deltaX) for panning + if (onPan && Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + // Scale down the pan amount for smoother panning + const panAmount = -e.deltaX * 0.5; + onPan(panAmount); + } + }; + return (
{/* Track controls column - scrolls with tracks */} @@ -130,6 +159,7 @@ export const TimelineTracks: React.FC = ({ className={`relative flex-1 bg-timeline-background timeline-scrollbar ${timeline.tracks.length === 0 ? "overflow-hidden" : "overflow-auto" }`} onScroll={timeline.tracks.length > 0 ? onScroll : undefined} + onWheel={handleWheel} > {timeline.tracks.length === 0 ? ( /* Empty state - non-scrollable and centered */ diff --git a/app/hooks/useTimeline.ts b/app/hooks/useTimeline.ts index 97cff8e..40d3ff8 100644 --- a/app/hooks/useTimeline.ts +++ b/app/hooks/useTimeline.ts @@ -1,19 +1,19 @@ -import { useState, useCallback, useEffect, useRef } from "react"; +import { useCallback, useRef, useState } from "react"; +import { toast } from "sonner"; import { - PIXELS_PER_SECOND, - MIN_ZOOM, - MAX_ZOOM, DEFAULT_ZOOM, - type TimelineState, - type TrackState, - type ScrubberState, + FPS, + MAX_ZOOM, + MIN_ZOOM, + PIXELS_PER_SECOND, type MediaBinItem, + type ScrubberState, type TimelineDataItem, + type TimelineState, + type TrackState, type Transition, - FPS, } from "../components/timeline/types"; import { generateUUID } from "../utils/uuid"; -import { toast } from "sonner"; export const useTimeline = () => { const [timeline, setTimeline] = useState({ @@ -113,6 +113,50 @@ export const useTimeline = () => { })); }, []); + // New zoom function that zooms to a specific center point + const handleZoomToPoint = useCallback((zoomFactor: number, centerX: number) => { + const currentZoom = zoomLevelRef.current; + const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, currentZoom * zoomFactor)); + const zoomRatio = newZoom / currentZoom; + + zoomLevelRef.current = newZoom; + setZoomLevel(newZoom); + + setTimeline((currentTimeline) => ({ + ...currentTimeline, + tracks: currentTimeline.tracks.map((track) => ({ + ...track, + scrubbers: track.scrubbers.map((scrubber) => { + // Calculate new position relative to zoom center + const scrubberCenter = scrubber.left + scrubber.width / 2; + const distanceFromCenter = scrubberCenter - centerX; + const newDistanceFromCenter = distanceFromCenter * zoomRatio; + const newLeft = centerX + newDistanceFromCenter - (scrubber.width * zoomRatio) / 2; + + return { + ...scrubber, + left: newLeft, + width: scrubber.width * zoomRatio, + }; + }), + })), + })); + }, []); + + // Pan function for horizontal scrolling + const handlePan = useCallback((deltaX: number) => { + setTimeline((currentTimeline) => ({ + ...currentTimeline, + tracks: currentTimeline.tracks.map((track) => ({ + ...track, + scrubbers: track.scrubbers.map((scrubber) => ({ + ...scrubber, + left: scrubber.left + deltaX, + })), + })), + })); + }, []); + // TODO: remove this after testing // useEffect(() => { // console.log('timeline meoeoeo', JSON.stringify(timeline, null, 2)) @@ -1087,6 +1131,8 @@ export const useTimeline = () => { handleZoomIn, handleZoomOut, handleZoomReset, + handleZoomToPoint, + handlePan, // Transition management handleAddTransitionToTrack, handleDeleteTransition, diff --git a/app/root.tsx b/app/root.tsx index 6d29670..894e08c 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,10 +1,10 @@ import { - isRouteErrorResponse, - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, } from "react-router"; import type { Route } from "./+types/root"; diff --git a/app/routes/home.tsx b/app/routes/home.tsx index c1e04ad..a3912e0 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -1,53 +1,53 @@ -import React, { useRef, useEffect, useCallback, useState } from "react"; -import type { PlayerRef, CallbackListener } from "@remotion/player"; +import type { CallbackListener, PlayerRef } from "@remotion/player"; import { + ChevronLeft, + Download, + Minus, Moon, - Sun, - Play, Pause, - Upload, - Download, - Settings, + Play, Plus, - Minus, - ChevronLeft, Scissors, + Settings, Star, + Sun, + Upload, } from "lucide-react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; // Custom video controls -import { MuteButton, FullscreenButton } from "~/components/ui/video-controls"; import { useTheme } from "next-themes"; +import { FullscreenButton, MuteButton } from "~/components/ui/video-controls"; // Components +import { toast } from "sonner"; import LeftPanel from "~/components/editor/LeftPanel"; -import { VideoPlayer } from "~/video-compositions/VideoPlayer"; import { RenderStatus } from "~/components/timeline/RenderStatus"; import { TimelineRuler } from "~/components/timeline/TimelineRuler"; import { TimelineTracks } from "~/components/timeline/TimelineTracks"; -import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; -import { Separator } from "~/components/ui/separator"; -import { Switch } from "~/components/ui/switch"; -import { Label } from "~/components/ui/label"; +import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; import { - ResizablePanelGroup, - ResizablePanel, ResizableHandle, + ResizablePanel, + ResizablePanelGroup, } from "~/components/ui/resizable"; -import { toast } from "sonner"; +import { Separator } from "~/components/ui/separator"; +import { Switch } from "~/components/ui/switch"; +import { VideoPlayer } from "~/video-compositions/VideoPlayer"; // Hooks -import { useTimeline } from "~/hooks/useTimeline"; import { useMediaBin } from "~/hooks/useMediaBin"; -import { useRuler } from "~/hooks/useRuler"; import { useRenderer } from "~/hooks/useRenderer"; +import { useRuler } from "~/hooks/useRuler"; +import { useTimeline } from "~/hooks/useTimeline"; // Types and constants -import { FPS, type Transition } from "~/components/timeline/types"; import { useNavigate } from "react-router"; import { ChatBox } from "~/components/chat/ChatBox"; +import { FPS, type Transition } from "~/components/timeline/types"; interface Message { id: string; @@ -119,6 +119,8 @@ export default function TimelineEditor() { handleZoomIn, handleZoomOut, handleZoomReset, + handleZoomToPoint, + handlePan, // Transition management handleAddTransitionToTrack, handleDeleteTransition, @@ -355,8 +357,10 @@ export default function TimelineEditor() { if (player) { if (player.isPlaying()) { player.pause(); + setIsPlaying(false); } else { player.play(); + setIsPlaying(true); } } } @@ -402,32 +406,7 @@ export default function TimelineEditor() { } }, [isDraggingRuler, handleRulerMouseMove, handleRulerMouseUp]); - // Timeline wheel zoom functionality - useEffect(() => { - const timelineContainer = containerRef.current; - if (!timelineContainer) return; - - const handleWheel = (e: WheelEvent) => { - // Only zoom if Ctrl or Cmd is held - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - const scrollDirection = e.deltaY > 0 ? -1 : 1; - - if (scrollDirection > 0) { - handleZoomIn(); - } else { - handleZoomOut(); - } - } - }; - timelineContainer.addEventListener("wheel", handleWheel, { - passive: false, - }); - return () => { - timelineContainer.removeEventListener("wheel", handleWheel); - }; - }, [handleZoomIn, handleZoomOut]); useEffect(() => { setMounted(true) @@ -678,6 +657,7 @@ export default function TimelineEditor() { > {Math.round(((durationInFrames || 0) / FPS) * 10) / 10}s + {/* • Mouse wheel to zoom */}
@@ -771,6 +751,8 @@ export default function TimelineEditor() { pixelsPerSecond={getPixelsPerSecond()} selectedScrubberId={selectedScrubberId} onSelectScrubber={setSelectedScrubberId} + onZoom={handleZoomToPoint} + onPan={handlePan} />
diff --git a/app/utils/api.ts b/app/utils/api.ts index a07129e..97fbfdb 100644 --- a/app/utils/api.ts +++ b/app/utils/api.ts @@ -1,5 +1,5 @@ // Set to true for production, false for development -const isProduction = true; +const isProduction = false; export const getApiBaseUrl = (fastapi: boolean = false): string => { if (!isProduction) {