feat: drag attachments out to local file system #267
This commit is contained in:
@@ -96,6 +96,8 @@ import { usePluginStore } from "@/stores/plugin-store";
|
|||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
|
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
|
||||||
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
||||||
|
import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag";
|
||||||
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
|
|
||||||
interface EmailViewerProps {
|
interface EmailViewerProps {
|
||||||
email: Email | null;
|
email: Email | null;
|
||||||
@@ -791,6 +793,48 @@ function ContactSidebarPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DraggableAttachmentChipProps {
|
||||||
|
attachment: EffectiveAttachment;
|
||||||
|
client: IJMAPClient | null;
|
||||||
|
enabled: boolean;
|
||||||
|
children: (dragProps: {
|
||||||
|
draggable: boolean;
|
||||||
|
onPointerEnter: () => void;
|
||||||
|
onDragStart: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
}) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableAttachmentChip({ attachment, client, enabled, children }: DraggableAttachmentChipProps) {
|
||||||
|
const source = useMemo<AttachmentDragSource>(() => ({
|
||||||
|
name: attachment.name || 'download',
|
||||||
|
type: attachment.type || 'application/octet-stream',
|
||||||
|
getBlobUrl: async () => {
|
||||||
|
if (attachment.blobId && client) {
|
||||||
|
try {
|
||||||
|
return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attachment.tnefData) {
|
||||||
|
const bytes = attachment.tnefData;
|
||||||
|
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||||
|
return URL.createObjectURL(new Blob([buffer], { type: attachment.type || 'application/octet-stream' }));
|
||||||
|
}
|
||||||
|
if (attachment.decryptedAttachment) {
|
||||||
|
const bytes = getAttachmentContentBytes(attachment.decryptedAttachment);
|
||||||
|
if (!bytes || bytes.byteLength === 0) return null;
|
||||||
|
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||||
|
return URL.createObjectURL(new Blob([buffer], { type: attachment.type || 'application/octet-stream' }));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}), [attachment, client]);
|
||||||
|
const drag = useAttachmentDrag(source, enabled);
|
||||||
|
return <>{children(drag)}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
|
function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -854,6 +898,7 @@ export function EmailViewer({
|
|||||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||||
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
|
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
|
||||||
|
const dragOutActive = useMemo(() => isDragOutSupported(), []);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
@@ -4404,8 +4449,9 @@ export function EmailViewer({
|
|||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
const thumbUrl = imageThumbUrls[attachment.id];
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
return (
|
return (
|
||||||
|
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||||
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
|
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
|
||||||
thumbUrl
|
thumbUrl
|
||||||
@@ -4414,6 +4460,10 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||||
|
draggable={dragProps.draggable}
|
||||||
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
|
onDragStart={dragProps.onDragStart}
|
||||||
|
onDragEnd={dragProps.onDragEnd}
|
||||||
>
|
>
|
||||||
{thumbUrl && (
|
{thumbUrl && (
|
||||||
<div className="w-full h-16 bg-background/40 flex items-center justify-center overflow-hidden">
|
<div className="w-full h-16 bg-background/40 flex items-center justify-center overflow-hidden">
|
||||||
@@ -4457,6 +4507,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DraggableAttachmentChip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{effectiveAttachments.length > 2 && (
|
{effectiveAttachments.length > 2 && (
|
||||||
@@ -4477,11 +4529,16 @@ export function EmailViewer({
|
|||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
return (
|
||||||
|
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||||
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }}
|
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }}
|
||||||
|
draggable={dragProps.draggable}
|
||||||
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
|
onDragStart={dragProps.onDragStart}
|
||||||
|
onDragEnd={dragProps.onDragEnd}
|
||||||
>
|
>
|
||||||
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="text-xs text-foreground truncate max-w-[180px]">
|
<span className="text-xs text-foreground truncate max-w-[180px]">
|
||||||
@@ -4509,6 +4566,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DraggableAttachmentChip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -4762,8 +4821,9 @@ export function EmailViewer({
|
|||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
const thumbUrl = imageThumbUrls[attachment.id];
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
return (
|
return (
|
||||||
|
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||||
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer flex-shrink-0 overflow-hidden",
|
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer flex-shrink-0 overflow-hidden",
|
||||||
thumbUrl
|
thumbUrl
|
||||||
@@ -4772,6 +4832,10 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||||
|
draggable={dragProps.draggable}
|
||||||
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
|
onDragStart={dragProps.onDragStart}
|
||||||
|
onDragEnd={dragProps.onDragEnd}
|
||||||
>
|
>
|
||||||
{thumbUrl && (
|
{thumbUrl && (
|
||||||
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
|
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
|
||||||
@@ -4820,6 +4884,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DraggableAttachmentChip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
|
{visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
|
||||||
@@ -4840,11 +4906,16 @@ export function EmailViewer({
|
|||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
return (
|
||||||
|
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||||
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }}
|
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }}
|
||||||
|
draggable={dragProps.draggable}
|
||||||
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
|
onDragStart={dragProps.onDragStart}
|
||||||
|
onDragEnd={dragProps.onDragEnd}
|
||||||
>
|
>
|
||||||
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="text-sm text-foreground truncate max-w-[220px]">
|
<span className="text-sm text-foreground truncate max-w-[220px]">
|
||||||
@@ -4872,6 +4943,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DraggableAttachmentChip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -4891,8 +4964,9 @@ export function EmailViewer({
|
|||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
const thumbUrl = imageThumbUrls[attachment.id];
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
return (
|
return (
|
||||||
|
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||||
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
|
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
|
||||||
thumbUrl
|
thumbUrl
|
||||||
@@ -4901,6 +4975,10 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||||
|
draggable={dragProps.draggable}
|
||||||
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
|
onDragStart={dragProps.onDragStart}
|
||||||
|
onDragEnd={dragProps.onDragEnd}
|
||||||
>
|
>
|
||||||
{thumbUrl && (
|
{thumbUrl && (
|
||||||
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
|
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
|
||||||
@@ -4944,6 +5022,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DraggableAttachmentChip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{effectiveAttachments.length > 2 && (
|
{effectiveAttachments.length > 2 && (
|
||||||
@@ -4963,11 +5043,16 @@ export function EmailViewer({
|
|||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
return (
|
||||||
|
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||||
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }}
|
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }}
|
||||||
|
draggable={dragProps.draggable}
|
||||||
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
|
onDragStart={dragProps.onDragStart}
|
||||||
|
onDragEnd={dragProps.onDragEnd}
|
||||||
>
|
>
|
||||||
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="text-xs text-foreground truncate max-w-[180px]">
|
<span className="text-xs text-foreground truncate max-w-[180px]">
|
||||||
@@ -4995,6 +5080,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DraggableAttachmentChip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user