diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index 3f2da2b..08224f1 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -1061,8 +1061,21 @@ function FlowEditorInner({ { "mod+z": () => step(true), "mod+shift+z": () => step(false), - "mod+c": () => void copyNodes(), - "mod+v": pasteNodes, + // Both decline the key unless the node clipboard has something to do + // with it, so the native copy and paste still work on the canvas. A + // text selection wins outright: that is what the user asked to copy. + "mod+c": () => { + const selection = document.getSelection() + if (selection && !selection.isCollapsed) return false + if (!canvasNodes.some((node) => node.selected) && !selectedId) { + return false + } + void copyNodes() + }, + "mod+v": () => { + if (!safeStorage.get(CLIPBOARD_KEY)) return false + pasteNodes() + }, // ⌘P, not ⌘K: the sidebar's global search owns that everywhere, and this // palette is the canvas's own, narrower thing. "mod+p": () => setPaletteOpen((open) => !open), diff --git a/frontend/src/lib/shortcuts.ts b/frontend/src/lib/shortcuts.ts index 8a6004f..e203883 100644 --- a/frontend/src/lib/shortcuts.ts +++ b/frontend/src/lib/shortcuts.ts @@ -6,8 +6,12 @@ import { useEffect, useRef } from "react" * A chord reads as `mod+shift+z`, where `mod` is ⌘ on a Mac and Ctrl * everywhere else. Bindings are plain data, so a view declares the whole set * it answers to in one place. + * + * A binding that returns `false` declines the key: it did nothing, so the + * browser's own default runs. That is how ⌘C stays a native copy whenever the + * canvas has nothing of its own to copy. */ -export type Shortcuts = Record void> +export type Shortcuts = Record unknown> /** Text fields and the code editor keep their own bindings and their own undo. */ export function isTextEntry(target: EventTarget | null): boolean { @@ -55,8 +59,8 @@ export function useShortcuts(bindings: Shortcuts, inTextEntry: string[] = []) { ) { return } + if (run(event) === false) return event.preventDefault() - run(event) } window.addEventListener("keydown", onKeyDown) return () => window.removeEventListener("keydown", onKeyDown) diff --git a/frontend/tests/clipboard.spec.ts b/frontend/tests/clipboard.spec.ts new file mode 100644 index 0000000..4aa42f9 --- /dev/null +++ b/frontend/tests/clipboard.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from "@playwright/test" +import { deleteAll } from "./utils/api" + +const flowName = `test_clip_${Date.now().toString(36)}` +const KEY = "fluksio.nodeClipboard" + +declare global { + interface Window { + /** Whether the app took the last ⌘C/⌘V away from the browser. */ + prevented: boolean | null + } +} + +test.use({ + storageState: "playwright/.auth/user.json", + permissions: ["clipboard-read", "clipboard-write"], +}) + +test.afterAll(async ({ browser }) => { + await deleteAll(browser, [`/flows/${flowName}`]) +}) + +test("the canvas clipboard yields to the native one", async ({ page }) => { + await page.goto("/flows") + await page.getByTestId("new-flow").click() + await page.getByTestId("new-flow-name").fill(flowName) + await page.getByTestId("create-flow").click() + await page.waitForURL(`/flows/${flowName}`) + + await page.getByTestId("add-node").click() + await page + .getByRole("option", { name: /function/i }) + .first() + .click() + await expect(page.locator(".react-flow__node")).toHaveCount(1) + await page.keyboard.press("Escape") + + // Records whether the app took the chord away from the browser. + await page.evaluate((key) => { + localStorage.removeItem(key) + window.prevented = null + window.addEventListener("keydown", (event) => { + if (["c", "v"].includes(event.key.toLowerCase())) { + window.prevented = event.defaultPrevented + } + }) + }, KEY) + const prevented = () => page.evaluate(() => window.prevented) + + // A node selected and no text selection: the canvas clipboard acts. + await page.locator(".react-flow__node").first().click() + await page.keyboard.press("ControlOrMeta+c") + await expect + .poll(() => page.evaluate((key) => localStorage.getItem(key), KEY)) + .not.toBeNull() + expect(await prevented()).toBe(true) + + // And it still pastes. + await page.keyboard.press("ControlOrMeta+v") + await expect(page.locator(".react-flow__node")).toHaveCount(2) + expect(await prevented()).toBe(true) + + // Seeded before the selection is made: writing the clipboard drops it. + await page.evaluate(() => navigator.clipboard.writeText("nothing copied")) + + // A text selection wins, node selection or not. React Flow's own CSS puts + // `user-select: none` on every node, so the selectable text on this route is + // the floating chrome over the canvas — here the open node panel. + const selected = await page.evaluate(() => { + const node = document.querySelector( + '[data-testid="node-panel"]', + ) as HTMLElement + const range = document.createRange() + range.selectNodeContents(node) + const selection = document.getSelection() as Selection + selection.removeAllRanges() + selection.addRange(range) + return selection.toString().trim() + }) + expect(selected).not.toBe("") + await page.keyboard.press("ControlOrMeta+c") + expect(await prevented()).toBe(false) + const copied = await page.evaluate(() => navigator.clipboard.readText()) + expect(copied.trim()).not.toBe("nothing copied") + expect(selected).toContain(copied.trim().split("\n")[0]) + + // Nothing stored: paste falls through to the browser too. + await page.evaluate((key) => { + localStorage.removeItem(key) + document.getSelection()?.removeAllRanges() + }, KEY) + await page.keyboard.press("ControlOrMeta+v") + expect(await prevented()).toBe(false) +})