feat(data-grid): 支持表格多格式复制

- 新增 DataGrid 剪贴板 payload 构建工具,统一生成 plain text、HTML、CSV、Markdown、JSON 等格式
- 将单元格选择复制、行复制、列复制从纯文本复制升级为富格式复制
- Ctrl/Cmd+C 改为通过 copy 事件写入多种 MIME 类型,兼容 Excel 等表格软件粘贴
- 为多格式 payload 构建、事件写入、选区复制补充 Vitest 覆盖

Validation:
- npm --prefix frontend test -- dataGridClipboardPayload.test.ts dataGridSelectionCopy.test.ts DataGrid.ddl.test.tsx -t "copies row and column data|copies loaded column data|dataGridClipboardPayload|dataGridSelectionCopy"
- cd frontend && npx patch-package --verbose
- npm --prefix frontend run build
This commit is contained in:
liuqiufeng
2026-08-02 20:51:58 +08:00
parent bd1ae3c24a
commit fa14dcb7cb
7 changed files with 367 additions and 34 deletions

View File

@@ -71,7 +71,7 @@ import {
type CopySqlError,
} from './dataGridCopyInsert';
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
import { buildSelectedCellClipboardPayload } from './dataGridSelectionCopy';
import { buildCopiedRowsForPaste, buildPastedRowsFromCopiedRows } from './dataGridRowClipboard';
import {
buildDataGridSelectBaseSql,
@@ -84,6 +84,11 @@ import {
buildClipboardMarkdown,
pickRowsForClipboard,
} from './dataGridClipboardExport';
import {
buildTabularClipboardPayloadFromTsv,
writeClipboardPayload,
type DataGridClipboardPayload,
} from './dataGridClipboardPayload';
import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap';
import {
DEFAULT_SHORTCUT_OPTIONS,
@@ -3748,8 +3753,9 @@ const DataGrid: React.FC<DataGridProps> = ({
useEffect(() => clearAutoCommitTimer, [clearAutoCommitTimer]);
const copyToClipboard = useCallback((text: string) => {
navigator.clipboard.writeText(text).catch(console.error);
const copyToClipboard = useCallback((value: string | DataGridClipboardPayload) => {
const payload = typeof value === 'string' ? { plainText: value } : value;
writeClipboardPayload(payload).catch(console.error);
void message.success(translateDataGrid('data_grid.message.copied_to_clipboard'));
}, [translateDataGrid]);
@@ -3778,7 +3784,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const text = mergedDisplayData
.map((row) => normalizeClipboardTsvCell(formatClipboardCellText(row?.[normalizedColumnName], columnType, currentConnConfig)))
.join('\n');
copyToClipboard(text);
copyToClipboard(buildTabularClipboardPayloadFromTsv(text));
}, [columnMetaMap, columnMetaMapByLowerName, copyToClipboard, currentConnConfig, displayOutputColumnNames, mergedDisplayData, translateDataGrid]);
const {
@@ -3819,7 +3825,7 @@ const DataGrid: React.FC<DataGridProps> = ({
buildOrderBySQL,
buildPaginatedSelectSQL,
buildRpcConnectionConfig,
buildSelectedCellClipboardText,
buildSelectedCellClipboardPayload,
buildTableExportTab,
buildWhereSQL,
cellContextMenu,

View File

@@ -59,6 +59,7 @@ import {
} from './dataGridCopyInsert';
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
import type { DataGridClipboardPayload } from './dataGridClipboardPayload';
import { buildCopiedRowsForPaste, buildPastedRowsFromCopiedRows } from './dataGridRowClipboard';
import {
buildDataGridSelectBaseSql,
@@ -876,7 +877,7 @@ const DataContext = React.createContext<{
handleCopyJson: (r: any) => void;
handleCopyCsv: (r: any) => void;
handleExportSelected: (options: DataExportFileOptions, r: any) => Promise<void>;
copyToClipboard: (t: string) => void;
copyToClipboard: (t: string | DataGridClipboardPayload) => void;
tableName?: string;
enableRowContextMenu: boolean;
supportsCopyInsert: boolean;

View File

@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import {
buildTabularClipboardPayload,
buildTabularClipboardPayloadFromTsv,
writeClipboardPayloadToEvent,
} from './dataGridClipboardPayload';
describe('dataGridClipboardPayload', () => {
it('builds plain text, HTML and CSV from one table payload', () => {
const payload = buildTabularClipboardPayload({
columns: ['id', 'name'],
rows: [
['1', 'Alice & Bob'],
['2', '<Admin>'],
],
jsonRows: [
{ id: 1, name: 'Alice & Bob' },
{ id: 2, name: '<Admin>' },
],
});
expect(payload.plainText).toBe('id\tname\n1\tAlice & Bob\n2\t<Admin>');
expect(payload.csv).toBe('"id","name"\n"1","Alice & Bob"\n"2","<Admin>"');
expect(payload.html).toContain('<th>name</th>');
expect(payload.html).toContain('<td>Alice &amp; Bob</td>');
expect(payload.html).toContain('<td>&lt;Admin&gt;</td>');
expect(payload.markdown).toBe('| id | name |\n| --- | --- |\n| 1 | Alice & Bob |\n| 2 | <Admin> |');
expect(payload.json).toBe('[\n {\n "id": 1,\n "name": "Alice & Bob"\n },\n {\n "id": 2,\n "name": "<Admin>"\n }\n]');
});
it('keeps the original TSV plain text when deriving rich formats', () => {
const payload = buildTabularClipboardPayloadFromTsv('id\tname\n1\talpha', { firstRowIsHeader: true });
expect(payload.plainText).toBe('id\tname\n1\talpha');
expect(payload.html).toContain('<thead><tr><th>id</th><th>name</th></tr></thead>');
expect(payload.csv).toBe('"id","name"\n"1","alpha"');
});
it('sets multiple clipboard MIME types without overwriting different formats', () => {
const values: Record<string, string> = {};
const event = {
clipboardData: {
clearData: vi.fn(() => {
Object.keys(values).forEach((key) => delete values[key]);
}),
setData: vi.fn((type: string, value: string) => {
values[type] = value;
}),
},
preventDefault: vi.fn(),
};
const payload = buildTabularClipboardPayload({
columns: ['id'],
rows: [['1']],
jsonRows: [{ id: 1 }],
});
expect(writeClipboardPayloadToEvent(event, payload)).toBe(true);
expect(values['text/plain']).toBe('id\n1');
expect(values['text/html']).toContain('<table>');
expect(values['text/csv']).toBe('"id"\n"1"');
expect(values['text/markdown']).toBe('| id |\n| --- |\n| 1 |');
expect(values['application/json']).toBe('[\n {\n "id": 1\n }\n]');
expect(event.preventDefault).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,160 @@
export interface DataGridClipboardPayload {
plainText: string;
html?: string;
csv?: string;
markdown?: string;
json?: string;
}
export interface BuildTabularClipboardPayloadInput {
columns?: string[];
rows: string[][];
jsonRows?: Array<Record<string, unknown>>;
}
type ClipboardWriter = Pick<Clipboard, 'write' | 'writeText'>;
type ClipboardDataWriter = Pick<DataTransfer, 'clearData' | 'setData'>;
export interface ClipboardEventLike {
clipboardData?: ClipboardDataWriter | null;
preventDefault?: () => void;
}
const normalizeClipboardMatrixCell = (value: unknown): string => (
value === null || value === undefined ? '' : String(value)
);
const escapeHtml = (value: string): string => (
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
);
const escapeCsvCell = (value: string): string => `"${value.replace(/"/g, '""')}"`;
const buildDelimitedText = (rows: string[][], delimiter: string, columns?: string[]): string => {
const matrix = columns ? [columns, ...rows] : rows;
return matrix.map((row) => row.map(normalizeClipboardMatrixCell).join(delimiter)).join('\n');
};
const buildCsvText = (rows: string[][], columns?: string[]): string => {
const matrix = columns ? [columns, ...rows] : rows;
return matrix.map((row) => row.map((cell) => escapeCsvCell(normalizeClipboardMatrixCell(cell))).join(',')).join('\n');
};
const buildHtmlTable = (rows: string[][], columns?: string[]): string => {
const header = columns && columns.length > 0
? `<thead><tr>${columns.map((column) => `<th>${escapeHtml(normalizeClipboardMatrixCell(column))}</th>`).join('')}</tr></thead>`
: '';
const body = rows.map((row) => (
`<tr>${row.map((cell) => `<td>${escapeHtml(normalizeClipboardMatrixCell(cell))}</td>`).join('')}</tr>`
)).join('');
return `<meta charset="utf-8"><table>${header}<tbody>${body}</tbody></table>`;
};
const buildMarkdownText = (rows: string[][], columns?: string[]): string => {
if (!columns || columns.length === 0) return '';
const escapeMarkdownCell = (value: string): string => (
normalizeClipboardMatrixCell(value)
.replace(/\|/g, '\\|')
.replace(/\r?\n/g, ' ')
);
const header = `| ${columns.map(escapeMarkdownCell).join(' | ')} |`;
const separator = `| ${columns.map(() => '---').join(' | ')} |`;
const lines = rows.map((row) => `| ${row.map(escapeMarkdownCell).join(' | ')} |`);
return [header, separator, ...lines].join('\n');
};
export const buildTabularClipboardPayload = ({
columns,
rows,
jsonRows,
}: BuildTabularClipboardPayloadInput): DataGridClipboardPayload => ({
plainText: buildDelimitedText(rows, '\t', columns),
html: buildHtmlTable(rows, columns),
csv: buildCsvText(rows, columns),
markdown: buildMarkdownText(rows, columns) || undefined,
json: jsonRows ? JSON.stringify(jsonRows, null, 2) : undefined,
});
export const buildTabularClipboardPayloadFromTsv = (
text: string,
options: { firstRowIsHeader?: boolean } = {},
): DataGridClipboardPayload => {
const matrix = text.split('\n').map((line) => line.split('\t'));
const columns = options.firstRowIsHeader ? matrix[0] || [] : undefined;
const rows = options.firstRowIsHeader ? matrix.slice(1) : matrix;
return {
...buildTabularClipboardPayload({ columns, rows }),
plainText: text,
};
};
export const writeClipboardPayloadToEvent = (
event: ClipboardEventLike,
payload: DataGridClipboardPayload,
): boolean => {
const clipboardData = event.clipboardData;
if (!clipboardData || !payload.plainText) return false;
clipboardData.clearData();
clipboardData.setData('text/plain', payload.plainText);
if (payload.html) clipboardData.setData('text/html', payload.html);
if (payload.csv) clipboardData.setData('text/csv', payload.csv);
if (payload.markdown) clipboardData.setData('text/markdown', payload.markdown);
if (payload.json) clipboardData.setData('application/json', payload.json);
event.preventDefault?.();
return true;
};
const appendClipboardItemPart = (
parts: Record<string, Blob>,
type: string,
value: string | undefined,
supportsType?: (type: string) => boolean,
) => {
if (!value) return;
if (type !== 'text/plain') {
if (supportsType && !supportsType(type)) return;
if (!supportsType && type !== 'text/html') return;
}
parts[type] = new Blob([value], { type });
};
export const writeClipboardPayload = async (
payload: DataGridClipboardPayload,
clipboard: ClipboardWriter | undefined = globalThis.navigator?.clipboard,
): Promise<void> => {
if (!payload.plainText) return;
const ClipboardItemConstructor = typeof ClipboardItem === 'undefined' ? null : ClipboardItem;
if (clipboard?.write && ClipboardItemConstructor) {
const supportsType = typeof ClipboardItemConstructor.supports === 'function'
? ClipboardItemConstructor.supports.bind(ClipboardItemConstructor)
: undefined;
const parts: Record<string, Blob> = {};
appendClipboardItemPart(parts, 'text/plain', payload.plainText, supportsType);
appendClipboardItemPart(parts, 'text/html', payload.html, supportsType);
appendClipboardItemPart(parts, 'text/csv', payload.csv, supportsType);
appendClipboardItemPart(parts, 'text/markdown', payload.markdown, supportsType);
appendClipboardItemPart(parts, 'application/json', payload.json, supportsType);
try {
await clipboard.write([new ClipboardItemConstructor(parts)]);
return;
} catch {
// Fall back to writeText below when WebView support is partial.
}
}
if (clipboard?.writeText) {
await clipboard.writeText(payload.plainText);
return;
}
throw new Error('Clipboard write is not supported');
};

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { buildSelectedCellClipboardText, canSelectGridCellForClipboard } from './dataGridSelectionCopy';
import {
buildSelectedCellClipboardPayload,
buildSelectedCellClipboardText,
canSelectGridCellForClipboard,
} from './dataGridSelectionCopy';
describe('dataGridSelectionCopy helpers', () => {
it('allows displayed read-only cells while keeping editable expressions out of batch selection', () => {
@@ -58,4 +62,23 @@ describe('dataGridSelectionCopy helpers', () => {
expect(text).toBe('NULL\t{"a":1}\nline1 line2 value\t[1,2]');
});
it('builds a multi-format payload for selected cells', () => {
const payload = buildSelectedCellClipboardPayload({
selectedCells: [
{ rowKey: 'row-1', colName: 'name' },
{ rowKey: 'row-1', colName: 'note' },
],
rows: [
{ __rowKey: 'row-1', name: 'A&B', note: '<owner>' },
],
columnOrder: ['name', 'note'],
rowKeyField: '__rowKey',
});
expect(payload.plainText).toBe('A&B\t<owner>');
expect(payload.csv).toBe('"A&B","<owner>"');
expect(payload.html).toContain('<td>A&amp;B</td>');
expect(payload.html).toContain('<td>&lt;owner&gt;</td>');
});
});

View File

@@ -1,3 +1,5 @@
import { buildTabularClipboardPayload, type DataGridClipboardPayload } from './dataGridClipboardPayload';
export interface SelectedGridCell {
rowKey: string;
colName: string;
@@ -44,8 +46,29 @@ export const buildSelectedCellClipboardText = ({
columnOrder: string[];
rowKeyField: string;
}): string => {
const matrix = buildSelectedCellClipboardMatrix({
selectedCells,
rows,
columnOrder,
rowKeyField,
});
return matrix.map((row) => row.join('\t')).join('\n');
};
const buildSelectedCellClipboardMatrix = ({
selectedCells,
rows,
columnOrder,
rowKeyField,
}: {
selectedCells: SelectedGridCell[];
rows: Array<Record<string, any>>;
columnOrder: string[];
rowKeyField: string;
}): string[][] => {
if (!selectedCells.length || !rows.length || !columnOrder.length || !rowKeyField) {
return '';
return [];
}
const selectedRowKeys = new Set(selectedCells.map((cell) => cell.rowKey));
@@ -54,7 +77,7 @@ export const buildSelectedCellClipboardText = ({
const orderedColumns = columnOrder.filter((columnName) => selectedColumnKeys.has(columnName));
if (!orderedRows.length || !orderedColumns.length) {
return '';
return [];
}
const selectedCellKeySet = new Set(selectedCells.map((cell) => `${cell.rowKey}::${cell.colName}`));
@@ -68,8 +91,26 @@ export const buildSelectedCellClipboardText = ({
return '';
}
return normalizeClipboardCellValue(row?.[columnName]);
})
.join('\t');
})
.join('\n');
});
});
};
export const buildSelectedCellClipboardPayload = ({
selectedCells,
rows,
columnOrder,
rowKeyField,
}: {
selectedCells: SelectedGridCell[];
rows: Array<Record<string, any>>;
columnOrder: string[];
rowKeyField: string;
}): DataGridClipboardPayload => {
const matrix = buildSelectedCellClipboardMatrix({
selectedCells,
rows,
columnOrder,
rowKeyField,
});
return buildTabularClipboardPayload({ rows: matrix });
};

View File

@@ -3,6 +3,11 @@ import { message } from 'antd';
import { ExportQueryWithOptions } from '../../wailsjs/go/app/App';
import type { CopySqlError } from './dataGridCopyInsert';
import type { V2CellContextMenuActionKey, V2ColumnHeaderContextMenuActionKey } from './V2TableContextMenu';
import {
buildTabularClipboardPayloadFromTsv,
writeClipboardPayloadToEvent,
type DataGridClipboardPayload,
} from './dataGridClipboardPayload';
import {
DEFAULT_DATA_EXPORT_FORMAT,
DEFAULT_XLSX_ROWS_PER_SHEET,
@@ -35,7 +40,7 @@ export const useDataGridV2Actions = (ctx: DataGridV2ActionsContext) => {
buildOrderBySQL,
buildPaginatedSelectSQL,
buildRpcConnectionConfig,
buildSelectedCellClipboardText,
buildSelectedCellClipboardPayload,
buildTableExportTab,
buildWhereSQL,
cellContextMenu,
@@ -270,11 +275,11 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
.catch(() => message.error(translateDataGrid('data_grid.message.ddl_copy_failed')));
}, [ddlText, translateDataGrid]);
const handleCopySelectedCellsToClipboard = useCallback(() => {
const buildSelectedCellsClipboardPayload = useCallback((): DataGridClipboardPayload | null => {
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
if (activeSelection.size === 0) {
void message.info(translateDataGrid('data_grid.message.drag_select_cells_to_copy'));
return;
return null;
}
const parsed = Array.from(activeSelection)
@@ -282,22 +287,37 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
.filter((item): item is { rowKey: string; colName: string } => !!item);
if (parsed.length === 0) {
void message.info(translateDataGrid('data_grid.message.no_copyable_cells'));
return;
return null;
}
const text = buildSelectedCellClipboardText({
const payload = buildSelectedCellClipboardPayload({
selectedCells: parsed,
rows: mergedDisplayData as Array<Record<string, any>>,
columnOrder: displayColumnNames,
rowKeyField: GONAVI_ROW_KEY,
});
if (!text) {
if (!payload.plainText) {
void message.info(translateDataGrid('data_grid.message.selection_no_copyable_content'));
return;
return null;
}
copyToClipboard(text);
}, [selectedCells, mergedDisplayData, displayColumnNames, copyToClipboard, translateDataGrid]);
return payload;
}, [
GONAVI_ROW_KEY,
buildSelectedCellClipboardPayload,
currentSelectionRef,
displayColumnNames,
mergedDisplayData,
selectedCells,
splitCellKey,
translateDataGrid,
]);
const handleCopySelectedCellsToClipboard = useCallback(() => {
const payload = buildSelectedCellsClipboardPayload();
if (!payload) return;
copyToClipboard(payload);
}, [buildSelectedCellsClipboardPayload, copyToClipboard]);
useEffect(() => {
if (!isActive || !isTableSurfaceActive || (!cellEditMode && selectedCells.size === 0)) return;
@@ -325,21 +345,35 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
return;
}
const isCopy = (event.ctrlKey || event.metaKey) && !event.altKey && String(event.key || '').toLowerCase() === 'c';
if (!isCopy) return;
if (document.getSelection?.()?.toString()) return;
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
if (activeSelection.size === 0) return;
event.preventDefault();
handleCopySelectedCellsToClipboard();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard, resetCellSelection, closeCellEditMode, isActive, isTableSurfaceActive]);
}, [cellEditMode, selectedCells, resetCellSelection, closeCellEditMode, isActive, isTableSurfaceActive]);
useEffect(() => {
if (!isActive || !isTableSurfaceActive || selectedCells.size === 0) return;
const onCopy = (event: ClipboardEvent) => {
if (event.defaultPrevented) return;
const activeElement = document.activeElement as HTMLElement | null;
const eventTarget = event.target instanceof HTMLElement ? event.target : null;
const nativeShortcutGuard = 'input, textarea, select, [contenteditable="true"], .ant-modal, .ant-dropdown, .ant-select-dropdown, .ant-picker-dropdown, .ant-popover, [data-gonavi-close-shortcut-guard]';
if (activeElement?.closest(nativeShortcutGuard) || eventTarget?.closest(nativeShortcutGuard)) return;
if (document.getSelection?.()?.toString()) return;
const payload = buildSelectedCellsClipboardPayload();
if (!payload) return;
if (writeClipboardPayloadToEvent(event, payload)) {
void message.success(translateDataGrid('data_grid.message.copied_to_clipboard'));
return;
}
copyToClipboard(payload);
};
window.addEventListener('copy', onCopy);
return () => window.removeEventListener('copy', onCopy);
}, [buildSelectedCellsClipboardPayload, copyToClipboard, isActive, isTableSurfaceActive, selectedCells.size, translateDataGrid]);
useEffect(() => {
if (!isActive || !isTableSurfaceActive || (!cellEditMode && selectedCells.size === 0)) return;
@@ -523,7 +557,7 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
void message.info(translateDataGrid('data_grid.message.current_row_no_copyable_content'));
return;
}
copyToClipboard(text);
copyToClipboard(buildTabularClipboardPayloadFromTsv(text, { firstRowIsHeader: true }));
}, [columnMetaMap, columnMetaMapByLowerName, copyToClipboard, currentConnConfig, displayOutputColumnNames, getContextMenuTargetRows, translateDataGrid]);
const buildConnConfig = useCallback(() => {