feat(ui): add drag-and-drop and file picker for image attachments
Add two intuitive ways to attach images to chat messages: 1. Drag-and-drop images onto the compose area (with visual feedback) 2. Click the paperclip button to open file picker Existing clipboard paste functionality remains unchanged. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
699784dbee
commit
095b1bc4e9
@ -263,6 +263,43 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Drag-over visual feedback */
|
||||
.chat-compose--drag-over {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -2px;
|
||||
background: rgba(var(--accent-rgb), 0.05);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* File attach button */
|
||||
.chat-compose__attach {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
transition: all 150ms ease-out;
|
||||
}
|
||||
|
||||
.chat-compose__attach:hover {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.chat-compose__attach svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
|
||||
.chat-compose__actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
|
||||
@ -93,4 +93,24 @@ describe("chat view", () => {
|
||||
expect(onNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).not.toContain("Stop");
|
||||
});
|
||||
|
||||
it("renders file input for image attachments", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
onAttachmentsChange: vi.fn(),
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(fileInput).not.toBeNull();
|
||||
expect(fileInput?.accept).toBe("image/*");
|
||||
expect(fileInput?.multiple).toBe(true);
|
||||
|
||||
const attachLabel = container.querySelector('label[for="chat-file-input"]');
|
||||
expect(attachLabel).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -108,10 +108,29 @@ function generateAttachmentId(): string {
|
||||
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
function handlePaste(
|
||||
e: ClipboardEvent,
|
||||
props: ChatProps,
|
||||
) {
|
||||
function processFiles(files: FileList, props: ChatProps) {
|
||||
if (!props.onAttachmentsChange) return;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file.type.startsWith("image/")) continue;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const newAttachment: ChatAttachment = {
|
||||
id: generateAttachmentId(),
|
||||
dataUrl,
|
||||
mimeType: file.type,
|
||||
};
|
||||
const current = props.attachments ?? [];
|
||||
props.onAttachmentsChange?.([...current, newAttachment]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent, props: ChatProps) {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items || !props.onAttachmentsChange) return;
|
||||
|
||||
@ -146,6 +165,34 @@ function handlePaste(
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as HTMLElement).classList.add("chat-compose--drag-over");
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as HTMLElement).classList.remove("chat-compose--drag-over");
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, props: ChatProps) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as HTMLElement).classList.remove("chat-compose--drag-over");
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files || !props.onAttachmentsChange) return;
|
||||
processFiles(files, props);
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event, props: ChatProps) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files) processFiles(input.files, props);
|
||||
input.value = ""; // Reset so same file can be selected again
|
||||
}
|
||||
|
||||
function renderAttachmentPreview(props: ChatProps) {
|
||||
const attachments = props.attachments ?? [];
|
||||
if (attachments.length === 0) return nothing;
|
||||
@ -327,7 +374,12 @@ export function renderChat(props: ChatProps) {
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<div class="chat-compose">
|
||||
<div
|
||||
class="chat-compose"
|
||||
@dragover=${handleDragOver}
|
||||
@dragleave=${handleDragLeave}
|
||||
@drop=${(e: DragEvent) => handleDrop(e, props)}
|
||||
>
|
||||
${renderAttachmentPreview(props)}
|
||||
<div class="chat-compose__row">
|
||||
<label class="field chat-compose__field">
|
||||
@ -353,6 +405,22 @@ export function renderChat(props: ChatProps) {
|
||||
placeholder=${composePlaceholder}
|
||||
></textarea>
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id="chat-file-input"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
@change=${(e: Event) => handleFileSelect(e, props)}
|
||||
/>
|
||||
<label
|
||||
for="chat-file-input"
|
||||
class="btn btn--icon chat-compose__attach"
|
||||
title="Attach images"
|
||||
aria-label="Attach images"
|
||||
>
|
||||
${icons.paperclip}
|
||||
</label>
|
||||
<div class="chat-compose__actions">
|
||||
<button
|
||||
class="btn"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user