2026-01-31 00:20:04 +01:00
|
|
|
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";
|
2026-02-08 01:51:25 +01:00
|
|
|
import { isMainTrack, enforceMainTrackStart } from "./track-utils";
|
2026-01-31 00:20:04 +01:00
|
|
|
|
|
|
|
|
function getTrackAtY({
|
|
|
|
|
mouseY,
|
|
|
|
|
tracks,
|
|
|
|
|
verticalDragDirection,
|
|
|
|
|
}: {
|
|
|
|
|
mouseY: number;
|
|
|
|
|
tracks: TimelineTrack[];
|
|
|
|
|
verticalDragDirection?: "up" | "down" | null;
|
|
|
|
|
}): { trackIndex: number; relativeY: number } | null {
|
|
|
|
|
let cumulativeHeight = 0;
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < tracks.length; i++) {
|
|
|
|
|
const trackHeight = TRACK_HEIGHTS[tracks[i].type];
|
|
|
|
|
const trackTop = cumulativeHeight;
|
|
|
|
|
const trackBottom = trackTop + trackHeight;
|
|
|
|
|
|
|
|
|
|
if (mouseY >= trackTop && mouseY < trackBottom) {
|
|
|
|
|
return {
|
|
|
|
|
trackIndex: i,
|
|
|
|
|
relativeY: mouseY - trackTop,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (i < tracks.length - 1 && verticalDragDirection) {
|
|
|
|
|
const gapTop = trackBottom;
|
|
|
|
|
const gapBottom = gapTop + TRACK_GAP;
|
|
|
|
|
if (mouseY >= gapTop && mouseY < gapBottom) {
|
|
|
|
|
const isDraggingUp = verticalDragDirection === "up";
|
|
|
|
|
return {
|
|
|
|
|
trackIndex: isDraggingUp ? i : i + 1,
|
|
|
|
|
relativeY: isDraggingUp ? trackHeight - 1 : 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cumulativeHeight += trackHeight + TRACK_GAP;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isCompatible({
|
|
|
|
|
elementType,
|
|
|
|
|
trackType,
|
|
|
|
|
}: {
|
|
|
|
|
elementType: ElementType;
|
|
|
|
|
trackType: TimelineTrack["type"];
|
|
|
|
|
}): boolean {
|
|
|
|
|
if (elementType === "text") return trackType === "text";
|
|
|
|
|
if (elementType === "audio") return trackType === "audio";
|
|
|
|
|
if (elementType === "sticker") return trackType === "sticker";
|
|
|
|
|
if (elementType === "video" || elementType === "image") {
|
|
|
|
|
return trackType === "video";
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
|
|
|
|
|
return tracks.findIndex((track) => isMainTrack(track));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findInsertIndex({
|
|
|
|
|
elementType,
|
|
|
|
|
tracks,
|
|
|
|
|
preferredIndex,
|
|
|
|
|
insertAbove,
|
|
|
|
|
}: {
|
|
|
|
|
elementType: ElementType;
|
|
|
|
|
tracks: TimelineTrack[];
|
|
|
|
|
preferredIndex: number;
|
|
|
|
|
insertAbove: boolean;
|
|
|
|
|
}): { index: number; position: "above" | "below" } {
|
|
|
|
|
const mainTrackIndex = getMainTrackIndex({ tracks });
|
|
|
|
|
|
|
|
|
|
if (elementType === "audio") {
|
|
|
|
|
if (preferredIndex <= mainTrackIndex) {
|
|
|
|
|
return { index: mainTrackIndex + 1, position: "below" };
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
index: insertAbove ? preferredIndex : preferredIndex + 1,
|
|
|
|
|
position: insertAbove ? "above" : "below",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1;
|
|
|
|
|
|
|
|
|
|
if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) {
|
|
|
|
|
return { index: mainTrackIndex, position: "above" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
index: overlayInsertIndex,
|
|
|
|
|
position: insertAbove ? "above" : "below",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function computeDropTarget({
|
|
|
|
|
elementType,
|
|
|
|
|
mouseX,
|
|
|
|
|
mouseY,
|
|
|
|
|
tracks,
|
|
|
|
|
playheadTime,
|
|
|
|
|
isExternalDrop,
|
|
|
|
|
elementDuration,
|
|
|
|
|
pixelsPerSecond,
|
|
|
|
|
zoomLevel,
|
|
|
|
|
verticalDragDirection,
|
|
|
|
|
startTimeOverride,
|
|
|
|
|
excludeElementId,
|
|
|
|
|
}: ComputeDropTargetParams): DropTarget {
|
|
|
|
|
const xPosition =
|
|
|
|
|
typeof startTimeOverride === "number"
|
|
|
|
|
? startTimeOverride
|
|
|
|
|
: isExternalDrop
|
|
|
|
|
? playheadTime
|
|
|
|
|
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
|
|
|
|
|
|
|
|
|
const mainTrackIndex = getMainTrackIndex({ tracks });
|
|
|
|
|
|
|
|
|
|
if (tracks.length === 0) {
|
|
|
|
|
if (elementType === "audio") {
|
|
|
|
|
return {
|
|
|
|
|
trackIndex: 0,
|
|
|
|
|
isNewTrack: true,
|
|
|
|
|
insertPosition: "below",
|
|
|
|
|
xPosition,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
|
|
|
|
|
|
|
|
|
|
if (!trackAtMouse) {
|
|
|
|
|
const isAboveAllTracks = mouseY < 0;
|
|
|
|
|
|
|
|
|
|
if (elementType === "audio") {
|
|
|
|
|
return {
|
|
|
|
|
trackIndex: tracks.length,
|
|
|
|
|
isNewTrack: true,
|
|
|
|
|
insertPosition: "below",
|
|
|
|
|
xPosition,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isAboveAllTracks) {
|
|
|
|
|
return {
|
|
|
|
|
trackIndex: 0,
|
|
|
|
|
isNewTrack: true,
|
|
|
|
|
insertPosition: "above",
|
|
|
|
|
xPosition,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
trackIndex: Math.max(0, mainTrackIndex),
|
|
|
|
|
isNewTrack: true,
|
|
|
|
|
insertPosition: "above",
|
|
|
|
|
xPosition,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { trackIndex, relativeY } = trackAtMouse;
|
|
|
|
|
const track = tracks[trackIndex];
|
|
|
|
|
const trackHeight = TRACK_HEIGHTS[track.type];
|
|
|
|
|
const isInUpperHalf = relativeY < trackHeight / 2;
|
|
|
|
|
|
|
|
|
|
const isTrackCompatible = isCompatible({
|
|
|
|
|
elementType,
|
|
|
|
|
trackType: track.type,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const endTime = xPosition + elementDuration;
|
|
|
|
|
const hasOverlap = wouldElementOverlap({
|
|
|
|
|
elements: track.elements,
|
|
|
|
|
startTime: xPosition,
|
|
|
|
|
endTime,
|
|
|
|
|
excludeElementId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (isTrackCompatible && !hasOverlap) {
|
2026-02-08 01:51:25 +01:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
return {
|
|
|
|
|
trackIndex,
|
|
|
|
|
isNewTrack: false,
|
|
|
|
|
insertPosition: null,
|
2026-02-08 01:51:25 +01:00
|
|
|
xPosition: adjustedXPosition,
|
2026-01-31 00:20:04 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let insertAbove = isInUpperHalf;
|
|
|
|
|
if (!isTrackCompatible && verticalDragDirection) {
|
|
|
|
|
insertAbove = verticalDragDirection === "up";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { index, position } = findInsertIndex({
|
|
|
|
|
elementType,
|
|
|
|
|
tracks,
|
|
|
|
|
preferredIndex: trackIndex,
|
|
|
|
|
insertAbove,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
trackIndex: index,
|
|
|
|
|
isNewTrack: true,
|
|
|
|
|
insertPosition: position,
|
|
|
|
|
xPosition,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getDropLineY({
|
|
|
|
|
dropTarget,
|
|
|
|
|
tracks,
|
|
|
|
|
}: {
|
|
|
|
|
dropTarget: DropTarget;
|
|
|
|
|
tracks: TimelineTrack[];
|
|
|
|
|
}): number {
|
|
|
|
|
const safeTrackIndex = Math.min(
|
|
|
|
|
Math.max(dropTarget.trackIndex, 0),
|
|
|
|
|
tracks.length,
|
|
|
|
|
);
|
|
|
|
|
let y = 0;
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < safeTrackIndex; i++) {
|
|
|
|
|
y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return y;
|
|
|
|
|
}
|