Files
SRCmail/hooks/use-attachment-drag.ts
T

125 lines
3.9 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>. The filename must be
// raw - URL-encoding it lands literally on disk (`%20` instead of a
// space). Callers are expected to sanitise reserved chars (`:` etc.)
// beforehand.
e.dataTransfer.setData("DownloadURL", `${type}:${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,
};
}