fix(grid): preserve null clipboard metadata

This commit is contained in:
zipg authored and GitHub committed 2026-07-21 11:13:21 +08:00
1 parent 8632a0f261
commit c4052074b8
5 files changed
+138 -19

No files matched your search

@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { claimDataGridPaste, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
import { copyToClipboard } from "@/lib/common/clipboard";
import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard";
afterEach(() => clearDataGridClipboardCopy());
function target(nativeClipboard: boolean): EventTarget {
return {
@@ -72,3 +75,62 @@ describe("planDataGridPaste", () => {
expect(planDataGridPaste([["a"]], 1, 0)).toEqual([]);
});
});
describe("parseDataGridClipboard", () => {
it("restores null values copied from the DBX grid", () => {
rememberDataGridClipboardCopy("NULL\tNULL", [[null, "NULL"]]);
expect(parseDataGridClipboard("NULL\tNULL")).toEqual([[null, "NULL"]]);
});
it("keeps null text from external clipboard content as strings", () => {
rememberDataGridClipboardCopy("NULL", [[null]]);
clearDataGridClipboardCopy();
expect(parseDataGridClipboard("NULL")).toEqual([["NULL"]]);
expect(parseDataGridClipboard("null")).toEqual([["null"]]);
});
it("does not reuse null metadata after a literal NULL is copied", () => {
rememberDataGridClipboardCopy("NULL", [[null]]);
rememberDataGridClipboardCopy("NULL", [["NULL"]]);
expect(parseDataGridClipboard("NULL")).toEqual([["NULL"]]);
});
it("invalidates null metadata after another successful in-app copy", async () => {
rememberDataGridClipboardCopy("NULL", [[null]]);
await copyToClipboard("NULL", { navigator: { clipboard: { writeText: vi.fn() } } });
expect(parseDataGridClipboard("NULL")).toEqual([["NULL"]]);
});
it("keeps null metadata when a later in-app copy fails", async () => {
rememberDataGridClipboardCopy("NULL", [[null]]);
await expect(copyToClipboard("NULL", { navigator: { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) } } })).rejects.toThrow("Clipboard API is not available");
expect(parseDataGridClipboard("NULL")).toEqual([[null]]);
});
it("preserves null positions beside cells containing tabs and newlines", () => {
const text = "left\tinside\tNULL\nline 1\nline 2\ttail";
rememberDataGridClipboardCopy(text, [
["left\tinside", null],
["line 1\nline 2", "tail"],
]);
expect(parseDataGridClipboard(text)).toEqual([
["left\tinside", null],
["line 1\nline 2", "tail"],
]);
});
it("restores null positions after copied headers", () => {
rememberDataGridClipboardCopy("name\tnote\nAda\tNULL", [["Ada", null]], true);
expect(parseDataGridClipboard("name\tnote\nAda\tNULL")).toEqual([
["name", "note"],
["Ada", null],
]);
});
});
+12
View File
@@ -58,6 +58,15 @@ interface NativeClipboardSelectionEnvironment {
const EDITABLE_CLIPBOARD_TARGET_SELECTOR = "input, textarea, [contenteditable='true'], [role='textbox']";
const NATIVE_CLIPBOARD_REGION_SELECTOR = "[data-native-clipboard]";
let clipboardWriteRevision = 0;
export function getClipboardWriteRevision(): number {
return clipboardWriteRevision;
}
function recordClipboardWrite(): void {
clipboardWriteRevision += 1;
}
function closestElement(target: unknown, selector: string): unknown {
return (target as { closest?: (selector: string) => unknown } | null)?.closest?.(selector) ?? null;
@@ -117,6 +126,7 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment =
try {
const { writeText } = await import("@tauri-apps/plugin-clipboard-manager");
await writeText(text);
recordClipboardWrite();
return;
} catch {
// Preserve Web Clipboard and legacy copy compatibility when native writes fail.
@@ -126,6 +136,7 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment =
try {
if (env.navigator?.clipboard?.writeText) {
await env.navigator.clipboard.writeText(text);
recordClipboardWrite();
return;
}
} catch {
@@ -153,6 +164,7 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment =
if (!document.execCommand("copy")) {
throw new Error("Clipboard copy failed");
}
recordClipboardWrite();
} finally {
document.body.removeChild(textarea);
}
@@ -1,13 +1,23 @@
import { eventTargetUsesNativeClipboard } from "@/lib/common/clipboard";
import { eventTargetUsesNativeClipboard, getClipboardWriteRevision } from "@/lib/common/clipboard";
import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
import { parseClipboardTable } from "@/lib/dataGrid/gridSelection";
export type DataGridPasteIntent = "native" | "block" | "paste";
export interface DataGridPasteCell {
rowOffset: number;
columnOffset: number;
value: string;
value: string | null;
}
interface InternalDataGridClipboardCopy {
text: string;
rows: Array<Array<string | null>>;
writeRevision: number;
}
let internalClipboardCopy: InternalDataGridClipboardCopy | null = null;
interface DataGridPasteEvent {
target?: EventTarget | null;
preventDefault(): void;
@@ -21,7 +31,30 @@ export function claimDataGridPaste(event: DataGridPasteEvent, editable: boolean,
return editable && hasSelection ? "paste" : "block";
}
export function planDataGridPaste(rows: readonly (readonly string[])[], maxRows: number, maxColumns: number): DataGridPasteCell[] {
export function clearDataGridClipboardCopy(): void {
internalClipboardCopy = null;
}
export function rememberDataGridClipboardCopy(text: string, rows: readonly (readonly unknown[])[], includeHeader = false): void {
if (!rows.some((row) => row.some((value) => value === null))) {
internalClipboardCopy = null;
return;
}
// Preserve the logical grid matrix because plain TSV cannot escape embedded tabs or newlines.
const headerRows = includeHeader ? parseClipboardTable(text).slice(0, 1) : [];
const copiedRows = rows.map((row) => row.map((value) => (value === null ? null : displayCellValue(value as CellValue))));
internalClipboardCopy = { text, rows: [...headerRows, ...copiedRows], writeRevision: getClipboardWriteRevision() };
}
export function parseDataGridClipboard(text: string): Array<Array<string | null>> {
if (internalClipboardCopy?.text === text && internalClipboardCopy.writeRevision === getClipboardWriteRevision()) {
return internalClipboardCopy.rows.map((row) => [...row]);
}
return parseClipboardTable(text);
}
export function planDataGridPaste(rows: readonly (readonly (string | null)[])[], maxRows: number, maxColumns: number): DataGridPasteCell[] {
if (maxRows <= 0 || maxColumns <= 0) return [];
const cells: DataGridPasteCell[] = [];
rows.slice(0, maxRows).forEach((row, rowOffset) => {