mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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.
This commit is contained in:
@@ -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() {
|
||||
<DomEditProvider value={domEditSession}>
|
||||
<div
|
||||
className="flex flex-col h-full w-full bg-neutral-950 relative"
|
||||
onDragOver={dragOverlay.onDragOver}
|
||||
onDragEnter={dragOverlay.onDragEnter}
|
||||
onDragLeave={dragOverlay.onDragLeave}
|
||||
onDrop={dragOverlay.onDrop}
|
||||
onDragOver={fileDrop.onDragOver}
|
||||
onDrop={fileDrop.onDrop}
|
||||
>
|
||||
<StudioHeader
|
||||
captureFrameHref={frameCapture.captureFrameHref}
|
||||
@@ -573,7 +571,6 @@ export function StudioApp() {
|
||||
clearConsoleErrors={() => setConsoleErrors(null)}
|
||||
domEditSession={domEditSession}
|
||||
activeCompPath={activeCompPath}
|
||||
dragOverlayActive={dragOverlay.active}
|
||||
toasts={toasts}
|
||||
dismissToast={dismissToast}
|
||||
/>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export function StudioGlobalDragOverlay() {
|
||||
return (
|
||||
<div className="hf-backdrop-in absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-studio-accent"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-studio-accent">Drop to add at the playhead</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof useDomEditSession>;
|
||||
activeCompPath: string | null;
|
||||
dragOverlayActive: boolean;
|
||||
toasts: ReturnType<typeof useToast>["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 && <StudioGlobalDragOverlay />}
|
||||
{/* One bottom-right stack so the feedback card and toasts queue instead
|
||||
of covering each other. Empty when nothing is showing. */}
|
||||
<div className="absolute bottom-6 right-6 z-[91] flex flex-col items-end gap-2">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -39,12 +39,22 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps {
|
||||
playheadRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** 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 && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="timeline-drop-preview"
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: getTimelineRowTop(dropRowIndex, props.rowHeights) + CLIP_Y,
|
||||
left: props.contentOrigin + props.dropPreview.start * props.pps,
|
||||
width: DROP_PREVIEW_SECONDS * props.pps,
|
||||
height: TRACK_H - CLIP_Y * 2,
|
||||
border: "1px solid rgba(60,230,172,0.55)",
|
||||
background: "rgba(60,230,172,0.12)",
|
||||
borderRadius: 4,
|
||||
zIndex: 30,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Insertion line — a new track will be inserted at this boundary on drop.
|
||||
Shown while the pointer is near a lane boundary (insert mode). */}
|
||||
{draggedClip?.started && draggedClip.insertRow != null && (
|
||||
|
||||
@@ -220,4 +220,35 @@ describe("useTimelineAssetDrop", () => {
|
||||
expect(view.api.isDragOver).toBe(false);
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("previews the exact placement the drop commits, and clears it after", () => {
|
||||
const onAssetDrop = vi.fn();
|
||||
const view = renderHarness(onAssetDrop);
|
||||
const payload = JSON.stringify({ path: "assets/a.png" });
|
||||
|
||||
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, 100)));
|
||||
const preview = view.api.dropPreview;
|
||||
expect(preview).toEqual({ start: 10, track: 0 });
|
||||
|
||||
act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, 100)));
|
||||
expect(onAssetDrop).toHaveBeenCalledExactlyOnceWith("assets/a.png", preview);
|
||||
expect(view.api.dropPreview).toBeNull();
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("moves the preview with the pointer and drops it when the drag leaves", () => {
|
||||
const view = renderHarness(vi.fn());
|
||||
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
|
||||
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, 100)));
|
||||
expect(view.api.dropPreview?.start).toBe(20);
|
||||
|
||||
act(() =>
|
||||
view.api.handleAssetDragLeave({
|
||||
relatedTarget: null,
|
||||
currentTarget: document.body,
|
||||
} as unknown as React.DragEvent),
|
||||
);
|
||||
expect(view.api.dropPreview).toBeNull();
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,7 @@ export function useTimelineAssetDrop({
|
||||
sessionEpoch,
|
||||
}: UseTimelineAssetDropOptions) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dropPreview, setDropPreview] = useState<TimelinePlacement | null>(null);
|
||||
const dragPointerRef = useRef<{ clientX: number; clientY: number; sessionEpoch: number } | null>(
|
||||
null,
|
||||
);
|
||||
@@ -142,38 +143,6 @@ export function useTimelineAssetDrop({
|
||||
[scrollRef, sessionEpoch, stepAutoScroll],
|
||||
);
|
||||
|
||||
const handleAssetDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
const types = Array.from(e.dataTransfer.types);
|
||||
const hasFiles = types.includes("Files");
|
||||
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
|
||||
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
||||
const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
|
||||
if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
activeDropEpochRef.current = sessionEpoch;
|
||||
setIsDragOver(true);
|
||||
syncAutoScroll(e.clientX, e.clientY);
|
||||
},
|
||||
[sessionEpoch, syncAutoScroll],
|
||||
);
|
||||
|
||||
const clearDropPreview = useCallback(() => {
|
||||
activeDropEpochRef.current = null;
|
||||
stopAutoScroll();
|
||||
setIsDragOver(false);
|
||||
}, [stopAutoScroll]);
|
||||
|
||||
const handleAssetDragLeave = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
const related = e.relatedTarget;
|
||||
if (related instanceof Node && e.currentTarget.contains(related)) return;
|
||||
clearDropPreview();
|
||||
},
|
||||
[clearDropPreview],
|
||||
);
|
||||
|
||||
const resolveDropPlacement = useCallback(
|
||||
(clientX: number, clientY: number): TimelinePlacement => {
|
||||
const scroll = scrollRef.current;
|
||||
@@ -196,6 +165,43 @@ export function useTimelineAssetDrop({
|
||||
[scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
|
||||
);
|
||||
|
||||
const handleAssetDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
const types = Array.from(e.dataTransfer.types);
|
||||
const hasFiles = types.includes("Files");
|
||||
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
|
||||
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
||||
const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
|
||||
if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
activeDropEpochRef.current = sessionEpoch;
|
||||
setIsDragOver(true);
|
||||
const next = resolveDropPlacement(e.clientX, e.clientY);
|
||||
setDropPreview((prev) =>
|
||||
prev?.start === next.start && prev.track === next.track ? prev : next,
|
||||
);
|
||||
syncAutoScroll(e.clientX, e.clientY);
|
||||
},
|
||||
[resolveDropPlacement, sessionEpoch, syncAutoScroll],
|
||||
);
|
||||
|
||||
const clearDropPreview = useCallback(() => {
|
||||
activeDropEpochRef.current = null;
|
||||
stopAutoScroll();
|
||||
setIsDragOver(false);
|
||||
setDropPreview(null);
|
||||
}, [stopAutoScroll]);
|
||||
|
||||
const handleAssetDragLeave = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
const related = e.relatedTarget;
|
||||
if (related instanceof Node && e.currentTarget.contains(related)) return;
|
||||
clearDropPreview();
|
||||
},
|
||||
[clearDropPreview],
|
||||
);
|
||||
|
||||
const handleAssetDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -240,6 +246,7 @@ export function useTimelineAssetDrop({
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
dropPreview,
|
||||
handleAssetDragOver,
|
||||
handleAssetDragLeave,
|
||||
handleAssetDrop,
|
||||
|
||||
@@ -37,6 +37,10 @@ const BASE_FLAG = "--base";
|
||||
* its own. Remove an entry once its deletion has landed on the base.
|
||||
*/
|
||||
export const ALLOWED_DELETIONS = new Map([
|
||||
[
|
||||
"packages/studio/src/components/StudioGlobalDragOverlay.tsx",
|
||||
"the full-screen drop-to-add box is replaced by a landing preview on the timeline; no remaining references",
|
||||
],
|
||||
[
|
||||
"docs/snippets/catalog-overview-player.jsx",
|
||||
"#4051 removes the Catalog overview's only consumer of this snippet (replaced by the browse-grid mount); confirmed unreferenced repo-wide before deleting",
|
||||
|
||||
Reference in New Issue
Block a user