summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline/drop-target.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline/drop-target.ts')
-rw-r--r--apps/web/src/components/editor/panels/timeline/drop-target.ts251
1 files changed, 251 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/drop-target.ts b/apps/web/src/components/editor/panels/timeline/drop-target.ts
new file mode 100644
index 00000000..dbd4efaf
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/drop-target.ts
@@ -0,0 +1,251 @@
+import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
+import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
+import { resolveTrackPlacement } from "@/lib/timeline/placement";
+import { TIMELINE_TRACK_GAP_PX } from "./layout";
+import { getTrackHeight } from "./track-layout";
+
+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 = getTrackHeight({ type: 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 + TIMELINE_TRACK_GAP_PX;
+ if (mouseY >= gapTop && mouseY < gapBottom) {
+ const isDraggingUp = verticalDragDirection === "up";
+ return {
+ trackIndex: isDraggingUp ? i : i + 1,
+ relativeY: isDraggingUp ? trackHeight - 1 : 0,
+ };
+ }
+ }
+
+ cumulativeHeight += trackHeight + TIMELINE_TRACK_GAP_PX;
+ }
+
+ 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 = getTrackHeight({ type: track.type });
+ 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 += getTrackHeight({ type: tracks[i].type }) + TIMELINE_TRACK_GAP_PX;
+ }
+
+ return y;
+}