From c3b0eae4d84ba3c8bdf9cd38ad13c4dde861090f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 20:04:41 +0000 Subject: [PATCH] feat(editor): add drag-and-drop and paste file upload support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds comprehensive file upload support via drag-and-drop and paste directly into the chat composer editor. Changes to MentionEditor: - Add FileDropHandler TipTap extension to intercept file drops/pastes - Filter for valid media types (image/video/audio) - Add onFileDrop callback prop to communicate with parent - Handle both drag-and-drop and clipboard paste events Changes to useBlossomUpload hook: - Update open() method to accept optional File[] parameter - Add initialFiles state to track pre-selected files - Clear initialFiles when dialog closes - Pass initialFiles to BlossomUploadDialog component Changes to BlossomUploadDialog: - Add initialFiles prop for pre-selecting files - Auto-select first file when initialFiles provided - Generate preview URL for images/video on initialization - Seamless UX: dropped files immediately appear in dialog Changes to ChatViewer: - Wire up onFileDrop callback to open upload dialog - Pass dropped files to upload dialog via open(files) - Fix onClick handler to properly call openUpload() User experience: - Drag image/video/audio file onto chat composer → upload dialog opens - Paste image from clipboard → upload dialog opens - File automatically selected and previewed - Click upload to complete (same as manual file selection) Technical details: - Uses ProseMirror Plugin API for drop/paste interception - File type validation: /^(image|video|audio)\// - Bonus: Also works with clipboard paste (Ctrl+V) - Clean state management with automatic cleanup --- src/components/BlossomUploadDialog.tsx | 24 +++++++- src/components/ChatViewer.tsx | 6 +- src/components/editor/MentionEditor.tsx | 74 +++++++++++++++++++++++++ src/hooks/useBlossomUpload.tsx | 29 ++++++++-- 4 files changed, 124 insertions(+), 9 deletions(-) diff --git a/src/components/BlossomUploadDialog.tsx b/src/components/BlossomUploadDialog.tsx index 9a6d461..e0af497 100644 --- a/src/components/BlossomUploadDialog.tsx +++ b/src/components/BlossomUploadDialog.tsx @@ -54,6 +54,8 @@ interface BlossomUploadDialogProps { onError?: (error: Error) => void; /** File types to accept (e.g., "image/*,video/*,audio/*") */ accept?: string; + /** Optional initial files to pre-select (e.g., from drag-and-drop) */ + initialFiles?: File[]; } /** @@ -72,6 +74,7 @@ export function BlossomUploadDialog({ onCancel, onError, accept = "image/*,video/*,audio/*", + initialFiles, }: BlossomUploadDialogProps) { const eventStore = useEventStore(); const activeAccount = use$(accountManager.active$); @@ -96,14 +99,29 @@ export function BlossomUploadDialog({ // Reset state when dialog opens useEffect(() => { if (open) { - setSelectedFile(null); - setPreviewUrl(null); + // If initial files provided, set the first one + if (initialFiles && initialFiles.length > 0) { + const file = initialFiles[0]; + setSelectedFile(file); + + // Create preview URL for images/video + if (file.type.startsWith("image/") || file.type.startsWith("video/")) { + const url = URL.createObjectURL(file); + setPreviewUrl(url); + } else { + setPreviewUrl(null); + } + } else { + setSelectedFile(null); + setPreviewUrl(null); + } + setUploadResults([]); setUploadErrors([]); setUploading(false); setUsingFallback(false); } - }, [open]); + }, [open, initialFiles]); // Helper to set fallback servers const applyFallbackServers = useCallback(() => { diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 53696b5..3ae7102 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -1078,7 +1078,7 @@ export function ChatViewer({ variant="ghost" size="icon" className="flex-shrink-0 size-7 text-muted-foreground hover:text-foreground" - onClick={openUpload} + onClick={() => openUpload()} disabled={isSending} > @@ -1096,6 +1096,10 @@ export function ChatViewer({ searchEmojis={searchEmojis} searchCommands={searchCommands} onCommandExecute={handleCommandExecute} + onFileDrop={(files) => { + // Open upload dialog with dropped files + openUpload(files); + }} onSubmit={(content, emojiTags, blobAttachments) => { if (content.trim()) { handleSend(content, replyTo, emojiTags, blobAttachments); diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index 94db0df..559dd12 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -80,6 +80,7 @@ export interface MentionEditorProps { searchEmojis?: (query: string) => Promise; searchCommands?: (query: string) => Promise; onCommandExecute?: (action: ChatAction) => Promise; + onFileDrop?: (files: File[]) => void; autoFocus?: boolean; className?: string; } @@ -487,6 +488,73 @@ const NostrPasteHandler = Extension.create({ }, }); +// File drop handler extension to intercept file drops and trigger upload +const FileDropHandler = Extension.create({ + name: "fileDropHandler", + + addProseMirrorPlugins() { + const onFileDrop = this.options.onFileDrop; + + return [ + new Plugin({ + key: new PluginKey("fileDropHandler"), + + props: { + handleDrop: (_view, event) => { + // Check if this is a file drop + const files = event.dataTransfer?.files; + if (!files || files.length === 0) return false; + + // Check if files are images, videos, or audio (same as upload dialog accepts) + const validFiles = Array.from(files).filter((file) => + file.type.match(/^(image|video|audio)\//), + ); + + if (validFiles.length === 0) return false; + + // Trigger the file drop callback + if (onFileDrop) { + onFileDrop(validFiles); + event.preventDefault(); + return true; // Prevent default drop behavior + } + + return false; + }, + + handlePaste: (_view, event) => { + // Also handle paste events with files (e.g., pasting images from clipboard) + const files = event.clipboardData?.files; + if (!files || files.length === 0) return false; + + // Check if files are images, videos, or audio + const validFiles = Array.from(files).filter((file) => + file.type.match(/^(image|video|audio)\//), + ); + + if (validFiles.length === 0) return false; + + // Trigger the file drop callback + if (onFileDrop) { + onFileDrop(validFiles); + event.preventDefault(); + return true; // Prevent default paste behavior + } + + return false; + }, + }, + }), + ]; + }, + + addOptions() { + return { + onFileDrop: undefined, + }; + }, +}); + export const MentionEditor = forwardRef< MentionEditorHandle, MentionEditorProps @@ -499,6 +567,7 @@ export const MentionEditor = forwardRef< searchEmojis, searchCommands, onCommandExecute, + onFileDrop, autoFocus = false, className = "", }, @@ -968,6 +1037,10 @@ export const MentionEditor = forwardRef< NostrEventPreview, // Add paste handler to transform bech32 strings into previews NostrPasteHandler, + // Add file drop handler for drag-and-drop file uploads + FileDropHandler.configure({ + onFileDrop, + }), ]; // Add emoji extension if search is provided @@ -1045,6 +1118,7 @@ export const MentionEditor = forwardRef< emojiSuggestion, slashCommandSuggestion, onCommandExecute, + onFileDrop, placeholder, ]); diff --git a/src/hooks/useBlossomUpload.tsx b/src/hooks/useBlossomUpload.tsx index aee53a5..a6b83c8 100644 --- a/src/hooks/useBlossomUpload.tsx +++ b/src/hooks/useBlossomUpload.tsx @@ -14,8 +14,8 @@ export interface UseBlossomUploadOptions { } export interface UseBlossomUploadReturn { - /** Open the upload dialog */ - open: () => void; + /** Open the upload dialog, optionally with pre-selected files */ + open: (files?: File[]) => void; /** Close the upload dialog */ close: () => void; /** Whether the dialog is currently open */ @@ -50,9 +50,20 @@ export function useBlossomUpload( options: UseBlossomUploadOptions = {}, ): UseBlossomUploadReturn { const [isOpen, setIsOpen] = useState(false); + const [initialFiles, setInitialFiles] = useState( + undefined, + ); - const open = useCallback(() => setIsOpen(true), []); - const close = useCallback(() => setIsOpen(false), []); + const open = useCallback((files?: File[]) => { + setInitialFiles(files); + setIsOpen(true); + }, []); + + const close = useCallback(() => { + setIsOpen(false); + // Clear initial files when closing + setInitialFiles(undefined); + }, []); const handleSuccess = useCallback( (results: UploadResult[]) => { @@ -84,9 +95,17 @@ export function useBlossomUpload( onCancel={handleCancel} onError={handleError} accept={options.accept} + initialFiles={initialFiles} /> ), - [isOpen, handleSuccess, handleCancel, handleError, options.accept], + [ + isOpen, + handleSuccess, + handleCancel, + handleError, + options.accept, + initialFiles, + ], ); return { open, close, isOpen, dialog };