mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'feature/plus-button-timeline' into staging
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
|
import { addMediaToTimeline } from "@/lib/timeline-utils";
|
||||||
|
|
||||||
export function MediaView() {
|
export function MediaView() {
|
||||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||||
@@ -288,6 +289,7 @@ export function MediaView() {
|
|||||||
name: item.name,
|
name: item.name,
|
||||||
}}
|
}}
|
||||||
showPlusOnDrag={false}
|
showPlusOnDrag={false}
|
||||||
|
onAddToTimeline={(currentTime) => addMediaToTimeline(item, currentTime)}
|
||||||
rounded={false}
|
rounded={false}
|
||||||
/>
|
/>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|||||||
@@ -1,4 +1,30 @@
|
|||||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
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() {
|
export function TextView() {
|
||||||
return (
|
return (
|
||||||
@@ -11,12 +37,13 @@ export function TextView() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
dragData={{
|
dragData={{
|
||||||
id: "default-text",
|
id: textData.id,
|
||||||
type: "text",
|
type: textData.type,
|
||||||
name: "Default text",
|
name: textData.name,
|
||||||
content: "Default text",
|
content: textData.content,
|
||||||
}}
|
}}
|
||||||
aspectRatio={1}
|
aspectRatio={1}
|
||||||
|
onAddToTimeline={(currentTime) => addTextToTimeline(textData, currentTime)}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { useSelectionBox } from "@/hooks/use-selection-box";
|
|||||||
import { SnapIndicator } from "./snap-indicator";
|
import { SnapIndicator } from "./snap-indicator";
|
||||||
import { SnapPoint } from "@/hooks/use-timeline-snapping";
|
import { SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||||
import type { DragData, TimelineTrack } from "@/types/timeline";
|
import type { DragData, TimelineTrack } from "@/types/timeline";
|
||||||
|
import { addTextToNewTrack, addMediaToNewTrack } from "@/lib/timeline-utils";
|
||||||
import {
|
import {
|
||||||
getTrackHeight,
|
getTrackHeight,
|
||||||
getCumulativeHeightBefore,
|
getCumulativeHeightBefore,
|
||||||
@@ -228,7 +229,7 @@ export function Timeline() {
|
|||||||
Math.min(
|
Math.min(
|
||||||
duration,
|
duration,
|
||||||
(mouseX + scrollLeft) /
|
(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") {
|
if (dragData.type === "text") {
|
||||||
// Always create new text track to avoid overlaps
|
// Always create new text track to avoid overlaps
|
||||||
const newTrackId = addTrack("text");
|
addTextToNewTrack(dragData);
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Handle media items
|
// Handle media items
|
||||||
const mediaItem = mediaItems.find((item) => item.id === dragData.id);
|
const mediaItem = mediaItems.find((item) => item.id === dragData.id);
|
||||||
@@ -407,19 +386,7 @@ export function Timeline() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackType = dragData.type === "audio" ? "audio" : "media";
|
addMediaToNewTrack(mediaItem);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error parsing dropped item data:", error);
|
console.error("Error parsing dropped item data:", error);
|
||||||
@@ -447,18 +414,7 @@ export function Timeline() {
|
|||||||
item.name === processedItem.name && item.url === processedItem.url
|
item.name === processedItem.name && item.url === processedItem.url
|
||||||
);
|
);
|
||||||
if (addedItem) {
|
if (addedItem) {
|
||||||
const trackType =
|
addMediaToNewTrack(addedItem);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -946,21 +902,19 @@ export function Timeline() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`absolute top-0 bottom-0 ${
|
className={`absolute top-0 bottom-0 ${isMainMarker
|
||||||
isMainMarker
|
|
||||||
? "border-l border-muted-foreground/40"
|
? "border-l border-muted-foreground/40"
|
||||||
: "border-l border-muted-foreground/20"
|
: "border-l border-muted-foreground/20"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`absolute top-1 left-1 text-[0.6rem] ${
|
className={`absolute top-1 left-1 text-[0.6rem] ${isMainMarker
|
||||||
isMainMarker
|
|
||||||
? "text-muted-foreground font-medium"
|
? "text-muted-foreground font-medium"
|
||||||
: "text-muted-foreground/70"
|
: "text-muted-foreground/70"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { ReactNode, useState, useRef, useEffect } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
|
|
||||||
export interface DraggableMediaItemProps {
|
export interface DraggableMediaItemProps {
|
||||||
name: string;
|
name: string;
|
||||||
preview: ReactNode;
|
preview: ReactNode;
|
||||||
dragData: Record<string, any>;
|
dragData: Record<string, any>;
|
||||||
onDragStart?: (e: React.DragEvent) => void;
|
onDragStart?: (e: React.DragEvent) => void;
|
||||||
|
onAddToTimeline?: (currentTime: number) => void;
|
||||||
aspectRatio?: number;
|
aspectRatio?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
showPlusOnDrag?: boolean;
|
showPlusOnDrag?: boolean;
|
||||||
@@ -24,6 +26,7 @@ export function DraggableMediaItem({
|
|||||||
preview,
|
preview,
|
||||||
dragData,
|
dragData,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
|
onAddToTimeline,
|
||||||
aspectRatio = 16 / 9,
|
aspectRatio = 16 / 9,
|
||||||
className = "",
|
className = "",
|
||||||
showPlusOnDrag = true,
|
showPlusOnDrag = true,
|
||||||
@@ -33,6 +36,11 @@ export function DraggableMediaItem({
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||||
const dragRef = useRef<HTMLDivElement>(null);
|
const dragRef = useRef<HTMLDivElement>(null);
|
||||||
|
const currentTime = usePlaybackStore((state) => state.currentTime);
|
||||||
|
|
||||||
|
const handleAddToTimeline = () => {
|
||||||
|
onAddToTimeline?.(currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
const emptyImg = new window.Image();
|
const emptyImg = new window.Image();
|
||||||
emptyImg.src =
|
emptyImg.src =
|
||||||
@@ -92,7 +100,10 @@ export function DraggableMediaItem({
|
|||||||
>
|
>
|
||||||
{preview}
|
{preview}
|
||||||
{!isDragging && (
|
{!isDragging && (
|
||||||
<PlusButton className="opacity-0 group-hover:opacity-100" />
|
<PlusButton
|
||||||
|
className="opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={handleAddToTimeline}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
{showLabel && (
|
{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">
|
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
|
||||||
{preview}
|
{preview}
|
||||||
</div>
|
</div>
|
||||||
{showPlusOnDrag && <PlusButton />}
|
{showPlusOnDrag && <PlusButton onClick={handleAddToTimeline} tooltipText="Add to timeline or drag to position" />}
|
||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -138,11 +149,16 @@ export function DraggableMediaItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlusButton({ className }: { className?: string }) {
|
function PlusButton({ className, onClick }: { className?: string; onClick?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn("absolute bottom-2 right-2 size-4", className)}
|
className={cn("absolute bottom-2 right-2 size-4", className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="!size-3" />
|
<Plus className="!size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const TIMELINE_CONSTANTS = {
|
|||||||
PIXELS_PER_SECOND: 50,
|
PIXELS_PER_SECOND: 50,
|
||||||
TRACK_HEIGHT: 60, // Default fallback
|
TRACK_HEIGHT: 60, // Default fallback
|
||||||
DEFAULT_TEXT_DURATION: 5,
|
DEFAULT_TEXT_DURATION: 5,
|
||||||
|
DEFAULT_IMAGE_DURATION: 5,
|
||||||
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
|
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user