Drag & Drop Improvements (#139)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import type { Base64ContentBlock } from "@langchain/core/messages";
|
||||
import { MultimodalPreview } from "../ui/MultimodalPreview";
|
||||
import { MultimodalPreview } from "./MultimodalPreview";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ContentBlocksPreviewProps {
|
||||
|
||||
@@ -18,15 +18,6 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
|
||||
className,
|
||||
size = "md",
|
||||
}) => {
|
||||
// Sizing
|
||||
const sizeMap = {
|
||||
sm: "h-10 w-10 text-base",
|
||||
md: "h-16 w-16 text-lg",
|
||||
lg: "h-24 w-24 text-xl",
|
||||
};
|
||||
const iconSize: string =
|
||||
typeof sizeMap[size] === "string" ? sizeMap[size] : sizeMap["md"];
|
||||
|
||||
// Image block
|
||||
if (
|
||||
block.type === "image" &&
|
||||
@@ -72,28 +63,28 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center gap-2 rounded-md border bg-gray-100 px-3 py-2",
|
||||
"relative flex items-start gap-2 rounded-md border bg-gray-100 px-3 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<File
|
||||
className={cn(
|
||||
"flex-shrink-0 text-teal-700",
|
||||
size === "sm" ? "h-5 w-5" : "h-7 w-7",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-shrink-0 flex-col items-start justify-start">
|
||||
<File
|
||||
className={cn(
|
||||
"text-teal-700",
|
||||
size === "sm" ? "h-5 w-5" : "h-7 w-7",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm text-gray-800",
|
||||
size === "sm" ? "max-w-[80px]" : "max-w-[160px]",
|
||||
)}
|
||||
className={cn("min-w-0 flex-1 text-sm break-all text-gray-800")}
|
||||
style={{ wordBreak: "break-all", whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{String(filename)}
|
||||
</span>
|
||||
{removable && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 rounded-full bg-gray-200 p-1 text-teal-700 hover:bg-gray-300"
|
||||
className="ml-2 self-start rounded-full bg-gray-200 p-1 text-teal-700 hover:bg-gray-300"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove PDF"
|
||||
>
|
||||
@@ -133,6 +133,8 @@ export function Thread() {
|
||||
dropRef,
|
||||
removeBlock,
|
||||
resetBlocks,
|
||||
dragOver,
|
||||
handlePaste,
|
||||
} = useFileUpload();
|
||||
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
|
||||
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
|
||||
@@ -442,7 +444,12 @@ export function Thread() {
|
||||
|
||||
<div
|
||||
ref={dropRef}
|
||||
className="bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl border shadow-xs"
|
||||
className={cn(
|
||||
"bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl shadow-xs transition-all",
|
||||
dragOver
|
||||
? "border-primary border-2 border-dotted"
|
||||
: "border border-solid",
|
||||
)}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
@@ -455,6 +462,7 @@ export function Thread() {
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
|
||||
@@ -5,8 +5,8 @@ import { getContentString } from "../utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { BranchSwitcher, CommandBar } from "./shared";
|
||||
import { MultimodalPreview } from "@/components/ui/MultimodalPreview";
|
||||
import type { Base64ContentBlock } from "@langchain/core/messages";
|
||||
import { MultimodalPreview } from "@/components/thread/MultimodalPreview";
|
||||
import { isBase64ContentBlock } from "@/lib/multimodal-utils";
|
||||
|
||||
function EditableContent({
|
||||
value,
|
||||
@@ -34,36 +34,6 @@ function EditableContent({
|
||||
);
|
||||
}
|
||||
|
||||
// Type guard for Base64ContentBlock
|
||||
function isBase64ContentBlock(block: unknown): block is Base64ContentBlock {
|
||||
if (typeof block !== "object" || block === null || !("type" in block))
|
||||
return false;
|
||||
// file type (legacy)
|
||||
if (
|
||||
(block as { type: unknown }).type === "file" &&
|
||||
"source_type" in block &&
|
||||
(block as { source_type: unknown }).source_type === "base64" &&
|
||||
"mime_type" in block &&
|
||||
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
|
||||
((block as { mime_type: string }).mime_type.startsWith("image/") ||
|
||||
(block as { mime_type: string }).mime_type === "application/pdf")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// image type (new)
|
||||
if (
|
||||
(block as { type: unknown }).type === "image" &&
|
||||
"source_type" in block &&
|
||||
(block as { source_type: unknown }).source_type === "base64" &&
|
||||
"mime_type" in block &&
|
||||
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
|
||||
(block as { mime_type: string }).mime_type.startsWith("image/")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function HumanMessage({
|
||||
message,
|
||||
isLoading,
|
||||
@@ -119,7 +89,7 @@ export function HumanMessage({
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Render images and files if no text */}
|
||||
{Array.isArray(message.content) && message.content.length > 0 && (
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex flex-wrap items-end justify-end gap-2">
|
||||
{message.content.reduce<React.ReactNode[]>(
|
||||
(acc, block, idx) => {
|
||||
if (isBase64ContentBlock(block)) {
|
||||
|
||||
@@ -21,6 +21,8 @@ export function useFileUpload({
|
||||
const [contentBlocks, setContentBlocks] =
|
||||
useState<Base64ContentBlock[]>(initialBlocks);
|
||||
const dropRef = useRef<HTMLDivElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
const isDuplicate = (file: File, blocks: Base64ContentBlock[]) => {
|
||||
if (file.type === "application/pdf") {
|
||||
@@ -81,14 +83,27 @@ export function useFileUpload({
|
||||
useEffect(() => {
|
||||
if (!dropRef.current) return;
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Global drag events with counter for robust dragOver state
|
||||
const handleWindowDragEnter = (e: DragEvent) => {
|
||||
if (e.dataTransfer?.types?.includes("Files")) {
|
||||
dragCounter.current += 1;
|
||||
setDragOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: DragEvent) => {
|
||||
const handleWindowDragLeave = (e: DragEvent) => {
|
||||
if (e.dataTransfer?.types?.includes("Files")) {
|
||||
dragCounter.current -= 1;
|
||||
if (dragCounter.current <= 0) {
|
||||
setDragOver(false);
|
||||
dragCounter.current = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleWindowDrop = async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current = 0;
|
||||
setDragOver(false);
|
||||
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
@@ -122,28 +137,53 @@ export function useFileUpload({
|
||||
: [];
|
||||
setContentBlocks((prev) => [...prev, ...newBlocks]);
|
||||
};
|
||||
const handleWindowDragEnd = (e: DragEvent) => {
|
||||
dragCounter.current = 0;
|
||||
setDragOver(false);
|
||||
};
|
||||
window.addEventListener("dragenter", handleWindowDragEnter);
|
||||
window.addEventListener("dragleave", handleWindowDragLeave);
|
||||
window.addEventListener("drop", handleWindowDrop);
|
||||
window.addEventListener("dragend", handleWindowDragEnd);
|
||||
|
||||
// Prevent default browser behavior for dragover globally
|
||||
const handleWindowDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
window.addEventListener("dragover", handleWindowDragOver);
|
||||
|
||||
// Remove element-specific drop event (handled globally)
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(true);
|
||||
};
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const element = dropRef.current;
|
||||
element.addEventListener("dragover", handleDragOver);
|
||||
element.addEventListener("drop", handleDrop);
|
||||
element.addEventListener("dragenter", handleDragEnter);
|
||||
element.addEventListener("dragleave", handleDragLeave);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("dragover", handleDragOver);
|
||||
element.removeEventListener("drop", handleDrop);
|
||||
element.removeEventListener("dragenter", handleDragEnter);
|
||||
element.removeEventListener("dragleave", handleDragLeave);
|
||||
window.removeEventListener("dragenter", handleWindowDragEnter);
|
||||
window.removeEventListener("dragleave", handleWindowDragLeave);
|
||||
window.removeEventListener("drop", handleWindowDrop);
|
||||
window.removeEventListener("dragend", handleWindowDragEnd);
|
||||
window.removeEventListener("dragover", handleWindowDragOver);
|
||||
dragCounter.current = 0;
|
||||
};
|
||||
}, [contentBlocks]);
|
||||
|
||||
@@ -153,6 +193,68 @@ export function useFileUpload({
|
||||
|
||||
const resetBlocks = () => setContentBlocks([]);
|
||||
|
||||
/**
|
||||
* Handle paste event for files (images, PDFs)
|
||||
* Can be used as onPaste={handlePaste} on a textarea or input
|
||||
*/
|
||||
const handlePaste = async (
|
||||
e: React.ClipboardEvent<HTMLTextAreaElement | HTMLInputElement>,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
const items = e.clipboardData.items;
|
||||
if (!items) return;
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const item = items[i];
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
const validFiles = files.filter((file) =>
|
||||
SUPPORTED_FILE_TYPES.includes(file.type),
|
||||
);
|
||||
const invalidFiles = files.filter(
|
||||
(file) => !SUPPORTED_FILE_TYPES.includes(file.type),
|
||||
);
|
||||
const isDuplicate = (file: File) => {
|
||||
if (file.type === "application/pdf") {
|
||||
return contentBlocks.some(
|
||||
(b) =>
|
||||
b.type === "file" &&
|
||||
b.mime_type === "application/pdf" &&
|
||||
b.metadata?.filename === file.name,
|
||||
);
|
||||
}
|
||||
if (SUPPORTED_FILE_TYPES.includes(file.type)) {
|
||||
return contentBlocks.some(
|
||||
(b) =>
|
||||
b.type === "image" &&
|
||||
b.metadata?.name === file.name &&
|
||||
b.mime_type === file.type,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const duplicateFiles = validFiles.filter(isDuplicate);
|
||||
const uniqueFiles = validFiles.filter((file) => !isDuplicate(file));
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error(
|
||||
"You have pasted an invalid file type. Please paste a JPEG, PNG, GIF, WEBP image or a PDF.",
|
||||
);
|
||||
}
|
||||
if (duplicateFiles.length > 0) {
|
||||
toast.error(
|
||||
`Duplicate file(s) detected: ${duplicateFiles.map((f) => f.name).join(", ")}. Each file can only be uploaded once per message.`,
|
||||
);
|
||||
}
|
||||
if (uniqueFiles.length > 0) {
|
||||
const newBlocks = await Promise.all(uniqueFiles.map(fileToContentBlock));
|
||||
setContentBlocks((prev) => [...prev, ...newBlocks]);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
contentBlocks,
|
||||
setContentBlocks,
|
||||
@@ -160,5 +262,7 @@ export function useFileUpload({
|
||||
dropRef,
|
||||
removeBlock,
|
||||
resetBlocks,
|
||||
dragOver,
|
||||
handlePaste,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,3 +55,35 @@ export async function fileToBase64(file: File): Promise<string> {
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Type guard for Base64ContentBlock
|
||||
export function isBase64ContentBlock(
|
||||
block: unknown,
|
||||
): block is Base64ContentBlock {
|
||||
if (typeof block !== "object" || block === null || !("type" in block))
|
||||
return false;
|
||||
// file type (legacy)
|
||||
if (
|
||||
(block as { type: unknown }).type === "file" &&
|
||||
"source_type" in block &&
|
||||
(block as { source_type: unknown }).source_type === "base64" &&
|
||||
"mime_type" in block &&
|
||||
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
|
||||
((block as { mime_type: string }).mime_type.startsWith("image/") ||
|
||||
(block as { mime_type: string }).mime_type === "application/pdf")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// image type (new)
|
||||
if (
|
||||
(block as { type: unknown }).type === "image" &&
|
||||
"source_type" in block &&
|
||||
(block as { source_type: unknown }).source_type === "base64" &&
|
||||
"mime_type" in block &&
|
||||
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
|
||||
(block as { mime_type: string }).mime_type.startsWith("image/")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user