fix(grid): preserve null clipboard metadata
This commit is contained in:
parent
8632a0f261
commit
c4052074b8
|
|
@ -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])) {
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue