fix(grid): prevent duplicate paste handling
This commit is contained in:
parent
b11f38d2df
commit
977f47a65f
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue