258 lines
6.2 KiB
TypeScript
258 lines
6.2 KiB
TypeScript
import type {
|
|
TimelineTrack,
|
|
TimelineElement,
|
|
} from "@/lib/timeline";
|
|
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
|
|
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
|
|
import { resolveTrackPlacement } from "@/lib/timeline/placement";
|
|
|
|
function findElementAtPosition({
|
|
mouseX,
|
|
tracks,
|
|
trackIndex,
|
|
targetElementTypes,
|
|
pixelsPerSecond,
|
|
zoomLevel,
|
|
}: {
|
|
mouseX: number;
|
|
tracks: TimelineTrack[];
|
|
trackIndex: number;
|
|
targetElementTypes: string[];
|
|
pixelsPerSecond: number;
|
|
zoomLevel: number;
|
|
}): { elementId: string; trackId: string } | null {
|
|
const time = mouseX / (pixelsPerSecond * zoomLevel);
|
|
const track = tracks[trackIndex];
|
|
if (!track || !("elements" in track)) return null;
|
|
|
|
const hit = track.elements.find(
|
|
(element: TimelineElement) =>
|
|
targetElementTypes.includes(element.type) &&
|
|
element.startTime <= time &&
|
|
time < element.startTime + element.duration,
|
|
);
|
|
if (!hit) return null;
|
|
return { elementId: hit.id, trackId: track.id };
|
|
}
|
|
|
|
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_CONFIG[tracks[i].type].height;
|
|
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;
|
|
}
|
|
|
|
const EMPTY_TARGET_ELEMENT = null;
|
|
|
|
function fallbackNewTrackDropTarget({
|
|
xPosition,
|
|
}: {
|
|
xPosition: number;
|
|
}): DropTarget {
|
|
return {
|
|
trackIndex: 0,
|
|
isNewTrack: true,
|
|
insertPosition: null,
|
|
xPosition,
|
|
targetElement: EMPTY_TARGET_ELEMENT,
|
|
};
|
|
}
|
|
|
|
export function computeDropTarget({
|
|
elementType,
|
|
mouseX,
|
|
mouseY,
|
|
tracks,
|
|
playheadTime,
|
|
isExternalDrop,
|
|
elementDuration,
|
|
pixelsPerSecond,
|
|
zoomLevel,
|
|
verticalDragDirection,
|
|
startTimeOverride,
|
|
excludeElementId,
|
|
targetElementTypes,
|
|
}: ComputeDropTargetParams): DropTarget {
|
|
const xPosition =
|
|
typeof startTimeOverride === "number"
|
|
? startTimeOverride
|
|
: isExternalDrop
|
|
? playheadTime
|
|
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
|
|
|
if (tracks.length === 0) {
|
|
const placementResult = resolveTrackPlacement({
|
|
tracks,
|
|
elementType,
|
|
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
|
strategy: {
|
|
type: "preferIndex",
|
|
trackIndex: 0,
|
|
hoverDirection: "below",
|
|
createNewTrackOnly: true,
|
|
},
|
|
});
|
|
const emptyTimelineResult =
|
|
placementResult?.kind === "newTrack" ? placementResult : null;
|
|
if (!emptyTimelineResult) {
|
|
return fallbackNewTrackDropTarget({ xPosition });
|
|
}
|
|
|
|
return {
|
|
trackIndex: emptyTimelineResult.insertIndex,
|
|
isNewTrack: true,
|
|
insertPosition: emptyTimelineResult.insertPosition,
|
|
xPosition,
|
|
targetElement: EMPTY_TARGET_ELEMENT,
|
|
};
|
|
}
|
|
|
|
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
|
|
|
|
if (!trackAtMouse) {
|
|
const isAboveAllTracks = mouseY < 0;
|
|
|
|
const placementResult = resolveTrackPlacement({
|
|
tracks,
|
|
elementType,
|
|
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
|
strategy: {
|
|
type: "preferIndex",
|
|
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
|
|
hoverDirection: isAboveAllTracks ? "above" : "below",
|
|
createNewTrackOnly: true,
|
|
},
|
|
});
|
|
const outOfBoundsResult =
|
|
placementResult?.kind === "newTrack" ? placementResult : null;
|
|
if (!outOfBoundsResult) {
|
|
return fallbackNewTrackDropTarget({ xPosition });
|
|
}
|
|
|
|
return {
|
|
trackIndex: outOfBoundsResult.insertIndex,
|
|
isNewTrack: true,
|
|
insertPosition: outOfBoundsResult.insertPosition,
|
|
xPosition,
|
|
targetElement: EMPTY_TARGET_ELEMENT,
|
|
};
|
|
}
|
|
|
|
const { trackIndex, relativeY } = trackAtMouse;
|
|
const track = tracks[trackIndex];
|
|
|
|
if (
|
|
targetElementTypes &&
|
|
targetElementTypes.length > 0
|
|
) {
|
|
const targetElement = findElementAtPosition({
|
|
mouseX,
|
|
tracks,
|
|
trackIndex,
|
|
targetElementTypes,
|
|
pixelsPerSecond,
|
|
zoomLevel,
|
|
});
|
|
if (targetElement) {
|
|
return {
|
|
trackIndex,
|
|
isNewTrack: false,
|
|
insertPosition: null,
|
|
xPosition,
|
|
targetElement,
|
|
};
|
|
}
|
|
}
|
|
|
|
const trackHeight = TRACK_CONFIG[track.type].height;
|
|
const placementResult = resolveTrackPlacement({
|
|
tracks,
|
|
elementType,
|
|
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
|
strategy: {
|
|
type: "preferIndex",
|
|
trackIndex,
|
|
hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
|
|
verticalDragDirection,
|
|
},
|
|
});
|
|
if (!placementResult) {
|
|
return fallbackNewTrackDropTarget({ xPosition });
|
|
}
|
|
|
|
if (placementResult.kind === "existingTrack") {
|
|
const adjustedXPosition =
|
|
placementResult.adjustedStartTime ?? xPosition;
|
|
|
|
return {
|
|
trackIndex: placementResult.trackIndex,
|
|
isNewTrack: false,
|
|
insertPosition: null,
|
|
xPosition: adjustedXPosition,
|
|
targetElement: EMPTY_TARGET_ELEMENT,
|
|
};
|
|
}
|
|
|
|
return {
|
|
trackIndex: placementResult.insertIndex,
|
|
isNewTrack: true,
|
|
insertPosition: placementResult.insertPosition,
|
|
xPosition,
|
|
targetElement: EMPTY_TARGET_ELEMENT,
|
|
};
|
|
}
|
|
|
|
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_CONFIG[tracks[i].type].height + TRACK_GAP;
|
|
}
|
|
|
|
return y;
|
|
}
|