mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
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:
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user