feat(editor): add drag-and-drop and paste file upload support

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
This commit is contained in:
Claude
2026-01-20 20:04:41 +00:00
parent eee97cea27
commit c3b0eae4d8
4 changed files with 124 additions and 9 deletions

View File

@@ -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(() => {

View File

@@ -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}
>
<Paperclip className="size-4" />
@@ -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);

View File

@@ -80,6 +80,7 @@ export interface MentionEditorProps {
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
searchCommands?: (query: string) => Promise<ChatAction[]>;
onCommandExecute?: (action: ChatAction) => Promise<void>;
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,
]);

View File

@@ -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<File[] | undefined>(
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 };