summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/controllers/drag-drop-controller.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/timeline/controllers/drag-drop-controller.ts')
-rw-r--r--apps/web/src/timeline/controllers/drag-drop-controller.ts577
1 files changed, 577 insertions, 0 deletions
diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts
new file mode 100644
index 00000000..cd03300e
--- /dev/null
+++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts
@@ -0,0 +1,577 @@
+import type { DragEvent } from "react";
+import { processMediaAssets } from "@/media/processing";
+import { showMediaUploadToast } from "@/media/upload-toast";
+import {
+ DEFAULT_NEW_ELEMENT_DURATION,
+ toElementDurationTicks,
+} from "@/timeline/creation";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { roundToFrame, type FrameRate } from "opencut-wasm";
+import {
+ buildTextElement,
+ buildGraphicElement,
+ buildStickerElement,
+ buildElementFromMedia,
+ buildEffectElement,
+} from "@/timeline/element-utils";
+import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline";
+import { BatchCommand } from "@/commands";
+import type { Command } from "@/commands/base-command";
+import { computeDropTarget } from "@/timeline/components/drop-target";
+import type { TimelineDragSource } from "@/timeline/drag-source";
+import type {
+ TrackType,
+ DropTarget,
+ ElementType,
+ SceneTracks,
+ TimelineTrack,
+ CreateTimelineElement,
+} from "@/timeline";
+import type { TimelineDragData } from "@/timeline/drag";
+import type { MediaAsset } from "@/media/types";
+import type { ProcessedMediaAsset } from "@/media/processing";
+
+// --- Config ---
+
+export interface DragDropConfig {
+ zoomLevel: number;
+ getContainerEl: () => HTMLDivElement | null;
+ getHeaderEl: () => HTMLElement | null;
+ getTracksScrollEl: () => HTMLDivElement | null;
+ getActiveProjectFps: () => FrameRate | null;
+ getActiveProjectId: () => string | null;
+ getSceneTracks: () => SceneTracks;
+ getCurrentPlayheadTime: () => number;
+ getMediaAssets: () => MediaAsset[];
+ dragSource: TimelineDragSource;
+ addMediaAsset: (args: {
+ projectId: string;
+ asset: ProcessedMediaAsset;
+ }) => Promise<MediaAsset | null>;
+ executeCommand: (command: Command) => void;
+ insertElement: (args: {
+ placement: { mode: "explicit"; trackId: string };
+ element: CreateTimelineElement;
+ }) => void;
+ addClipEffect: (args: {
+ trackId: string;
+ elementId: string;
+ effectType: string;
+ }) => void;
+}
+
+export interface DragDropConfigRef {
+ readonly current: DragDropConfig;
+}
+
+// --- State ---
+
+interface DragOverState {
+ kind: "over";
+ dropTarget: DropTarget | null;
+ elementType: ElementType | null;
+}
+
+type DragDropState = { kind: "idle" } | DragOverState;
+
+interface TimelineCoords {
+ mouseX: number;
+ mouseY: number;
+}
+
+// --- Pure helpers ---
+
+function elementTypeFromDrag({
+ dragData,
+}: {
+ dragData: TimelineDragData;
+}): ElementType {
+ switch (dragData.type) {
+ case "text":
+ return "text";
+ case "graphic":
+ return "graphic";
+ case "sticker":
+ return "sticker";
+ case "effect":
+ return "effect";
+ case "media":
+ return dragData.mediaType;
+ }
+}
+
+function getTargetElementTypesForDrag({
+ dragData,
+}: {
+ dragData: TimelineDragData;
+}): string[] | undefined {
+ if (dragData.type === "effect") return dragData.targetElementTypes;
+ if (dragData.type === "media") return dragData.targetElementTypes;
+ return undefined;
+}
+
+function getDurationForDrag({
+ dragData,
+ mediaAssets,
+}: {
+ dragData: TimelineDragData;
+ mediaAssets: MediaAsset[];
+}): number {
+ if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION;
+ const media = mediaAssets.find((asset) => asset.id === dragData.id);
+ return toElementDurationTicks({ seconds: media?.duration });
+}
+
+function orderedTracks({
+ sceneTracks,
+}: {
+ sceneTracks: SceneTracks;
+}): TimelineTrack[] {
+ return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio];
+}
+
+// --- Controller ---
+
+export class DragDropController {
+ private state: DragDropState = { kind: "idle" };
+ private enterCount = 0;
+ private readonly subscribers = new Set<() => void>();
+ private readonly configRef: DragDropConfigRef;
+
+ constructor(deps: { configRef: DragDropConfigRef }) {
+ this.configRef = deps.configRef;
+ this.onDragEnter = this.onDragEnter.bind(this);
+ this.onDragOver = this.onDragOver.bind(this);
+ this.onDragLeave = this.onDragLeave.bind(this);
+ this.onDrop = this.onDrop.bind(this);
+ }
+
+ private get config(): DragDropConfig {
+ return this.configRef.current;
+ }
+
+ get isDragOver(): boolean {
+ return this.state.kind !== "idle";
+ }
+
+ get dropTarget(): DropTarget | null {
+ return this.state.kind === "over" ? this.state.dropTarget : null;
+ }
+
+ get dragElementType(): ElementType | null {
+ return this.state.kind === "over" ? this.state.elementType : null;
+ }
+
+ subscribe(fn: () => void): () => void {
+ this.subscribers.add(fn);
+ return () => this.subscribers.delete(fn);
+ }
+
+ destroy(): void {
+ this.subscribers.clear();
+ }
+
+ // --- Drag event handlers (bound, stable, passed as React props) ---
+
+ onDragEnter(event: DragEvent): void {
+ event.preventDefault();
+ const hasAsset = this.config.dragSource.isActive();
+ const hasFiles = event.dataTransfer.types.includes("Files");
+ if (!hasAsset && !hasFiles) return;
+
+ this.enterCount += 1;
+ if (this.state.kind === "idle") {
+ this.setOver({ dropTarget: null, elementType: null });
+ }
+ }
+
+ onDragOver(event: DragEvent): void {
+ event.preventDefault();
+
+ const coords = this.getMouseTimelineCoords({ event });
+ if (!coords) return;
+
+ const dragData = this.config.dragSource.getActive();
+ const hasFiles = event.dataTransfer.types.includes("Files");
+ const isExternal = hasFiles && !dragData;
+
+ if (!dragData) {
+ if (hasFiles && isExternal) {
+ this.setOver({ dropTarget: null, elementType: null });
+ }
+ return;
+ }
+
+ const elementType = elementTypeFromDrag({ dragData });
+ const duration = getDurationForDrag({
+ dragData,
+ mediaAssets: this.config.getMediaAssets(),
+ });
+ const targetElementTypes = getTargetElementTypesForDrag({ dragData });
+
+ const sceneTracks = this.config.getSceneTracks();
+ const target = computeDropTarget({
+ elementType,
+ mouseX: coords.mouseX,
+ mouseY: coords.mouseY,
+ tracks: sceneTracks,
+ playheadTime: this.config.getCurrentPlayheadTime(),
+ isExternalDrop: isExternal,
+ elementDuration: duration,
+ pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
+ zoomLevel: this.config.zoomLevel,
+ targetElementTypes,
+ });
+
+ const fps = this.config.getActiveProjectFps();
+ target.xPosition = fps
+ ? (roundToFrame({ time: target.xPosition, rate: fps }) ??
+ target.xPosition)
+ : target.xPosition;
+
+ this.setOver({ dropTarget: target, elementType });
+ event.dataTransfer.dropEffect = "copy";
+ }
+
+ onDragLeave(event: DragEvent): void {
+ event.preventDefault();
+ if (this.enterCount === 0) return;
+ this.enterCount -= 1;
+ if (this.enterCount === 0) {
+ this.setIdle();
+ }
+ }
+
+ onDrop(event: DragEvent): void {
+ event.preventDefault();
+ this.enterCount = 0;
+
+ const dragData = this.config.dragSource.getActive();
+ const hasFiles = event.dataTransfer.files?.length > 0;
+ if (!dragData && !hasFiles) return;
+
+ const currentTarget = this.dropTarget;
+ this.setIdle();
+
+ try {
+ if (dragData) {
+ if (!currentTarget) return;
+ this.executeAssetDrop({ target: currentTarget, dragData });
+ return;
+ }
+
+ const coords = this.getMouseTimelineCoords({ event });
+ if (!coords) return;
+ this.executeFileDrop({
+ files: Array.from(event.dataTransfer.files),
+ mouseX: coords.mouseX,
+ mouseY: coords.mouseY,
+ }).catch((error) => {
+ console.error("Failed to process file drop:", error);
+ });
+ } catch (error) {
+ console.error("Failed to process drop:", error);
+ }
+ }
+
+ // --- Private ---
+
+ private setOver(state: {
+ dropTarget: DropTarget | null;
+ elementType: ElementType | null;
+ }): void {
+ this.state = { kind: "over", ...state };
+ this.notify();
+ }
+
+ private setIdle(): void {
+ this.state = { kind: "idle" };
+ this.notify();
+ }
+
+ private notify(): void {
+ for (const fn of this.subscribers) fn();
+ }
+
+ private getMouseTimelineCoords({
+ event,
+ }: {
+ event: DragEvent;
+ }): TimelineCoords | null {
+ const scrollContainer = this.config.getTracksScrollEl();
+ const referenceRect =
+ scrollContainer?.getBoundingClientRect() ??
+ this.config.getContainerEl()?.getBoundingClientRect();
+ if (!referenceRect) return null;
+
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+ const headerHeight =
+ this.config.getHeaderEl()?.getBoundingClientRect().height ?? 0;
+
+ return {
+ mouseX: event.clientX - referenceRect.left + scrollLeft,
+ mouseY: event.clientY - referenceRect.top + scrollTop - headerHeight,
+ };
+ }
+
+ // Shared insertion logic — new track vs existing track.
+ private insertAtTarget({
+ element,
+ target,
+ trackType,
+ }: {
+ element: CreateTimelineElement;
+ target: DropTarget;
+ trackType: TrackType;
+ }): void {
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
+ this.config.executeCommand(
+ new BatchCommand([
+ addTrackCmd,
+ new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ }),
+ ]),
+ );
+ return;
+ }
+
+ const tracks = orderedTracks({ sceneTracks: this.config.getSceneTracks() });
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ this.config.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ }
+
+ private executeAssetDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: TimelineDragData;
+ }): void {
+ switch (dragData.type) {
+ case "text":
+ this.executeTextDrop({ target, dragData });
+ return;
+ case "graphic":
+ this.executeGraphicDrop({ target, dragData });
+ return;
+ case "sticker":
+ this.executeStickerDrop({ target, dragData });
+ return;
+ case "effect":
+ this.executeEffectDrop({ target, dragData });
+ return;
+ case "media":
+ this.executeMediaDrop({ target, dragData });
+ return;
+ }
+ }
+
+ private executeTextDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract<TimelineDragData, { type: "text" }>;
+ }): void {
+ const element = buildTextElement({
+ raw: { name: dragData.name ?? "", content: dragData.content ?? "" },
+ startTime: target.xPosition,
+ });
+ this.insertAtTarget({ element, target, trackType: "text" });
+ }
+
+ private executeStickerDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract<TimelineDragData, { type: "sticker" }>;
+ }): void {
+ const element = buildStickerElement({
+ stickerId: dragData.stickerId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ });
+ this.insertAtTarget({ element, target, trackType: "graphic" });
+ }
+
+ private executeGraphicDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract<TimelineDragData, { type: "graphic" }>;
+ }): void {
+ const element = buildGraphicElement({
+ definitionId: dragData.definitionId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ params: dragData.params,
+ });
+ this.insertAtTarget({ element, target, trackType: "graphic" });
+ }
+
+ private executeMediaDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract<TimelineDragData, { type: "media" }>;
+ }): void {
+ if (target.targetElement) {
+ // Replace media source — not yet implemented
+ return;
+ }
+
+ const mediaAsset = this.config
+ .getMediaAssets()
+ .find((asset) => asset.id === dragData.id);
+ if (!mediaAsset) return;
+
+ const trackType: TrackType =
+ dragData.mediaType === "audio" ? "audio" : "video";
+ const element = buildElementFromMedia({
+ mediaId: mediaAsset.id,
+ mediaType: mediaAsset.type,
+ name: mediaAsset.name,
+ duration: toElementDurationTicks({ seconds: mediaAsset.duration }),
+ startTime: target.xPosition,
+ });
+ this.insertAtTarget({ element, target, trackType });
+ }
+
+ private executeEffectDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract<TimelineDragData, { type: "effect" }>;
+ }): void {
+ if (target.targetElement) {
+ this.config.addClipEffect({
+ trackId: target.targetElement.trackId,
+ elementId: target.targetElement.elementId,
+ effectType: dragData.effectType,
+ });
+ return;
+ }
+
+ const element = buildEffectElement({
+ effectType: dragData.effectType,
+ startTime: target.xPosition,
+ });
+
+ const existingEffectTrack = orderedTracks({
+ sceneTracks: this.config.getSceneTracks(),
+ }).find((track) => track.type === "effect");
+
+ if (existingEffectTrack) {
+ this.config.insertElement({
+ placement: { mode: "explicit", trackId: existingEffectTrack.id },
+ element,
+ });
+ return;
+ }
+
+ this.insertAtTarget({ element, target, trackType: "effect" });
+ }
+
+ private async executeFileDrop({
+ files,
+ mouseX,
+ mouseY,
+ }: {
+ files: File[];
+ mouseX: number;
+ mouseY: number;
+ }): Promise<void> {
+ const projectId = this.config.getActiveProjectId();
+ if (!projectId) return;
+
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({ files });
+
+ // Sequential on purpose: each iteration reads getSceneTracks()
+ // to decide placement (reuse empty main vs new track) and that
+ // decision depends on the effects of prior inserts.
+ for (const asset of processedAssets) {
+ const createdAsset = await this.config.addMediaAsset({
+ projectId,
+ asset,
+ });
+ if (!createdAsset) continue;
+
+ const duration = toElementDurationTicks({
+ seconds: createdAsset.duration,
+ });
+
+ const sceneTracks = this.config.getSceneTracks();
+ const currentTime = this.config.getCurrentPlayheadTime();
+
+ const reuseMainTrackId =
+ createdAsset.type !== "audio" &&
+ sceneTracks.overlay.length === 0 &&
+ sceneTracks.audio.length === 0 &&
+ sceneTracks.main.elements.length === 0
+ ? sceneTracks.main.id
+ : null;
+
+ if (reuseMainTrackId) {
+ this.config.insertElement({
+ placement: { mode: "explicit", trackId: reuseMainTrackId },
+ element: buildElementFromMedia({
+ mediaId: createdAsset.id,
+ mediaType: createdAsset.type,
+ name: createdAsset.name,
+ duration,
+ startTime: currentTime,
+ }),
+ });
+ continue;
+ }
+
+ const dropTarget = computeDropTarget({
+ elementType: createdAsset.type,
+ mouseX,
+ mouseY,
+ tracks: sceneTracks,
+ playheadTime: currentTime,
+ isExternalDrop: true,
+ elementDuration: duration,
+ pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
+ zoomLevel: this.config.zoomLevel,
+ });
+
+ const trackType: TrackType =
+ createdAsset.type === "audio" ? "audio" : "video";
+ this.insertAtTarget({
+ element: buildElementFromMedia({
+ mediaId: createdAsset.id,
+ mediaType: createdAsset.type,
+ name: createdAsset.name,
+ duration,
+ startTime: dropTarget.xPosition,
+ }),
+ target: dropTarget,
+ trackType,
+ });
+ }
+
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
+ });
+ }
+}