mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-10 00:19:40 +08:00
@@ -49,6 +49,7 @@ import {
|
||||
resolveUniqueKeyGroupsFromIndexes,
|
||||
} from './dataGridCopyInsert';
|
||||
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
|
||||
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
|
||||
|
||||
// --- Error Boundary ---
|
||||
interface DataGridErrorBoundaryState {
|
||||
@@ -3734,6 +3735,59 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
navigator.clipboard.writeText(text).catch(console.error);
|
||||
void message.success("Copied to clipboard");
|
||||
}, []);
|
||||
|
||||
const handleCopySelectedCellsToClipboard = useCallback(() => {
|
||||
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
|
||||
if (activeSelection.size === 0) {
|
||||
void message.info('请先拖选要复制的单元格');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Array.from(activeSelection)
|
||||
.map((cellKey) => splitCellKey(cellKey))
|
||||
.filter((item): item is { rowKey: string; colName: string } => !!item);
|
||||
if (parsed.length === 0) {
|
||||
void message.info('未识别到可复制的单元格');
|
||||
return;
|
||||
}
|
||||
|
||||
const text = buildSelectedCellClipboardText({
|
||||
selectedCells: parsed,
|
||||
rows: mergedDisplayData as Array<Record<string, any>>,
|
||||
columnOrder: displayColumnNames,
|
||||
rowKeyField: GONAVI_ROW_KEY,
|
||||
});
|
||||
if (!text) {
|
||||
void message.info('当前选区没有可复制内容');
|
||||
return;
|
||||
}
|
||||
|
||||
copyToClipboard(text);
|
||||
}, [selectedCells, mergedDisplayData, displayColumnNames, copyToClipboard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cellEditMode) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const isCopy = (event.ctrlKey || event.metaKey) && !event.altKey && String(event.key || '').toLowerCase() === 'c';
|
||||
if (!isCopy) return;
|
||||
|
||||
const activeElement = document.activeElement as HTMLElement | null;
|
||||
const tagName = String(activeElement?.tagName || '').toLowerCase();
|
||||
if (tagName === 'input' || tagName === 'textarea' || activeElement?.isContentEditable) {
|
||||
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]);
|
||||
|
||||
const getTargets = useCallback((clickedRecord: any) => {
|
||||
const selKeys = selectedRowKeysRef.current;
|
||||
@@ -4892,6 +4946,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</Button>
|
||||
{cellEditMode && selectedCells.size > 0 && (
|
||||
<>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={handleCopySelectedCellsToClipboard}
|
||||
>
|
||||
复制选区 ({selectedCells.size})
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={handleCopySelectedColumnsFromRow}
|
||||
|
||||
43
frontend/src/components/dataGridSelectionCopy.test.ts
Normal file
43
frontend/src/components/dataGridSelectionCopy.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
|
||||
|
||||
describe('dataGridSelectionCopy helpers', () => {
|
||||
it('builds clipboard text in visible row and column order', () => {
|
||||
const text = buildSelectedCellClipboardText({
|
||||
selectedCells: [
|
||||
{ rowKey: 'row-2', colName: 'name' },
|
||||
{ rowKey: 'row-1', colName: 'id' },
|
||||
{ rowKey: 'row-1', colName: 'name' },
|
||||
{ rowKey: 'row-2', colName: 'id' },
|
||||
],
|
||||
rows: [
|
||||
{ __rowKey: 'row-1', id: 1, name: 'Alice' },
|
||||
{ __rowKey: 'row-2', id: 2, name: 'Bob' },
|
||||
],
|
||||
columnOrder: ['id', 'name', 'email'],
|
||||
rowKeyField: '__rowKey',
|
||||
});
|
||||
|
||||
expect(text).toBe('1\tAlice\n2\tBob');
|
||||
});
|
||||
|
||||
it('normalizes null, objects and multiline text for clipboard safety', () => {
|
||||
const text = buildSelectedCellClipboardText({
|
||||
selectedCells: [
|
||||
{ rowKey: 'row-1', colName: 'notes' },
|
||||
{ rowKey: 'row-1', colName: 'meta' },
|
||||
{ rowKey: 'row-2', colName: 'notes' },
|
||||
{ rowKey: 'row-2', colName: 'meta' },
|
||||
],
|
||||
rows: [
|
||||
{ __rowKey: 'row-1', notes: null, meta: { a: 1 } },
|
||||
{ __rowKey: 'row-2', notes: 'line1\nline2\tvalue', meta: [1, 2] },
|
||||
],
|
||||
columnOrder: ['notes', 'meta'],
|
||||
rowKeyField: '__rowKey',
|
||||
});
|
||||
|
||||
expect(text).toBe('NULL\t{"a":1}\nline1 line2 value\t[1,2]');
|
||||
});
|
||||
});
|
||||
65
frontend/src/components/dataGridSelectionCopy.ts
Normal file
65
frontend/src/components/dataGridSelectionCopy.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export interface SelectedGridCell {
|
||||
rowKey: string;
|
||||
colName: string;
|
||||
}
|
||||
|
||||
const normalizeClipboardCellValue = (value: unknown): 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();
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value).replace(/[\t\n\r]+/g, ' ').trim();
|
||||
} catch {
|
||||
return String(value).replace(/[\t\n\r]+/g, ' ').trim();
|
||||
}
|
||||
};
|
||||
|
||||
export const buildSelectedCellClipboardText = ({
|
||||
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 '';
|
||||
}
|
||||
|
||||
const selectedRowKeys = new Set(selectedCells.map((cell) => cell.rowKey));
|
||||
const selectedColumnKeys = new Set(selectedCells.map((cell) => cell.colName));
|
||||
const orderedRows = rows.filter((row) => selectedRowKeys.has(String(row?.[rowKeyField] ?? '')));
|
||||
const orderedColumns = columnOrder.filter((columnName) => selectedColumnKeys.has(columnName));
|
||||
|
||||
if (!orderedRows.length || !orderedColumns.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const selectedCellKeySet = new Set(selectedCells.map((cell) => `${cell.rowKey}::${cell.colName}`));
|
||||
|
||||
return orderedRows
|
||||
.map((row) => {
|
||||
const rowKey = String(row?.[rowKeyField] ?? '');
|
||||
return orderedColumns
|
||||
.map((columnName) => {
|
||||
if (!selectedCellKeySet.has(`${rowKey}::${columnName}`)) {
|
||||
return '';
|
||||
}
|
||||
return normalizeClipboardCellValue(row?.[columnName]);
|
||||
})
|
||||
.join('\t');
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
Reference in New Issue
Block a user