From 041098000c812a7c39f7627d1480a143f03433bc Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Sat, 29 Aug 2026 19:15:00 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --- frontend/e2e/atlas-paste-convert.spec.ts | 105 +++++++++++++++++- frontend/src/atlas/AtlasBoard.tsx | 22 +--- frontend/src/atlas/atlasCreateHelpers.test.ts | 42 ++++++- frontend/src/atlas/atlasCreateHelpers.ts | 18 +++ frontend/src/atlas/useAtlasClipboard.ts | 7 ++ frontend/src/atlas/useAtlasDeleteKey.ts | 37 ++++++ frontend/src/atlas/useAtlasNativeFileDrop.ts | 33 ++++-- frontend/src/atlas/useAtlasPaste.ts | 65 +++++++---- .../src/composition/useCanvasClipboard.ts | 8 ++ frontend/src/shared/modalGate.ts | 12 ++ userdocs/concepts/atlas.md | 9 +- userdocs/llms-full.txt | 9 +- 12 files changed, 310 insertions(+), 57 deletions(-) create mode 100644 frontend/src/atlas/useAtlasDeleteKey.ts create mode 100644 frontend/src/shared/modalGate.ts 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