From dbf9bfa902e2e5216fbcd544a67b3ad01ba4c54a Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Mon, 17 Aug 2026 09:14:17 -0600 Subject: [PATCH] Support dragging files/images onto the composer to attach them (#32) Reuses the existing upload path unchanged: handleFileSelected's body is now handleFile(file), called from both the file-input's onChange and a new onDrop handler on the composer, so drag-and-drop and the "Attach a file" button share the exact same size-check/branch-on-content-type/ error-surfacing logic rather than duplicating it. Only the first dropped file, matching the existing single-attachment- per-message limit. A dashed-border overlay appears while dragging over the composer for discoverability; a nested dragenter/dragleave counter keeps it from flickering as the drag crosses child element boundaries. Co-Authored-By: Claude Sonnet 5 --- frontend/src/components/Composer.css | 17 ++++++++ frontend/src/components/Composer.tsx | 64 +++++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Composer.css b/frontend/src/components/Composer.css index 7bcef36..cff7804 100644 --- a/frontend/src/components/Composer.css +++ b/frontend/src/components/Composer.css @@ -1,4 +1,5 @@ .composer { + position: relative; padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--ds-border); background: var(--ds-surface); @@ -7,6 +8,22 @@ gap: 6px; } +.composer-drop-overlay { + position: absolute; + inset: 4px; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--ds-accent) 12%, var(--ds-surface) 88%); + border: 2px dashed var(--ds-accent); + border-radius: var(--radius); + color: var(--ds-accent); + font-size: 0.86rem; + font-weight: 700; + pointer-events: none; +} + .composer-box { display: flex; gap: var(--sp-2); diff --git a/frontend/src/components/Composer.tsx b/frontend/src/components/Composer.tsx index 2a8c263..09a4bde 100644 --- a/frontend/src/components/Composer.tsx +++ b/frontend/src/components/Composer.tsx @@ -4,6 +4,7 @@ import { useRef, useState, type ChangeEvent, + type DragEvent, type FormEvent, type KeyboardEvent, } from 'react' @@ -55,8 +56,14 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos const [maxUploadBytes, setMaxUploadBytes] = useState(null) const [mentionQuery, setMentionQuery] = useState(null) const [mentionActiveIndex, setMentionActiveIndex] = useState(0) + const [dragActive, setDragActive] = useState(false) const textareaRef = useRef(null) const fileInputRef = useRef(null) + // Counts nested dragenter/dragleave pairs (the overlay, the composer box, + // the textarea are all separate elements a drag passes over) so the + // highlight doesn't flicker off every time the pointer crosses a child + // element boundary -- only actually leaving the whole composer zeroes it. + const dragCounterRef = useRef(0) const online = useOnlineStatus() const mentionMatches = useMemo(() => { @@ -148,11 +155,7 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos setMentionActiveIndex(0) } - async function handleFileSelected(e: ChangeEvent) { - const file = e.target.files?.[0] - e.target.value = '' - if (!file) return - + async function handleFile(file: File) { setUploadError(null) if (maxUploadBytes !== null && file.size > maxUploadBytes) { @@ -179,6 +182,44 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos } } + function handleFileSelected(e: ChangeEvent) { + const file = e.target.files?.[0] + e.target.value = '' + if (file) handleFile(file) + } + + function handleDragEnter(e: DragEvent) { + e.preventDefault() + if (disabled) return + dragCounterRef.current++ + setDragActive(true) + } + + function handleDragLeave(e: DragEvent) { + e.preventDefault() + dragCounterRef.current = Math.max(0, dragCounterRef.current - 1) + if (dragCounterRef.current === 0) setDragActive(false) + } + + function handleDragOver(e: DragEvent) { + // Required even though it does nothing else -- without preventDefault() + // here, the browser rejects the element as a drop target entirely and + // handleDrop never fires (it just navigates to/opens the dropped file). + e.preventDefault() + } + + function handleDrop(e: DragEvent) { + e.preventDefault() + dragCounterRef.current = 0 + setDragActive(false) + if (disabled) return + // Only the first dropped file, matching the existing single-attachment- + // per-message limit (the button-triggered file input isn't `multiple` + // either). + const file = e.dataTransfer.files?.[0] + if (file) handleFile(file) + } + function removePendingImage() { setPendingImage((prev) => { if (prev) URL.revokeObjectURL(prev.previewUrl) @@ -205,7 +246,18 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos } return ( -
+
+ {dragActive && ( +
+ Drop to attach +
+ )} {pendingImage && (