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 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:14:17 -06:00
co-authored by Claude Sonnet 5
parent 4bac502b2c
commit dbf9bfa902
2 changed files with 75 additions and 6 deletions
+17
View File
@@ -1,4 +1,5 @@
.composer { .composer {
position: relative;
padding: var(--sp-3) var(--sp-4); padding: var(--sp-3) var(--sp-4);
border-top: 1px solid var(--ds-border); border-top: 1px solid var(--ds-border);
background: var(--ds-surface); background: var(--ds-surface);
@@ -7,6 +8,22 @@
gap: 6px; 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 { .composer-box {
display: flex; display: flex;
gap: var(--sp-2); gap: var(--sp-2);
+58 -6
View File
@@ -4,6 +4,7 @@ import {
useRef, useRef,
useState, useState,
type ChangeEvent, type ChangeEvent,
type DragEvent,
type FormEvent, type FormEvent,
type KeyboardEvent, type KeyboardEvent,
} from 'react' } from 'react'
@@ -55,8 +56,14 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos
const [maxUploadBytes, setMaxUploadBytes] = useState<number | null>(null) const [maxUploadBytes, setMaxUploadBytes] = useState<number | null>(null)
const [mentionQuery, setMentionQuery] = useState<MentionQuery | null>(null) const [mentionQuery, setMentionQuery] = useState<MentionQuery | null>(null)
const [mentionActiveIndex, setMentionActiveIndex] = useState(0) const [mentionActiveIndex, setMentionActiveIndex] = useState(0)
const [dragActive, setDragActive] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(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 online = useOnlineStatus()
const mentionMatches = useMemo(() => { const mentionMatches = useMemo(() => {
@@ -148,11 +155,7 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos
setMentionActiveIndex(0) setMentionActiveIndex(0)
} }
async function handleFileSelected(e: ChangeEvent<HTMLInputElement>) { async function handleFile(file: File) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
setUploadError(null) setUploadError(null)
if (maxUploadBytes !== null && file.size > maxUploadBytes) { if (maxUploadBytes !== null && file.size > maxUploadBytes) {
@@ -179,6 +182,44 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos
} }
} }
function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (file) handleFile(file)
}
function handleDragEnter(e: DragEvent<HTMLDivElement>) {
e.preventDefault()
if (disabled) return
dragCounterRef.current++
setDragActive(true)
}
function handleDragLeave(e: DragEvent<HTMLDivElement>) {
e.preventDefault()
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1)
if (dragCounterRef.current === 0) setDragActive(false)
}
function handleDragOver(e: DragEvent<HTMLDivElement>) {
// 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<HTMLDivElement>) {
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() { function removePendingImage() {
setPendingImage((prev) => { setPendingImage((prev) => {
if (prev) URL.revokeObjectURL(prev.previewUrl) if (prev) URL.revokeObjectURL(prev.previewUrl)
@@ -205,7 +246,18 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos
} }
return ( return (
<div className="composer"> <div
className="composer"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{dragActive && (
<div className="composer-drop-overlay">
<span>Drop to attach</span>
</div>
)}
{pendingImage && ( {pendingImage && (
<div className="composer-attachment"> <div className="composer-attachment">
<img src={pendingImage.previewUrl} alt="" className="composer-attachment-thumb" /> <img src={pendingImage.previewUrl} alt="" className="composer-attachment-thumb" />