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