summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline/drop-utils.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/timeline/drop-utils.ts')
-rw-r--r--apps/web/src/lib/timeline/drop-utils.ts234
1 files changed, 234 insertions, 0 deletions
diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts
new file mode 100644
index 00000000..8c22194a
--- /dev/null
+++ b/apps/web/src/lib/timeline/drop-utils.ts
@@ -0,0 +1,234 @@
+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";
+
+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) {
+ return {
+ trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition,
+ };
+ }
+
+ 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;
+}