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 17893156..393d72db 100644
--- a/apps/web/src/components/editor/media-panel/views/media.tsx
+++ b/apps/web/src/components/editor/media-panel/views/media.tsx
@@ -24,7 +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";
+import { useTimelineStore } from "@/stores/timeline-store";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
@@ -289,7 +289,11 @@ export function MediaView() {
name: item.name,
}}
showPlusOnDrag={false}
- onAddToTimeline={(currentTime) => addMediaToTimeline(item, currentTime)}
+ onAddToTimeline={(currentTime) =>
+ useTimelineStore
+ .getState()
+ .addMediaAtTime(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 fad79343..6d0d310b 100644
--- a/apps/web/src/components/editor/media-panel/views/text.tsx
+++ b/apps/web/src/components/editor/media-panel/views/text.tsx
@@ -1,6 +1,6 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { addTextToTimeline } from "@/lib/timeline-utils";
+import { useTimelineStore } from "@/stores/timeline-store";
import { type TextElement } from "@/types/timeline";
let textData: TextElement = {
@@ -43,7 +43,9 @@ export function TextView() {
content: textData.content,
}}
aspectRatio={1}
- onAddToTimeline={(currentTime) => addTextToTimeline(textData, currentTime)}
+ onAddToTimeline={(currentTime) =>
+ useTimelineStore.getState().addTextAtTime(textData, currentTime)
+ }
showLabel={false}
/>
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index ff9e0694..c1603542 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -48,7 +48,6 @@ 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,
@@ -229,7 +228,7 @@ export function Timeline() {
Math.min(
duration,
(mouseX + scrollLeft) /
- (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
+ (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
)
);
@@ -377,7 +376,7 @@ export function Timeline() {
if (dragData.type === "text") {
// Always create new text track to avoid overlaps
- addTextToNewTrack(dragData);
+ useTimelineStore.getState().addTextToNewTrack(dragData);
} else {
// Handle media items
const mediaItem = mediaItems.find((item) => item.id === dragData.id);
@@ -386,7 +385,7 @@ export function Timeline() {
return;
}
- addMediaToNewTrack(mediaItem);
+ useTimelineStore.getState().addMediaToNewTrack(mediaItem);
}
} catch (error) {
console.error("Error parsing dropped item data:", error);
@@ -414,7 +413,7 @@ export function Timeline() {
item.name === processedItem.name && item.url === processedItem.url
);
if (addedItem) {
- addMediaToNewTrack(addedItem);
+ useTimelineStore.getState().addMediaToNewTrack(addedItem);
}
}
} catch (error) {
@@ -902,19 +901,21 @@ export function Timeline() {
return (
{(() => {
const formatTime = (seconds: number) => {
diff --git a/apps/web/src/lib/timeline-utils.ts b/apps/web/src/lib/timeline-utils.ts
deleted file mode 100644
index 994cb314..00000000
--- a/apps/web/src/lib/timeline-utils.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-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);
-};
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index f6b9bfd8..9a2c7361 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -5,15 +5,22 @@ import {
CreateTimelineElement,
TimelineTrack,
TextElement,
+ DragData,
sortTracksByOrder,
ensureMainTrack,
validateElementTrackCompatibility,
} from "@/types/timeline";
import { useEditorStore } from "./editor-store";
-import { useMediaStore, getMediaAspectRatio } from "./media-store";
+import {
+ useMediaStore,
+ getMediaAspectRatio,
+ type MediaItem,
+} from "./media-store";
import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
import { generateUUID } from "@/lib/utils";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { toast } from "sonner";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
@@ -166,6 +173,17 @@ interface TimelineStore {
>
>
) => void;
+ checkElementOverlap: (
+ trackId: string,
+ startTime: number,
+ duration: number,
+ excludeElementId?: string
+ ) => boolean;
+ findOrCreateTrack: (trackType: TrackType) => string;
+ addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean;
+ addTextAtTime: (item: TextElement, currentTime?: number) => boolean;
+ addMediaToNewTrack: (item: MediaItem) => boolean;
+ addTextToNewTrack: (item: TextElement | DragData) => boolean;
}
export const useTimelineStore = create((set, get) => {
@@ -963,5 +981,149 @@ export const useTimelineStore = create((set, get) => {
toggleSnapping: () => {
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
},
+
+ checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
+ const track = get()._tracks.find((t) => t.id === trackId);
+ if (!track) return false;
+
+ const overlap = track.elements.some((element) => {
+ const elementEnd =
+ element.startTime +
+ element.duration -
+ element.trimStart -
+ element.trimEnd;
+
+ if (element.id === excludeElementId) {
+ return false;
+ }
+
+ return (
+ (startTime >= element.startTime && startTime < elementEnd) ||
+ (startTime + duration > element.startTime &&
+ startTime + duration <= elementEnd) ||
+ (startTime < element.startTime && startTime + duration > elementEnd)
+ );
+ });
+ return overlap;
+ },
+
+ findOrCreateTrack: (trackType) => {
+ // Always create new text track to allow multiple text elements
+ if (trackType === "text") {
+ return get().addTrack(trackType);
+ }
+
+ const existingTrack = get()._tracks.find((t) => t.type === trackType);
+ if (existingTrack) {
+ return existingTrack.id;
+ }
+
+ return get().addTrack(trackType);
+ },
+
+ addMediaAtTime: (item, currentTime = 0) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().findOrCreateTrack(trackType);
+
+ const duration =
+ item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
+
+ if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements"
+ );
+ return false;
+ }
+
+ get().addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: item.id,
+ name: item.name,
+ duration,
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ return true;
+ },
+
+ addTextAtTime: (item, currentTime = 0) => {
+ const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
+
+ get().addElementToTrack(targetTrackId, {
+ type: "text",
+ name: item.name || "Text",
+ content: item.content || "Default Text",
+ duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: item.fontSize || 48,
+ fontFamily: item.fontFamily || "Arial",
+ color: item.color || "#ffffff",
+ backgroundColor: item.backgroundColor || "transparent",
+ textAlign: item.textAlign || "center",
+ fontWeight: item.fontWeight || "normal",
+ fontStyle: item.fontStyle || "normal",
+ textDecoration: item.textDecoration || "none",
+ x: item.x || 0,
+ y: item.y || 0,
+ rotation: item.rotation || 0,
+ opacity: item.opacity !== undefined ? item.opacity : 1,
+ });
+ return true;
+ },
+
+ addMediaToNewTrack: (item) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().findOrCreateTrack(trackType);
+
+ get().addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: item.id,
+ name: item.name,
+ duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ return true;
+ },
+
+ addTextToNewTrack: (item) => {
+ const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
+
+ get().addElementToTrack(targetTrackId, {
+ type: "text",
+ name: item.name || "Text",
+ content:
+ ("content" in item ? item.content : "Default Text") || "Default Text",
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: ("fontSize" in item ? item.fontSize : 48) || 48,
+ fontFamily:
+ ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial",
+ color: ("color" in item ? item.color : "#ffffff") || "#ffffff",
+ backgroundColor:
+ ("backgroundColor" in item ? item.backgroundColor : "transparent") ||
+ "transparent",
+ textAlign:
+ ("textAlign" in item ? item.textAlign : "center") || "center",
+ fontWeight:
+ ("fontWeight" in item ? item.fontWeight : "normal") || "normal",
+ fontStyle:
+ ("fontStyle" in item ? item.fontStyle : "normal") || "normal",
+ textDecoration:
+ ("textDecoration" in item ? item.textDecoration : "none") || "none",
+ x: ("x" in item ? item.x : 0) || 0,
+ y: ("y" in item ? item.y : 0) || 0,
+ rotation: ("rotation" in item ? item.rotation : 0) || 0,
+ opacity:
+ "opacity" in item && item.opacity !== undefined ? item.opacity : 1,
+ });
+ return true;
+ },
};
});