Files
OpenCut/apps/web/src/lib/timeline/drop-utils.ts
T

245 lines
5.6 KiB
TypeScript

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, enforceMainTrackStart } from "./track-utils";
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) {
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: adjustedXPosition,
};
}
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;
}