fix(grid): preserve null clipboard metadata

This commit is contained in:
zipg 2026-07-21 11:13:21 +08:00 committed by GitHub
parent 8632a0f261
commit c4052074b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 138 additions and 19 deletions

View File

@ -157,7 +157,7 @@ import { allNullColumnIndexes } from "@/lib/dataGrid/dataGridColumnVisibility";
import { buildDataGridColumnLookupItems, filterDataGridColumnLookupItems } from "@/lib/dataGrid/dataGridColumnLookup";
import { uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder";
import { dataGridColumnLayoutScopeKey, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { parseClipboardTable, summarizeSelection } from "@/lib/dataGrid/gridSelection";
import { summarizeSelection } from "@/lib/dataGrid/gridSelection";
import {
createDataGridCellContextMenuItems,
createDataGridColumnContextMenuItems,
@ -173,7 +173,7 @@ import {
import { useToast } from "@/composables/useToast";
import { useDataGridExport } from "@/composables/useDataGridExport";
import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard";
import { claimDataGridPaste, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState";
import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
@ -4912,6 +4912,10 @@ function resumeCanvasGridWork() {
});
}
function clearInternalClipboardCopy() {
clearDataGridClipboardCopy();
}
onMounted(resumeCanvasGridWork);
onActivated(resumeCanvasGridWork);
onMounted(() => {
@ -4920,6 +4924,8 @@ onMounted(() => {
window.visualViewport?.addEventListener("resize", scheduleCanvasPixelRatioRefresh);
window.addEventListener("dbx:ui-scale-applied", scheduleCanvasPixelRatioRefresh);
window.addEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onTableDataGridColumnOrderChanged);
window.addEventListener("blur", clearInternalClipboardCopy);
document.addEventListener("visibilitychange", clearInternalClipboardCopy);
});
onDeactivated(pauseCanvasGridWork);
onUnmounted(() => {
@ -4936,6 +4942,8 @@ onUnmounted(() => {
window.visualViewport?.removeEventListener("resize", scheduleCanvasPixelRatioRefresh);
window.removeEventListener("dbx:ui-scale-applied", scheduleCanvasPixelRatioRefresh);
window.removeEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onTableDataGridColumnOrderChanged);
window.removeEventListener("blur", clearInternalClipboardCopy);
document.removeEventListener("visibilitychange", clearInternalClipboardCopy);
});
function setRowStatusFilter(value: string) {
@ -5295,7 +5303,7 @@ async function pasteClipboardIntoSelection() {
}
function pasteTextIntoSelection(text: string): boolean {
const rows = parseClipboardTable(text);
const rows = parseDataGridClipboard(text);
const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow();
if (rows.length === 1 && rows[0]?.length === 1 && fillSelectionWithValue(rows[0][0])) {

View File

@ -7,6 +7,7 @@ import { useToast } from "@/composables/useToast";
import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/connection/actionActivation";
import { copyToClipboard } from "@/lib/common/clipboard";
import { clearDataGridClipboardCopy, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard";
import { buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements, type DataGridCopyInsertMode, type DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
import { formatSqlInsert, formatTsv } from "@/lib/export/exportFormats";
import { uuid } from "@/lib/common/utils";
@ -210,9 +211,12 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
} = options;
const selectedCellMatrix = selectedCellMatrixOption ?? computed<CellSelectionMatrix | null>(() => null);
async function copyText(text: string) {
async function copyText(text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; includeHeader?: boolean }) {
const copiedRows = gridCopy?.rows.map((row) => [...row]);
clearDataGridClipboardCopy();
try {
await copyToClipboard(text);
if (copiedRows) rememberDataGridClipboardCopy(text, copiedRows, gridCopy?.includeHeader);
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
@ -684,12 +688,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
// --- Selection copy functions ---
async function copySelectionTsv() {
if (!hasCellSelection.value) return;
await copyText(formatSelectionAsTsv(selectedCells.value));
const selection = selectedCells.value;
await copyText(formatSelectionAsTsv(selection), { rows: selection.rows });
}
async function copySelectionTsvWithHeaders() {
if (!hasCellSelection.value) return;
await copyText(formatSelectionAsTsv(selectedCells.value, true));
const selection = selectedCells.value;
await copyText(formatSelectionAsTsv(selection, true), { rows: selection.rows, includeHeader: true });
}
async function copySelectionCsv() {
@ -711,14 +717,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
if (!hasRowSelection.value || selectedRowIds.value.size === 0) return;
const rows = displayItems.value.filter((item) => selectedRowIds.value.has(item.id) && !item.isDraft).map((item) => item.data);
if (rows.length === 0) return;
await copyText(formatSelectionAsTsv({ columns: columns.value, rows }));
await copyText(formatSelectionAsTsv({ columns: columns.value, rows }), { rows });
}
async function copySelectedRowsTsvWithHeaders() {
if (!hasRowSelection.value || selectedRowIds.value.size === 0) return;
const rows = displayItems.value.filter((item) => selectedRowIds.value.has(item.id) && !item.isDraft).map((item) => item.data);
if (rows.length === 0) return;
await copyText(formatSelectionAsTsv({ columns: columns.value, rows }, true));
await copyText(formatSelectionAsTsv({ columns: columns.value, rows }, true), { rows, includeHeader: true });
}
async function copyColumnNames() {
@ -753,7 +759,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
const item = getRowItem(contextCell.value.rowId);
if (!item || item.isDraft) return;
const val = item?.data[contextCell.value.col] ?? null;
await copyText(displayCellValue(val));
await copyText(displayCellValue(val), { rows: [[val]] });
}
async function copyRow() {
@ -823,11 +829,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function copyAll() {
const header = columns.value.join("\t");
const body = displayItems.value
.filter((item) => !item.isDraft)
.map((item) => item.data.map((c) => displayCellValue(c)).join("\t"))
.join("\n");
await copyText(`${header}\n${body}`);
const rows = displayItems.value.filter((item) => !item.isDraft).map((item) => item.data);
const body = rows.map((row) => row.map((cell) => displayCellValue(cell)).join("\t")).join("\n");
await copyText(`${header}\n${body}`, { rows, includeHeader: true });
}
// --- Export functions ---

View File

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

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

View File

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