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