From 007a7f7f1cb7dc6117171b45a6a1f0c67c9c9be7 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Wed, 24 Jun 2026 22:18:54 +0800 Subject: [PATCH] fix(platform): clear the full-page drop overlay after a composer drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping a file onto the chat composer left the full-page "Drop files here" overlay (PageDropOverlay / usePageFileDrop) stuck until a manual page reload. The composer's FileUpload.DropZone calls stopPropagation() on its drop so the file lands there — which also stopped the window's bubble-phase drop handler from running, so isDragOver/dragDepth never reset. Reset the overlay state in the CAPTURE phase, which runs window->target BEFORE any child's stopPropagation, so it always fires. The bubble handler still owns the upload for drops that don't hit a region zone (no double-upload). Adds a regression test. --- .../app/hooks/use-page-file-drop.test.ts | 30 +++++++++++++++++++ .../platform/app/hooks/use-page-file-drop.ts | 20 +++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/services/platform/app/hooks/use-page-file-drop.test.ts b/services/platform/app/hooks/use-page-file-drop.test.ts index 5419ff62c..44525e85b 100644 --- a/services/platform/app/hooks/use-page-file-drop.test.ts +++ b/services/platform/app/hooks/use-page-file-drop.test.ts @@ -28,6 +28,36 @@ describe('usePageFileDrop', () => { expect(onFilesDropped.mock.calls[0][0]).toEqual([file]); }); + it('clears the overlay even when a region drop zone stops propagation', () => { + // Regression: dropping ONTO the chat composer (a FileUpload.DropZone that + // calls stopPropagation on drop) stopped the window bubble handler from + // running, so isDragOver never reset and the full-page overlay stuck until + // reload. The capture-phase reset must still clear it. + const onFilesDropped = vi.fn(); + const { result } = renderHook(() => usePageFileDrop({ onFilesDropped })); + + const region = document.createElement('div'); + document.body.appendChild(region); + region.addEventListener('drop', (e) => e.stopPropagation()); + + act(() => { + fireEvent.dragEnter(window, { dataTransfer: fileTransfer([]) }); + }); + expect(result.current.isDragOver).toBe(true); + + const file = new File(['hi'], 'note.txt', { type: 'text/plain' }); + act(() => { + fireEvent.drop(region, { dataTransfer: fileTransfer([file]) }); + }); + + // Overlay cleared despite the region zone swallowing the bubble... + expect(result.current.isDragOver).toBe(false); + // ...and the window handler did NOT also upload (the region zone owns it). + expect(onFilesDropped).not.toHaveBeenCalled(); + + document.body.removeChild(region); + }); + it('ignores drags that carry no files (text/links/internal dnd)', () => { const onFilesDropped = vi.fn(); const { result } = renderHook(() => usePageFileDrop({ onFilesDropped })); diff --git a/services/platform/app/hooks/use-page-file-drop.ts b/services/platform/app/hooks/use-page-file-drop.ts index f1dbc5a54..edc5ff752 100644 --- a/services/platform/app/hooks/use-page-file-drop.ts +++ b/services/platform/app/hooks/use-page-file-drop.ts @@ -24,7 +24,9 @@ interface UsePageFileDropOptions { * cursor crosses child elements. * - A region-scoped `FileUpload.DropZone` that calls `stopPropagation()` on a * drop (e.g. the chat composer) still wins for drops landing on it; only - * drops elsewhere bubble to this window handler. + * drops elsewhere bubble to this window handler. The overlay reset runs in + * the CAPTURE phase so it still clears on those region-zone drops (a missing + * reset there left the full-page overlay stuck until reload). * - Prevents the browser's default "navigate to the dropped file" while * mounted, so a stray drop never blows away the app. */ @@ -71,23 +73,35 @@ export function usePageFileDrop(options: UsePageFileDropOptions): { const onDrop = (e: DragEvent) => { if (!carriesFiles(e)) return; e.preventDefault(); - dragDepth.current = 0; - setIsDragOver(false); const all = Array.from(e.dataTransfer?.files ?? []); const accept = acceptRef.current; const files = accept ? all.filter(accept) : all; if (files.length > 0) onFilesDroppedRef.current(files); }; + // Overlay reset runs in the CAPTURE phase: a drop ALWAYS ends the drag, so + // the overlay must clear even when a region-scoped DropZone (the chat + // composer) calls stopPropagation() on its drop — which would otherwise + // stop the bubble-phase `onDrop` above from ever running, leaving the + // overlay stuck until a page reload. Capture runs window->target, BEFORE + // the target's stopPropagation, so it always fires. Reset only here (no + // file handling) so a region-zone drop never double-uploads: the bubble + // `onDrop` still owns the upload for drops that DON'T hit a region zone. + const onDropResetCapture = () => { + dragDepth.current = 0; + setIsDragOver(false); + }; window.addEventListener('dragenter', onDragEnter); window.addEventListener('dragover', onDragOver); window.addEventListener('dragleave', onDragLeave); window.addEventListener('drop', onDrop); + window.addEventListener('drop', onDropResetCapture, true); return () => { window.removeEventListener('dragenter', onDragEnter); window.removeEventListener('dragover', onDragOver); window.removeEventListener('dragleave', onDragLeave); window.removeEventListener('drop', onDrop); + window.removeEventListener('drop', onDropResetCapture, true); }; }, [disabled]);