diff --git a/apps/desktop/src/components/editor/SqlPreviewPanel.vue b/apps/desktop/src/components/editor/SqlPreviewPanel.vue index 18a1871bd..5edb79065 100644 --- a/apps/desktop/src/components/editor/SqlPreviewPanel.vue +++ b/apps/desktop/src/components/editor/SqlPreviewPanel.vue @@ -182,10 +182,10 @@ onBeforeUnmount(() => { -
+
-
{{ displaySql }}
+
{{ displaySql }}
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 9638814a0..80a1e2912 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -129,7 +129,7 @@ import { parseClipboardTable } from "@/lib/gridSelection"; import { useToast } from "@/composables/useToast"; import { useDataGridExport } from "@/composables/useDataGridExport"; -import { readTextFromClipboard } from "@/lib/clipboard"; +import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/clipboard"; import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue"; import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; import { useDataGridSelection } from "@/composables/useDataGridSelection"; @@ -4746,25 +4746,6 @@ function openImagePreview(src: string, title: string) { imagePreviewOpen.value = true; } -function selectionNodeElement(node: Node | null): Element | null { - if (!node) return null; - return node instanceof Element ? node : node.parentElement; -} - -function hasNativeClipboardSelection(): boolean { - const selection = window.getSelection(); - if (!selection || selection.isCollapsed) return false; - const anchorRegion = selectionNodeElement(selection.anchorNode)?.closest("[data-native-clipboard]"); - const focusRegion = selectionNodeElement(selection.focusNode)?.closest("[data-native-clipboard]"); - return !!anchorRegion && anchorRegion === focusRegion; -} - -function eventTargetAllowsNativeClipboard(event: KeyboardEvent): boolean { - const target = event.target as HTMLElement | null; - if (target?.closest("input, textarea, [contenteditable='true'], [role='textbox']")) return true; - return clipboardShortcut(event, "c") && hasNativeClipboardSelection(); -} - function onDrawerContextMenu(event: MouseEvent) { event.stopPropagation(); const target = event.target as HTMLElement | null; @@ -4773,7 +4754,7 @@ function onDrawerContextMenu(event: MouseEvent) { } function clipboardShortcut(event: KeyboardEvent, key: string): boolean { - return (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === key; + return isPlainClipboardShortcut(event, key); } let lastPasteEventAt = 0; diff --git a/apps/desktop/src/lib/clipboard.ts b/apps/desktop/src/lib/clipboard.ts index da60ce809..716c95bce 100644 --- a/apps/desktop/src/lib/clipboard.ts +++ b/apps/desktop/src/lib/clipboard.ts @@ -37,6 +37,55 @@ export interface ClipboardEnvironment { document?: ClipboardDocument; } +export interface ClipboardShortcutEvent { + key: string; + metaKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + target?: EventTarget | null; +} + +interface SelectionLike { + anchorNode: Node | null; + focusNode: Node | null; + isCollapsed: boolean; +} + +interface NativeClipboardSelectionEnvironment { + getSelection?: () => SelectionLike | null; +} + +const EDITABLE_CLIPBOARD_TARGET_SELECTOR = "input, textarea, [contenteditable='true'], [role='textbox']"; +const NATIVE_CLIPBOARD_REGION_SELECTOR = "[data-native-clipboard]"; + +function closestElement(target: unknown, selector: string): unknown { + return (target as { closest?: (selector: string) => unknown } | null)?.closest?.(selector) ?? null; +} + +function selectionNodeElement(node: Node | null): Element | null { + if (!node) return null; + if (typeof Element !== "undefined" && node instanceof Element) return node; + return (node as { parentElement?: Element | null }).parentElement ?? null; +} + +export function isPlainClipboardShortcut(event: ClipboardShortcutEvent, key: string): boolean { + return !!(event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === key; +} + +export function hasNativeClipboardSelection(env: NativeClipboardSelectionEnvironment = globalThis as unknown as NativeClipboardSelectionEnvironment): boolean { + const selection = env.getSelection?.(); + if (!selection || selection.isCollapsed) return false; + const anchorRegion = selectionNodeElement(selection.anchorNode)?.closest(NATIVE_CLIPBOARD_REGION_SELECTOR); + const focusRegion = selectionNodeElement(selection.focusNode)?.closest(NATIVE_CLIPBOARD_REGION_SELECTOR); + return !!anchorRegion && anchorRegion === focusRegion; +} + +export function eventTargetAllowsNativeClipboard(event: ClipboardShortcutEvent, env: NativeClipboardSelectionEnvironment = globalThis as unknown as NativeClipboardSelectionEnvironment): boolean { + if (closestElement(event.target, EDITABLE_CLIPBOARD_TARGET_SELECTOR)) return true; + return isPlainClipboardShortcut(event, "c") && hasNativeClipboardSelection(env); +} + export async function readTextFromClipboard(env: ClipboardEnvironment = globalThis as unknown as ClipboardEnvironment): Promise { if (isTauriRuntime(env as unknown as Record)) { try { diff --git a/packages/app-tests/clipboard.test.ts b/packages/app-tests/clipboard.test.ts index 08d2bd267..1c9f4ec29 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, readTextFromClipboard } from "../../apps/desktop/src/lib/clipboard.ts"; +import { copyToClipboard, eventTargetAllowsNativeClipboard, hasNativeClipboardSelection, isPlainClipboardShortcut, readTextFromClipboard } from "../../apps/desktop/src/lib/clipboard.ts"; test("copyToClipboard falls back when navigator clipboard is unavailable", async () => { const appended: unknown[] = []; @@ -60,3 +60,55 @@ test("readTextFromClipboard uses navigator clipboard when available", async () = assert.equal(text, "orders\t42"); }); + +test("clipboard shortcut detection requires a plain mod shortcut", () => { + assert.equal(isPlainClipboardShortcut({ key: "C", ctrlKey: true }, "c"), true); + assert.equal(isPlainClipboardShortcut({ key: "c", metaKey: true }, "c"), true); + assert.equal(isPlainClipboardShortcut({ key: "c", ctrlKey: true, shiftKey: true }, "c"), false); + assert.equal(isPlainClipboardShortcut({ key: "c", altKey: true }, "c"), false); +}); + +test("eventTargetAllowsNativeClipboard lets editable targets keep clipboard shortcuts", () => { + const inputTarget = { + closest: (selector: string) => (selector.includes("input") ? {} : null), + } as unknown as EventTarget; + + assert.equal(eventTargetAllowsNativeClipboard({ key: "v", ctrlKey: true, target: inputTarget }), true); +}); + +test("hasNativeClipboardSelection detects selections inside one native clipboard region", () => { + const region = {}; + const element = { + closest: (selector: string) => (selector === "[data-native-clipboard]" ? region : null), + }; + const textNode = { parentElement: element } as unknown as Node; + + assert.equal( + hasNativeClipboardSelection({ + getSelection: () => ({ + anchorNode: textNode, + focusNode: textNode, + isCollapsed: false, + }), + }), + true, + ); +}); + +test("eventTargetAllowsNativeClipboard lets native regions handle copy only with a text selection", () => { + const region = {}; + const element = { + closest: (selector: string) => (selector === "[data-native-clipboard]" ? region : null), + }; + const textNode = { parentElement: element } as unknown as Node; + const env = { + getSelection: () => ({ + anchorNode: textNode, + focusNode: textNode, + isCollapsed: false, + }), + }; + + assert.equal(eventTargetAllowsNativeClipboard({ key: "c", ctrlKey: true }, env), true); + assert.equal(eventTargetAllowsNativeClipboard({ key: "x", ctrlKey: true }, env), false); +});