UI: add webchat image attachments

This commit is contained in:
Manoj Naik 2026-01-30 20:00:57 +05:30
parent 6af205a13a
commit 2447a2360f
2 changed files with 106 additions and 21 deletions

View File

@ -111,6 +111,12 @@
z-index: 10; z-index: 10;
} }
.chat-compose--dragging {
outline: 2px dashed var(--accent);
outline-offset: 6px;
border-radius: 12px;
}
/* Image attachments preview */ /* Image attachments preview */
.chat-attachments { .chat-attachments {
display: inline-flex; display: inline-flex;
@ -270,6 +276,10 @@
gap: 8px; gap: 8px;
} }
.chat-compose__file-input {
display: none;
}
.chat-compose .chat-compose__actions .btn { .chat-compose .chat-compose__actions .btn {
padding: 0 16px; padding: 0 16px;
font-size: 13px; font-size: 13px;

View File

@ -71,6 +71,7 @@ export type ChatProps = {
}; };
const COMPACTION_TOAST_DURATION_MS = 5000; const COMPACTION_TOAST_DURATION_MS = 5000;
const MAX_ATTACHMENT_BYTES = 5_000_000;
function adjustTextareaHeight(el: HTMLTextAreaElement) { function adjustTextareaHeight(el: HTMLTextAreaElement) {
el.style.height = "auto"; el.style.height = "auto";
@ -108,10 +109,47 @@ 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 readFileAsDataUrl(file: File): Promise<string | null> {
e: ClipboardEvent, return new Promise((resolve) => {
props: ChatProps, const reader = new FileReader();
) { reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null);
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
}
function isValidImageFile(file: File): boolean {
if (!file.type.startsWith("image/")) return false;
if (file.size <= 0 || file.size > MAX_ATTACHMENT_BYTES) return false;
return true;
}
function addFilesToAttachments(files: File[], props: ChatProps) {
if (!props.onAttachmentsChange || !props.connected) return;
const filtered = files.filter(isValidImageFile);
if (filtered.length === 0) return;
void (async () => {
const dataUrls = await Promise.all(filtered.map(readFileAsDataUrl));
const newAttachments = dataUrls
.map((dataUrl, idx) => {
if (!dataUrl) return null;
const file = filtered[idx];
return {
id: generateAttachmentId(),
dataUrl,
mimeType: file.type,
} satisfies ChatAttachment;
})
.filter((item): item is ChatAttachment => item !== null);
if (newAttachments.length === 0) return;
const current = props.attachments ?? [];
props.onAttachmentsChange?.([...current, ...newAttachments]);
})();
}
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;
@ -127,23 +165,28 @@ function handlePaste(
e.preventDefault(); e.preventDefault();
for (const item of imageItems) { const files = imageItems
const file = item.getAsFile(); .map((item) => item.getAsFile())
if (!file) continue; .filter((file): file is File => Boolean(file));
addFilesToAttachments(files, props);
}
const reader = new FileReader(); function handleDragHover(e: DragEvent, active: boolean) {
reader.onload = () => { e.preventDefault();
const dataUrl = reader.result as string; const target = e.currentTarget;
const newAttachment: ChatAttachment = { if (!(target instanceof HTMLElement)) return;
id: generateAttachmentId(), target.classList.toggle("chat-compose--dragging", active);
dataUrl, }
mimeType: file.type,
}; function handleDrop(e: DragEvent, props: ChatProps) {
const current = props.attachments ?? []; e.preventDefault();
props.onAttachmentsChange?.([...current, newAttachment]); const target = e.currentTarget;
}; if (target instanceof HTMLElement) {
reader.readAsDataURL(file); target.classList.remove("chat-compose--dragging");
} }
const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
if (files.length === 0) return;
addFilesToAttachments(files, props);
} }
function renderAttachmentPreview(props: ChatProps) { function renderAttachmentPreview(props: ChatProps) {
@ -181,6 +224,7 @@ function renderAttachmentPreview(props: ChatProps) {
} }
export function renderChat(props: ChatProps) { export function renderChat(props: ChatProps) {
let fileInput: HTMLInputElement | null = null;
const canCompose = props.connected; const canCompose = props.connected;
const isBusy = props.sending || props.stream !== null; const isBusy = props.sending || props.stream !== null;
const canAbort = Boolean(props.canAbort && props.onAbort); const canAbort = Boolean(props.canAbort && props.onAbort);
@ -198,7 +242,7 @@ export function renderChat(props: ChatProps) {
const composePlaceholder = props.connected const composePlaceholder = props.connected
? hasAttachments ? hasAttachments
? "Add a message or paste more images..." ? "Add a message or paste more images..."
: "Message (↩ to send, Shift+↩ for line breaks, paste images)" : "Message (↩ to send, Shift+↩ for line breaks, paste or drop images)"
: "Connect to the gateway to start chatting…"; : "Connect to the gateway to start chatting…";
const splitRatio = props.splitRatio ?? 0.6; const splitRatio = props.splitRatio ?? 0.6;
@ -327,7 +371,13 @@ export function renderChat(props: ChatProps) {
` `
: nothing} : nothing}
<div class="chat-compose"> <div
class="chat-compose"
@dragenter=${(e: DragEvent) => handleDragHover(e, true)}
@dragover=${(e: DragEvent) => handleDragHover(e, true)}
@dragleave=${(e: DragEvent) => handleDragHover(e, false)}
@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">
@ -354,6 +404,31 @@ export function renderChat(props: ChatProps) {
></textarea> ></textarea>
</label> </label>
<div class="chat-compose__actions"> <div class="chat-compose__actions">
<button
class="btn btn--icon"
type="button"
?disabled=${!props.connected}
aria-label="Add image"
title="Add image"
@click=${() => fileInput?.click()}
>
${icons.paperclip}
</button>
<input
class="chat-compose__file-input"
type="file"
accept="image/*"
multiple
${ref((el) => {
fileInput = el as HTMLInputElement | null;
})}
@change=${(e: Event) => {
const target = e.target as HTMLInputElement;
const files = target.files ? Array.from(target.files) : [];
if (files.length > 0) addFilesToAttachments(files, props);
target.value = "";
}}
/>
<button <button
class="btn" class="btn"
?disabled=${!props.connected || (!canAbort && props.sending)} ?disabled=${!props.connected || (!canAbort && props.sending)}