mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: ripple editing mode on timeline
This commit is contained in:
@@ -1,386 +1,392 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import {
|
import {
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Scissors,
|
Scissors,
|
||||||
Trash2,
|
Trash2,
|
||||||
SplitSquareHorizontal,
|
SplitSquareHorizontal,
|
||||||
Music,
|
Music,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Type,
|
Type,
|
||||||
Copy,
|
Copy,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMediaStore } from "@/stores/media-store";
|
import { useMediaStore } from "@/stores/media-store";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import AudioWaveform from "./audio-waveform";
|
import AudioWaveform from "./audio-waveform";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { TimelineElementProps, TrackType } from "@/types/timeline";
|
import { TimelineElementProps, TrackType } from "@/types/timeline";
|
||||||
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
|
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
|
||||||
import {
|
import {
|
||||||
getTrackElementClasses,
|
getTrackElementClasses,
|
||||||
TIMELINE_CONSTANTS,
|
TIMELINE_CONSTANTS,
|
||||||
getTrackHeight,
|
getTrackHeight,
|
||||||
} from "@/constants/timeline-constants";
|
} from "@/constants/timeline-constants";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "../ui/context-menu";
|
} from "../ui/context-menu";
|
||||||
|
|
||||||
export function TimelineElement({
|
export function TimelineElement({
|
||||||
element,
|
element,
|
||||||
track,
|
track,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
isSelected,
|
isSelected,
|
||||||
onElementMouseDown,
|
onElementMouseDown,
|
||||||
onElementClick,
|
onElementClick,
|
||||||
}: TimelineElementProps) {
|
}: TimelineElementProps) {
|
||||||
const { mediaItems } = useMediaStore();
|
const { mediaItems } = useMediaStore();
|
||||||
const {
|
const {
|
||||||
updateElementTrim,
|
updateElementTrim,
|
||||||
updateElementDuration,
|
updateElementDuration,
|
||||||
removeElementFromTrack,
|
removeElementFromTrack,
|
||||||
dragState,
|
removeElementFromTrackWithRipple,
|
||||||
splitElement,
|
dragState,
|
||||||
splitAndKeepLeft,
|
splitElement,
|
||||||
splitAndKeepRight,
|
splitAndKeepLeft,
|
||||||
separateAudio,
|
splitAndKeepRight,
|
||||||
addElementToTrack,
|
separateAudio,
|
||||||
replaceElementMedia,
|
addElementToTrack,
|
||||||
} = useTimelineStore();
|
replaceElementMedia,
|
||||||
const { currentTime } = usePlaybackStore();
|
rippleEditingEnabled,
|
||||||
|
} = useTimelineStore();
|
||||||
const [elementMenuOpen, setElementMenuOpen] = useState(false);
|
const { currentTime } = usePlaybackStore();
|
||||||
|
|
||||||
const {
|
const [elementMenuOpen, setElementMenuOpen] = useState(false);
|
||||||
resizing,
|
|
||||||
isResizing,
|
const {
|
||||||
handleResizeStart,
|
resizing,
|
||||||
handleResizeMove,
|
isResizing,
|
||||||
handleResizeEnd,
|
handleResizeStart,
|
||||||
} = useTimelineElementResize({
|
handleResizeMove,
|
||||||
element,
|
handleResizeEnd,
|
||||||
track,
|
} = useTimelineElementResize({
|
||||||
zoomLevel,
|
element,
|
||||||
onUpdateTrim: updateElementTrim,
|
track,
|
||||||
onUpdateDuration: updateElementDuration,
|
zoomLevel,
|
||||||
});
|
onUpdateTrim: updateElementTrim,
|
||||||
|
onUpdateDuration: updateElementDuration,
|
||||||
const effectiveDuration =
|
});
|
||||||
element.duration - element.trimStart - element.trimEnd;
|
|
||||||
const elementWidth = Math.max(
|
const effectiveDuration =
|
||||||
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
|
element.duration - element.trimStart - element.trimEnd;
|
||||||
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
|
const elementWidth = Math.max(
|
||||||
);
|
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
|
||||||
|
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
|
||||||
// Use real-time position during drag, otherwise use stored position
|
);
|
||||||
const isBeingDragged = dragState.elementId === element.id;
|
|
||||||
const elementStartTime =
|
// Use real-time position during drag, otherwise use stored position
|
||||||
isBeingDragged && dragState.isDragging
|
const isBeingDragged = dragState.elementId === element.id;
|
||||||
? dragState.currentTime
|
const elementStartTime =
|
||||||
: element.startTime;
|
isBeingDragged && dragState.isDragging
|
||||||
|
? dragState.currentTime
|
||||||
// Element should always be positioned at startTime - trimStart only affects content, not position
|
: element.startTime;
|
||||||
const elementLeft = elementStartTime * 50 * zoomLevel;
|
|
||||||
|
// Element should always be positioned at startTime - trimStart only affects content, not position
|
||||||
const handleElementSplitContext = () => {
|
const elementLeft = elementStartTime * 50 * zoomLevel;
|
||||||
const effectiveStart = element.startTime;
|
|
||||||
const effectiveEnd =
|
const handleElementSplitContext = () => {
|
||||||
element.startTime +
|
const effectiveStart = element.startTime;
|
||||||
(element.duration - element.trimStart - element.trimEnd);
|
const effectiveEnd =
|
||||||
|
element.startTime +
|
||||||
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
|
(element.duration - element.trimStart - element.trimEnd);
|
||||||
const secondElementId = splitElement(track.id, element.id, currentTime);
|
|
||||||
if (!secondElementId) {
|
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
|
||||||
toast.error("Failed to split element");
|
const secondElementId = splitElement(track.id, element.id, currentTime);
|
||||||
}
|
if (!secondElementId) {
|
||||||
} else {
|
toast.error("Failed to split element");
|
||||||
toast.error("Playhead must be within element to split");
|
}
|
||||||
}
|
} else {
|
||||||
};
|
toast.error("Playhead must be within element to split");
|
||||||
|
}
|
||||||
const handleElementDuplicateContext = () => {
|
};
|
||||||
const { id, ...elementWithoutId } = element;
|
|
||||||
addElementToTrack(track.id, {
|
const handleElementDuplicateContext = () => {
|
||||||
...elementWithoutId,
|
const { id, ...elementWithoutId } = element;
|
||||||
name: element.name + " (copy)",
|
addElementToTrack(track.id, {
|
||||||
startTime:
|
...elementWithoutId,
|
||||||
element.startTime +
|
name: element.name + " (copy)",
|
||||||
(element.duration - element.trimStart - element.trimEnd) +
|
startTime:
|
||||||
0.1,
|
element.startTime +
|
||||||
});
|
(element.duration - element.trimStart - element.trimEnd) +
|
||||||
};
|
0.1,
|
||||||
|
});
|
||||||
const handleElementDeleteContext = () => {
|
};
|
||||||
removeElementFromTrack(track.id, element.id);
|
|
||||||
};
|
const handleElementDeleteContext = () => {
|
||||||
|
if (rippleEditingEnabled) {
|
||||||
const handleReplaceClip = () => {
|
removeElementFromTrackWithRipple(track.id, element.id);
|
||||||
if (element.type !== "media") {
|
} else {
|
||||||
toast.error("Replace is only available for media clips");
|
removeElementFromTrack(track.id, element.id);
|
||||||
return;
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Create a file input to select replacement media
|
const handleReplaceClip = () => {
|
||||||
const input = document.createElement("input");
|
if (element.type !== "media") {
|
||||||
input.type = "file";
|
toast.error("Replace is only available for media clips");
|
||||||
input.accept = "video/*,audio/*,image/*";
|
return;
|
||||||
input.onchange = async (e) => {
|
}
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
|
||||||
if (!file) return;
|
// Create a file input to select replacement media
|
||||||
|
const input = document.createElement("input");
|
||||||
try {
|
input.type = "file";
|
||||||
const success = await replaceElementMedia(track.id, element.id, file);
|
input.accept = "video/*,audio/*,image/*";
|
||||||
if (success) {
|
input.onchange = async (e) => {
|
||||||
toast.success("Clip replaced successfully");
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
} else {
|
if (!file) return;
|
||||||
toast.error("Failed to replace clip");
|
|
||||||
}
|
try {
|
||||||
} catch (error) {
|
const success = await replaceElementMedia(track.id, element.id, file);
|
||||||
toast.error("Failed to replace clip");
|
if (success) {
|
||||||
console.log(
|
toast.success("Clip replaced successfully");
|
||||||
JSON.stringify({ error: "Failed to replace clip", details: error })
|
} else {
|
||||||
);
|
toast.error("Failed to replace clip");
|
||||||
}
|
}
|
||||||
};
|
} catch (error) {
|
||||||
input.click();
|
toast.error("Failed to replace clip");
|
||||||
};
|
console.log(
|
||||||
|
JSON.stringify({ error: "Failed to replace clip", details: error })
|
||||||
const renderElementContent = () => {
|
);
|
||||||
if (element.type === "text") {
|
}
|
||||||
return (
|
};
|
||||||
<div className="w-full h-full flex items-center justify-start pl-2">
|
input.click();
|
||||||
<span className="text-xs text-foreground/80 truncate">
|
};
|
||||||
{element.content}
|
|
||||||
</span>
|
const renderElementContent = () => {
|
||||||
</div>
|
if (element.type === "text") {
|
||||||
);
|
return (
|
||||||
}
|
<div className="w-full h-full flex items-center justify-start pl-2">
|
||||||
|
<span className="text-xs text-foreground/80 truncate">
|
||||||
// Render media element ->
|
{element.content}
|
||||||
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
|
</span>
|
||||||
if (!mediaItem) {
|
</div>
|
||||||
return (
|
);
|
||||||
<span className="text-xs text-foreground/80 truncate">
|
}
|
||||||
{element.name}
|
|
||||||
</span>
|
// Render media element ->
|
||||||
);
|
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
|
||||||
}
|
if (!mediaItem) {
|
||||||
|
return (
|
||||||
const TILE_ASPECT_RATIO = 16 / 9;
|
<span className="text-xs text-foreground/80 truncate">
|
||||||
|
{element.name}
|
||||||
if (mediaItem.type === "image") {
|
</span>
|
||||||
// Calculate tile size based on 16:9 aspect ratio
|
);
|
||||||
const trackHeight = getTrackHeight(track.type);
|
}
|
||||||
const tileHeight = trackHeight - 8; // Account for padding
|
|
||||||
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
|
const TILE_ASPECT_RATIO = 16 / 9;
|
||||||
|
|
||||||
return (
|
if (mediaItem.type === "image") {
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
// Calculate tile size based on 16:9 aspect ratio
|
||||||
<div className="bg-[#004D52] py-3 w-full h-full relative">
|
const trackHeight = getTrackHeight(track.type);
|
||||||
{/* Background with tiled images */}
|
const tileHeight = trackHeight - 8; // Account for padding
|
||||||
<div
|
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
|
||||||
className="absolute top-3 bottom-3 left-0 right-0"
|
|
||||||
style={{
|
return (
|
||||||
backgroundImage: mediaItem.url
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
? `url(${mediaItem.url})`
|
<div className="bg-[#004D52] py-3 w-full h-full relative">
|
||||||
: "none",
|
{/* Background with tiled images */}
|
||||||
backgroundRepeat: "repeat-x",
|
<div
|
||||||
backgroundSize: `${tileWidth}px ${tileHeight}px`,
|
className="absolute top-3 bottom-3 left-0 right-0"
|
||||||
backgroundPosition: "left center",
|
style={{
|
||||||
pointerEvents: "none",
|
backgroundImage: mediaItem.url
|
||||||
}}
|
? `url(${mediaItem.url})`
|
||||||
aria-label={`Tiled background of ${mediaItem.name}`}
|
: "none",
|
||||||
/>
|
backgroundRepeat: "repeat-x",
|
||||||
{/* Overlay with vertical borders */}
|
backgroundSize: `${tileWidth}px ${tileHeight}px`,
|
||||||
<div
|
backgroundPosition: "left center",
|
||||||
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
|
pointerEvents: "none",
|
||||||
style={{
|
}}
|
||||||
backgroundImage: `repeating-linear-gradient(
|
aria-label={`Tiled background of ${mediaItem.name}`}
|
||||||
to right,
|
/>
|
||||||
transparent 0px,
|
{/* Overlay with vertical borders */}
|
||||||
transparent ${tileWidth - 1}px,
|
<div
|
||||||
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
|
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
|
||||||
rgba(255, 255, 255, 0.6) ${tileWidth}px
|
style={{
|
||||||
)`,
|
backgroundImage: `repeating-linear-gradient(
|
||||||
backgroundPosition: "left center",
|
to right,
|
||||||
}}
|
transparent 0px,
|
||||||
/>
|
transparent ${tileWidth - 1}px,
|
||||||
</div>
|
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
|
||||||
</div>
|
rgba(255, 255, 255, 0.6) ${tileWidth}px
|
||||||
);
|
)`,
|
||||||
}
|
backgroundPosition: "left center",
|
||||||
|
}}
|
||||||
const VIDEO_TILE_PADDING = 16;
|
/>
|
||||||
const OVERLAY_SPACE_MULTIPLIER = 1.5;
|
</div>
|
||||||
|
</div>
|
||||||
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
|
);
|
||||||
const trackHeight = getTrackHeight(track.type);
|
}
|
||||||
const tileHeight = trackHeight - VIDEO_TILE_PADDING;
|
|
||||||
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
|
const VIDEO_TILE_PADDING = 16;
|
||||||
|
const OVERLAY_SPACE_MULTIPLIER = 1.5;
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center gap-2">
|
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
|
||||||
<div className="flex-1 h-full relative overflow-hidden">
|
const trackHeight = getTrackHeight(track.type);
|
||||||
{/* Background with tiled thumbnails */}
|
const tileHeight = trackHeight - VIDEO_TILE_PADDING;
|
||||||
<div
|
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
return (
|
||||||
backgroundImage: mediaItem.thumbnailUrl
|
<div className="w-full h-full flex items-center gap-2">
|
||||||
? `url(${mediaItem.thumbnailUrl})`
|
<div className="flex-1 h-full relative overflow-hidden">
|
||||||
: "none",
|
{/* Background with tiled thumbnails */}
|
||||||
backgroundRepeat: "repeat-x",
|
<div
|
||||||
backgroundSize: `${tileWidth}px ${tileHeight}px`,
|
className="absolute inset-0"
|
||||||
backgroundPosition: "left center",
|
style={{
|
||||||
pointerEvents: "none",
|
backgroundImage: mediaItem.thumbnailUrl
|
||||||
}}
|
? `url(${mediaItem.thumbnailUrl})`
|
||||||
aria-label={`Tiled thumbnail of ${mediaItem.name}`}
|
: "none",
|
||||||
/>
|
backgroundRepeat: "repeat-x",
|
||||||
{/* Overlay with vertical borders */}
|
backgroundSize: `${tileWidth}px ${tileHeight}px`,
|
||||||
<div
|
backgroundPosition: "left center",
|
||||||
className="absolute inset-0 pointer-events-none"
|
pointerEvents: "none",
|
||||||
style={{
|
}}
|
||||||
backgroundImage: `repeating-linear-gradient(
|
aria-label={`Tiled thumbnail of ${mediaItem.name}`}
|
||||||
to right,
|
/>
|
||||||
transparent 0px,
|
{/* Overlay with vertical borders */}
|
||||||
transparent ${tileWidth - 1}px,
|
<div
|
||||||
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
|
className="absolute inset-0 pointer-events-none"
|
||||||
rgba(255, 255, 255, 0.6) ${tileWidth}px
|
style={{
|
||||||
)`,
|
backgroundImage: `repeating-linear-gradient(
|
||||||
backgroundPosition: "left center",
|
to right,
|
||||||
}}
|
transparent 0px,
|
||||||
/>
|
transparent ${tileWidth - 1}px,
|
||||||
</div>
|
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
|
||||||
{elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
|
rgba(255, 255, 255, 0.6) ${tileWidth}px
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate">
|
)`,
|
||||||
{element.name}
|
backgroundPosition: "left center",
|
||||||
</div>
|
}}
|
||||||
) : (
|
/>
|
||||||
<span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]">
|
</div>
|
||||||
{element.name}
|
{elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
|
||||||
</span>
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate">
|
||||||
)}
|
{element.name}
|
||||||
</div>
|
</div>
|
||||||
);
|
) : (
|
||||||
}
|
<span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]">
|
||||||
|
{element.name}
|
||||||
// Render audio element ->
|
</span>
|
||||||
if (mediaItem.type === "audio") {
|
)}
|
||||||
return (
|
</div>
|
||||||
<div className="w-full h-full flex items-center gap-2">
|
);
|
||||||
<div className="flex-1 min-w-0">
|
}
|
||||||
<AudioWaveform
|
|
||||||
audioUrl={mediaItem.url || ""}
|
// Render audio element ->
|
||||||
height={24}
|
if (mediaItem.type === "audio") {
|
||||||
className="w-full"
|
return (
|
||||||
/>
|
<div className="w-full h-full flex items-center gap-2">
|
||||||
</div>
|
<div className="flex-1 min-w-0">
|
||||||
</div>
|
<AudioWaveform
|
||||||
);
|
audioUrl={mediaItem.url || ""}
|
||||||
}
|
height={24}
|
||||||
|
className="w-full"
|
||||||
return (
|
/>
|
||||||
<span className="text-xs text-foreground/80 truncate">
|
</div>
|
||||||
{element.name}
|
</div>
|
||||||
</span>
|
);
|
||||||
);
|
}
|
||||||
};
|
|
||||||
|
return (
|
||||||
const handleElementMouseDown = (e: React.MouseEvent) => {
|
<span className="text-xs text-foreground/80 truncate">
|
||||||
if (onElementMouseDown) {
|
{element.name}
|
||||||
onElementMouseDown(e, element);
|
</span>
|
||||||
}
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleElementMouseDown = (e: React.MouseEvent) => {
|
||||||
<ContextMenu>
|
if (onElementMouseDown) {
|
||||||
<ContextMenuTrigger asChild>
|
onElementMouseDown(e, element);
|
||||||
<div
|
}
|
||||||
className={`absolute top-0 h-full select-none timeline-element ${
|
};
|
||||||
isBeingDragged ? "z-50" : "z-10"
|
|
||||||
}`}
|
return (
|
||||||
style={{
|
<ContextMenu>
|
||||||
left: `${elementLeft}px`,
|
<ContextMenuTrigger asChild>
|
||||||
width: `${elementWidth}px`,
|
<div
|
||||||
}}
|
className={`absolute top-0 h-full select-none timeline-element ${
|
||||||
data-element-id={element.id}
|
isBeingDragged ? "z-50" : "z-10"
|
||||||
data-track-id={track.id}
|
}`}
|
||||||
onMouseMove={resizing ? handleResizeMove : undefined}
|
style={{
|
||||||
onMouseUp={resizing ? handleResizeEnd : undefined}
|
left: `${elementLeft}px`,
|
||||||
onMouseLeave={resizing ? handleResizeEnd : undefined}
|
width: `${elementWidth}px`,
|
||||||
>
|
}}
|
||||||
<div
|
data-element-id={element.id}
|
||||||
className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
|
data-track-id={track.id}
|
||||||
track.type
|
onMouseMove={resizing ? handleResizeMove : undefined}
|
||||||
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
|
onMouseUp={resizing ? handleResizeEnd : undefined}
|
||||||
isBeingDragged ? "z-50" : "z-10"
|
onMouseLeave={resizing ? handleResizeEnd : undefined}
|
||||||
}`}
|
>
|
||||||
onClick={(e) => onElementClick && onElementClick(e, element)}
|
<div
|
||||||
onMouseDown={handleElementMouseDown}
|
className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
|
||||||
onContextMenu={(e) =>
|
track.type
|
||||||
onElementMouseDown && onElementMouseDown(e, element)
|
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
|
||||||
}
|
isBeingDragged ? "z-50" : "z-10"
|
||||||
>
|
}`}
|
||||||
<div className="absolute inset-0 flex items-center h-full">
|
onClick={(e) => onElementClick && onElementClick(e, element)}
|
||||||
{renderElementContent()}
|
onMouseDown={handleElementMouseDown}
|
||||||
</div>
|
onContextMenu={(e) =>
|
||||||
|
onElementMouseDown && onElementMouseDown(e, element)
|
||||||
{isSelected && (
|
}
|
||||||
<>
|
>
|
||||||
<div
|
<div className="absolute inset-0 flex items-center h-full">
|
||||||
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
|
{renderElementContent()}
|
||||||
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
|
</div>
|
||||||
/>
|
|
||||||
<div
|
{isSelected && (
|
||||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
|
<>
|
||||||
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
|
<div
|
||||||
/>
|
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
|
||||||
</>
|
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
|
||||||
)}
|
/>
|
||||||
</div>
|
<div
|
||||||
</div>
|
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
|
||||||
</ContextMenuTrigger>
|
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
|
||||||
<ContextMenuContent>
|
/>
|
||||||
<ContextMenuItem onClick={handleElementSplitContext}>
|
</>
|
||||||
<Scissors className="h-4 w-4 mr-2" />
|
)}
|
||||||
Split at playhead
|
</div>
|
||||||
</ContextMenuItem>
|
</div>
|
||||||
<ContextMenuItem onClick={handleElementDuplicateContext}>
|
</ContextMenuTrigger>
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
<ContextMenuContent>
|
||||||
Duplicate {element.type === "text" ? "text" : "clip"}
|
<ContextMenuItem onClick={handleElementSplitContext}>
|
||||||
</ContextMenuItem>
|
<Scissors className="h-4 w-4 mr-2" />
|
||||||
{element.type === "media" && (
|
Split at playhead
|
||||||
<ContextMenuItem onClick={handleReplaceClip}>
|
</ContextMenuItem>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<ContextMenuItem onClick={handleElementDuplicateContext}>
|
||||||
Replace clip
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
</ContextMenuItem>
|
Duplicate {element.type === "text" ? "text" : "clip"}
|
||||||
)}
|
</ContextMenuItem>
|
||||||
<ContextMenuSeparator />
|
{element.type === "media" && (
|
||||||
<ContextMenuItem
|
<ContextMenuItem onClick={handleReplaceClip}>
|
||||||
onClick={handleElementDeleteContext}
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
className="text-destructive focus:text-destructive"
|
Replace clip
|
||||||
>
|
</ContextMenuItem>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
)}
|
||||||
Delete {element.type === "text" ? "text" : "clip"}
|
<ContextMenuSeparator />
|
||||||
</ContextMenuItem>
|
<ContextMenuItem
|
||||||
</ContextMenuContent>
|
onClick={handleElementDeleteContext}
|
||||||
</ContextMenu>
|
className="text-destructive focus:text-destructive"
|
||||||
);
|
>
|
||||||
}
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete {element.type === "text" ? "text" : "clip"}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function TimelineTrackContent({
|
|||||||
addTrack,
|
addTrack,
|
||||||
moveElementToTrack,
|
moveElementToTrack,
|
||||||
updateElementStartTime,
|
updateElementStartTime,
|
||||||
|
updateElementStartTimeWithRipple,
|
||||||
addElementToTrack,
|
addElementToTrack,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
selectElement,
|
selectElement,
|
||||||
@@ -49,6 +50,7 @@ export function TimelineTrackContent({
|
|||||||
clearSelectedElements,
|
clearSelectedElements,
|
||||||
insertTrackAt,
|
insertTrackAt,
|
||||||
snappingEnabled,
|
snappingEnabled,
|
||||||
|
rippleEditingEnabled,
|
||||||
} = useTimelineStore();
|
} = useTimelineStore();
|
||||||
|
|
||||||
const { currentTime } = usePlaybackStore();
|
const { currentTime } = usePlaybackStore();
|
||||||
@@ -221,11 +223,19 @@ export function TimelineTrackContent({
|
|||||||
const timelineRect = timelineRef.current?.getBoundingClientRect();
|
const timelineRect = timelineRef.current?.getBoundingClientRect();
|
||||||
if (!timelineRect) {
|
if (!timelineRect) {
|
||||||
if (isTrackThatStartedDrag) {
|
if (isTrackThatStartedDrag) {
|
||||||
updateElementStartTime(
|
if (rippleEditingEnabled) {
|
||||||
track.id,
|
updateElementStartTimeWithRipple(
|
||||||
dragState.elementId,
|
track.id,
|
||||||
dragState.currentTime
|
dragState.elementId,
|
||||||
);
|
dragState.currentTime
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updateElementStartTime(
|
||||||
|
track.id,
|
||||||
|
dragState.elementId,
|
||||||
|
dragState.currentTime
|
||||||
|
);
|
||||||
|
}
|
||||||
endDragAction();
|
endDragAction();
|
||||||
// Clear snap point when drag ends
|
// Clear snap point when drag ends
|
||||||
onSnapPointChange?.(null);
|
onSnapPointChange?.(null);
|
||||||
@@ -272,7 +282,19 @@ export function TimelineTrackContent({
|
|||||||
|
|
||||||
if (!hasOverlap) {
|
if (!hasOverlap) {
|
||||||
if (dragState.trackId === track.id) {
|
if (dragState.trackId === track.id) {
|
||||||
updateElementStartTime(track.id, dragState.elementId, finalTime);
|
if (rippleEditingEnabled) {
|
||||||
|
updateElementStartTimeWithRipple(
|
||||||
|
track.id,
|
||||||
|
dragState.elementId,
|
||||||
|
finalTime
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updateElementStartTime(
|
||||||
|
track.id,
|
||||||
|
dragState.elementId,
|
||||||
|
finalTime
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
moveElementToTrack(
|
moveElementToTrack(
|
||||||
dragState.trackId,
|
dragState.trackId,
|
||||||
@@ -280,11 +302,19 @@ export function TimelineTrackContent({
|
|||||||
dragState.elementId
|
dragState.elementId
|
||||||
);
|
);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
updateElementStartTime(
|
if (rippleEditingEnabled) {
|
||||||
track.id,
|
updateElementStartTimeWithRipple(
|
||||||
dragState.elementId!,
|
track.id,
|
||||||
finalTime
|
dragState.elementId!,
|
||||||
);
|
finalTime
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updateElementStartTime(
|
||||||
|
track.id,
|
||||||
|
dragState.elementId!,
|
||||||
|
finalTime
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,7 +348,15 @@ export function TimelineTrackContent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!hasOverlap) {
|
if (!hasOverlap) {
|
||||||
updateElementStartTime(track.id, dragState.elementId, finalTime);
|
if (rippleEditingEnabled) {
|
||||||
|
updateElementStartTimeWithRipple(
|
||||||
|
track.id,
|
||||||
|
dragState.elementId,
|
||||||
|
finalTime
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updateElementStartTime(track.id, dragState.elementId, finalTime);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -743,12 +781,28 @@ export function TimelineTrackContent({
|
|||||||
|
|
||||||
if (fromTrackId === track.id) {
|
if (fromTrackId === track.id) {
|
||||||
// Moving within same track
|
// Moving within same track
|
||||||
updateElementStartTime(track.id, elementId, finalStartTime);
|
if (rippleEditingEnabled) {
|
||||||
|
updateElementStartTimeWithRipple(
|
||||||
|
track.id,
|
||||||
|
elementId,
|
||||||
|
finalStartTime
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updateElementStartTime(track.id, elementId, finalStartTime);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Moving to different track
|
// Moving to different track
|
||||||
moveElementToTrack(fromTrackId, track.id, elementId);
|
moveElementToTrack(fromTrackId, track.id, elementId);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
updateElementStartTime(track.id, elementId, finalStartTime);
|
if (rippleEditingEnabled) {
|
||||||
|
updateElementStartTimeWithRipple(
|
||||||
|
track.id,
|
||||||
|
elementId,
|
||||||
|
finalStartTime
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updateElementStartTime(track.id, elementId, finalStartTime);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (hasMediaItem) {
|
} else if (hasMediaItem) {
|
||||||
|
|||||||
+1095
-1069
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
|||||||
|
import { TimelineElement } from "@/types/timeline";
|
||||||
|
|
||||||
|
// Helper function to check for element overlaps and prevent invalid timeline states
|
||||||
|
export const checkElementOverlaps = (elements: TimelineElement[]): boolean => {
|
||||||
|
// Sort elements by start time
|
||||||
|
const sortedElements = [...elements].sort(
|
||||||
|
(a, b) => a.startTime - b.startTime
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < sortedElements.length - 1; i++) {
|
||||||
|
const current = sortedElements[i];
|
||||||
|
const next = sortedElements[i + 1];
|
||||||
|
|
||||||
|
const currentEnd =
|
||||||
|
current.startTime +
|
||||||
|
(current.duration - current.trimStart - current.trimEnd);
|
||||||
|
|
||||||
|
// Check if current element overlaps with next element
|
||||||
|
if (currentEnd > next.startTime) return true; // Overlap detected
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // No overlaps
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to resolve overlaps by adjusting element positions
|
||||||
|
export const resolveElementOverlaps = (
|
||||||
|
elements: TimelineElement[]
|
||||||
|
): TimelineElement[] => {
|
||||||
|
// Sort elements by start time
|
||||||
|
const sortedElements = [...elements].sort(
|
||||||
|
(a, b) => a.startTime - b.startTime
|
||||||
|
);
|
||||||
|
const resolvedElements: TimelineElement[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < sortedElements.length; i++) {
|
||||||
|
const current = { ...sortedElements[i] };
|
||||||
|
|
||||||
|
if (resolvedElements.length > 0) {
|
||||||
|
const previous = resolvedElements[resolvedElements.length - 1];
|
||||||
|
const previousEnd =
|
||||||
|
previous.startTime +
|
||||||
|
(previous.duration - previous.trimStart - previous.trimEnd);
|
||||||
|
|
||||||
|
// If current element would overlap with previous, push it after previous ends
|
||||||
|
if (current.startTime < previousEnd) {
|
||||||
|
current.startTime = previousEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedElements.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedElements;
|
||||||
|
};
|
||||||
@@ -21,6 +21,7 @@ import { useProjectStore } from "./project-store";
|
|||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
|
||||||
|
|
||||||
// Helper function to manage element naming with suffixes
|
// Helper function to manage element naming with suffixes
|
||||||
const getElementNameWithSuffix = (
|
const getElementNameWithSuffix = (
|
||||||
@@ -55,6 +56,10 @@ interface TimelineStore {
|
|||||||
// Snapping actions
|
// Snapping actions
|
||||||
toggleSnapping: () => void;
|
toggleSnapping: () => void;
|
||||||
|
|
||||||
|
// Ripple editing mode
|
||||||
|
rippleEditingEnabled: boolean;
|
||||||
|
toggleRippleEditing: () => void;
|
||||||
|
|
||||||
// Multi-selection
|
// Multi-selection
|
||||||
selectedElements: { trackId: string; elementId: string }[];
|
selectedElements: { trackId: string; elementId: string }[];
|
||||||
selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
|
selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
|
||||||
@@ -89,6 +94,7 @@ interface TimelineStore {
|
|||||||
addTrack: (type: TrackType) => string;
|
addTrack: (type: TrackType) => string;
|
||||||
insertTrackAt: (type: TrackType, index: number) => string;
|
insertTrackAt: (type: TrackType, index: number) => string;
|
||||||
removeTrack: (trackId: string) => void;
|
removeTrack: (trackId: string) => void;
|
||||||
|
removeTrackWithRipple: (trackId: string) => void;
|
||||||
addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
|
addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
|
||||||
removeElementFromTrack: (trackId: string, elementId: string) => void;
|
removeElementFromTrack: (trackId: string, elementId: string) => void;
|
||||||
moveElementToTrack: (
|
moveElementToTrack: (
|
||||||
@@ -139,6 +145,17 @@ interface TimelineStore {
|
|||||||
newFile: File
|
newFile: File
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
|
|
||||||
|
// Ripple editing functions
|
||||||
|
updateElementStartTimeWithRipple: (
|
||||||
|
trackId: string,
|
||||||
|
elementId: string,
|
||||||
|
newStartTime: number
|
||||||
|
) => void;
|
||||||
|
removeElementFromTrackWithRipple: (
|
||||||
|
trackId: string,
|
||||||
|
elementId: string
|
||||||
|
) => void;
|
||||||
|
|
||||||
// Computed values
|
// Computed values
|
||||||
getTotalDuration: () => number;
|
getTotalDuration: () => number;
|
||||||
|
|
||||||
@@ -226,6 +243,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
history: [],
|
history: [],
|
||||||
redoStack: [],
|
redoStack: [],
|
||||||
selectedElements: [],
|
selectedElements: [],
|
||||||
|
rippleEditingEnabled: false,
|
||||||
|
|
||||||
// Snapping settings defaults
|
// Snapping settings defaults
|
||||||
snappingEnabled: true,
|
snappingEnabled: true,
|
||||||
@@ -346,10 +364,107 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeTrack: (trackId) => {
|
removeTrack: (trackId) => {
|
||||||
|
const { rippleEditingEnabled } = get();
|
||||||
|
|
||||||
|
if (rippleEditingEnabled) {
|
||||||
|
get().removeTrackWithRipple(trackId);
|
||||||
|
} else {
|
||||||
|
get().pushHistory();
|
||||||
|
updateTracksAndSave(
|
||||||
|
get()._tracks.filter((track) => track.id !== trackId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTrackWithRipple: (trackId) => {
|
||||||
|
const { _tracks } = get();
|
||||||
|
const trackToRemove = _tracks.find((t) => t.id === trackId);
|
||||||
|
|
||||||
|
if (!trackToRemove) return;
|
||||||
|
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
updateTracksAndSave(
|
|
||||||
get()._tracks.filter((track) => track.id !== trackId)
|
// If track has no elements, just remove it normally
|
||||||
);
|
if (trackToRemove.elements.length === 0) {
|
||||||
|
updateTracksAndSave(_tracks.filter((track) => track.id !== trackId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all the time ranges occupied by elements in the track being removed
|
||||||
|
const occupiedRanges = trackToRemove.elements.map((element) => ({
|
||||||
|
startTime: element.startTime,
|
||||||
|
endTime:
|
||||||
|
element.startTime +
|
||||||
|
(element.duration - element.trimStart - element.trimEnd),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sort ranges by start time
|
||||||
|
occupiedRanges.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
|
// Merge overlapping ranges to get consolidated gaps
|
||||||
|
const mergedRanges: Array<{
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
duration: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const range of occupiedRanges) {
|
||||||
|
if (mergedRanges.length === 0) {
|
||||||
|
mergedRanges.push({
|
||||||
|
startTime: range.startTime,
|
||||||
|
endTime: range.endTime,
|
||||||
|
duration: range.endTime - range.startTime,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const lastRange = mergedRanges[mergedRanges.length - 1];
|
||||||
|
if (range.startTime <= lastRange.endTime) {
|
||||||
|
// Overlapping or adjacent ranges, merge them
|
||||||
|
lastRange.endTime = Math.max(lastRange.endTime, range.endTime);
|
||||||
|
lastRange.duration = lastRange.endTime - lastRange.startTime;
|
||||||
|
} else {
|
||||||
|
// Non-overlapping range, add as new
|
||||||
|
mergedRanges.push({
|
||||||
|
startTime: range.startTime,
|
||||||
|
endTime: range.endTime,
|
||||||
|
duration: range.endTime - range.startTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the track and apply ripple effects to remaining tracks
|
||||||
|
const updatedTracks = _tracks
|
||||||
|
.filter((track) => track.id !== trackId)
|
||||||
|
.map((track) => {
|
||||||
|
const updatedElements = track.elements.map((element) => {
|
||||||
|
let newStartTime = element.startTime;
|
||||||
|
|
||||||
|
// Process gaps from right to left (latest to earliest) to avoid cumulative shifts
|
||||||
|
for (let i = mergedRanges.length - 1; i >= 0; i--) {
|
||||||
|
const gap = mergedRanges[i];
|
||||||
|
// If this element starts after the gap, shift it left by the gap duration
|
||||||
|
if (newStartTime >= gap.endTime) {
|
||||||
|
newStartTime -= gap.duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
startTime: Math.max(0, newStartTime),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for overlaps and resolve them if necessary
|
||||||
|
const hasOverlaps = checkElementOverlaps(updatedElements);
|
||||||
|
if (hasOverlaps) {
|
||||||
|
const resolvedElements = resolveElementOverlaps(updatedElements);
|
||||||
|
return { ...track, elements: resolvedElements };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...track, elements: updatedElements };
|
||||||
|
});
|
||||||
|
|
||||||
|
updateTracksAndSave(updatedTracks);
|
||||||
},
|
},
|
||||||
|
|
||||||
addElementToTrack: (trackId, elementData) => {
|
addElementToTrack: (trackId, elementData) => {
|
||||||
@@ -434,21 +549,99 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeElementFromTrack: (trackId, elementId) => {
|
removeElementFromTrack: (trackId, elementId) => {
|
||||||
|
const { rippleEditingEnabled } = get();
|
||||||
|
|
||||||
|
if (rippleEditingEnabled) {
|
||||||
|
get().removeElementFromTrackWithRipple(trackId, elementId);
|
||||||
|
} else {
|
||||||
|
get().pushHistory();
|
||||||
|
updateTracksAndSave(
|
||||||
|
get()
|
||||||
|
._tracks.map((track) =>
|
||||||
|
track.id === trackId
|
||||||
|
? {
|
||||||
|
...track,
|
||||||
|
elements: track.elements.filter(
|
||||||
|
(element) => element.id !== elementId
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: track
|
||||||
|
)
|
||||||
|
.filter((track) => track.elements.length > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeElementFromTrackWithRipple: (trackId, elementId) => {
|
||||||
|
const { _tracks, rippleEditingEnabled } = get();
|
||||||
|
|
||||||
|
if (!rippleEditingEnabled) {
|
||||||
|
// If ripple editing is disabled, use regular removal
|
||||||
|
get().removeElementFromTrack(trackId, elementId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = _tracks.find((t) => t.id === trackId);
|
||||||
|
const element = track?.elements.find((e) => e.id === elementId);
|
||||||
|
|
||||||
|
if (!element || !track) return;
|
||||||
|
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
updateTracksAndSave(
|
|
||||||
get()
|
const elementStartTime = element.startTime;
|
||||||
._tracks.map((track) =>
|
const elementDuration =
|
||||||
track.id === trackId
|
element.duration - element.trimStart - element.trimEnd;
|
||||||
? {
|
const elementEndTime = elementStartTime + elementDuration;
|
||||||
...track,
|
|
||||||
elements: track.elements.filter(
|
// Remove the element and shift all elements that come after it
|
||||||
(element) => element.id !== elementId
|
const updatedTracks = _tracks
|
||||||
|
.map((currentTrack) => {
|
||||||
|
// Only apply ripple effects to the same track unless multi-track ripple is enabled
|
||||||
|
const shouldApplyRipple = currentTrack.id === trackId;
|
||||||
|
|
||||||
|
const updatedElements = currentTrack.elements
|
||||||
|
.filter((currentElement) => {
|
||||||
|
// Remove the target element
|
||||||
|
if (
|
||||||
|
currentElement.id === elementId &&
|
||||||
|
currentTrack.id === trackId
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((currentElement) => {
|
||||||
|
// Only apply ripple effects if we should process this track
|
||||||
|
if (!shouldApplyRipple) {
|
||||||
|
return currentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift elements that start after the removed element
|
||||||
|
if (currentElement.startTime >= elementEndTime) {
|
||||||
|
return {
|
||||||
|
...currentElement,
|
||||||
|
startTime: Math.max(
|
||||||
|
0,
|
||||||
|
currentElement.startTime - elementDuration
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
: track
|
}
|
||||||
)
|
return currentElement;
|
||||||
.filter((track) => track.elements.length > 0)
|
});
|
||||||
);
|
|
||||||
|
// Check for overlaps and resolve them if necessary
|
||||||
|
const hasOverlaps = checkElementOverlaps(updatedElements);
|
||||||
|
if (hasOverlaps) {
|
||||||
|
// Resolve overlaps by adjusting element positions
|
||||||
|
const resolvedElements = resolveElementOverlaps(updatedElements);
|
||||||
|
return { ...currentTrack, elements: resolvedElements };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...currentTrack, elements: updatedElements };
|
||||||
|
})
|
||||||
|
.filter((track) => track.elements.length > 0 || track.isMain);
|
||||||
|
|
||||||
|
updateTracksAndSave(updatedTracks);
|
||||||
},
|
},
|
||||||
|
|
||||||
moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
|
moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
|
||||||
@@ -544,6 +737,95 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => {
|
||||||
|
const { _tracks, rippleEditingEnabled } = get();
|
||||||
|
|
||||||
|
if (!rippleEditingEnabled) {
|
||||||
|
// If ripple editing is disabled, use regular update
|
||||||
|
get().updateElementStartTime(trackId, elementId, newStartTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = _tracks.find((t) => t.id === trackId);
|
||||||
|
const element = track?.elements.find((e) => e.id === elementId);
|
||||||
|
|
||||||
|
if (!element || !track) return;
|
||||||
|
|
||||||
|
get().pushHistory();
|
||||||
|
|
||||||
|
const oldStartTime = element.startTime;
|
||||||
|
const oldEndTime =
|
||||||
|
element.startTime +
|
||||||
|
(element.duration - element.trimStart - element.trimEnd);
|
||||||
|
const newEndTime =
|
||||||
|
newStartTime + (element.duration - element.trimStart - element.trimEnd);
|
||||||
|
const timeDelta = newStartTime - oldStartTime;
|
||||||
|
|
||||||
|
// Update tracks based on multi-track ripple setting
|
||||||
|
const updatedTracks = _tracks.map((currentTrack) => {
|
||||||
|
// Only apply ripple effects to the same track unless multi-track ripple is enabled
|
||||||
|
const shouldApplyRipple = currentTrack.id === trackId;
|
||||||
|
|
||||||
|
const updatedElements = currentTrack.elements.map((currentElement) => {
|
||||||
|
if (currentElement.id === elementId && currentTrack.id === trackId) {
|
||||||
|
// Update the moved element
|
||||||
|
return { ...currentElement, startTime: newStartTime };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only apply ripple effects if we should process this track
|
||||||
|
if (!shouldApplyRipple) {
|
||||||
|
return currentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For ripple editing, we need to move elements that come after the moved element
|
||||||
|
const currentElementStart = currentElement.startTime;
|
||||||
|
const currentElementEnd =
|
||||||
|
currentElement.startTime +
|
||||||
|
(currentElement.duration -
|
||||||
|
currentElement.trimStart -
|
||||||
|
currentElement.trimEnd);
|
||||||
|
|
||||||
|
// If moving element to the right (positive delta)
|
||||||
|
if (timeDelta > 0) {
|
||||||
|
// Move elements that start after the original position of the moved element
|
||||||
|
if (currentElementStart >= oldEndTime) {
|
||||||
|
return {
|
||||||
|
...currentElement,
|
||||||
|
startTime: currentElementStart + timeDelta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If moving element to the left (negative delta)
|
||||||
|
else if (timeDelta < 0) {
|
||||||
|
// Move elements that start after the new position of the moved element
|
||||||
|
if (
|
||||||
|
currentElementStart >= newEndTime &&
|
||||||
|
currentElementStart >= oldStartTime
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...currentElement,
|
||||||
|
startTime: Math.max(0, currentElementStart + timeDelta),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentElement;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for overlaps and resolve them if necessary
|
||||||
|
const hasOverlaps = checkElementOverlaps(updatedElements);
|
||||||
|
if (hasOverlaps) {
|
||||||
|
// Resolve overlaps by adjusting element positions
|
||||||
|
const resolvedElements = resolveElementOverlaps(updatedElements);
|
||||||
|
return { ...currentTrack, elements: resolvedElements };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...currentTrack, elements: updatedElements };
|
||||||
|
});
|
||||||
|
|
||||||
|
updateTracksAndSave(updatedTracks);
|
||||||
|
},
|
||||||
|
|
||||||
toggleTrackMute: (trackId) => {
|
toggleTrackMute: (trackId) => {
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
updateTracksAndSave(
|
updateTracksAndSave(
|
||||||
@@ -982,6 +1264,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
|
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Ripple editing functions
|
||||||
|
toggleRippleEditing: () => {
|
||||||
|
set((state) => ({
|
||||||
|
rippleEditingEnabled: !state.rippleEditingEnabled,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
|
checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
|
||||||
const track = get()._tracks.find((t) => t.id === trackId);
|
const track = get()._tracks.find((t) => t.id === trackId);
|
||||||
if (!track) return false;
|
if (!track) return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user