fix(grid): prevent duplicate paste handling

This commit is contained in:
t8y2 2026-07-15 16:06:54 +08:00
parent b11f38d2df
commit 977f47a65f
5 changed files with 84 additions and 16 deletions

View File

@ -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;
}
}

View File

@ -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();
});
});

View File

@ -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<ClipboardShortcutEvent, "target">): 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<string> {

View File

@ -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";
}

View File

@ -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", () => {