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 {
|
.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);
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user