| author | |
| committer | |
| log | a6ac27e7fd965f26ee0c61946e056f0fd889818c |
| tree | 590fc2966e18365536b6d144ce40ac0ff7bafc5d |
| parent | 1e87029261f9b81061a2a7420849b9eeaf1a2ebe |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 180 insertions(+), 43 deletions(-)
apps/web/src/components/ChatView.tsx+41-1| ... | @@ -141,6 +141,7 @@ import { closePreviewSession } from "./preview/closePreviewSession"; | ... | @@ -141,6 +141,7 @@ import { closePreviewSession } from "./preview/closePreviewSession"; |
| 141 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; | 141 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; |
| 142 | import { subscribePreviewAction } from "./preview/previewActionBus"; | 142 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 143 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; | 143 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; |
| 144 | import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; | ||
| 144 | import { | 145 | import { |
| 145 | selectThreadPreviewMiniPlayer, | 146 | selectThreadPreviewMiniPlayer, |
| 146 | usePreviewMiniPlayerStore, | 147 | usePreviewMiniPlayerStore, |
| ... | @@ -164,6 +165,7 @@ import { | ... | @@ -164,6 +165,7 @@ import { |
| 164 | CheckCircle2Icon, | 165 | CheckCircle2Icon, |
| 165 | ChevronDownIcon, | 166 | ChevronDownIcon, |
| 166 | GitBranchIcon, | 167 | GitBranchIcon, |
| 168 | PaperclipIcon, | ||
| 167 | WifiOffIcon, | 169 | WifiOffIcon, |
| 168 | } from "lucide-react"; | 170 | } from "lucide-react"; |
| 169 | import { cn, randomHex } from "~/lib/utils"; | 171 | import { cn, randomHex } from "~/lib/utils"; |
| ... | @@ -1336,6 +1338,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1336,6 +1338,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1336 | const composerElementContextsRef = useRef<ElementContextDraft[]>([]); | 1338 | const composerElementContextsRef = useRef<ElementContextDraft[]>([]); |
| 1337 | const localComposerRef = useRef<ChatComposerHandle | null>(null); | 1339 | const localComposerRef = useRef<ChatComposerHandle | null>(null); |
| 1338 | const composerRef = useComposerHandleContext() ?? localComposerRef; | 1340 | const composerRef = useComposerHandleContext() ?? localComposerRef; |
| 1341 | const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); | ||
| 1339 | const [showScrollToBottom, setShowScrollToBottom] = useState(false); | 1342 | const [showScrollToBottom, setShowScrollToBottom] = useState(false); |
| 1340 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); | 1343 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 1341 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); | 1344 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); |
| ... | @@ -1356,6 +1359,17 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1356,6 +1359,17 @@ function ChatViewContent(props: ChatViewProps) { |
| 1356 | const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< | 1359 | const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< |
| 1357 | ApprovalRequestId[] | 1360 | ApprovalRequestId[] |
| 1358 | >([]); | 1361 | >([]); |
| 1362 | |||
| 1363 | useEffect(() => { | ||
| 1364 | setIsWorkspaceFileDragActive(false); | ||
| 1365 | }, [draftId, routeThreadKey]); | ||
| 1366 | |||
| 1367 | useEffect(() => { | ||
| 1368 | if (!isWorkspaceFileDragActive) return; | ||
| 1369 | const clearWorkspaceFileDrag = () => setIsWorkspaceFileDragActive(false); | ||
| 1370 | window.addEventListener("dragend", clearWorkspaceFileDrag); | ||
| 1371 | return () => window.removeEventListener("dragend", clearWorkspaceFileDrag); | ||
| 1372 | }, [isWorkspaceFileDragActive]); | ||
| 1359 | const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< | 1373 | const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< |
| 1360 | Record<string, Record<string, PendingUserInputDraftAnswer>> | 1374 | Record<string, Record<string, PendingUserInputDraftAnswer>> |
| 1361 | >({}); | 1375 | >({}); |
| ... | @@ -6149,6 +6163,11 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -6149,6 +6163,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 6149 | ) : null | 6163 | ) : null |
| 6150 | ) : null; | 6164 | ) : null; |
| 6151 | 6165 | ||
| 6166 | const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ | ||
| 6167 | setDragActive: setIsWorkspaceFileDragActive, | ||
| 6168 | addFiles: (files) => composerRef.current?.addDroppedFiles(files), | ||
| 6169 | }); | ||
| 6170 | |||
| 6152 | return ( | 6171 | return ( |
| 6153 | <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background"> | 6172 | <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background"> |
| 6154 | {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null} | 6173 | {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null} |
| ... | @@ -6217,7 +6236,28 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -6217,7 +6236,28 @@ function ChatViewContent(props: ChatViewProps) { |
| 6217 | {/* Main content area with optional plan sidebar */} | 6236 | {/* Main content area with optional plan sidebar */} |
| 6218 | <div className="flex min-h-0 min-w-0 flex-1"> | 6237 | <div className="flex min-h-0 min-w-0 flex-1"> |
| 6219 | {/* Chat column */} | 6238 | {/* Chat column */} |
| 6220 | <div className="relative flex min-h-0 min-w-0 flex-1 flex-col"> | 6239 | <div |
| 6240 | className="relative flex min-h-0 min-w-0 flex-1 flex-col" | ||
| 6241 | data-chat-workspace-drop-target="true" | ||
| 6242 | onDragEnter={workspaceFileDropHandlers.onDragEnter} | ||
| 6243 | onDragOver={workspaceFileDropHandlers.onDragOver} | ||
| 6244 | onDragLeave={workspaceFileDropHandlers.onDragLeave} | ||
| 6245 | onDrop={workspaceFileDropHandlers.onDrop} | ||
| 6246 | > | ||
| 6247 | {isWorkspaceFileDragActive ? ( | ||
| 6248 | <div | ||
| 6249 | className="pointer-events-none absolute inset-2 z-40 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/60 bg-primary/[0.035]" | ||
| 6250 | data-chat-workspace-drop-overlay="true" | ||
| 6251 | > | ||
| 6252 | <div | ||
| 6253 | role="status" | ||
| 6254 | className="flex items-center gap-2 rounded-full border border-primary/25 bg-background/95 px-4 py-2.5 text-sm font-medium text-foreground shadow-lg" | ||
| 6255 | > | ||
| 6256 | <PaperclipIcon className="size-4 text-primary" aria-hidden="true" /> | ||
| 6257 | Drop files to attach | ||
| 6258 | </div> | ||
| 6259 | </div> | ||
| 6260 | ) : null} | ||
| 6221 | {/* Provider status overlays the timeline without changing its content height. */} | 6261 | {/* Provider status overlays the timeline without changing its content height. */} |
| 6222 | <div className="pointer-events-none absolute inset-x-0 top-0 z-20"> | 6262 | <div className="pointer-events-none absolute inset-x-0 top-0 z-20"> |
| 6223 | <ProviderStatusBanner | 6263 | <ProviderStatusBanner |
apps/web/src/components/chat/ChatComposer.tsx+7-42| ... | @@ -450,6 +450,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( | ... | @@ -450,6 +450,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 450 | export interface ChatComposerHandle { | 450 | export interface ChatComposerHandle { |
| 451 | focusAtEnd: () => void; | 451 | focusAtEnd: () => void; |
| 452 | focusAt: (cursor: number) => void; | 452 | focusAt: (cursor: number) => void; |
| 453 | addDroppedFiles: (files: File[]) => void; | ||
| 453 | insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; | 454 | insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; |
| 454 | openModelPicker: () => void; | 455 | openModelPicker: () => void; |
| 455 | toggleModelPicker: () => void; | 456 | toggleModelPicker: () => void; |
| ... | @@ -971,7 +972,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -971,7 +972,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 971 | const mobileComposerExpandFrameRef = useRef<number | null>(null); | 972 | const mobileComposerExpandFrameRef = useRef<number | null>(null); |
| 972 | const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null); | 973 | const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null); |
| 973 | const mobileComposerExpandInFlightRef = useRef(false); | 974 | const mobileComposerExpandInFlightRef = useRef(false); |
| 974 | const dragDepthRef = useRef(0); | ||
| 975 | const stashPulseKeyRef = useRef(0); | 975 | const stashPulseKeyRef = useRef(0); |
| 976 | const stashPulseTimeoutRef = useRef<number | null>(null); | 976 | const stashPulseTimeoutRef = useRef<number | null>(null); |
| 977 | /** | 977 | /** |
| ... | @@ -1399,7 +1399,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1399,7 +1399,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1399 | setComposerHighlightedItemId(null); | 1399 | setComposerHighlightedItemId(null); |
| 1400 | setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); | 1400 | setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); |
| 1401 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); | 1401 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); |
| 1402 | dragDepthRef.current = 0; | ||
| 1403 | setIsDragOverComposer(false); | 1402 | setIsDragOverComposer(false); |
| 1404 | }, [draftId, activeThreadId, promptRef]); | 1403 | }, [draftId, activeThreadId, promptRef]); |
| 1405 | 1404 | ||
| ... | @@ -2380,41 +2379,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2380,41 +2379,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2380 | void addComposerImages(imageFiles); | 2379 | void addComposerImages(imageFiles); |
| 2381 | }; | 2380 | }; |
| 2382 | 2381 | ||
| 2383 | const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => { | ||
| 2384 | if (!event.dataTransfer.types.includes("Files")) return; | ||
| 2385 | event.preventDefault(); | ||
| 2386 | dragDepthRef.current += 1; | ||
| 2387 | setIsDragOverComposer(true); | ||
| 2388 | }; | ||
| 2389 | |||
| 2390 | const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => { | ||
| 2391 | if (!event.dataTransfer.types.includes("Files")) return; | ||
| 2392 | event.preventDefault(); | ||
| 2393 | event.dataTransfer.dropEffect = "copy"; | ||
| 2394 | setIsDragOverComposer(true); | ||
| 2395 | }; | ||
| 2396 | |||
| 2397 | const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => { | ||
| 2398 | if (!event.dataTransfer.types.includes("Files")) return; | ||
| 2399 | event.preventDefault(); | ||
| 2400 | const nextTarget = event.relatedTarget; | ||
| 2401 | if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; | ||
| 2402 | dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); | ||
| 2403 | if (dragDepthRef.current === 0) { | ||
| 2404 | setIsDragOverComposer(false); | ||
| 2405 | } | ||
| 2406 | }; | ||
| 2407 | |||
| 2408 | const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => { | ||
| 2409 | if (!event.dataTransfer.types.includes("Files")) return; | ||
| 2410 | event.preventDefault(); | ||
| 2411 | dragDepthRef.current = 0; | ||
| 2412 | setIsDragOverComposer(false); | ||
| 2413 | const files = Array.from(event.dataTransfer.files); | ||
| 2414 | void addComposerImages(files); | ||
| 2415 | focusComposer(); | ||
| 2416 | }; | ||
| 2417 | |||
| 2418 | const insertComposerTextAtEnd = ( | 2382 | const insertComposerTextAtEnd = ( |
| 2419 | text: string, | 2383 | text: string, |
| 2420 | options?: { ensureLeadingBoundary?: boolean }, | 2384 | options?: { ensureLeadingBoundary?: boolean }, |
| ... | @@ -2468,7 +2432,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2468,7 +2432,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2468 | useEffect(() => { | 2432 | useEffect(() => { |
| 2469 | if (!isDragOverComposer) return; | 2433 | if (!isDragOverComposer) return; |
| 2470 | const onWindowDragEnd = () => { | 2434 | const onWindowDragEnd = () => { |
| 2471 | dragDepthRef.current = 0; | ||
| 2472 | setIsDragOverComposer(false); | 2435 | setIsDragOverComposer(false); |
| 2473 | }; | 2436 | }; |
| 2474 | window.addEventListener("dragend", onWindowDragEnd); | 2437 | window.addEventListener("dragend", onWindowDragEnd); |
| ... | @@ -2537,6 +2500,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2537,6 +2500,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2537 | focusAt: (cursor: number) => { | 2500 | focusAt: (cursor: number) => { |
| 2538 | composerEditorRef.current?.focusAt(cursor); | 2501 | composerEditorRef.current?.focusAt(cursor); |
| 2539 | }, | 2502 | }, |
| 2503 | addDroppedFiles: (files: File[]) => { | ||
| 2504 | void addComposerImages(files); | ||
| 2505 | focusComposer(); | ||
| 2506 | }, | ||
| 2540 | insertTextAtEnd: insertComposerTextAtEnd, | 2507 | insertTextAtEnd: insertComposerTextAtEnd, |
| 2541 | openModelPicker: () => { | 2508 | openModelPicker: () => { |
| 2542 | setIsComposerModelPickerOpen(true); | 2509 | setIsComposerModelPickerOpen(true); |
| ... | @@ -2619,6 +2586,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2619,6 +2586,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2619 | }), | 2586 | }), |
| 2620 | [ | 2587 | [ |
| 2621 | activeThread, | 2588 | activeThread, |
| 2589 | addComposerImages, | ||
| 2622 | composerDraftTarget, | 2590 | composerDraftTarget, |
| 2623 | composerCursor, | 2591 | composerCursor, |
| 2624 | composerTerminalContexts, | 2592 | composerTerminalContexts, |
| ... | @@ -2629,6 +2597,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2629,6 +2597,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2629 | composerElementContextsRef, | 2597 | composerElementContextsRef, |
| 2630 | composerPreviewAnnotations, | 2598 | composerPreviewAnnotations, |
| 2631 | composerReviewComments, | 2599 | composerReviewComments, |
| 2600 | focusComposer, | ||
| 2632 | isConnecting, | 2601 | isConnecting, |
| 2633 | isComposerApprovalState, | 2602 | isComposerApprovalState, |
| 2634 | pendingUserInputs.length, | 2603 | pendingUserInputs.length, |
| ... | @@ -2660,10 +2629,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2660,10 +2629,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2660 | "group rounded-[22px] p-px transition-colors duration-200", | 2629 | "group rounded-[22px] p-px transition-colors duration-200", |
| 2661 | composerProviderState.composerFrameClassName, | 2630 | composerProviderState.composerFrameClassName, |
| 2662 | )} | 2631 | )} |
| 2663 | onDragEnter={onComposerDragEnter} | ||
| 2664 | onDragOver={onComposerDragOver} | ||
| 2665 | onDragLeave={onComposerDragLeave} | ||
| 2666 | onDrop={onComposerDrop} | ||
| 2667 | onDragEnterCapture={composerMentionDragHandlers.onDragEnter} | 2632 | onDragEnterCapture={composerMentionDragHandlers.onDragEnter} |
| 2668 | onDragOverCapture={composerMentionDragHandlers.onDragOver} | 2633 | onDragOverCapture={composerMentionDragHandlers.onDragOver} |
| 2669 | onDragLeaveCapture={onComposerMentionDragLeaveCapture} | 2634 | onDragLeaveCapture={onComposerMentionDragLeaveCapture} |
apps/web/src/components/chat/workspaceFileDrop.test.ts created+78| ... | @@ -0,0 +1,78 @@ | ||
| 1 | import { describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | makeWorkspaceFileDropHandlers, | ||
| 4 | type WorkspaceFileDragEvent, | ||
| 5 | type WorkspaceFileDropHost, | ||
| 6 | } from "./workspaceFileDrop"; | ||
| 7 | |||
| 8 | function makeDragEvent(options?: { | ||
| 9 | types?: string[]; | ||
| 10 | files?: File[]; | ||
| 11 | movedWithinTarget?: boolean; | ||
| 12 | }) { | ||
| 13 | const preventDefault = vi.fn(); | ||
| 14 | const event = { | ||
| 15 | dataTransfer: { | ||
| 16 | types: options?.types ?? ["Files"], | ||
| 17 | files: options?.files ?? [], | ||
| 18 | dropEffect: "none", | ||
| 19 | }, | ||
| 20 | relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null, | ||
| 21 | currentTarget: { | ||
| 22 | contains: () => options?.movedWithinTarget ?? false, | ||
| 23 | }, | ||
| 24 | preventDefault, | ||
| 25 | } satisfies WorkspaceFileDragEvent; | ||
| 26 | return { event, preventDefault }; | ||
| 27 | } | ||
| 28 | |||
| 29 | function makeHost() { | ||
| 30 | const setDragActive = vi.fn(); | ||
| 31 | const addFiles = vi.fn(); | ||
| 32 | const host = { setDragActive, addFiles } satisfies WorkspaceFileDropHost; | ||
| 33 | return { host, setDragActive, addFiles }; | ||
| 34 | } | ||
| 35 | |||
| 36 | describe("makeWorkspaceFileDropHandlers", () => { | ||
| 37 | it("activates the target for an external file drag", () => { | ||
| 38 | const { host, setDragActive } = makeHost(); | ||
| 39 | const { event, preventDefault } = makeDragEvent(); | ||
| 40 | |||
| 41 | makeWorkspaceFileDropHandlers(host).onDragEnter(event); | ||
| 42 | |||
| 43 | expect(preventDefault).toHaveBeenCalledOnce(); | ||
| 44 | expect(setDragActive).toHaveBeenCalledWith(true); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("ignores non-file drags", () => { | ||
| 48 | const { host, setDragActive } = makeHost(); | ||
| 49 | const { event, preventDefault } = makeDragEvent({ types: ["text/plain"] }); | ||
| 50 | |||
| 51 | makeWorkspaceFileDropHandlers(host).onDragOver(event); | ||
| 52 | |||
| 53 | expect(preventDefault).not.toHaveBeenCalled(); | ||
| 54 | expect(setDragActive).not.toHaveBeenCalled(); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("does not flicker when the drag moves between children", () => { | ||
| 58 | const { host, setDragActive } = makeHost(); | ||
| 59 | const { event } = makeDragEvent({ movedWithinTarget: true }); | ||
| 60 | |||
| 61 | const handlers = makeWorkspaceFileDropHandlers(host); | ||
| 62 | handlers.onDragEnter(event); | ||
| 63 | handlers.onDragLeave(event); | ||
| 64 | |||
| 65 | expect(setDragActive).not.toHaveBeenCalled(); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("forwards dropped files and clears the active state", () => { | ||
| 69 | const file = new File(["contents"], "example.txt", { type: "text/plain" }); | ||
| 70 | const { host, setDragActive, addFiles } = makeHost(); | ||
| 71 | const { event } = makeDragEvent({ files: [file] }); | ||
| 72 | |||
| 73 | makeWorkspaceFileDropHandlers(host).onDrop(event); | ||
| 74 | |||
| 75 | expect(setDragActive).toHaveBeenCalledWith(false); | ||
| 76 | expect(addFiles).toHaveBeenCalledWith([file]); | ||
| 77 | }); | ||
| 78 | }); | ||
apps/web/src/components/chat/workspaceFileDrop.ts created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | export interface WorkspaceFileDragEvent { | ||
| 2 | readonly dataTransfer: { | ||
| 3 | readonly types: ReadonlyArray<string>; | ||
| 4 | readonly files: Iterable<File>; | ||
| 5 | dropEffect: string; | ||
| 6 | }; | ||
| 7 | readonly relatedTarget: EventTarget | null; | ||
| 8 | readonly currentTarget: { | ||
| 9 | contains(target: Node | null): boolean; | ||
| 10 | }; | ||
| 11 | preventDefault(): void; | ||
| 12 | } | ||
| 13 | |||
| 14 | export interface WorkspaceFileDropHost { | ||
| 15 | setDragActive(active: boolean): void; | ||
| 16 | addFiles(files: File[]): void; | ||
| 17 | } | ||
| 18 | |||
| 19 | function isFileDrag(event: WorkspaceFileDragEvent): boolean { | ||
| 20 | return event.dataTransfer.types.includes("Files"); | ||
| 21 | } | ||
| 22 | |||
| 23 | function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean { | ||
| 24 | return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node); | ||
| 25 | } | ||
| 26 | |||
| 27 | export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { | ||
| 28 | return { | ||
| 29 | onDragEnter(event: WorkspaceFileDragEvent) { | ||
| 30 | if (!isFileDrag(event)) return; | ||
| 31 | event.preventDefault(); | ||
| 32 | if (movedWithinDropTarget(event)) return; | ||
| 33 | host.setDragActive(true); | ||
| 34 | }, | ||
| 35 | onDragOver(event: WorkspaceFileDragEvent) { | ||
| 36 | if (!isFileDrag(event)) return; | ||
| 37 | event.preventDefault(); | ||
| 38 | event.dataTransfer.dropEffect = "copy"; | ||
| 39 | host.setDragActive(true); | ||
| 40 | }, | ||
| 41 | onDragLeave(event: WorkspaceFileDragEvent) { | ||
| 42 | if (!isFileDrag(event)) return; | ||
| 43 | event.preventDefault(); | ||
| 44 | if (movedWithinDropTarget(event)) return; | ||
| 45 | host.setDragActive(false); | ||
| 46 | }, | ||
| 47 | onDrop(event: WorkspaceFileDragEvent) { | ||
| 48 | if (!isFileDrag(event)) return; | ||
| 49 | event.preventDefault(); | ||
| 50 | host.setDragActive(false); | ||
| 51 | host.addFiles(Array.from(event.dataTransfer.files)); | ||
| 52 | }, | ||
| 53 | }; | ||
| 54 | } | ||