feat(data-grid): 支持富格式剪贴板粘贴

- 粘贴表格单元格时优先解析 HTML/CSV,再回退 TSV
- 保持 text/plain TSV 安全降级,避免单元格内 tab/换行破坏列结构
- 保留富格式中的真实 tab 和换行内容
- 补充多行多列区域复制粘贴与 HTML tab 保留测试

验证:
- npm --prefix frontend test -- dataGridClipboardPaste.test.ts dataGridClipboardPayload.test.ts dataGridSelectionCopy.test.ts
This commit is contained in:
liuqiufeng
2026-08-02 21:35:51 +08:00
parent fa14dcb7cb
commit dd733816b5
7 changed files with 314 additions and 11 deletions

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { buildDataGridClipboardPasteRows, parseDataGridClipboardText } from './dataGridClipboardPaste';
import {
buildDataGridClipboardPasteRows,
parseDataGridClipboardData,
parseDataGridClipboardText,
} from './dataGridClipboardPaste';
import { buildSelectedCellClipboardPayload } from './dataGridSelectionCopy';
const isValueEqual = (left: unknown, right: unknown) => {
if (left === right) return true;
@@ -9,6 +14,11 @@ const isValueEqual = (left: unknown, right: unknown) => {
return leftNullish && rightNullish;
};
const clipboardData = (values: Record<string, string>) => ({
types: Object.keys(values),
getData: (type: string) => values[type] || '',
});
describe('dataGridClipboardPaste helpers', () => {
it('parses rows, columns, empty values, CRLF and database NULL values', () => {
expect(parseDataGridClipboardText('alpha\tNULL\r\n\tbeta\r\n')).toEqual([
@@ -22,6 +32,116 @@ describe('dataGridClipboardPaste helpers', () => {
expect(parseDataGridClipboardText('')).toEqual([['']]);
});
it('prefers HTML table data over plain TSV so tabs inside cells are preserved', () => {
const matrix = parseDataGridClipboardData(clipboardData({
'text/plain': 'alpha\tbeta\t<ok>',
'text/html': '<meta charset="utf-8"><table><tbody><tr><td>alpha\tbeta</td><td>&lt;ok&gt;</td></tr></tbody></table>',
}));
expect(matrix).toEqual([
['alpha\tbeta', '<ok>'],
]);
});
it('preserves literal tab characters from text/html cells', () => {
const matrix = parseDataGridClipboardData(clipboardData({
'text/html': '<table><tbody><tr><td>left\tright</td><td>next</td></tr></tbody></table>',
}));
expect(matrix).toEqual([
['left\tright', 'next'],
]);
});
it('parses CSV cells with embedded tabs, quotes and newlines', () => {
const matrix = parseDataGridClipboardData(clipboardData({
'text/plain': 'alpha\tbeta\tline1\nline2',
'text/csv': '"alpha\tbeta","say ""hi""","line1\nline2"\n"NULL","",tail',
}));
expect(matrix).toEqual([
['alpha\tbeta', 'say "hi"', 'line1\nline2'],
[null, '', 'tail'],
]);
});
it('falls back to plain TSV when no richer table format is present', () => {
expect(parseDataGridClipboardData(clipboardData({
'text/plain': 'alpha\tNULL\r\n\tbeta\r\n',
}))).toEqual([
['alpha', null],
['', 'beta'],
]);
});
it('round-trips a multi-row multi-column selection into a coordinate paste matrix', () => {
const payload = buildSelectedCellClipboardPayload({
selectedCells: [
{ rowKey: 'source-2', colName: 'colC' },
{ rowKey: 'source-1', colName: 'colA' },
{ rowKey: 'source-1', colName: 'colB' },
{ rowKey: 'source-2', colName: 'colB' },
{ rowKey: 'source-1', colName: 'colC' },
{ rowKey: 'source-2', colName: 'colA' },
],
rows: [
{ __rowKey: 'source-1', colA: 'A1', colB: 'B\t1', colC: 'C1' },
{ __rowKey: 'source-2', colA: 'A2', colB: 'B2', colC: 'C\n2' },
],
columnOrder: ['colA', 'colB', 'colC'],
rowKeyField: '__rowKey',
});
expect(payload.plainText).toBe('A1\tB 1\tC1\nA2\tB2\tC 2');
const matrix = parseDataGridClipboardData(clipboardData({
'text/plain': payload.plainText,
'text/html': payload.html || '',
'text/csv': payload.csv || '',
}));
expect(matrix).toEqual([
['A1', 'B\t1', 'C1'],
['A2', 'B2', 'C\n2'],
]);
const result = buildDataGridClipboardPasteRows({
matrix,
rows: [
{ rowKey: 'target-1', colA: 'old-a1', colB: 'old-b1', colC: 'old-c1' },
{ rowKey: 'target-2', colA: 'old-a2', colB: 'old-b2', colC: 'old-c2' },
],
columnNames: ['colA', 'colB', 'colC'],
startRowIndex: 0,
startColumnIndex: 0,
rowKeyField: 'rowKey',
addedRowKeys: new Set(),
modifiedRows: {},
deletedRowKeys: new Set(),
isWritableColumn: () => true,
isValueEqual,
});
expect(result).toEqual({
rows: [
{
rowKey: 'target-1',
values: { colA: 'A1', colB: 'B\t1', colC: 'C1' },
modifiedValues: { colA: 'A1', colB: 'B\t1', colC: 'C1' },
modifiedColumnNames: ['colA', 'colB', 'colC'],
isAdded: false,
},
{
rowKey: 'target-2',
values: { colA: 'A2', colB: 'B2', colC: 'C\n2' },
modifiedValues: { colA: 'A2', colB: 'B2', colC: 'C\n2' },
modifiedColumnNames: ['colA', 'colB', 'colC'],
isAdded: false,
},
],
updatedCellCount: 6,
});
});
it('maps a clipboard matrix by coordinates without shifting past read-only columns', () => {
const result = buildDataGridClipboardPasteRows({
matrix: [

View File

@@ -1,5 +1,10 @@
export type DataGridClipboardValue = string | null;
export interface DataGridClipboardDataReader {
types?: readonly string[] | DOMStringList;
getData: (format: string) => string;
}
export interface DataGridClipboardPasteRow {
rowKey: string;
values: Record<string, DataGridClipboardValue>;
@@ -8,15 +13,146 @@ export interface DataGridClipboardPasteRow {
isAdded: boolean;
}
const toClipboardValue = (value: string): DataGridClipboardValue => (
value === 'NULL' ? null : value
);
export const parseDataGridClipboardText = (text: string): DataGridClipboardValue[][] => {
const normalized = text.replace(/\r\n?/g, '\n');
const content = normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized;
return content.split('\n').map((line) => (
line.split('\t').map((value) => value === 'NULL' ? null : value)
line.split('\t').map(toClipboardValue)
));
};
const parseDelimitedClipboardText = (text: string, delimiter: ',' | '\t'): DataGridClipboardValue[][] => {
const normalized = text.replace(/\r\n?/g, '\n');
const content = normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized;
const rows: DataGridClipboardValue[][] = [];
let row: DataGridClipboardValue[] = [];
let cell = '';
let quoted = false;
for (let index = 0; index < content.length; index += 1) {
const char = content[index];
if (quoted) {
if (char === '"') {
if (content[index + 1] === '"') {
cell += '"';
index += 1;
} else {
quoted = false;
}
} else {
cell += char;
}
continue;
}
if (char === '"' && cell === '') {
quoted = true;
continue;
}
if (char === delimiter) {
row.push(toClipboardValue(cell));
cell = '';
continue;
}
if (char === '\n') {
row.push(toClipboardValue(cell));
rows.push(row);
row = [];
cell = '';
continue;
}
cell += char;
}
row.push(toClipboardValue(cell));
rows.push(row);
return rows;
};
export const parseDataGridClipboardCsv = (text: string): DataGridClipboardValue[][] => (
parseDelimitedClipboardText(text, ',')
);
const decodeHtmlEntities = (text: string): string => (
text
.replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number(code)))
.replace(/&#x([0-9a-f]+);/gi, (_match, code) => String.fromCodePoint(Number.parseInt(code, 16)))
.replace(/&nbsp;/gi, ' ')
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/gi, "'")
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&amp;/gi, '&')
);
const normalizeHtmlCellContent = (html: string): string => (
decodeHtmlEntities(
html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
)
);
export const parseDataGridClipboardHtml = (html: string): DataGridClipboardValue[][] => {
if (!String(html || '').trim()) return [];
const rows: DataGridClipboardValue[][] = [];
const rowPattern = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi;
let rowMatch: RegExpExecArray | null;
while ((rowMatch = rowPattern.exec(html)) !== null) {
const rowHtml = rowMatch[1] || '';
const cells: DataGridClipboardValue[] = [];
const cellPattern = /<t[hd]\b[^>]*>([\s\S]*?)<\/t[hd]>/gi;
let cellMatch: RegExpExecArray | null;
while ((cellMatch = cellPattern.exec(rowHtml)) !== null) {
cells.push(toClipboardValue(normalizeHtmlCellContent(cellMatch[1] || '')));
}
if (cells.length > 0) rows.push(cells);
}
return rows;
};
const getClipboardTypes = (clipboardData: DataGridClipboardDataReader): string[] => (
Array.from(clipboardData.types || [])
);
const hasClipboardType = (clipboardData: DataGridClipboardDataReader, type: string): boolean => (
getClipboardTypes(clipboardData).includes(type)
);
const hasPasteMatrixValues = (matrix: DataGridClipboardValue[][]): boolean => (
matrix.length > 0 && matrix.some((row) => row.length > 0)
);
export const parseDataGridClipboardData = (
clipboardData: DataGridClipboardDataReader | null | undefined,
): DataGridClipboardValue[][] => {
if (!clipboardData) return [];
if (hasClipboardType(clipboardData, 'text/html')) {
const matrix = parseDataGridClipboardHtml(clipboardData.getData('text/html'));
if (hasPasteMatrixValues(matrix)) return matrix;
}
if (hasClipboardType(clipboardData, 'text/csv')) {
const matrix = parseDataGridClipboardCsv(clipboardData.getData('text/csv'));
if (hasPasteMatrixValues(matrix)) return matrix;
}
if (hasClipboardType(clipboardData, 'text/plain')) {
return parseDataGridClipboardText(clipboardData.getData('text/plain'));
}
return [];
};
export const buildDataGridClipboardPasteRows = ({
matrix,
rows,

View File

@@ -37,6 +37,17 @@ describe('dataGridClipboardPayload', () => {
expect(payload.csv).toBe('"id","name"\n"1","alpha"');
});
it('keeps rich formats lossless while plain text remains a safe TSV fallback', () => {
const payload = buildTabularClipboardPayload({
rows: [['alpha\tbeta', 'line1\nline2']],
});
expect(payload.plainText).toBe('alpha beta\tline1 line2');
expect(payload.csv).toBe('"alpha\tbeta","line1\nline2"');
expect(payload.html).toContain('<td>alpha\tbeta</td>');
expect(payload.html).toContain('<td>line1\nline2</td>');
});
it('sets multiple clipboard MIME types without overwriting different formats', () => {
const values: Record<string, string> = {};
const event = {

View File

@@ -25,6 +25,12 @@ const normalizeClipboardMatrixCell = (value: unknown): string => (
value === null || value === undefined ? '' : String(value)
);
const normalizePlainTextMatrixCell = (value: unknown): string => (
normalizeClipboardMatrixCell(value)
.replace(/\r\n/g, '\n')
.replace(/[\t\n\r]+/g, ' ')
);
const escapeHtml = (value: string): string => (
value
.replace(/&/g, '&amp;')
@@ -38,7 +44,7 @@ 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');
return matrix.map((row) => row.map(normalizePlainTextMatrixCell).join(delimiter)).join('\n');
};
const buildCsvText = (rows: string[][], columns?: string[]): string => {

View File

@@ -81,4 +81,21 @@ describe('dataGridSelectionCopy helpers', () => {
expect(payload.html).toContain('<td>A&amp;B</td>');
expect(payload.html).toContain('<td>&lt;owner&gt;</td>');
});
it('preserves tabs and newlines in rich selected-cell formats', () => {
const payload = buildSelectedCellClipboardPayload({
selectedCells: [
{ rowKey: 'row-1', colName: 'note' },
],
rows: [
{ __rowKey: 'row-1', note: 'left\tright\nnext' },
],
columnOrder: ['note'],
rowKeyField: '__rowKey',
});
expect(payload.plainText).toBe('left right next');
expect(payload.csv).toBe('"left\tright\nnext"');
expect(payload.html).toContain('<td>left\tright\nnext</td>');
});
});

View File

@@ -15,13 +15,18 @@ export const canSelectGridCellForClipboard = ({
isWritableColumn: boolean;
}): boolean => isDisplayedColumn && (!canModifyData || isWritableColumn);
const normalizeClipboardCellValue = (value: unknown): string => {
const normalizeUnsafePlainTextCell = (value: string): string => (
value.replace(/\r\n/g, '\n').replace(/[\t\n\r]+/g, ' ').trim()
);
const normalizeClipboardCellValue = (value: unknown, options: { preserveCellWhitespace?: boolean } = {}): string => {
if (value === null || value === undefined) {
return 'NULL';
}
if (typeof value === 'string') {
return value.replace(/\r\n/g, '\n').replace(/[\t\n\r]+/g, ' ').trim();
const normalized = value.replace(/\r\n/g, '\n');
return options.preserveCellWhitespace ? normalized : normalizeUnsafePlainTextCell(normalized);
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
@@ -29,10 +34,15 @@ const normalizeClipboardCellValue = (value: unknown): string => {
}
try {
return JSON.stringify(value).replace(/[\t\n\r]+/g, ' ').trim();
const text = JSON.stringify(value);
if (typeof text === 'string') {
return options.preserveCellWhitespace ? text : normalizeUnsafePlainTextCell(text);
}
} catch {
return String(value).replace(/[\t\n\r]+/g, ' ').trim();
// Fall through to String(value) below.
}
const text = String(value);
return options.preserveCellWhitespace ? text : normalizeUnsafePlainTextCell(text);
};
export const buildSelectedCellClipboardText = ({
@@ -61,11 +71,13 @@ const buildSelectedCellClipboardMatrix = ({
rows,
columnOrder,
rowKeyField,
preserveCellWhitespace = false,
}: {
selectedCells: SelectedGridCell[];
rows: Array<Record<string, any>>;
columnOrder: string[];
rowKeyField: string;
preserveCellWhitespace?: boolean;
}): string[][] => {
if (!selectedCells.length || !rows.length || !columnOrder.length || !rowKeyField) {
return [];
@@ -90,7 +102,7 @@ const buildSelectedCellClipboardMatrix = ({
if (!selectedCellKeySet.has(`${rowKey}::${columnName}`)) {
return '';
}
return normalizeClipboardCellValue(row?.[columnName]);
return normalizeClipboardCellValue(row?.[columnName], { preserveCellWhitespace });
});
});
};
@@ -111,6 +123,7 @@ export const buildSelectedCellClipboardPayload = ({
rows,
columnOrder,
rowKeyField,
preserveCellWhitespace: true,
});
return buildTabularClipboardPayload({ rows: matrix });
};

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect } from 'react';
import type React from 'react';
import { message } from 'antd';
import type { Item } from './DataGridCore';
import { buildDataGridClipboardPasteRows, parseDataGridClipboardText } from './dataGridClipboardPaste';
import { buildDataGridClipboardPasteRows, parseDataGridClipboardData } from './dataGridClipboardPaste';
import { canSelectGridCellForClipboard } from './dataGridSelectionCopy';
type DataGridBatchActionsContext = Record<string, any> & {
@@ -522,8 +522,8 @@ const handleBatchFillCells = useCallback(() => {
if (activeElement?.closest(nativePasteGuard) || eventTarget?.closest(nativePasteGuard)) return;
const clipboardData = e.clipboardData;
if (!clipboardData?.types.includes('text/plain')) return;
const matrix = parseDataGridClipboardText(clipboardData.getData('text/plain'));
const matrix = parseDataGridClipboardData(clipboardData);
if (matrix.length === 0) return;
const currentRows = displayDataRef.current;
const start = selectionStartRef.current;