cc-haha/desktop/src/components/chat/useComposerFileDrop.ts
程序员阿江(Relakkes) 390c18d82c Let desktop users attach files by dropping them into the composer
A user-requested drag target should reuse the existing attachment pipeline instead
of creating a second upload path. The composer now handles browser DataTransfer
drops and Tauri native drag-drop events, while keeping desktop attachments
path-only so large files are not serialized into chat payloads. The fallback
/goal metadata is also aligned with the existing desktop command surface because
the desktop gate exercises that menu while validating composer behavior.

Constraint: Desktop attachments must remain path-only to avoid inflated websocket payloads
Rejected: Read dropped desktop files with FileReader | would reintroduce large inline data payloads
Confidence: high
Scope-risk: moderate
Directive: Do not replace the Tauri drag-drop path conversion with data URLs without rerunning payload-size regression coverage
Tested: cd desktop && bun run test -- --run src/lib/composerAttachments.test.ts src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: bun run check:desktop
Tested: agent-browser overlay and dropped-chip smoke at http://127.0.0.1:1421/?serverUrl=http%3A%2F%2F127.0.0.1%3A3456
Tested: bun run verify
Not-tested: Finder-to-packaged-.app manual drag smoke
2026-05-16 13:18:59 +08:00

164 lines
4.5 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type DragEvent, type RefObject } from 'react'
import { isTauriRuntime } from '../../lib/desktopRuntime'
import {
dataTransferHasFiles,
dataTransferToComposerAttachments,
pathsToComposerAttachments,
type ComposerAttachment,
} from '../../lib/composerAttachments'
type TauriDropPosition = {
x: number
y: number
}
type TauriDragDropPayload =
| { type: 'enter'; paths: string[]; position: TauriDropPosition }
| { type: 'over'; position: TauriDropPosition }
| { type: 'drop'; paths: string[]; position: TauriDropPosition }
| { type: 'leave' }
| { type: 'cancel' }
type TauriDragDropEvent = {
payload: TauriDragDropPayload
}
type UseComposerFileDropOptions = {
disabled?: boolean
panelRef: RefObject<HTMLElement | null>
onAttachments: (attachments: ComposerAttachment[]) => void
onError?: (error: unknown) => void
}
function isPointInsideElement(element: HTMLElement | null, position: TauriDropPosition): boolean {
if (!element) return false
const rect = element.getBoundingClientRect()
return (
position.x >= rect.left &&
position.x <= rect.right &&
position.y >= rect.top &&
position.y <= rect.bottom
)
}
export function useComposerFileDrop({
disabled = false,
panelRef,
onAttachments,
onError,
}: UseComposerFileDropOptions) {
const [isDragActive, setIsDragActive] = useState(false)
const dragDepthRef = useRef(0)
const disabledRef = useRef(disabled)
const onAttachmentsRef = useRef(onAttachments)
const onErrorRef = useRef(onError)
useEffect(() => {
disabledRef.current = disabled
}, [disabled])
useEffect(() => {
onAttachmentsRef.current = onAttachments
}, [onAttachments])
useEffect(() => {
onErrorRef.current = onError
}, [onError])
useEffect(() => {
if (!isTauriRuntime()) return
let disposed = false
let unlisten: (() => void) | undefined
void import('@tauri-apps/api/webview')
.then(({ getCurrentWebview }) =>
getCurrentWebview().onDragDropEvent((event) => {
if (disposed) return
const payload = event.payload as TauriDragDropEvent['payload']
if (payload.type === 'cancel' || payload.type === 'leave') {
dragDepthRef.current = 0
setIsDragActive(false)
return
}
const isInside = isPointInsideElement(panelRef.current, payload.position)
if (payload.type === 'enter' || payload.type === 'over') {
setIsDragActive(!disabledRef.current && isInside)
return
}
dragDepthRef.current = 0
setIsDragActive(false)
if (disabledRef.current || !isInside) return
const attachments = pathsToComposerAttachments(payload.paths)
if (attachments.length > 0) onAttachmentsRef.current(attachments)
}),
)
.then((nextUnlisten) => {
if (disposed) {
nextUnlisten()
return
}
unlisten = nextUnlisten
})
.catch((error) => {
onErrorRef.current?.(error)
})
return () => {
disposed = true
unlisten?.()
}
}, [panelRef])
const onDragEnter = useCallback((event: DragEvent) => {
if (disabled || !dataTransferHasFiles(event.dataTransfer)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
dragDepthRef.current += 1
setIsDragActive(true)
}, [disabled])
const onDragOver = useCallback((event: DragEvent) => {
if (disabled || !dataTransferHasFiles(event.dataTransfer)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
setIsDragActive(true)
}, [disabled])
const onDragLeave = useCallback((event: DragEvent) => {
if (disabled || !dataTransferHasFiles(event.dataTransfer)) return
event.preventDefault()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setIsDragActive(false)
}, [disabled])
const onDrop = useCallback((event: DragEvent) => {
if (disabled || !dataTransferHasFiles(event.dataTransfer)) return
event.preventDefault()
dragDepthRef.current = 0
setIsDragActive(false)
void dataTransferToComposerAttachments(event.dataTransfer)
.then((attachments) => {
if (attachments.length > 0) onAttachments(attachments)
})
.catch((error) => {
onError?.(error)
})
}, [disabled, onAttachments, onError])
return {
isDragActive,
dragHandlers: {
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
},
}
}