fix(grid): preserve null clipboard metadata
This commit is contained in:
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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user