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
@@ -10,6 +10,8 @@ import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import { import {
buildEmptyTrack, buildEmptyTrack,
getHighestInsertIndexForTrack, getHighestInsertIndexForTrack,
isMainTrack,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils"; } from "@/lib/timeline/track-utils";
export class PasteCommand extends Command { export class PasteCommand extends Command {
@@ -65,11 +67,32 @@ export class PasteCommand extends Command {
if (resolvedTargetIndex >= 0) { if (resolvedTargetIndex >= 0) {
const targetTrack = updatedTracks[resolvedTargetIndex]; 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] = { updatedTracks[resolvedTargetIndex] = {
...targetTrack, ...targetTrack,
elements: [...targetTrack.elements, ...elementsToAdd], elements: [...targetTrack.elements, ...adjustedElements],
} as TimelineTrack; } as TimelineTrack;
for (const element of elementsToAdd) { for (const element of adjustedElements) {
this.pastedElements.push({ this.pastedElements.push({
trackId: targetTrack.id, trackId: targetTrack.id,
elementId: element.id, elementId: element.id,
@@ -17,6 +17,7 @@ import {
canElementGoOnTrack, canElementGoOnTrack,
getDefaultInsertIndexForTrack, getDefaultInsertIndexForTrack,
validateElementTrackCompatibility, validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils"; } from "@/lib/timeline/track-utils";
import type { MediaAsset } from "@/types/assets"; import type { MediaAsset } from "@/types/assets";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
@@ -204,9 +205,18 @@ export class InsertElementCommand extends Command {
return null; return null;
} }
const adjustedElement = this.adjustElementForMainTrack({
tracks,
targetTrackId: targetTrack.id,
element,
});
const updatedTracks = tracks.map((track) => const updatedTracks = tracks.map((track) =>
track.id === targetTrack.id track.id === targetTrack.id
? { ...track, elements: [...track.elements, element] } ? {
...track,
elements: [...track.elements, adjustedElement],
}
: track, : track,
) as TimelineTrack[]; ) as TimelineTrack[];
@@ -248,9 +258,18 @@ export class InsertElementCommand extends Command {
}); });
if (existingTrack) { if (existingTrack) {
const adjustedElement = this.adjustElementForMainTrack({
tracks,
targetTrackId: existingTrack.id,
element,
});
const updatedTracks = tracks.map((track) => const updatedTracks = tracks.map((track) =>
track.id === existingTrack.id track.id === existingTrack.id
? { ...track, elements: [...track.elements, element] } ? {
...track,
elements: [...track.elements, adjustedElement],
}
: track, : track,
) as TimelineTrack[]; ) 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({ private getTrackTypeForElement({
element, element,
}: { }: {
@@ -9,6 +9,7 @@ import {
buildEmptyTrack, buildEmptyTrack,
isMainTrack, isMainTrack,
validateElementTrackCompatibility, validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils"; } from "@/lib/timeline/track-utils";
export class MoveElementCommand extends Command { export class MoveElementCommand extends Command {
@@ -66,9 +67,16 @@ export class MoveElementCommand extends Command {
return; return;
} }
const adjustedStartTime = enforceMainTrackStart({
tracks: tracksToUpdate,
targetTrackId: this.targetTrackId,
requestedStartTime: this.newStartTime,
excludeElementId: this.elementId,
});
const movedElement: TimelineElement = { const movedElement: TimelineElement = {
...element, ...element,
startTime: this.newStartTime, startTime: adjustedStartTime,
}; };
const isSameTrack = this.sourceTrackId === this.targetTrackId; const isSameTrack = this.sourceTrackId === this.targetTrackId;
@@ -1,6 +1,7 @@
import { Command } from "@/lib/commands/base-command"; import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline"; import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
export class UpdateElementStartTimeCommand extends Command { export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
@@ -16,7 +17,8 @@ export class UpdateElementStartTimeCommand extends Command {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); 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( const hasElementsToUpdate = this.elements.some(
(el) => el.trackId === track.id, (el) => el.trackId === track.id,
); );
@@ -29,9 +31,19 @@ export class UpdateElementStartTimeCommand extends Command {
const shouldUpdate = this.elements.some( const shouldUpdate = this.elements.some(
(el) => el.elementId === element.id && el.trackId === track.id, (el) => el.elementId === element.id && el.trackId === track.id,
); );
return shouldUpdate if (!shouldUpdate) {
? { ...element, startTime: Math.max(0, this.startTime) } return element;
: 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; return { ...track, elements: newElements } as typeof track;
}); });
+12 -2
View File
@@ -2,7 +2,7 @@ import type { TimelineTrack, ElementType } from "@/types/timeline";
import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants"; import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils"; import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline"; import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
import { isMainTrack } from "./track-utils"; import { isMainTrack, enforceMainTrackStart } from "./track-utils";
function getTrackAtY({ function getTrackAtY({
mouseY, mouseY,
@@ -185,11 +185,21 @@ export function computeDropTarget({
}); });
if (isTrackCompatible && !hasOverlap) { 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 { return {
trackIndex, trackIndex,
isNewTrack: false, isNewTrack: false,
insertPosition: null, insertPosition: null,
xPosition, xPosition: adjustedXPosition,
}; };
} }
+60
View File
@@ -6,6 +6,7 @@ import type {
AudioTrack, AudioTrack,
StickerTrack, StickerTrack,
TextTrack, TextTrack,
TimelineElement,
} from "@/types/timeline"; } from "@/types/timeline";
import { import {
TRACK_COLORS, TRACK_COLORS,
@@ -242,3 +243,62 @@ export function validateElementTrackCompatibility({
return { isValid: true }; 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;
}