diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-07-15 16:28:08 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-07-15 16:28:08 +0200 |
| commit | fdeddbe3edf761ad41a5cd6bf9e1fb7f18624453 (patch) | |
| tree | 24d9e6a21f5811779e8675d6e0e97e89953c613e /apps | |
| parent | b1ccf3695e3c955de0ffe11ff2cf2dfec37a049d (diff) | |
| parent | 03b3c952c75b7642b537fa2499d081edd1a0a1e4 (diff) | |
| download | opencut-fdeddbe3edf761ad41a5cd6bf9e1fb7f18624453.tar.gz | |
Merge branch 'feature/plus-button-timeline' into staging
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/media.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/text.tsx | 35 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 64 | ||||
| -rw-r--r-- | apps/web/src/components/ui/draggable-item.tsx | 22 | ||||
| -rw-r--r-- | apps/web/src/constants/timeline-constants.ts | 1 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline-utils.ts | 117 |
6 files changed, 179 insertions, 62 deletions
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}
/>
</ContextMenuTrigger>
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() { </div>
}
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}
/>
</div>
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 ( <div key={i} - className={`absolute top-0 bottom-0 ${ - isMainMarker + className={`absolute top-0 bottom-0 ${isMainMarker ? "border-l border-muted-foreground/40" : "border-l border-muted-foreground/20" - }`} + }`} style={{ left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, }} > <span - className={`absolute top-1 left-1 text-[0.6rem] ${ - isMainMarker + className={`absolute top-1 left-1 text-[0.6rem] ${isMainMarker ? "text-muted-foreground font-medium" : "text-muted-foreground/70" - }`} + }`} > {(() => { 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<string, any>; 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<HTMLDivElement>(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 && ( - <PlusButton className="opacity-0 group-hover:opacity-100" /> + <PlusButton + className="opacity-0 group-hover:opacity-100" + onClick={handleAddToTimeline} + /> )} </AspectRatio> {showLabel && ( @@ -128,7 +139,7 @@ export function DraggableMediaItem({ <div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none"> {preview} </div> - {showPlusOnDrag && <PlusButton />} + {showPlusOnDrag && <PlusButton onClick={handleAddToTimeline} tooltipText="Add to timeline or drag to position" />} </AspectRatio> </div> </div>, @@ -138,11 +149,16 @@ export function DraggableMediaItem({ ); } -function PlusButton({ className }: { className?: string }) { +function PlusButton({ className, onClick }: { className?: string; onClick?: () => void }) { return ( <Button size="icon" className={cn("absolute bottom-2 right-2 size-4", className)} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onClick?.(); + }} > <Plus className="!size-3" /> </Button> 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); +}; |
