Skip to content

Commit 93f848e

Browse files
authored
Drag & Drop Improvements (#139)
2 parents 06e0de6 + ef6454a commit 93f848e

File tree

6 files changed

+170
-65
lines changed

6 files changed

+170
-65
lines changed

src/components/thread/ContentBlocksPreview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from "react";
22
import type { Base64ContentBlock } from "@langchain/core/messages";
3-
import { MultimodalPreview } from "../ui/MultimodalPreview";
3+
import { MultimodalPreview } from "./MultimodalPreview";
44
import { cn } from "@/lib/utils";
55

66
interface ContentBlocksPreviewProps {

src/components/ui/MultimodalPreview.tsx renamed to src/components/thread/MultimodalPreview.tsx

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,6 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
1818
className,
1919
size = "md",
2020
}) => {
21-
// Sizing
22-
const sizeMap = {
23-
sm: "h-10 w-10 text-base",
24-
md: "h-16 w-16 text-lg",
25-
lg: "h-24 w-24 text-xl",
26-
};
27-
const iconSize: string =
28-
typeof sizeMap[size] === "string" ? sizeMap[size] : sizeMap["md"];
29-
3021
// Image block
3122
if (
3223
block.type === "image" &&
@@ -72,28 +63,28 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
7263
return (
7364
<div
7465
className={cn(
75-
"relative flex items-center gap-2 rounded-md border bg-gray-100 px-3 py-2",
66+
"relative flex items-start gap-2 rounded-md border bg-gray-100 px-3 py-2",
7667
className,
7768
)}
7869
>
79-
<File
80-
className={cn(
81-
"flex-shrink-0 text-teal-700",
82-
size === "sm" ? "h-5 w-5" : "h-7 w-7",
83-
)}
84-
/>
70+
<div className="flex flex-shrink-0 flex-col items-start justify-start">
71+
<File
72+
className={cn(
73+
"text-teal-700",
74+
size === "sm" ? "h-5 w-5" : "h-7 w-7",
75+
)}
76+
/>
77+
</div>
8578
<span
86-
className={cn(
87-
"truncate text-sm text-gray-800",
88-
size === "sm" ? "max-w-[80px]" : "max-w-[160px]",
89-
)}
79+
className={cn("min-w-0 flex-1 text-sm break-all text-gray-800")}
80+
style={{ wordBreak: "break-all", whiteSpace: "pre-wrap" }}
9081
>
9182
{String(filename)}
9283
</span>
9384
{removable && (
9485
<button
9586
type="button"
96-
className="ml-2 rounded-full bg-gray-200 p-1 text-teal-700 hover:bg-gray-300"
87+
className="ml-2 self-start rounded-full bg-gray-200 p-1 text-teal-700 hover:bg-gray-300"
9788
onClick={onRemove}
9889
aria-label="Remove PDF"
9990
>

src/components/thread/index.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ export function Thread() {
133133
dropRef,
134134
removeBlock,
135135
resetBlocks,
136+
dragOver,
137+
handlePaste,
136138
} = useFileUpload();
137139
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
138140
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
@@ -442,7 +444,12 @@ export function Thread() {
442444

443445
<div
444446
ref={dropRef}
445-
className="bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl border shadow-xs"
447+
className={cn(
448+
"bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl shadow-xs transition-all",
449+
dragOver
450+
? "border-primary border-2 border-dotted"
451+
: "border border-solid",
452+
)}
446453
>
447454
<form
448455
onSubmit={handleSubmit}
@@ -455,6 +462,7 @@ export function Thread() {
455462
<textarea
456463
value={input}
457464
onChange={(e) => setInput(e.target.value)}
465+
onPaste={handlePaste}
458466
onKeyDown={(e) => {
459467
if (
460468
e.key === "Enter" &&

src/components/thread/messages/human.tsx

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { getContentString } from "../utils";
55
import { cn } from "@/lib/utils";
66
import { Textarea } from "@/components/ui/textarea";
77
import { BranchSwitcher, CommandBar } from "./shared";
8-
import { MultimodalPreview } from "@/components/ui/MultimodalPreview";
9-
import type { Base64ContentBlock } from "@langchain/core/messages";
8+
import { MultimodalPreview } from "@/components/thread/MultimodalPreview";
9+
import { isBase64ContentBlock } from "@/lib/multimodal-utils";
1010

1111
function EditableContent({
1212
value,
@@ -34,36 +34,6 @@ function EditableContent({
3434
);
3535
}
3636

37-
// Type guard for Base64ContentBlock
38-
function isBase64ContentBlock(block: unknown): block is Base64ContentBlock {
39-
if (typeof block !== "object" || block === null || !("type" in block))
40-
return false;
41-
// file type (legacy)
42-
if (
43-
(block as { type: unknown }).type === "file" &&
44-
"source_type" in block &&
45-
(block as { source_type: unknown }).source_type === "base64" &&
46-
"mime_type" in block &&
47-
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
48-
((block as { mime_type: string }).mime_type.startsWith("image/") ||
49-
(block as { mime_type: string }).mime_type === "application/pdf")
50-
) {
51-
return true;
52-
}
53-
// image type (new)
54-
if (
55-
(block as { type: unknown }).type === "image" &&
56-
"source_type" in block &&
57-
(block as { source_type: unknown }).source_type === "base64" &&
58-
"mime_type" in block &&
59-
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
60-
(block as { mime_type: string }).mime_type.startsWith("image/")
61-
) {
62-
return true;
63-
}
64-
return false;
65-
}
66-
6737
export function HumanMessage({
6838
message,
6939
isLoading,
@@ -119,7 +89,7 @@ export function HumanMessage({
11989
<div className="flex flex-col gap-2">
12090
{/* Render images and files if no text */}
12191
{Array.isArray(message.content) && message.content.length > 0 && (
122-
<div className="flex flex-col items-end gap-2">
92+
<div className="flex flex-wrap items-end justify-end gap-2">
12393
{message.content.reduce<React.ReactNode[]>(
12494
(acc, block, idx) => {
12595
if (isBase64ContentBlock(block)) {

src/hooks/use-file-upload.tsx

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export function useFileUpload({
2121
const [contentBlocks, setContentBlocks] =
2222
useState<Base64ContentBlock[]>(initialBlocks);
2323
const dropRef = useRef<HTMLDivElement>(null);
24+
const [dragOver, setDragOver] = useState(false);
25+
const dragCounter = useRef(0);
2426

2527
const isDuplicate = (file: File, blocks: Base64ContentBlock[]) => {
2628
if (file.type === "application/pdf") {
@@ -81,14 +83,27 @@ export function useFileUpload({
8183
useEffect(() => {
8284
if (!dropRef.current) return;
8385

84-
const handleDragOver = (e: DragEvent) => {
85-
e.preventDefault();
86-
e.stopPropagation();
86+
// Global drag events with counter for robust dragOver state
87+
const handleWindowDragEnter = (e: DragEvent) => {
88+
if (e.dataTransfer?.types?.includes("Files")) {
89+
dragCounter.current += 1;
90+
setDragOver(true);
91+
}
8792
};
88-
89-
const handleDrop = async (e: DragEvent) => {
93+
const handleWindowDragLeave = (e: DragEvent) => {
94+
if (e.dataTransfer?.types?.includes("Files")) {
95+
dragCounter.current -= 1;
96+
if (dragCounter.current <= 0) {
97+
setDragOver(false);
98+
dragCounter.current = 0;
99+
}
100+
}
101+
};
102+
const handleWindowDrop = async (e: DragEvent) => {
90103
e.preventDefault();
91104
e.stopPropagation();
105+
dragCounter.current = 0;
106+
setDragOver(false);
92107

93108
if (!e.dataTransfer) return;
94109

@@ -122,28 +137,53 @@ export function useFileUpload({
122137
: [];
123138
setContentBlocks((prev) => [...prev, ...newBlocks]);
124139
};
140+
const handleWindowDragEnd = (e: DragEvent) => {
141+
dragCounter.current = 0;
142+
setDragOver(false);
143+
};
144+
window.addEventListener("dragenter", handleWindowDragEnter);
145+
window.addEventListener("dragleave", handleWindowDragLeave);
146+
window.addEventListener("drop", handleWindowDrop);
147+
window.addEventListener("dragend", handleWindowDragEnd);
125148

126-
const handleDragEnter = (e: DragEvent) => {
149+
// Prevent default browser behavior for dragover globally
150+
const handleWindowDragOver = (e: DragEvent) => {
127151
e.preventDefault();
128152
e.stopPropagation();
129153
};
154+
window.addEventListener("dragover", handleWindowDragOver);
130155

156+
// Remove element-specific drop event (handled globally)
157+
const handleDragOver = (e: DragEvent) => {
158+
e.preventDefault();
159+
e.stopPropagation();
160+
setDragOver(true);
161+
};
162+
const handleDragEnter = (e: DragEvent) => {
163+
e.preventDefault();
164+
e.stopPropagation();
165+
setDragOver(true);
166+
};
131167
const handleDragLeave = (e: DragEvent) => {
132168
e.preventDefault();
133169
e.stopPropagation();
170+
setDragOver(false);
134171
};
135-
136172
const element = dropRef.current;
137173
element.addEventListener("dragover", handleDragOver);
138-
element.addEventListener("drop", handleDrop);
139174
element.addEventListener("dragenter", handleDragEnter);
140175
element.addEventListener("dragleave", handleDragLeave);
141176

142177
return () => {
143178
element.removeEventListener("dragover", handleDragOver);
144-
element.removeEventListener("drop", handleDrop);
145179
element.removeEventListener("dragenter", handleDragEnter);
146180
element.removeEventListener("dragleave", handleDragLeave);
181+
window.removeEventListener("dragenter", handleWindowDragEnter);
182+
window.removeEventListener("dragleave", handleWindowDragLeave);
183+
window.removeEventListener("drop", handleWindowDrop);
184+
window.removeEventListener("dragend", handleWindowDragEnd);
185+
window.removeEventListener("dragover", handleWindowDragOver);
186+
dragCounter.current = 0;
147187
};
148188
}, [contentBlocks]);
149189

@@ -153,12 +193,76 @@ export function useFileUpload({
153193

154194
const resetBlocks = () => setContentBlocks([]);
155195

196+
/**
197+
* Handle paste event for files (images, PDFs)
198+
* Can be used as onPaste={handlePaste} on a textarea or input
199+
*/
200+
const handlePaste = async (
201+
e: React.ClipboardEvent<HTMLTextAreaElement | HTMLInputElement>,
202+
) => {
203+
e.preventDefault();
204+
const items = e.clipboardData.items;
205+
if (!items) return;
206+
const files: File[] = [];
207+
for (let i = 0; i < items.length; i += 1) {
208+
const item = items[i];
209+
if (item.kind === "file") {
210+
const file = item.getAsFile();
211+
if (file) files.push(file);
212+
}
213+
}
214+
if (files.length === 0) return;
215+
const validFiles = files.filter((file) =>
216+
SUPPORTED_FILE_TYPES.includes(file.type),
217+
);
218+
const invalidFiles = files.filter(
219+
(file) => !SUPPORTED_FILE_TYPES.includes(file.type),
220+
);
221+
const isDuplicate = (file: File) => {
222+
if (file.type === "application/pdf") {
223+
return contentBlocks.some(
224+
(b) =>
225+
b.type === "file" &&
226+
b.mime_type === "application/pdf" &&
227+
b.metadata?.filename === file.name,
228+
);
229+
}
230+
if (SUPPORTED_FILE_TYPES.includes(file.type)) {
231+
return contentBlocks.some(
232+
(b) =>
233+
b.type === "image" &&
234+
b.metadata?.name === file.name &&
235+
b.mime_type === file.type,
236+
);
237+
}
238+
return false;
239+
};
240+
const duplicateFiles = validFiles.filter(isDuplicate);
241+
const uniqueFiles = validFiles.filter((file) => !isDuplicate(file));
242+
if (invalidFiles.length > 0) {
243+
toast.error(
244+
"You have pasted an invalid file type. Please paste a JPEG, PNG, GIF, WEBP image or a PDF.",
245+
);
246+
}
247+
if (duplicateFiles.length > 0) {
248+
toast.error(
249+
`Duplicate file(s) detected: ${duplicateFiles.map((f) => f.name).join(", ")}. Each file can only be uploaded once per message.`,
250+
);
251+
}
252+
if (uniqueFiles.length > 0) {
253+
const newBlocks = await Promise.all(uniqueFiles.map(fileToContentBlock));
254+
setContentBlocks((prev) => [...prev, ...newBlocks]);
255+
}
256+
};
257+
156258
return {
157259
contentBlocks,
158260
setContentBlocks,
159261
handleFileUpload,
160262
dropRef,
161263
removeBlock,
162264
resetBlocks,
265+
dragOver,
266+
handlePaste,
163267
};
164268
}

src/lib/multimodal-utils.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,35 @@ export async function fileToBase64(file: File): Promise<string> {
5555
reader.readAsDataURL(file);
5656
});
5757
}
58+
59+
// Type guard for Base64ContentBlock
60+
export function isBase64ContentBlock(
61+
block: unknown,
62+
): block is Base64ContentBlock {
63+
if (typeof block !== "object" || block === null || !("type" in block))
64+
return false;
65+
// file type (legacy)
66+
if (
67+
(block as { type: unknown }).type === "file" &&
68+
"source_type" in block &&
69+
(block as { source_type: unknown }).source_type === "base64" &&
70+
"mime_type" in block &&
71+
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
72+
((block as { mime_type: string }).mime_type.startsWith("image/") ||
73+
(block as { mime_type: string }).mime_type === "application/pdf")
74+
) {
75+
return true;
76+
}
77+
// image type (new)
78+
if (
79+
(block as { type: unknown }).type === "image" &&
80+
"source_type" in block &&
81+
(block as { source_type: unknown }).source_type === "base64" &&
82+
"mime_type" in block &&
83+
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
84+
(block as { mime_type: string }).mime_type.startsWith("image/")
85+
) {
86+
return true;
87+
}
88+
return false;
89+
}

0 commit comments

Comments
 (0)