diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 64fb40e9d..e49d9988f 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -212,6 +212,7 @@ import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, import { useToast } from "@/composables/useToast"; import { useDataGridExport } from "@/composables/useDataGridExport"; import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard"; +import { claimDataGridPaste } from "@/lib/dataGrid/dataGridClipboard"; import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue"; import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; import { useDataGridSelection } from "@/composables/useDataGridSelection"; @@ -6918,8 +6919,6 @@ function clipboardShortcut(event: KeyboardEvent, key: string): boolean { return isPlainClipboardShortcut(event, key); } -let lastPasteEventAt = 0; - async function pasteClipboardIntoSelection() { if (!props.editable) return; const text = await readTextFromClipboard(); @@ -6952,13 +6951,11 @@ function pasteTextIntoSelection(text: string): boolean { } function onGridPaste(event: ClipboardEvent) { - if (!props.editable || (!selectedRange.value && !hasColumnSelection.value)) return; - const target = event.target as HTMLElement | null; - if (target?.closest("input, textarea, [contenteditable='true'], [role='textbox']")) return; + const intent = claimDataGridPaste(event, props.editable, !!selectedRange.value || hasColumnSelection.value); + if (intent === "native") return; + if (intent === "block") return; const text = event.clipboardData?.getData("text/plain"); if (text === undefined) return; - event.preventDefault(); - lastPasteEventAt = Date.now(); pasteTextIntoSelection(text); } @@ -7420,12 +7417,11 @@ async function onGridKeydown(event: KeyboardEvent) { return; } if (clipboardShortcut(event, "v")) { - if (!props.editable || (!selectedRange.value && !hasColumnSelection.value)) return; - const keydownAt = Date.now(); - window.setTimeout(() => { - if (lastPasteEventAt >= keydownAt) return; - pasteClipboardIntoSelection().catch((e) => toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000)); - }, 50); + const intent = claimDataGridPaste(event, props.editable, !!selectedRange.value || hasColumnSelection.value); + if (intent === "native") return; + // A focused grid owns the shortcut even when read-only; otherwise the webview may paste into the previously focused SQL editor. + if (intent === "block") return; + pasteClipboardIntoSelection().catch((e) => toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000)); return; } } diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridClipboard.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridClipboard.spec.ts new file mode 100644 index 000000000..5746ad07d --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridClipboard.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; + +import { claimDataGridPaste } from "@/lib/dataGrid/dataGridClipboard"; + +function target(nativeClipboard: boolean): EventTarget { + return { + closest: () => (nativeClipboard ? {} : null), + } as unknown as EventTarget; +} + +function pasteEvent(nativeClipboard: boolean) { + return { + target: target(nativeClipboard), + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; +} + +describe("claimDataGridPaste", () => { + it("keeps native paste behavior for editors inside the grid", () => { + const event = pasteEvent(true); + + expect(claimDataGridPaste(event, true, true)).toBe("native"); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.stopPropagation).not.toHaveBeenCalled(); + }); + + it("owns and applies paste for editable grid selections", () => { + const event = pasteEvent(false); + + expect(claimDataGridPaste(event, true, true)).toBe("paste"); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopPropagation).toHaveBeenCalledOnce(); + }); + + it("blocks paste for read-only results", () => { + const event = pasteEvent(false); + + expect(claimDataGridPaste(event, false, true)).toBe("block"); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopPropagation).toHaveBeenCalledOnce(); + }); + + it("blocks paste when the grid has no selection target", () => { + const event = pasteEvent(false); + + expect(claimDataGridPaste(event, true, false)).toBe("block"); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopPropagation).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/desktop/src/lib/common/clipboard.ts b/apps/desktop/src/lib/common/clipboard.ts index f4ba38b3e..0f419c08e 100644 --- a/apps/desktop/src/lib/common/clipboard.ts +++ b/apps/desktop/src/lib/common/clipboard.ts @@ -63,6 +63,10 @@ function closestElement(target: unknown, selector: string): unknown { return (target as { closest?: (selector: string) => unknown } | null)?.closest?.(selector) ?? null; } +export function eventTargetUsesNativeClipboard(event: Pick): boolean { + return !!closestElement(event.target, EDITABLE_CLIPBOARD_TARGET_SELECTOR); +} + function selectionNodeElement(node: Node | null): Element | null { if (!node) return null; if (typeof Element !== "undefined" && node instanceof Element) return node; @@ -82,13 +86,13 @@ export function hasNativeClipboardSelection(env: NativeClipboardSelectionEnviron } export function eventTargetAllowsNativeClipboard(event: ClipboardShortcutEvent, env: NativeClipboardSelectionEnvironment = globalThis as unknown as NativeClipboardSelectionEnvironment): boolean { - if (closestElement(event.target, EDITABLE_CLIPBOARD_TARGET_SELECTOR)) return true; + if (eventTargetUsesNativeClipboard(event)) return true; return isPlainClipboardShortcut(event, "c") && hasNativeClipboardSelection(env); } export function eventTargetAllowsAppClipboardShortcut(event: ClipboardShortcutEvent, key = "v"): boolean { if (!isPlainClipboardShortcut(event, key)) return false; - return !closestElement(event.target, EDITABLE_CLIPBOARD_TARGET_SELECTOR); + return !eventTargetUsesNativeClipboard(event); } export async function readTextFromClipboard(env: ClipboardEnvironment = globalThis as unknown as ClipboardEnvironment): Promise { diff --git a/apps/desktop/src/lib/dataGrid/dataGridClipboard.ts b/apps/desktop/src/lib/dataGrid/dataGridClipboard.ts new file mode 100644 index 000000000..9e2aa1874 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridClipboard.ts @@ -0,0 +1,16 @@ +import { eventTargetUsesNativeClipboard } from "@/lib/common/clipboard"; + +export type DataGridPasteIntent = "native" | "block" | "paste"; + +interface DataGridPasteEvent { + target?: EventTarget | null; + preventDefault(): void; + stopPropagation(): void; +} + +export function claimDataGridPaste(event: DataGridPasteEvent, editable: boolean, hasSelection: boolean): DataGridPasteIntent { + if (eventTargetUsesNativeClipboard(event)) return "native"; + event.preventDefault(); + event.stopPropagation(); + return editable && hasSelection ? "paste" : "block"; +} diff --git a/packages/app-tests/clipboard.test.ts b/packages/app-tests/clipboard.test.ts index cb1fd4e0e..1d255a250 100644 --- a/packages/app-tests/clipboard.test.ts +++ b/packages/app-tests/clipboard.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { copyToClipboard, eventTargetAllowsAppClipboardShortcut, eventTargetAllowsNativeClipboard, hasNativeClipboardSelection, isPlainClipboardShortcut, readTextFromClipboard } from "../../apps/desktop/src/lib/common/clipboard.ts"; +import { copyToClipboard, eventTargetAllowsAppClipboardShortcut, eventTargetAllowsNativeClipboard, eventTargetUsesNativeClipboard, hasNativeClipboardSelection, isPlainClipboardShortcut, readTextFromClipboard } from "../../apps/desktop/src/lib/common/clipboard.ts"; test("copyToClipboard falls back when navigator clipboard is unavailable", async () => { const appended: unknown[] = []; @@ -74,6 +74,7 @@ test("eventTargetAllowsNativeClipboard lets editable targets keep clipboard shor } as unknown as EventTarget; assert.equal(eventTargetAllowsNativeClipboard({ key: "v", ctrlKey: true, target: inputTarget }), true); + assert.equal(eventTargetUsesNativeClipboard({ target: inputTarget }), true); }); test("eventTargetAllowsAppClipboardShortcut ignores editable targets only", () => {