mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-07 23:23:52 +08:00
✨ 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:
@@ -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><ok></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: [
|
||||
|
||||
@@ -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(/ /gi, ' ')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/&/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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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, '&')
|
||||
@@ -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 => {
|
||||
|
||||
@@ -81,4 +81,21 @@ describe('dataGridSelectionCopy helpers', () => {
|
||||
expect(payload.html).toContain('<td>A&B</td>');
|
||||
expect(payload.html).toContain('<td><owner></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>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user