Files
hyperframes/packages/studio/src/components/nle/usePreviewBlockDrop.ts
T

134 lines
4.0 KiB
TypeScript

import { useCallback, useRef, useState, type RefObject } from "react";
import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
interface UsePreviewBlockDropOptions {
portrait?: boolean;
/**
* Authored composition size measured from the live preview. Preferred over
* the portrait fallback — hard-coding 1080/1920 places drops at the wrong
* spot for any composition authored at another size (square, 720p, 4K).
*/
compositionSize?: { width: number; height: number } | null;
stageRef: RefObject<HTMLDivElement | null>;
onBlockDrop?: (blockName: string, position: { left: number; top: number }) => void;
}
interface BlockDropPayload {
name: string;
dimensions?: { width: number; height: number };
}
function parseBlockPayload(raw: string): BlockDropPayload | null {
try {
const parsed = JSON.parse(raw) as {
name?: string;
dimensions?: { width: number; height: number };
};
return parsed.name ? (parsed as BlockDropPayload) : null;
} catch {
return null;
}
}
function resolveCompositionPosition(
clientX: number,
clientY: number,
stageRect: DOMRect,
compositionSize: { width: number; height: number } | null | undefined,
portrait: boolean | undefined,
): { left: number; top: number } | null {
if (stageRect.width === 0 || stageRect.height === 0) return null;
const normalizedX = (clientX - stageRect.left) / stageRect.width;
const normalizedY = (clientY - stageRect.top) / stageRect.height;
const compWidth = compositionSize?.width ?? (portrait ? 1080 : 1920);
const compHeight = compositionSize?.height ?? (portrait ? 1920 : 1080);
return {
left: Math.max(0, Math.min(normalizedX * compWidth, compWidth)),
top: Math.max(0, Math.min(normalizedY * compHeight, compHeight)),
};
}
function centerBlockAtPosition(
pos: { left: number; top: number },
block: BlockDropPayload,
): { left: number; top: number } {
const blockW = block.dimensions?.width ?? 0;
const blockH = block.dimensions?.height ?? 0;
return {
left: Math.max(0, pos.left - blockW / 2),
top: Math.max(0, pos.top - blockH / 2),
};
}
export function usePreviewBlockDrop({
portrait,
compositionSize,
stageRef,
onBlockDrop,
}: UsePreviewBlockDropOptions) {
const [isDragOver, setIsDragOver] = useState(false);
// dragenter/dragleave fire for every internal element boundary; a depth
// counter keeps the drop indicator steady instead of flickering.
const dragDepthRef = useRef(0);
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
if (!onBlockDrop) return;
if (!e.dataTransfer.types.includes(TIMELINE_BLOCK_MIME)) return;
dragDepthRef.current += 1;
setIsDragOver(true);
},
[onBlockDrop],
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
if (!onBlockDrop) return;
if (!e.dataTransfer.types.includes(TIMELINE_BLOCK_MIME)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
// dragenter/dragleave own the isDragOver flag (depth-counted).
},
[onBlockDrop],
);
const handleDragLeave = useCallback(() => {
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDragOver(false);
}, []);
// fallow-ignore-next-line complexity
const handleDrop = useCallback(
(e: React.DragEvent) => {
dragDepthRef.current = 0;
setIsDragOver(false);
if (!onBlockDrop) return;
const payload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
if (!payload) return;
e.preventDefault();
const block = parseBlockPayload(payload);
const stage = stageRef.current;
if (!block || !stage) return;
const pos = resolveCompositionPosition(
e.clientX,
e.clientY,
stage.getBoundingClientRect(),
compositionSize,
portrait,
);
if (!pos) return;
onBlockDrop(block.name, centerBlockAtPosition(pos, block));
},
[onBlockDrop, stageRef, compositionSize, portrait],
);
return { isDragOver, handleDragEnter, handleDragOver, handleDragLeave, handleDrop };
}