Skip to content

Commit 9b5fd6e

Browse files
committed
feat: ensure elements on main track starts at 0
1 parent 00cf7f6 commit 9b5fd6e

File tree

6 files changed

+160
-11
lines changed

6 files changed

+160
-11
lines changed

apps/web/src/lib/commands/timeline/clipboard/paste.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { wouldElementOverlap } from "@/lib/timeline/element-utils";
1010
import {
1111
buildEmptyTrack,
1212
getHighestInsertIndexForTrack,
13+
isMainTrack,
14+
enforceMainTrackStart,
1315
} from "@/lib/timeline/track-utils";
1416

1517
export class PasteCommand extends Command {
@@ -65,11 +67,32 @@ export class PasteCommand extends Command {
6567

6668
if (resolvedTargetIndex >= 0) {
6769
const targetTrack = updatedTracks[resolvedTargetIndex];
70+
let adjustedElements = elementsToAdd;
71+
72+
if (isMainTrack(targetTrack)) {
73+
const earliestElement = elementsToAdd.reduce((earliest, element) =>
74+
element.startTime < earliest.startTime ? element : earliest,
75+
);
76+
const adjustedEarliestStartTime = enforceMainTrackStart({
77+
tracks: updatedTracks,
78+
targetTrackId: targetTrack.id,
79+
requestedStartTime: earliestElement.startTime,
80+
});
81+
const delta = adjustedEarliestStartTime - earliestElement.startTime;
82+
83+
if (delta !== 0) {
84+
adjustedElements = elementsToAdd.map((element) => ({
85+
...element,
86+
startTime: Math.max(0, element.startTime + delta),
87+
}));
88+
}
89+
}
90+
6891
updatedTracks[resolvedTargetIndex] = {
6992
...targetTrack,
70-
elements: [...targetTrack.elements, ...elementsToAdd],
93+
elements: [...targetTrack.elements, ...adjustedElements],
7194
} as TimelineTrack;
72-
for (const element of elementsToAdd) {
95+
for (const element of adjustedElements) {
7396
this.pastedElements.push({
7497
trackId: targetTrack.id,
7598
elementId: element.id,

apps/web/src/lib/commands/timeline/element/insert-element.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
canElementGoOnTrack,
1818
getDefaultInsertIndexForTrack,
1919
validateElementTrackCompatibility,
20+
enforceMainTrackStart,
2021
} from "@/lib/timeline/track-utils";
2122
import type { MediaAsset } from "@/types/assets";
2223
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
@@ -204,9 +205,18 @@ export class InsertElementCommand extends Command {
204205
return null;
205206
}
206207

208+
const adjustedElement = this.adjustElementForMainTrack({
209+
tracks,
210+
targetTrackId: targetTrack.id,
211+
element,
212+
});
213+
207214
const updatedTracks = tracks.map((track) =>
208215
track.id === targetTrack.id
209-
? { ...track, elements: [...track.elements, element] }
216+
? {
217+
...track,
218+
elements: [...track.elements, adjustedElement],
219+
}
210220
: track,
211221
) as TimelineTrack[];
212222

@@ -248,9 +258,18 @@ export class InsertElementCommand extends Command {
248258
});
249259

250260
if (existingTrack) {
261+
const adjustedElement = this.adjustElementForMainTrack({
262+
tracks,
263+
targetTrackId: existingTrack.id,
264+
element,
265+
});
266+
251267
const updatedTracks = tracks.map((track) =>
252268
track.id === existingTrack.id
253-
? { ...track, elements: [...track.elements, element] }
269+
? {
270+
...track,
271+
elements: [...track.elements, adjustedElement],
272+
}
254273
: track,
255274
) as TimelineTrack[];
256275

@@ -298,6 +317,23 @@ export class InsertElementCommand extends Command {
298317
});
299318
}
300319

320+
private adjustElementForMainTrack({
321+
tracks,
322+
targetTrackId,
323+
element,
324+
}: {
325+
tracks: TimelineTrack[];
326+
targetTrackId: string;
327+
element: TimelineElement;
328+
}): TimelineElement {
329+
const adjustedStartTime = enforceMainTrackStart({
330+
tracks,
331+
targetTrackId,
332+
requestedStartTime: element.startTime,
333+
});
334+
return { ...element, startTime: adjustedStartTime };
335+
}
336+
301337
private getTrackTypeForElement({
302338
element,
303339
}: {

apps/web/src/lib/commands/timeline/element/move-elements.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
buildEmptyTrack,
1010
isMainTrack,
1111
validateElementTrackCompatibility,
12+
enforceMainTrackStart,
1213
} from "@/lib/timeline/track-utils";
1314

1415
export class MoveElementCommand extends Command {
@@ -66,9 +67,16 @@ export class MoveElementCommand extends Command {
6667
return;
6768
}
6869

70+
const adjustedStartTime = enforceMainTrackStart({
71+
tracks: tracksToUpdate,
72+
targetTrackId: this.targetTrackId,
73+
requestedStartTime: this.newStartTime,
74+
excludeElementId: this.elementId,
75+
});
76+
6977
const movedElement: TimelineElement = {
7078
...element,
71-
startTime: this.newStartTime,
79+
startTime: adjustedStartTime,
7280
};
7381

7482
const isSameTrack = this.sourceTrackId === this.targetTrackId;

apps/web/src/lib/commands/timeline/element/update-element-start-time.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Command } from "@/lib/commands/base-command";
22
import type { TimelineTrack } from "@/types/timeline";
33
import { EditorCore } from "@/core";
4+
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
45

56
export class UpdateElementStartTimeCommand extends Command {
67
private savedState: TimelineTrack[] | null = null;
@@ -16,7 +17,8 @@ export class UpdateElementStartTimeCommand extends Command {
1617
const editor = EditorCore.getInstance();
1718
this.savedState = editor.timeline.getTracks();
1819

19-
const updatedTracks = this.savedState.map((track) => {
20+
const currentTracks = this.savedState;
21+
const updatedTracks = currentTracks.map((track) => {
2022
const hasElementsToUpdate = this.elements.some(
2123
(el) => el.trackId === track.id,
2224
);
@@ -29,9 +31,19 @@ export class UpdateElementStartTimeCommand extends Command {
2931
const shouldUpdate = this.elements.some(
3032
(el) => el.elementId === element.id && el.trackId === track.id,
3133
);
32-
return shouldUpdate
33-
? { ...element, startTime: Math.max(0, this.startTime) }
34-
: element;
34+
if (!shouldUpdate) {
35+
return element;
36+
}
37+
38+
const baseStartTime = Math.max(0, this.startTime);
39+
const adjustedStartTime = enforceMainTrackStart({
40+
tracks: currentTracks,
41+
targetTrackId: track.id,
42+
requestedStartTime: baseStartTime,
43+
excludeElementId: element.id,
44+
});
45+
46+
return { ...element, startTime: adjustedStartTime };
3547
});
3648
return { ...track, elements: newElements } as typeof track;
3749
});

apps/web/src/lib/timeline/drop-utils.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { TimelineTrack, ElementType } from "@/types/timeline";
22
import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
33
import { wouldElementOverlap } from "./element-utils";
44
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
5-
import { isMainTrack } from "./track-utils";
5+
import { isMainTrack, enforceMainTrackStart } from "./track-utils";
66

77
function getTrackAtY({
88
mouseY,
@@ -185,11 +185,21 @@ export function computeDropTarget({
185185
});
186186

187187
if (isTrackCompatible && !hasOverlap) {
188+
const targetTrack = tracks[trackIndex];
189+
// safe: snap to 0 only happens when element becomes the new earliest,
190+
// meaning the space before the current earliest is empty
191+
const adjustedXPosition = enforceMainTrackStart({
192+
tracks,
193+
targetTrackId: targetTrack.id,
194+
requestedStartTime: xPosition,
195+
excludeElementId,
196+
});
197+
188198
return {
189199
trackIndex,
190200
isNewTrack: false,
191201
insertPosition: null,
192-
xPosition,
202+
xPosition: adjustedXPosition,
193203
};
194204
}
195205

apps/web/src/lib/timeline/track-utils.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
AudioTrack,
77
StickerTrack,
88
TextTrack,
9+
TimelineElement,
910
} from "@/types/timeline";
1011
import {
1112
TRACK_COLORS,
@@ -242,3 +243,62 @@ export function validateElementTrackCompatibility({
242243

243244
return { isValid: true };
244245
}
246+
247+
export function getEarliestMainTrackElement({
248+
tracks,
249+
excludeElementId,
250+
}: {
251+
tracks: TimelineTrack[];
252+
excludeElementId?: string;
253+
}): TimelineElement | null {
254+
const mainTrack = getMainTrack({ tracks });
255+
if (!mainTrack) {
256+
return null;
257+
}
258+
259+
const elements = mainTrack.elements.filter(
260+
(element) => !excludeElementId || element.id !== excludeElementId,
261+
);
262+
263+
if (elements.length === 0) {
264+
return null;
265+
}
266+
267+
return elements.reduce((earliest, element) =>
268+
element.startTime < earliest.startTime ? element : earliest,
269+
);
270+
}
271+
272+
export function enforceMainTrackStart({
273+
tracks,
274+
targetTrackId,
275+
requestedStartTime,
276+
excludeElementId,
277+
}: {
278+
tracks: TimelineTrack[];
279+
targetTrackId: string;
280+
requestedStartTime: number;
281+
excludeElementId?: string;
282+
}): number {
283+
const mainTrack = getMainTrack({ tracks });
284+
if (!mainTrack || mainTrack.id !== targetTrackId) {
285+
return requestedStartTime;
286+
}
287+
288+
const earliestElement = getEarliestMainTrackElement({
289+
tracks,
290+
excludeElementId,
291+
});
292+
293+
if (!earliestElement) {
294+
return 0;
295+
}
296+
297+
// main track must always start at time 0; if this element would
298+
// become the earliest, pin it to the start
299+
if (requestedStartTime <= earliestElement.startTime) {
300+
return 0;
301+
}
302+
303+
return requestedStartTime;
304+
}

0 commit comments

Comments
 (0)