Merge branch 'feature/plus-button-timeline' into staging
This commit is contained in:
commit
fdeddbe3ed
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
Loading…
Reference in New Issue