diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-01 19:27:49 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-01 19:27:49 +0200 |
| commit | bd9ec023eefc4b5d26fa8b66c4338e97104f0f96 (patch) | |
| tree | 08350eaa107b27f026eebdb65df54b1ca25521fd /apps/web/src/components/editor/panels/timeline/drop-target.ts | |
| parent | f4b9f40ab1ba1cedf208bba708f4331d36bb30cc (diff) | |
| download | opencut-bd9ec023eefc4b5d26fa8b66c4338e97104f0f96.tar.gz | |
refactor: split constants by domain and isolate timeline panel from core
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.ts | 251 |
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; +} |
