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 5438737f..17893156 100644
--- a/apps/web/src/components/editor/media-panel/views/media.tsx
+++ b/apps/web/src/components/editor/media-panel/views/media.tsx
@@ -24,6 +24,7 @@ import {
} from "@/components/ui/select";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { useProjectStore } from "@/stores/project-store";
+import { addMediaToTimeline } from "@/lib/timeline-utils";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
@@ -288,6 +289,7 @@ export function MediaView() {
name: item.name,
}}
showPlusOnDrag={false}
+ onAddToTimeline={(currentTime) => addMediaToTimeline(item, currentTime)}
rounded={false}
/>
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 bec32c85..fad79343 100644
--- a/apps/web/src/components/editor/media-panel/views/text.tsx
+++ b/apps/web/src/components/editor/media-panel/views/text.tsx
@@ -1,4 +1,30 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { addTextToTimeline } from "@/lib/timeline-utils";
+import { type TextElement } from "@/types/timeline";
+
+let textData: TextElement = {
+ id: "default-text",
+ type: "text",
+ name: "Default text",
+ content: "Default text",
+ fontSize: 48,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ backgroundColor: "transparent",
+ textAlign: "center" as const,
+ fontWeight: "normal" as const,
+ fontStyle: "normal" as const,
+ textDecoration: "none" as const,
+ x: 0,
+ y: 0,
+ rotation: 0,
+ opacity: 1,
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+};
export function TextView() {
return (
@@ -11,12 +37,13 @@ export function TextView() {
}
dragData={{
- id: "default-text",
- type: "text",
- name: "Default text",
- content: "Default text",
+ id: textData.id,
+ type: textData.type,
+ name: textData.name,
+ content: textData.content,
}}
aspectRatio={1}
+ onAddToTimeline={(currentTime) => addTextToTimeline(textData, currentTime)}
showLabel={false}
/>
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index e83b64b9..ff9e0694 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -48,6 +48,7 @@ import { useSelectionBox } from "@/hooks/use-selection-box";
import { SnapIndicator } from "./snap-indicator";
import { SnapPoint } from "@/hooks/use-timeline-snapping";
import type { DragData, TimelineTrack } from "@/types/timeline";
+import { addTextToNewTrack, addMediaToNewTrack } from "@/lib/timeline-utils";
import {
getTrackHeight,
getCumulativeHeightBefore,
@@ -228,7 +229,7 @@ export function Timeline() {
Math.min(
duration,
(mouseX + scrollLeft) /
- (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
+ (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
)
);
@@ -376,29 +377,7 @@ export function Timeline() {
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: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- 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,
- });
+ addTextToNewTrack(dragData);
} else {
// Handle media items
const mediaItem = mediaItems.find((item) => item.id === dragData.id);
@@ -407,19 +386,7 @@ export function Timeline() {
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,
- });
+ addMediaToNewTrack(mediaItem);
}
} catch (error) {
console.error("Error parsing dropped item data:", error);
@@ -447,18 +414,7 @@ export function Timeline() {
item.name === processedItem.name && item.url === processedItem.url
);
if (addedItem) {
- const trackType =
- processedItem.type === "audio" ? "audio" : "media";
- const newTrackId = addTrack(trackType);
- addElementToTrack(newTrackId, {
- type: "media",
- mediaId: addedItem.id,
- name: addedItem.name,
- duration: addedItem.duration || 5,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
+ addMediaToNewTrack(addedItem);
}
}
} catch (error) {
@@ -946,21 +902,19 @@ export function Timeline() {
return (
{(() => {
const formatTime = (seconds: number) => {
diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx
index 16518dde..c1f81e16 100644
--- a/apps/web/src/components/ui/draggable-item.tsx
+++ b/apps/web/src/components/ui/draggable-item.tsx
@@ -6,12 +6,14 @@ import { ReactNode, useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
+import { usePlaybackStore } from "@/stores/playback-store";
export interface DraggableMediaItemProps {
name: string;
preview: ReactNode;
dragData: Record;
onDragStart?: (e: React.DragEvent) => void;
+ onAddToTimeline?: (currentTime: number) => void;
aspectRatio?: number;
className?: string;
showPlusOnDrag?: boolean;
@@ -24,6 +26,7 @@ export function DraggableMediaItem({
preview,
dragData,
onDragStart,
+ onAddToTimeline,
aspectRatio = 16 / 9,
className = "",
showPlusOnDrag = true,
@@ -33,6 +36,11 @@ export function DraggableMediaItem({
const [isDragging, setIsDragging] = useState(false);
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const dragRef = useRef(null);
+ const currentTime = usePlaybackStore((state) => state.currentTime);
+
+ const handleAddToTimeline = () => {
+ onAddToTimeline?.(currentTime);
+ };
const emptyImg = new window.Image();
emptyImg.src =
@@ -92,7 +100,10 @@ export function DraggableMediaItem({
>
{preview}
{!isDragging && (
-
+
)}
{showLabel && (
@@ -128,7 +139,7 @@ export function DraggableMediaItem({
{preview}
- {showPlusOnDrag && }
+ {showPlusOnDrag && }
,
@@ -138,11 +149,16 @@ export function DraggableMediaItem({
);
}
-function PlusButton({ className }: { className?: string }) {
+function PlusButton({ className, onClick }: { className?: string; onClick?: () => void }) {
return (
diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts
index 92045c5e..8c76a2e8 100644
--- a/apps/web/src/constants/timeline-constants.ts
+++ b/apps/web/src/constants/timeline-constants.ts
@@ -74,6 +74,7 @@ export const TIMELINE_CONSTANTS = {
PIXELS_PER_SECOND: 50,
TRACK_HEIGHT: 60, // Default fallback
DEFAULT_TEXT_DURATION: 5,
+ DEFAULT_IMAGE_DURATION: 5,
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
} as const;
diff --git a/apps/web/src/lib/timeline-utils.ts b/apps/web/src/lib/timeline-utils.ts
new file mode 100644
index 00000000..994cb314
--- /dev/null
+++ b/apps/web/src/lib/timeline-utils.ts
@@ -0,0 +1,117 @@
+import { useTimelineStore } from "@/stores/timeline-store";
+import { type MediaItem } from "@/stores/media-store";
+import { toast } from "sonner";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { DragData, TextElement } from "@/types/timeline";
+
+
+const findOrCreateTrack = (trackType: "media" | "audio" | "text") => {
+ const timelineStore = useTimelineStore.getState();
+
+ // Always create new text track to allow multiple text elements
+ if (trackType === "text") {
+ return timelineStore.addTrack(trackType);
+ }
+
+ const existingTrack = timelineStore.tracks.find(track => track.type === trackType);
+ return existingTrack ? existingTrack.id : timelineStore.addTrack(trackType);
+};
+
+
+const checkOverlap = (trackId: string, startTime: number, duration: number, excludeElementId?: string) => {
+ const timelineStore = useTimelineStore.getState();
+ const targetTrack = timelineStore.tracks.find(track => track.id === trackId);
+
+ if (!targetTrack) {
+ return true;
+ }
+
+ const elementEnd = startTime + duration;
+
+ return targetTrack.elements.some((existingElement) => {
+ if (excludeElementId && existingElement.id === excludeElementId) {
+ return false;
+ }
+
+ const existingStart = existingElement.startTime;
+ const existingEnd = existingElement.startTime +
+ (existingElement.duration - existingElement.trimStart - existingElement.trimEnd);
+
+ return startTime < existingEnd && elementEnd > existingStart;
+ });
+};
+
+const addMediaElement = (trackId: string, item: MediaItem, startTime: number) => {
+ const timelineStore = useTimelineStore.getState();
+
+ timelineStore.addElementToTrack(trackId, {
+ type: "media",
+ mediaId: item.id,
+ name: item.name,
+ duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+};
+
+const addTextElement = (trackId: string, item: TextElement | DragData, startTime: number) => {
+ const timelineStore = useTimelineStore.getState();
+
+ timelineStore.addElementToTrack(trackId, {
+ type: "text",
+ name: item.name,
+ content: ("content" in item ? item.content : "Default Text"),
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime,
+ trimStart: ("trimStart" in item ? item.trimStart : 0),
+ trimEnd: ("trimEnd" in item ? item.trimEnd : 0),
+ fontSize: ("fontSize" in item ? item.fontSize : 48),
+ fontFamily: ("fontFamily" in item ? item.fontFamily : "Arial"),
+ color: ("color" in item ? item.color : "#ffffff"),
+ backgroundColor: ("backgroundColor" in item ? item.backgroundColor : "transparent"),
+ textAlign: ("textAlign" in item ? item.textAlign : "center"),
+ fontWeight: ("fontWeight" in item ? item.fontWeight : "normal"),
+ fontStyle: ("fontStyle" in item ? item.fontStyle : "normal"),
+ textDecoration: ("textDecoration" in item ? item.textDecoration : "none"),
+ x: ("x" in item ? item.x : 0),
+ y: ("y" in item ? item.y : 0),
+ rotation: ("rotation" in item ? item.rotation : 0),
+ opacity: ("opacity" in item && item.opacity !== undefined ? item.opacity : 1),
+ });
+};
+
+
+// Adds a media item to the timeline at the specified time
+export const addMediaToTimeline = (item: MediaItem, currentTime: number = 0) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = findOrCreateTrack(trackType);
+
+ const duration = item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
+
+ if (checkOverlap(targetTrackId, currentTime, duration)) {
+ toast.error("Cannot place element here - it would overlap with existing elements");
+ return;
+ }
+
+ addMediaElement(targetTrackId, item, currentTime);
+};
+
+// Adds a text item to the timeline at the specified time
+export const addTextToTimeline = (item: TextElement, currentTime: number = 0) => {
+ const targetTrackId = findOrCreateTrack("text");
+ addTextElement(targetTrackId, item, currentTime);
+};
+
+// Adds a media item to timeline
+export const addMediaToNewTrack = (item: MediaItem) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = findOrCreateTrack(trackType);
+ addMediaElement(targetTrackId, item, 0);
+};
+
+// Adds a text item to timeline
+export const addTextToNewTrack = (item: TextElement | DragData) => {
+ const targetTrackId = findOrCreateTrack("text");
+ addTextElement(targetTrackId, item, 0);
+};