drop images onto the composer to attach them
The whole composer bar is the target, not just the textarea — a screenshot dragged out of the macOS corner thumbnail is a small thing to aim with, and the bar is the biggest thing near where the cursor already is. Paste already worked; this is the same attach path. Three details, each of which breaks the drop silently if missed. `preventDefault` on dragover, or the browser refuses the drop, never fires onDrop, and navigates to the file instead — taking whatever was typed with it. A depth counter rather than a boolean, because dragenter/dragleave fire for every child crossed and the highlight strobes as you move over the textarea. And only claiming drags that carry files, so dragging selected text across the composer neither lights it up nor swallows the drop. Non-image files in the same drag are ignored quietly: refusing the PDF among them with a toast would be noise when the three screenshots you meant went in fine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Loader2, Mic, Send, Square, ListPlus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
||||
@@ -43,9 +44,21 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
|
||||
const { recording, transcribing, toggleRecording } = useAudioRecording(appendToInput);
|
||||
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
||||
const drop = useImageDrop(attachImage);
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-border bg-background/60 p-2 md:p-3">
|
||||
<div
|
||||
className="relative shrink-0 border-t border-border bg-background/60 p-2 md:p-3"
|
||||
onDragEnter={drop.onDragEnter}
|
||||
onDragOver={drop.onDragOver}
|
||||
onDragLeave={drop.onDragLeave}
|
||||
onDrop={drop.onDrop}
|
||||
>
|
||||
{drop.isTarget && (
|
||||
<div className="pointer-events-none absolute inset-1 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-duck-teal bg-background/80">
|
||||
<span className="text-xs font-medium text-duck-teal">Drop images to attach</span>
|
||||
</div>
|
||||
)}
|
||||
{commandFeedback && (
|
||||
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
|
||||
)}
|
||||
@@ -132,6 +145,61 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
/**
|
||||
* Drop images anywhere on the composer, not just on the textarea — the target is the whole bar, because
|
||||
* a screenshot dragged out of the macOS corner thumbnail is a small thing to aim with.
|
||||
*
|
||||
* Three things this has to get right, each of which silently breaks the drop if missed:
|
||||
*
|
||||
* - **`preventDefault` on dragover.** Without it the browser refuses the drop and never fires `onDrop`;
|
||||
* it just navigates to the file instead, throwing away whatever was typed.
|
||||
* - **A depth counter, not a boolean.** `dragenter`/`dragleave` fire for every child crossed, so moving
|
||||
* over the textarea or a button reads as leaving the bar and the highlight strobes.
|
||||
* - **Only claim drags that carry files.** Dragging selected text across the composer would otherwise
|
||||
* light it up and then swallow the drop, which is how you lose a text-drag into the input.
|
||||
*/
|
||||
function useImageDrop(attachImage: (file: File) => void) {
|
||||
const [isTarget, setIsTarget] = useState(false);
|
||||
const depth = useRef(0);
|
||||
|
||||
const carriesFiles = (ev: DragEvent<HTMLElement>) => Array.from(ev.dataTransfer.types).includes('Files');
|
||||
|
||||
const reset = () => {
|
||||
depth.current = 0;
|
||||
setIsTarget(false);
|
||||
};
|
||||
|
||||
return {
|
||||
isTarget,
|
||||
onDragEnter: (ev: DragEvent<HTMLElement>) => {
|
||||
if (!carriesFiles(ev)) return;
|
||||
ev.preventDefault();
|
||||
depth.current += 1;
|
||||
setIsTarget(true);
|
||||
},
|
||||
onDragOver: (ev: DragEvent<HTMLElement>) => {
|
||||
if (!carriesFiles(ev)) return;
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
},
|
||||
onDragLeave: (ev: DragEvent<HTMLElement>) => {
|
||||
if (!carriesFiles(ev)) return;
|
||||
depth.current -= 1;
|
||||
if (depth.current <= 0) reset();
|
||||
},
|
||||
onDrop: (ev: DragEvent<HTMLElement>) => {
|
||||
if (!carriesFiles(ev)) return;
|
||||
ev.preventDefault();
|
||||
reset();
|
||||
// Images only, and quietly: a drag can carry several files, and refusing the PDF among them with a
|
||||
// toast would be noise when the three screenshots you meant went in fine.
|
||||
for (const file of Array.from(ev.dataTransfer.files)) {
|
||||
if (file.type.startsWith('image/')) attachImage(file);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What is waiting to be sent. Without this a queued prompt is invisible until its turn comes — the
|
||||
* composer empties and nothing else changes, which reads exactly like the message having been lost.
|
||||
|
||||
Reference in New Issue
Block a user