Support dropping non-image files onto markdown editor as attachments

When dragging files like .zip onto the issue description editor, non-image
files are now uploaded as attachments instead of being silently ignored.
Images continue to be handled inline by MDXEditor's image plugin.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-05 06:39:20 -05:00
parent e9c8bd4805
commit 68499eb2f4
3 changed files with 35 additions and 6 deletions

View file

@ -11,6 +11,8 @@ interface InlineEditorProps {
placeholder?: string; placeholder?: string;
multiline?: boolean; multiline?: boolean;
imageUploadHandler?: (file: File) => Promise<string>; imageUploadHandler?: (file: File) => Promise<string>;
/** Called when a non-image file is dropped onto the editor. */
onDropFile?: (file: File) => Promise<void>;
mentions?: MentionOption[]; mentions?: MentionOption[];
nullable?: boolean; nullable?: boolean;
} }
@ -46,6 +48,7 @@ export function InlineEditor({
multiline = false, multiline = false,
nullable = false, nullable = false,
imageUploadHandler, imageUploadHandler,
onDropFile,
mentions, mentions,
}: InlineEditorProps) { }: InlineEditorProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@ -228,6 +231,7 @@ export function InlineEditor({
className="bg-transparent" className="bg-transparent"
contentClassName={cn("paperclip-edit-in-place-content", className)} contentClassName={cn("paperclip-edit-in-place-content", className)}
imageUploadHandler={imageUploadHandler} imageUploadHandler={imageUploadHandler}
onDropFile={onDropFile}
mentions={mentions} mentions={mentions}
onSubmit={() => { onSubmit={() => {
finalizeMultilineBlurOrSubmit(); finalizeMultilineBlurOrSubmit();

View file

@ -62,6 +62,8 @@ interface MarkdownEditorProps {
contentClassName?: string; contentClassName?: string;
onBlur?: () => void; onBlur?: () => void;
imageUploadHandler?: (file: File) => Promise<string>; imageUploadHandler?: (file: File) => Promise<string>;
/** Called when a non-image file is dropped onto the editor (e.g. .zip). */
onDropFile?: (file: File) => Promise<void>;
bordered?: boolean; bordered?: boolean;
/** List of mentionable entities. Enables @-mention autocomplete. */ /** List of mentionable entities. Enables @-mention autocomplete. */
mentions?: MentionOption[]; mentions?: MentionOption[];
@ -314,6 +316,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
contentClassName, contentClassName,
onBlur, onBlur,
imageUploadHandler, imageUploadHandler,
onDropFile,
bordered = true, bordered = true,
mentions, mentions,
onSubmit, onSubmit,
@ -668,6 +671,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
} }
const canDropImage = Boolean(imageUploadHandler); const canDropImage = Boolean(imageUploadHandler);
const canDropFile = Boolean(imageUploadHandler || onDropFile);
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => { const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
const clipboard = event.clipboardData; const clipboard = event.clipboardData;
if (!clipboard || !ref.current) return; if (!clipboard || !ref.current) return;
@ -747,23 +751,41 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
} }
}} }}
onDragEnter={(evt) => { onDragEnter={(evt) => {
if (!canDropImage || !hasFilePayload(evt)) return; if (!canDropFile || !hasFilePayload(evt)) return;
dragDepthRef.current += 1; dragDepthRef.current += 1;
setIsDragOver(true); setIsDragOver(true);
}} }}
onDragOver={(evt) => { onDragOver={(evt) => {
if (!canDropImage || !hasFilePayload(evt)) return; if (!canDropFile || !hasFilePayload(evt)) return;
evt.preventDefault(); evt.preventDefault();
evt.dataTransfer.dropEffect = "copy"; evt.dataTransfer.dropEffect = "copy";
}} }}
onDragLeave={() => { onDragLeave={() => {
if (!canDropImage) return; if (!canDropFile) return;
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDragOver(false); if (dragDepthRef.current === 0) setIsDragOver(false);
}} }}
onDrop={() => { onDrop={(evt) => {
dragDepthRef.current = 0; dragDepthRef.current = 0;
setIsDragOver(false); setIsDragOver(false);
if (!onDropFile) return;
const files = evt.dataTransfer?.files;
if (!files || files.length === 0) return;
const allFiles = Array.from(files);
const nonImageFiles = allFiles.filter(
(f) => !f.type.startsWith("image/"),
);
if (nonImageFiles.length === 0) return;
// If all dropped files are non-image, prevent default so MDXEditor
// doesn't try to handle them. If mixed, let images flow through to
// the image plugin and only handle the non-image files ourselves.
if (nonImageFiles.length === allFiles.length) {
evt.preventDefault();
evt.stopPropagation();
}
for (const file of nonImageFiles) {
void onDropFile(file);
}
}} }}
onPasteCapture={handlePasteCapture} onPasteCapture={handlePasteCapture}
> >
@ -854,14 +876,14 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
document.body, document.body,
)} )}
{isDragOver && canDropImage && ( {isDragOver && canDropFile && (
<div <div
className={cn( className={cn(
"pointer-events-none absolute inset-1 z-40 flex items-center justify-center rounded-md border border-dashed border-primary/80 bg-primary/10 text-xs font-medium text-primary", "pointer-events-none absolute inset-1 z-40 flex items-center justify-center rounded-md border border-dashed border-primary/80 bg-primary/10 text-xs font-medium text-primary",
!bordered && "inset-0 rounded-sm", !bordered && "inset-0 rounded-sm",
)} )}
> >
Drop image to upload Drop file to upload
</div> </div>
)} )}
{uploadError && ( {uploadError && (

View file

@ -1329,6 +1329,9 @@ export function IssueDetail() {
const attachment = await uploadAttachment.mutateAsync(file); const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath; return attachment.contentPath;
}} }}
onDropFile={async (file) => {
await uploadAttachment.mutateAsync(file);
}}
/> />
</div> </div>