diff --git a/frontend/e2e/atlas-paste-convert.spec.ts b/frontend/e2e/atlas-paste-convert.spec.ts
index 944cf0f9..84e89ab4 100644
--- a/frontend/e2e/atlas-paste-convert.spec.ts
+++ b/frontend/e2e/atlas-paste-convert.spec.ts
@@ -1,6 +1,9 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
import { test, expect } from './fixtures/server'
import { deleteViaPageMenu } from './fixtures/atlasPage'
-import { openCard } from './fixtures/atlasBoard'
+import { closeCard, deleteSticky, openCard } from './fixtures/atlasBoard'
import { clickRowAction } from './inventoryRow'
import { contextMenu } from './fixtures/contextMenu'
@@ -54,6 +57,19 @@ async function pasteHTML(page: import('@playwright/test').Page, html: string, pl
}, { html, text: plainTextSibling })
}
+// pastePlainText injects text/plain VERBATIM (unlike pasteText below,
+// which URI-encodes to mimic the diagram tool's own copy format) --
+// the shape a copied file path or ordinary prose actually has.
+async function pastePlainText(page: import('@playwright/test').Page, raw: string) {
+ // eslint-disable-next-line no-restricted-syntax -- cursor-position-only gesture, not a checkable interaction (pasteText's own comment below has the full reasoning)
+ await page.mouse.move(1000, 220)
+ await page.evaluate((t) => {
+ const dt = new DataTransfer()
+ dt.setData('text/plain', t)
+ window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }))
+ }, raw)
+}
+
async function pasteText(page: import('@playwright/test').Page, raw: string) {
// The paste anchors at the pointer (a paste inside a frame files
// 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
await clickRowAction(page, listRow, 'Delete')
await expect(listRow).toHaveCount(0)
})
+
+// Regression: pasting a local file PATH (text) landed a sticky note
+// containing the raw path string, while DROPPING the same file landed
+// the real thing (goal 0179's founding rule). A pasted path now routes
+// through the drop door's own landing pipeline: .md becomes a mirrored
+// document card, .drawio a diagram board object -- and a path that
+// doesn't resolve on disk still falls back to the note, never a dead
+// end.
+test('pasting a file path lands what dropping the file would; a dead path still falls back to a note', async ({ page }) => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mill-e2e-paste-path-'))
+ const mdPath = path.join(dir, 'ZzE2ePastedDocPath.md')
+ fs.writeFileSync(mdPath, '# Pasted doc\n\nbody\n')
+ const drawioPath = path.join(dir, 'ZzE2ePastedDiagramPath.drawio')
+ fs.writeFileSync(drawioPath, ''
+ + ''
+ + ''
+ + '')
+ try {
+ await page.goto('/')
+ await page.getByRole('link', { name: 'Atlas' }).click()
+ await expect(page.getByTestId('atlas-board')).toBeVisible()
+
+ // .md path -> mirrored document card, exactly like dropping the file.
+ await pastePlainText(page, mdPath)
+ const card = page.getByTestId('atlas-note-card').filter({ hasText: 'ZzE2ePastedDocPath' })
+ await expect(card).toBeVisible()
+ await expect(page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2ePastedDocPath' })).toHaveCount(0)
+
+ // .drawio path -> diagram board object, never a card or note.
+ await pastePlainText(page, drawioPath)
+ const diagram = page.locator('[data-testid="atlas-board-object"][data-object-kind="diagram"]')
+ await expect(diagram).toHaveCount(1)
+ await expect(page.getByTestId('atlas-note-card').filter({ hasText: 'ZzE2ePastedDiagramPath' })).toHaveCount(0)
+
+ // A path-shaped string that doesn't exist stays ordinary text: the
+ // note fallback, so nothing a user pastes ever vanishes.
+ const deadPath = path.join(dir, 'ZzE2eDeadPath.md')
+ await pastePlainText(page, deadPath)
+ const note = page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2eDeadPath' })
+ await expect(note).toBeVisible()
+
+ // Cleanup (shared pool): note, diagram object, card.
+ await deleteSticky(page, note)
+ await deleteObjectViaMenu(diagram)
+ await expect(diagram).toHaveCount(0)
+ await card.click({ button: 'right' })
+ const menu = contextMenu(page)
+ await expect(menu).toBeVisible()
+ await menu.getByText('Delete', { exact: true }).click()
+ await expect(card).toHaveCount(0)
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+// Regression: the board's window-level paste door stayed live while a
+// card page (a modal dialog) covered the board -- pasting with focus
+// on no field landed a sticky note INVISIBLY behind the dialog. The
+// door now stands down while a modal surface is open, and comes back
+// the moment it closes.
+test('pasting while a card page is open lands nothing behind it; the door returns on close', async ({ page }) => {
+ await page.goto('/')
+ await page.getByRole('link', { name: 'Atlas' }).click()
+ await expect(page.getByTestId('atlas-board')).toBeVisible()
+
+ const card = page.getByTestId('atlas-note-card').filter({ hasText: 'Discovery workstream' }).first()
+ await openCard(page, card)
+ const overlay = page.locator('[data-component="atlas-card-overlay"]')
+ await expect(overlay).toBeVisible()
+ // Land focus on nothing editable: click the page's own header region
+ // (the real state a user reaches by clicking any non-field area).
+ await page.getByTestId('atlas-page-header').click()
+
+ await pastePlainText(page, 'ZzE2eModalGateProbe')
+ // No observable "nothing happened" signal exists to await -- a fixed
+ // settle window is the only way to assert the note did NOT land.
+ await page.waitForTimeout(800) // asserting a no-op: no observable condition exists to await
+ await expect(page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2eModalGateProbe' })).toHaveCount(0)
+
+ await closeCard(page, overlay)
+
+ // The same paste with the board foreground again lands its note.
+ await pastePlainText(page, 'ZzE2eModalGateProbe')
+ const note = page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2eModalGateProbe' })
+ await expect(note).toBeVisible()
+ await deleteSticky(page, note)
+})
diff --git a/frontend/src/atlas/AtlasBoard.tsx b/frontend/src/atlas/AtlasBoard.tsx
index 5bd6306b..165a037e 100644
--- a/frontend/src/atlas/AtlasBoard.tsx
+++ b/frontend/src/atlas/AtlasBoard.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from 'react'
import { useRenderStormGuard } from '../shared/renderStormGuard'
+import { useAtlasDeleteKey } from './useAtlasDeleteKey'
import { useTranslation } from 'react-i18next'
import { ReactFlow, ReactFlowProvider, useNodesState, useReactFlow } from '@xyflow/react'
import '@xyflow/react/dist/style.css'
@@ -126,24 +127,7 @@ function AtlasBoardInner({ boardFilter, onBoardFilterChange, filterMatchCount, f
const selection = useAtlasSelection({ cards, notes, objects, onMultiSelectContextMenu })
const wrapperClicks = useAtlasPaneClick({ tablePicker, topLevelBoxes, screenToFlowPosition, onCreateTableSized, placeAt: creation.placeAt })
- // Delete/Backspace over a live selection -> the shared confirm
- // (never fires from editable elements; single or multi).
- useEffect(() => {
- const onKeyDown = (e: KeyboardEvent) => {
- if (e.key !== 'Delete' && e.key !== 'Backspace') return
- const el = document.activeElement
- if (el instanceof HTMLElement && (el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
- const sel = selection.selectedIDsRef.current
- if (sel.length === 0) return
- e.preventDefault()
- const cardIDs = sel.filter((id) => cards.some((c) => c.ID === id))
- const noteIDs = sel.filter((id) => notes.some((n) => n.ID === id))
- const objectIDs = sel.filter((id) => objects.some((o) => o.ID === id))
- if (cardIDs.length + noteIDs.length + objectIDs.length > 0) onDeleteSelection(cardIDs, noteIDs, objectIDs)
- }
- window.addEventListener('keydown', onKeyDown)
- return () => window.removeEventListener('keydown', onKeyDown)
- }, [cards, notes, objects, onDeleteSelection, selection.selectedIDsRef])
+ useAtlasDeleteKey({ cards, notes, objects, selectedIDsRef: selection.selectedIDsRef, onDeleteSelection })
// Zoom chip / group-header click / Enter on a region frame (routed
// here through AtlasGroupNode's own data.onDrill) all fly the camera
@@ -183,7 +167,7 @@ function AtlasBoardInner({ boardFilter, onBoardFilterChange, filterMatchCount, f
// The capture doors (goal 0081 slice A3): own hook files, 500-line cap.
const fileDrop = useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPosition, setPulsedID, reduceMotion })
- useAtlasPaste({ topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated: selection.selectNote })
+ useAtlasPaste({ topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated: selection.selectNote, landFiles: fileDrop.landFiles })
useAtlasClipboard({ allCards, allNotes, links, kinds, selectedCardIDs: selection.selectedCards, selectedNoteIDs: selection.selectedNotes, topLevelBoxes, screenToFlowPosition, viewedID, readOnly, showToast: onQuietToast })
// Handle honesty: no kind restricts linking, so zero legal targets means a board with nothing else on it.
diff --git a/frontend/src/atlas/atlasCreateHelpers.test.ts b/frontend/src/atlas/atlasCreateHelpers.test.ts
index e050a580..3c587969 100644
--- a/frontend/src/atlas/atlasCreateHelpers.test.ts
+++ b/frontend/src/atlas/atlasCreateHelpers.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { imagePathFromClipboardText, normalizeLocalPathInput, resolveDefaultKindID, resolveNoteCommitText, titleFromFilename, titleFromNoteText } from './atlasCreateHelpers'
+import { imagePathFromClipboardText, localPathFromPastedText, normalizeLocalPathInput, resolveDefaultKindID, resolveNoteCommitText, titleFromFilename, titleFromNoteText } from './atlasCreateHelpers'
import type { Kind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
function kind(id: string): Kind {
@@ -167,3 +167,43 @@ describe('imagePathFromClipboardText', () => {
expect(imagePathFromClipboardText('', ' ')).toBeNull()
})
})
+
+describe('localPathFromPastedText', () => {
+ // The board paste door's file-drop gate: pasting a local file path
+ // must route like dropping that file, and nothing else may.
+ it('accepts an absolute path', () => {
+ expect(localPathFromPastedText('/Users/me/notes/plan.md')).toBe('/Users/me/notes/plan.md')
+ })
+
+ it('accepts a quoted absolute path', () => {
+ expect(localPathFromPastedText('"/Users/me/My Docs/plan.md"')).toBe('/Users/me/My Docs/plan.md')
+ })
+
+ it('accepts a file:// URL, percent-decoded', () => {
+ expect(localPathFromPastedText('file:///Users/me/My%20Docs/plan.md')).toBe('/Users/me/My Docs/plan.md')
+ })
+
+ it('accepts a directory path (no extension required)', () => {
+ expect(localPathFromPastedText('/Users/me/project')).toBe('/Users/me/project')
+ })
+
+ it('rejects multi-line text even when the first line is a path', () => {
+ expect(localPathFromPastedText('/Users/me/plan.md\nand more prose')).toBeNull()
+ })
+
+ it('rejects relative paths', () => {
+ expect(localPathFromPastedText('notes/plan.md')).toBeNull()
+ })
+
+ it('rejects http URLs', () => {
+ expect(localPathFromPastedText('https://example.com/plan.md')).toBeNull()
+ })
+
+ it('rejects ordinary prose', () => {
+ expect(localPathFromPastedText('meet at the usual place')).toBeNull()
+ })
+
+ it('rejects empty and whitespace text', () => {
+ expect(localPathFromPastedText(' ')).toBeNull()
+ })
+})
diff --git a/frontend/src/atlas/atlasCreateHelpers.ts b/frontend/src/atlas/atlasCreateHelpers.ts
index 809141bf..2bf22158 100644
--- a/frontend/src/atlas/atlasCreateHelpers.ts
+++ b/frontend/src/atlas/atlasCreateHelpers.ts
@@ -94,6 +94,24 @@ export function imagePathFromClipboardText(...candidates: string[]): string | nu
return null
}
+// localPathFromPastedText resolves a pasted text/plain clipboard into
+// a local ABSOLUTE file path, or null when the text isn't path-shaped
+// -- the board paste door's gate before treating a paste as a file
+// drop. Deliberately stricter than imagePathFromClipboardText above:
+// the WHOLE text must be one line (the first line of pasted prose must
+// never silently swallow the rest), and only absolute paths qualify.
+// Wrapping quotes are stripped (a terminal-copied path) and file://
+// URLs normalize through the same helper the picker uses. SHAPE only:
+// whether the path actually exists is the backend's decision
+// (ResolveFileDropRoute), and a dead path falls back to ordinary text.
+export function localPathFromPastedText(text: string): string | null {
+ const trimmed = text.trim()
+ if (trimmed === '' || /[\r\n]/.test(trimmed)) return null
+ const unquoted = /^(['"]).*\1$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed
+ const path = normalizeLocalPathInput(unquoted)
+ return path.startsWith('/') ? path : null
+}
+
// resolveNoteCommitText decides whether a re-edited note's text should
// persist, and exactly what to persist (goal 0226's round-trip
// contract): null skips the write entirely (an existing note's own
diff --git a/frontend/src/atlas/useAtlasClipboard.ts b/frontend/src/atlas/useAtlasClipboard.ts
index b0d10fb5..fe7fb0c4 100644
--- a/frontend/src/atlas/useAtlasClipboard.ts
+++ b/frontend/src/atlas/useAtlasClipboard.ts
@@ -7,6 +7,7 @@ import { childrenOf } from './atlasGrouping'
import { frameContainingPoint } from './atlasFramePoint'
import type { FrameBox } from './useAtlasDragFiling'
import { parseAtlasClonePayload, serializeAtlasSelection, type AtlasClonePayload } from './atlasClipboard'
+import { modalSurfaceOpen } from '../shared/modalGate'
// createClones performs the payload's writes: cards parents-before-
// children (co-copied structure re-parents onto fresh clone ids, pass
@@ -83,6 +84,9 @@ export function useAtlasClipboard({ allCards, allNotes, links, kinds, selectedCa
const onCopy = (e: ClipboardEvent) => {
const s = stateRef.current
+ // A modal above the board owns the screen: copying here would
+ // silently overwrite the clipboard with a HIDDEN board selection.
+ if (modalSurfaceOpen()) return
if (isEditableTarget(document.activeElement)) return
if (window.getSelection()?.toString()) return
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
const onPaste = (e: ClipboardEvent) => {
const s = stateRef.current
if (s.readOnly) return
+ // Same modal stand-down as onCopy: pasted clones would land
+ // invisibly behind the open dialog.
+ if (modalSurfaceOpen()) return
if (isEditableTarget(document.activeElement)) return
const payload = parseAtlasClonePayload(e.clipboardData?.getData('text/plain') ?? '')
if (!payload) return
diff --git a/frontend/src/atlas/useAtlasDeleteKey.ts b/frontend/src/atlas/useAtlasDeleteKey.ts
new file mode 100644
index 00000000..8b9ed74a
--- /dev/null
+++ b/frontend/src/atlas/useAtlasDeleteKey.ts
@@ -0,0 +1,37 @@
+import { useEffect, type RefObject } from 'react'
+import type { Card, Note, BoardObject } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
+import { modalSurfaceOpen } from '../shared/modalGate'
+
+// Delete/Backspace over a live board selection -> the shared
+// delete path (quick-delete-with-undo for notes/objects, the confirm
+// for cards; single or multi). Split out of AtlasBoard.tsx along its
+// own effect seam (the 500-line convention). Never fires from
+// editable elements, and stands down entirely while a modal dialog
+// owns the screen (shared/modalGate.ts, the goal-0183 gesture-leak
+// class): a Delete reaching the COVERED board would destroy a
+// selection the user can't even see behind the dialog.
+export function useAtlasDeleteKey({ cards, notes, objects, selectedIDsRef, onDeleteSelection }: {
+ cards: Card[]
+ notes: Note[]
+ objects: BoardObject[]
+ selectedIDsRef: RefObject
+ onDeleteSelection: (cardIDs: string[], noteIDs: string[], objectIDs?: string[]) => void
+}) {
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== 'Delete' && e.key !== 'Backspace') return
+ if (modalSurfaceOpen()) return
+ const el = document.activeElement
+ if (el instanceof HTMLElement && (el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
+ const sel = selectedIDsRef.current
+ if (sel.length === 0) return
+ e.preventDefault()
+ const cardIDs = sel.filter((id) => cards.some((c) => c.ID === id))
+ const noteIDs = sel.filter((id) => notes.some((n) => n.ID === id))
+ const objectIDs = sel.filter((id) => objects.some((o) => o.ID === id))
+ if (cardIDs.length + noteIDs.length + objectIDs.length > 0) onDeleteSelection(cardIDs, noteIDs, objectIDs)
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [cards, notes, objects, onDeleteSelection, selectedIDsRef])
+}
diff --git a/frontend/src/atlas/useAtlasNativeFileDrop.ts b/frontend/src/atlas/useAtlasNativeFileDrop.ts
index 155c1072..32487fed 100644
--- a/frontend/src/atlas/useAtlasNativeFileDrop.ts
+++ b/frontend/src/atlas/useAtlasNativeFileDrop.ts
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Events } from '@wailsio/runtime'
import { AtlasService } from '../shared/bindings'
@@ -78,15 +78,19 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo
stateRef.current = { parentID, topLevelBoxes, screenToFlowPosition, requestFolderImport, setPulsedID, reduceMotion, diagramObjectCreate, imageObjectCreate, sheetObjectCreate }
}, [parentID, topLevelBoxes, screenToFlowPosition, requestFolderImport, setPulsedID, reduceMotion, diagramObjectCreate, imageObjectCreate, sheetObjectCreate])
- useEffect(() => {
- return Events.On(FILE_DROP_EVENT_NAME, (evt) => {
- const payload = evt.data as { filenames?: string[]; x?: number; y?: number; context?: string } | undefined
- if (!payload || payload.context !== FILE_DROP_CONTEXT_BOARD || !payload.filenames?.length) return
- const { topLevelBoxes: boxes, screenToFlowPosition: toFlow, parentID: currentParentID, requestFolderImport: request, setPulsedID: pulse, reduceMotion: reduced, diagramObjectCreate: diagramCreate, imageObjectCreate: imageCreate, sheetObjectCreate: sheetCreate } = stateRef.current
- const point = toFlow({ x: payload.x ?? 0, y: payload.y ?? 0 })
- const targetParentID = frameContainingPoint(boxes, point) ?? currentParentID
+ // The drop door's LANDING half, split from the OS drop gesture so the
+ // board's paste door can land a pasted file PATH through the exact
+ // same pipeline (routing, plugin claims, extension enablement, folder
+ // import, the card fallback's pulse/duplicate notice). Returns the
+ // un-caught promise: each gesture owns its own failure answer -- the
+ // OS drop shows dropError below, a pasted path falls back to the
+ // ordinary text-paste flow (useAtlasPaste.ts).
+ const landFiles = useCallback((filenames: string[], screenPoint: { x: number; y: number }) => {
+ const { topLevelBoxes: boxes, screenToFlowPosition: toFlow, parentID: currentParentID, requestFolderImport: request, setPulsedID: pulse, reduceMotion: reduced, diagramObjectCreate: diagramCreate, imageObjectCreate: imageCreate, sheetObjectCreate: sheetCreate } = stateRef.current
+ const point = toFlow(screenPoint)
+ const targetParentID = frameContainingPoint(boxes, point) ?? currentParentID
- AtlasService.ResolveFileDropRoute(payload.filenames)
+ return AtlasService.ResolveFileDropRoute(filenames)
.then((route) => {
if (route.Kind === 'import') {
request(route.Path, targetParentID)
@@ -128,9 +132,16 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo
}))
}
})
+ }, [t])
+
+ useEffect(() => {
+ return Events.On(FILE_DROP_EVENT_NAME, (evt) => {
+ const payload = evt.data as { filenames?: string[]; x?: number; y?: number; context?: string } | undefined
+ if (!payload || payload.context !== FILE_DROP_CONTEXT_BOARD || !payload.filenames?.length) return
+ void landFiles(payload.filenames, { x: payload.x ?? 0, y: payload.y ?? 0 })
.catch(() => setDropError(t('capture.dropError')))
})
- }, [t])
+ }, [t, landFiles])
useEffect(() => {
if (!dropError) return
@@ -144,5 +155,5 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo
return () => window.clearTimeout(timer)
}, [dropDuplicateNotice])
- return { dropError, dropDuplicateNotice }
+ return { dropError, dropDuplicateNotice, landFiles }
}
diff --git a/frontend/src/atlas/useAtlasPaste.ts b/frontend/src/atlas/useAtlasPaste.ts
index 87fadcb1..a1df2b70 100644
--- a/frontend/src/atlas/useAtlasPaste.ts
+++ b/frontend/src/atlas/useAtlasPaste.ts
@@ -3,6 +3,8 @@ import { AtlasService } from '../shared/bindings'
import type { PasteResult } from '../../bindings/github.com/alicoding/mill/internal/services/atlassvc/models'
import { refreshAtlas } from './atlasStore'
import { frameContainingPoint } from './atlasFramePoint'
+import { localPathFromPastedText } from './atlasCreateHelpers'
+import { modalSurfaceOpen } from '../shared/modalGate'
import type { FrameBox } from './useAtlasDragFiling'
// isEditableTarget mirrors the LOCKED design's own gate ("when the
@@ -27,7 +29,7 @@ function isEditableTarget(el: Element | null): boolean {
// cheap: a ref updated on pointermove, no re-renders) rather than a
// fixed viewport center, so the note lands near where the user
// actually is.
-export function useAtlasPaste({ topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated }: {
+export function useAtlasPaste({ topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated, landFiles }: {
topLevelBoxes: FrameBox[]
screenToFlowPosition: (p: { x: number; y: number }) => { x: number; y: number }
viewedID: string
@@ -36,12 +38,17 @@ export function useAtlasPaste({ topLevelBoxes, screenToFlowPosition, viewedID, o
// caller's own selection mechanism (useAtlasSelection's selectNote)
// marks it selected without a pointer event ever touching it.
onNoteCreated: (id: string) => void
+ // The native drop door's landing half (useAtlasNativeFileDrop.ts):
+ // a pasted local file path lands through the exact same pipeline a
+ // dropped file does. Rejects when the path doesn't resolve, which
+ // this hook answers by falling back to the ordinary text flow.
+ landFiles: (filenames: string[], screenPoint: { x: number; y: number }) => Promise
}) {
const lastMouse = useRef<{ x: number; y: number } | null>(null)
- const stateRef = useRef({ topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated })
+ const stateRef = useRef({ topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated, landFiles })
useEffect(() => {
- stateRef.current = { topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated }
- }, [topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated])
+ stateRef.current = { topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated, landFiles }
+ }, [topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated, landFiles])
useEffect(() => {
const onPointerMove = (e: PointerEvent) => {
@@ -57,6 +64,9 @@ export function useAtlasPaste({ topLevelBoxes, screenToFlowPosition, viewedID, o
// zone) marks the event handled via preventDefault before it
// bubbles here -- acting anyway would land the same paste twice.
if (e.defaultPrevented) return
+ // A modal above the board (a card page, the palette) owns the
+ // screen: pasting here would land a note invisibly BEHIND it.
+ if (modalSurfaceOpen()) return
if (isEditableTarget(document.activeElement)) return
const data = e.clipboardData
if (!data) return
@@ -111,21 +121,38 @@ export function useAtlasPaste({ topLevelBoxes, screenToFlowPosition, viewedID, o
// through to the note door above. text/html can't both be empty
// here (guarded above), so PasteToBoard always gets something to
// try.
- void AtlasService.PasteToBoard(text, html, targetParentID, flowPos.x, flowPos.y)
- .then((res) => {
- if (res.Recognized) {
- void refreshAtlas()
- converted(res)
- return
- }
- fallThrough()
- })
- .catch((err) => {
- // A conversion failure falls through to the note door --
- // logged so a real defect is visible, not silent.
- console.error('paste conversion failed', err)
- fallThrough()
- })
+ const runRecognizer = () => {
+ void AtlasService.PasteToBoard(text, html, targetParentID, flowPos.x, flowPos.y)
+ .then((res) => {
+ if (res.Recognized) {
+ void refreshAtlas()
+ converted(res)
+ return
+ }
+ fallThrough()
+ })
+ .catch((err) => {
+ // A conversion failure falls through to the note door --
+ // logged so a real defect is visible, not silent.
+ console.error('paste conversion failed', err)
+ fallThrough()
+ })
+ }
+
+ // A pasted LOCAL FILE PATH behaves exactly like dropping that
+ // file at the pointer (goal 0179's founding rule: creating a
+ // thing creates THAT THING) -- routed through the drop door's own
+ // landing pipeline, so diagram/sheet/image extensions, plugin
+ // claims, folder import, and the card fallback all match a real
+ // drop. A path that doesn't resolve on disk is just text that
+ // looks like a path: it falls back to the recognizer flow and
+ // still lands as a note, never a dead end.
+ const pastedPath = localPathFromPastedText(text)
+ if (pastedPath) {
+ void stateRef.current.landFiles([pastedPath], anchorPos).catch(runRecognizer)
+ return
+ }
+ runRecognizer()
}
window.addEventListener('paste', onPaste)
return () => window.removeEventListener('paste', onPaste)
diff --git a/frontend/src/composition/useCanvasClipboard.ts b/frontend/src/composition/useCanvasClipboard.ts
index cf52e817..477ecc59 100644
--- a/frontend/src/composition/useCanvasClipboard.ts
+++ b/frontend/src/composition/useCanvasClipboard.ts
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { newLocalID } from '../shared/localId'
+import { modalSurfaceOpen } from '../shared/modalGate'
import type { CanvasStore } from './canvasStore'
import { materializeCanvasClones, parseWorkflowClonePayload, serializeCanvasSelection } from './canvasClipboard'
@@ -38,6 +39,10 @@ export function useCanvasClipboard({ store, readOnly, screenToFlowPosition, flas
lastMouse.current = { x: e.clientX, y: e.clientY }
}
const onCopy = (e: ClipboardEvent) => {
+ // A modal above the canvas owns the screen (shared/modalGate.ts):
+ // copying here would silently overwrite the clipboard with a
+ // hidden canvas selection.
+ if (modalSurfaceOpen()) return
if (isEditableTarget(document.activeElement)) return
if (window.getSelection()?.toString()) return
const { nodes, edges, notes } = stateRef.current.store.getState()
@@ -56,6 +61,9 @@ export function useCanvasClipboard({ store, readOnly, screenToFlowPosition, flas
const onPaste = (e: ClipboardEvent) => {
const { readOnly: ro, screenToFlowPosition: toFlow, flash: show, t: tt } = stateRef.current
if (ro) return
+ // Same modal stand-down as onCopy: pasted clones would land
+ // behind the open dialog.
+ if (modalSurfaceOpen()) return
if (isEditableTarget(document.activeElement)) return
const payload = parseWorkflowClonePayload(e.clipboardData?.getData('text/plain') ?? '')
if (!payload) return
diff --git a/frontend/src/shared/modalGate.ts b/frontend/src/shared/modalGate.ts
new file mode 100644
index 00000000..d02c4653
--- /dev/null
+++ b/frontend/src/shared/modalGate.ts
@@ -0,0 +1,12 @@
+// modalSurfaceOpen reports whether a modal dialog currently owns the
+// screen -- a card page, the command palette, a confirm dialog (Primer
+// Dialog renders role="dialog"; its alert variant role="alertdialog").
+// Window-level clipboard doors on a canvas stand down while one is
+// open: a paste reaching the COVERED canvas lands entities invisibly
+// behind the modal, and a copy silently overwrites the clipboard with
+// a hidden canvas selection. Anchored popovers (the image popover,
+// pickers) render role="none" and deliberately do not engage this
+// gate -- the canvas stays the foreground surface under them.
+export function modalSurfaceOpen(): boolean {
+ return document.querySelector('[role="dialog"], [role="alertdialog"]') !== null
+}
diff --git a/userdocs/concepts/atlas.md b/userdocs/concepts/atlas.md
index 0550ba9e..42af5d5e 100644
--- a/userdocs/concepts/atlas.md
+++ b/userdocs/concepts/atlas.md
@@ -88,9 +88,12 @@ connected by links, grouped into areas you can drill into.
- **Paste anything from outside Mill — it lands as the right kind of
thing.** A table copied from a spreadsheet or a document app becomes
a table on the board, ready to browse and edit like any other list.
- Everything else lands as a sticky note at your pointer, already
- selected — nothing else to fill in. Multiple tables in one paste each
- land as their own table, offset so you can tell them apart.
+ A pasted file path lands what dropping the file would: a document
+ becomes a card, a diagram or spreadsheet file its own board object,
+ a folder path opens the folder import. Everything else lands as a
+ sticky note at your pointer, already selected — nothing else to
+ fill in. Multiple tables in one paste each land as their own table,
+ offset so you can tell them apart.
- **Create by pointing.** Press C (or pick Card in the toolbar) and
click — the card appears right there and you name it in place;
Enter keeps the name, Escape keeps it as Untitled. Web addresses
diff --git a/userdocs/llms-full.txt b/userdocs/llms-full.txt
index 465f60a4..d28e470e 100644
--- a/userdocs/llms-full.txt
+++ b/userdocs/llms-full.txt
@@ -398,9 +398,12 @@ connected by links, grouped into areas you can drill into.
- **Paste anything from outside Mill — it lands as the right kind of
thing.** A table copied from a spreadsheet or a document app becomes
a table on the board, ready to browse and edit like any other list.
- Everything else lands as a sticky note at your pointer, already
- selected — nothing else to fill in. Multiple tables in one paste each
- land as their own table, offset so you can tell them apart.
+ A pasted file path lands what dropping the file would: a document
+ becomes a card, a diagram or spreadsheet file its own board object,
+ a folder path opens the folder import. Everything else lands as a
+ sticky note at your pointer, already selected — nothing else to
+ fill in. Multiple tables in one paste each land as their own table,
+ offset so you can tell them apart.
- **Create by pointing.** Press C (or pick Card in the toolbar) and
click — the card appears right there and you name it in place;
Enter keeps the name, Escape keeps it as Untitled. Web addresses