summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-07-06 20:45:29 +0200
committerMaze Winther <mazewinther@gmail.com>2025-07-06 20:45:29 +0200
commit40c7fbb4f86130c83fbd9029e83c5e7550e3219d (patch)
tree054d918d282358c084f4fc08462913833de644a5
parent0e32c732ddc335d35e6a7f8547f4af68bfd9c369 (diff)
downloadopencut-40c7fbb4f86130c83fbd9029e83c5e7550e3219d.tar.gz
refactor: move to a typed-tracks system and add support for text
-rw-r--r--apps/web/src/app/contributors/page.tsx2
-rw-r--r--apps/web/src/components/development-debug.tsx56
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx16
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx1
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx222
-rw-r--r--apps/web/src/components/editor/properties-panel.tsx30
-rw-r--r--apps/web/src/components/editor/timeline-element.tsx (renamed from apps/web/src/components/editor/timeline-clip.tsx)209
-rw-r--r--apps/web/src/components/editor/timeline-toolbar.tsx2
-rw-r--r--apps/web/src/components/editor/timeline-track.tsx1545
-rw-r--r--apps/web/src/components/editor/timeline.tsx468
-rw-r--r--apps/web/src/components/footer.tsx2
-rw-r--r--apps/web/src/components/header.tsx2
-rw-r--r--apps/web/src/components/landing/hero.tsx7
-rw-r--r--apps/web/src/components/ui/draggable-item.tsx18
-rw-r--r--apps/web/src/hooks/use-drag-clip.ts89
-rw-r--r--apps/web/src/hooks/use-playback-controls.ts91
-rw-r--r--apps/web/src/lib/fetch-github-stars.ts (renamed from apps/web/src/lib/fetchGhStars.ts)2
-rw-r--r--apps/web/src/stores/media-store.ts38
-rw-r--r--apps/web/src/stores/timeline-store.ts387
-rw-r--r--apps/web/src/types/timeline.ts73
20 files changed, 1795 insertions, 1465 deletions
diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx
index feee2513..778df181 100644
--- a/apps/web/src/app/contributors/page.tsx
+++ b/apps/web/src/app/contributors/page.tsx
@@ -47,7 +47,7 @@ async function getContributors(): Promise<Contributor[]> {
return [];
}
- const contributors = await response.json();
+ const contributors = (await response.json()) as Contributor[];
const filteredContributors = contributors.filter(
(contributor: Contributor) => contributor.type === "User"
diff --git a/apps/web/src/components/development-debug.tsx b/apps/web/src/components/development-debug.tsx
index aef69999..f407e2e0 100644
--- a/apps/web/src/components/development-debug.tsx
+++ b/apps/web/src/components/development-debug.tsx
@@ -1,20 +1,17 @@
"use client";
-import {
- useTimelineStore,
- type TimelineClip,
- type TimelineTrack,
-} from "@/stores/timeline-store";
+import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { Button } from "@/components/ui/button";
import { useState } from "react";
+import type { TimelineElement } from "@/types/timeline";
// Only show in development
const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
-interface ActiveClip {
- clip: TimelineClip;
+interface ActiveElement {
+ element: TimelineElement;
track: TimelineTrack;
mediaItem: MediaItem | null;
}
@@ -28,31 +25,32 @@ export function DevelopmentDebug() {
// Don't render anything in production
if (!SHOW_DEBUG_INFO) return null;
- // Get active clips at current time
- const getActiveClips = (): ActiveClip[] => {
- const activeClips: ActiveClip[] = [];
+ // Get active elements at current time
+ const getActiveElements = (): ActiveElement[] => {
+ const activeElements: ActiveElement[] = [];
tracks.forEach((track) => {
- track.clips.forEach((clip) => {
- const clipStart = clip.startTime;
- const clipEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ track.elements.forEach((element) => {
+ const elementStart = element.startTime;
+ const elementEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (currentTime >= clipStart && currentTime < clipEnd) {
+ if (currentTime >= elementStart && currentTime < elementEnd) {
const mediaItem =
- clip.mediaId === "test"
- ? null // Test clips don't have a real media item
- : mediaItems.find((item) => item.id === clip.mediaId) || null;
+ element.type === "media"
+ ? mediaItems.find((item) => item.id === element.mediaId) || null
+ : null; // Text elements don't have media items
- activeClips.push({ clip, track, mediaItem });
+ activeElements.push({ element, track, mediaItem });
}
});
});
- return activeClips;
+ return activeElements;
};
- const activeClips = getActiveClips();
+ const activeElements = getActiveElements();
return (
<div className="fixed bottom-4 right-4 z-50">
@@ -71,28 +69,30 @@ export function DevelopmentDebug() {
{showDebug && (
<div className="backdrop-blur-md bg-background/90 border border-border/50 rounded-lg p-3 max-w-sm">
<div className="text-xs font-medium mb-2 text-foreground">
- Active Clips ({activeClips.length})
+ Active Elements ({activeElements.length})
</div>
<div className="space-y-1 max-h-40 overflow-y-auto">
- {activeClips.map((clipData, index) => (
+ {activeElements.map((elementData, index) => (
<div
- key={clipData.clip.id}
+ key={elementData.element.id}
className="flex items-center gap-2 px-2 py-1 bg-muted/60 rounded text-xs"
>
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4 flex-shrink-0">
{index + 1}
</span>
<div className="min-w-0 flex-1">
- <div className="truncate">{clipData.clip.name}</div>
+ <div className="truncate">{elementData.element.name}</div>
<div className="text-muted-foreground text-[10px]">
- {clipData.mediaItem?.type || "test"}
+ {elementData.element.type === "media"
+ ? elementData.mediaItem?.type || "media"
+ : "text"}
</div>
</div>
</div>
))}
- {activeClips.length === 0 && (
+ {activeElements.length === 0 && (
<div className="text-muted-foreground text-xs py-2 text-center">
- No active clips
+ No active elements
</div>
)}
</div>
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx
index 50fb6fa6..f975496e 100644
--- a/apps/web/src/components/editor/media-panel/views/media.tsx
+++ b/apps/web/src/components/editor/media-panel/views/media.tsx
@@ -73,18 +73,22 @@ export function MediaView() {
// Remove a media item from the store
e.stopPropagation();
- // Remove tracks automatically when delete media
+ // Remove elements automatically when delete media
const { tracks, removeTrack } = useTimelineStore.getState();
tracks.forEach((track) => {
- const clipsToRemove = track.clips.filter((clip) => clip.mediaId === id);
- clipsToRemove.forEach((clip) => {
- useTimelineStore.getState().removeClipFromTrack(track.id, clip.id);
+ const elementsToRemove = track.elements.filter(
+ (element) => element.type === "media" && element.mediaId === id
+ );
+ elementsToRemove.forEach((element) => {
+ useTimelineStore
+ .getState()
+ .removeElementFromTrack(track.id, element.id);
});
- // Only remove track if it becomes empty and has no other clips
+ // Only remove track if it becomes empty and has no other elements
const updatedTrack = useTimelineStore
.getState()
.tracks.find((t) => t.id === track.id);
- if (updatedTrack && updatedTrack.clips.length === 0) {
+ if (updatedTrack && updatedTrack.elements.length === 0) {
removeTrack(track.id);
}
});
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 ab698528..bec32c85 100644
--- a/apps/web/src/components/editor/media-panel/views/text.tsx
+++ b/apps/web/src/components/editor/media-panel/views/text.tsx
@@ -17,7 +17,6 @@ export function TextView() {
content: "Default text",
}}
aspectRatio={1}
- className="w-24"
showLabel={false}
/>
</div>
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index eba8ac04..9a02560b 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -2,9 +2,9 @@
import {
useTimelineStore,
- type TimelineClip,
type TimelineTrack,
} from "@/stores/timeline-store";
+import { TimelineElement } from "@/types/timeline";
import {
useMediaStore,
type MediaItem,
@@ -21,13 +21,13 @@ import {
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
-import { Play, Pause, Volume2, VolumeX, Plus, Square } from "lucide-react";
+import { Play, Pause } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
-interface ActiveClip {
- clip: TimelineClip;
+interface ActiveElement {
+ element: TimelineElement;
track: TimelineTrack;
mediaItem: MediaItem | null;
}
@@ -35,8 +35,8 @@ interface ActiveClip {
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
- const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
- const { canvasSize, canvasPresets, setCanvasSize } = useEditorStore();
+ const { currentTime } = usePlaybackStore();
+ const { canvasSize } = useEditorStore();
const previewRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [previewDimensions, setPreviewDimensions] = useState({
@@ -104,97 +104,139 @@ export function PreviewPanel() {
return () => resizeObserver.disconnect();
}, [canvasSize.width, canvasSize.height]);
- // Get active clips at current time
- const getActiveClips = (): ActiveClip[] => {
- const activeClips: ActiveClip[] = [];
+ // Get active elements at current time
+ const getActiveElements = (): ActiveElement[] => {
+ const activeElements: ActiveElement[] = [];
tracks.forEach((track) => {
- track.clips.forEach((clip) => {
- const clipStart = clip.startTime;
- const clipEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ track.elements.forEach((element) => {
+ const elementStart = element.startTime;
+ const elementEnd =
+ element.startTime + (element.duration - element.trimStart - element.trimEnd);
- if (currentTime >= clipStart && currentTime < clipEnd) {
- const mediaItem =
- clip.mediaId === "test"
- ? null // Test clips don't have a real media item
- : mediaItems.find((item) => item.id === clip.mediaId) || null;
+ if (currentTime >= elementStart && currentTime < elementEnd) {
+ let mediaItem = null;
+
+ // Only get media item for media elements
+ if (element.type === "media") {
+ mediaItem = element.mediaId === "test"
+ ? null // Test elements don't have a real media item
+ : mediaItems.find((item) => item.id === element.mediaId) || null;
+ }
- activeClips.push({ clip, track, mediaItem });
+ activeElements.push({ element, track, mediaItem });
}
});
});
- return activeClips;
+ return activeElements;
};
- const activeClips = getActiveClips();
+ const activeElements = getActiveElements();
- // Check if there are any clips in the timeline at all
- const hasAnyClips = tracks.some((track) => track.clips.length > 0);
+ // Check if there are any elements in the timeline at all
+ const hasAnyElements = tracks.some((track) => track.elements.length > 0);
- // Render a clip
- const renderClip = (clipData: ActiveClip, index: number) => {
- const { clip, mediaItem } = clipData;
+ // Render an element
+ const renderElement = (elementData: ActiveElement, index: number) => {
+ const { element, mediaItem } = elementData;
- // Test clips
- if (!mediaItem || clip.mediaId === "test") {
+ // Text elements
+ if (element.type === "text") {
return (
<div
- key={clip.id}
- className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
+ key={element.id}
+ className="absolute flex items-center justify-center"
+ style={{
+ left: `${50 + (element.x / canvasSize.width) * 100}%`,
+ top: `${50 + (element.y / canvasSize.height) * 100}%`,
+ transform: `translate(-50%, -50%) rotate(${element.rotation}deg)`,
+ opacity: element.opacity,
+ zIndex: 100 + index, // Text elements on top
+ }}
>
- <div className="text-center">
- <div className="text-2xl mb-2">🎬</div>
- <p className="text-xs text-white">{clip.name}</p>
+ <div
+ style={{
+ fontSize: `${element.fontSize}px`,
+ fontFamily: element.fontFamily,
+ color: element.color,
+ backgroundColor: element.backgroundColor,
+ textAlign: element.textAlign,
+ fontWeight: element.fontWeight,
+ fontStyle: element.fontStyle,
+ textDecoration: element.textDecoration,
+ padding: '4px 8px',
+ borderRadius: '2px',
+ whiteSpace: 'pre-wrap',
+ }}
+ >
+ {element.content}
</div>
</div>
);
}
- // Video clips
- if (mediaItem.type === "video") {
- return (
- <div key={clip.id} className="absolute inset-0">
- <VideoPlayer
- src={mediaItem.url}
- poster={mediaItem.thumbnailUrl}
- clipStartTime={clip.startTime}
- trimStart={clip.trimStart}
- trimEnd={clip.trimEnd}
- clipDuration={clip.duration}
- />
- </div>
- );
- }
+ // Media elements
+ if (element.type === "media") {
+ // Test elements
+ if (!mediaItem || element.mediaId === "test") {
+ return (
+ <div
+ key={element.id}
+ className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
+ >
+ <div className="text-center">
+ <div className="text-2xl mb-2">🎬</div>
+ <p className="text-xs text-white">{element.name}</p>
+ </div>
+ </div>
+ );
+ }
- // Image clips
- if (mediaItem.type === "image") {
- return (
- <div key={clip.id} className="absolute inset-0">
- <img
- src={mediaItem.url}
- alt={mediaItem.name}
- className="w-full h-full object-cover"
- draggable={false}
- />
- </div>
- );
- }
+ // Video elements
+ if (mediaItem.type === "video") {
+ return (
+ <div key={element.id} className="absolute inset-0">
+ <VideoPlayer
+ src={mediaItem.url!}
+ poster={mediaItem.thumbnailUrl}
+ clipStartTime={element.startTime}
+ trimStart={element.trimStart}
+ trimEnd={element.trimEnd}
+ clipDuration={element.duration}
+ />
+ </div>
+ );
+ }
- // Audio clips (visual representation)
- if (mediaItem.type === "audio") {
- return (
- <div
- key={clip.id}
- className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
- >
- <div className="text-center">
- <div className="text-2xl mb-2">🎵</div>
- <p className="text-xs text-white">{mediaItem.name}</p>
+ // Image elements
+ if (mediaItem.type === "image") {
+ return (
+ <div key={element.id} className="absolute inset-0">
+ <img
+ src={mediaItem.url!}
+ alt={mediaItem.name}
+ className="w-full h-full object-cover"
+ draggable={false}
+ />
</div>
- </div>
- );
+ );
+ }
+
+ // Audio elements (visual representation)
+ if (mediaItem.type === "audio") {
+ return (
+ <div
+ key={element.id}
+ className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
+ >
+ <div className="text-center">
+ <div className="text-2xl mb-2">🎵</div>
+ <p className="text-xs text-white">{mediaItem.name}</p>
+ </div>
+ </div>
+ );
+ }
}
return null;
@@ -206,7 +248,7 @@ export function PreviewPanel() {
ref={containerRef}
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
>
- {hasAnyClips ? (
+ {hasAnyElements ? (
<div
ref={previewRef}
className="relative overflow-hidden rounded-sm bg-black border"
@@ -215,12 +257,12 @@ export function PreviewPanel() {
height: previewDimensions.height,
}}
>
- {activeClips.length === 0 ? (
+ {activeElements.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
- No clips at current time
+ No elements at current time
</div>
) : (
- activeClips.map((clipData, index) => renderClip(clipData, index))
+ activeElements.map((elementData, index) => renderElement(elementData, index))
)}
</div>
) : (
@@ -230,13 +272,13 @@ export function PreviewPanel() {
</>
)}
- <PreviewToolbar hasAnyClips={hasAnyClips} />
+ <PreviewToolbar hasAnyElements={hasAnyElements} />
</div>
</div>
);
}
-function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {
+function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
const { isPlaying, toggle, currentTime } = usePlaybackStore();
const {
canvasSize,
@@ -261,13 +303,15 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {
const getOriginalAspectRatio = () => {
// Find first video or image in timeline
for (const track of tracks) {
- for (const clip of track.clips) {
- const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
- if (
- mediaItem &&
- (mediaItem.type === "video" || mediaItem.type === "image")
- ) {
- return getMediaAspectRatio(mediaItem);
+ for (const element of track.elements) {
+ if (element.type === "media") {
+ const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
+ if (
+ mediaItem &&
+ (mediaItem.type === "video" || mediaItem.type === "image")
+ ) {
+ return getMediaAspectRatio(mediaItem);
+ }
}
}
}
@@ -291,7 +335,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {
<p
className={cn(
"text-xs text-muted-foreground tabular-nums",
- !hasAnyClips && "opacity-50"
+ !hasAnyElements && "opacity-50"
)}
>
{formatTimeCode(currentTime, "HH:MM:SS:CS")}/
@@ -302,7 +346,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {
variant="text"
size="icon"
onClick={toggle}
- disabled={!hasAnyClips}
+ disabled={!hasAnyElements}
>
{isPlaying ? (
<Pause className="h-3 w-3" />
@@ -316,7 +360,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {
<Button
size="sm"
className="!bg-background text-foreground/85 text-xs h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light"
- disabled={!hasAnyClips}
+ disabled={!hasAnyElements}
>
{currentPreset?.name || "Ratio"}
</Button>
diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx
index 96ef66e5..0de78988 100644
--- a/apps/web/src/components/editor/properties-panel.tsx
+++ b/apps/web/src/components/editor/properties-panel.tsx
@@ -25,27 +25,29 @@ export function PropertiesPanel() {
const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
const [backgroundColor, setBackgroundColor] = useState("#000000");
- // Get the first video clip for preview (simplified)
- const firstVideoClip = tracks
- .flatMap((track) => track.clips)
- .find((clip) => {
- const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
+ // Get the first video element for preview (simplified)
+ const firstVideoElement = tracks
+ .flatMap((track) => track.elements)
+ .find((element) => {
+ if (element.type !== "media") return false;
+ const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
return mediaItem?.type === "video";
});
- const firstVideoItem = firstVideoClip
- ? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
+ const firstVideoItem = firstVideoElement && firstVideoElement.type === "media"
+ ? mediaItems.find((item) => item.id === firstVideoElement.mediaId)
: null;
- const firstImageClip = tracks
- .flatMap((track) => track.clips)
- .find((clip) => {
- const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
+ const firstImageElement = tracks
+ .flatMap((track) => track.elements)
+ .find((element) => {
+ if (element.type !== "media") return false;
+ const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
return mediaItem?.type === "image";
});
- const firstImageItem = firstImageClip
- ? mediaItems.find((item) => item.id === firstImageClip.mediaId)
+ const firstImageItem = firstImageElement && firstImageElement.type === "media"
+ ? mediaItems.find((item) => item.id === firstImageElement.mediaId)
: null;
return (
@@ -62,7 +64,7 @@ export function PropertiesPanel() {
<Label>Preview</Label>
<div className="w-full aspect-video max-w-48">
<ImageTimelineTreatment
- src={firstImageItem.url}
+ src={firstImageItem.url!}
alt={firstImageItem.name}
targetAspectRatio={16 / 9}
className="rounded-sm border"
diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-element.tsx
index ddc58f8d..fe2f6bcc 100644
--- a/apps/web/src/components/editor/timeline-clip.tsx
+++ b/apps/web/src/components/editor/timeline-element.tsx
@@ -10,13 +10,14 @@ import {
Music,
ChevronRight,
ChevronLeft,
+ Type,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "./audio-waveform";
import { toast } from "sonner";
-import { TimelineClipProps, ResizeState } from "@/types/timeline";
+import { TimelineElementProps, ResizeState, TrackType } from "@/types/timeline";
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,23 +28,21 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "../ui/dropdown-menu";
-import { isDragging } from "motion/react";
-export function TimelineClip({
- clip,
+export function TimelineElement({
+ element,
track,
zoomLevel,
isSelected,
- onClipMouseDown,
- onClipClick,
-}: TimelineClipProps) {
+ onElementMouseDown,
+ onElementClick,
+}: TimelineElementProps) {
const { mediaItems } = useMediaStore();
const {
- updateClipTrim,
- addClipToTrack,
- removeClipFromTrack,
+ updateElementTrim,
+ removeElementFromTrack,
dragState,
- splitClip,
+ splitElement,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
@@ -51,47 +50,48 @@ export function TimelineClip({
const { currentTime } = usePlaybackStore();
const [resizing, setResizing] = useState<ResizeState | null>(null);
- const [clipMenuOpen, setClipMenuOpen] = useState(false);
+ const [elementMenuOpen, setElementMenuOpen] = useState(false);
- const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
- const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
+ const effectiveDuration =
+ element.duration - element.trimStart - element.trimEnd;
+ const elementWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
// Use real-time position during drag, otherwise use stored position
- const isBeingDragged = dragState.clipId === clip.id;
- const clipStartTime =
+ const isBeingDragged = dragState.elementId === element.id;
+ const elementStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
- : clip.startTime;
- const clipLeft = clipStartTime * 50 * zoomLevel;
+ : element.startTime;
+ const elementLeft = elementStartTime * 50 * zoomLevel;
- const getTrackColor = (type: string) => {
+ const getTrackColor = (type: TrackType) => {
switch (type) {
- case "video":
+ case "media":
return "bg-blue-500/20 border-blue-500/30";
+ case "text":
+ return "bg-purple-500/20 border-purple-500/30";
case "audio":
return "bg-green-500/20 border-green-500/30";
- case "effects":
- return "bg-purple-500/20 border-purple-500/30";
default:
return "bg-gray-500/20 border-gray-500/30";
}
};
- // Resize handles for trimming clips
+ // Resize handles for trimming elements
const handleResizeStart = (
e: React.MouseEvent,
- clipId: string,
+ elementId: string,
side: "left" | "right"
) => {
e.stopPropagation();
e.preventDefault();
setResizing({
- clipId,
+ elementId,
side,
startX: e.clientX,
- initialTrimStart: clip.trimStart,
- initialTrimEnd: clip.trimEnd,
+ initialTrimStart: element.trimStart,
+ initialTrimEnd: element.trimEnd,
});
};
@@ -105,20 +105,20 @@ export function TimelineClip({
const newTrimStart = Math.max(
0,
Math.min(
- clip.duration - clip.trimEnd - 0.1,
+ element.duration - element.trimEnd - 0.1,
resizing.initialTrimStart + deltaTime
)
);
- updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd);
+ updateElementTrim(track.id, element.id, newTrimStart, element.trimEnd);
} else {
const newTrimEnd = Math.max(
0,
Math.min(
- clip.duration - clip.trimStart - 0.1,
+ element.duration - element.trimStart - 0.1,
resizing.initialTrimEnd - deltaTime
)
);
- updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd);
+ updateElementTrim(track.id, element.id, element.trimStart, newTrimEnd);
}
};
@@ -130,96 +130,111 @@ export function TimelineClip({
setResizing(null);
};
- const handleDeleteClip = () => {
- removeClipFromTrack(track.id, clip.id);
- setClipMenuOpen(false);
- toast.success("Clip deleted");
+ const handleDeleteElement = () => {
+ removeElementFromTrack(track.id, element.id);
+ setElementMenuOpen(false);
};
- const handleSplitClip = () => {
- const effectiveStart = clip.startTime;
+ const handleSplitElement = () => {
+ const effectiveStart = element.startTime;
const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within clip to split");
+ toast.error("Playhead must be within element to split");
return;
}
- const secondClipId = splitClip(track.id, clip.id, currentTime);
- if (secondClipId) {
- toast.success("Clip split successfully");
- } else {
- toast.error("Failed to split clip");
+ const secondElementId = splitElement(track.id, element.id, currentTime);
+ if (!secondElementId) {
+ toast.error("Failed to split element");
}
- setClipMenuOpen(false);
+ setElementMenuOpen(false);
};
const handleSplitAndKeepLeft = () => {
- const effectiveStart = clip.startTime;
+ const effectiveStart = element.startTime;
const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within clip");
+ toast.error("Playhead must be within element");
return;
}
- splitAndKeepLeft(track.id, clip.id, currentTime);
- toast.success("Split and kept left portion");
- setClipMenuOpen(false);
+ splitAndKeepLeft(track.id, element.id, currentTime);
+ setElementMenuOpen(false);
};
const handleSplitAndKeepRight = () => {
- const effectiveStart = clip.startTime;
+ const effectiveStart = element.startTime;
const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within clip");
+ toast.error("Playhead must be within element");
return;
}
- splitAndKeepRight(track.id, clip.id, currentTime);
- toast.success("Split and kept right portion");
- setClipMenuOpen(false);
+ splitAndKeepRight(track.id, element.id, currentTime);
+ setElementMenuOpen(false);
};
const handleSeparateAudio = () => {
- const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
+ if (element.type !== "media") {
+ toast.error("Audio separation only available for media elements");
+ return;
+ }
+ const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
if (!mediaItem || mediaItem.type !== "video") {
- toast.error("Audio separation only available for video clips");
+ toast.error("Audio separation only available for video elements");
return;
}
- const audioClipId = separateAudio(track.id, clip.id);
- if (audioClipId) {
- toast.success("Audio separated to audio track");
- } else {
+ const audioElementId = separateAudio(track.id, element.id);
+ if (!audioElementId) {
toast.error("Failed to separate audio");
}
- setClipMenuOpen(false);
+ setElementMenuOpen(false);
};
const canSplitAtPlayhead = () => {
- const effectiveStart = clip.startTime;
+ const effectiveStart = element.startTime;
const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
return currentTime > effectiveStart && currentTime < effectiveEnd;
};
const canSeparateAudio = () => {
- const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
- return mediaItem?.type === "video" && track.type === "video";
+ if (element.type !== "media") return false;
+ const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
+ return mediaItem?.type === "video" && track.type === "media";
};
- const renderClipContent = () => {
- const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
+ const renderElementContent = () => {
+ if (element.type === "text") {
+ return (
+ <div className="w-full h-full flex items-center justify-center px-2">
+ <Type className="h-4 w-4 mr-2 text-purple-400 flex-shrink-0" />
+ <span className="text-xs text-foreground/80 truncate">
+ {element.content}
+ </span>
+ </div>
+ );
+ }
+ // Render media element ->
+ const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
if (!mediaItem) {
return (
- <span className="text-xs text-foreground/80 truncate">{clip.name}</span>
+ <span className="text-xs text-foreground/80 truncate">
+ {element.name}
+ </span>
);
}
@@ -248,18 +263,19 @@ export function TimelineClip({
/>
</div>
<span className="text-xs text-foreground/80 truncate flex-1">
- {clip.name}
+ {element.name}
</span>
</div>
);
}
+ // Render audio element ->
if (mediaItem.type === "audio") {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="flex-1 min-w-0">
<AudioWaveform
- audioUrl={mediaItem.url}
+ audioUrl={mediaItem.url || ""}
height={24}
className="w-full"
/>
@@ -269,24 +285,26 @@ export function TimelineClip({
}
return (
- <span className="text-xs text-foreground/80 truncate">{clip.name}</span>
+ <span className="text-xs text-foreground/80 truncate">
+ {element.name}
+ </span>
);
};
- const handleClipMouseDown = (e: React.MouseEvent) => {
- if (onClipMouseDown) {
- onClipMouseDown(e, clip);
+ const handleElementMouseDown = (e: React.MouseEvent) => {
+ if (onElementMouseDown) {
+ onElementMouseDown(e, element);
}
};
return (
<div
- className={`absolute top-0 h-full select-none transition-all duration-75${
+ className={`absolute top-0 h-full select-none timeline-element ${
isBeingDragged ? "z-50" : "z-10"
}`}
style={{
- left: `${clipLeft}px`,
- width: `${clipWidth}px`,
+ left: `${elementLeft}px`,
+ width: `${elementWidth}px`,
}}
onMouseMove={resizing ? handleResizeMove : undefined}
onMouseUp={resizing ? handleResizeEnd : undefined}
@@ -298,29 +316,34 @@ export function TimelineClip({
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
isBeingDragged ? "z-50" : "z-10"
}`}
- onClick={(e) => onClipClick && onClipClick(e, clip)}
- onMouseDown={handleClipMouseDown}
- onContextMenu={(e) => onClipMouseDown && onClipMouseDown(e, clip)}
+ onClick={(e) => onElementClick && onElementClick(e, element)}
+ onMouseDown={handleElementMouseDown}
+ onContextMenu={(e) =>
+ onElementMouseDown && onElementMouseDown(e, element)
+ }
>
<div className="absolute inset-1 flex items-center p-1">
- {renderClipContent()}
+ {renderElementContent()}
</div>
{isSelected && (
<>
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
- onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
+ onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
/>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
- onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
+ onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
/>
</>
)}
<div className="absolute top-1 right-1">
- <DropdownMenu open={clipMenuOpen} onOpenChange={setClipMenuOpen}>
+ <DropdownMenu
+ open={elementMenuOpen}
+ onOpenChange={setElementMenuOpen}
+ >
<DropdownMenuTrigger asChild>
<Button
variant="outline"
@@ -328,21 +351,21 @@ export function TimelineClip({
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background"
onClick={(e) => {
e.stopPropagation();
- setClipMenuOpen(true);
+ setElementMenuOpen(true);
}}
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
- {/* Split operations - only available when playhead is within clip */}
+ {/* Split operations - only available when playhead is within element */}
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={!canSplitAtPlayhead()}>
<Scissors className="mr-2 h-4 w-4" />
Split
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
- <DropdownMenuItem onClick={handleSplitClip}>
+ <DropdownMenuItem onClick={handleSplitElement}>
<SplitSquareHorizontal className="mr-2 h-4 w-4" />
Split at Playhead
</DropdownMenuItem>
@@ -357,7 +380,7 @@ export function TimelineClip({
</DropdownMenuSubContent>
</DropdownMenuSub>
- {/* Audio separation - only available for video clips */}
+ {/* Audio separation - only available for video elements */}
{canSeparateAudio() && (
<>
<DropdownMenuSeparator />
@@ -370,11 +393,11 @@ export function TimelineClip({
<DropdownMenuSeparator />
<DropdownMenuItem
- onClick={handleDeleteClip}
+ onClick={handleDeleteElement}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
- Delete Clip
+ Delete {element.type === "text" ? "text" : "clip"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
diff --git a/apps/web/src/components/editor/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline-toolbar.tsx
index 51464794..e887efa0 100644
--- a/apps/web/src/components/editor/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/timeline-toolbar.tsx
@@ -102,7 +102,7 @@ export function TimelineToolbar({
variant="outline"
size="sm"
onClick={() => {
- const trackId = addTrack("video");
+ const trackId = addTrack("media");
addClipToTrack(trackId, {
mediaId: "test",
name: "Test Clip",
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx
index 4d32511f..dd4219f9 100644
--- a/apps/web/src/components/editor/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline-track.tsx
@@ -1,690 +1,855 @@
-"use client";
-
-import { useRef, useState, useEffect } from "react";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { toast } from "sonner";
-import { Copy, Scissors, Trash2 } from "lucide-react";
-import { TimelineClip } from "./timeline-clip";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
-} from "../ui/context-menu";
-import {
- TimelineTrack,
- TimelineClip as TypeTimelineClip,
-} from "@/stores/timeline-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-
-export function TimelineTrackContent({
- track,
- zoomLevel,
-}: {
- track: TimelineTrack;
- zoomLevel: number;
-}) {
- const { mediaItems } = useMediaStore();
- const {
- tracks,
- moveClipToTrack,
- updateClipStartTime,
- addClipToTrack,
- selectedClips,
- selectClip,
- deselectClip,
- dragState,
- startDrag: startDragAction,
- updateDragTime,
- endDrag: endDragAction,
- } = useTimelineStore();
-
- const timelineRef = useRef<HTMLDivElement>(null);
- const [isDropping, setIsDropping] = useState(false);
- const [dropPosition, setDropPosition] = useState<number | null>(null);
- const [wouldOverlap, setWouldOverlap] = useState(false);
- const dragCounterRef = useRef(0);
- const [mouseDownLocation, setMouseDownLocation] = useState<{
- x: number;
- y: number;
- } | null>(null);
-
- // Set up mouse event listeners for drag
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!timelineRef.current) return;
-
- // On first mouse move during drag, ensure the clip is selected
- if (dragState.clipId && dragState.trackId) {
- const isSelected = selectedClips.some(
- (c) =>
- c.trackId === dragState.trackId && c.clipId === dragState.clipId
- );
-
- if (!isSelected) {
- // Select this clip (replacing other selections) since we're dragging it
- selectClip(dragState.trackId, dragState.clipId, false);
- }
- }
-
- const timelineRect = timelineRef.current.getBoundingClientRect();
- const mouseX = e.clientX - timelineRect.left;
- const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
- const snappedTime = Math.round(adjustedTime * 10) / 10;
-
- updateDragTime(snappedTime);
- };
-
- const handleMouseUp = () => {
- if (!dragState.clipId || !dragState.trackId) return;
-
- const finalTime = dragState.currentTime;
-
- // Check for overlaps and update position
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingClip = sourceTrack?.clips.find(
- (c) => c.id === dragState.clipId
- );
-
- if (movingClip) {
- const movingClipDuration =
- movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
- const movingClipEnd = finalTime + movingClipDuration;
-
- const targetTrack = tracks.find((t) => t.id === track.id);
- const hasOverlap = targetTrack?.clips.some((existingClip) => {
- if (
- dragState.trackId === track.id &&
- existingClip.id === dragState.clipId
- ) {
- return false;
- }
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
- return finalTime < existingEnd && movingClipEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (dragState.trackId === track.id) {
- updateClipStartTime(track.id, dragState.clipId, finalTime);
- } else {
- moveClipToTrack(dragState.trackId, track.id, dragState.clipId);
- requestAnimationFrame(() => {
- updateClipStartTime(track.id, dragState.clipId!, finalTime);
- });
- }
- }
- }
-
- endDragAction();
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }, [
- dragState.isDragging,
- dragState.clickOffsetTime,
- dragState.clipId,
- dragState.trackId,
- dragState.currentTime,
- zoomLevel,
- tracks,
- track.id,
- updateDragTime,
- updateClipStartTime,
- moveClipToTrack,
- endDragAction,
- ]);
-
- const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => {
- setMouseDownLocation({ x: e.clientX, y: e.clientY });
-
- // Detect right-click (button 2) and handle selection without starting drag
- const isRightClick = e.button === 2;
- const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
-
- if (isRightClick) {
- // Handle right-click selection
- const isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- // If clip is not selected, select it (keep other selections if multi-select)
- if (!isSelected) {
- selectClip(track.id, clip.id, isMultiSelect);
- }
- // If clip is already selected, keep it selected
-
- // Don't start drag action for right-clicks
- return;
- }
-
- // Handle multi-selection for left-click with modifiers
- if (isMultiSelect) {
- selectClip(track.id, clip.id, true);
- }
-
- // Calculate the offset from the left edge of the clip to where the user clicked
- const clipElement = e.currentTarget as HTMLElement;
- const clipRect = clipElement.getBoundingClientRect();
- const clickOffsetX = e.clientX - clipRect.left;
- const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
-
- startDragAction(
- clip.id,
- track.id,
- e.clientX,
- clip.startTime,
- clickOffsetTime
- );
- };
-
- const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => {
- e.stopPropagation();
-
- // Check if mouse moved significantly
- if (mouseDownLocation) {
- const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
- const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
- // If it moved more than a few pixels, consider it a drag and not a click.
- if (deltaX > 5 || deltaY > 5) {
- setMouseDownLocation(null); // Reset for next interaction
- return;
- }
- }
-
- // Skip selection logic for multi-selection (handled in mousedown)
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- return;
- }
-
- // Handle single selection
- const isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- if (!isSelected) {
- // If clip is not selected, select it (replacing other selections)
- selectClip(track.id, clip.id, false);
- }
- // If clip is already selected, keep it selected (do nothing)
- };
-
- const handleTrackDragOver = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Handle both timeline clips and media items
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineClip && !hasMediaItem) return;
-
- if (hasMediaItem) {
- try {
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (mediaItemData) {
- const { type } = JSON.parse(mediaItemData);
- const isCompatible =
- (track.type === "video" &&
- (type === "video" || type === "image")) ||
- (track.type === "audio" && type === "audio");
-
- if (!isCompatible) {
- e.dataTransfer.dropEffect = "none";
- return;
- }
- }
- } catch (error) {
- console.error("Error parsing dropped media item:", error);
- }
- }
-
- // Calculate drop position for overlap checking
- const trackContainer = e.currentTarget.querySelector(
- ".track-clips-container"
- ) as HTMLElement;
- let dropTime = 0;
- if (trackContainer) {
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- dropTime = mouseX / (50 * zoomLevel);
- }
-
- // Check for potential overlaps and show appropriate feedback
- let wouldOverlap = false;
-
- if (hasMediaItem) {
- try {
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (mediaItemData) {
- const { id } = JSON.parse(mediaItemData);
- const mediaItem = mediaItems.find((item) => item.id === id);
- if (mediaItem) {
- const newClipDuration = mediaItem.duration || 5;
- const snappedTime = Math.round(dropTime * 10) / 10;
- const newClipEnd = snappedTime + newClipDuration;
-
- wouldOverlap = track.clips.some((existingClip) => {
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
- return snappedTime < existingEnd && newClipEnd > existingStart;
- });
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- } else if (hasTimelineClip) {
- try {
- const timelineClipData = e.dataTransfer.getData(
- "application/x-timeline-clip"
- );
- if (timelineClipData) {
- const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData);
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingClip = sourceTrack?.clips.find(
- (c: any) => c.id === clipId
- );
-
- if (movingClip) {
- const movingClipDuration =
- movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
- const snappedTime = Math.round(dropTime * 10) / 10;
- const movingClipEnd = snappedTime + movingClipDuration;
-
- wouldOverlap = track.clips.some((existingClip) => {
- if (fromTrackId === track.id && existingClip.id === clipId)
- return false;
-
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
- return snappedTime < existingEnd && movingClipEnd > existingStart;
- });
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- }
-
- if (wouldOverlap) {
- e.dataTransfer.dropEffect = "none";
- setWouldOverlap(true);
- setDropPosition(Math.round(dropTime * 10) / 10);
- return;
- }
-
- e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
- setWouldOverlap(false);
- setDropPosition(Math.round(dropTime * 10) / 10);
- };
-
- const handleTrackDragEnter = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineClip && !hasMediaItem) return;
-
- dragCounterRef.current++;
- setIsDropping(true);
- };
-
- const handleTrackDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineClip && !hasMediaItem) return;
-
- dragCounterRef.current--;
-
- if (dragCounterRef.current === 0) {
- setIsDropping(false);
- setWouldOverlap(false);
- setDropPosition(null);
- }
- };
-
- const handleTrackDrop = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
-
- // Reset all drag states
- dragCounterRef.current = 0;
- setIsDropping(false);
- setWouldOverlap(false);
- const currentDropPosition = dropPosition;
- setDropPosition(null);
-
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineClip && !hasMediaItem) return;
-
- const trackContainer = e.currentTarget.querySelector(
- ".track-clips-container"
- ) as HTMLElement;
- if (!trackContainer) return;
-
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- const newStartTime = mouseX / (50 * zoomLevel);
- const snappedTime = Math.round(newStartTime * 10) / 10;
-
- try {
- if (hasTimelineClip) {
- // Handle timeline clip movement
- const timelineClipData = e.dataTransfer.getData(
- "application/x-timeline-clip"
- );
- if (!timelineClipData) return;
-
- const {
- clipId,
- trackId: fromTrackId,
- clickOffsetTime = 0,
- } = JSON.parse(timelineClipData);
-
- // Find the clip being moved
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingClip = sourceTrack?.clips.find(
- (c: TypeTimelineClip) => c.id === clipId
- );
-
- if (!movingClip) {
- toast.error("Clip not found");
- return;
- }
-
- // Adjust position based on where user clicked on the clip
- const adjustedStartTime = snappedTime - clickOffsetTime;
- const finalStartTime = Math.max(
- 0,
- Math.round(adjustedStartTime * 10) / 10
- );
-
- // Check for overlaps with existing clips (excluding the moving clip itself)
- const movingClipDuration =
- movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
- const movingClipEnd = finalStartTime + movingClipDuration;
-
- const hasOverlap = track.clips.some((existingClip) => {
- // Skip the clip being moved if it's on the same track
- if (fromTrackId === track.id && existingClip.id === clipId)
- return false;
-
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
-
- // Check if clips overlap
- return finalStartTime < existingEnd && movingClipEnd > existingStart;
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot move clip here - it would overlap with existing clips"
- );
- return;
- }
-
- if (fromTrackId === track.id) {
- // Moving within same track
- updateClipStartTime(track.id, clipId, finalStartTime);
- } else {
- // Moving to different track
- moveClipToTrack(fromTrackId, track.id, clipId);
- requestAnimationFrame(() => {
- updateClipStartTime(track.id, clipId, finalStartTime);
- });
- }
- } else if (hasMediaItem) {
- // Handle media item drop
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (!mediaItemData) return;
-
- const { id, type } = JSON.parse(mediaItemData);
- const mediaItem = mediaItems.find((item) => item.id === id);
-
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
-
- // Check if track type is compatible
- const isCompatible =
- (track.type === "video" && (type === "video" || type === "image")) ||
- (track.type === "audio" && type === "audio");
-
- if (!isCompatible) {
- toast.error(`Cannot add ${type} to ${track.type} track`);
- return;
- }
-
- // Check for overlaps with existing clips
- const newClipDuration = mediaItem.duration || 5;
- const newClipEnd = snappedTime + newClipDuration;
-
- const hasOverlap = track.clips.some((existingClip) => {
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
-
- // Check if clips overlap
- return snappedTime < existingEnd && newClipEnd > existingStart;
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place clip here - it would overlap with existing clips"
- );
- return;
- }
-
- addClipToTrack(track.id, {
- mediaId: mediaItem.id,
- name: mediaItem.name,
- duration: mediaItem.duration || 5,
- startTime: snappedTime,
- trimStart: 0,
- trimEnd: 0,
- });
- }
- } catch (error) {
- console.error("Error handling drop:", error);
- toast.error("Failed to add media to track");
- }
- };
-
- return (
- <div
- className="w-full h-full hover:bg-muted/20"
- onClick={(e) => {
- // If clicking empty area (not on a clip), deselect all clips
- if (!(e.target as HTMLElement).closest(".timeline-clip")) {
- const { clearSelectedClips } = useTimelineStore.getState();
- clearSelectedClips();
- }
- }}
- onDragOver={handleTrackDragOver}
- onDragEnter={handleTrackDragEnter}
- onDragLeave={handleTrackDragLeave}
- onDrop={handleTrackDrop}
- >
- <div
- ref={timelineRef}
- className="h-full relative track-clips-container min-w-full"
- >
- {track.clips.length === 0 ? (
- <div
- className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
- isDropping
- ? wouldOverlap
- ? "border-red-500 bg-red-500/10 text-red-600"
- : "border-blue-500 bg-blue-500/10 text-blue-600"
- : "border-muted/30"
- }`}
- >
- {isDropping
- ? wouldOverlap
- ? "Cannot drop - would overlap"
- : "Drop clip here"
- : "Drop media here"}
- </div>
- ) : (
- <>
- {track.clips.map((clip) => {
- const isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- const handleClipSplit = () => {
- const { currentTime } = usePlaybackStore();
- const { updateClipTrim, addClipToTrack } = useTimelineStore();
- const splitTime = currentTime;
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime +
- (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (splitTime > effectiveStart && splitTime < effectiveEnd) {
- updateClipTrim(
- track.id,
- clip.id,
- clip.trimStart,
- clip.trimEnd + (effectiveEnd - splitTime)
- );
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (split)",
- duration: clip.duration,
- startTime: splitTime,
- trimStart: clip.trimStart + (splitTime - effectiveStart),
- trimEnd: clip.trimEnd,
- });
- toast.success("Clip split successfully");
- } else {
- toast.error("Playhead must be within clip to split");
- }
- };
-
- const handleClipDuplicate = () => {
- const { addClipToTrack } = useTimelineStore.getState();
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (copy)",
- duration: clip.duration,
- startTime:
- clip.startTime +
- (clip.duration - clip.trimStart - clip.trimEnd) +
- 0.1,
- trimStart: clip.trimStart,
- trimEnd: clip.trimEnd,
- });
- toast.success("Clip duplicated");
- };
-
- const handleClipDelete = () => {
- const { removeClipFromTrack } = useTimelineStore.getState();
- removeClipFromTrack(track.id, clip.id);
- toast.success("Clip deleted");
- };
-
- return (
- <ContextMenu key={clip.id}>
- <ContextMenuTrigger asChild>
- <div>
- <TimelineClip
- clip={clip}
- track={track}
- zoomLevel={zoomLevel}
- isSelected={isSelected}
- onClipMouseDown={handleClipMouseDown}
- onClipClick={handleClipClick}
- />
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem onClick={handleClipSplit}>
- <Scissors className="h-4 w-4 mr-2" />
- Split at Playhead
- </ContextMenuItem>
- <ContextMenuItem onClick={handleClipDuplicate}>
- <Copy className="h-4 w-4 mr-2" />
- Duplicate Clip
- </ContextMenuItem>
- <ContextMenuSeparator />
- <ContextMenuItem
- onClick={handleClipDelete}
- className="text-destructive focus:text-destructive"
- >
- <Trash2 className="h-4 w-4 mr-2" />
- Delete Clip
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- );
- })}
- </>
- )}
- </div>
- </div>
- );
-}
+"use client";
+
+import { useRef, useState, useEffect } from "react";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { toast } from "sonner";
+import { Copy, Scissors, Trash2 } from "lucide-react";
+import { TimelineElement } from "./timeline-element";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "../ui/context-menu";
+import { TimelineTrack } from "@/stores/timeline-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import type {
+ TimelineElement as TimelineElementType,
+ DragData,
+} from "@/types/timeline";
+
+export function TimelineTrackContent({
+ track,
+ zoomLevel,
+}: {
+ track: TimelineTrack;
+ zoomLevel: number;
+}) {
+ const { mediaItems } = useMediaStore();
+ const {
+ tracks,
+ moveElementToTrack,
+ updateElementStartTime,
+ addElementToTrack,
+ selectedElements,
+ selectElement,
+ dragState,
+ startDrag: startDragAction,
+ updateDragTime,
+ endDrag: endDragAction,
+ clearSelectedElements,
+ insertTrackAt,
+ } = useTimelineStore();
+
+ const timelineRef = useRef<HTMLDivElement>(null);
+ const [isDropping, setIsDropping] = useState(false);
+ const [dropPosition, setDropPosition] = useState<number | null>(null);
+ const [wouldOverlap, setWouldOverlap] = useState(false);
+ const dragCounterRef = useRef(0);
+ const [mouseDownLocation, setMouseDownLocation] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+
+ // Set up mouse event listeners for drag
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!timelineRef.current) return;
+
+ // On first mouse move during drag, ensure the element is selected
+ if (dragState.elementId && dragState.trackId) {
+ const isSelected = selectedElements.some(
+ (c) =>
+ c.trackId === dragState.trackId &&
+ c.elementId === dragState.elementId
+ );
+
+ if (!isSelected) {
+ // Select this element (replacing other selections) since we're dragging it
+ selectElement(dragState.trackId, dragState.elementId, false);
+ }
+ }
+
+ const timelineRect = timelineRef.current.getBoundingClientRect();
+ const mouseX = e.clientX - timelineRect.left;
+ const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
+ const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
+ const snappedTime = Math.round(adjustedTime * 10) / 10;
+
+ updateDragTime(snappedTime);
+ };
+
+ const handleMouseUp = (e: MouseEvent) => {
+ if (!dragState.elementId || !dragState.trackId) return;
+
+ // Check if the mouse is actually over this track
+ const timelineRect = timelineRef.current?.getBoundingClientRect();
+ if (!timelineRect) return;
+
+ const isMouseOverThisTrack =
+ e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
+
+ // Only handle if mouse is over this track
+ if (!isMouseOverThisTrack) return;
+
+ const finalTime = dragState.currentTime;
+
+ // Check for overlaps and update position
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const movingElement = sourceTrack?.elements.find(
+ (c) => c.id === dragState.elementId
+ );
+
+ if (movingElement) {
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const movingElementEnd = finalTime + movingElementDuration;
+
+ const targetTrack = tracks.find((t) => t.id === track.id);
+ const hasOverlap = targetTrack?.elements.some((existingElement) => {
+ if (
+ dragState.trackId === track.id &&
+ existingElement.id === dragState.elementId
+ ) {
+ return false;
+ }
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return finalTime < existingEnd && movingElementEnd > existingStart;
+ });
+
+ if (!hasOverlap) {
+ if (dragState.trackId === track.id) {
+ updateElementStartTime(track.id, dragState.elementId, finalTime);
+ } else {
+ moveElementToTrack(
+ dragState.trackId,
+ track.id,
+ dragState.elementId
+ );
+ requestAnimationFrame(() => {
+ updateElementStartTime(track.id, dragState.elementId!, finalTime);
+ });
+ }
+ }
+ }
+
+ endDragAction();
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [
+ dragState.isDragging,
+ dragState.clickOffsetTime,
+ dragState.elementId,
+ dragState.trackId,
+ dragState.currentTime,
+ zoomLevel,
+ tracks,
+ track.id,
+ updateDragTime,
+ updateElementStartTime,
+ moveElementToTrack,
+ endDragAction,
+ selectedElements,
+ selectElement,
+ ]);
+
+ const handleElementMouseDown = (
+ e: React.MouseEvent,
+ element: TimelineElementType
+ ) => {
+ setMouseDownLocation({ x: e.clientX, y: e.clientY });
+
+ // Detect right-click (button 2) and handle selection without starting drag
+ const isRightClick = e.button === 2;
+ const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
+
+ if (isRightClick) {
+ // Handle right-click selection
+ const isSelected = selectedElements.some(
+ (c) => c.trackId === track.id && c.elementId === element.id
+ );
+
+ // If element is not selected, select it (keep other selections if multi-select)
+ if (!isSelected) {
+ selectElement(track.id, element.id, isMultiSelect);
+ }
+ // If element is already selected, keep it selected
+
+ // Don't start drag action for right-clicks
+ return;
+ }
+
+ // Handle multi-selection for left-click with modifiers
+ if (isMultiSelect) {
+ selectElement(track.id, element.id, true);
+ }
+
+ // Calculate the offset from the left edge of the element to where the user clicked
+ const elementElement = e.currentTarget as HTMLElement;
+ const elementRect = elementElement.getBoundingClientRect();
+ const clickOffsetX = e.clientX - elementRect.left;
+ const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
+
+ startDragAction(
+ element.id,
+ track.id,
+ e.clientX,
+ element.startTime,
+ clickOffsetTime
+ );
+ };
+
+ const handleElementClick = (
+ e: React.MouseEvent,
+ element: TimelineElementType
+ ) => {
+ e.stopPropagation();
+
+ // Check if mouse moved significantly
+ if (mouseDownLocation) {
+ const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
+ const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
+ // If it moved more than a few pixels, consider it a drag and not a click.
+ if (deltaX > 5 || deltaY > 5) {
+ setMouseDownLocation(null); // Reset for next interaction
+ return;
+ }
+ }
+
+ // Skip selection logic for multi-selection (handled in mousedown)
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
+ return;
+ }
+
+ // Handle single selection
+ const isSelected = selectedElements.some(
+ (c) => c.trackId === track.id && c.elementId === element.id
+ );
+
+ if (!isSelected) {
+ // If element is not selected, select it (replacing other selections)
+ selectElement(track.id, element.id, false);
+ }
+ // If element is already selected, keep it selected (do nothing)
+ };
+
+ const handleTrackDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Handle both timeline elements and media items
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ // Calculate drop position for overlap checking
+ const trackContainer = e.currentTarget.querySelector(
+ ".track-elements-container"
+ ) as HTMLElement;
+ let dropTime = 0;
+ if (trackContainer) {
+ const rect = trackContainer.getBoundingClientRect();
+ const mouseX = Math.max(0, e.clientX - rect.left);
+ dropTime = mouseX / (50 * zoomLevel);
+ }
+
+ // Check for potential overlaps and show appropriate feedback
+ let wouldOverlap = false;
+
+ if (hasMediaItem) {
+ try {
+ const mediaItemData = e.dataTransfer.getData(
+ "application/x-media-item"
+ );
+ if (mediaItemData) {
+ const dragData: DragData = JSON.parse(mediaItemData);
+
+ if (dragData.type === "text") {
+ // Text elements have default duration of 5 seconds
+ const newElementDuration = 5;
+ const snappedTime = Math.round(dropTime * 10) / 10;
+ const newElementEnd = snappedTime + newElementDuration;
+
+ wouldOverlap = track.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return snappedTime < existingEnd && newElementEnd > existingStart;
+ });
+ } else {
+ // Media elements
+ const mediaItem = mediaItems.find(
+ (item) => item.id === dragData.id
+ );
+ if (mediaItem) {
+ const newElementDuration = mediaItem.duration || 5;
+ const snappedTime = Math.round(dropTime * 10) / 10;
+ const newElementEnd = snappedTime + newElementDuration;
+
+ wouldOverlap = track.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return (
+ snappedTime < existingEnd && newElementEnd > existingStart
+ );
+ });
+ }
+ }
+ }
+ } catch (error) {
+ // Continue with default behavior
+ }
+ } else if (hasTimelineElement) {
+ try {
+ const timelineElementData = e.dataTransfer.getData(
+ "application/x-timeline-element"
+ );
+ if (timelineElementData) {
+ const { elementId, trackId: fromTrackId } =
+ JSON.parse(timelineElementData);
+ const sourceTrack = tracks.find(
+ (t: TimelineTrack) => t.id === fromTrackId
+ );
+ const movingElement = sourceTrack?.elements.find(
+ (c: any) => c.id === elementId
+ );
+
+ if (movingElement) {
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const snappedTime = Math.round(dropTime * 10) / 10;
+ const movingElementEnd = snappedTime + movingElementDuration;
+
+ wouldOverlap = track.elements.some((existingElement) => {
+ if (fromTrackId === track.id && existingElement.id === elementId)
+ return false;
+
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return (
+ snappedTime < existingEnd && movingElementEnd > existingStart
+ );
+ });
+ }
+ }
+ } catch (error) {
+ // Continue with default behavior
+ }
+ }
+
+ if (wouldOverlap) {
+ e.dataTransfer.dropEffect = "none";
+ setWouldOverlap(true);
+ setDropPosition(Math.round(dropTime * 10) / 10);
+ return;
+ }
+
+ e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
+ setWouldOverlap(false);
+ setDropPosition(Math.round(dropTime * 10) / 10);
+ };
+
+ const handleTrackDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ dragCounterRef.current++;
+ setIsDropping(true);
+ };
+
+ const handleTrackDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ dragCounterRef.current--;
+
+ if (dragCounterRef.current === 0) {
+ setIsDropping(false);
+ setWouldOverlap(false);
+ setDropPosition(null);
+ }
+ };
+
+ const handleTrackDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Reset all drag states
+ dragCounterRef.current = 0;
+ setIsDropping(false);
+ setWouldOverlap(false);
+
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ const trackContainer = e.currentTarget.querySelector(
+ ".track-elements-container"
+ ) as HTMLElement;
+ if (!trackContainer) return;
+
+ const rect = trackContainer.getBoundingClientRect();
+ const mouseX = Math.max(0, e.clientX - rect.left);
+ const mouseY = e.clientY - rect.top; // Get Y position relative to this track
+ const newStartTime = mouseX / (50 * zoomLevel);
+ const snappedTime = Math.round(newStartTime * 10) / 10;
+
+ // Calculate drop position relative to tracks
+ const TRACK_HEIGHT = 60;
+ const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
+
+ // Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
+ let dropPosition: "above" | "on" | "below";
+ if (mouseY < 20) {
+ dropPosition = "above";
+ } else if (mouseY > 40) {
+ dropPosition = "below";
+ } else {
+ dropPosition = "on";
+ }
+
+ try {
+ if (hasTimelineElement) {
+ // Handle timeline element movement
+ const timelineElementData = e.dataTransfer.getData(
+ "application/x-timeline-element"
+ );
+ if (!timelineElementData) return;
+
+ const {
+ elementId,
+ trackId: fromTrackId,
+ clickOffsetTime = 0,
+ } = JSON.parse(timelineElementData);
+
+ // Find the element being moved
+ const sourceTrack = tracks.find(
+ (t: TimelineTrack) => t.id === fromTrackId
+ );
+ const movingElement = sourceTrack?.elements.find(
+ (c: TimelineElementType) => c.id === elementId
+ );
+
+ if (!movingElement) {
+ toast.error("Element not found");
+ return;
+ }
+
+ // Adjust position based on where user clicked on the element
+ const adjustedStartTime = snappedTime - clickOffsetTime;
+ const finalStartTime = Math.max(
+ 0,
+ Math.round(adjustedStartTime * 10) / 10
+ );
+
+ // Check for overlaps with existing elements (excluding the moving element itself)
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const movingElementEnd = finalStartTime + movingElementDuration;
+
+ const hasOverlap = track.elements.some((existingElement) => {
+ // Skip the element being moved if it's on the same track
+ if (fromTrackId === track.id && existingElement.id === elementId)
+ return false;
+
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+
+ // Check if elements overlap
+ return (
+ finalStartTime < existingEnd && movingElementEnd > existingStart
+ );
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot move element here - it would overlap with existing elements"
+ );
+ return;
+ }
+
+ if (fromTrackId === track.id) {
+ // Moving within same track
+ updateElementStartTime(track.id, elementId, finalStartTime);
+ } else {
+ // Moving to different track
+ moveElementToTrack(fromTrackId, track.id, elementId);
+ requestAnimationFrame(() => {
+ updateElementStartTime(track.id, elementId, finalStartTime);
+ });
+ }
+ } else if (hasMediaItem) {
+ // Handle media item drop
+ const mediaItemData = e.dataTransfer.getData(
+ "application/x-media-item"
+ );
+ if (!mediaItemData) return;
+
+ const dragData: DragData = JSON.parse(mediaItemData);
+
+ if (dragData.type === "text") {
+ let targetTrackId = track.id;
+ let targetTrack = track;
+
+ // Handle position-aware track creation for text
+ if (track.type !== "text" || dropPosition !== "on") {
+ // Determine where to insert the new text track
+ let insertIndex: number;
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // dropPosition === "on" but track is not text type
+ insertIndex = currentTrackIndex + 1;
+ }
+
+ targetTrackId = insertTrackAt("text", insertIndex);
+ // Get the updated tracks array after creating the new track
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ }
+
+ // Check for overlaps with existing elements in target track
+ const newElementDuration = 5; // Default text duration
+ const newElementEnd = snappedTime + newElementDuration;
+
+ const hasOverlap = targetTrack.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+
+ // Check if elements overlap
+ return snappedTime < existingEnd && newElementEnd > existingStart;
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements"
+ );
+ return;
+ }
+
+ addElementToTrack(targetTrackId, {
+ type: "text",
+ name: dragData.name || "Text",
+ content: dragData.content || "Default Text",
+ duration: 5,
+ startTime: snappedTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: 48,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ backgroundColor: "transparent",
+ textAlign: "center",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ x: 0,
+ y: 0,
+ rotation: 0,
+ opacity: 1,
+ });
+ } else {
+ // Handle media items
+ const mediaItem = mediaItems.find((item) => item.id === dragData.id);
+
+ if (!mediaItem) {
+ toast.error("Media item not found");
+ return;
+ }
+
+ let targetTrackId = track.id;
+
+ // Check if track type is compatible
+ const isVideoOrImage =
+ dragData.type === "video" || dragData.type === "image";
+ const isAudio = dragData.type === "audio";
+ const isCompatible =
+ (track.type === "media" && isVideoOrImage) ||
+ (track.type === "audio" && isAudio);
+
+ let targetTrack = tracks.find((t) => t.id === targetTrackId);
+
+ // Handle position-aware track creation for media elements
+ if (!isCompatible || dropPosition !== "on") {
+ const needsNewTrack = !isCompatible || dropPosition !== "on";
+
+ if (needsNewTrack) {
+ // Determine where to insert the new track
+ let insertIndex: number;
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // dropPosition === "on" but track is incompatible
+ insertIndex = currentTrackIndex + 1;
+ }
+
+ if (isVideoOrImage) {
+ // For video/image, check if main media track is empty and at the right position
+ const mainMediaTrack = tracks.find((t) => t.type === "media");
+ if (
+ mainMediaTrack &&
+ mainMediaTrack.elements.length === 0 &&
+ dropPosition === "on"
+ ) {
+ targetTrackId = mainMediaTrack.id;
+ targetTrack = mainMediaTrack;
+ } else {
+ targetTrackId = insertTrackAt("media", insertIndex);
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ }
+ } else if (isAudio) {
+ targetTrackId = insertTrackAt("audio", insertIndex);
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ }
+ }
+ }
+
+ if (!targetTrack) return;
+
+ // Check for overlaps with existing elements in target track
+ const newElementDuration = mediaItem.duration || 5;
+ const newElementEnd = snappedTime + newElementDuration;
+
+ const hasOverlap = targetTrack.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+
+ // Check if elements overlap
+ return snappedTime < existingEnd && newElementEnd > existingStart;
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements"
+ );
+ return;
+ }
+
+ addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: mediaItem.id,
+ name: mediaItem.name,
+ duration: mediaItem.duration || 5,
+ startTime: snappedTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }
+ }
+ } catch (error) {
+ console.error("Error handling drop:", error);
+ toast.error("Failed to add media to track");
+ }
+ };
+
+ return (
+ <div
+ className="w-full h-full hover:bg-muted/20"
+ onClick={(e) => {
+ // If clicking empty area (not on an element), deselect all elements
+ if (!(e.target as HTMLElement).closest(".timeline-element")) {
+ clearSelectedElements();
+ }
+ }}
+ onDragOver={handleTrackDragOver}
+ onDragEnter={handleTrackDragEnter}
+ onDragLeave={handleTrackDragLeave}
+ onDrop={handleTrackDrop}
+ >
+ <div
+ ref={timelineRef}
+ className="h-full relative track-elements-container min-w-full"
+ >
+ {track.elements.length === 0 ? (
+ <div
+ className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
+ isDropping
+ ? wouldOverlap
+ ? "border-red-500 bg-red-500/10 text-red-600"
+ : "border-blue-500 bg-blue-500/10 text-blue-600"
+ : "border-muted/30"
+ }`}
+ >
+ {isDropping
+ ? wouldOverlap
+ ? "Cannot drop - would overlap"
+ : "Drop element here"
+ : "Drop media here"}
+ </div>
+ ) : (
+ <>
+ {track.elements.map((element) => {
+ const isSelected = selectedElements.some(
+ (c) => c.trackId === track.id && c.elementId === element.id
+ );
+
+ const handleElementSplit = () => {
+ const { currentTime } = usePlaybackStore();
+ const { splitElement } = useTimelineStore();
+ const splitTime = currentTime;
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (splitTime > effectiveStart && splitTime < effectiveEnd) {
+ const secondElementId = splitElement(
+ track.id,
+ element.id,
+ splitTime
+ );
+ if (!secondElementId) {
+ toast.error("Failed to split element");
+ }
+ } else {
+ toast.error("Playhead must be within element to split");
+ }
+ };
+
+ const handleElementDuplicate = () => {
+ const { addElementToTrack } = useTimelineStore.getState();
+ const { id, ...elementWithoutId } = element;
+ addElementToTrack(track.id, {
+ ...elementWithoutId,
+ name: element.name + " (copy)",
+ startTime:
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1,
+ });
+ };
+
+ const handleElementDelete = () => {
+ const { removeElementFromTrack } = useTimelineStore.getState();
+ removeElementFromTrack(track.id, element.id);
+ };
+
+ return (
+ <ContextMenu key={element.id}>
+ <ContextMenuTrigger asChild>
+ <div>
+ <TimelineElement
+ element={element}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onElementMouseDown={handleElementMouseDown}
+ onElementClick={handleElementClick}
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem onClick={handleElementSplit}>
+ <Scissors className="h-4 w-4 mr-2" />
+ Split at playhead
+ </ContextMenuItem>
+ <ContextMenuItem onClick={handleElementDuplicate}>
+ <Copy className="h-4 w-4 mr-2" />
+ Duplicate {element.type === "text" ? "text" : "clip"}
+ </ContextMenuItem>
+ <ContextMenuSeparator />
+ <ContextMenuItem
+ onClick={handleElementDelete}
+ className="text-destructive focus:text-destructive"
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ Delete {element.type === "text" ? "text" : "clip"}
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+ })}
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index e9f44e64..283f6b04 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -42,23 +42,22 @@ import {
SelectValue,
} from "../ui/select";
import { TimelineTrackContent } from "./timeline-track";
+import type { DragData } from "@/types/timeline";
export function Timeline() {
- // Timeline shows all tracks (video, audio, effects) and their clips.
+ // Timeline shows all tracks (video, audio, effects) and their elements.
// You can drag media here to add it to your project.
- // Clips can be trimmed, deleted, and moved.
+ // elements can be trimmed, deleted, and moved.
const {
tracks,
addTrack,
- addClipToTrack,
- removeTrack,
- toggleTrackMute,
- removeClipFromTrack,
+ addElementToTrack,
+ removeElementFromTrack,
getTotalDuration,
- selectedClips,
- clearSelectedClips,
- setSelectedClips,
- splitClip,
+ selectedElements,
+ clearSelectedElements,
+ setSelectedElements,
+ splitElement,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
@@ -116,36 +115,28 @@ export function Timeline() {
const lastRulerSync = useRef(0);
const lastTracksSync = useRef(0);
- // New refs for direct playhead DOM manipulation
- const rulerPlayheadRef = useRef<HTMLDivElement>(null);
- const tracksPlayheadRef = useRef<HTMLDivElement>(null);
-
- // Refs to store initial mouse and scroll positions for drag calculations
- const initialMouseXRef = useRef(0);
- const initialTimelineScrollLeftRef = useRef(0);
-
// Update timeline duration when tracks change
useEffect(() => {
const totalDuration = getTotalDuration();
setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline
}, [tracks, setDuration, getTotalDuration]);
- // Keyboard event for deleting selected clips
+ // Keyboard event for deleting selected elements
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
(e.key === "Delete" || e.key === "Backspace") &&
- selectedClips.length > 0
+ selectedElements.length > 0
) {
- selectedClips.forEach(({ trackId, clipId }) => {
- removeClipFromTrack(trackId, clipId);
+ selectedElements.forEach(({ trackId, elementId }) => {
+ removeElementFromTrack(trackId, elementId);
});
- clearSelectedClips();
+ clearSelectedElements();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
- }, [selectedClips, removeClipFromTrack, clearSelectedClips]);
+ }, [selectedElements, removeElementFromTrack, clearSelectedElements]);
// Keyboard event for undo (Cmd+Z)
useEffect(() => {
@@ -190,9 +181,9 @@ export function Timeline() {
// Add new click handler for deselection
const handleTimelineClick = (e: React.MouseEvent) => {
- // If clicking empty area (not on a clip) and not starting marquee, deselect all clips
- if (!(e.target as HTMLElement).closest(".timeline-clip")) {
- clearSelectedClips();
+ // If clicking empty area (not on an element) and not starting marquee, deselect all elements
+ if (!(e.target as HTMLElement).closest(".timeline-element")) {
+ clearSelectedElements();
}
};
@@ -218,7 +209,7 @@ export function Timeline() {
};
}, [marquee]);
- // On marquee end, select clips in box
+ // On marquee end, select elements in box
useEffect(() => {
if (!marquee || marquee.active) return;
const timeline = timelineRef.current;
@@ -240,56 +231,54 @@ export function Timeline() {
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; clipId: string }[] = [];
+ let newSelection: { trackId: string; elementId: string }[] = [];
tracks.forEach((track, trackIdx) => {
- track.clips.forEach((clip) => {
- const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
- const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
- const clipLeft = clip.startTime * 50 * zoomLevel;
+ track.elements.forEach((element) => {
+ const clipLeft = element.startTime * 50 * zoomLevel;
const clipTop = trackIdx * 60;
const clipBottom = clipTop + 60;
- const clipRight = clipLeft + 60; // Set a fixed width for time display
+ const clipRight = clipLeft + 60;
if (
bx1 < clipRight &&
bx2 > clipLeft &&
by1 < clipBottom &&
by2 > clipTop
) {
- newSelection.push({ trackId: track.id, clipId: clip.id });
+ newSelection.push({ trackId: track.id, elementId: element.id });
}
});
});
if (newSelection.length > 0) {
if (marquee.additive) {
const selectedSet = new Set(
- selectedClips.map((c) => c.trackId + ":" + c.clipId)
+ selectedElements.map((c) => c.trackId + ":" + c.elementId)
);
newSelection = [
- ...selectedClips,
+ ...selectedElements,
...newSelection.filter(
- (c) => !selectedSet.has(c.trackId + ":" + c.clipId)
+ (c) => !selectedSet.has(c.trackId + ":" + c.elementId)
),
];
}
- setSelectedClips(newSelection);
+ setSelectedElements(newSelection);
} else if (!marquee.additive) {
- clearSelectedClips();
+ clearSelectedElements();
}
setMarquee(null);
}, [
marquee,
tracks,
zoomLevel,
- selectedClips,
- setSelectedClips,
- clearSelectedClips,
+ selectedElements,
+ setSelectedElements,
+ clearSelectedElements,
]);
const handleDragEnter = (e: React.DragEvent) => {
// When something is dragged over the timeline, show overlay
e.preventDefault();
- // Don't show overlay for timeline clips - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-clip")) {
+ // Don't show overlay for timeline elements - they're handled by tracks
+ if (e.dataTransfer.types.includes("application/x-timeline-element")) {
return;
}
dragCounterRef.current += 1;
@@ -305,8 +294,8 @@ export function Timeline() {
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
- // Don't update state for timeline clips - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-clip")) {
+ // Don't update state for timeline elements - they're handled by tracks
+ if (e.dataTransfer.types.includes("application/x-timeline-element")) {
return;
}
@@ -317,44 +306,74 @@ export function Timeline() {
};
const handleDrop = async (e: React.DragEvent) => {
- // When media is dropped, add it as a new track/clip
+ // When media is dropped, add it as a new track/element
e.preventDefault();
setIsDragOver(false);
dragCounterRef.current = 0;
- // Ignore timeline clip drags - they're handled by track-specific handlers
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
+ // Ignore timeline element drags - they're handled by track-specific handlers
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
);
- if (hasTimelineClip) {
+ if (hasTimelineElement) {
return;
}
- const mediaItemData = e.dataTransfer.getData("application/x-media-item");
- if (mediaItemData) {
- // Handle media item drops by creating new tracks
+ const itemData = e.dataTransfer.getData("application/x-media-item");
+ if (itemData) {
try {
- const { id, type } = JSON.parse(mediaItemData);
- const mediaItem = mediaItems.find((item) => item.id === id);
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
+ const dragData: DragData = JSON.parse(itemData);
+
+ if (dragData.type === "text") {
+ // Always create new text track to avoid overlaps
+ const newTrackId = addTrack("text");
+
+ addElementToTrack(newTrackId, {
+ type: "text",
+ name: dragData.name || "Text",
+ content: dragData.content || "Default Text",
+ duration: 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: 48,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ backgroundColor: "transparent",
+ textAlign: "center",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ x: 0,
+ y: 0,
+ rotation: 0,
+ opacity: 1,
+ });
+ } else {
+ // Handle media items
+ const mediaItem = mediaItems.find((item) => item.id === dragData.id);
+ if (!mediaItem) {
+ toast.error("Media item not found");
+ return;
+ }
+
+ const trackType = dragData.type === "audio" ? "audio" : "media";
+ let targetTrack = tracks.find((t) => t.type === trackType);
+ const newTrackId = targetTrack ? targetTrack.id : addTrack(trackType);
+
+ addElementToTrack(newTrackId, {
+ type: "media",
+ mediaId: mediaItem.id,
+ name: mediaItem.name,
+ duration: mediaItem.duration || 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
}
- // Add to video or audio track depending on type
- const trackType = type === "audio" ? "audio" : "video";
- const newTrackId = addTrack(trackType);
- addClipToTrack(newTrackId, {
- mediaId: mediaItem.id,
- name: mediaItem.name,
- duration: mediaItem.duration || 5,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
} catch (error) {
- // Show error if parsing fails
- console.error("Error parsing media item data:", error);
- toast.error("Failed to add media to timeline");
+ console.error("Error parsing dropped item data:", error);
+ toast.error("Failed to add item to timeline");
}
} else if (e.dataTransfer.files?.length > 0) {
// Handle file drops by creating new tracks
@@ -374,9 +393,10 @@ export function Timeline() {
);
if (addedItem) {
const trackType =
- processedItem.type === "audio" ? "audio" : "video";
+ processedItem.type === "audio" ? "audio" : "media";
const newTrackId = addTrack(trackType);
- addClipToTrack(newTrackId, {
+ addElementToTrack(newTrackId, {
+ type: "media",
mediaId: addedItem.id,
name: addedItem.name,
duration: addedItem.duration || 5,
@@ -502,175 +522,134 @@ export function Timeline() {
// Action handlers for toolbar
const handleSplitSelected = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
+ if (selectedElements.length === 0) {
+ toast.error("No elements selected");
return;
}
let splitCount = 0;
- selectedClips.forEach(({ trackId, clipId }) => {
+ selectedElements.forEach(({ trackId, elementId }) => {
const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- const effectiveStart = clip.startTime;
+ const element = track?.elements.find((c) => c.id === elementId);
+ if (element && track) {
+ const effectiveStart = element.startTime;
const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const newClipId = splitClip(trackId, clipId, currentTime);
- if (newClipId) splitCount++;
+ const newElementId = splitElement(trackId, elementId, currentTime);
+ if (newElementId) splitCount++;
}
}
});
- if (splitCount > 0) {
- toast.success(`Split ${splitCount} clip(s) at playhead`);
- } else {
- toast.error("Playhead must be within selected clips to split");
+ if (splitCount === 0) {
+ toast.error("Playhead must be within selected elements to split");
}
};
const handleDuplicateSelected = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
+ if (selectedElements.length === 0) {
+ toast.error("No elements selected");
return;
}
- selectedClips.forEach(({ trackId, clipId }) => {
+ const canDuplicate = selectedElements.length === 1;
+ if (!canDuplicate) return;
+
+ const newSelections: { trackId: string; elementId: string }[] = [];
+
+ selectedElements.forEach(({ trackId, elementId }) => {
const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (copy)",
- duration: clip.duration,
- startTime:
- clip.startTime +
- (clip.duration - clip.trimStart - clip.trimEnd) +
- 0.1,
- trimStart: clip.trimStart,
- trimEnd: clip.trimEnd,
+ const element = track?.elements.find((el) => el.id === elementId);
+
+ if (element) {
+ const newStartTime =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1;
+
+ // Create element without id (will be generated by store)
+ const { id, ...elementWithoutId } = element;
+
+ addElementToTrack(trackId, {
+ ...elementWithoutId,
+ startTime: newStartTime,
});
+
+ // We can't predict the new id, so just clear selection for now
+ // TODO: addElementToTrack could return the new element id
}
});
- toast.success("Duplicated selected clip(s)");
+
+ clearSelectedElements();
};
const handleFreezeSelected = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- // Add a new freeze frame clip at the playhead
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (freeze)",
- duration: 1, // 1 second freeze frame
- startTime: currentTime,
- trimStart: 0,
- trimEnd: clip.duration - 1,
- });
- }
- });
+ toast.info("Freeze frame functionality coming soon!");
};
+
const handleSplitAndKeepLeft = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element");
return;
}
-
- let splitCount = 0;
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- splitAndKeepLeft(trackId, clipId, currentTime);
- splitCount++;
- }
- }
- });
-
- if (splitCount > 0) {
- toast.success(`Split and kept left portion of ${splitCount} clip(s)`);
- } else {
- toast.error("Playhead must be within selected clips");
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
+ if (!element) return;
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within selected element");
+ return;
}
+ splitAndKeepLeft(trackId, elementId, currentTime);
};
const handleSplitAndKeepRight = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element");
return;
}
-
- let splitCount = 0;
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- splitAndKeepRight(trackId, clipId, currentTime);
- splitCount++;
- }
- }
- });
-
- if (splitCount > 0) {
- toast.success(`Split and kept right portion of ${splitCount} clip(s)`);
- } else {
- toast.error("Playhead must be within selected clips");
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
+ if (!element) return;
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within selected element");
+ return;
}
+ splitAndKeepRight(trackId, elementId, currentTime);
};
const handleSeparateAudio = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one media element to separate audio");
return;
}
-
- let separatedCount = 0;
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- const mediaItem = mediaItems.find((item) => item.id === clip?.mediaId);
-
- if (
- clip &&
- track &&
- mediaItem?.type === "video" &&
- track.type === "video"
- ) {
- const audioClipId = separateAudio(trackId, clipId);
- if (audioClipId) separatedCount++;
- }
- });
-
- if (separatedCount > 0) {
- toast.success(`Separated audio from ${separatedCount} video clip(s)`);
- } else {
- toast.error("Select video clips to separate audio");
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ if (!track || track.type !== "media") {
+ toast.error("Select a media element to separate audio");
+ return;
}
+ separateAudio(trackId, elementId);
};
+
const handleDeleteSelected = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
+ if (selectedElements.length === 0) {
+ toast.error("No elements selected");
return;
}
- selectedClips.forEach(({ trackId, clipId }) => {
- removeClipFromTrack(trackId, clipId);
+ selectedElements.forEach(({ trackId, elementId }) => {
+ removeElementFromTrack(trackId, elementId);
});
- clearSelectedClips();
- toast.success("Deleted selected clip(s)");
+ clearSelectedElements();
};
// Prevent explorer zooming in/out when in timeline
@@ -754,7 +733,7 @@ export function Timeline() {
return (
<div
- className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`}
+ className={`h-full flex flex-col transition-colors duration-200 relative`}
{...dragProps}
onMouseEnter={() => setIsInTimeline(true)}
onMouseLeave={() => setIsInTimeline(false)}
@@ -783,9 +762,7 @@ export function Timeline() {
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
-
<div className="w-px h-6 bg-border mx-1" />
-
{/* Time Display */}
<div
className="text-xs text-muted-foreground font-mono px-2"
@@ -793,7 +770,6 @@ export function Timeline() {
>
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
-
{/* Test Clip Button - for debugging */}
{tracks.length === 0 && (
<>
@@ -804,8 +780,9 @@ export function Timeline() {
variant="outline"
size="sm"
onClick={() => {
- const trackId = addTrack("video");
- addClipToTrack(trackId, {
+ const trackId = addTrack("media");
+ addElementToTrack(trackId, {
+ type: "media",
mediaId: "test",
name: "Test Clip",
duration: 5,
@@ -823,18 +800,15 @@ export function Timeline() {
</Tooltip>
</>
)}
-
<div className="w-px h-6 bg-border mx-1" />
-
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleSplitSelected}>
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
- <TooltipContent>Split clip (Ctrl+S)</TooltipContent>
+ <TooltipContent>Split element (Ctrl+S)</TooltipContent>
</Tooltip>
-
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -847,7 +821,6 @@ export function Timeline() {
</TooltipTrigger>
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
</Tooltip>
-
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -860,7 +833,6 @@ export function Timeline() {
</TooltipTrigger>
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
</Tooltip>
-
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleSeparateAudio}>
@@ -869,7 +841,6 @@ export function Timeline() {
</TooltipTrigger>
<TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
</Tooltip>
-
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -880,9 +851,8 @@ export function Timeline() {
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
- <TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
+ <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
</Tooltip>
-
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
@@ -891,19 +861,15 @@ export function Timeline() {
</TooltipTrigger>
<TooltipContent>Freeze frame (F)</TooltipContent>
</Tooltip>
-
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
- <TooltipContent>Delete clip (Delete)</TooltipContent>
+ <TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
-
- <div className="w-px h-6 bg-border mx-1" />
-
- {/* Speed Control */}
+ <div className="w-px h-6 bg-border mx-1" />c{/* Speed Control */}
<Tooltip>
<TooltipTrigger asChild>
<Select
@@ -935,9 +901,6 @@ export function Timeline() {
<span className="text-sm font-medium text-muted-foreground">
Tracks
</span>
- <div className="text-xs text-muted-foreground">
- {zoomLevel.toFixed(1)}x
- </div>
</div>
{/* Timeline Ruler */}
@@ -1045,7 +1008,7 @@ export function Timeline() {
<div className="flex items-center flex-1 min-w-0">
<div
className={`w-3 h-3 rounded-full flex-shrink-0 ${
- track.type === "video"
+ track.type === "media"
? "bg-blue-500"
: track.type === "audio"
? "bg-green-500"
@@ -1080,16 +1043,7 @@ export function Timeline() {
onMouseDown={handleTimelineMouseDown}
>
{tracks.length === 0 ? (
- <div className="absolute inset-0 flex items-center justify-center">
- <div className="text-center">
- <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4 mx-auto">
- <SplitSquareHorizontal className="h-8 w-8 text-muted-foreground" />
- </div>
- <p className="text-sm text-muted-foreground">
- Drop media here to start
- </p>
- </div>
- </div>
+ <div></div>
) : (
<>
{tracks.map((track, index) => (
@@ -1102,13 +1056,13 @@ export function Timeline() {
height: "60px",
}}
onClick={(e) => {
- // If clicking empty area (not on a clip), deselect all clips
+ // If clicking empty area (not on a element), deselect all elements
if (
!(e.target as HTMLElement).closest(
- ".timeline-clip"
+ ".timeline-element"
)
) {
- clearSelectedClips();
+ clearSelectedElements();
}
}}
>
@@ -1119,33 +1073,8 @@ export function Timeline() {
</div>
</ContextMenuTrigger>
<ContextMenuContent>
- <ContextMenuItem
- onClick={() => {
- toggleTrackMute(track.id);
- }}
- >
- {track.muted ? (
- <>
- <Volume2 className="h-4 w-4 mr-2" />
- Unmute Track
- </>
- ) : (
- <>
- <VolumeX className="h-4 w-4 mr-2" />
- Mute Track
- </>
- )}
- </ContextMenuItem>
- <ContextMenuSeparator />
- <ContextMenuItem
- onClick={() => {
- removeTrack(track.id);
- toast.success("Track deleted");
- }}
- className="text-destructive focus:text-destructive"
- >
- <Trash2 className="h-4 w-4 mr-2" />
- Delete Track
+ <ContextMenuItem>
+ Track settings (soon)
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -1164,15 +1093,6 @@ export function Timeline() {
)}
</>
)}
- {isDragOver && (
- <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
- <div>
- {isProcessing
- ? `Processing ${progress}%`
- : "Drop media here to add to timeline"}
- </div>
- </div>
- )}
</div>
</ScrollArea>
</div>
diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx
index ea2c33e8..10f0e119 100644
--- a/apps/web/src/components/footer.tsx
+++ b/apps/web/src/components/footer.tsx
@@ -4,7 +4,7 @@ import { motion } from "motion/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { RiGithubLine, RiTwitterXLine } from "react-icons/ri";
-import { getStars } from "@/lib/fetchGhStars";
+import { getStars } from "@/lib/fetch-github-stars";
import Image from "next/image";
export function Footer() {
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 56317c08..468edf16 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -5,7 +5,7 @@ import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
import { useSession } from "@opencut/auth/client";
-import { getStars } from "@/lib/fetchGhStars";
+import { getStars } from "@/lib/fetch-github-stars";
import { useEffect, useState } from "react";
import Image from "next/image";
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index aac63e94..113d7579 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -4,7 +4,6 @@ import { motion } from "motion/react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { ArrowRight } from "lucide-react";
-import Link from "next/link";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
@@ -42,7 +41,7 @@ export function Hero({ signupCount }: HeroProps) {
body: JSON.stringify({ email: email.trim() }),
});
- const data = await response.json();
+ const data = (await response.json()) as { error: string };
if (response.ok) {
toast({
@@ -53,7 +52,9 @@ export function Hero({ signupCount }: HeroProps) {
} else {
toast({
title: "Oops!",
- description: data.error || "Something went wrong. Please try again.",
+ description:
+ (data as { error: string }).error ||
+ "Something went wrong. Please try again.",
variant: "destructive",
});
}
diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx
index 4ec72d12..16518dde 100644
--- a/apps/web/src/components/ui/draggable-item.tsx
+++ b/apps/web/src/components/ui/draggable-item.tsx
@@ -7,11 +7,6 @@ import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
-// Create the empty image once, outside the component
-const emptyImg = new Image();
-emptyImg.src =
- "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
-
export interface DraggableMediaItemProps {
name: string;
preview: ReactNode;
@@ -39,6 +34,10 @@ export function DraggableMediaItem({
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const dragRef = useRef<HTMLDivElement>(null);
+ const emptyImg = new window.Image();
+ emptyImg.src =
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
+
useEffect(() => {
if (!isDragging) return;
@@ -76,10 +75,9 @@ export function DraggableMediaItem({
return (
<>
- <div ref={dragRef} className="relative group">
- <Button
- variant="outline"
- className={`flex flex-col gap-1 p-0 h-auto w-full relative border-none !bg-transparent cursor-default ${className}`}
+ <div ref={dragRef} className="relative group w-28 h-28">
+ <div
+ className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`}
>
<AspectRatio
ratio={aspectRatio}
@@ -108,7 +106,7 @@ export function DraggableMediaItem({
: name}
</span>
)}
- </Button>
+ </div>
</div>
{/* Custom drag preview */}
diff --git a/apps/web/src/hooks/use-drag-clip.ts b/apps/web/src/hooks/use-drag-clip.ts
index eb4d7435..1d38343b 100644
--- a/apps/web/src/hooks/use-drag-clip.ts
+++ b/apps/web/src/hooks/use-drag-clip.ts
@@ -3,23 +3,24 @@ import { useTimelineStore } from "@/stores/timeline-store";
interface DragState {
isDragging: boolean;
- clipId: string | null;
+ elementId: string | null;
trackId: string | null;
startMouseX: number;
- startClipTime: number;
+ startElementTime: number;
clickOffsetTime: number;
currentTime: number;
}
export function useDragClip(zoomLevel: number) {
- const { tracks, updateClipStartTime, moveClipToTrack } = useTimelineStore();
+ const { tracks, updateElementStartTime, moveElementToTrack } =
+ useTimelineStore();
const [dragState, setDragState] = useState<DragState>({
isDragging: false,
- clipId: null,
+ elementId: null,
trackId: null,
startMouseX: 0,
- startClipTime: 0,
+ startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
});
@@ -33,9 +34,9 @@ export function useDragClip(zoomLevel: number) {
const startDrag = useCallback(
(
e: React.MouseEvent,
- clipId: string,
+ elementId: string,
trackId: string,
- clipStartTime: number,
+ elementStartTime: number,
clickOffsetTime: number
) => {
e.preventDefault();
@@ -43,12 +44,12 @@ export function useDragClip(zoomLevel: number) {
setDragState({
isDragging: true,
- clipId,
+ elementId,
trackId,
startMouseX: e.clientX,
- startClipTime: clipStartTime,
+ startElementTime: elementStartTime,
clickOffsetTime,
- currentTime: clipStartTime,
+ currentTime: elementStartTime,
});
},
[]
@@ -76,7 +77,7 @@ export function useDragClip(zoomLevel: number) {
const endDrag = useCallback(
(targetTrackId?: string) => {
- if (!dragState.isDragging || !dragState.clipId || !dragState.trackId)
+ if (!dragState.isDragging || !dragState.elementId || !dragState.trackId)
return;
const finalTrackId = targetTrackId || dragState.trackId;
@@ -85,71 +86,81 @@ export function useDragClip(zoomLevel: number) {
// Check for overlaps
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const targetTrack = tracks.find((t) => t.id === finalTrackId);
- const movingClip = sourceTrack?.clips.find(
- (c) => c.id === dragState.clipId
+ const movingElement = sourceTrack?.elements.find(
+ (e) => e.id === dragState.elementId
);
- if (!movingClip || !targetTrack) {
+ if (!movingElement || !targetTrack) {
setDragState((prev) => ({ ...prev, isDragging: false }));
return;
}
- const movingClipDuration =
- movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
- const movingClipEnd = finalTime + movingClipDuration;
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const movingElementEnd = finalTime + movingElementDuration;
- const hasOverlap = targetTrack.clips.some((existingClip) => {
- // Skip the clip being moved if it's on the same track
+ const hasOverlap = targetTrack.elements.some((existingElement) => {
+ // Skip the element being moved if it's on the same track
if (
dragState.trackId === finalTrackId &&
- existingClip.id === dragState.clipId
+ existingElement.id === dragState.elementId
) {
return false;
}
- const existingStart = existingClip.startTime;
+ const existingStart = existingElement.startTime;
const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
- return finalTime < existingEnd && movingClipEnd > existingStart;
+ return finalTime < existingEnd && movingElementEnd > existingStart;
});
if (!hasOverlap) {
if (dragState.trackId === finalTrackId) {
// Moving within same track
- updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
+ updateElementStartTime(finalTrackId, dragState.elementId!, finalTime);
} else {
// Moving to different track
- moveClipToTrack(dragState.trackId!, finalTrackId, dragState.clipId!);
+ moveElementToTrack(
+ dragState.trackId!,
+ finalTrackId,
+ dragState.elementId!
+ );
requestAnimationFrame(() => {
- updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
+ updateElementStartTime(
+ finalTrackId,
+ dragState.elementId!,
+ finalTime
+ );
});
}
}
setDragState({
isDragging: false,
- clipId: null,
+ elementId: null,
trackId: null,
startMouseX: 0,
- startClipTime: 0,
+ startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
});
},
- [dragState, tracks, updateClipStartTime, moveClipToTrack]
+ [dragState, tracks, updateElementStartTime, moveElementToTrack]
);
const cancelDrag = useCallback(() => {
setDragState({
isDragging: false,
- clipId: null,
+ elementId: null,
trackId: null,
startMouseX: 0,
- startClipTime: 0,
+ startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
});
@@ -176,12 +187,12 @@ export function useDragClip(zoomLevel: number) {
};
}, [dragState.isDragging, updateDrag, endDrag, cancelDrag]);
- const getDraggedClipPosition = useCallback(
- (clipId: string) => {
+ const getDraggedElementPosition = useCallback(
+ (elementId: string) => {
// Use ref to get current state, not stale closure
const currentDragState = dragStateRef.current;
const isMatch =
- currentDragState.isDragging && currentDragState.clipId === clipId;
+ currentDragState.isDragging && currentDragState.elementId === elementId;
if (isMatch) {
return currentDragState.currentTime;
@@ -209,7 +220,7 @@ export function useDragClip(zoomLevel: number) {
return {
// State
isDragging: dragState.isDragging,
- draggedClipId: dragState.clipId,
+ draggedElementId: dragState.elementId,
currentDragTime: dragState.currentTime,
clickOffsetTime: dragState.clickOffsetTime,
@@ -217,7 +228,7 @@ export function useDragClip(zoomLevel: number) {
startDrag,
endDrag,
cancelDrag,
- getDraggedClipPosition,
+ getDraggedElementPosition,
isValidDropTarget,
// Refs
diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts
index 2c689470..1191d4b4 100644
--- a/apps/web/src/hooks/use-playback-controls.ts
+++ b/apps/web/src/hooks/use-playback-controls.ts
@@ -7,106 +7,105 @@ export const usePlaybackControls = () => {
const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
const {
- selectedClips,
+ selectedElements,
tracks,
- splitClip,
+ splitElement,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
} = useTimelineStore();
- const handleSplitSelectedClip = useCallback(() => {
- if (selectedClips.length !== 1) {
- toast.error("Select exactly one clip to split");
+ const handleSplitSelectedElement = useCallback(() => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element to split");
return;
}
- const { trackId, clipId } = selectedClips[0];
+ const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
+ const element = track?.elements.find((e) => e.id === elementId);
- if (!clip) return;
+ if (!element) return;
- const effectiveStart = clip.startTime;
+ const effectiveStart = element.startTime;
const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected clip");
+ toast.error("Playhead must be within selected element");
return;
}
- splitClip(trackId, clipId, currentTime);
- toast.success("Clip split at playhead");
- }, [selectedClips, tracks, currentTime, splitClip]);
+ splitElement(trackId, elementId, currentTime);
+ }, [selectedElements, tracks, currentTime, splitElement]);
const handleSplitAndKeepLeftCallback = useCallback(() => {
- if (selectedClips.length !== 1) {
- toast.error("Select exactly one clip");
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element");
return;
}
- const { trackId, clipId } = selectedClips[0];
+ const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
+ const element = track?.elements.find((e) => e.id === elementId);
- if (!clip) return;
+ if (!element) return;
- const effectiveStart = clip.startTime;
+ const effectiveStart = element.startTime;
const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected clip");
+ toast.error("Playhead must be within selected element");
return;
}
- splitAndKeepLeft(trackId, clipId, currentTime);
- toast.success("Split and kept left portion");
- }, [selectedClips, tracks, currentTime, splitAndKeepLeft]);
+ splitAndKeepLeft(trackId, elementId, currentTime);
+ }, [selectedElements, tracks, currentTime, splitAndKeepLeft]);
const handleSplitAndKeepRightCallback = useCallback(() => {
- if (selectedClips.length !== 1) {
- toast.error("Select exactly one clip");
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element");
return;
}
- const { trackId, clipId } = selectedClips[0];
+ const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
+ const element = track?.elements.find((e) => e.id === elementId);
- if (!clip) return;
+ if (!element) return;
- const effectiveStart = clip.startTime;
+ const effectiveStart = element.startTime;
const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected clip");
+ toast.error("Playhead must be within selected element");
return;
}
- splitAndKeepRight(trackId, clipId, currentTime);
- toast.success("Split and kept right portion");
- }, [selectedClips, tracks, currentTime, splitAndKeepRight]);
+ splitAndKeepRight(trackId, elementId, currentTime);
+ }, [selectedElements, tracks, currentTime, splitAndKeepRight]);
const handleSeparateAudioCallback = useCallback(() => {
- if (selectedClips.length !== 1) {
- toast.error("Select exactly one video clip to separate audio");
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one media element to separate audio");
return;
}
- const { trackId, clipId } = selectedClips[0];
+ const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
- if (!track || track.type !== "video") {
- toast.error("Select a video clip to separate audio");
+ if (!track || track.type !== "media") {
+ toast.error("Select a media element to separate audio");
return;
}
- separateAudio(trackId, clipId);
- toast.success("Audio separated to audio track");
- }, [selectedClips, tracks, separateAudio]);
+ separateAudio(trackId, elementId);
+ }, [selectedElements, tracks, separateAudio]);
const handleKeyPress = useCallback(
(e: KeyboardEvent) => {
@@ -130,7 +129,7 @@ export const usePlaybackControls = () => {
case "s":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
- handleSplitSelectedClip();
+ handleSplitSelectedElement();
}
break;
@@ -160,7 +159,7 @@ export const usePlaybackControls = () => {
isPlaying,
play,
pause,
- handleSplitSelectedClip,
+ handleSplitSelectedElement,
handleSplitAndKeepLeftCallback,
handleSplitAndKeepRightCallback,
handleSeparateAudioCallback,
diff --git a/apps/web/src/lib/fetchGhStars.ts b/apps/web/src/lib/fetch-github-stars.ts
index e0236840..c4444a62 100644
--- a/apps/web/src/lib/fetchGhStars.ts
+++ b/apps/web/src/lib/fetch-github-stars.ts
@@ -10,7 +10,7 @@ export async function getStars(): Promise<string> {
if (!res.ok) {
throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
}
- const data = await res.json();
+ const data = (await res.json()) as { stargazers_count: number };
const count = data.stargazers_count;
if (typeof count !== "number") {
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts
index 48932db5..ab413b14 100644
--- a/apps/web/src/stores/media-store.ts
+++ b/apps/web/src/stores/media-store.ts
@@ -1,16 +1,25 @@
import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
+export type MediaType = "image" | "video" | "audio";
+
export interface MediaItem {
id: string;
name: string;
- type: "image" | "video" | "audio";
+ type: MediaType;
file: File;
- url: string; // Object URL for preview
+ url?: string; // Object URL for preview
thumbnailUrl?: string; // For video thumbnails
duration?: number; // For video/audio duration
width?: number; // For video/image width
height?: number; // For video/image height
+ // Text-specific properties
+ content?: string; // Text content
+ fontSize?: number; // Font size
+ fontFamily?: string; // Font family
+ color?: string; // Text color
+ backgroundColor?: string; // Background color
+ textAlign?: "left" | "center" | "right"; // Text alignment
}
interface MediaStore {
@@ -25,7 +34,7 @@ interface MediaStore {
}
// Helper function to determine file type
-export const getFileType = (file: File): "image" | "video" | "audio" | null => {
+export const getFileType = (file: File): MediaType | null => {
const { type } = file;
if (type.startsWith("image/")) {
@@ -46,7 +55,7 @@ export const getImageDimensions = (
file: File
): Promise<{ width: number; height: number }> => {
return new Promise((resolve, reject) => {
- const img = new Image();
+ const img = new window.Image();
img.addEventListener("load", () => {
const width = img.naturalWidth;
@@ -69,8 +78,8 @@ export const generateVideoThumbnail = (
file: File
): Promise<{ thumbnailUrl: string; width: number; height: number }> => {
return new Promise((resolve, reject) => {
- const video = document.createElement("video");
- const canvas = document.createElement("canvas");
+ const video = document.createElement("video") as HTMLVideoElement;
+ const canvas = document.createElement("canvas") as HTMLCanvasElement;
const ctx = canvas.getContext("2d");
if (!ctx) {
@@ -115,7 +124,7 @@ export const getMediaDuration = (file: File): Promise<number> => {
return new Promise((resolve, reject) => {
const element = document.createElement(
file.type.startsWith("video/") ? "video" : "audio"
- ) as HTMLVideoElement | HTMLAudioElement;
+ ) as HTMLVideoElement;
element.addEventListener("loadedmetadata", () => {
resolve(element.duration);
@@ -162,17 +171,17 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
console.error("Failed to save media item:", error);
// Remove from local state if save failed
set((state) => ({
- mediaItems: state.mediaItems.filter((item) => item.id !== newItem.id),
+ mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id),
}));
}
},
- removeMediaItem: async (id) => {
+ removeMediaItem: async (id: string) => {
const state = get();
- const item = state.mediaItems.find((item) => item.id === id);
+ const item = state.mediaItems.find((media) => media.id === id);
// Cleanup object URLs to prevent memory leaks
- if (item) {
+ if (item && item.url) {
URL.revokeObjectURL(item.url);
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
@@ -181,7 +190,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// Remove from local state immediately
set((state) => ({
- mediaItems: state.mediaItems.filter((item) => item.id !== id),
+ mediaItems: state.mediaItems.filter((media) => media.id !== id),
}));
// Remove from persistent storage
@@ -189,7 +198,6 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
await storageService.deleteMediaItem(id);
} catch (error) {
console.error("Failed to delete media item:", error);
- // Could re-add to local state here if needed
}
},
@@ -211,7 +219,9 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// Cleanup all object URLs
state.mediaItems.forEach((item) => {
- URL.revokeObjectURL(item.url);
+ if (item.url) {
+ URL.revokeObjectURL(item.url);
+ }
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 01f4fea6..ef08fffd 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -1,10 +1,14 @@
import { create } from "zustand";
-import type { TrackType } from "@/types/timeline";
+import type {
+ TrackType,
+ TimelineElement,
+ CreateTimelineElement,
+} from "@/types/timeline";
import { useEditorStore } from "./editor-store";
import { useMediaStore, getMediaAspectRatio } from "./media-store";
-// Helper function to manage clip naming with suffixes
-const getClipNameWithSuffix = (
+// Helper function to manage element naming with suffixes
+const getElementNameWithSuffix = (
originalName: string,
suffix: string
): string => {
@@ -18,21 +22,11 @@ const getClipNameWithSuffix = (
return `${baseName} (${suffix})`;
};
-export interface TimelineClip {
- id: string;
- mediaId: string;
- name: string;
- duration: number;
- startTime: number;
- trimStart: number;
- trimEnd: number;
-}
-
export interface TimelineTrack {
id: string;
name: string;
type: TrackType;
- clips: TimelineClip[];
+ elements: TimelineElement[];
muted?: boolean;
}
@@ -42,28 +36,30 @@ interface TimelineStore {
redoStack: TimelineTrack[][];
// Multi-selection
- selectedClips: { trackId: string; clipId: string }[];
- selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
- deselectClip: (trackId: string, clipId: string) => void;
- clearSelectedClips: () => void;
- setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
+ selectedElements: { trackId: string; elementId: string }[];
+ selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
+ deselectElement: (trackId: string, elementId: string) => void;
+ clearSelectedElements: () => void;
+ setSelectedElements: (
+ elements: { trackId: string; elementId: string }[]
+ ) => void;
// Drag state
dragState: {
isDragging: boolean;
- clipId: string | null;
+ elementId: string | null;
trackId: string | null;
startMouseX: number;
- startClipTime: number;
+ startElementTime: number;
clickOffsetTime: number;
currentTime: number;
};
setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
startDrag: (
- clipId: string,
+ elementId: string,
trackId: string,
startMouseX: number,
- startClipTime: number,
+ startElementTime: number,
clickOffsetTime: number
) => void;
updateDragTime: (currentTime: number) => void;
@@ -71,44 +67,45 @@ interface TimelineStore {
// Actions
addTrack: (type: TrackType) => string;
+ insertTrackAt: (type: TrackType, index: number) => string;
removeTrack: (trackId: string) => void;
- addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
- removeClipFromTrack: (trackId: string, clipId: string) => void;
- moveClipToTrack: (
+ addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
+ removeElementFromTrack: (trackId: string, elementId: string) => void;
+ moveElementToTrack: (
fromTrackId: string,
toTrackId: string,
- clipId: string
+ elementId: string
) => void;
- updateClipTrim: (
+ updateElementTrim: (
trackId: string,
- clipId: string,
+ elementId: string,
trimStart: number,
trimEnd: number
) => void;
- updateClipStartTime: (
+ updateElementStartTime: (
trackId: string,
- clipId: string,
+ elementId: string,
startTime: number
) => void;
toggleTrackMute: (trackId: string) => void;
- // Split operations for clips
- splitClip: (
+ // Split operations for elements
+ splitElement: (
trackId: string,
- clipId: string,
+ elementId: string,
splitTime: number
) => string | null;
splitAndKeepLeft: (
trackId: string,
- clipId: string,
+ elementId: string,
splitTime: number
) => void;
splitAndKeepRight: (
trackId: string,
- clipId: string,
+ elementId: string,
splitTime: number
) => void;
- separateAudio: (trackId: string, clipId: string) => string | null;
+ separateAudio: (trackId: string, elementId: string) => string | null;
// Computed values
getTotalDuration: () => number;
@@ -123,10 +120,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
tracks: [],
history: [],
redoStack: [],
- selectedClips: [],
+ selectedElements: [],
pushHistory: () => {
- const { tracks, history, redoStack } = get();
+ const { tracks, history } = get();
set({
history: [...history, JSON.parse(JSON.stringify(tracks))],
redoStack: [],
@@ -144,46 +141,62 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
});
},
- selectClip: (trackId, clipId, multi = false) => {
+ selectElement: (trackId, elementId, multi = false) => {
set((state) => {
- const exists = state.selectedClips.some(
- (c) => c.trackId === trackId && c.clipId === clipId
+ const exists = state.selectedElements.some(
+ (c) => c.trackId === trackId && c.elementId === elementId
);
if (multi) {
return exists
? {
- selectedClips: state.selectedClips.filter(
- (c) => !(c.trackId === trackId && c.clipId === clipId)
+ selectedElements: state.selectedElements.filter(
+ (c) => !(c.trackId === trackId && c.elementId === elementId)
),
}
- : { selectedClips: [...state.selectedClips, { trackId, clipId }] };
+ : {
+ selectedElements: [
+ ...state.selectedElements,
+ { trackId, elementId },
+ ],
+ };
} else {
- return { selectedClips: [{ trackId, clipId }] };
+ return { selectedElements: [{ trackId, elementId }] };
}
});
},
- deselectClip: (trackId, clipId) => {
+ deselectElement: (trackId, elementId) => {
set((state) => ({
- selectedClips: state.selectedClips.filter(
- (c) => !(c.trackId === trackId && c.clipId === clipId)
+ selectedElements: state.selectedElements.filter(
+ (c) => !(c.trackId === trackId && c.elementId === elementId)
),
}));
},
- clearSelectedClips: () => {
- set({ selectedClips: [] });
+ clearSelectedElements: () => {
+ set({ selectedElements: [] });
},
- setSelectedClips: (clips) => set({ selectedClips: clips }),
+ setSelectedElements: (elements) => set({ selectedElements: elements }),
addTrack: (type) => {
get().pushHistory();
+
+ // Generate proper track name based on type
+ const trackName =
+ type === "media"
+ ? "Media Track"
+ : type === "text"
+ ? "Text Track"
+ : type === "audio"
+ ? "Audio Track"
+ : "Track";
+
const newTrack: TimelineTrack = {
id: crypto.randomUUID(),
- name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
+ name: trackName,
type,
- clips: [],
+ elements: [],
muted: false,
};
set((state) => ({
@@ -192,6 +205,35 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
return newTrack.id;
},
+ insertTrackAt: (type, index) => {
+ get().pushHistory();
+
+ // Generate proper track name based on type
+ const trackName =
+ type === "media"
+ ? "Media Track"
+ : type === "text"
+ ? "Text Track"
+ : type === "audio"
+ ? "Audio Track"
+ : "Track";
+
+ const newTrack: TimelineTrack = {
+ id: crypto.randomUUID(),
+ name: trackName,
+ type,
+ elements: [],
+ muted: false,
+ };
+
+ set((state) => {
+ const newTracks = [...state.tracks];
+ newTracks.splice(index, 0, newTrack);
+ return { tracks: newTracks };
+ });
+ return newTrack.id;
+ },
+
removeTrack: (trackId) => {
get().pushHistory();
set((state) => ({
@@ -199,31 +241,64 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}));
},
- addClipToTrack: (trackId, clipData) => {
+ addElementToTrack: (trackId, elementData) => {
get().pushHistory();
- // Check if this is the first clip being added to the timeline
+ // Validate element type matches track type
+ const track = get().tracks.find((t) => t.id === trackId);
+ if (!track) {
+ console.error("Track not found:", trackId);
+ return;
+ }
+
+ // Validate element can be added to this track type
+ if (track.type === "media" && elementData.type !== "media") {
+ console.error("Media track only accepts media elements");
+ return;
+ }
+ if (track.type === "text" && elementData.type !== "text") {
+ console.error("Text track only accepts text elements");
+ return;
+ }
+ if (track.type === "audio" && elementData.type !== "media") {
+ console.error("Audio track only accepts media elements");
+ return;
+ }
+
+ // For media elements, validate mediaId exists
+ if (elementData.type === "media" && !elementData.mediaId) {
+ console.error("Media element must have mediaId");
+ return;
+ }
+
+ // For text elements, validate required text properties
+ if (elementData.type === "text" && !elementData.content) {
+ console.error("Text element must have content");
+ return;
+ }
+
+ // Check if this is the first element being added to the timeline
const currentState = get();
- const totalClipsInTimeline = currentState.tracks.reduce(
- (total, track) => total + track.clips.length,
+ const totalElementsInTimeline = currentState.tracks.reduce(
+ (total, track) => total + track.elements.length,
0
);
- const isFirstClip = totalClipsInTimeline === 0;
+ const isFirstElement = totalElementsInTimeline === 0;
- const newClip: TimelineClip = {
- ...clipData,
+ const newElement: TimelineElement = {
+ ...elementData,
id: crypto.randomUUID(),
- startTime: clipData.startTime || 0,
+ startTime: elementData.startTime || 0,
trimStart: 0,
trimEnd: 0,
- };
+ } as TimelineElement; // Type assertion since we trust the caller passes valid data
- // If this is the first clip, automatically set the project canvas size
+ // If this is the first element and it's a media element, automatically set the project canvas size
// to match the media's aspect ratio
- if (isFirstClip && clipData.mediaId) {
+ if (isFirstElement && newElement.type === "media") {
const mediaStore = useMediaStore.getState();
const mediaItem = mediaStore.mediaItems.find(
- (item) => item.id === clipData.mediaId
+ (item) => item.id === newElement.mediaId
);
if (
@@ -240,13 +315,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
- ? { ...track, clips: [...track.clips, newClip] }
+ ? { ...track, elements: [...track.elements, newElement] }
: track
),
}));
},
- removeClipFromTrack: (trackId, clipId) => {
+ removeElementFromTrack: (trackId, elementId) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks
@@ -254,21 +329,25 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
track.id === trackId
? {
...track,
- clips: track.clips.filter((clip) => clip.id !== clipId),
+ elements: track.elements.filter(
+ (element) => element.id !== elementId
+ ),
}
: track
)
- .filter((track) => track.clips.length > 0),
+ .filter((track) => track.elements.length > 0),
}));
},
- moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
+ moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
get().pushHistory();
set((state) => {
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
- const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
+ const elementToMove = fromTrack?.elements.find(
+ (element) => element.id === elementId
+ );
- if (!clipToMove) return state;
+ if (!elementToMove) return state;
return {
tracks: state.tracks
@@ -276,30 +355,34 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
if (track.id === fromTrackId) {
return {
...track,
- clips: track.clips.filter((clip) => clip.id !== clipId),
+ elements: track.elements.filter(
+ (element) => element.id !== elementId
+ ),
};
} else if (track.id === toTrackId) {
return {
...track,
- clips: [...track.clips, clipToMove],
+ elements: [...track.elements, elementToMove],
};
}
return track;
})
- .filter((track) => track.clips.length > 0),
+ .filter((track) => track.elements.length > 0),
};
});
},
- updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
+ updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
- clips: track.clips.map((clip) =>
- clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
+ elements: track.elements.map((element) =>
+ element.id === elementId
+ ? { ...element, trimStart, trimEnd }
+ : element
),
}
: track
@@ -307,15 +390,15 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}));
},
- updateClipStartTime: (trackId, clipId, startTime) => {
+ updateElementStartTime: (trackId, elementId, startTime) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
- clips: track.clips.map((clip) =>
- clip.id === clipId ? { ...clip, startTime } : clip
+ elements: track.elements.map((element) =>
+ element.id === elementId ? { ...element, startTime } : element
),
}
: track
@@ -332,47 +415,48 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}));
},
- splitClip: (trackId, clipId, splitTime) => {
+ splitElement: (trackId, elementId, splitTime) => {
const { tracks } = get();
const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!clip) return null;
+ if (!element) return null;
- const effectiveStart = clip.startTime;
+ const effectiveStart = element.startTime;
const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
get().pushHistory();
- const relativeTime = splitTime - clip.startTime;
+ const relativeTime = splitTime - element.startTime;
const firstDuration = relativeTime;
const secondDuration =
- clip.duration - clip.trimStart - clip.trimEnd - relativeTime;
+ element.duration - element.trimStart - element.trimEnd - relativeTime;
- const secondClipId = crypto.randomUUID();
+ const secondElementId = crypto.randomUUID();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
- clips: track.clips.flatMap((c) =>
- c.id === clipId
+ elements: track.elements.flatMap((c) =>
+ c.id === elementId
? [
{
...c,
trimEnd: c.trimEnd + secondDuration,
- name: getClipNameWithSuffix(c.name, "left"),
+ name: getElementNameWithSuffix(c.name, "left"),
},
{
...c,
- id: secondClipId,
+ id: secondElementId,
startTime: splitTime,
trimStart: c.trimStart + firstDuration,
- name: getClipNameWithSuffix(c.name, "right"),
+ name: getElementNameWithSuffix(c.name, "right"),
},
]
: [c]
@@ -382,40 +466,41 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
),
}));
- return secondClipId;
+ return secondElementId;
},
- // Split clip and keep only the left portion
- splitAndKeepLeft: (trackId, clipId, splitTime) => {
+ // Split element and keep only the left portion
+ splitAndKeepLeft: (trackId, elementId, splitTime) => {
const { tracks } = get();
const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!clip) return;
+ if (!element) return;
- const effectiveStart = clip.startTime;
+ const effectiveStart = element.startTime;
const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
get().pushHistory();
- const relativeTime = splitTime - clip.startTime;
+ const relativeTime = splitTime - element.startTime;
const durationToRemove =
- clip.duration - clip.trimStart - clip.trimEnd - relativeTime;
+ element.duration - element.trimStart - element.trimEnd - relativeTime;
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
- clips: track.clips.map((c) =>
- c.id === clipId
+ elements: track.elements.map((c) =>
+ c.id === elementId
? {
...c,
trimEnd: c.trimEnd + durationToRemove,
- name: getClipNameWithSuffix(c.name, "left"),
+ name: getElementNameWithSuffix(c.name, "left"),
}
: c
),
@@ -425,36 +510,37 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}));
},
- // Split clip and keep only the right portion
- splitAndKeepRight: (trackId, clipId, splitTime) => {
+ // Split element and keep only the right portion
+ splitAndKeepRight: (trackId, elementId, splitTime) => {
const { tracks } = get();
const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!clip) return;
+ if (!element) return;
- const effectiveStart = clip.startTime;
+ const effectiveStart = element.startTime;
const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
get().pushHistory();
- const relativeTime = splitTime - clip.startTime;
+ const relativeTime = splitTime - element.startTime;
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
- clips: track.clips.map((c) =>
- c.id === clipId
+ elements: track.elements.map((c) =>
+ c.id === elementId
? {
...c,
startTime: splitTime,
trimStart: c.trimStart + relativeTime,
- name: getClipNameWithSuffix(c.name, "right"),
+ name: getElementNameWithSuffix(c.name, "right"),
}
: c
),
@@ -464,33 +550,33 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}));
},
- // Extract audio from video clip to an audio track
- separateAudio: (trackId, clipId) => {
+ // Extract audio from video element to an audio track
+ separateAudio: (trackId, elementId) => {
const { tracks } = get();
const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!clip || track?.type !== "video") return null;
+ if (!element || track?.type !== "media") return null;
get().pushHistory();
// Find existing audio track or prepare to create one
const existingAudioTrack = tracks.find((t) => t.type === "audio");
- const audioClipId = crypto.randomUUID();
+ const audioElementId = crypto.randomUUID();
if (existingAudioTrack) {
- // Add audio clip to existing audio track
+ // Add audio element to existing audio track
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === existingAudioTrack.id
? {
...track,
- clips: [
- ...track.clips,
+ elements: [
+ ...track.elements,
{
- ...clip,
- id: audioClipId,
- name: getClipNameWithSuffix(clip.name, "audio"),
+ ...element,
+ id: audioElementId,
+ name: getElementNameWithSuffix(element.name, "audio"),
},
],
}
@@ -498,16 +584,16 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
),
}));
} else {
- // Create new audio track with the audio clip in a single atomic update
+ // Create new audio track with the audio element in a single atomic update
const newAudioTrack: TimelineTrack = {
id: crypto.randomUUID(),
name: "Audio Track",
type: "audio",
- clips: [
+ elements: [
{
- ...clip,
- id: audioClipId,
- name: getClipNameWithSuffix(clip.name, "audio"),
+ ...element,
+ id: audioElementId,
+ name: getElementNameWithSuffix(element.name, "audio"),
},
],
muted: false,
@@ -518,7 +604,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}));
}
- return audioClipId;
+ return audioElementId;
},
getTotalDuration: () => {
@@ -526,10 +612,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
if (tracks.length === 0) return 0;
const trackEndTimes = tracks.map((track) =>
- track.clips.reduce((maxEnd, clip) => {
- const clipEnd =
- clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
- return Math.max(maxEnd, clipEnd);
+ track.elements.reduce((maxEnd, element) => {
+ const elementEnd =
+ element.startTime +
+ element.duration -
+ element.trimStart -
+ element.trimEnd;
+ return Math.max(maxEnd, elementEnd);
}, 0)
);
@@ -545,10 +634,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
dragState: {
isDragging: false,
- clipId: null,
+ elementId: null,
trackId: null,
startMouseX: 0,
- startClipTime: 0,
+ startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
},
@@ -558,16 +647,22 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
dragState: { ...state.dragState, ...dragState },
})),
- startDrag: (clipId, trackId, startMouseX, startClipTime, clickOffsetTime) => {
+ startDrag: (
+ elementId,
+ trackId,
+ startMouseX,
+ startElementTime,
+ clickOffsetTime
+ ) => {
set({
dragState: {
isDragging: true,
- clipId,
+ elementId,
trackId,
startMouseX,
- startClipTime,
+ startElementTime,
clickOffsetTime,
- currentTime: startClipTime,
+ currentTime: startElementTime,
},
});
},
@@ -585,10 +680,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
set({
dragState: {
isDragging: false,
- clipId: null,
+ elementId: null,
trackId: null,
startMouseX: 0,
- startClipTime: 0,
+ startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
},
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index 54a433a7..daada718 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -1,20 +1,79 @@
-import { TimelineTrack, TimelineClip } from "@/stores/timeline-store";
+import { MediaType } from "@/stores/media-store";
+import { TimelineTrack } from "@/stores/timeline-store";
-export type TrackType = "video" | "audio" | "effects";
+export type TrackType = "media" | "text" | "audio";
-export interface TimelineClipProps {
- clip: TimelineClip;
+// Base element properties
+interface BaseTimelineElement {
+ id: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ trimStart: number;
+ trimEnd: number;
+}
+
+// Media element that references MediaStore
+export interface MediaElement extends BaseTimelineElement {
+ type: "media";
+ mediaId: string;
+}
+
+// Text element with embedded text data
+export interface TextElement extends BaseTimelineElement {
+ type: "text";
+ content: string;
+ fontSize: number;
+ fontFamily: string;
+ color: string;
+ backgroundColor: string;
+ textAlign: "left" | "center" | "right";
+ fontWeight: "normal" | "bold";
+ fontStyle: "normal" | "italic";
+ textDecoration: "none" | "underline" | "line-through";
+ x: number; // Position relative to canvas center
+ y: number; // Position relative to canvas center
+ rotation: number; // in degrees
+ opacity: number; // 0-1
+}
+
+// Typed timeline elements
+export type TimelineElement = MediaElement | TextElement;
+
+// Creation types (without id, for addElementToTrack)
+export type CreateMediaElement = Omit<MediaElement, "id">;
+export type CreateTextElement = Omit<TextElement, "id">;
+export type CreateTimelineElement = CreateMediaElement | CreateTextElement;
+
+export interface TimelineElementProps {
+ element: TimelineElement;
track: TimelineTrack;
zoomLevel: number;
isSelected: boolean;
- onClipMouseDown: (e: React.MouseEvent, clip: TimelineClip) => void;
- onClipClick: (e: React.MouseEvent, clip: TimelineClip) => void;
+ onElementMouseDown: (e: React.MouseEvent, element: TimelineElement) => void;
+ onElementClick: (e: React.MouseEvent, element: TimelineElement) => void;
}
export interface ResizeState {
- clipId: string;
+ elementId: string;
side: "left" | "right";
startX: number;
initialTrimStart: number;
initialTrimEnd: number;
}
+
+// Drag data types for type-safe drag and drop
+export interface MediaItemDragData {
+ id: string;
+ type: MediaType;
+ name: string;
+}
+
+export interface TextItemDragData {
+ id: string;
+ type: "text";
+ name: string;
+ content: string;
+}
+
+export type DragData = MediaItemDragData | TextItemDragData;