Skip to content

Commit 0410980

Browse files
alicodingclaude
andcommitted
fix: pasting a file path lands what dropping it would, and covered-board gestures stand down behind modals
Two more findings from the same felt-surface sweep as #523, both live-reproduced and e2e-pinned: - Paste/drop parity: pasting a local file path (text) landed a sticky note containing the raw path string, while dropping the same file landed the real thing. The drop hook's landing half is now exposed (landFiles) and the board paste door routes single-line absolute paths (quoted and file:// forms normalized) through it -- identical routing, plugin claims, extension enablement, folder-import handoff, and card fallback. A path that doesn't resolve on disk still falls back to the note, never a dead end. Generalizes #523's image-path paste to every file kind the drop door knows. - Modal stand-down (the goal-0183 gesture-leak class, third strike): the board's window-level paste door stayed live while a card page covered the board -- pasting with focus on no field landed a note INVISIBLY behind the dialog (reproduced live). New shared/modalGate helper; the atlas paste/clone-clipboard doors, the composition canvas clipboard doors, and the board's Delete/Backspace handler (extracted to useAtlasDeleteKey at the 500-line seam) all stand down while a role=dialog/alertdialog surface is open. Anchored popovers render role=none and deliberately stay unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq
1 parent 7bdb1b6 commit 0410980

12 files changed

Lines changed: 310 additions & 57 deletions

frontend/e2e/atlas-paste-convert.spec.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import fs from 'node:fs'
2+
import os from 'node:os'
3+
import path from 'node:path'
14
import { test, expect } from './fixtures/server'
25
import { deleteViaPageMenu } from './fixtures/atlasPage'
3-
import { openCard } from './fixtures/atlasBoard'
6+
import { closeCard, deleteSticky, openCard } from './fixtures/atlasBoard'
47
import { clickRowAction } from './inventoryRow'
58
import { contextMenu } from './fixtures/contextMenu'
69

@@ -54,6 +57,19 @@ async function pasteHTML(page: import('@playwright/test').Page, html: string, pl
5457
}, { html, text: plainTextSibling })
5558
}
5659

60+
// pastePlainText injects text/plain VERBATIM (unlike pasteText below,
61+
// which URI-encodes to mimic the diagram tool's own copy format) --
62+
// the shape a copied file path or ordinary prose actually has.
63+
async function pastePlainText(page: import('@playwright/test').Page, raw: string) {
64+
// eslint-disable-next-line no-restricted-syntax -- cursor-position-only gesture, not a checkable interaction (pasteText's own comment below has the full reasoning)
65+
await page.mouse.move(1000, 220)
66+
await page.evaluate((t) => {
67+
const dt = new DataTransfer()
68+
dt.setData('text/plain', t)
69+
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }))
70+
}, raw)
71+
}
72+
5773
async function pasteText(page: import('@playwright/test').Page, raw: string) {
5874
// The paste anchors at the pointer (a paste inside a frame files
5975
// into it, by design) -- aim at open canvas so the entities land at
@@ -186,3 +202,90 @@ test('a table copied from an M365 app (HTML clipboard flavor) lands a board-loca
186202
await clickRowAction(page, listRow, 'Delete')
187203
await expect(listRow).toHaveCount(0)
188204
})
205+
206+
// Regression: pasting a local file PATH (text) landed a sticky note
207+
// containing the raw path string, while DROPPING the same file landed
208+
// the real thing (goal 0179's founding rule). A pasted path now routes
209+
// through the drop door's own landing pipeline: .md becomes a mirrored
210+
// document card, .drawio a diagram board object -- and a path that
211+
// doesn't resolve on disk still falls back to the note, never a dead
212+
// end.
213+
test('pasting a file path lands what dropping the file would; a dead path still falls back to a note', async ({ page }) => {
214+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mill-e2e-paste-path-'))
215+
const mdPath = path.join(dir, 'ZzE2ePastedDocPath.md')
216+
fs.writeFileSync(mdPath, '# Pasted doc\n\nbody\n')
217+
const drawioPath = path.join(dir, 'ZzE2ePastedDiagramPath.drawio')
218+
fs.writeFileSync(drawioPath, '<mxfile><diagram name="P">'
219+
+ '<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>'
220+
+ '<mxCell id="2" value="Box" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40"/></mxCell>'
221+
+ '</root></mxGraphModel></diagram></mxfile>')
222+
try {
223+
await page.goto('/')
224+
await page.getByRole('link', { name: 'Atlas' }).click()
225+
await expect(page.getByTestId('atlas-board')).toBeVisible()
226+
227+
// .md path -> mirrored document card, exactly like dropping the file.
228+
await pastePlainText(page, mdPath)
229+
const card = page.getByTestId('atlas-note-card').filter({ hasText: 'ZzE2ePastedDocPath' })
230+
await expect(card).toBeVisible()
231+
await expect(page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2ePastedDocPath' })).toHaveCount(0)
232+
233+
// .drawio path -> diagram board object, never a card or note.
234+
await pastePlainText(page, drawioPath)
235+
const diagram = page.locator('[data-testid="atlas-board-object"][data-object-kind="diagram"]')
236+
await expect(diagram).toHaveCount(1)
237+
await expect(page.getByTestId('atlas-note-card').filter({ hasText: 'ZzE2ePastedDiagramPath' })).toHaveCount(0)
238+
239+
// A path-shaped string that doesn't exist stays ordinary text: the
240+
// note fallback, so nothing a user pastes ever vanishes.
241+
const deadPath = path.join(dir, 'ZzE2eDeadPath.md')
242+
await pastePlainText(page, deadPath)
243+
const note = page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2eDeadPath' })
244+
await expect(note).toBeVisible()
245+
246+
// Cleanup (shared pool): note, diagram object, card.
247+
await deleteSticky(page, note)
248+
await deleteObjectViaMenu(diagram)
249+
await expect(diagram).toHaveCount(0)
250+
await card.click({ button: 'right' })
251+
const menu = contextMenu(page)
252+
await expect(menu).toBeVisible()
253+
await menu.getByText('Delete', { exact: true }).click()
254+
await expect(card).toHaveCount(0)
255+
} finally {
256+
fs.rmSync(dir, { recursive: true, force: true })
257+
}
258+
})
259+
260+
// Regression: the board's window-level paste door stayed live while a
261+
// card page (a modal dialog) covered the board -- pasting with focus
262+
// on no field landed a sticky note INVISIBLY behind the dialog. The
263+
// door now stands down while a modal surface is open, and comes back
264+
// the moment it closes.
265+
test('pasting while a card page is open lands nothing behind it; the door returns on close', async ({ page }) => {
266+
await page.goto('/')
267+
await page.getByRole('link', { name: 'Atlas' }).click()
268+
await expect(page.getByTestId('atlas-board')).toBeVisible()
269+
270+
const card = page.getByTestId('atlas-note-card').filter({ hasText: 'Discovery workstream' }).first()
271+
await openCard(page, card)
272+
const overlay = page.locator('[data-component="atlas-card-overlay"]')
273+
await expect(overlay).toBeVisible()
274+
// Land focus on nothing editable: click the page's own header region
275+
// (the real state a user reaches by clicking any non-field area).
276+
await page.getByTestId('atlas-page-header').click()
277+
278+
await pastePlainText(page, 'ZzE2eModalGateProbe')
279+
// No observable "nothing happened" signal exists to await -- a fixed
280+
// settle window is the only way to assert the note did NOT land.
281+
await page.waitForTimeout(800) // asserting a no-op: no observable condition exists to await
282+
await expect(page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2eModalGateProbe' })).toHaveCount(0)
283+
284+
await closeCard(page, overlay)
285+
286+
// The same paste with the board foreground again lands its note.
287+
await pastePlainText(page, 'ZzE2eModalGateProbe')
288+
const note = page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2eModalGateProbe' })
289+
await expect(note).toBeVisible()
290+
await deleteSticky(page, note)
291+
})

frontend/src/atlas/AtlasBoard.tsx

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from 'react'
22
import { useRenderStormGuard } from '../shared/renderStormGuard'
3+
import { useAtlasDeleteKey } from './useAtlasDeleteKey'
34
import { useTranslation } from 'react-i18next'
45
import { ReactFlow, ReactFlowProvider, useNodesState, useReactFlow } from '@xyflow/react'
56
import '@xyflow/react/dist/style.css'
@@ -126,24 +127,7 @@ function AtlasBoardInner({ boardFilter, onBoardFilterChange, filterMatchCount, f
126127
const selection = useAtlasSelection({ cards, notes, objects, onMultiSelectContextMenu })
127128
const wrapperClicks = useAtlasPaneClick({ tablePicker, topLevelBoxes, screenToFlowPosition, onCreateTableSized, placeAt: creation.placeAt })
128129

129-
// Delete/Backspace over a live selection -> the shared confirm
130-
// (never fires from editable elements; single or multi).
131-
useEffect(() => {
132-
const onKeyDown = (e: KeyboardEvent) => {
133-
if (e.key !== 'Delete' && e.key !== 'Backspace') return
134-
const el = document.activeElement
135-
if (el instanceof HTMLElement && (el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
136-
const sel = selection.selectedIDsRef.current
137-
if (sel.length === 0) return
138-
e.preventDefault()
139-
const cardIDs = sel.filter((id) => cards.some((c) => c.ID === id))
140-
const noteIDs = sel.filter((id) => notes.some((n) => n.ID === id))
141-
const objectIDs = sel.filter((id) => objects.some((o) => o.ID === id))
142-
if (cardIDs.length + noteIDs.length + objectIDs.length > 0) onDeleteSelection(cardIDs, noteIDs, objectIDs)
143-
}
144-
window.addEventListener('keydown', onKeyDown)
145-
return () => window.removeEventListener('keydown', onKeyDown)
146-
}, [cards, notes, objects, onDeleteSelection, selection.selectedIDsRef])
130+
useAtlasDeleteKey({ cards, notes, objects, selectedIDsRef: selection.selectedIDsRef, onDeleteSelection })
147131

148132
// Zoom chip / group-header click / Enter on a region frame (routed
149133
// here through AtlasGroupNode's own data.onDrill) all fly the camera
@@ -183,7 +167,7 @@ function AtlasBoardInner({ boardFilter, onBoardFilterChange, filterMatchCount, f
183167

184168
// The capture doors (goal 0081 slice A3): own hook files, 500-line cap.
185169
const fileDrop = useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPosition, setPulsedID, reduceMotion })
186-
useAtlasPaste({ topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated: selection.selectNote })
170+
useAtlasPaste({ topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated: selection.selectNote, landFiles: fileDrop.landFiles })
187171
useAtlasClipboard({ allCards, allNotes, links, kinds, selectedCardIDs: selection.selectedCards, selectedNoteIDs: selection.selectedNotes, topLevelBoxes, screenToFlowPosition, viewedID, readOnly, showToast: onQuietToast })
188172

189173
// Handle honesty: no kind restricts linking, so zero legal targets means a board with nothing else on it.

frontend/src/atlas/atlasCreateHelpers.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { imagePathFromClipboardText, normalizeLocalPathInput, resolveDefaultKindID, resolveNoteCommitText, titleFromFilename, titleFromNoteText } from './atlasCreateHelpers'
2+
import { imagePathFromClipboardText, localPathFromPastedText, normalizeLocalPathInput, resolveDefaultKindID, resolveNoteCommitText, titleFromFilename, titleFromNoteText } from './atlasCreateHelpers'
33
import type { Kind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
44

55
function kind(id: string): Kind {
@@ -167,3 +167,43 @@ describe('imagePathFromClipboardText', () => {
167167
expect(imagePathFromClipboardText('', ' ')).toBeNull()
168168
})
169169
})
170+
171+
describe('localPathFromPastedText', () => {
172+
// The board paste door's file-drop gate: pasting a local file path
173+
// must route like dropping that file, and nothing else may.
174+
it('accepts an absolute path', () => {
175+
expect(localPathFromPastedText('/Users/me/notes/plan.md')).toBe('/Users/me/notes/plan.md')
176+
})
177+
178+
it('accepts a quoted absolute path', () => {
179+
expect(localPathFromPastedText('"/Users/me/My Docs/plan.md"')).toBe('/Users/me/My Docs/plan.md')
180+
})
181+
182+
it('accepts a file:// URL, percent-decoded', () => {
183+
expect(localPathFromPastedText('file:///Users/me/My%20Docs/plan.md')).toBe('/Users/me/My Docs/plan.md')
184+
})
185+
186+
it('accepts a directory path (no extension required)', () => {
187+
expect(localPathFromPastedText('/Users/me/project')).toBe('/Users/me/project')
188+
})
189+
190+
it('rejects multi-line text even when the first line is a path', () => {
191+
expect(localPathFromPastedText('/Users/me/plan.md\nand more prose')).toBeNull()
192+
})
193+
194+
it('rejects relative paths', () => {
195+
expect(localPathFromPastedText('notes/plan.md')).toBeNull()
196+
})
197+
198+
it('rejects http URLs', () => {
199+
expect(localPathFromPastedText('https://example.com/plan.md')).toBeNull()
200+
})
201+
202+
it('rejects ordinary prose', () => {
203+
expect(localPathFromPastedText('meet at the usual place')).toBeNull()
204+
})
205+
206+
it('rejects empty and whitespace text', () => {
207+
expect(localPathFromPastedText(' ')).toBeNull()
208+
})
209+
})

frontend/src/atlas/atlasCreateHelpers.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,24 @@ export function imagePathFromClipboardText(...candidates: string[]): string | nu
9494
return null
9595
}
9696

97+
// localPathFromPastedText resolves a pasted text/plain clipboard into
98+
// a local ABSOLUTE file path, or null when the text isn't path-shaped
99+
// -- the board paste door's gate before treating a paste as a file
100+
// drop. Deliberately stricter than imagePathFromClipboardText above:
101+
// the WHOLE text must be one line (the first line of pasted prose must
102+
// never silently swallow the rest), and only absolute paths qualify.
103+
// Wrapping quotes are stripped (a terminal-copied path) and file://
104+
// URLs normalize through the same helper the picker uses. SHAPE only:
105+
// whether the path actually exists is the backend's decision
106+
// (ResolveFileDropRoute), and a dead path falls back to ordinary text.
107+
export function localPathFromPastedText(text: string): string | null {
108+
const trimmed = text.trim()
109+
if (trimmed === '' || /[\r\n]/.test(trimmed)) return null
110+
const unquoted = /^(['"]).*\1$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed
111+
const path = normalizeLocalPathInput(unquoted)
112+
return path.startsWith('/') ? path : null
113+
}
114+
97115
// resolveNoteCommitText decides whether a re-edited note's text should
98116
// persist, and exactly what to persist (goal 0226's round-trip
99117
// contract): null skips the write entirely (an existing note's own

frontend/src/atlas/useAtlasClipboard.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { childrenOf } from './atlasGrouping'
77
import { frameContainingPoint } from './atlasFramePoint'
88
import type { FrameBox } from './useAtlasDragFiling'
99
import { parseAtlasClonePayload, serializeAtlasSelection, type AtlasClonePayload } from './atlasClipboard'
10+
import { modalSurfaceOpen } from '../shared/modalGate'
1011

1112
// createClones performs the payload's writes: cards parents-before-
1213
// children (co-copied structure re-parents onto fresh clone ids, pass
@@ -83,6 +84,9 @@ export function useAtlasClipboard({ allCards, allNotes, links, kinds, selectedCa
8384

8485
const onCopy = (e: ClipboardEvent) => {
8586
const s = stateRef.current
87+
// A modal above the board owns the screen: copying here would
88+
// silently overwrite the clipboard with a HIDDEN board selection.
89+
if (modalSurfaceOpen()) return
8690
if (isEditableTarget(document.activeElement)) return
8791
if (window.getSelection()?.toString()) return
8892
const payload = serializeAtlasSelection(s.allCards, s.allNotes, s.links, s.selectedCardIDs, s.selectedNoteIDs)
@@ -138,6 +142,9 @@ export function useAtlasClipboard({ allCards, allNotes, links, kinds, selectedCa
138142
const onPaste = (e: ClipboardEvent) => {
139143
const s = stateRef.current
140144
if (s.readOnly) return
145+
// Same modal stand-down as onCopy: pasted clones would land
146+
// invisibly behind the open dialog.
147+
if (modalSurfaceOpen()) return
141148
if (isEditableTarget(document.activeElement)) return
142149
const payload = parseAtlasClonePayload(e.clipboardData?.getData('text/plain') ?? '')
143150
if (!payload) return
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useEffect, type RefObject } from 'react'
2+
import type { Card, Note, BoardObject } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
3+
import { modalSurfaceOpen } from '../shared/modalGate'
4+
5+
// Delete/Backspace over a live board selection -> the shared
6+
// delete path (quick-delete-with-undo for notes/objects, the confirm
7+
// for cards; single or multi). Split out of AtlasBoard.tsx along its
8+
// own effect seam (the 500-line convention). Never fires from
9+
// editable elements, and stands down entirely while a modal dialog
10+
// owns the screen (shared/modalGate.ts, the goal-0183 gesture-leak
11+
// class): a Delete reaching the COVERED board would destroy a
12+
// selection the user can't even see behind the dialog.
13+
export function useAtlasDeleteKey({ cards, notes, objects, selectedIDsRef, onDeleteSelection }: {
14+
cards: Card[]
15+
notes: Note[]
16+
objects: BoardObject[]
17+
selectedIDsRef: RefObject<string[]>
18+
onDeleteSelection: (cardIDs: string[], noteIDs: string[], objectIDs?: string[]) => void
19+
}) {
20+
useEffect(() => {
21+
const onKeyDown = (e: KeyboardEvent) => {
22+
if (e.key !== 'Delete' && e.key !== 'Backspace') return
23+
if (modalSurfaceOpen()) return
24+
const el = document.activeElement
25+
if (el instanceof HTMLElement && (el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
26+
const sel = selectedIDsRef.current
27+
if (sel.length === 0) return
28+
e.preventDefault()
29+
const cardIDs = sel.filter((id) => cards.some((c) => c.ID === id))
30+
const noteIDs = sel.filter((id) => notes.some((n) => n.ID === id))
31+
const objectIDs = sel.filter((id) => objects.some((o) => o.ID === id))
32+
if (cardIDs.length + noteIDs.length + objectIDs.length > 0) onDeleteSelection(cardIDs, noteIDs, objectIDs)
33+
}
34+
window.addEventListener('keydown', onKeyDown)
35+
return () => window.removeEventListener('keydown', onKeyDown)
36+
}, [cards, notes, objects, onDeleteSelection, selectedIDsRef])
37+
}

frontend/src/atlas/useAtlasNativeFileDrop.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useState } from 'react'
1+
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { useTranslation } from 'react-i18next'
33
import { Events } from '@wailsio/runtime'
44
import { AtlasService } from '../shared/bindings'
@@ -78,15 +78,19 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo
7878
stateRef.current = { parentID, topLevelBoxes, screenToFlowPosition, requestFolderImport, setPulsedID, reduceMotion, diagramObjectCreate, imageObjectCreate, sheetObjectCreate }
7979
}, [parentID, topLevelBoxes, screenToFlowPosition, requestFolderImport, setPulsedID, reduceMotion, diagramObjectCreate, imageObjectCreate, sheetObjectCreate])
8080

81-
useEffect(() => {
82-
return Events.On(FILE_DROP_EVENT_NAME, (evt) => {
83-
const payload = evt.data as { filenames?: string[]; x?: number; y?: number; context?: string } | undefined
84-
if (!payload || payload.context !== FILE_DROP_CONTEXT_BOARD || !payload.filenames?.length) return
85-
const { topLevelBoxes: boxes, screenToFlowPosition: toFlow, parentID: currentParentID, requestFolderImport: request, setPulsedID: pulse, reduceMotion: reduced, diagramObjectCreate: diagramCreate, imageObjectCreate: imageCreate, sheetObjectCreate: sheetCreate } = stateRef.current
86-
const point = toFlow({ x: payload.x ?? 0, y: payload.y ?? 0 })
87-
const targetParentID = frameContainingPoint(boxes, point) ?? currentParentID
81+
// The drop door's LANDING half, split from the OS drop gesture so the
82+
// board's paste door can land a pasted file PATH through the exact
83+
// same pipeline (routing, plugin claims, extension enablement, folder
84+
// import, the card fallback's pulse/duplicate notice). Returns the
85+
// un-caught promise: each gesture owns its own failure answer -- the
86+
// OS drop shows dropError below, a pasted path falls back to the
87+
// ordinary text-paste flow (useAtlasPaste.ts).
88+
const landFiles = useCallback((filenames: string[], screenPoint: { x: number; y: number }) => {
89+
const { topLevelBoxes: boxes, screenToFlowPosition: toFlow, parentID: currentParentID, requestFolderImport: request, setPulsedID: pulse, reduceMotion: reduced, diagramObjectCreate: diagramCreate, imageObjectCreate: imageCreate, sheetObjectCreate: sheetCreate } = stateRef.current
90+
const point = toFlow(screenPoint)
91+
const targetParentID = frameContainingPoint(boxes, point) ?? currentParentID
8892

89-
AtlasService.ResolveFileDropRoute(payload.filenames)
93+
return AtlasService.ResolveFileDropRoute(filenames)
9094
.then((route) => {
9195
if (route.Kind === 'import') {
9296
request(route.Path, targetParentID)
@@ -128,9 +132,16 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo
128132
}))
129133
}
130134
})
135+
}, [t])
136+
137+
useEffect(() => {
138+
return Events.On(FILE_DROP_EVENT_NAME, (evt) => {
139+
const payload = evt.data as { filenames?: string[]; x?: number; y?: number; context?: string } | undefined
140+
if (!payload || payload.context !== FILE_DROP_CONTEXT_BOARD || !payload.filenames?.length) return
141+
void landFiles(payload.filenames, { x: payload.x ?? 0, y: payload.y ?? 0 })
131142
.catch(() => setDropError(t('capture.dropError')))
132143
})
133-
}, [t])
144+
}, [t, landFiles])
134145

135146
useEffect(() => {
136147
if (!dropError) return
@@ -144,5 +155,5 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo
144155
return () => window.clearTimeout(timer)
145156
}, [dropDuplicateNotice])
146157

147-
return { dropError, dropDuplicateNotice }
158+
return { dropError, dropDuplicateNotice, landFiles }
148159
}

0 commit comments

Comments
 (0)