feat: ripple editing mode on timeline

This commit is contained in:
Maze Winther
2025-07-19 19:08:08 +02:00
parent 42ae8a7530
commit 5266aed5eb
5 changed files with 1914 additions and 1485 deletions
@@ -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) {
File diff suppressed because it is too large Load Diff
+54
View File
@@ -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;
};
+305 -16
View File
@@ -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;