From 70a6d58b8432e455ab1443d45bc23dd70b363bea Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 31 Mar 2026 15:47:30 +0200 Subject: refactor: centralize track placement into its own module --- .../src/lib/commands/timeline/clipboard/paste.ts | 165 +++------------- .../commands/timeline/element/delete-elements.ts | 3 +- .../timeline/element/duplicate-elements.ts | 46 ++--- .../commands/timeline/element/insert-element.ts | 208 ++++++--------------- .../lib/commands/timeline/element/move-elements.ts | 2 +- .../timeline/element/update-element-start-time.ts | 2 +- .../timeline/element/update-element-trim.ts | 2 +- .../src/lib/commands/timeline/track/add-track.ts | 2 +- .../lib/commands/timeline/track/remove-track.ts | 2 +- 9 files changed, 116 insertions(+), 316 deletions(-) (limited to 'apps/web/src/lib/commands/timeline') diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index d9c54ec3..f48bc2da 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -6,13 +6,12 @@ import type { ClipboardItem, } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; -import { wouldElementOverlap } from "@/lib/timeline/element-utils"; import { - buildEmptyTrack, - getHighestInsertIndexForTrack, + applyPlacement, + resolveTrackPlacement, isMainTrack, enforceMainTrackStart, -} from "@/lib/timeline/track-utils"; +} from "@/lib/track-placement"; import { cloneAnimations } from "@/lib/animation"; export class PasteCommand extends Command { @@ -59,17 +58,22 @@ export class PasteCommand extends Command { const sourceTrackIndex = updatedTracks.findIndex( (track) => track.id === trackId, ); - const resolvedTargetIndex = resolveTargetTrackIndex({ + const placementResult = resolveTrackPlacement({ tracks: updatedTracks, - sourceTrackIndex, trackType, - elements: elementsToAdd, + timeSpans: elementsToAdd.map((element) => ({ + startTime: element.startTime, + duration: element.duration, + })), + strategy: { type: "aboveSource", sourceTrackIndex }, }); + if (!placementResult) { + continue; + } - if (resolvedTargetIndex >= 0) { - const targetTrack = updatedTracks[resolvedTargetIndex]; - let adjustedElements = elementsToAdd; - + let elementsForPlacement = elementsToAdd; + if (placementResult.kind === "existingTrack") { + const targetTrack = updatedTracks[placementResult.trackIndex]; if (isMainTrack(targetTrack)) { const earliestElement = elementsToAdd.reduce((earliest, element) => element.startTime < earliest.startTime ? element : earliest, @@ -82,39 +86,28 @@ export class PasteCommand extends Command { const delta = adjustedEarliestStartTime - earliestElement.startTime; if (delta !== 0) { - adjustedElements = elementsToAdd.map((element) => ({ + elementsForPlacement = elementsToAdd.map((element) => ({ ...element, startTime: Math.max(0, element.startTime + delta), })); } } - - updatedTracks[resolvedTargetIndex] = { - ...targetTrack, - elements: [...targetTrack.elements, ...adjustedElements], - } as TimelineTrack; - for (const element of adjustedElements) { - this.pastedElements.push({ - trackId: targetTrack.id, - elementId: element.id, - }); - } - continue; } - const insertIndex = resolveInsertIndexForNewTrack({ + const applied = applyPlacement({ tracks: updatedTracks, - sourceTrackIndex, - trackType, + placementResult, + elements: elementsForPlacement, }); - const newTrack = buildTrackWithElements({ - trackType, - elements: elementsToAdd, - }); - updatedTracks.splice(insertIndex, 0, newTrack); - for (const element of elementsToAdd) { + if (!applied) { + continue; + } + + updatedTracks = applied.updatedTracks; + + for (const element of elementsForPlacement) { this.pastedElements.push({ - trackId: newTrack.id, + trackId: applied.targetTrackId, elementId: element.id, }); } @@ -187,107 +180,3 @@ function buildPastedElements({ return elementsToAdd; } -function resolveTargetTrackIndex({ - tracks, - sourceTrackIndex, - trackType, - elements, -}: { - tracks: TimelineTrack[]; - sourceTrackIndex: number; - trackType: ClipboardItem["trackType"]; - elements: TimelineElement[]; -}): number { - if (sourceTrackIndex >= 0) { - const aboveIndex = sourceTrackIndex - 1; - if (aboveIndex < 0) { - return -1; - } - - const aboveTrack = tracks[aboveIndex]; - if (aboveTrack.type !== trackType) { - return -1; - } - - const canPlaceOnAbove = canPlaceElementsOnTrack({ - track: aboveTrack, - elements, - }); - return canPlaceOnAbove ? aboveIndex : -1; - } - - const highestCompatibleIndex = tracks.findIndex( - (track) => track.type === trackType, - ); - if (highestCompatibleIndex < 0) { - return -1; - } - - const highestCompatibleTrack = tracks[highestCompatibleIndex]; - const canPlaceOnHighest = canPlaceElementsOnTrack({ - track: highestCompatibleTrack, - elements, - }); - - return canPlaceOnHighest ? highestCompatibleIndex : -1; -} - -function resolveInsertIndexForNewTrack({ - tracks, - sourceTrackIndex, - trackType, -}: { - tracks: TimelineTrack[]; - sourceTrackIndex: number; - trackType: ClipboardItem["trackType"]; -}): number { - if (sourceTrackIndex >= 0) { - return sourceTrackIndex; - } - - const highestCompatibleIndex = tracks.findIndex( - (track) => track.type === trackType, - ); - if (highestCompatibleIndex >= 0) { - return highestCompatibleIndex; - } - - return getHighestInsertIndexForTrack({ tracks, trackType }); -} - -function canPlaceElementsOnTrack({ - track, - elements, -}: { - track: TimelineTrack; - elements: TimelineElement[]; -}): boolean { - for (const element of elements) { - const endTime = element.startTime + element.duration; - const hasOverlap = wouldElementOverlap({ - elements: track.elements, - startTime: element.startTime, - endTime, - }); - if (hasOverlap) { - return false; - } - } - - return true; -} - -function buildTrackWithElements({ - trackType, - elements, -}: { - trackType: ClipboardItem["trackType"]; - elements: TimelineElement[]; -}): TimelineTrack { - const newTrackId = generateUUID(); - const newTrackBase = buildEmptyTrack({ id: newTrackId, type: trackType }); - return { - ...newTrackBase, - elements, - } as TimelineTrack; -} 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 9b27a3c2..b3078851 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,7 +1,8 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; -import { isMainTrack, rippleShiftElements } from "@/lib/timeline"; +import { rippleShiftElements } from "@/lib/timeline"; +import { isMainTrack } from "@/lib/track-placement"; export class DeleteElementsCommand extends Command { private savedState: TimelineTrack[] | null = null; diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts index 346f8c73..6b9edae0 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -2,10 +2,7 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; -import { - buildEmptyTrack, - getHighestInsertIndexForTrack, -} from "@/lib/timeline/track-utils"; +import { applyPlacement, resolveTrackPlacement } from "@/lib/track-placement"; import { cloneAnimations } from "@/lib/animation"; interface DuplicateElementsParams { @@ -45,22 +42,12 @@ export class DuplicateElementsCommand extends Command { ); const newTrackElements: TimelineElement[] = []; - const newTrackId = generateUUID(); - const newTrackBase = buildEmptyTrack({ - id: newTrackId, - type: track.type, - }); - for (const element of track.elements) { if (!elementIdsToDuplicate.has(element.id)) { continue; } const newId = generateUUID(); - this.duplicatedElements.push({ - trackId: newTrackId, - elementId: newId, - }); newTrackElements.push( buildDuplicateElement({ element, @@ -70,16 +57,33 @@ export class DuplicateElementsCommand extends Command { ); } - const newTrack = { - ...newTrackBase, - elements: newTrackElements, - } as TimelineTrack; - - const insertIndex = getHighestInsertIndexForTrack({ + const placementResult = resolveTrackPlacement({ tracks: updatedTracks, trackType: track.type, + timeSpans: [], + strategy: { type: "alwaysNew", position: "highest" }, }); - updatedTracks.splice(insertIndex, 0, newTrack); + if (!placementResult || placementResult.kind !== "newTrack") { + continue; + } + + const applied = applyPlacement({ + tracks: updatedTracks, + placementResult, + elements: newTrackElements, + }); + if (!applied) { + continue; + } + + updatedTracks = applied.updatedTracks; + + for (const element of newTrackElements) { + this.duplicatedElements.push({ + trackId: applied.targetTrackId, + elementId: element.id, + }); + } } editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts index e6d6ee99..d224cfdb 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -5,23 +5,18 @@ import type { TimelineTrack, TimelineElement, TrackType, - ElementType, } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; +import { requiresMediaId } from "@/lib/timeline/element-utils"; +import type { MediaAsset } from "@/lib/media/types"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; import { - requiresMediaId, - wouldElementOverlap, -} from "@/lib/timeline/element-utils"; -import { - buildEmptyTrack, + applyPlacement, canElementGoOnTrack, - getDefaultInsertIndexForTrack, + resolveTrackPlacement, validateElementTrackCompatibility, - enforceMainTrackStart, -} from "@/lib/timeline/track-utils"; -import type { MediaAsset } from "@/lib/media/types"; -import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; +} from "@/lib/track-placement"; type InsertElementPlacement = | { mode: "explicit"; trackId: string } @@ -67,7 +62,7 @@ export class InsertElementCommand extends Command { const isFirstElement = totalElementsInTimeline === 0; const newElement = this.buildElement({ element: this.element }); - const updateResult = this.resolveTracksWithElement({ + const updateResult = this.applyPlacementResult({ tracks: this.savedState, element: newElement, }); @@ -190,7 +185,7 @@ export class InsertElementCommand extends Command { return true; } - private resolveTracksWithElement({ + private applyPlacementResult({ tracks, element, }: { @@ -199,160 +194,71 @@ export class InsertElementCommand extends Command { }): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null { const placement = this.placement; - if (placement.mode === "explicit") { - const targetTrack = tracks.find( - (track) => track.id === placement.trackId, - ); - - if (!targetTrack) { - console.error("Track not found:", placement.trackId); - return null; - } - - const validation = validateElementTrackCompatibility({ - element, - track: targetTrack, - }); - - if (!validation.isValid) { - console.error(validation.errorMessage); - return null; - } - - const adjustedElement = this.adjustElementForMainTrack({ - tracks, - targetTrackId: targetTrack.id, - element, - }); - - const updatedTracks = tracks.map((track) => - track.id === targetTrack.id - ? { - ...track, - elements: [...track.elements, adjustedElement], - } - : track, - ) as TimelineTrack[]; - - return { updatedTracks, targetTrackId: targetTrack.id }; - } - - const trackType = - placement.trackType ?? this.getTrackTypeForElement({ element }); - if ( + placement.mode === "auto" && placement.trackType && !canElementGoOnTrack({ elementType: element.type, - trackType, + trackType: placement.trackType, }) ) { console.error( - `${element.type} elements cannot be placed on ${trackType} tracks`, + `${element.type} elements cannot be placed on ${placement.trackType} tracks`, ); return null; } - const elementEndTime = element.startTime + element.duration; - const existingTrack = tracks.find((track) => { - if ( - !canElementGoOnTrack({ - elementType: element.type, - trackType: track.type, - }) - ) { - return false; - } - - return !wouldElementOverlap({ - elements: track.elements, - startTime: element.startTime, - endTime: elementEndTime, - }); - }); - - if (existingTrack) { - const adjustedElement = this.adjustElementForMainTrack({ - tracks, - targetTrackId: existingTrack.id, - element, - }); - - const updatedTracks = tracks.map((track) => - track.id === existingTrack.id - ? { - ...track, - elements: [...track.elements, adjustedElement], - } - : track, - ) as TimelineTrack[]; - - return { updatedTracks, targetTrackId: existingTrack.id }; - } - - const newTrackId = generateUUID(); - const newTrack = buildEmptyTrack({ - id: newTrackId, - type: trackType, + const placementResult = resolveTrackPlacement({ + tracks, + ...(placement.mode === "auto" && placement.trackType + ? { trackType: placement.trackType } + : { elementType: element.type }), + timeSpans: [ + { + startTime: element.startTime, + duration: element.duration, + }, + ], + strategy: + placement.mode === "explicit" + ? { type: "explicit", trackId: placement.trackId } + : { type: "firstAvailable" }, }); - const newTrackWithElement = { - ...newTrack, - elements: [...newTrack.elements, element], - } as TimelineTrack; - - const updatedTracks = [...tracks]; - const insertIndex = - placement.insertIndex ?? - this.getAutoInsertIndex({ tracks: updatedTracks, trackType }); - updatedTracks.splice(insertIndex, 0, newTrackWithElement); - - return { updatedTracks, targetTrackId: newTrackId }; - } - - private getAutoInsertIndex({ - tracks, - trackType, - }: { - tracks: TimelineTrack[]; - trackType: TrackType; - }): number { - if (trackType === "text") { - const firstVideoTrackIndex = tracks.findIndex( - (track) => track.type === "video", - ); - if (firstVideoTrackIndex >= 0) { - return firstVideoTrackIndex; + if (!placementResult) { + if (placement.mode === "explicit") { + const targetTrack = tracks.find((track) => track.id === placement.trackId); + if (!targetTrack) { + console.error("Track not found:", placement.trackId); + return null; + } + + const validation = validateElementTrackCompatibility({ + element, + track: targetTrack, + }); + console.error(validation.errorMessage); } + + return null; } - return getDefaultInsertIndexForTrack({ - tracks, - trackType, - }); - } + const elementToPlace = + placementResult.kind === "existingTrack" + ? { + ...element, + startTime: + placementResult.adjustedStartTime ?? element.startTime, + } + : element; - private adjustElementForMainTrack({ - tracks, - targetTrackId, - element, - }: { - tracks: TimelineTrack[]; - targetTrackId: string; - element: TimelineElement; - }): TimelineElement { - const adjustedStartTime = enforceMainTrackStart({ + return applyPlacement({ tracks, - targetTrackId, - requestedStartTime: element.startTime, + placementResult, + elements: [elementToPlace], + newTrackInsertIndexOverride: + placement.mode === "auto" && typeof placement.insertIndex === "number" + ? placement.insertIndex + : undefined, }); - return { ...element, startTime: adjustedStartTime }; - } - - private getTrackTypeForElement({ - element, - }: { - element: { type: ElementType }; - }): TrackType { - return ELEMENT_TRACK_MAP[element.type]; } } diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index 24605d0b..f9144d38 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -10,7 +10,7 @@ import { isMainTrack, validateElementTrackCompatibility, enforceMainTrackStart, -} from "@/lib/timeline/track-utils"; +} from "@/lib/track-placement"; import { rippleShiftElements } from "@/lib/timeline/ripple-utils"; export class MoveElementCommand extends Command { diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts index d640d001..cd7e9bd9 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts @@ -1,7 +1,7 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; -import { enforceMainTrackStart } from "@/lib/timeline/track-utils"; +import { enforceMainTrackStart } from "@/lib/track-placement"; export class UpdateElementStartTimeCommand extends Command { private savedState: TimelineTrack[] | null = null; diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts index b2c77418..54ec5697 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts @@ -3,7 +3,7 @@ import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { clampAnimationsToDuration } from "@/lib/animation"; import { isRetimableElement, rippleShiftElements } from "@/lib/timeline"; -import { enforceMainTrackStart } from "@/lib/timeline/track-utils"; +import { enforceMainTrackStart } from "@/lib/track-placement"; export class UpdateElementTrimCommand extends Command { private savedState: TimelineTrack[] | null = null; diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts index b1f0daa0..43286b95 100644 --- a/apps/web/src/lib/commands/timeline/track/add-track.ts +++ b/apps/web/src/lib/commands/timeline/track/add-track.ts @@ -5,7 +5,7 @@ import { EditorCore } from "@/core"; import { buildEmptyTrack, getDefaultInsertIndexForTrack, -} from "@/lib/timeline/track-utils"; +} from "@/lib/track-placement"; export class AddTrackCommand extends Command { private trackId: string; diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts index a9859a1a..b5d57a3d 100644 --- a/apps/web/src/lib/commands/timeline/track/remove-track.ts +++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts @@ -1,7 +1,7 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { TimelineTrack } from "@/lib/timeline"; -import { getMainTrack } from "@/lib/timeline"; +import { getMainTrack } from "@/lib/track-placement"; export class RemoveTrackCommand extends Command { private savedState: TimelineTrack[] | null = null; -- cgit v1.3.1