UI: add webchat image attachments
This commit is contained in:
parent
6af205a13a
commit
2447a2360f
@ -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;
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user