import type { ComputeDropTargetParams, DropTarget, TimelineElement, TimelineTrack, } from "@/timeline"; import { resolveTrackPlacement } from "@/timeline/placement"; import { TIMELINE_TRACK_GAP_PX } from "./layout"; import { getTrackHeight } from "./track-layout"; import { mediaTime, type MediaTime, roundMediaTime, TICKS_PER_SECOND, } from "@/wasm"; 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 = mediaTime({ ticks: Math.round( (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, ), }); 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: MediaTime; }): DropTarget { return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition, targetElement: EMPTY_TARGET_ELEMENT, targetCut: null, }; } export function computeTransitionCutDropTarget({ mouseX, mouseY, tracks, pixelsPerSecond, zoomLevel, }: { mouseX: number; mouseY: number; tracks: ComputeDropTargetParams["tracks"]; pixelsPerSecond: number; zoomLevel: number; }): DropTarget | null { const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const trackAtMouse = getTrackAtY({ mouseY, tracks: orderedTracks }); if (!trackAtMouse) return null; const track = orderedTracks[trackAtMouse.trackIndex]; if (!track || track.type !== "video") return null; const elements = track.elements .filter((element) => element.type === "video" || element.type === "image") .slice() .sort((left, right) => left.startTime - right.startTime); let nearest: { outgoingElementId: string; incomingElementId: string; time: MediaTime; distance: number; } | null = null; for (let index = 1; index < elements.length; index += 1) { const outgoing = elements[index - 1]; const incoming = elements[index]; const cutTime = incoming.startTime; if (outgoing.startTime + outgoing.duration !== cutTime) continue; const cutX = (cutTime / TICKS_PER_SECOND) * pixelsPerSecond * zoomLevel; const distance = Math.abs(mouseX - cutX); if (distance > 12 || (nearest && nearest.distance <= distance)) continue; nearest = { outgoingElementId: outgoing.id, incomingElementId: incoming.id, time: cutTime, distance, }; } if (!nearest) return null; return { trackIndex: trackAtMouse.trackIndex, isNewTrack: false, insertPosition: null, xPosition: nearest.time, targetElement: null, targetCut: { trackId: track.id, outgoingElementId: nearest.outgoingElementId, incomingElementId: nearest.incomingElementId, time: nearest.time, }, }; } export function computeDropTarget({ elementType, mouseX, mouseY, tracks, playheadTime, isExternalDrop, elementDuration, pixelsPerSecond, zoomLevel, verticalDragDirection, startTimeOverride, excludeElementId, targetElementTypes, }: ComputeDropTargetParams): DropTarget { const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const mainTrackIndex = tracks.overlay.length; const xPosition = startTimeOverride !== undefined ? startTimeOverride : isExternalDrop ? playheadTime : mediaTime({ ticks: Math.round( Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, ), }); if (orderedTracks.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, targetCut: null, }; } const trackAtMouse = getTrackAtY({ mouseY, tracks: orderedTracks, verticalDragDirection, }); if (!trackAtMouse) { const isAboveAllTracks = mouseY < 0; const placementResult = resolveTrackPlacement({ tracks, elementType, timeSpans: [ { startTime: xPosition, duration: elementDuration, excludeElementId }, ], strategy: { type: "preferIndex", trackIndex: isAboveAllTracks ? 0 : orderedTracks.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, targetCut: null, }; } const { trackIndex, relativeY } = trackAtMouse; const track = orderedTracks[trackIndex]; if (targetElementTypes && targetElementTypes.length > 0) { const targetElement = findElementAtPosition({ mouseX, tracks: orderedTracks, trackIndex, targetElementTypes, pixelsPerSecond, zoomLevel, }); if (targetElement) { return { trackIndex, isNewTrack: false, insertPosition: null, xPosition, targetElement, targetCut: null, }; } } 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 !== undefined ? roundMediaTime({ time: placementResult.adjustedStartTime }) : xPosition; return { trackIndex: placementResult.trackIndex, isNewTrack: false, insertPosition: null, xPosition: adjustedXPosition, targetElement: EMPTY_TARGET_ELEMENT, targetCut: null, }; } return { trackIndex: placementResult.insertIndex, isNewTrack: true, insertPosition: placementResult.insertPosition, xPosition, targetElement: EMPTY_TARGET_ELEMENT, targetCut: null, }; } 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; }