diff options
Diffstat (limited to 'apps/web/src/components')
30 files changed, 1791 insertions, 857 deletions
diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx new file mode 100644 index 00000000..721564ea --- /dev/null +++ b/apps/web/src/components/background-settings.tsx @@ -0,0 +1,184 @@ +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
+import { Button } from "./ui/button";
+import { BackgroundIcon } from "./icons";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import { colors } from "@/data/colors";
+import { useProjectStore } from "@/stores/project-store";
+import { PipetteIcon } from "lucide-react";
+
+type BackgroundTab = "color" | "blur";
+
+export function BackgroundSettings() {
+ const { activeProject, updateBackgroundType } = useProjectStore();
+
+ // ✅ Good: derive activeTab from activeProject during rendering
+ const activeTab = activeProject?.backgroundType || "color";
+
+ const handleColorSelect = (color: string) => {
+ updateBackgroundType("color", { backgroundColor: color });
+ };
+
+ const handleBlurSelect = (blurIntensity: number) => {
+ updateBackgroundType("blur", { blurIntensity });
+ };
+
+ const tabs = [
+ {
+ label: "Color",
+ value: "color",
+ },
+ {
+ label: "Blur",
+ value: "blur",
+ },
+ ];
+
+ return (
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ className="!size-4 border border-muted-foreground"
+ >
+ <BackgroundIcon className="!size-3" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
+ <div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
+ <h2 className="text-sm">Background</h2>
+ <div className="flex items-center gap-2 text-sm">
+ {tabs.map((tab) => (
+ <span
+ key={tab.value}
+ onClick={() => {
+ // Switch to the background type when clicking tabs
+ if (tab.value === "color") {
+ updateBackgroundType("color", {
+ backgroundColor:
+ activeProject?.backgroundColor || "#000000",
+ });
+ } else {
+ updateBackgroundType("blur", {
+ blurIntensity: activeProject?.blurIntensity || 8,
+ });
+ }
+ }}
+ className={cn(
+ "text-muted-foreground cursor-pointer",
+ activeTab === tab.value && "text-foreground"
+ )}
+ >
+ {tab.label}
+ </span>
+ ))}
+ </div>
+ </div>
+ {activeTab === "color" ? (
+ <ColorView
+ selectedColor={activeProject?.backgroundColor || "#000000"}
+ onColorSelect={handleColorSelect}
+ />
+ ) : (
+ <BlurView
+ selectedBlur={activeProject?.blurIntensity || 8}
+ onBlurSelect={handleBlurSelect}
+ />
+ )}
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function ColorView({
+ selectedColor,
+ onColorSelect,
+}: {
+ selectedColor: string;
+ onColorSelect: (color: string) => void;
+}) {
+ return (
+ <div className="w-full h-full">
+ <div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none"></div>
+ <div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
+ <div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
+ <PipetteIcon className="size-4" />
+ </div>
+ {colors.map((color) => (
+ <ColorItem
+ key={color}
+ color={color}
+ isSelected={color === selectedColor}
+ onClick={() => onColorSelect(color)}
+ />
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function ColorItem({
+ color,
+ isSelected,
+ onClick,
+}: {
+ color: string;
+ isSelected: boolean;
+ onClick: () => void;
+}) {
+ return (
+ <div
+ className={cn(
+ "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
+ isSelected && "border-2 border-primary"
+ )}
+ style={{ backgroundColor: color }}
+ onClick={onClick}
+ />
+ );
+}
+
+function BlurView({
+ selectedBlur,
+ onBlurSelect,
+}: {
+ selectedBlur: number;
+ onBlurSelect: (blurIntensity: number) => void;
+}) {
+ const blurLevels = [
+ { label: "Light", value: 4 },
+ { label: "Medium", value: 8 },
+ { label: "Heavy", value: 18 },
+ ];
+ const blurImage =
+ "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
+
+ return (
+ <div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
+ {blurLevels.map((blur) => (
+ <div
+ key={blur.value}
+ className={cn(
+ "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
+ selectedBlur === blur.value && "border-2 border-primary"
+ )}
+ onClick={() => onBlurSelect(blur.value)}
+ >
+ <Image
+ src={blurImage}
+ alt={`Blur preview ${blur.label}`}
+ fill
+ className="object-cover"
+ style={{ filter: `blur(${blur.value}px)` }}
+ />
+ <div className="absolute bottom-1 left-1 right-1 text-center">
+ <span className="text-xs text-white bg-black/50 px-1 rounded">
+ {blur.label}
+ </span>
+ </div>
+ </div>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index daeb14cb..c4081c75 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -30,14 +30,25 @@ export function EditorHeader() { );
const centerContent = (
- <div className="flex items-center gap-2 text-xs text-muted-foreground">
- <span>{formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")}</span>
+ <div className="flex items-center gap-2 text-xs">
+ <span>
+ {formatTimeCode(
+ getTotalDuration(),
+ "HH:MM:SS:FF",
+ activeProject?.fps || 30
+ )}
+ </span>
</div>
);
const rightContent = (
<nav className="flex items-center gap-2">
- <Button size="sm" variant="primary" onClick={handleExport}>
+ <Button
+ size="sm"
+ variant="primary"
+ className="h-7 text-xs"
+ onClick={handleExport}
+ >
<Download className="h-4 w-4" />
<span className="text-sm">Export</span>
</Button>
@@ -49,7 +60,7 @@ export function EditorHeader() { leftContent={leftContent}
centerContent={centerContent}
rightContent={rightContent}
- className="bg-background border-b"
+ className="bg-background h-[3.2rem] px-4"
/>
);
}
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 51e9a264..9f7322c7 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -47,7 +47,7 @@ export function MediaPanel() { };
return (
- <div className="h-full flex flex-col">
+ <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
<TabBar />
<div className="flex-1">{viewMap[activeTab]}</div>
</div>
diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index 580394b9..f4e520b2 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -69,7 +69,7 @@ export function TabBar() { />
<div
ref={scrollContainerRef}
- className="h-12 bg-accent/50 px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative"
+ className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative"
>
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
const tab = tabs[tabKey];
@@ -111,7 +111,7 @@ function ScrollButton({ const Icon = direction === "left" ? ChevronLeft : ChevronRight;
return (
- <div className="bg-accent/50 w-12 h-full flex items-center justify-center">
+ <div className="bg-panel-accent w-12 h-full flex items-center justify-center">
<Button
size="icon"
className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
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 f7da684d..48e54961 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -3,7 +3,6 @@ import { useDragDrop } from "@/hooks/use-drag-drop";
import { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
-import { useTimelineStore } from "@/stores/timeline-store";
import { Image, Music, Plus, Upload, Video } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@@ -85,25 +84,7 @@ export function MediaView() { return;
}
- // Remove elements automatically when delete media
- const { tracks, removeTrack } = useTimelineStore.getState();
- tracks.forEach((track) => {
- const elementsToRemove = track.elements.filter(
- (element) => element.type === "media" && element.mediaId === id
- );
- elementsToRemove.forEach((element) => {
- useTimelineStore
- .getState()
- .removeElementFromTrack(track.id, element.id);
- });
- // Only remove track if it becomes empty and has no other elements
- const updatedTrack = useTimelineStore
- .getState()
- .tracks.find((t) => t.id === track.id);
- if (updatedTrack && updatedTrack.elements.length === 0) {
- removeTrack(track.id);
- }
- });
+ // Media store now handles cascade deletion automatically
await removeMediaItem(activeProject.id, id);
};
@@ -254,7 +235,7 @@ export function MediaView() { size="sm"
onClick={handleFileSelect}
disabled={isProcessing}
- className="flex-none 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"
>
{isProcessing ? (
<>
@@ -297,7 +278,7 @@ export function MediaView() { {/* Render each media item as a draggable button */}
{filteredMediaItems.map((item) => (
<ContextMenu key={item.id}>
- <ContextMenuTrigger asChild>
+ <ContextMenuTrigger>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 2809a8a5..894b6257 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -2,14 +2,12 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { TimelineElement, TimelineTrack } from "@/types/timeline"; -import { - useMediaStore, - type MediaItem, - getMediaAspectRatio, -} from "@/stores/media-store"; +import { useMediaStore, type MediaItem } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { useEditorStore } from "@/stores/editor-store"; +import { useAspectRatio } from "@/hooks/use-aspect-ratio"; import { VideoPlayer } from "@/components/ui/video-player"; +import { AudioPlayer } from "@/components/ui/audio-player"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -18,10 +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; @@ -40,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(() => { @@ -137,12 +139,96 @@ 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; // Text elements if (element.type === "text") { + const fontClassName = + FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || ""; + + const scaleRatio = previewDimensions.width / canvasSize.width; + return ( <div key={element.id} @@ -150,15 +236,15 @@ 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 }} > <div + className={fontClassName} style={{ fontSize: `${element.fontSize}px`, - fontFamily: element.fontFamily, color: element.color, backgroundColor: element.backgroundColor, textAlign: element.textAlign, @@ -167,7 +253,9 @@ 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 }), }} > {element.content} @@ -196,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} @@ -212,28 +303,32 @@ 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> ); } - // Audio elements (visual representation) + // Audio elements (no visual representation) if (mediaItem.type === "audio") { return ( - <div - key={element.id} - className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center" - > - <div className="text-center"> - <div className="text-2xl mb-2">🎵</div> - <p className="text-xs text-white">{mediaItem.name}</p> - </div> + <div key={element.id} className="absolute inset-0"> + <AudioPlayer + src={mediaItem.url!} + clipStartTime={element.startTime} + trimStart={element.trimStart} + trimEnd={element.trimEnd} + clipDuration={element.duration} + trackMuted={elementData.track.muted} + /> </div> ); } @@ -243,20 +338,26 @@ export function PreviewPanel() { }; return ( - <div className="h-full w-full flex flex-col min-h-0 min-w-0"> + <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 gap-4" + 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 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 @@ -266,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} @@ -279,68 +388,53 @@ export function PreviewPanel() { function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { const { isPlaying, toggle, currentTime } = usePlaybackStore(); + const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); + const { getTotalDuration } = useTimelineStore(); + const { activeProject } = useProjectStore(); const { - canvasSize, + currentPreset, + isOriginal, + getOriginalAspectRatio, + getDisplayName, canvasPresets, - setCanvasSize, - setCanvasSizeFromAspectRatio, - } = useEditorStore(); - const { mediaItems } = useMediaStore(); - const { tracks, getTotalDuration } = useTimelineStore(); - - // Find the current preset based on canvas size - const currentPreset = canvasPresets.find( - (preset) => - preset.width === canvasSize.width && preset.height === canvasSize.height - ); + } = useAspectRatio(); const handlePresetSelect = (preset: { width: number; height: number }) => { setCanvasSize({ width: preset.width, height: preset.height }); }; - // Get the first video/image media item to determine original aspect ratio - const getOriginalAspectRatio = () => { - // Find first video or image in timeline - for (const track of tracks) { - for (const element of track.elements) { - if (element.type === "media") { - const mediaItem = mediaItems.find( - (item) => item.id === element.mediaId - ); - if ( - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") - ) { - return getMediaAspectRatio(mediaItem); - } - } - } - } - return 16 / 9; // Default aspect ratio - }; - const handleOriginalSelect = () => { const aspectRatio = getOriginalAspectRatio(); - setCanvasSizeFromAspectRatio(aspectRatio); + setCanvasSizeToOriginal(aspectRatio); }; - // Check if current size is "Original" (not matching any preset) - const isOriginal = !currentPreset; - return ( <div data-toolbar - className="flex items-end justify-between gap-2 p-1 pt-2 bg-background-500 w-full" + className="flex items-end justify-between gap-2 p-1 pt-2 w-full" > <div> <p className={cn( - "text-xs text-muted-foreground tabular-nums", + "text-[0.75rem] text-muted-foreground flex items-center gap-1", !hasAnyElements && "opacity-50" )} > - {formatTimeCode(currentTime, "HH:MM:SS:CS")}/ - {formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")} + <span className="text-primary tabular-nums"> + {formatTimeCode( + currentTime, + "HH:MM:SS:FF", + activeProject?.fps || 30 + )} + </span> + <span className="opacity-50">/</span> + <span className="tabular-nums"> + {formatTimeCode( + getTotalDuration(), + "HH:MM:SS:FF", + activeProject?.fps || 30 + )} + </span> </p> </div> <Button @@ -348,6 +442,7 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { size="icon" onClick={toggle} disabled={!hasAnyElements} + className="h-auto p-0" > {isPlaying ? ( <Pause className="h-3 w-3" /> @@ -355,15 +450,16 @@ 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-background 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} > - {currentPreset?.name || "Ratio"} + {getDisplayName()} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> @@ -388,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 0de78988..00000000 --- a/apps/web/src/components/editor/properties-panel.tsx +++ /dev/null @@ -1,220 +0,0 @@ -"use client"; - -import { Input } from "../ui/input"; -import { Label } from "../ui/label"; -import { Slider } from "../ui/slider"; -import { ScrollArea } from "../ui/scroll-area"; -import { Separator } from "../ui/separator"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../ui/select"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useMediaStore } from "@/stores/media-store"; -import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; -import { useState } from "react"; -import { SpeedControl } from "./speed-control"; -import type { BackgroundType } from "@/types/editor"; - -export function PropertiesPanel() { - const { tracks } = useTimelineStore(); - const { mediaItems } = useMediaStore(); - const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur"); - const [backgroundColor, setBackgroundColor] = useState("#000000"); - - // Get the first video element for preview (simplified) - const firstVideoElement = tracks - .flatMap((track) => track.elements) - .find((element) => { - if (element.type !== "media") return false; - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); - return mediaItem?.type === "video"; - }); - - const firstVideoItem = firstVideoElement && firstVideoElement.type === "media" - ? mediaItems.find((item) => item.id === firstVideoElement.mediaId) - : null; - - const firstImageElement = tracks - .flatMap((track) => track.elements) - .find((element) => { - if (element.type !== "media") return false; - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); - return mediaItem?.type === "image"; - }); - - const firstImageItem = firstImageElement && firstImageElement.type === "media" - ? mediaItems.find((item) => item.id === firstImageElement.mediaId) - : null; - - return ( - <ScrollArea className="h-full"> - <div className="space-y-6 p-5"> - {/* Image Treatment - only show if an image is selected */} - {firstImageItem && ( - <> - <div className="space-y-4"> - <h3 className="text-sm font-medium">Image Treatment</h3> - <div className="space-y-4"> - {/* Preview */} - <div className="space-y-2"> - <Label>Preview</Label> - <div className="w-full aspect-video max-w-48"> - <ImageTimelineTreatment - src={firstImageItem.url!} - alt={firstImageItem.name} - targetAspectRatio={16 / 9} - className="rounded-sm border" - backgroundType={backgroundType} - backgroundColor={backgroundColor} - /> - </div> - </div> - - {/* Background Type */} - <div className="space-y-2"> - <Label htmlFor="bg-type">Background Type</Label> - <Select - value={backgroundType} - onValueChange={(value: BackgroundType) => - setBackgroundType(value) - } - > - <SelectTrigger> - <SelectValue placeholder="Select background type" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="blur">Blur</SelectItem> - <SelectItem value="mirror">Mirror</SelectItem> - <SelectItem value="color">Solid Color</SelectItem> - </SelectContent> - </Select> - </div> - - {/* Background Color - only show for color type */} - {backgroundType === "color" && ( - <div className="space-y-2"> - <Label htmlFor="bg-color">Background Color</Label> - <div className="flex gap-2"> - <Input - id="bg-color" - type="color" - value={backgroundColor} - onChange={(e) => setBackgroundColor(e.target.value)} - className="w-16 h-10 p-1" - /> - <Input - value={backgroundColor} - onChange={(e) => setBackgroundColor(e.target.value)} - placeholder="#000000" - className="flex-1" - /> - </div> - </div> - )} - </div> - </div> - - <Separator /> - </> - )} - - {/* Video Controls - only show if a video is selected */} - {firstVideoItem && ( - <> - <SpeedControl /> - <Separator /> - </> - )} - - {/* Transform */} - <div className="space-y-4"> - <h3 className="text-sm font-medium">Transform</h3> - <div className="space-y-2"> - <div className="grid grid-cols-2 gap-2"> - <div className="space-y-1"> - <Label htmlFor="x">X Position</Label> - <Input id="x" type="number" defaultValue="0" /> - </div> - <div className="space-y-1"> - <Label htmlFor="y">Y Position</Label> - <Input id="y" type="number" defaultValue="0" /> - </div> - </div> - <div className="space-y-1"> - <Label htmlFor="rotation">Rotation</Label> - <Slider - id="rotation" - max={360} - step={1} - defaultValue={[0]} - className="mt-2" - /> - </div> - </div> - </div> - - <Separator /> - - {/* Effects */} - <div className="space-y-4"> - <h3 className="text-sm font-medium">Effects</h3> - <div className="space-y-4"> - <div className="space-y-1"> - <Label htmlFor="opacity">Opacity</Label> - <Slider - id="opacity" - max={100} - step={1} - defaultValue={[100]} - className="mt-2" - /> - </div> - <div className="space-y-1"> - <Label htmlFor="blur">Blur</Label> - <Slider - id="blur" - max={20} - step={0.5} - defaultValue={[0]} - className="mt-2" - /> - </div> - </div> - </div> - - <Separator /> - - {/* Timing */} - <div className="space-y-4"> - <h3 className="text-sm font-medium">Timing</h3> - <div className="space-y-2"> - <div className="space-y-1"> - <Label htmlFor="duration">Duration (seconds)</Label> - <Input - id="duration" - type="number" - min="0" - step="0.1" - defaultValue="5" - /> - </div> - <div className="space-y-1"> - <Label htmlFor="delay">Delay (seconds)</Label> - <Input - id="delay" - type="number" - min="0" - step="0.1" - defaultValue="0" - /> - </div> - </div> - </div> - </div> - </ScrollArea> - ); -} 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 4a909ce4..ee9445f3 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -11,6 +11,8 @@ import { ChevronRight, ChevronLeft, Type, + Copy, + RefreshCw, } from "lucide-react"; import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; @@ -22,7 +24,7 @@ import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { getTrackElementClasses, TIMELINE_CONSTANTS, -} from "@/lib/timeline-constants"; +} from "@/constants/timeline-constants"; import { DropdownMenu, DropdownMenuContent, @@ -33,6 +35,13 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, } from "../ui/dropdown-menu"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../ui/context-menu"; export function TimelineElement({ element, @@ -52,6 +61,8 @@ export function TimelineElement({ splitAndKeepLeft, splitAndKeepRight, separateAudio, + addElementToTrack, + replaceElementMedia, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); @@ -172,10 +183,73 @@ export function TimelineElement({ return mediaItem?.type === "video" && track.type === "media"; }; + const handleElementSplitContext = () => { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const secondElementId = splitElement(track.id, element.id, currentTime); + if (!secondElementId) { + toast.error("Failed to split element"); + } + } else { + toast.error("Playhead must be within element to split"); + } + }; + + const handleElementDuplicateContext = () => { + const { id, ...elementWithoutId } = element; + addElementToTrack(track.id, { + ...elementWithoutId, + name: element.name + " (copy)", + startTime: + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1, + }); + }; + + const handleElementDeleteContext = () => { + removeElementFromTrack(track.id, element.id); + }; + + const handleReplaceClip = () => { + if (element.type !== "media") { + toast.error("Replace is only available for media clips"); + return; + } + + // Create a file input to select replacement media + const input = document.createElement("input"); + input.type = "file"; + input.accept = "video/*,audio/*,image/*"; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + try { + const success = await replaceElementMedia(track.id, element.id, file); + if (success) { + toast.success("Clip replaced successfully"); + } else { + toast.error("Failed to replace clip"); + } + } catch (error) { + toast.error("Failed to replace clip"); + console.log( + JSON.stringify({ error: "Failed to replace clip", details: error }) + ); + } + }; + input.click(); + }; + const renderElementContent = () => { if (element.type === "text") { return ( - <div className="w-full h-full flex items-center justify-center"> + <div className="w-full h-full flex items-center justify-start pl-2"> <span className="text-xs text-foreground/80 truncate"> {element.content} </span> @@ -255,47 +329,77 @@ export function TimelineElement({ }; return ( - <div - className={`absolute top-0 h-full select-none timeline-element ${ - isBeingDragged ? "z-50" : "z-10" - }`} - style={{ - left: `${elementLeft}px`, - width: `${elementWidth}px`, - }} - onMouseMove={resizing ? handleResizeMove : undefined} - onMouseUp={resizing ? handleResizeEnd : undefined} - onMouseLeave={resizing ? handleResizeEnd : undefined} - > - <div - className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses( - track.type - )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ - isBeingDragged ? "z-50" : "z-10" - }`} - onClick={(e) => onElementClick && onElementClick(e, element)} - onMouseDown={handleElementMouseDown} - onContextMenu={(e) => - onElementMouseDown && onElementMouseDown(e, element) - } - > - <div className="absolute inset-0 flex items-center h-full"> - {renderElementContent()} - </div> + <ContextMenu> + <ContextMenuTrigger asChild> + <div + className={`absolute top-0 h-full select-none timeline-element ${ + isBeingDragged ? "z-50" : "z-10" + }`} + style={{ + 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} + > + <div + className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses( + track.type + )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ + isBeingDragged ? "z-50" : "z-10" + }`} + onClick={(e) => onElementClick && onElementClick(e, element)} + onMouseDown={handleElementMouseDown} + onContextMenu={(e) => + onElementMouseDown && onElementMouseDown(e, element) + } + > + <div className="absolute inset-0 flex items-center h-full"> + {renderElementContent()} + </div> - {isSelected && ( - <> - <div - className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" - onMouseDown={(e) => handleResizeStart(e, element.id, "left")} - /> - <div - className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50" - onMouseDown={(e) => handleResizeStart(e, element.id, "right")} - /> - </> + {isSelected && ( + <> + <div + className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" + onMouseDown={(e) => handleResizeStart(e, element.id, "left")} + /> + <div + className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50" + onMouseDown={(e) => handleResizeStart(e, element.id, "right")} + /> + </> + )} + </div> + </div> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem onClick={handleElementSplitContext}> + <Scissors className="h-4 w-4 mr-2" /> + Split at playhead + </ContextMenuItem> + <ContextMenuItem onClick={handleElementDuplicateContext}> + <Copy className="h-4 w-4 mr-2" /> + Duplicate {element.type === "text" ? "text" : "clip"} + </ContextMenuItem> + {element.type === "media" && ( + <ContextMenuItem onClick={handleReplaceClip}> + <RefreshCw className="h-4 w-4 mr-2" /> + Replace clip + </ContextMenuItem> )} - </div> - </div> + <ContextMenuSeparator /> + <ContextMenuItem + onClick={handleElementDeleteContext} + className="text-destructive focus:text-destructive" + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete {element.type === "text" ? "text" : "clip"} + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> ); } 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 f1766593..c3a16b9a 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -4,16 +4,8 @@ import { useRef, useState, useEffect } from "react"; import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { toast } from "sonner"; -import { Copy, Scissors, Trash2 } from "lucide-react"; import { TimelineElement } from "./timeline-element"; import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "../ui/context-menu"; -import { TimelineTrack, sortTracksByOrder, ensureMainTrack, @@ -25,7 +17,11 @@ import type { TimelineElement as TimelineElementType, DragData, } from "@/types/timeline"; -import { TIMELINE_CONSTANTS } from "@/lib/timeline-constants"; +import { + snapTimeToFrame, + TIMELINE_CONSTANTS, +} from "@/constants/timeline-constants"; +import { useProjectStore } from "@/stores/project-store"; export function TimelineTrackContent({ track, @@ -88,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); }; @@ -96,65 +95,115 @@ export function TimelineTrackContent({ const handleMouseUp = (e: MouseEvent) => { if (!dragState.elementId || !dragState.trackId) return; - // Check if the mouse is actually over this track + // If this track initiated the drag, we should handle the mouse up regardless of where it occurs + const isTrackThatStartedDrag = dragState.trackId === track.id; + const timelineRect = timelineRef.current?.getBoundingClientRect(); - if (!timelineRect) return; + if (!timelineRect) { + if (isTrackThatStartedDrag) { + updateElementStartTime( + track.id, + dragState.elementId, + dragState.currentTime + ); + endDragAction(); + } + return; + } const isMouseOverThisTrack = e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom; - // Only handle if mouse is over this track - if (!isMouseOverThisTrack) return; + if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return; const finalTime = dragState.currentTime; - // Check for overlaps and update position - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const movingElement = sourceTrack?.elements.find( - (c) => c.id === dragState.elementId - ); + if (isMouseOverThisTrack) { + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const movingElement = sourceTrack?.elements.find( + (c) => c.id === dragState.elementId + ); - if (movingElement) { - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const movingElementEnd = finalTime + movingElementDuration; + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const movingElementEnd = finalTime + movingElementDuration; - const targetTrack = tracks.find((t) => t.id === track.id); - const hasOverlap = targetTrack?.elements.some((existingElement) => { - if ( - dragState.trackId === track.id && - existingElement.id === dragState.elementId - ) { - return false; + const targetTrack = tracks.find((t) => t.id === track.id); + const hasOverlap = targetTrack?.elements.some((existingElement) => { + if ( + dragState.trackId === track.id && + existingElement.id === dragState.elementId + ) { + return false; + } + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return finalTime < existingEnd && movingElementEnd > existingStart; + }); + + if (!hasOverlap) { + if (dragState.trackId === track.id) { + updateElementStartTime(track.id, dragState.elementId, finalTime); + } else { + moveElementToTrack( + dragState.trackId, + track.id, + dragState.elementId + ); + requestAnimationFrame(() => { + updateElementStartTime( + track.id, + dragState.elementId!, + finalTime + ); + }); + } } - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return finalTime < existingEnd && movingElementEnd > existingStart; - }); + } + } else if (isTrackThatStartedDrag) { + // Mouse is not over this track, but this track started the drag + // This means user released over ruler/outside - update position within same track + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const movingElement = sourceTrack?.elements.find( + (c) => c.id === dragState.elementId + ); + + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const movingElementEnd = finalTime + movingElementDuration; - if (!hasOverlap) { - if (dragState.trackId === track.id) { + const hasOverlap = track.elements.some((existingElement) => { + if (existingElement.id === dragState.elementId) { + return false; + } + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return finalTime < existingEnd && movingElementEnd > existingStart; + }); + + if (!hasOverlap) { updateElementStartTime(track.id, dragState.elementId, finalTime); - } else { - moveElementToTrack( - dragState.trackId, - track.id, - dragState.elementId - ); - requestAnimationFrame(() => { - updateElementStartTime(track.id, dragState.elementId!, finalTime); - }); } } } - endDragAction(); + if (isTrackThatStartedDrag) { + endDragAction(); + } }; document.addEventListener("mousemove", handleMouseMove); @@ -300,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) => { @@ -319,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) => { @@ -359,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) => { @@ -386,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) => { @@ -460,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); @@ -506,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) @@ -882,38 +943,15 @@ export function TimelineTrackContent({ }; return ( - <ContextMenu key={element.id}> - <ContextMenuTrigger asChild> - <div> - <TimelineElement - element={element} - track={track} - zoomLevel={zoomLevel} - isSelected={isSelected} - onElementMouseDown={handleElementMouseDown} - onElementClick={handleElementClick} - /> - </div> - </ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem onClick={handleElementSplit}> - <Scissors className="h-4 w-4 mr-2" /> - Split at playhead - </ContextMenuItem> - <ContextMenuItem onClick={handleElementDuplicate}> - <Copy className="h-4 w-4 mr-2" /> - Duplicate {element.type === "text" ? "text" : "clip"} - </ContextMenuItem> - <ContextMenuSeparator /> - <ContextMenuItem - onClick={handleElementDelete} - className="text-destructive focus:text-destructive" - > - <Trash2 className="h-4 w-4 mr-2" /> - Delete {element.type === "text" ? "text" : "clip"} - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> + <TimelineElement + key={element.id} + element={element} + track={track} + zoomLevel={zoomLevel} + isSelected={isSelected} + onElementMouseDown={handleElementMouseDown} + onElementClick={handleElementClick} + /> ); })} </> diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index f7208224..71e2997b 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -10,10 +10,11 @@ import { Snowflake, Copy, SplitSquareHorizontal, - Volume2, - VolumeX, Pause, Play, + Video, + Music, + TypeIcon, } from "lucide-react"; import { Tooltip, @@ -25,13 +26,13 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, - ContextMenuSeparator, 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"; @@ -43,14 +44,19 @@ import { SelectValue, } from "../ui/select"; import { TimelineTrackContent } from "./timeline-track"; -import type { DragData } from "@/types/timeline"; import { - getTrackLabelColor, + 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, getCumulativeHeightBefore, getTotalTracksHeight, TIMELINE_CONSTANTS, -} from "@/lib/timeline-constants"; +} from "@/constants/timeline-constants"; export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their elements. @@ -68,47 +74,30 @@ export function Timeline() { splitElement, splitAndKeepLeft, splitAndKeepRight, + toggleTrackMute, separateAudio, undo, redo, } = 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); - const [zoomLevel, setZoomLevel] = useState(1); const dragCounterRef = useRef(0); const timelineRef = useRef<HTMLDivElement>(null); + const rulerRef = useRef<HTMLDivElement>(null); const [isInTimeline, setIsInTimeline] = useState(false); - // 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); + // Timeline zoom functionality + const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ + containerRef: timelineRef, + isInTimeline, + }); - // 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( @@ -120,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(() => { @@ -133,6 +248,22 @@ export function Timeline() { // Keyboard event for deleting selected elements useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + // Don't trigger when typing in input fields or textareas + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) { + return; + } + + // Only trigger when timeline is focused or mouse is over timeline + if ( + !isInTimeline && + !timelineRef.current?.contains(document.activeElement) + ) { + return; + } + if ( (e.key === "Delete" || e.key === "Backspace") && selectedElements.length > 0 @@ -145,7 +276,12 @@ export function Timeline() { }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedElements, removeElementFromTrack, clearSelectedElements]); + }, [ + selectedElements, + removeElementFromTrack, + clearSelectedElements, + isInTimeline, + ]); // Keyboard event for undo (Cmd+Z) useEffect(() => { @@ -174,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 @@ -432,102 +460,6 @@ export function Timeline() { } }; - const handleWheel = (e: React.WheelEvent) => { - // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true) - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - const delta = e.deltaY > 0 ? -0.05 : 0.05; - setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); - } - // Otherwise, allow normal scrolling - }; - - // --- 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 timeline = timelineRef.current; - if (!timeline) return; - const rect = timeline.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, @@ -667,26 +599,6 @@ export function Timeline() { clearSelectedElements(); }; - // Prevent explorer zooming in/out when in timeline - useEffect(() => { - const preventZoom = (e: WheelEvent) => { - // if (isInTimeline && (e.ctrlKey || e.metaKey)) { - if ( - isInTimeline && - (e.ctrlKey || e.metaKey) && - timelineRef.current?.contains(e.target as Node) - ) { - e.preventDefault(); - } - }; - - document.addEventListener("wheel", preventZoom, { passive: false }); - - return () => { - document.removeEventListener("wheel", preventZoom); - }; - }, [isInTimeline]); - // --- Scroll synchronization effect --- useEffect(() => { const rulerViewport = rulerScrollRef.current?.querySelector( @@ -695,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; @@ -712,48 +630,60 @@ 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`} + className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`} {...dragProps} onMouseEnter={() => setIsInTimeline(true)} onMouseLeave={() => setIsInTimeline(false)} - onWheel={handleWheel} > {/* Toolbar */} <div className="border-b flex items-center px-2 py-1 gap-1"> @@ -885,48 +815,49 @@ export function Timeline() { </TooltipTrigger> <TooltipContent>Delete element (Delete)</TooltipContent> </Tooltip> - <div className="w-px h-6 bg-border mx-1" />c{/* 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"> + <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-background 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"> + <div + 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={timelineRef} - className={`relative h-12 bg-muted/30 select-none ${ - isDraggingRuler ? "cursor-grabbing" : "cursor-grab" - }`} + ref={rulerRef} + className="relative h-4 select-none cursor-default" style={{ width: `${dynamicTimelineWidth}px`, }} @@ -970,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" @@ -999,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> @@ -1019,35 +939,48 @@ 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-background overflow-y-auto"> - <div className="flex flex-col"> - {tracks.map((track) => ( - <div - key={track.id} - className="flex items-center px-3 border-b border-muted/30 bg-background group" - style={{ height: `${getTrackHeight(track.type)}px` }} - > - <div className="flex items-center flex-1 min-w-0"> - <div - className={`w-3 h-3 rounded-full flex-shrink-0 ${getTrackLabelColor(track.type)}`} - /> - <span className="ml-2 text-sm font-medium truncate"> - {track.name} - </span> + <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> )} {/* Timeline Tracks Content */} - <div className="flex-1 relative overflow-hidden"> + <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" @@ -1055,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> @@ -1066,7 +997,7 @@ export function Timeline() { <ContextMenu key={track.id}> <ContextMenuTrigger asChild> <div - className="absolute left-0 right-0 border-b border-muted/30" + 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`, @@ -1089,24 +1020,17 @@ export function Timeline() { </div> </ContextMenuTrigger> <ContextMenuContent> + <ContextMenuItem + onClick={() => toggleTrackMute(track.id)} + > + {track.muted ? "Unmute Track" : "Mute Track"} + </ContextMenuItem> <ContextMenuItem> Track settings (soon) </ContextMenuItem> </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> @@ -1117,3 +1041,19 @@ export function Timeline() { </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/header.tsx b/apps/web/src/components/header.tsx index 468edf16..bfe314d6 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -41,9 +41,9 @@ export function Header() { </Button>
</Link>
{process.env.NODE_ENV === "development" ? (
- <Link href="/editor/1">
+ <Link href="/projects">
<Button size="sm" className="text-sm ml-4">
- Editor
+ Projects
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx index 563c92a9..4f3986e3 100644 --- a/apps/web/src/components/icons.tsx +++ b/apps/web/src/components/icons.tsx @@ -37,3 +37,64 @@ export function GithubIcon({ className }: { className?: string }) { </svg> ); } + +export function BackgroundIcon({ className }: { className?: string }) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="353" + height="353" + viewBox="0 0 353 353" + fill="none" + className={className} + > + <g clipPath="url(#clip0_1_3)"> + <rect + x="-241.816" + y="233.387" + width="592.187" + height="17.765" + transform="rotate(-37 -241.816 233.387)" + fill="white" + /> + <rect + x="-189.907" + y="306.804" + width="592.187" + height="17.765" + transform="rotate(-37 -189.907 306.804)" + fill="white" + /> + <rect + x="-146.928" + y="389.501" + width="592.187" + height="17.765" + transform="rotate(-37 -146.928 389.501)" + fill="white" + /> + <rect + x="-103.144" + y="477.904" + width="592.187" + height="17.765" + transform="rotate(-37 -103.144 477.904)" + fill="white" + /> + <rect + x="-57.169" + y="570.714" + width="592.187" + height="17.765" + transform="rotate(-37 -57.169 570.714)" + fill="white" + /> + </g> + <defs> + <clipPath id="clip0_1_3"> + <rect width="353" height="353" fill="white" /> + </clipPath> + </defs> + </svg> + ); +}
\ No newline at end of file diff --git a/apps/web/src/components/landing/handlebars.tsx b/apps/web/src/components/landing/handlebars.tsx new file mode 100644 index 00000000..36705e90 --- /dev/null +++ b/apps/web/src/components/landing/handlebars.tsx @@ -0,0 +1,164 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { motion, useMotionValue, useTransform, PanInfo } from "motion/react"; + +interface HandlebarsProps { + children: React.ReactNode; + minWidth?: number; + maxWidth?: number; + onRangeChange?: (left: number, right: number) => void; +} + +export function Handlebars({ + children, + minWidth = 50, + maxWidth = 400, + onRangeChange, +}: HandlebarsProps) { + const [leftHandle, setLeftHandle] = useState(0); + const [rightHandle, setRightHandle] = useState(maxWidth); + const [contentWidth, setContentWidth] = useState(maxWidth); + + const leftHandleX = useMotionValue(0); + const rightHandleX = useMotionValue(maxWidth); + + const visibleWidth = useTransform( + [leftHandleX, rightHandleX], + (values: number[]) => values[1] - values[0] + ); + + const contentLeft = useTransform(leftHandleX, (left: number) => -left); + + const containerRef = useRef<HTMLDivElement>(null); + const measureRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!measureRef.current) return; + + const measureContent = () => { + if (measureRef.current) { + const width = measureRef.current.scrollWidth; + const paddedWidth = width + 32; + setContentWidth(paddedWidth); + setRightHandle(paddedWidth); + rightHandleX.set(paddedWidth); + } + }; + + measureContent(); + const timer = setTimeout(measureContent, 50); + + return () => clearTimeout(timer); + }, [children, rightHandleX]); + + useEffect(() => { + leftHandleX.set(leftHandle); + }, [leftHandle, leftHandleX]); + + useEffect(() => { + rightHandleX.set(rightHandle); + }, [rightHandle, rightHandleX]); + + useEffect(() => { + onRangeChange?.(leftHandle, rightHandle); + }, [leftHandle, rightHandle, onRangeChange]); + + const handleLeftDrag = (event: any, info: PanInfo) => { + const newLeft = Math.max( + 0, + Math.min(leftHandle + info.offset.x, rightHandle - minWidth) + ); + setLeftHandle(newLeft); + }; + + const handleRightDrag = (event: any, info: PanInfo) => { + const newRight = Math.max( + leftHandle + minWidth, + Math.min(contentWidth, rightHandle + info.offset.x) + ); + setRightHandle(newRight); + }; + + return ( + <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2"> + <div + ref={measureRef} + className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]" + > + {children} + </div> + + <div + ref={containerRef} + className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2" + style={{ width: contentWidth }} + > + <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between"> + <motion.div + className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none" + style={{ + position: "absolute", + x: leftHandleX, + left: 0, + zIndex: 10, + }} + drag="x" + dragConstraints={{ left: 0, right: rightHandle - minWidth }} + dragElastic={0} + dragMomentum={false} + onDrag={handleLeftDrag} + whileHover={{ scale: 1.05 }} + whileDrag={{ scale: 1.1 }} + transition={{ type: "spring", stiffness: 400, damping: 30 }} + > + <div className="w-2 h-8 rounded-full bg-yellow-500"></div> + </motion.div> + + <motion.div + className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none" + style={{ + position: "absolute", + x: rightHandleX, + left: -30, + zIndex: 10, + }} + drag="x" + dragConstraints={{ + left: leftHandle + minWidth, + right: contentWidth, + }} + dragElastic={0} + dragMomentum={false} + onDrag={handleRightDrag} + whileHover={{ scale: 1.05 }} + whileDrag={{ scale: 1.1 }} + transition={{ type: "spring", stiffness: 400, damping: 30 }} + > + <div className="w-2 h-8 rounded-full bg-yellow-500"></div> + </motion.div> + </div> + + <motion.div + className="relative overflow-hidden rounded-2xl" + style={{ + width: visibleWidth, + x: leftHandleX, + height: "100%", + }} + > + <motion.div + className="w-full h-full flex items-center justify-center px-4" + style={{ + x: contentLeft, + width: contentWidth, + whiteSpace: "nowrap", + }} + > + {children} + </motion.div> + </motion.div> + </div> + </div> + ); +}; diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index d45f33ed..776cc654 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -8,6 +8,7 @@ import { useState } from "react"; import { useToast } from "@/hooks/use-toast"; import Image from "next/image"; +import { Handlebars } from "./handlebars"; interface HeroProps { signupCount: number; @@ -91,14 +92,7 @@ export function Hero({ signupCount }: HeroProps) { className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]" > <h1>The Open Source</h1> - <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2"> - <div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2"> - <Image src="/frame.svg" height={79} width={459} alt="frame" /> - <span className="absolute inset-0 flex items-center justify-center"> - Video Editor - </span> - </div> - </div> + <Handlebars>Video Editor</Handlebars> </motion.div> <motion.p @@ -121,15 +115,17 @@ export function Hero({ signupCount }: HeroProps) { onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg flex-col sm:flex-row" > - <Input - type="email" - placeholder="Enter your email" - className="h-11 text-base flex-1" - value={email} - onChange={(e) => setEmail(e.target.value)} - disabled={isSubmitting} - required - /> + <div className="relative w-full"> + <Input + type="email" + placeholder="Enter your email" + className="h-11 text-base flex-1" + value={email} + onChange={(e) => setEmail(e.target.value)} + disabled={isSubmitting} + required + /> + </div> <Button type="submit" size="lg" diff --git a/apps/web/src/components/ui/audio-player.tsx b/apps/web/src/components/ui/audio-player.tsx new file mode 100644 index 00000000..774dd95c --- /dev/null +++ b/apps/web/src/components/ui/audio-player.tsx @@ -0,0 +1,127 @@ +"use client";
+
+import { useRef, useEffect } from "react";
+import { usePlaybackStore } from "@/stores/playback-store";
+
+interface AudioPlayerProps {
+ src: string;
+ className?: string;
+ clipStartTime: number;
+ trimStart: number;
+ trimEnd: number;
+ clipDuration: number;
+ trackMuted?: boolean;
+}
+
+export function AudioPlayer({
+ src,
+ className = "",
+ clipStartTime,
+ trimStart,
+ trimEnd,
+ clipDuration,
+ trackMuted = false,
+}: AudioPlayerProps) {
+ const audioRef = useRef<HTMLAudioElement>(null);
+ const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
+
+ // Calculate if we're within this clip's timeline range
+ const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
+ const isInClipRange =
+ currentTime >= clipStartTime && currentTime < clipEndTime;
+
+ // Sync playback events
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio || !isInClipRange) return;
+
+ const handleSeekEvent = (e: CustomEvent) => {
+ // Always update audio time, even if outside clip range
+ const timelineTime = e.detail.time;
+ const audioTime = Math.max(
+ trimStart,
+ Math.min(
+ clipDuration - trimEnd,
+ timelineTime - clipStartTime + trimStart
+ )
+ );
+ audio.currentTime = audioTime;
+ };
+
+ const handleUpdateEvent = (e: CustomEvent) => {
+ // Always update audio time, even if outside clip range
+ const timelineTime = e.detail.time;
+ const targetTime = Math.max(
+ trimStart,
+ Math.min(
+ clipDuration - trimEnd,
+ timelineTime - clipStartTime + trimStart
+ )
+ );
+
+ if (Math.abs(audio.currentTime - targetTime) > 0.5) {
+ audio.currentTime = targetTime;
+ }
+ };
+
+ const handleSpeed = (e: CustomEvent) => {
+ audio.playbackRate = e.detail.speed;
+ };
+
+ window.addEventListener("playback-seek", handleSeekEvent as EventListener);
+ window.addEventListener(
+ "playback-update",
+ handleUpdateEvent as EventListener
+ );
+ window.addEventListener("playback-speed", handleSpeed as EventListener);
+
+ return () => {
+ window.removeEventListener(
+ "playback-seek",
+ handleSeekEvent as EventListener
+ );
+ window.removeEventListener(
+ "playback-update",
+ handleUpdateEvent as EventListener
+ );
+ window.removeEventListener(
+ "playback-speed",
+ handleSpeed as EventListener
+ );
+ };
+ }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
+
+ // Sync playback state
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+
+ if (isPlaying && isInClipRange && !trackMuted) {
+ audio.play().catch(() => {});
+ } else {
+ audio.pause();
+ }
+ }, [isPlaying, isInClipRange, trackMuted]);
+
+ // Sync volume and speed
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+
+ audio.volume = volume;
+ audio.muted = muted || trackMuted;
+ audio.playbackRate = speed;
+ }, [volume, speed, muted, trackMuted]);
+
+ return (
+ <audio
+ ref={audioRef}
+ src={src}
+ className={className}
+ preload="auto"
+ controls={false}
+ style={{ display: "none" }} // Audio elements don't need visual representation
+ onContextMenu={(e) => e.preventDefault()}
+ />
+ );
+}
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 234230f6..b8b9ab2f 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -24,7 +24,7 @@ const buttonVariants = cva( }, size: { default: "h-9 px-4 py-2", - sm: "h-8 rounded-md px-3 text-xs", + sm: "h-8 rounded-sm px-3 text-xs", lg: "h-10 rounded-md px-8", icon: "h-7 w-7", }, diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index 9db8a268..d9962dd2 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -82,6 +82,10 @@ const DropdownMenuContent = React.forwardRef< <DropdownMenuPrimitive.Content ref={ref} sideOffset={sideOffset} + onCloseAutoFocus={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} className={cn( "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx new file mode 100644 index 00000000..9a152fd4 --- /dev/null +++ b/apps/web/src/components/ui/font-picker.tsx @@ -0,0 +1,40 @@ +import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants";
+
+interface FontPickerProps {
+ defaultValue?: FontFamily;
+ onValueChange?: (value: FontFamily) => void;
+ className?: string;
+}
+
+export function FontPicker({
+ defaultValue,
+ onValueChange,
+ className,
+}: FontPickerProps) {
+ return (
+ <Select defaultValue={defaultValue} onValueChange={onValueChange}>
+ <SelectTrigger className={`w-full text-xs ${className || ""}`}>
+ <SelectValue placeholder="Select a font" />
+ </SelectTrigger>
+ <SelectContent>
+ {FONT_OPTIONS.map((font) => (
+ <SelectItem
+ key={font.value}
+ value={font.value}
+ className="text-xs"
+ style={{ fontFamily: font.value }}
+ >
+ {font.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+}
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 72314045..8fc31a58 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -11,14 +11,7 @@ interface InputProps extends React.ComponentProps<"input"> { const Input = React.forwardRef<HTMLInputElement, InputProps>( ( - { - className, - type, - showPassword, - onShowPasswordChange, - value, - ...props - }, + { className, type, showPassword, onShowPasswordChange, value, ...props }, ref ) => { const isPassword = type === "password"; @@ -26,7 +19,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>( const inputType = isPassword && showPassword ? "text" : type; return ( - <div className="relative w-full"> + <div className={showPassword ? "relative w-full" : ""}> <input type={inputType} className={cn( diff --git a/apps/web/src/components/ui/resizable.tsx b/apps/web/src/components/ui/resizable.tsx index 0b9a955c..8f3cb2e8 100644 --- a/apps/web/src/components/ui/resizable.tsx +++ b/apps/web/src/components/ui/resizable.tsx @@ -29,17 +29,11 @@ const ResizableHandle = ({ }) => ( <ResizablePrimitive.PanelResizeHandle className={cn( - "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", + "relative w-0 bg-transparent cursor-col-resize data-[panel-group-direction=vertical]:h-0 data-[panel-group-direction=vertical]:cursor-row-resize", className )} {...props} - > - {withHandle && ( - <div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border"> - <GripVertical className="h-2.5 w-2.5" /> - </div> - )} - </ResizablePrimitive.PanelResizeHandle> + /> ); export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 4332dc6b..911b3e5d 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -97,6 +97,10 @@ const SelectContent = React.forwardRef< className )} position={position} + onCloseAutoFocus={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} {...props} > <SelectScrollUpButton /> diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx index 717abc35..64f81ce6 100644 --- a/apps/web/src/components/ui/textarea.tsx +++ b/apps/web/src/components/ui/textarea.tsx @@ -9,7 +9,7 @@ const Textarea = React.forwardRef< return ( <textarea className={cn( - "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs", className )} ref={ref} diff --git a/apps/web/src/components/ui/video-player.tsx b/apps/web/src/components/ui/video-player.tsx index d8a72e40..29d82cdf 100644 --- a/apps/web/src/components/ui/video-player.tsx +++ b/apps/web/src/components/ui/video-player.tsx @@ -118,7 +118,7 @@ export function VideoPlayer({ ref={videoRef} src={src} poster={poster} - className={`w-full h-full object-cover ${className}`} + className={`max-w-full max-h-full object-contain ${className}`} playsInline preload="auto" controls={false} |
