summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-02-08 01:51:25 +0100
committerMaze Winther <mazewinther@gmail.com>2026-02-08 01:51:25 +0100
commit9b5fd6eb077a3fb2380f42414b65405030a01fcd (patch)
tree91d56e1c71e9a876afb1ae39fe4644ed8d5accee /apps/web/src/lib/timeline
parent00cf7f6ab7b35616290cfeab06256a14f6d4beba (diff)
downloadopencut-9b5fd6eb077a3fb2380f42414b65405030a01fcd.tar.gz
feat: ensure elements on main track starts at 0
Diffstat (limited to 'apps/web/src/lib/timeline')
-rw-r--r--apps/web/src/lib/timeline/drop-utils.ts14
-rw-r--r--apps/web/src/lib/timeline/track-utils.ts60
2 files changed, 72 insertions, 2 deletions
diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts
index 8c22194a..9182892f 100644
--- a/apps/web/src/lib/timeline/drop-utils.ts
+++ b/apps/web/src/lib/timeline/drop-utils.ts
@@ -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,
};
}
diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts
index b6261569..415d0ace 100644
--- a/apps/web/src/lib/timeline/track-utils.ts
+++ b/apps/web/src/lib/timeline/track-utils.ts
@@ -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;
+}