summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/components/drop-target.ts
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-08-16 06:16:56 +0200
committercashmere <cashmere@autistici.org>2026-08-16 06:16:56 +0200
commitf1043734438c8518a302f698d9ae6583a75f0c14 (patch)
treed3e636a96e6349c1ad17afa7b28c9ffe5c882e37 /apps/web/src/timeline/components/drop-target.ts
parent37d22b1e390bd6cea04bf8e15069281ee125cf10 (diff)
downloadopencut-f1043734438c8518a302f698d9ae6583a75f0c14.tar.gz
feat: add transitions, track mixing, and voiceover controls
Diffstat (limited to 'apps/web/src/timeline/components/drop-target.ts')
-rw-r--r--apps/web/src/timeline/components/drop-target.ts90
1 files changed, 85 insertions, 5 deletions
diff --git a/apps/web/src/timeline/components/drop-target.ts b/apps/web/src/timeline/components/drop-target.ts
index f68a4b72..e22c8af9 100644
--- a/apps/web/src/timeline/components/drop-target.ts
+++ b/apps/web/src/timeline/components/drop-target.ts
@@ -1,5 +1,9 @@
-import type { TimelineTrack, TimelineElement } from "@/timeline";
-import type { ComputeDropTargetParams, DropTarget } from "@/timeline";
+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";
@@ -97,6 +101,71 @@ function fallbackNewTrackDropTarget({
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,
+ },
};
}
@@ -133,7 +202,9 @@ export function computeDropTarget({
const placementResult = resolveTrackPlacement({
tracks,
elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ timeSpans: [
+ { startTime: xPosition, duration: elementDuration, excludeElementId },
+ ],
strategy: {
type: "preferIndex",
trackIndex: 0,
@@ -153,6 +224,7 @@ export function computeDropTarget({
insertPosition: emptyTimelineResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
+ targetCut: null,
};
}
@@ -168,7 +240,9 @@ export function computeDropTarget({
const placementResult = resolveTrackPlacement({
tracks,
elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ timeSpans: [
+ { startTime: xPosition, duration: elementDuration, excludeElementId },
+ ],
strategy: {
type: "preferIndex",
trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1,
@@ -188,6 +262,7 @@ export function computeDropTarget({
insertPosition: outOfBoundsResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
+ targetCut: null,
};
}
@@ -210,6 +285,7 @@ export function computeDropTarget({
insertPosition: null,
xPosition,
targetElement,
+ targetCut: null,
};
}
}
@@ -218,7 +294,9 @@ export function computeDropTarget({
const placementResult = resolveTrackPlacement({
tracks,
elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ timeSpans: [
+ { startTime: xPosition, duration: elementDuration, excludeElementId },
+ ],
strategy: {
type: "preferIndex",
trackIndex,
@@ -242,6 +320,7 @@ export function computeDropTarget({
insertPosition: null,
xPosition: adjustedXPosition,
targetElement: EMPTY_TARGET_ELEMENT,
+ targetCut: null,
};
}
@@ -251,6 +330,7 @@ export function computeDropTarget({
insertPosition: placementResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
+ targetCut: null,
};
}