Let the native clipboard through where the canvas has nothing to copy
A bound chord used to be `preventDefault`ed before its handler ran, so the canvas took every ⌘C and ⌘V whether or not the node clipboard would act — which is what blocked copying selected text out of the chrome floating over the canvas. A binding may now return `false` to decline the key; ⌘C does so when there is a text selection or no node selected, ⌘V when nothing of ours is stored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CL9zvnnvcp1mvA8o7impxk
This commit is contained in:
@@ -1061,8 +1061,21 @@ function FlowEditorInner({
|
|||||||
{
|
{
|
||||||
"mod+z": () => step(true),
|
"mod+z": () => step(true),
|
||||||
"mod+shift+z": () => step(false),
|
"mod+shift+z": () => step(false),
|
||||||
"mod+c": () => void copyNodes(),
|
// Both decline the key unless the node clipboard has something to do
|
||||||
"mod+v": pasteNodes,
|
// 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
|
// ⌘P, not ⌘K: the sidebar's global search owns that everywhere, and this
|
||||||
// palette is the canvas's own, narrower thing.
|
// palette is the canvas's own, narrower thing.
|
||||||
"mod+p": () => setPaletteOpen((open) => !open),
|
"mod+p": () => setPaletteOpen((open) => !open),
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import { useEffect, useRef } from "react"
|
|||||||
* A chord reads as `mod+shift+z`, where `mod` is ⌘ on a Mac and Ctrl
|
* 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
|
* everywhere else. Bindings are plain data, so a view declares the whole set
|
||||||
* it answers to in one place.
|
* 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<string, (event: KeyboardEvent) => void>
|
export type Shortcuts = Record<string, (event: KeyboardEvent) => unknown>
|
||||||
|
|
||||||
/** Text fields and the code editor keep their own bindings and their own undo. */
|
/** Text fields and the code editor keep their own bindings and their own undo. */
|
||||||
export function isTextEntry(target: EventTarget | null): boolean {
|
export function isTextEntry(target: EventTarget | null): boolean {
|
||||||
@@ -55,8 +59,8 @@ export function useShortcuts(bindings: Shortcuts, inTextEntry: string[] = []) {
|
|||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (run(event) === false) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
run(event)
|
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", onKeyDown)
|
window.addEventListener("keydown", onKeyDown)
|
||||||
return () => window.removeEventListener("keydown", onKeyDown)
|
return () => window.removeEventListener("keydown", onKeyDown)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user