Private
Public Access
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:
@@ -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);
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
const [mentionQuery, setMentionQuery] = useState<MentionQuery | null>(null)
|
||||
const [mentionActiveIndex, setMentionActiveIndex] = useState(0)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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 mentionMatches = useMemo(() => {
|
||||
@@ -148,11 +155,7 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos
|
||||
setMentionActiveIndex(0)
|
||||
}
|
||||
|
||||
async function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
|
||||
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<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() {
|
||||
setPendingImage((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev.previewUrl)
|
||||
@@ -205,7 +246,18 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos
|
||||
}
|
||||
|
||||
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 && (
|
||||
<div className="composer-attachment">
|
||||
<img src={pendingImage.previewUrl} alt="" className="composer-attachment-thumb" />
|
||||
|
||||
Reference in New Issue
Block a user