From 744419469da403be57d30b5b19f6b43246caa57d Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 23 Feb 2026 07:09:26 +0100 Subject: [PATCH] feat: basic ripple editing --- .../web/src/core/managers/timeline-manager.ts | 13 +- .../src/hooks/actions/use-editor-actions.ts | 29 +++- apps/web/src/lib/actions/definitions.ts | 4 + .../timeline/element/delete-elements.ts | 56 +++++--- .../timeline/element/split-elements.ts | 134 ++++++++++-------- apps/web/src/lib/timeline/index.ts | 1 + apps/web/src/lib/timeline/ripple-utils.ts | 17 +++ 7 files changed, 175 insertions(+), 79 deletions(-) create mode 100644 apps/web/src/lib/timeline/ripple-utils.ts diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index c8ac5a79..cf906aee 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -140,12 +140,19 @@ export class TimelineManager { elements, splitTime, retainSide = "both", + rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; splitTime: number; retainSide?: "both" | "left" | "right"; + rippleEnabled?: boolean; }): { trackId: string; elementId: string }[] { - const command = new SplitElementsCommand(elements, splitTime, retainSide); + const command = new SplitElementsCommand( + elements, + splitTime, + retainSide, + rippleEnabled, + ); this.editor.command.execute({ command }); return command.getRightSideElements(); } @@ -194,10 +201,12 @@ export class TimelineManager { deleteElements({ elements, + rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; + rippleEnabled?: boolean; }): void { - const command = new DeleteElementsCommand(elements); + const command = new DeleteElementsCommand(elements, rippleEnabled); this.editor.command.execute({ command }); } diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index 1ce890cb..5f48fc8f 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -10,7 +10,13 @@ export function useEditorActions() { const editor = useEditor(); const activeProject = editor.project.getActive(); const { selectedElements, setElementSelection } = useElementSelection(); - const { clipboard, setClipboard, toggleSnapping } = useTimelineStore(); + const { + clipboard, + setClipboard, + toggleSnapping, + rippleEditingEnabled, + toggleRippleEditing, + } = useTimelineStore(); useActionHandler( "toggle-play", @@ -158,11 +164,21 @@ export function useEditorActions() { if (elementsToSplit.length === 0) return; - editor.timeline.splitElements({ + const rightSideElements = editor.timeline.splitElements({ elements: elementsToSplit, splitTime: currentTime, retainSide: "right", + rippleEnabled: rippleEditingEnabled, }); + + if (rippleEditingEnabled && rightSideElements.length > 0) { + const firstRightElement = editor.timeline.getElementsWithTracks({ + elements: [rightSideElements[0]], + })[0]; + if (firstRightElement) { + editor.playback.seek({ time: firstRightElement.element.startTime }); + } + } }, undefined, ); @@ -198,6 +214,7 @@ export function useEditorActions() { } editor.timeline.deleteElements({ elements: selectedElements, + rippleEnabled: rippleEditingEnabled, }); editor.selection.clearSelection(); }, @@ -295,6 +312,14 @@ export function useEditorActions() { undefined, ); + useActionHandler( + "toggle-ripple-editing", + () => { + toggleRippleEditing(); + }, + undefined, + ); + useActionHandler( "undo", () => { diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts index 83144a25..5ec056c4 100644 --- a/apps/web/src/lib/actions/definitions.ts +++ b/apps/web/src/lib/actions/definitions.ts @@ -105,6 +105,10 @@ export const ACTIONS = { category: "editing", defaultShortcuts: ["n"], }, + "toggle-ripple-editing": { + description: "Toggle ripple editing", + category: "editing", + }, "select-all": { description: "Select all elements", category: "selection", diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index 91eb6397..9e6ed8cd 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,12 +1,15 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/types/timeline"; import { EditorCore } from "@/core"; -import { isMainTrack } from "@/lib/timeline"; +import { isMainTrack, rippleShiftElements } from "@/lib/timeline"; export class DeleteElementsCommand extends Command { private savedState: TimelineTrack[] | null = null; - constructor(private elements: { trackId: string; elementId: string }[]) { + constructor( + private elements: { trackId: string; elementId: string }[], + private rippleEnabled = false, + ) { super(); } @@ -16,23 +19,44 @@ export class DeleteElementsCommand extends Command { const updatedTracks = this.savedState .map((track) => { - const hasElementsToDelete = this.elements.some( - (el) => el.trackId === track.id, - ); + const elementsToDeleteOnTrack = this.elements.filter( + (target) => target.trackId === track.id, + ); + const hasElementsToDelete = elementsToDeleteOnTrack.length > 0; - if (!hasElementsToDelete) { - return track; + if (!hasElementsToDelete) { + return track; + } + + const deletedElementInfos = elementsToDeleteOnTrack + .map((target) => + track.elements.find((element) => element.id === target.elementId), + ) + .filter((element): element is NonNullable => element !== undefined) + .map((element) => ({ startTime: element.startTime, duration: element.duration })); + + let elements = track.elements.filter( + (element) => + !this.elements.some( + (target) => + target.trackId === track.id && target.elementId === element.id, + ), + ); + + if (this.rippleEnabled && deletedElementInfos.length > 0) { + const sortedByStartDesc = [...deletedElementInfos].sort( + (a, b) => b.startTime - a.startTime, + ); + for (const { startTime, duration } of sortedByStartDesc) { + elements = rippleShiftElements({ + elements, + afterTime: startTime, + shiftAmount: duration, + }); + } } - return { - ...track, - elements: track.elements.filter( - (element) => - !this.elements.some( - (el) => el.trackId === track.id && el.elementId === element.id, - ), - ), - } as typeof track; + return { ...track, elements } as typeof track; }) .filter((track) => track.elements.length > 0 || isMainTrack(track)); diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts index abe133c9..05a23fff 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -2,6 +2,7 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/types/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; +import { rippleShiftElements } from "@/lib/timeline"; export class SplitElementsCommand extends Command { private savedState: TimelineTrack[] | null = null; @@ -12,6 +13,7 @@ export class SplitElementsCommand extends Command { private elements: { trackId: string; elementId: string }[], private splitTime: number, private retainSide: "both" | "left" | "right" = "both", + private rippleEnabled = false, ) { super(); } @@ -28,74 +30,39 @@ export class SplitElementsCommand extends Command { const updatedTracks = this.savedState.map((track) => { const elementsToSplit = this.elements.filter( - (el) => el.trackId === track.id, + (target) => target.trackId === track.id, ); if (elementsToSplit.length === 0) { return track; } - return { - ...track, - elements: track.elements.flatMap((element) => { - const shouldSplit = elementsToSplit.some( - (el) => el.elementId === element.id, - ); + let leftVisibleDurationForRipple: number | null = null; - if (!shouldSplit) { - return [element]; - } + let elements = track.elements.flatMap((element) => { + const shouldSplit = elementsToSplit.some( + (target) => target.elementId === element.id, + ); - const effectiveStart = element.startTime; - const effectiveEnd = element.startTime + element.duration; + if (!shouldSplit) { + return [element]; + } - if ( - this.splitTime <= effectiveStart || - this.splitTime >= effectiveEnd - ) { - return [element]; - } + const effectiveStart = element.startTime; + const effectiveEnd = element.startTime + element.duration; - const relativeTime = this.splitTime - element.startTime; - const leftVisibleDuration = relativeTime; - const rightVisibleDuration = element.duration - relativeTime; + if ( + this.splitTime <= effectiveStart || + this.splitTime >= effectiveEnd + ) { + return [element]; + } - if (this.retainSide === "left") { - return [ - { - ...element, - duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightVisibleDuration, - name: `${element.name} (left)`, - }, - ]; - } - - if (this.retainSide === "right") { - const newId = generateUUID(); - this.rightSideElements.push({ - trackId: track.id, - elementId: newId, - }); - return [ - { - ...element, - id: newId, - startTime: this.splitTime, - duration: rightVisibleDuration, - trimStart: element.trimStart + leftVisibleDuration, - name: `${element.name} (right)`, - }, - ]; - } - - // "both" - split into two pieces - const secondElementId = generateUUID(); - this.rightSideElements.push({ - trackId: track.id, - elementId: secondElementId, - }); + const relativeTime = this.splitTime - element.startTime; + const leftVisibleDuration = relativeTime; + const rightVisibleDuration = element.duration - relativeTime; + if (this.retainSide === "left") { return [ { ...element, @@ -103,17 +70,66 @@ export class SplitElementsCommand extends Command { trimEnd: element.trimEnd + rightVisibleDuration, name: `${element.name} (left)`, }, + ]; + } + + if (this.retainSide === "right") { + if (this.rippleEnabled && elementsToSplit.length === 1) { + leftVisibleDurationForRipple = leftVisibleDuration; + } + const newId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: newId, + }); + return [ { ...element, - id: secondElementId, + id: newId, startTime: this.splitTime, duration: rightVisibleDuration, trimStart: element.trimStart + leftVisibleDuration, name: `${element.name} (right)`, }, ]; - }), - } as typeof track; + } + + const secondElementId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: secondElementId, + }); + + return [ + { + ...element, + duration: leftVisibleDuration, + trimEnd: element.trimEnd + rightVisibleDuration, + name: `${element.name} (left)`, + }, + { + ...element, + id: secondElementId, + startTime: this.splitTime, + duration: rightVisibleDuration, + trimStart: element.trimStart + leftVisibleDuration, + name: `${element.name} (right)`, + }, + ]; + }); + + if ( + this.rippleEnabled && + leftVisibleDurationForRipple !== null + ) { + elements = rippleShiftElements({ + elements, + afterTime: this.splitTime, + shiftAmount: leftVisibleDurationForRipple, + }); + } + + return { ...track, elements } as typeof track; }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index f3ace94f..b5e74a45 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -4,6 +4,7 @@ export * from "./track-utils"; export * from "./element-utils"; export * from "./zoom-utils"; export * from "./ruler-utils"; +export * from "./ripple-utils"; export function calculateTotalDuration({ tracks, diff --git a/apps/web/src/lib/timeline/ripple-utils.ts b/apps/web/src/lib/timeline/ripple-utils.ts new file mode 100644 index 00000000..5f37bf29 --- /dev/null +++ b/apps/web/src/lib/timeline/ripple-utils.ts @@ -0,0 +1,17 @@ +import type { TimelineElement } from "@/types/timeline"; + +export function rippleShiftElements({ + elements, + afterTime, + shiftAmount, +}: { + elements: TimelineElement[]; + afterTime: number; + shiftAmount: number; +}): TimelineElement[] { + return elements.map((element) => + element.startTime >= afterTime + ? { ...element, startTime: element.startTime - shiftAmount } + : element, + ); +}