mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: add drag-and-drop image upload and improve image previews
- Add FileDropZone component with overlay for web drag-and-drop - Add useFileDropZone hook using native addEventListener for web - Wire up drag-drop to agent screen to add images to input - Improve image preview styling with visible border - Show X button on hover with dark overlay for removal 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -41,6 +41,8 @@ import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { ImportAgentModal } from "@/components/create-agent-modal";
|
||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import {
|
||||
ExplorerSidebarAnimationProvider,
|
||||
useExplorerSidebarAnimation,
|
||||
@@ -183,6 +185,15 @@ function AgentScreenContent({
|
||||
const [menuContentHeight, setMenuContentHeight] = useState(0);
|
||||
const menuButtonRef = useRef<View>(null);
|
||||
const [showImportAgentModal, setShowImportAgentModal] = useState(false);
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
|
||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||
addImagesRef.current?.(files);
|
||||
}, []);
|
||||
|
||||
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
|
||||
addImagesRef.current = addImages;
|
||||
}, []);
|
||||
|
||||
const { isOpen: isExplorerOpen, toggle: toggleExplorer, open: openExplorer, close: closeExplorer } = useExplorerSidebarStore();
|
||||
const {
|
||||
@@ -672,6 +683,7 @@ function AgentScreenContent({
|
||||
|
||||
const mainContent = (
|
||||
<View style={styles.outerContainer}>
|
||||
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isInitializing}>
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<MenuHeader
|
||||
@@ -735,7 +747,7 @@ function AgentScreenContent({
|
||||
|
||||
{/* Agent Input Area */}
|
||||
{!isInitializing && agent && resolvedAgentId && (
|
||||
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus />
|
||||
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
|
||||
)}
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
@@ -899,6 +911,7 @@ function AgentScreenContent({
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
|
||||
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
|
||||
{!isMobile && isExplorerOpen && resolvedAgentId && (
|
||||
|
||||
@@ -46,6 +46,8 @@ interface AgentInputAreaProps {
|
||||
onChangeText?: (text: string) => void;
|
||||
/** When true, auto-focuses the text input on web. */
|
||||
autoFocus?: boolean;
|
||||
/** Callback to expose the addImages function to parent components */
|
||||
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
}
|
||||
|
||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
@@ -68,6 +70,7 @@ export function AgentInputArea({
|
||||
value,
|
||||
onChangeText,
|
||||
autoFocus = false,
|
||||
onAddImages,
|
||||
}: AgentInputAreaProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -130,6 +133,15 @@ export function AgentInputArea({
|
||||
const sendAgentMessageRef = useRef(sendAgentMessage);
|
||||
const onSubmitMessageRef = useRef(onSubmitMessage);
|
||||
|
||||
// Expose addImages function to parent for drag-and-drop support
|
||||
const addImages = useCallback((images: ImageAttachment[]) => {
|
||||
setSelectedImages((prev) => [...prev, ...images]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onAddImages?.(addImages);
|
||||
}, [addImages, onAddImages]);
|
||||
|
||||
const submitMessage = useCallback(
|
||||
async (text: string, images?: ImageAttachment[]) => {
|
||||
if (onSubmitMessageRef.current) {
|
||||
|
||||
95
packages/app/src/components/file-drop-zone.tsx
Normal file
95
packages/app/src/components/file-drop-zone.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { View, Text, Platform } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useEffect } from "react";
|
||||
import { Upload } from "lucide-react-native";
|
||||
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
||||
import type { ImageAttachment } from "./message-input";
|
||||
|
||||
interface FileDropZoneProps {
|
||||
children: React.ReactNode;
|
||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
|
||||
export function FileDropZone({
|
||||
children,
|
||||
onFilesDropped,
|
||||
disabled = false,
|
||||
}: FileDropZoneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { isDragging, containerRef } = useFileDropZone({
|
||||
onFilesDropped,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const overlayOpacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
overlayOpacity.value = withTiming(isDragging ? 1 : 0, { duration: 150 });
|
||||
}, [isDragging, overlayOpacity]);
|
||||
|
||||
const overlayAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: overlayOpacity.value,
|
||||
pointerEvents: overlayOpacity.value > 0 ? "auto" : "none",
|
||||
}));
|
||||
|
||||
// On non-web platforms, just render children
|
||||
if (!IS_WEB) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
// Cast ref for web - View renders as div on web
|
||||
ref={containerRef as unknown as React.RefObject<View>}
|
||||
style={styles.container}
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* Drop overlay */}
|
||||
<Animated.View style={[styles.overlay, overlayAnimatedStyle]}>
|
||||
{/* Backdrop */}
|
||||
<View style={styles.backdrop} />
|
||||
{/* Content */}
|
||||
<View style={styles.overlayContent}>
|
||||
<Upload size={32} color={theme.colors.primary} />
|
||||
<Text style={styles.overlayText}>Drop images here</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: theme.colors.background,
|
||||
opacity: 0.7,
|
||||
},
|
||||
overlayContent: {
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
overlayText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -426,20 +426,30 @@ export function MessageInput({
|
||||
{hasImages && (
|
||||
<View style={styles.imagePreviewContainer}>
|
||||
{images.map((image, index) => (
|
||||
<View key={`${image.uri}-${index}`} style={styles.imagePill}>
|
||||
<Image
|
||||
source={{ uri: image.uri }}
|
||||
style={styles.imageThumbnail}
|
||||
/>
|
||||
{onRemoveImage && (
|
||||
<Pressable
|
||||
onPress={() => onRemoveImage(index)}
|
||||
style={styles.removeImageButton}
|
||||
>
|
||||
<X size={16} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
key={`${image.uri}-${index}`}
|
||||
style={styles.imagePill}
|
||||
onPress={onRemoveImage ? () => onRemoveImage(index) : undefined}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Image
|
||||
source={{ uri: image.uri }}
|
||||
style={styles.imageThumbnail}
|
||||
/>
|
||||
{onRemoveImage && (
|
||||
<View
|
||||
style={[
|
||||
styles.removeImageButton,
|
||||
(hovered || !IS_WEB) && styles.removeImageButtonVisible,
|
||||
]}
|
||||
>
|
||||
<X size={16} color="white" />
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
@@ -591,23 +601,40 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
imagePill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.muted,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[1],
|
||||
gap: theme.spacing[2],
|
||||
position: "relative",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.accentBorder,
|
||||
overflow: "hidden",
|
||||
...(IS_WEB
|
||||
? {
|
||||
cursor: "pointer",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
imageThumbnail: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
width: 48,
|
||||
height: 48,
|
||||
},
|
||||
removeImageButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
opacity: 0,
|
||||
...(IS_WEB
|
||||
? {
|
||||
transitionProperty: "opacity",
|
||||
transitionDuration: "150ms",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
removeImageButtonVisible: {
|
||||
opacity: 1,
|
||||
},
|
||||
textInput: {
|
||||
width: "100%",
|
||||
|
||||
144
packages/app/src/hooks/use-file-drop-zone.ts
Normal file
144
packages/app/src/hooks/use-file-drop-zone.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
|
||||
interface UseFileDropZoneOptions {
|
||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseFileDropZoneReturn {
|
||||
isDragging: boolean;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
|
||||
function isImageFile(file: File): boolean {
|
||||
return file.type.startsWith("image/");
|
||||
}
|
||||
|
||||
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve({
|
||||
uri: reader.result,
|
||||
mimeType: file.type || "image/jpeg",
|
||||
});
|
||||
} else {
|
||||
reject(new Error("Failed to read file as data URL"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function useFileDropZone({
|
||||
onFilesDropped,
|
||||
disabled = false,
|
||||
}: UseFileDropZoneOptions): UseFileDropZoneReturn {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
const dragCounterRef = useRef(0);
|
||||
const onFilesDroppedRef = useRef(onFilesDropped);
|
||||
|
||||
// Keep callback ref up to date
|
||||
useEffect(() => {
|
||||
onFilesDroppedRef.current = onFilesDropped;
|
||||
}, [onFilesDropped]);
|
||||
|
||||
// Reset drag state when disabled changes
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
// Set up event listeners on web
|
||||
useEffect(() => {
|
||||
if (!IS_WEB) return;
|
||||
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
|
||||
function handleDragEnter(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
if (e.dataTransfer?.types.includes("Files")) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
const imageFiles = files.filter(isImageFile);
|
||||
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const attachments = await Promise.all(
|
||||
imageFiles.map(fileToImageAttachment)
|
||||
);
|
||||
onFilesDroppedRef.current(attachments);
|
||||
} catch (error) {
|
||||
console.error("[useFileDropZone] Failed to process dropped files:", error);
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener("dragenter", handleDragEnter);
|
||||
element.addEventListener("dragover", handleDragOver);
|
||||
element.addEventListener("dragleave", handleDragLeave);
|
||||
element.addEventListener("drop", handleDrop);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("dragenter", handleDragEnter);
|
||||
element.removeEventListener("dragover", handleDragOver);
|
||||
element.removeEventListener("dragleave", handleDragLeave);
|
||||
element.removeEventListener("drop", handleDrop);
|
||||
};
|
||||
}, [disabled]);
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
containerRef,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user