summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor')
-rw-r--r--apps/web/src/components/editor/layout-guide-overlay.tsx27
-rw-r--r--apps/web/src/components/editor/media-panel/index.tsx7
-rw-r--r--apps/web/src/components/editor/media-panel/tabbar.tsx118
-rw-r--r--apps/web/src/components/editor/media-panel/views/base-view.tsx59
-rw-r--r--apps/web/src/components/editor/media-panel/views/captions.tsx9
-rw-r--r--apps/web/src/components/editor/media-panel/views/settings.tsx50
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx5
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx104
-rw-r--r--apps/web/src/components/editor/snap-indicator.tsx4
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx147
-rw-r--r--apps/web/src/components/editor/timeline/timeline-playhead.tsx8
-rw-r--r--apps/web/src/components/editor/timeline/timeline-track.tsx51
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);