diff options
Diffstat (limited to 'apps/web/src/components/editor')
12 files changed, 813 insertions, 457 deletions
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index c5621fea..894b6257 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -16,11 +16,13 @@ import { DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; -import { Play, Pause } from "lucide-react"; +import { Play, Pause, Expand } from "lucide-react"; import { useState, useRef, useEffect } from "react"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { FONT_CLASS_MAP } from "@/lib/font-config"; +import { BackgroundSettings } from "../background-settings"; +import { useProjectStore } from "@/stores/project-store"; interface ActiveElement { element: TimelineElement; @@ -39,6 +41,7 @@ export function PreviewPanel() { width: 0, height: 0, }); + const { activeProject } = useProjectStore(); // Calculate optimal preview size that fits in container while maintaining aspect ratio useEffect(() => { @@ -136,6 +139,85 @@ export function PreviewPanel() { // 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( + ({ element, mediaItem }) => + element.type === "media" && + mediaItem && + (mediaItem.type === "video" || mediaItem.type === "image") && + element.mediaId !== "test" // Exclude test elements + ); + }; + + const blurBackgroundElements = getBlurBackgroundElements(); + + // Render blur background layer + const renderBlurBackground = () => { + if ( + !activeProject?.backgroundType || + activeProject.backgroundType !== "blur" || + blurBackgroundElements.length === 0 + ) { + return null; + } + + // Use the first media element for background (could be enhanced to use primary/focused element) + const backgroundElement = blurBackgroundElements[0]; + const { element, mediaItem } = backgroundElement; + + if (!mediaItem) return null; + + const blurIntensity = activeProject.blurIntensity || 8; + + if (mediaItem.type === "video") { + return ( + <div + key={`blur-${element.id}`} + className="absolute inset-0 overflow-hidden" + style={{ + filter: `blur(${blurIntensity}px)`, + transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts + transformOrigin: "center", + }} + > + <VideoPlayer + src={mediaItem.url!} + poster={mediaItem.thumbnailUrl} + clipStartTime={element.startTime} + trimStart={element.trimStart} + trimEnd={element.trimEnd} + clipDuration={element.duration} + className="w-full h-full object-cover" + /> + </div> + ); + } + + if (mediaItem.type === "image") { + return ( + <div + key={`blur-${element.id}`} + className="absolute inset-0 overflow-hidden" + style={{ + filter: `blur(${blurIntensity}px)`, + transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts + transformOrigin: "center", + }} + > + <img + src={mediaItem.url!} + alt={mediaItem.name} + className="w-full h-full object-cover" + draggable={false} + /> + </div> + ); + } + + return null; + }; + // Render an element const renderElement = (elementData: ActiveElement, index: number) => { const { element, mediaItem } = elementData; @@ -145,6 +227,8 @@ export function PreviewPanel() { const fontClassName = FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || ""; + const scaleRatio = previewDimensions.width / canvasSize.width; + return ( <div key={element.id} @@ -152,7 +236,7 @@ export function PreviewPanel() { style={{ left: `${50 + (element.x / canvasSize.width) * 100}%`, top: `${50 + (element.y / canvasSize.height) * 100}%`, - transform: `translate(-50%, -50%) rotate(${element.rotation}deg)`, + transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`, opacity: element.opacity, zIndex: 100 + index, // Text elements on top }} @@ -169,7 +253,7 @@ export function PreviewPanel() { textDecoration: element.textDecoration, padding: "4px 8px", borderRadius: "2px", - whiteSpace: "pre-wrap", + whiteSpace: "nowrap", // Fallback for system fonts that don't have classes ...(fontClassName === "" && { fontFamily: element.fontFamily }), }} @@ -200,7 +284,10 @@ export function PreviewPanel() { // Video elements if (mediaItem.type === "video") { return ( - <div key={element.id} className="absolute inset-0"> + <div + key={element.id} + className="absolute inset-0 flex items-center justify-center" + > <VideoPlayer src={mediaItem.url!} poster={mediaItem.thumbnailUrl} @@ -216,11 +303,14 @@ export function PreviewPanel() { // Image elements if (mediaItem.type === "image") { return ( - <div key={element.id} className="absolute inset-0"> + <div + key={element.id} + className="absolute inset-0 flex items-center justify-center" + > <img src={mediaItem.url!} alt={mediaItem.name} - className="w-full h-full object-cover" + className="max-w-full max-h-full object-contain" draggable={false} /> </div> @@ -257,12 +347,17 @@ export function PreviewPanel() { {hasAnyElements ? ( <div ref={previewRef} - className="relative overflow-hidden rounded-sm bg-black border" + className="relative overflow-hidden rounded-sm 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 @@ -272,6 +367,14 @@ export function PreviewPanel() { 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> + )} </div> ) : null} @@ -287,6 +390,7 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { const { isPlaying, toggle, currentTime } = usePlaybackStore(); const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); const { getTotalDuration } = useTimelineStore(); + const { activeProject } = useProjectStore(); const { currentPreset, isOriginal, @@ -317,11 +421,19 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { )} > <span className="text-primary tabular-nums"> - {formatTimeCode(currentTime, "HH:MM:SS:CS")} + {formatTimeCode( + currentTime, + "HH:MM:SS:FF", + activeProject?.fps || 30 + )} </span> <span className="opacity-50">/</span> <span className="tabular-nums"> - {formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")} + {formatTimeCode( + getTotalDuration(), + "HH:MM:SS:FF", + activeProject?.fps || 30 + )} </span> </p> </div> @@ -338,12 +450,13 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { <Play className="h-3 w-3" /> )} </Button> - <div> + <div className="flex items-center gap-3"> + <BackgroundSettings /> <DropdownMenu> <DropdownMenuTrigger asChild> <Button size="sm" - className="!bg-panel-accent text-foreground/85 text-xs h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light" + className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light" disabled={!hasAnyElements} > {getDisplayName()} @@ -371,6 +484,13 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { ))} </DropdownMenuContent> </DropdownMenu> + <Button + variant="text" + size="icon" + className="!size-4 text-muted-foreground" + > + <Expand className="!size-4" /> + </Button> </div> </div> ); diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx deleted file mode 100644 index a0532ab3..00000000 --- a/apps/web/src/components/editor/properties-panel.tsx +++ /dev/null @@ -1,115 +0,0 @@ -"use client"; - -import { useProjectStore } from "@/stores/project-store"; -import { useAspectRatio } from "@/hooks/use-aspect-ratio"; -import { Label } from "../ui/label"; -import { ScrollArea } from "../ui/scroll-area"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { Textarea } from "../ui/textarea"; -import { MediaElement, TextElement } from "@/types/timeline"; -import { useMediaStore } from "@/stores/media-store"; -import { - Select, - SelectContent, - SelectTrigger, - SelectValue, - SelectItem, -} from "../ui/select"; -import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants"; - -export function PropertiesPanel() { - const { activeProject } = useProjectStore(); - const { getDisplayName, canvasSize } = useAspectRatio(); - const { selectedElements, tracks, updateTextElement } = useTimelineStore(); - const { mediaItems } = useMediaStore(); - - const emptyView = ( - <div className="space-y-4 p-5"> - {/* Media Properties */} - <div className="flex flex-col gap-3"> - <PropertyItem label="Name:" value={activeProject?.name || ""} /> - <PropertyItem label="Aspect ratio:" value={getDisplayName()} /> - <PropertyItem - label="Resolution:" - value={`${canvasSize.width} × ${canvasSize.height}`} - /> - <PropertyItem label="Frame rate:" value="30.00fps" /> - </div> - </div> - ); - - const TextProperties = (element: TextElement, trackId: string) => ( - <div className="space-y-6 p-5"> - <Textarea - placeholder="Name" - defaultValue={element.content} - className="min-h-[4.5rem] resize-none" - onChange={(e) => - updateTextElement(trackId, element.id, { content: e.target.value }) - } - /> - <div className="flex items-center justify-between gap-6"> - <Label className="text-xs">Font</Label> - <Select - defaultValue={element.fontFamily} - onValueChange={(value: FontFamily) => - updateTextElement(trackId, element.id, { fontFamily: value }) - } - > - <SelectTrigger className="w-full text-xs"> - <SelectValue placeholder="Select a font" /> - </SelectTrigger> - <SelectContent> - {FONT_OPTIONS.map((font) => ( - <SelectItem key={font.value} value={font.value}> - {font.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - ); - - const MediaProperties = (element: MediaElement) => { - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); - - if (mediaItem?.type === "audio") { - return <div className="space-y-4 p-5">Audio properties</div>; - } - - // video or image - return <div className="space-y-4 p-5">Video/Image properties</div>; - }; - - const ElementProperties = ( - <> - {selectedElements.map(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - - if (element?.type === "text") { - return <div key={elementId}>{TextProperties(element, trackId)}</div>; - } - if (element?.type === "media") { - return <div key={elementId}>{MediaProperties(element)}</div>; - } - })} - </> - ); - - return ( - <ScrollArea className="h-full bg-panel rounded-sm"> - {selectedElements.length > 0 ? ElementProperties : emptyView} - </ScrollArea> - ); -} - -function PropertyItem({ label, value }: { label: string; value: string }) { - return ( - <div className="flex justify-between"> - <Label className="text-xs text-muted-foreground">{label}</Label> - <span className="text-xs text-right">{value}</span> - </div> - ); -} diff --git a/apps/web/src/components/editor/properties-panel/audio-properties.tsx b/apps/web/src/components/editor/properties-panel/audio-properties.tsx new file mode 100644 index 00000000..384f0c5e --- /dev/null +++ b/apps/web/src/components/editor/properties-panel/audio-properties.tsx @@ -0,0 +1,5 @@ +import { MediaElement } from "@/types/timeline";
+
+export function AudioProperties({ element }: { element: MediaElement }) {
+ return <div className="space-y-4 p-5">Audio properties</div>;
+}
\ No newline at end of file diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx new file mode 100644 index 00000000..9e28850f --- /dev/null +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useProjectStore } from "@/stores/project-store"; +import { useAspectRatio } from "@/hooks/use-aspect-ratio"; +import { Label } from "../../ui/label"; +import { ScrollArea } from "../../ui/scroll-area"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { AudioProperties } from "./audio-properties"; +import { MediaProperties } from "./media-properties"; +import { TextProperties } from "./text-properties"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../ui/select"; +import { FPS_PRESETS } from "@/constants/timeline-constants"; + +export function PropertiesPanel() { + const { activeProject, updateProjectFps } = useProjectStore(); + const { getDisplayName, canvasSize } = useAspectRatio(); + const { selectedElements, tracks } = useTimelineStore(); + const { mediaItems } = useMediaStore(); + + const handleFpsChange = (value: string) => { + const fps = parseFloat(value); + if (!isNaN(fps) && fps > 0) { + updateProjectFps(fps); + } + }; + + const emptyView = ( + <div className="space-y-4 p-5"> + {/* Media Properties */} + <div className="flex flex-col gap-3"> + <PropertyItem label="Name:" value={activeProject?.name || ""} /> + <PropertyItem label="Aspect ratio:" value={getDisplayName()} /> + <PropertyItem + label="Resolution:" + value={`${canvasSize.width} × ${canvasSize.height}`} + /> + <div className="flex justify-between items-center"> + <Label className="text-xs text-muted-foreground">Frame rate:</Label> + <Select + value={(activeProject?.fps || 30).toString()} + onValueChange={handleFpsChange} + > + <SelectTrigger className="w-32 h-6 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map(({ value, label }) => ( + <SelectItem key={value} value={value} className="text-xs"> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + </div> + ); + + return ( + <ScrollArea className="h-full bg-panel rounded-sm"> + {selectedElements.length > 0 + ? selectedElements.map(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); + + if (element?.type === "text") { + return ( + <div key={elementId}> + <TextProperties element={element} trackId={trackId} /> + </div> + ); + } + if (element?.type === "media") { + const mediaItem = mediaItems.find( + (item) => item.id === element.mediaId + ); + + if (mediaItem?.type === "audio") { + return <AudioProperties element={element} />; + } + + return ( + <div key={elementId}> + <MediaProperties element={element} /> + </div> + ); + } + return null; + }) + : emptyView} + </ScrollArea> + ); +} + +function PropertyItem({ label, value }: { label: string; value: string }) { + return ( + <div className="flex justify-between"> + <Label className="text-xs text-muted-foreground">{label}</Label> + <span className="text-xs text-right">{value}</span> + </div> + ); +} diff --git a/apps/web/src/components/editor/properties-panel/media-properties.tsx b/apps/web/src/components/editor/properties-panel/media-properties.tsx new file mode 100644 index 00000000..c2c514cf --- /dev/null +++ b/apps/web/src/components/editor/properties-panel/media-properties.tsx @@ -0,0 +1,5 @@ +import { MediaElement } from "@/types/timeline";
+
+export function MediaProperties({ element }: { element: MediaElement }) {
+ return <div className="space-y-4 p-5">Media properties</div>;
+}
diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx new file mode 100644 index 00000000..beb8c799 --- /dev/null +++ b/apps/web/src/components/editor/properties-panel/property-item.tsx @@ -0,0 +1,47 @@ +import { cn } from "@/lib/utils";
+
+interface PropertyItemProps {
+ direction?: "row" | "column";
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function PropertyItem({
+ direction = "row",
+ children,
+ className,
+}: PropertyItemProps) {
+ return (
+ <div
+ className={cn(
+ "flex gap-2",
+ direction === "row"
+ ? "items-center justify-between gap-6"
+ : "flex-col gap-1",
+ className
+ )}
+ >
+ {children}
+ </div>
+ );
+}
+
+export function PropertyItemLabel({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <label className={cn("text-xs", className)}>{children}</label>;
+}
+
+export function PropertyItemValue({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <div className={cn("flex-1", className)}>{children}</div>;
+}
diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx new file mode 100644 index 00000000..e13dabc8 --- /dev/null +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -0,0 +1,76 @@ +import { Textarea } from "@/components/ui/textarea";
+import { FontPicker } from "@/components/ui/font-picker";
+import { FontFamily } from "@/constants/font-constants";
+import { TextElement } from "@/types/timeline";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { Slider } from "@/components/ui/slider";
+import { Input } from "@/components/ui/input";
+import {
+ PropertyItem,
+ PropertyItemLabel,
+ PropertyItemValue,
+} from "./property-item";
+
+export function TextProperties({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const { updateTextElement } = useTimelineStore();
+
+ return (
+ <div className="space-y-6 p-5">
+ <Textarea
+ placeholder="Name"
+ defaultValue={element.content}
+ className="min-h-[4.5rem] resize-none bg-background/50"
+ onChange={(e) =>
+ updateTextElement(trackId, element.id, { content: e.target.value })
+ }
+ />
+ <PropertyItem direction="row">
+ <PropertyItemLabel>Font</PropertyItemLabel>
+ <PropertyItemValue>
+ <FontPicker
+ defaultValue={element.fontFamily}
+ onValueChange={(value: FontFamily) =>
+ updateTextElement(trackId, element.id, { fontFamily: value })
+ }
+ />
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Font size</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <Slider
+ defaultValue={[element.fontSize]}
+ min={8}
+ max={300}
+ step={1}
+ onValueChange={([value]) =>
+ updateTextElement(trackId, element.id, { fontSize: value })
+ }
+ className="w-full"
+ />
+ <Input
+ type="number"
+ value={element.fontSize}
+ onChange={(e) =>
+ updateTextElement(trackId, element.id, {
+ fontSize: parseInt(e.target.value),
+ })
+ }
+ className="w-12 !text-xs h-7 rounded-sm text-center
+ [appearance:textfield]
+ [&::-webkit-outer-spin-button]:appearance-none
+ [&::-webkit-inner-spin-button]:appearance-none"
+ />
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/selection-box.tsx b/apps/web/src/components/editor/selection-box.tsx new file mode 100644 index 00000000..5c452f7c --- /dev/null +++ b/apps/web/src/components/editor/selection-box.tsx @@ -0,0 +1,58 @@ +"use client";
+
+import { useEffect, useRef } from "react";
+
+interface SelectionBoxProps {
+ startPos: { x: number; y: number } | null;
+ currentPos: { x: number; y: number } | null;
+ containerRef: React.RefObject<HTMLElement>;
+ isActive: boolean;
+}
+
+export function SelectionBox({
+ startPos,
+ currentPos,
+ containerRef,
+ isActive,
+}: SelectionBoxProps) {
+ const selectionBoxRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ if (!isActive || !startPos || !currentPos || !containerRef.current) return;
+
+ const container = containerRef.current;
+ const containerRect = container.getBoundingClientRect();
+
+ // Calculate relative positions within the container
+ const startX = startPos.x - containerRect.left;
+ const startY = startPos.y - containerRect.top;
+ const currentX = currentPos.x - containerRect.left;
+ const currentY = currentPos.y - containerRect.top;
+
+ // Calculate the selection rectangle bounds
+ const left = Math.min(startX, currentX);
+ const top = Math.min(startY, currentY);
+ const width = Math.abs(currentX - startX);
+ const height = Math.abs(currentY - startY);
+
+ // Update the selection box position and size
+ if (selectionBoxRef.current) {
+ selectionBoxRef.current.style.left = `${left}px`;
+ selectionBoxRef.current.style.top = `${top}px`;
+ selectionBoxRef.current.style.width = `${width}px`;
+ selectionBoxRef.current.style.height = `${height}px`;
+ }
+ }, [startPos, currentPos, isActive, containerRef]);
+
+ if (!isActive || !startPos || !currentPos) return null;
+
+ return (
+ <div
+ ref={selectionBoxRef}
+ className="absolute pointer-events-none z-50"
+ style={{
+ backgroundColor: "hsl(var(--foreground) / 0.1)",
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index 1cd22c74..ee9445f3 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -339,6 +339,8 @@ export function TimelineElement({ left: `${elementLeft}px`, width: `${elementWidth}px`, }} + data-element-id={element.id} + data-track-id={track.id} onMouseMove={resizing ? handleResizeMove : undefined} onMouseUp={resizing ? handleResizeEnd : undefined} onMouseLeave={resizing ? handleResizeEnd : undefined} diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx new file mode 100644 index 00000000..ce4ed1ff --- /dev/null +++ b/apps/web/src/components/editor/timeline-playhead.tsx @@ -0,0 +1,110 @@ +"use client";
+
+import { useRef } from "react";
+import { TimelineTrack } from "@/types/timeline";
+import {
+ TIMELINE_CONSTANTS,
+ getTotalTracksHeight,
+} from "@/constants/timeline-constants";
+import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
+
+interface TimelinePlayheadProps {
+ currentTime: number;
+ duration: number;
+ zoomLevel: number;
+ tracks: TimelineTrack[];
+ seek: (time: number) => void;
+ rulerRef: React.RefObject<HTMLDivElement>;
+ rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ trackLabelsRef?: React.RefObject<HTMLDivElement>;
+ timelineRef: React.RefObject<HTMLDivElement>;
+ playheadRef?: React.RefObject<HTMLDivElement>;
+}
+
+export function TimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ tracks,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ trackLabelsRef,
+ timelineRef,
+ playheadRef: externalPlayheadRef,
+}: TimelinePlayheadProps) {
+ const internalPlayheadRef = useRef<HTMLDivElement>(null);
+ const playheadRef = externalPlayheadRef || internalPlayheadRef;
+ const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ // Use timeline container height minus a few pixels for breathing room
+ const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
+ const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
+
+ // Get dynamic track labels width, fallback to 0 if no tracks or no ref
+ const trackLabelsWidth =
+ tracks.length > 0 && trackLabelsRef?.current
+ ? trackLabelsRef.current.offsetWidth
+ : 0;
+ const leftPosition =
+ trackLabelsWidth +
+ playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+
+ return (
+ <div
+ ref={playheadRef}
+ className="absolute pointer-events-auto z-[100]"
+ style={{
+ left: `${leftPosition}px`,
+ top: 0,
+ height: `${totalHeight}px`,
+ width: "2px", // Slightly wider for better click target
+ }}
+ onMouseDown={handlePlayheadMouseDown}
+ >
+ {/* The red line spanning full height */}
+ <div className="absolute left-0 w-0.5 bg-foreground cursor-col-resize h-full" />
+
+ {/* Red dot indicator at the top (in ruler area) */}
+ <div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-foreground rounded-full border-2 border-foreground shadow-sm" />
+ </div>
+ );
+}
+
+// Also export a hook for getting ruler handlers
+export function useTimelinePlayheadRuler({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
+ const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ return { handleRulerMouseDown, isDraggingRuler };
+}
+
+export { TimelinePlayhead as default };
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index 7e2e93e0..c3a16b9a 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -17,7 +17,11 @@ import type { TimelineElement as TimelineElementType, DragData, } from "@/types/timeline"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + snapTimeToFrame, + TIMELINE_CONSTANTS, +} from "@/constants/timeline-constants"; +import { useProjectStore } from "@/stores/project-store"; export function TimelineTrackContent({ track, @@ -80,7 +84,10 @@ export function TimelineTrackContent({ mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) ); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - const snappedTime = Math.round(adjustedTime * 10) / 10; + // Use frame snapping if project has FPS, otherwise use decimal snapping + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const snappedTime = snapTimeToFrame(adjustedTime, projectFps); updateDragTime(snappedTime); }; @@ -342,7 +349,9 @@ export function TimelineTrackContent({ if (dragData.type === "text") { // Text elements have default duration of 5 seconds const newElementDuration = 5; - const snappedTime = Math.round(dropTime * 10) / 10; + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const snappedTime = snapTimeToFrame(dropTime, projectFps); const newElementEnd = snappedTime + newElementDuration; wouldOverlap = track.elements.some((existingElement) => { @@ -361,7 +370,9 @@ export function TimelineTrackContent({ ); if (mediaItem) { const newElementDuration = mediaItem.duration || 5; - const snappedTime = Math.round(dropTime * 10) / 10; + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const snappedTime = snapTimeToFrame(dropTime, projectFps); const newElementEnd = snappedTime + newElementDuration; wouldOverlap = track.elements.some((existingElement) => { @@ -401,7 +412,9 @@ export function TimelineTrackContent({ movingElement.duration - movingElement.trimStart - movingElement.trimEnd; - const snappedTime = Math.round(dropTime * 10) / 10; + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const snappedTime = snapTimeToFrame(dropTime, projectFps); const movingElementEnd = snappedTime + movingElementDuration; wouldOverlap = track.elements.some((existingElement) => { @@ -428,13 +441,17 @@ export function TimelineTrackContent({ if (wouldOverlap) { e.dataTransfer.dropEffect = "none"; setWouldOverlap(true); - setDropPosition(Math.round(dropTime * 10) / 10); + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + setDropPosition(snapTimeToFrame(dropTime, projectFps)); return; } e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy"; setWouldOverlap(false); - setDropPosition(Math.round(dropTime * 10) / 10); + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + setDropPosition(snapTimeToFrame(dropTime, projectFps)); }; const handleTrackDragEnter = (e: React.DragEvent) => { @@ -502,7 +519,9 @@ export function TimelineTrackContent({ const mouseY = e.clientY - rect.top; // Get Y position relative to this track const newStartTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - const snappedTime = Math.round(newStartTime * 10) / 10; + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const snappedTime = snapTimeToFrame(newStartTime, projectFps); // Calculate drop position relative to tracks const currentTrackIndex = tracks.findIndex((t) => t.id === track.id); @@ -548,7 +567,7 @@ export function TimelineTrackContent({ const adjustedStartTime = snappedTime - clickOffsetTime; const finalStartTime = Math.max( 0, - Math.round(adjustedStartTime * 10) / 10 + snapTimeToFrame(adjustedStartTime, projectFps) ); // Check for overlaps with existing elements (excluding the moving element itself) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index abbf5bba..71e2997b 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -44,6 +44,12 @@ import { SelectValue, } from "../ui/select"; import { TimelineTrackContent } from "./timeline-track"; +import { + TimelinePlayhead, + useTimelinePlayheadRuler, +} from "./timeline-playhead"; +import { SelectionBox } from "./selection-box"; +import { useSelectionBox } from "@/hooks/use-selection-box"; import type { DragData, TimelineTrack } from "@/types/timeline"; import { getTrackHeight, @@ -75,16 +81,8 @@ export function Timeline() { } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); const { activeProject } = useProjectStore(); - const { - currentTime, - duration, - seek, - setDuration, - isPlaying, - toggle, - setSpeed, - speed, - } = usePlaybackStore(); + const { currentTime, duration, seek, setDuration, isPlaying, toggle } = + usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); @@ -99,23 +97,7 @@ export function Timeline() { isInTimeline, }); - // Marquee selection state - const [marquee, setMarquee] = useState<{ - startX: number; - startY: number; - endX: number; - endY: number; - active: boolean; - additive: boolean; - } | null>(null); - - // Playhead scrubbing state - const [isScrubbing, setIsScrubbing] = useState(false); - const [scrubTime, setScrubTime] = useState<number | null>(null); - - // Add new state for ruler drag detection - const [isDraggingRuler, setIsDraggingRuler] = useState(false); - const [hasDraggedRuler, setHasDraggedRuler] = useState(false); + // Old marquee selection removed - using new SelectionBox component instead // Dynamic timeline width calculation based on playhead position and duration const dynamicTimelineWidth = Math.max( @@ -127,9 +109,135 @@ export function Timeline() { // 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, isDraggingRuler } = useTimelinePlayheadRuler({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + // Selection box functionality + const tracksContainerRef = useRef<HTMLDivElement>(null); + const { + selectionBox, + handleMouseDown: handleSelectionMouseDown, + isSelecting, + justFinishedSelecting, + } = useSelectionBox({ + containerRef: tracksContainerRef, + playheadRef, + onSelectionComplete: (elements) => { + console.log(JSON.stringify({ onSelectionComplete: elements.length })); + setSelectedElements(elements); + }, + }); + + // Timeline content click to seek handler + const handleTimelineContentClick = useCallback( + (e: React.MouseEvent) => { + console.log( + JSON.stringify({ + timelineClick: { + isSelecting, + justFinishedSelecting, + willReturn: isSelecting || justFinishedSelecting, + }, + }) + ); + + // Don't seek if this was a selection box operation + if (isSelecting || justFinishedSelecting) { + return; + } + + // Don't seek if clicking on timeline elements, but still deselect + if ((e.target as HTMLElement).closest(".timeline-element")) { + return; + } + + // Don't seek if clicking on playhead + if (playheadRef.current?.contains(e.target as Node)) { + return; + } + + // Don't seek if clicking on track labels + if ((e.target as HTMLElement).closest("[data-track-labels]")) { + clearSelectedElements(); + return; + } + + // Clear selected elements when clicking empty timeline area + console.log(JSON.stringify({ clearingSelectedElements: true })); + clearSelectedElements(); + + // Determine if we're clicking in ruler or tracks area + const isRulerClick = (e.target as HTMLElement).closest( + "[data-ruler-area]" + ); + + let mouseX: number; + let scrollLeft = 0; + + if (isRulerClick) { + // Calculate based on ruler position + const rulerContent = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + if (!rulerContent) return; + const rect = rulerContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = rulerContent.scrollLeft; + } else { + // Calculate based on tracks content position + const tracksContent = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + if (!tracksContent) return; + const rect = tracksContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = tracksContent.scrollLeft; + } + + const rawTime = Math.max( + 0, + Math.min( + duration, + (mouseX + scrollLeft) / + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) + ) + ); + + // Use frame snapping for timeline clicking + const projectFps = activeProject?.fps || 30; + const { snapTimeToFrame } = require("@/constants/timeline-constants"); + const time = snapTimeToFrame(rawTime, projectFps); + + seek(time); + }, + [ + duration, + zoomLevel, + seek, + rulerScrollRef, + tracksScrollRef, + clearSelectedElements, + isSelecting, + justFinishedSelecting, + ] + ); // Update timeline duration when tracks change useEffect(() => { @@ -202,115 +310,7 @@ export function Timeline() { return () => window.removeEventListener("keydown", handleKeyDown); }, [redo]); - // Mouse down on timeline background to start marquee - const handleTimelineMouseDown = (e: React.MouseEvent) => { - if (e.target === e.currentTarget && e.button === 0) { - setMarquee({ - startX: e.clientX, - startY: e.clientY, - endX: e.clientX, - endY: e.clientY, - active: true, - additive: e.metaKey || e.ctrlKey || e.shiftKey, - }); - } - }; - - // Add new click handler for deselection - const handleTimelineClick = (e: React.MouseEvent) => { - // If clicking empty area (not on an element) and not starting marquee, deselect all elements - if (!(e.target as HTMLElement).closest(".timeline-element")) { - clearSelectedElements(); - } - }; - - // Mouse move to update marquee - useEffect(() => { - if (!marquee || !marquee.active) return; - const handleMouseMove = (e: MouseEvent) => { - setMarquee( - (prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY } - ); - }; - const handleMouseUp = (e: MouseEvent) => { - setMarquee( - (prev) => - prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false } - ); - }; - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - return () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - }, [marquee]); - - // On marquee end, select elements in box - useEffect(() => { - if (!marquee || marquee.active) return; - const timeline = timelineRef.current; - if (!timeline) return; - const rect = timeline.getBoundingClientRect(); - const x1 = Math.min(marquee.startX, marquee.endX) - rect.left; - const x2 = Math.max(marquee.startX, marquee.endX) - rect.left; - const y1 = Math.min(marquee.startY, marquee.endY) - rect.top; - const y2 = Math.max(marquee.startY, marquee.endY) - rect.top; - // Validation: skip if too small - if (Math.abs(x2 - x1) < 5 || Math.abs(y2 - y1) < 5) { - setMarquee(null); - return; - } - // Clamp to timeline bounds - const clamp = (val: number, min: number, max: number) => - Math.max(min, Math.min(max, val)); - const bx1 = clamp(x1, 0, rect.width); - const bx2 = clamp(x2, 0, rect.width); - const by1 = clamp(y1, 0, rect.height); - const by2 = clamp(y2, 0, rect.height); - let newSelection: { trackId: string; elementId: string }[] = []; - tracks.forEach((track, trackIdx) => { - track.elements.forEach((element) => { - const clipLeft = - element.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const clipTop = getCumulativeHeightBefore(tracks, trackIdx); - const clipBottom = clipTop + getTrackHeight(track.type); - const clipRight = clipLeft + getTrackHeight(track.type); - if ( - bx1 < clipRight && - bx2 > clipLeft && - by1 < clipBottom && - by2 > clipTop - ) { - newSelection.push({ trackId: track.id, elementId: element.id }); - } - }); - }); - if (newSelection.length > 0) { - if (marquee.additive) { - const selectedSet = new Set( - selectedElements.map((c) => c.trackId + ":" + c.elementId) - ); - newSelection = [ - ...selectedElements, - ...newSelection.filter( - (c) => !selectedSet.has(c.trackId + ":" + c.elementId) - ), - ]; - } - setSelectedElements(newSelection); - } else if (!marquee.additive) { - clearSelectedElements(); - } - setMarquee(null); - }, [ - marquee, - tracks, - zoomLevel, - selectedElements, - setSelectedElements, - clearSelectedElements, - ]); + // Old marquee system removed - using new SelectionBox component instead const handleDragEnter = (e: React.DragEvent) => { // When something is dragged over the timeline, show overlay @@ -460,92 +460,6 @@ export function Timeline() { } }; - // --- Playhead Scrubbing Handlers --- - const handlePlayheadMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); // Prevent ruler drag from triggering - setIsScrubbing(true); - handleScrub(e); - }, - [duration, zoomLevel] - ); - - // Add new ruler mouse down handler - const handleRulerMouseDown = useCallback( - (e: React.MouseEvent) => { - // Only handle left mouse button - if (e.button !== 0) return; - - // Don't interfere if clicking on the playhead itself - if ((e.target as HTMLElement).closest(".playhead")) return; - - e.preventDefault(); - setIsDraggingRuler(true); - setHasDraggedRuler(false); - - // Start scrubbing immediately - setIsScrubbing(true); - handleScrub(e); - }, - [duration, zoomLevel] - ); - - const handleScrub = useCallback( - (e: MouseEvent | React.MouseEvent) => { - const ruler = rulerRef.current; - if (!ruler) return; - const rect = ruler.getBoundingClientRect(); - const x = e.clientX - rect.left; - const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); - setScrubTime(time); - seek(time); // update video preview in real time - }, - [duration, zoomLevel, seek] - ); - - useEffect(() => { - if (!isScrubbing) return; - const onMouseMove = (e: MouseEvent) => { - handleScrub(e); - // Mark that we've dragged if ruler drag is active - if (isDraggingRuler) { - setHasDraggedRuler(true); - } - }; - const onMouseUp = (e: MouseEvent) => { - setIsScrubbing(false); - if (scrubTime !== null) seek(scrubTime); // finalize seek - setScrubTime(null); - - // Handle ruler click vs drag - if (isDraggingRuler) { - setIsDraggingRuler(false); - // If we didn't drag, treat it as a click-to-seek - if (!hasDraggedRuler) { - handleScrub(e); - } - setHasDraggedRuler(false); - } - }; - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, [ - isScrubbing, - scrubTime, - seek, - handleScrub, - isDraggingRuler, - hasDraggedRuler, - ]); - - const playheadPosition = - isScrubbing && scrubTime !== null ? scrubTime : currentTime; - const dragProps = { onDragEnter: handleDragEnter, onDragOver: handleDragOver, @@ -693,7 +607,13 @@ export function Timeline() { 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; @@ -710,41 +630,54 @@ export function Timeline() { 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); }; }, []); - // --- Playhead auto-scroll effect --- - useEffect(() => { - const rulerViewport = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - const tracksViewport = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - if (!rulerViewport || !tracksViewport) return; - const playheadPx = - playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const viewportWidth = rulerViewport.clientWidth; - const scrollMin = 0; - const scrollMax = rulerViewport.scrollWidth - viewportWidth; - // Center the playhead if it's not visible (100px buffer) - const desiredScroll = Math.max( - scrollMin, - Math.min(scrollMax, playheadPx - viewportWidth / 2) - ); - if ( - playheadPx < rulerViewport.scrollLeft + 100 || - playheadPx > rulerViewport.scrollLeft + viewportWidth - 100 - ) { - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }, [playheadPosition, duration, zoomLevel]); - return ( <div className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`} @@ -882,52 +815,49 @@ export function Timeline() { </TooltipTrigger> <TooltipContent>Delete element (Delete)</TooltipContent> </Tooltip> - <div className="w-px h-6 bg-border mx-1" /> - {/* Speed Control */} - <Tooltip> - <TooltipTrigger asChild> - <Select - value={speed.toFixed(1)} - onValueChange={(value) => setSpeed(parseFloat(value))} - > - <SelectTrigger className="w-[90px] h-8"> - <SelectValue placeholder="1.0x" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="0.5">0.5x</SelectItem> - <SelectItem value="1.0">1.0x</SelectItem> - <SelectItem value="1.5">1.5x</SelectItem> - <SelectItem value="2.0">2.0x</SelectItem> - </SelectContent> - </Select> - </TooltipTrigger> - <TooltipContent>Playback Speed</TooltipContent> - </Tooltip> </TooltipProvider> </div> {/* Timeline Container */} - <div className="flex-1 flex flex-col overflow-hidden" ref={timelineRef}> + <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} + /> {/* Timeline Header with Ruler */} - <div className="flex border-b bg-panel-accent sticky top-0 z-10"> + <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"> - Tracks + {/* Empty space */} + <span className="text-sm font-medium text-muted-foreground opacity-0"> + . </span> </div> {/* Timeline Ruler */} <div - className="flex-1 relative overflow-hidden" + className="flex-1 relative overflow-hidden h-4" onWheel={handleWheel} + onMouseDown={handleSelectionMouseDown} + onClick={handleTimelineContentClick} + data-ruler-area > <ScrollArea className="w-full" ref={rulerScrollRef}> <div ref={rulerRef} - className={`relative h-12 bg-muted/30 select-none ${ - isDraggingRuler ? "cursor-grabbing" : "cursor-grab" - }`} + className="relative h-4 select-none cursor-default" style={{ width: `${dynamicTimelineWidth}px`, }} @@ -971,7 +901,7 @@ export function Timeline() { }} > <span - className={`absolute top-1 left-1 text-xs ${ + className={`absolute top-1 left-1 text-[0.6rem] ${ isMainMarker ? "text-muted-foreground font-medium" : "text-muted-foreground/70" @@ -1000,17 +930,6 @@ export function Timeline() { ); }).filter(Boolean); })()} - - {/* Playhead in ruler (scrubbable) */} - <div - className="playhead absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col-resize" - style={{ - left: `${playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, - }} - onMouseDown={handlePlayheadMouseDown} - > - <div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" /> - </div> </div> </ScrollArea> </div> @@ -1020,25 +939,31 @@ export function Timeline() { <div className="flex-1 flex overflow-hidden"> {/* Track Labels */} {tracks.length > 0 && ( - <div className="w-48 flex-shrink-0 border-r bg-panel-accent overflow-y-auto"> - <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 + ref={trackLabelsRef} + className="w-48 flex-shrink-0 border-r bg-panel-accent overflow-y-auto" + data-track-labels + > + <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> + <div className="flex flex-col gap-1"> + {tracks.map((track) => ( + <div + key={track.id} + className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5" + style={{ height: `${getTrackHeight(track.type)}px` }} + > + <div className="flex items-center flex-1 min-w-0"> + <TrackIcon track={track} /> + </div> + {track.muted && ( + <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0"> + Muted + </span> + )} </div> - {track.muted && ( - <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0"> - Muted - </span> - )} - </div> - ))} - </div> + ))} + </div> + </ScrollArea> </div> )} @@ -1046,7 +971,16 @@ export function Timeline() { <div className="flex-1 relative overflow-hidden" onWheel={handleWheel} + onMouseDown={handleSelectionMouseDown} + onClick={handleTimelineContentClick} + ref={tracksContainerRef} > + <SelectionBox + startPos={selectionBox?.startPos || null} + currentPos={selectionBox?.currentPos || null} + containerRef={tracksContainerRef} + isActive={selectionBox?.isActive || false} + /> <ScrollArea className="w-full h-full" ref={tracksScrollRef}> <div className="relative flex-1" @@ -1054,8 +988,6 @@ export function Timeline() { height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`, width: `${dynamicTimelineWidth}px`, }} - onClick={handleTimelineClick} - onMouseDown={handleTimelineMouseDown} > {tracks.length === 0 ? ( <div></div> @@ -1099,18 +1031,6 @@ export function Timeline() { </ContextMenuContent> </ContextMenu> ))} - - {/* Playhead for tracks area (scrubbable) */} - {tracks.length > 0 && ( - <div - className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col" - style={{ - left: `${playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, - height: `${getTotalTracksHeight(tracks)}px`, - }} - onMouseDown={handlePlayheadMouseDown} - /> - )} </> )} </div> |
