diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 539f12db..7ac8bdf0 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -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 = ({ 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 = ({ 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 = ({ buildOrderBySQL, buildPaginatedSelectSQL, buildRpcConnectionConfig, - buildSelectedCellClipboardText, + buildSelectedCellClipboardPayload, buildTableExportTab, buildWhereSQL, cellContextMenu, diff --git a/frontend/src/components/DataGridCore.tsx b/frontend/src/components/DataGridCore.tsx index 18ce3eac..975e5a2f 100644 --- a/frontend/src/components/DataGridCore.tsx +++ b/frontend/src/components/DataGridCore.tsx @@ -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; - copyToClipboard: (t: string) => void; + copyToClipboard: (t: string | DataGridClipboardPayload) => void; tableName?: string; enableRowContextMenu: boolean; supportsCopyInsert: boolean; diff --git a/frontend/src/components/dataGridClipboardPayload.test.ts b/frontend/src/components/dataGridClipboardPayload.test.ts new file mode 100644 index 00000000..9d784642 --- /dev/null +++ b/frontend/src/components/dataGridClipboardPayload.test.ts @@ -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', ''], + ], + jsonRows: [ + { id: 1, name: 'Alice & Bob' }, + { id: 2, name: '' }, + ], + }); + + expect(payload.plainText).toBe('id\tname\n1\tAlice & Bob\n2\t'); + expect(payload.csv).toBe('"id","name"\n"1","Alice & Bob"\n"2",""'); + expect(payload.html).toContain('name'); + expect(payload.html).toContain('Alice & Bob'); + expect(payload.html).toContain('<Admin>'); + expect(payload.markdown).toBe('| id | name |\n| --- | --- |\n| 1 | Alice & Bob |\n| 2 | |'); + expect(payload.json).toBe('[\n {\n "id": 1,\n "name": "Alice & Bob"\n },\n {\n "id": 2,\n "name": ""\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('idname'); + expect(payload.csv).toBe('"id","name"\n"1","alpha"'); + }); + + it('sets multiple clipboard MIME types without overwriting different formats', () => { + const values: Record = {}; + 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(''); + 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); + }); +}); diff --git a/frontend/src/components/dataGridClipboardPayload.ts b/frontend/src/components/dataGridClipboardPayload.ts new file mode 100644 index 00000000..be5f1a91 --- /dev/null +++ b/frontend/src/components/dataGridClipboardPayload.ts @@ -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>; +} + +type ClipboardWriter = Pick; + +type ClipboardDataWriter = Pick; + +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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +); + +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 + ? `${columns.map((column) => ``).join('')}` + : ''; + const body = rows.map((row) => ( + `${row.map((cell) => ``).join('')}` + )).join(''); + return `
${escapeHtml(normalizeClipboardMatrixCell(column))}
${escapeHtml(normalizeClipboardMatrixCell(cell))}
${header}${body}
`; +}; + +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, + 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 => { + 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 = {}; + 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'); +}; diff --git a/frontend/src/components/dataGridSelectionCopy.test.ts b/frontend/src/components/dataGridSelectionCopy.test.ts index 629328d9..74932138 100644 --- a/frontend/src/components/dataGridSelectionCopy.test.ts +++ b/frontend/src/components/dataGridSelectionCopy.test.ts @@ -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: '' }, + ], + columnOrder: ['name', 'note'], + rowKeyField: '__rowKey', + }); + + expect(payload.plainText).toBe('A&B\t'); + expect(payload.csv).toBe('"A&B",""'); + expect(payload.html).toContain('A&B'); + expect(payload.html).toContain('<owner>'); + }); }); diff --git a/frontend/src/components/dataGridSelectionCopy.ts b/frontend/src/components/dataGridSelectionCopy.ts index c9287f83..f935545d 100644 --- a/frontend/src/components/dataGridSelectionCopy.ts +++ b/frontend/src/components/dataGridSelectionCopy.ts @@ -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>; + 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>; + columnOrder: string[]; + rowKeyField: string; +}): DataGridClipboardPayload => { + const matrix = buildSelectedCellClipboardMatrix({ + selectedCells, + rows, + columnOrder, + rowKeyField, + }); + return buildTabularClipboardPayload({ rows: matrix }); }; diff --git a/frontend/src/components/useDataGridV2Actions.ts b/frontend/src/components/useDataGridV2Actions.ts index eb942deb..56603e4a 100644 --- a/frontend/src/components/useDataGridV2Actions.ts +++ b/frontend/src/components/useDataGridV2Actions.ts @@ -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>, 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(() => {