From 63d0ea4d28f75f93fb5512dad46b91cd75dd780e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 18 Sep 2026 21:27:39 -0400 Subject: [PATCH] fix(studio): show where a dragged file will land instead of a drop-to-add box (#4142) Dragging a file over Studio covered the screen with a "Drop to add at the playhead" box, but a drop lands at the pointer time on the target row. The box is gone; the timeline now draws a landing preview at the exact start and track the drop will use (a new row below the last lane when that is the target), taken from the same placement function the drop commits. --- packages/studio/src/App.tsx | 9 +-- .../components/StudioGlobalDragOverlay.tsx | 24 ------- .../studio/src/components/StudioOverlays.tsx | 6 +- .../studio/src/hooks/useStudioContextValue.ts | 24 ++----- .../studio/src/player/components/Timeline.tsx | 1 + .../src/player/components/TimelineCanvas.tsx | 30 ++++++++ .../components/timelineDragDrop.test.tsx | 31 ++++++++ .../src/player/components/timelineDragDrop.ts | 71 ++++++++++--------- scripts/check-no-main-deletions.mjs | 4 ++ 9 files changed, 114 insertions(+), 86 deletions(-) delete mode 100644 packages/studio/src/components/StudioGlobalDragOverlay.tsx diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 153f3250c4..cd7ac40bbc 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -342,7 +342,7 @@ export function StudioApp() { setConsoleErrors, resetErrors: resetConsoleErrors, } = useConsoleErrorCapture(previewIframe); - const dragOverlay = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop); + const fileDrop = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop); const handleToggleRecordingRef = useRef<() => void>(() => {}); const domEditSessionRef = useRef(domEditSession); domEditSessionRef.current = domEditSession; @@ -447,10 +447,8 @@ export function StudioApp() {
setConsoleErrors(null)} domEditSession={domEditSession} activeCompPath={activeCompPath} - dragOverlayActive={dragOverlay.active} toasts={toasts} dismissToast={dismissToast} /> diff --git a/packages/studio/src/components/StudioGlobalDragOverlay.tsx b/packages/studio/src/components/StudioGlobalDragOverlay.tsx deleted file mode 100644 index 00e562af33..0000000000 --- a/packages/studio/src/components/StudioGlobalDragOverlay.tsx +++ /dev/null @@ -1,24 +0,0 @@ -export function StudioGlobalDragOverlay() { - return ( -
-
- - - - - - Drop to add at the playhead -
-
- ); -} diff --git a/packages/studio/src/components/StudioOverlays.tsx b/packages/studio/src/components/StudioOverlays.tsx index 253bcb2107..a53e78678d 100644 --- a/packages/studio/src/components/StudioOverlays.tsx +++ b/packages/studio/src/components/StudioOverlays.tsx @@ -1,7 +1,6 @@ import type { ComponentProps } from "react"; import { LintModal } from "./LintModal"; import { AskAgentModal } from "./AskAgentModal"; -import { StudioGlobalDragOverlay } from "./StudioGlobalDragOverlay"; import { StudioToast } from "./StudioToast"; import { StudioFeedbackCard } from "./feedback/StudioFeedbackCard"; import { buildAgentContextPreview } from "./editor/domEditingAgentPrompt"; @@ -19,14 +18,13 @@ export interface StudioOverlaysProps { clearConsoleErrors: () => void; domEditSession: ReturnType; activeCompPath: string | null; - dragOverlayActive: boolean; toasts: ReturnType["toasts"]; dismissToast: (id: number) => void; } /** * Floating overlays for the studio shell: lint / console-error modals, the - * ask-agent modal, the global drag overlay, and the toast. Extracted from + * ask-agent modal, and the toast. Extracted from * `App.tsx` to keep the shell within the studio's 600-line decomposition budget. */ // fallow-ignore-next-line complexity @@ -39,7 +37,6 @@ export function StudioOverlays({ clearConsoleErrors, domEditSession, activeCompPath, - dragOverlayActive, toasts, dismissToast, }: StudioOverlaysProps) { @@ -78,7 +75,6 @@ export function StudioOverlays({ }} /> )} - {dragOverlayActive && } {/* One bottom-right stack so the feedback card and toasts queue instead of covering each other. Empty when nothing is showing. */}
diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index 1f1250c4fc..b7f25982cb 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState, type DragEvent } from "react"; +import { useCallback, useMemo, type DragEvent } from "react"; import type { DomEditSelection } from "../components/editor/domEditing"; import type { StudioContextValue } from "../contexts/StudioContext"; import type { RightInspectorPanes } from "../utils/studioHelpers"; @@ -116,35 +116,21 @@ export function useInspectorState( ]); } -// fallow-ignore-next-line complexity -function useDragOverlay(onImportFiles: (files: FileList) => void) { - const [active, setActive] = useState(false); - const counterRef = useRef(0); +/** Lets an OS file drop reach `onDrop` anywhere in the shell; the timeline shows its own landing preview. */ +function useFileDropTarget(onImportFiles: (files: FileList) => void) { const onDragOver = useCallback((e: DragEvent) => { if (!e.dataTransfer.types.includes("Files")) return; e.preventDefault(); }, []); - const onDragEnter = useCallback((e: DragEvent) => { - if (!e.dataTransfer.types.includes("Files")) return; - e.preventDefault(); - counterRef.current++; - setActive(true); - }, []); - const onDragLeave = useCallback(() => { - counterRef.current--; - if (counterRef.current === 0) setActive(false); - }, []); const onDrop = useCallback( (e: DragEvent) => { - counterRef.current = 0; - setActive(false); if (e.defaultPrevented) return; e.preventDefault(); if (e.dataTransfer.files.length) onImportFiles(e.dataTransfer.files); }, [onImportFiles], ); - return { active, onDragOver, onDragEnter, onDragLeave, onDrop }; + return { onDragOver, onDrop }; } /** Global OS file drop: imports and places at the playhead position. */ @@ -156,5 +142,5 @@ export function useGlobalFileDrop(handleTimelineFileDrop: TimelineFileDropHandle }, [handleTimelineFileDrop], ); - return useDragOverlay(onDrop); + return useFileDropTarget(onDrop); } diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index f2b803cc44..1a03ab72bd 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -498,6 +498,7 @@ export const Timeline = memo(function Timeline({ rangeSelection={rangeSelection} marqueeRect={marqueeRect} laneGapStrips={laneGapStrips} + dropPreview={assetDrop.dropPreview} theme={theme} displayTrackOrder={displayLayout.displayTrackOrder} rowHeights={displayLayout.displayRowHeights} diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index fd835f19d1..34caaeb0c8 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -39,12 +39,22 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps { playheadRef: React.RefObject; /** Gap strips: loud on gap-menu-row hover, quiet on the selected clip's lane. */ laneGapStrips: TimelineLaneGapStrips[]; + /** Landing spot of an outside drag in progress, or null. */ + dropPreview: { start: number; track: number } | null; } +// A dropped clip's length is unknown until it lands; the preview shows a default. +const DROP_PREVIEW_SECONDS = 3; + export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) { const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props; const draggedRowIndex = draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1; + const dropTrackIndex = props.dropPreview + ? displayTrackOrder.indexOf(props.dropPreview.track) + : -1; + // A track not in the order is a new track, drawn one row past the last lane. + const dropRowIndex = dropTrackIndex < 0 ? displayTrackOrder.length : dropTrackIndex; const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights); // A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes // occupy the rest of the row — see TimelineLanes' clipHeight), so the drag @@ -171,6 +181,26 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas /> )} + {/* Drop preview: where an asset or file dragged in from outside will land + (a row past the last lane means a new track). */} + {props.dropPreview && ( +