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;
|
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 {
|
.chat-compose__actions {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -93,4 +93,24 @@ describe("chat view", () => {
|
|||||||
expect(onNewSession).toHaveBeenCalledTimes(1);
|
expect(onNewSession).toHaveBeenCalledTimes(1);
|
||||||
expect(container.textContent).not.toContain("Stop");
|
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)}`;
|
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePaste(
|
function processFiles(files: FileList, props: ChatProps) {
|
||||||
e: ClipboardEvent,
|
if (!props.onAttachmentsChange) return;
|
||||||
props: ChatProps,
|
|
||||||
) {
|
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;
|
const items = e.clipboardData?.items;
|
||||||
if (!items || !props.onAttachmentsChange) return;
|
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) {
|
function renderAttachmentPreview(props: ChatProps) {
|
||||||
const attachments = props.attachments ?? [];
|
const attachments = props.attachments ?? [];
|
||||||
if (attachments.length === 0) return nothing;
|
if (attachments.length === 0) return nothing;
|
||||||
@ -327,7 +374,12 @@ export function renderChat(props: ChatProps) {
|
|||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
<div class="chat-compose">
|
<div
|
||||||
|
class="chat-compose"
|
||||||
|
@dragover=${handleDragOver}
|
||||||
|
@dragleave=${handleDragLeave}
|
||||||
|
@drop=${(e: DragEvent) => handleDrop(e, props)}
|
||||||
|
>
|
||||||
${renderAttachmentPreview(props)}
|
${renderAttachmentPreview(props)}
|
||||||
<div class="chat-compose__row">
|
<div class="chat-compose__row">
|
||||||
<label class="field chat-compose__field">
|
<label class="field chat-compose__field">
|
||||||
@ -353,6 +405,22 @@ export function renderChat(props: ChatProps) {
|
|||||||
placeholder=${composePlaceholder}
|
placeholder=${composePlaceholder}
|
||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</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">
|
<div class="chat-compose__actions">
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user