123 lines
3.8 KiB
TypeScript
123 lines
3.8 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, DragEvent } from "react";
|
|
|
|
// Chromium ships the `DownloadURL` DataTransfer entry, which the OS reads on
|
|
// drop to materialize a real file. Firefox and Safari ignore it, so we only
|
|
// enable drag-out where it actually works.
|
|
export function isDragOutSupported(): boolean {
|
|
if (typeof navigator === "undefined") return false;
|
|
const uaData = (navigator as { userAgentData?: { brands?: { brand: string }[] } }).userAgentData;
|
|
if (uaData?.brands?.length) {
|
|
return uaData.brands.some((b) => /Chromium|Google Chrome|Microsoft Edge|Brave|Opera/i.test(b.brand));
|
|
}
|
|
const ua = navigator.userAgent || "";
|
|
if (/Firefox|FxiOS/.test(ua)) return false;
|
|
if (/^((?!chrome|android).)*safari/i.test(ua)) return false;
|
|
return /Chrome|Chromium|Edg\//.test(ua);
|
|
}
|
|
|
|
export interface AttachmentDragSource {
|
|
name: string;
|
|
type: string;
|
|
getBlobUrl: () => Promise<string | null>;
|
|
}
|
|
|
|
export interface UseAttachmentDragResult {
|
|
draggable: boolean;
|
|
onPointerEnter: () => void;
|
|
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
|
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
|
|
}
|
|
|
|
const NOOP_HANDLERS: UseAttachmentDragResult = {
|
|
draggable: false,
|
|
onPointerEnter: () => {},
|
|
onDragStart: () => {},
|
|
onDragEnd: () => {},
|
|
};
|
|
|
|
export function useAttachmentDrag(
|
|
source: AttachmentDragSource,
|
|
enabled: boolean,
|
|
): UseAttachmentDragResult {
|
|
const urlRef = useRef<string | null>(null);
|
|
const ownedRef = useRef<boolean>(false);
|
|
const inFlightRef = useRef<Promise<string | null> | null>(null);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (urlRef.current && ownedRef.current) {
|
|
URL.revokeObjectURL(urlRef.current);
|
|
}
|
|
urlRef.current = null;
|
|
ownedRef.current = false;
|
|
inFlightRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
const prefetch = useCallback(() => {
|
|
if (!enabled) return;
|
|
if (urlRef.current || inFlightRef.current) return;
|
|
inFlightRef.current = source
|
|
.getBlobUrl()
|
|
.then((url) => {
|
|
if (url && !urlRef.current) {
|
|
urlRef.current = url;
|
|
// Mark as owned so we revoke on unmount. Callers that hand back a
|
|
// shared URL (e.g. a cached thumbnail blob URL) can return the same
|
|
// string each time - we still revoke once on unmount.
|
|
ownedRef.current = true;
|
|
}
|
|
return url;
|
|
})
|
|
.catch(() => null)
|
|
.finally(() => {
|
|
inFlightRef.current = null;
|
|
});
|
|
}, [enabled, source]);
|
|
|
|
const handleDragStart = useCallback(
|
|
(e: DragEvent<HTMLDivElement>) => {
|
|
const url = urlRef.current;
|
|
const name = source.name || "download";
|
|
const type = source.type || "application/octet-stream";
|
|
|
|
if (!url) {
|
|
// Blob isn't materialized yet. Kick off the fetch so the next attempt
|
|
// works, but cancel this drag so the user doesn't get a silent failure
|
|
// where the OS receives no file.
|
|
prefetch();
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium reads this on
|
|
// drop and writes a real file at the destination.
|
|
e.dataTransfer.setData("DownloadURL", `${type}:${encodeURIComponent(name)}:${url}`);
|
|
e.dataTransfer.effectAllowed = "copyMove";
|
|
},
|
|
[source.name, source.type, prefetch],
|
|
);
|
|
|
|
const handleDragEnd = useCallback(() => {
|
|
// Keep the blob URL around briefly - Chromium asynchronously fetches the
|
|
// blob: URL after dragend fires, so revoking immediately races the OS.
|
|
if (urlRef.current && ownedRef.current) {
|
|
const url = urlRef.current;
|
|
urlRef.current = null;
|
|
ownedRef.current = false;
|
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
}
|
|
}, []);
|
|
|
|
if (!enabled) return NOOP_HANDLERS;
|
|
|
|
return {
|
|
draggable: true,
|
|
onPointerEnter: prefetch,
|
|
onDragStart: handleDragStart,
|
|
onDragEnd: handleDragEnd,
|
|
};
|
|
}
|