feat: ensure elements on main track starts at 0

This commit is contained in:
Maze Winther 2026-02-08 01:51:25 +01:00
parent 00cf7f6ab7
commit 9b5fd6eb07
6 changed files with 160 additions and 11 deletions

View File

@ -10,6 +10,8 @@ import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
isMainTrack,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
export class PasteCommand extends Command {
@ -65,11 +67,32 @@ export class PasteCommand extends Command {
if (resolvedTargetIndex >= 0) {
const targetTrack = updatedTracks[resolvedTargetIndex];
let adjustedElements = elementsToAdd;
if (isMainTrack(targetTrack)) {
const earliestElement = elementsToAdd.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest,
);
const adjustedEarliestStartTime = enforceMainTrackStart({
tracks: updatedTracks,
targetTrackId: targetTrack.id,
requestedStartTime: earliestElement.startTime,
});
const delta = adjustedEarliestStartTime - earliestElement.startTime;
if (delta !== 0) {
adjustedElements = elementsToAdd.map((element) => ({
...element,
startTime: Math.max(0, element.startTime + delta),
}));
}
}
updatedTracks[resolvedTargetIndex] = {
...targetTrack,
elements: [...targetTrack.elements, ...elementsToAdd],
elements: [...targetTrack.elements, ...adjustedElements],
} as TimelineTrack;
for (const element of elementsToAdd) {
for (const element of adjustedElements) {
this.pastedElements.push({
trackId: targetTrack.id,
elementId: element.id,

View File

@ -17,6 +17,7 @@ import {
canElementGoOnTrack,
getDefaultInsertIndexForTrack,
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
import type { MediaAsset } from "@/types/assets";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
@ -204,9 +205,18 @@ export class InsertElementCommand extends Command {
return null;
}
const adjustedElement = this.adjustElementForMainTrack({
tracks,
targetTrackId: targetTrack.id,
element,
});
const updatedTracks = tracks.map((track) =>
track.id === targetTrack.id
? { ...track, elements: [...track.elements, element] }
? {
...track,
elements: [...track.elements, adjustedElement],
}
: track,
) as TimelineTrack[];
@ -248,9 +258,18 @@ export class InsertElementCommand extends Command {
});
if (existingTrack) {
const adjustedElement = this.adjustElementForMainTrack({
tracks,
targetTrackId: existingTrack.id,
element,
});
const updatedTracks = tracks.map((track) =>
track.id === existingTrack.id
? { ...track, elements: [...track.elements, element] }
? {
...track,
elements: [...track.elements, adjustedElement],
}
: track,
) as TimelineTrack[];
@ -298,6 +317,23 @@ export class InsertElementCommand extends Command {
});
}
private adjustElementForMainTrack({
tracks,
targetTrackId,
element,
}: {
tracks: TimelineTrack[];
targetTrackId: string;
element: TimelineElement;
}): TimelineElement {
const adjustedStartTime = enforceMainTrackStart({
tracks,
targetTrackId,
requestedStartTime: element.startTime,
});
return { ...element, startTime: adjustedStartTime };
}
private getTrackTypeForElement({
element,
}: {

View File

@ -9,6 +9,7 @@ import {
buildEmptyTrack,
isMainTrack,
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
export class MoveElementCommand extends Command {
@ -66,9 +67,16 @@ export class MoveElementCommand extends Command {
return;
}
const adjustedStartTime = enforceMainTrackStart({
tracks: tracksToUpdate,
targetTrackId: this.targetTrackId,
requestedStartTime: this.newStartTime,
excludeElementId: this.elementId,
});
const movedElement: TimelineElement = {
...element,
startTime: this.newStartTime,
startTime: adjustedStartTime,
};
const isSameTrack = this.sourceTrackId === this.targetTrackId;

View File

@ -1,6 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@ -16,7 +17,8 @@ export class UpdateElementStartTimeCommand extends Command {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((track) => {
const currentTracks = this.savedState;
const updatedTracks = currentTracks.map((track) => {
const hasElementsToUpdate = this.elements.some(
(el) => el.trackId === track.id,
);
@ -29,9 +31,19 @@ export class UpdateElementStartTimeCommand extends Command {
const shouldUpdate = this.elements.some(
(el) => el.elementId === element.id && el.trackId === track.id,
);
return shouldUpdate
? { ...element, startTime: Math.max(0, this.startTime) }
: element;
if (!shouldUpdate) {
return element;
}
const baseStartTime = Math.max(0, this.startTime);
const adjustedStartTime = enforceMainTrackStart({
tracks: currentTracks,
targetTrackId: track.id,
requestedStartTime: baseStartTime,
excludeElementId: element.id,
});
return { ...element, startTime: adjustedStartTime };
});
return { ...track, elements: newElements } as typeof track;
});

View File

@ -2,7 +2,7 @@ import type { TimelineTrack, ElementType } from "@/types/timeline";
import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
import { isMainTrack } from "./track-utils";
import { isMainTrack, enforceMainTrackStart } from "./track-utils";
function getTrackAtY({
mouseY,
@ -185,11 +185,21 @@ export function computeDropTarget({
});
if (isTrackCompatible && !hasOverlap) {
const targetTrack = tracks[trackIndex];
// safe: snap to 0 only happens when element becomes the new earliest,
// meaning the space before the current earliest is empty
const adjustedXPosition = enforceMainTrackStart({
tracks,
targetTrackId: targetTrack.id,
requestedStartTime: xPosition,
excludeElementId,
});
return {
trackIndex,
isNewTrack: false,
insertPosition: null,
xPosition,
xPosition: adjustedXPosition,
};
}

View File

@ -6,6 +6,7 @@ import type {
AudioTrack,
StickerTrack,
TextTrack,
TimelineElement,
} from "@/types/timeline";
import {
TRACK_COLORS,
@ -242,3 +243,62 @@ export function validateElementTrackCompatibility({
return { isValid: true };
}
export function getEarliestMainTrackElement({
tracks,
excludeElementId,
}: {
tracks: TimelineTrack[];
excludeElementId?: string;
}): TimelineElement | null {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack) {
return null;
}
const elements = mainTrack.elements.filter(
(element) => !excludeElementId || element.id !== excludeElementId,
);
if (elements.length === 0) {
return null;
}
return elements.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest,
);
}
export function enforceMainTrackStart({
tracks,
targetTrackId,
requestedStartTime,
excludeElementId,
}: {
tracks: TimelineTrack[];
targetTrackId: string;
requestedStartTime: number;
excludeElementId?: string;
}): number {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack || mainTrack.id !== targetTrackId) {
return requestedStartTime;
}
const earliestElement = getEarliestMainTrackElement({
tracks,
excludeElementId,
});
if (!earliestElement) {
return 0;
}
// main track must always start at time 0; if this element would
// become the earliest, pin it to the start
if (requestedStartTime <= earliestElement.startTime) {
return 0;
}
return requestedStartTime;
}