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 --- .../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 +- 6 files changed, 87 insertions(+), 176 deletions(-) (limited to 'apps/web/src/lib/commands/timeline/element') 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; -- cgit v1.3.1