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
@@ -57,6 +57,7 @@ export function TimelineElement({
updateElementTrim, updateElementTrim,
updateElementDuration, updateElementDuration,
removeElementFromTrack, removeElementFromTrack,
removeElementFromTrackWithRipple,
dragState, dragState,
splitElement, splitElement,
splitAndKeepLeft, splitAndKeepLeft,
@@ -64,6 +65,7 @@ export function TimelineElement({
separateAudio, separateAudio,
addElementToTrack, addElementToTrack,
replaceElementMedia, replaceElementMedia,
rippleEditingEnabled,
} = useTimelineStore(); } = useTimelineStore();
const { currentTime } = usePlaybackStore(); const { currentTime } = usePlaybackStore();
@@ -129,7 +131,11 @@ export function TimelineElement({
}; };
const handleElementDeleteContext = () => { const handleElementDeleteContext = () => {
removeElementFromTrack(track.id, element.id); if (rippleEditingEnabled) {
removeElementFromTrackWithRipple(track.id, element.id);
} else {
removeElementFromTrack(track.id, element.id);
}
}; };
const handleReplaceClip = () => { const handleReplaceClip = () => {
@@ -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) {
+27 -1
View File
@@ -17,6 +17,7 @@ import {
TypeIcon, TypeIcon,
Lock, Lock,
LockOpen, LockOpen,
Link,
} from "lucide-react"; } from "lucide-react";
import { import {
Tooltip, Tooltip,
@@ -66,6 +67,7 @@ export function Timeline() {
addTrack, addTrack,
addElementToTrack, addElementToTrack,
removeElementFromTrack, removeElementFromTrack,
removeElementFromTrackWithRipple,
getTotalDuration, getTotalDuration,
selectedElements, selectedElements,
clearSelectedElements, clearSelectedElements,
@@ -77,6 +79,8 @@ export function Timeline() {
separateAudio, separateAudio,
snappingEnabled, snappingEnabled,
toggleSnapping, toggleSnapping,
rippleEditingEnabled,
toggleRippleEditing,
dragState, dragState,
} = useTimelineStore(); } = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore(); const { mediaItems, addMediaItem } = useMediaStore();
@@ -553,7 +557,11 @@ export function Timeline() {
return; return;
} }
selectedElements.forEach(({ trackId, elementId }) => { selectedElements.forEach(({ trackId, elementId }) => {
removeElementFromTrack(trackId, elementId); if (rippleEditingEnabled) {
removeElementFromTrackWithRipple(trackId, elementId);
} else {
removeElementFromTrack(trackId, elementId);
}
}); });
clearSelectedElements(); clearSelectedElements();
}; };
@@ -809,6 +817,24 @@ export function Timeline() {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Auto snapping</TooltipContent> <TooltipContent>Auto snapping</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={toggleRippleEditing}
>
<Link
className={`h-4 w-4 ${rippleEditingEnabled ? "text-primary" : ""}`}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
{rippleEditingEnabled
? "Disable Ripple Editing"
: "Enable Ripple Editing"}
</TooltipContent>
</Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
</div> </div>
+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;