diff options
Diffstat (limited to 'apps/web/src/components/editor')
12 files changed, 393 insertions, 196 deletions
diff --git a/apps/web/src/components/editor/layout-guide-overlay.tsx b/apps/web/src/components/editor/layout-guide-overlay.tsx new file mode 100644 index 00000000..ff4cde57 --- /dev/null +++ b/apps/web/src/components/editor/layout-guide-overlay.tsx @@ -0,0 +1,27 @@ +"use client";
+
+import { useEditorStore } from "@/stores/editor-store";
+import Image from "next/image";
+
+function TikTokGuide() {
+ return (
+ <div className="absolute inset-0 pointer-events-none">
+ <Image
+ src="/platform-guides/tiktok-blueprint.png"
+ alt="TikTok layout guide"
+ className="absolute inset-0 w-full h-full object-contain"
+ draggable={false}
+ fill
+ />
+ </div>
+ );
+}
+
+export function LayoutGuideOverlay() {
+ const { layoutGuide } = useEditorStore();
+
+ if (layoutGuide.platform === null) return null;
+ if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
+
+ return null;
+}
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 70eb69dd..21df9924 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -7,6 +7,7 @@ import { TextView } from "./views/text"; import { SoundsView } from "./views/sounds"; import { Separator } from "@/components/ui/separator"; import { SettingsView } from "./views/settings"; +import { Captions } from "./views/captions"; export function MediaPanel() { const { activeTab } = useMediaPanelStore(); @@ -30,11 +31,7 @@ export function MediaPanel() { Transitions view coming soon... </div> ), - captions: ( - <div className="p-4 text-muted-foreground"> - Captions view coming soon... - </div> - ), + captions: <Captions />, filters: ( <div className="p-4 text-muted-foreground"> Filters view coming soon... diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index 6fab0abf..56437508 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -2,81 +2,24 @@ import { cn } from "@/lib/utils"; import { Tab, tabs, useMediaPanelStore } from "./store"; -import { Button } from "@/components/ui/button"; -import { ChevronRight, ChevronLeft } from "lucide-react"; -import { useRef, useState, useEffect } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; export function TabBar() { const { activeTab, setActiveTab } = useMediaPanelStore(); - const scrollContainerRef = useRef<HTMLDivElement>(null); - const [isAtEnd, setIsAtEnd] = useState(false); - const [isAtStart, setIsAtStart] = useState(true); - - const scrollToEnd = () => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - left: scrollContainerRef.current.scrollWidth, - }); - setIsAtEnd(true); - setIsAtStart(false); - } - }; - - const scrollToStart = () => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - left: 0, - }); - setIsAtStart(true); - setIsAtEnd(false); - } - }; - - const checkScrollPosition = () => { - if (scrollContainerRef.current) { - const { scrollLeft, scrollWidth, clientWidth } = - scrollContainerRef.current; - const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1; - const isAtStartNow = scrollLeft <= 1; - setIsAtEnd(isAtEndNow); - setIsAtStart(isAtStartNow); - } - }; - - // We're using useEffect because we need to sync with external DOM scroll events - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - checkScrollPosition(); - container.addEventListener("scroll", checkScrollPosition); - - const resizeObserver = new ResizeObserver(checkScrollPosition); - resizeObserver.observe(container); - - return () => { - container.removeEventListener("scroll", checkScrollPosition); - resizeObserver.disconnect(); - }; - }, []); return ( <div className="flex"> - <ScrollButton - direction="left" - onClick={scrollToStart} - isVisible={!isAtStart} - /> - <div - ref={scrollContainerRef} - className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4" - > + <div className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4"> {(Object.keys(tabs) as Tab[]).map((tabKey) => { const tab = tabs[tabKey]; return ( <div className={cn( - "flex flex-col gap-0.5 items-center cursor-pointer opacity-100 hover:opacity-75", + "flex z-[100] flex-col gap-0.5 items-center cursor-pointer", activeTab === tabKey ? "text-primary !opacity-100" : "text-muted-foreground" @@ -84,42 +27,25 @@ export function TabBar() { onClick={() => setActiveTab(tabKey)} key={tabKey} > - <tab.icon className="size-[1.1rem]!" /> + <Tooltip delayDuration={10}> + <TooltipTrigger asChild> + <tab.icon className="size-[1.1rem]! opacity-100 hover:opacity-75" /> + </TooltipTrigger> + <TooltipContent + side="right" + align="center" + variant="sidebar" + sideOffset={8} + > + <div className="dark:text-base-gray-950 text-black text-sm font-medium leading-none dark:text-white"> + {tab.label} + </div> + </TooltipContent> + </Tooltip> </div> ); })} </div> - <ScrollButton - direction="right" - onClick={scrollToEnd} - isVisible={!isAtEnd} - /> - </div> - ); -} - -function ScrollButton({ - direction, - onClick, - isVisible, -}: { - direction: "left" | "right"; - onClick: () => void; - isVisible: boolean; -}) { - if (!isVisible) return null; - - const Icon = direction === "left" ? ChevronLeft : ChevronRight; - - return ( - <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!" - onClick={onClick} - > - <Icon className="size-4! text-foreground" /> - </Button> </div> ); } diff --git a/apps/web/src/components/editor/media-panel/views/base-view.tsx b/apps/web/src/components/editor/media-panel/views/base-view.tsx new file mode 100644 index 00000000..296295fc --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/base-view.tsx @@ -0,0 +1,59 @@ +import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+interface BaseViewProps {
+ children?: React.ReactNode;
+ defaultTab?: string;
+ tabs?: {
+ value: string;
+ label: string;
+ content: React.ReactNode;
+ }[];
+ className?: string;
+}
+
+function ViewContent({ children }: { children: React.ReactNode }) {
+ return (
+ <ScrollArea className="flex-1">
+ <div className="p-5">{children}</div>
+ </ScrollArea>
+ );
+}
+
+export function BaseView({
+ children,
+ defaultTab,
+ tabs,
+ className = "",
+}: BaseViewProps) {
+ return (
+ <div className={`h-full flex flex-col ${className}`}>
+ {!tabs || tabs.length === 0 ? (
+ <ViewContent>{children}</ViewContent>
+ ) : (
+ <Tabs defaultValue={defaultTab} className="flex flex-col h-full">
+ <div className="px-3 pt-4 pb-0">
+ <TabsList>
+ {tabs.map((tab) => (
+ <TabsTrigger key={tab.value} value={tab.value}>
+ {tab.label}
+ </TabsTrigger>
+ ))}
+ </TabsList>
+ </div>
+ <Separator className="mt-4" />
+ {tabs.map((tab) => (
+ <TabsContent
+ key={tab.value}
+ value={tab.value}
+ className="mt-0 flex-1 flex flex-col min-h-0"
+ >
+ <ViewContent>{tab.content}</ViewContent>
+ </TabsContent>
+ ))}
+ </Tabs>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx new file mode 100644 index 00000000..ef919fec --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/captions.tsx @@ -0,0 +1,9 @@ +import { BaseView } from "./base-view";
+
+export function Captions() {
+ return (
+ <BaseView>
+ <div>Captions</div>
+ </BaseView>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index 3bce1753..d33b4cce 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -7,9 +7,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { BaseView } from "./base-view"; import { PropertyItem, PropertyItemLabel, @@ -34,37 +32,37 @@ export function SettingsView() { function ProjectSettingsTabs() { return ( - <div className="h-full flex flex-col"> - <Tabs defaultValue="project-info" className="flex flex-col h-full"> - <div className="px-3 pt-4 pb-0"> - <TabsList> - <TabsTrigger value="project-info">Project info</TabsTrigger> - <TabsTrigger value="background">Background</TabsTrigger> - </TabsList> - </div> - <Separator className="my-4" /> - <ScrollArea className="flex-1"> - <TabsContent value="project-info" className="p-5 pt-0 mt-0"> - <ProjectInfoView /> - </TabsContent> - <TabsContent value="background" className="p-4 pt-0"> - <BackgroundView /> - </TabsContent> - </ScrollArea> - </Tabs> - </div> + <BaseView + defaultTab="project-info" + tabs={[ + { + value: "project-info", + label: "Project info", + content: <ProjectInfoView />, + }, + { + value: "background", + label: "Background", + content: <BackgroundView />, + }, + ]} + /> ); } function ProjectInfoView() { - const { activeProject, updateProjectFps } = useProjectStore(); - const { canvasPresets, setCanvasSize } = useEditorStore(); + const { activeProject, updateProjectFps, updateCanvasSize } = + useProjectStore(); + const { canvasPresets } = useEditorStore(); const { getDisplayName } = useAspectRatio(); const handleAspectRatioChange = (value: string) => { const preset = canvasPresets.find((p) => p.name === value); if (preset) { - setCanvasSize({ width: preset.width, height: preset.height }); + updateCanvasSize( + { width: preset.width, height: preset.height }, + "preset" + ); } }; @@ -259,7 +257,7 @@ function BackgroundView() { ); return ( - <div className="flex flex-col gap-5"> + <div className="flex flex-col gap-4"> <PropertyGroup title="Blur" defaultExpanded={false}> <div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div> </PropertyGroup> diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index ca8e88c9..49eacbc7 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,4 +1,5 @@ import { DraggableMediaItem } from "@/components/ui/draggable-item"; +import { BaseView } from "./base-view"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useTimelineStore } from "@/stores/timeline-store"; import { type TextElement } from "@/types/timeline"; @@ -28,7 +29,7 @@ const textData: TextElement = { export function TextView() { return ( - <div className="p-4"> + <BaseView> <DraggableMediaItem name="Default text" preview={ @@ -48,6 +49,6 @@ export function TextView() { } showLabel={false} /> - </div> + </BaseView> ); } diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 14b086fe..8842b482 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -14,8 +14,18 @@ import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { EditableTimecode } from "@/components/ui/editable-timecode"; import { FONT_CLASS_MAP } from "@/lib/font-config"; -import { useProjectStore } from "@/stores/project-store"; +import { DEFAULT_CANVAS_SIZE, useProjectStore } from "@/stores/project-store"; import { TextElementDragState } from "@/types/editor"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Checkbox } from "@/components/ui/checkbox"; +import { LayoutGuideOverlay } from "./layout-guide-overlay"; +import { Label } from "../ui/label"; +import { SocialsIcon } from "../icons"; +import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store"; interface ActiveElement { element: TimelineElement; @@ -26,8 +36,8 @@ interface ActiveElement { export function PreviewPanel() { const { tracks, getTotalDuration, updateTextElement } = useTimelineStore(); const { mediaItems } = useMediaStore(); - const { currentTime, toggle, setCurrentTime, isPlaying } = usePlaybackStore(); - const { canvasSize } = useEditorStore(); + const { currentTime, toggle, setCurrentTime } = usePlaybackStore(); + const { activeProject } = useProjectStore(); const previewRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null); const [previewDimensions, setPreviewDimensions] = useState({ @@ -35,7 +45,8 @@ export function PreviewPanel() { height: 0, }); const [isExpanded, setIsExpanded] = useState(false); - const { activeProject } = useProjectStore(); + + const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE; const [dragState, setDragState] = useState<TextElementDragState>({ isDragging: false, elementId: null, @@ -350,20 +361,18 @@ export function PreviewPanel() { style={{ left: `${ 50 + - ( - (dragState.isDragging && dragState.elementId === element.id - ? dragState.currentX - : element.x) / canvasSize.width - ) * + ((dragState.isDragging && dragState.elementId === element.id + ? dragState.currentX + : element.x) / + canvasSize.width) * 100 }%`, top: `${ 50 + - ( - (dragState.isDragging && dragState.elementId === element.id - ? dragState.currentY - : element.y) / canvasSize.height - ) * + ((dragState.isDragging && dragState.elementId === element.id + ? dragState.currentY + : element.y) / + canvasSize.height) * 100 }%`, transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`, @@ -498,13 +507,7 @@ export function PreviewPanel() { renderElement(elementData, index) ) )} - {activeProject?.backgroundType === "blur" && - blurBackgroundElements.length === 0 && - activeElements.length > 0 && ( - <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> - Add a video or image to use blur background - </div> - )} + <LayoutGuideOverlay /> </div> ) : null} @@ -760,13 +763,7 @@ function FullscreenPreview({ renderElement(elementData, index) ) )} - {activeProject?.backgroundType === "blur" && - blurBackgroundElements.length === 0 && - activeElements.length > 0 && ( - <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> - Add a video or image to use blur background - </div> - )} + <LayoutGuideOverlay /> </div> </div> <div className="p-4 bg-background"> @@ -801,6 +798,7 @@ function PreviewToolbar({ getTotalDuration: () => number; }) { const { isPlaying } = usePlaybackStore(); + const { layoutGuide, toggleLayoutGuide } = useEditorStore(); if (isExpanded) { return ( @@ -820,7 +818,7 @@ function PreviewToolbar({ return ( <div data-toolbar - className="flex justify-between gap-2 px-1.5 pr-4 py-1.5 border border-border/50 w-auto absolute bottom-4 right-4 bg-black/20 rounded-full backdrop-blur-l text-white" + className="flex justify-end gap-2 h-auto pb-5 pr-5 pt-4 w-full" > <div className="flex items-center gap-2"> <Button @@ -836,6 +834,54 @@ function PreviewToolbar({ <Play className="h-3 w-3" /> )} </Button> + <Popover> + <PopoverTrigger asChild> + <Button + variant="text" + size="icon" + className="h-auto p-0 mr-1" + title="Toggle layout guide" + > + <SocialsIcon className="!size-6" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-80"> + <div className="grid gap-4"> + <div className="space-y-2"> + <h4 className="font-medium leading-none">Layout guide</h4> + <p className="text-sm text-muted-foreground"> + Show platform-specific layout guides to help align your + content with interface elements like profile pictures, + usernames, and interaction buttons. + </p> + </div> + <div className="grid gap-2"> + <div className="flex items-center space-x-2"> + <Checkbox + id="none" + checked={layoutGuide.platform === null} + onCheckedChange={() => + toggleLayoutGuide(layoutGuide.platform || "tiktok") + } + /> + <Label htmlFor="none">None</Label> + </div> + {Object.entries(PLATFORM_LAYOUTS).map(([platform, label]) => ( + <div key={platform} className="flex items-center space-x-2"> + <Checkbox + id={platform} + checked={layoutGuide.platform === platform} + onCheckedChange={() => + toggleLayoutGuide(platform as PlatformLayout) + } + /> + <Label htmlFor={platform}>{label}</Label> + </div> + ))} + </div> + </div> + </PopoverContent> + </Popover> <Button variant="text" size="icon" diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx index 0a51e107..ac884b71 100644 --- a/apps/web/src/components/editor/snap-indicator.tsx +++ b/apps/web/src/components/editor/snap-indicator.tsx @@ -28,9 +28,7 @@ export function SnapIndicator({ // Track scroll position to lock snap indicator to frame useEffect(() => { - const tracksViewport = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; + const tracksViewport = tracksScrollRef.current; if (!tracksViewport) return; diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 7a2cc7f2..8bb20d72 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -53,7 +53,7 @@ import { SelectionBox } from "../selection-box"; import { useSelectionBox } from "@/hooks/use-selection-box"; import { SnapIndicator } from "../snap-indicator"; import { SnapPoint } from "@/hooks/use-timeline-snapping"; -import type { DragData, TimelineTrack } from "@/types/timeline"; +import type { DragData, TimelineTrack, TrackType } from "@/types/timeline"; import { getTrackHeight, getCumulativeHeightBefore, @@ -167,11 +167,32 @@ export function Timeline() { const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { // Only track mouse down on timeline background areas (not elements) const target = e.target as HTMLElement; + console.log( + JSON.stringify({ + debug_mousedown: "START", + target_class: target.className, + target_parent_class: target.parentElement?.className, + clientX: e.clientX, + clientY: e.clientY, + timeStamp: e.timeStamp, + }) + ); + const isTimelineBackground = !target.closest(".timeline-element") && !playheadRef.current?.contains(target) && !target.closest("[data-track-labels]"); + console.log( + JSON.stringify({ + debug_mousedown: "CHECK", + isTimelineBackground, + hasTimelineElement: !!target.closest(".timeline-element"), + hasPlayhead: !!playheadRef.current?.contains(target), + hasTrackLabels: !!target.closest("[data-track-labels]"), + }) + ); + if (isTimelineBackground) { mouseTrackingRef.current = { isMouseDown: true, @@ -179,12 +200,38 @@ export function Timeline() { downY: e.clientY, downTime: e.timeStamp, }; + console.log( + JSON.stringify({ + debug_mousedown: "TRACKED", + mouseTracking: mouseTrackingRef.current, + }) + ); + } else { + console.log( + JSON.stringify({ + debug_mousedown: "IGNORED - not timeline background", + }) + ); } }, []); // Timeline content click to seek handler const handleTimelineContentClick = useCallback( (e: React.MouseEvent) => { + console.log( + JSON.stringify({ + debug_click: "START", + target: (e.target as HTMLElement).className, + target_parent: (e.target as HTMLElement).parentElement?.className, + mouseTracking: mouseTrackingRef.current, + isSelecting, + justFinishedSelecting, + clickX: e.clientX, + clickY: e.clientY, + timeStamp: e.timeStamp, + }) + ); + const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; // Reset mouse tracking @@ -199,8 +246,8 @@ export function Timeline() { if (!isMouseDown) { console.log( JSON.stringify({ - ignoredClickWithoutMouseDown: true, - timeStamp: e.timeStamp, + debug_click: "REJECTED - no mousedown", + mouseTracking: mouseTrackingRef.current, }) ); return; @@ -214,11 +261,10 @@ export function Timeline() { if (deltaX > 5 || deltaY > 5 || deltaTime > 500) { console.log( JSON.stringify({ - ignoredDragNotClick: true, + debug_click: "REJECTED - movement too large", deltaX, deltaY, deltaTime, - timeStamp: e.timeStamp, }) ); return; @@ -226,27 +272,54 @@ export function Timeline() { // Don't seek if this was a selection box operation if (isSelecting || justFinishedSelecting) { + console.log( + JSON.stringify({ + debug_click: "REJECTED - selection operation", + isSelecting, + justFinishedSelecting, + }) + ); return; } // Don't seek if clicking on timeline elements, but still deselect if ((e.target as HTMLElement).closest(".timeline-element")) { + console.log( + JSON.stringify({ + debug_click: "REJECTED - clicked timeline element", + }) + ); return; } // Don't seek if clicking on playhead if (playheadRef.current?.contains(e.target as Node)) { + console.log( + JSON.stringify({ + debug_click: "REJECTED - clicked playhead", + }) + ); return; } // Don't seek if clicking on track labels if ((e.target as HTMLElement).closest("[data-track-labels]")) { + console.log( + JSON.stringify({ + debug_click: "REJECTED - clicked track labels", + }) + ); clearSelectedElements(); return; } // Clear selected elements when clicking empty timeline area - console.log(JSON.stringify({ clearingSelectedElements: true })); + console.log( + JSON.stringify({ + debug_click: "PROCEEDING - clearing elements", + clearingSelectedElements: true, + }) + ); clearSelectedElements(); // Determine if we're clicking in ruler or tracks area @@ -254,24 +327,39 @@ export function Timeline() { "[data-ruler-area]" ); + console.log( + JSON.stringify({ + debug_click: "CALCULATING POSITION", + isRulerClick, + clientX: e.clientX, + clientY: e.clientY, + target_element: (e.target as HTMLElement).tagName, + target_class: (e.target as HTMLElement).className, + }) + ); + 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 rulerContent = rulerScrollRef.current; + if (!rulerContent) { + console.log( + JSON.stringify({ + debug_click: "ERROR - no ruler container found", + }) + ); + 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 tracksContent = tracksScrollRef.current; + if (!tracksContent) { + return; + } const rect = tracksContent.getBoundingClientRect(); mouseX = e.clientX - rect.left; scrollLeft = tracksContent.scrollLeft; @@ -289,7 +377,6 @@ export function Timeline() { // Use frame snapping for timeline clicking const projectFps = activeProject?.fps || 30; const time = snapTimeToFrame(rawTime, projectFps); - seek(time); }, [ @@ -403,7 +490,21 @@ export function Timeline() { item.name === processedItem.name && item.url === processedItem.url ); if (addedItem) { - useTimelineStore.getState().addMediaToNewTrack(addedItem); + const trackType: TrackType = + addedItem.type === "audio" ? "audio" : "media"; + const targetTrackId = useTimelineStore + .getState() + .insertTrackAt(trackType, 0); + + useTimelineStore.getState().addElementToTrack(targetTrackId, { + type: "media", + mediaId: addedItem.id, + name: addedItem.name, + duration: addedItem.duration || 5, + startTime: currentTime, + trimStart: 0, + trimEnd: 0, + }); } } } catch (error) { @@ -426,15 +527,9 @@ export function Timeline() { // --- Scroll synchronization 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; - const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; + const rulerViewport = rulerScrollRef.current; + const tracksViewport = tracksScrollRef.current; + const trackLabelsViewport = trackLabelsScrollRef.current; if (!rulerViewport || !tracksViewport) return; diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index 695843a4..e84512ce 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -51,9 +51,7 @@ export function TimelinePlayhead({ // Track scroll position to lock playhead to frame useEffect(() => { - const tracksViewport = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; + const tracksViewport = tracksScrollRef.current; if (!tracksViewport) return; @@ -86,9 +84,7 @@ export function TimelinePlayhead({ // Get the timeline content width and viewport width for right boundary const timelineContentWidth = duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const tracksViewport = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; + const tracksViewport = tracksScrollRef.current; const viewportWidth = tracksViewport?.clientWidth || 1000; // Constrain playhead to never appear outside the timeline area diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 1982d03e..ff4797fb 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -4,11 +4,10 @@ import { useRef, useState, useEffect } from "react"; import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { toast } from "sonner"; +import { processMediaFiles } from "@/lib/media-processing"; import { TimelineElement } from "./timeline-element"; import { TimelineTrack, - sortTracksByOrder, - ensureMainTrack, getMainTrack, canElementGoOnTrack, } from "@/types/timeline"; @@ -16,6 +15,7 @@ import { usePlaybackStore } from "@/stores/playback-store"; import type { TimelineElement as TimelineElementType, DragData, + TrackType, } from "@/types/timeline"; import { snapTimeToFrame, @@ -689,8 +689,9 @@ export function TimelineTrackContent({ const hasMediaItem = e.dataTransfer.types.includes( "application/x-media-item" ); + const hasFiles = e.dataTransfer.files?.length > 0; - if (!hasTimelineElement && !hasMediaItem) return; + if (!hasTimelineElement && !hasMediaItem && !hasFiles) return; const trackContainer = e.currentTarget.querySelector( ".track-elements-container" @@ -1050,6 +1051,50 @@ export function TimelineTrackContent({ trimEnd: 0, }); } + } else if (hasFiles) { + // External file drops + const { activeProject } = useProjectStore.getState(); + const { addMediaItem } = useMediaStore.getState(); + const { addElementToTrack } = useTimelineStore.getState(); + + if (!activeProject) { + toast.error("No active project"); + return; + } + + // Process and add files to new timeline tracks at playhead position + processMediaFiles(e.dataTransfer.files) + .then(async (processedItems) => { + for (const processedItem of processedItems) { + await addMediaItem(activeProject.id, processedItem); + const currentMediaItems = useMediaStore.getState().mediaItems; + const addedItem = currentMediaItems.find( + (item) => + item.name === processedItem.name && + item.url === processedItem.url + ); + + if (addedItem) { + const trackType: TrackType = + addedItem.type === "audio" ? "audio" : "media"; + const targetTrackId = insertTrackAt(trackType, 0); + + addElementToTrack(targetTrackId, { + type: "media", + mediaId: addedItem.id, + name: addedItem.name, + duration: addedItem.duration || 5, + startTime: currentTime, + trimStart: 0, + trimEnd: 0, + }); + } + } + }) + .catch((error) => { + console.error("Error processing external files:", error); + toast.error("Failed to process dropped files"); + }); } } catch (error) { console.error("Error handling drop:", error); |
