214 lines
4.9 KiB
TypeScript
214 lines
4.9 KiB
TypeScript
import type { TimelineTrack, TimelineElement } from "@/types/timeline";
|
|
import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
|
|
import { wouldElementOverlap } from "./element-utils";
|
|
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
|
|
|
|
function getTrackAtY({
|
|
mouseY,
|
|
tracks,
|
|
}: {
|
|
mouseY: number;
|
|
tracks: TimelineTrack[];
|
|
}): { 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,
|
|
};
|
|
}
|
|
|
|
cumulativeHeight += trackHeight + TRACK_GAP;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isCompatible({
|
|
elementType,
|
|
trackType,
|
|
}: {
|
|
elementType: TimelineElement["type"];
|
|
trackType: TimelineTrack["type"];
|
|
}): boolean {
|
|
if (elementType === "text") return trackType === "text";
|
|
if (elementType === "audio") return trackType === "audio";
|
|
if (elementType === "video" || elementType === "image") {
|
|
return trackType === "media";
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
|
|
return tracks.findIndex((t) => t.type === "media" && t.isMain);
|
|
}
|
|
|
|
function findInsertIndex({
|
|
elementType,
|
|
tracks,
|
|
preferredIndex,
|
|
insertAbove,
|
|
}: {
|
|
elementType: TimelineElement["type"];
|
|
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",
|
|
};
|
|
}
|
|
|
|
if (preferredIndex > mainTrackIndex && mainTrackIndex >= 0) {
|
|
return { index: mainTrackIndex, position: "above" };
|
|
}
|
|
|
|
return {
|
|
index: insertAbove ? preferredIndex : preferredIndex + 1,
|
|
position: insertAbove ? "above" : "below",
|
|
};
|
|
}
|
|
|
|
export function computeDropTarget({
|
|
elementType,
|
|
mouseX,
|
|
mouseY,
|
|
tracks,
|
|
playheadTime,
|
|
isExternalDrop,
|
|
elementDuration,
|
|
pixelsPerSecond,
|
|
zoomLevel,
|
|
}: ComputeDropTargetParams): DropTarget {
|
|
const xPosition = 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 });
|
|
|
|
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 as TimelineElement[],
|
|
startTime: xPosition,
|
|
endTime,
|
|
});
|
|
|
|
if (isTrackCompatible && !hasOverlap) {
|
|
return {
|
|
trackIndex,
|
|
isNewTrack: false,
|
|
insertPosition: null,
|
|
xPosition,
|
|
};
|
|
}
|
|
|
|
const { index, position } = findInsertIndex({
|
|
elementType,
|
|
tracks,
|
|
preferredIndex: trackIndex,
|
|
insertAbove: isInUpperHalf,
|
|
});
|
|
|
|
return {
|
|
trackIndex: index,
|
|
isNewTrack: true,
|
|
insertPosition: position,
|
|
xPosition,
|
|
};
|
|
}
|
|
|
|
export function getDropLineY({
|
|
dropTarget,
|
|
tracks,
|
|
}: {
|
|
dropTarget: DropTarget;
|
|
tracks: TimelineTrack[];
|
|
}): number {
|
|
let y = 0;
|
|
|
|
for (let i = 0; i < dropTarget.trackIndex && i < tracks.length; i++) {
|
|
y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP;
|
|
}
|
|
|
|
if (!dropTarget.isNewTrack && dropTarget.trackIndex < tracks.length) {
|
|
return y;
|
|
}
|
|
|
|
if (
|
|
dropTarget.insertPosition === "below" &&
|
|
dropTarget.trackIndex <= tracks.length
|
|
) {
|
|
if (dropTarget.trackIndex > 0 && dropTarget.trackIndex <= tracks.length) {
|
|
y += TRACK_HEIGHTS[tracks[dropTarget.trackIndex - 1]?.type ?? "media"];
|
|
}
|
|
}
|
|
|
|
return y;
|
|
}
|