From 8d0617a548a9a919e41d225dd8b834f17ea258b6 Mon Sep 17 00:00:00 2001 From: hamed musallam Date: Mon, 18 May 2026 10:01:23 +0200 Subject: [PATCH] feat: copy peaks table as TSV to clipboard --- .../panels/PeaksPanel/PeaksPanel.tsx | 100 ++++++++++++------ .../panels/PeaksPanel/PeaksTable.tsx | 52 +++++---- src/component/panels/PeaksPanel/peaksToTSV.ts | 45 ++++++++ 3 files changed, 139 insertions(+), 58 deletions(-) create mode 100644 src/component/panels/PeaksPanel/peaksToTSV.ts diff --git a/src/component/panels/PeaksPanel/PeaksPanel.tsx b/src/component/panels/PeaksPanel/PeaksPanel.tsx index 3840ae937..0b19199ee 100644 --- a/src/component/panels/PeaksPanel/PeaksPanel.tsx +++ b/src/component/panels/PeaksPanel/PeaksPanel.tsx @@ -2,9 +2,11 @@ import type { Info1D, Peak1D, Peaks } from '@zakodium/nmr-types'; import type { PeaksViewState, Spectrum1D } from '@zakodium/nmrium-core'; import { SvgNmrFt, SvgNmrPeaks, SvgNmrPeaksTopLabels } from 'cheminfo-font'; import { memo, useCallback, useMemo, useRef, useState } from 'react'; -import { FaThinkPeaks } from 'react-icons/fa'; +import { FaCopy, FaThinkPeaks } from 'react-icons/fa'; import isInRange from '../../../data/utilities/isInRange.js'; +import { ClipboardFallbackModal } from '../../../utils/clipboard/clipboardComponents.tsx'; +import { useClipboard } from '../../../utils/clipboard/clipboardHooks.ts'; import { useChartData } from '../../context/ChartContext.js'; import { useDispatch } from '../../context/DispatchContext.js'; import { usePreferences } from '../../context/PreferencesContext.js'; @@ -13,6 +15,7 @@ import { useAlert } from '../../elements/Alert.js'; import { useActiveSpectrumPeaksViewState } from '../../hooks/useActiveSpectrumPeaksViewState.js'; import { useFormatNumberByNucleus } from '../../hooks/useFormatNumberByNucleus.js'; import useSpectrum from '../../hooks/useSpectrum.js'; +import { EditPeakShapeModal } from '../../modal/EditPeakShapeModal.tsx'; import { booleanToString } from '../../utility/booleanToString.js'; import type { FilterType } from '../../utility/filterType.js'; import { TablePanel } from '../extra/BasicPanelStyle.js'; @@ -22,7 +25,8 @@ import DefaultPanelHeader from '../header/DefaultPanelHeader.js'; import PreferencesHeader from '../header/PreferencesHeader.js'; import PeaksPreferences from './PeaksPreferences.js'; -import PeaksTable from './PeaksTable.js'; +import PeaksTable, { usePeaksTableColumns } from './PeaksTable.js'; +import { exportPeaksToTSV } from './peaksToTSV.ts'; interface PeaksPanelInnerProps { peaks: Peaks; @@ -48,7 +52,9 @@ function PeaksPanelInner(props: PeaksPanelInnerProps) { const toaster = useToaster(); const settingRef = useRef(null); - + const { peak, tableColumns, setEditedPeak } = usePeaksTableColumns(activeTab); + const { rawWriteWithType, shouldFallback, cleanShouldFallback, text } = + useClipboard(); const yesHandler = useCallback(() => { dispatch({ type: 'DELETE_PEAK', payload: {} }); }, [dispatch]); @@ -126,6 +132,16 @@ function PeaksPanelInner(props: PeaksPanelInnerProps) { const { showPeaks, displayingMode, showPeaksShapes, showPeaksSum } = peaksViewState; + function handleExportPeaksToTSV(): void { + const tsv = exportPeaksToTSV(filteredPeaks, tableColumns); + void rawWriteWithType(tsv, 'text/plain').then(() => + toaster.show({ + message: 'Peaks copied to clipboard', + intent: 'success', + }), + ); + } + const leftButtons: ToolbarItemProps[] = [ { disabled, @@ -161,38 +177,60 @@ function PeaksPanelInner(props: PeaksPanelInnerProps) { onClick: toggleDisplayingMode, active: displayingMode === 'spread', }, + { + disabled, + icon: , + tooltip: `Copy as TSV`, + onClick: handleExportPeaksToTSV, + }, ]; return ( - - {!isFlipped && ( - - )} - {isFlipped && ( - - )} -
- {!isFlipped ? ( - - ) : ( - + <> + + setEditedPeak(undefined)} + /> + + {!isFlipped && ( + + )} + {isFlipped && ( + )} -
-
+
+ {!isFlipped ? ( + + ) : ( + + )} +
+ + ); } diff --git a/src/component/panels/PeaksPanel/PeaksTable.tsx b/src/component/panels/PeaksPanel/PeaksTable.tsx index 2d2d4374e..07aa50da2 100644 --- a/src/component/panels/PeaksPanel/PeaksTable.tsx +++ b/src/component/panels/PeaksPanel/PeaksTable.tsx @@ -13,24 +13,12 @@ import addCustomColumn, { createActionColumn, } from '../../elements/ReactTable/utility/addCustomColumn.js'; import { usePanelPreferences } from '../../hooks/usePanelPreferences.js'; -import { EditPeakShapeModal } from '../../modal/EditPeakShapeModal.js'; import { formatNumber } from '../../utility/formatNumber.js'; import { NoDataForFid } from '../extra/placeholder/NoDataForFid.js'; import type { PeakRecord } from './PeaksPanel.js'; -interface PeaksTableProps { - activeTab: string; - data: PeakRecord[]; - info: Info1D; -} - -function handleActiveRow(row: Row) { - return row.original.isConstantlyHighlighted; -} - -function PeaksTable(props: PeaksTableProps) { - const { activeTab, data, info } = props; +export function usePeaksTableColumns(activeTab: string) { const dispatch = useDispatch(); const peaksPreferences = usePanelPreferences('peaks', activeTab); const [peak, setEditedPeak] = useState(); @@ -202,6 +190,22 @@ function PeaksTable(props: PeaksTableProps) { return columns; }, [COLUMNS, peaksPreferences]); + return { tableColumns, peak, setEditedPeak }; +} + +interface PeaksTableProps { + tableColumns: Array>; + data: PeakRecord[]; + info: Info1D; +} + +function handleActiveRow(row: Row) { + return row.original.isConstantlyHighlighted; +} + +function PeaksTable(props: PeaksTableProps) { + const { tableColumns, data, info } = props; + if (info?.isFid) { return ; } @@ -211,20 +215,14 @@ function PeaksTable(props: PeaksTableProps) { } return ( - <> - setEditedPeak(undefined)} - /> - - + ); } diff --git a/src/component/panels/PeaksPanel/peaksToTSV.ts b/src/component/panels/PeaksPanel/peaksToTSV.ts new file mode 100644 index 000000000..f1a3463f0 --- /dev/null +++ b/src/component/panels/PeaksPanel/peaksToTSV.ts @@ -0,0 +1,45 @@ +import dlv from 'dlv'; + +import type { ControlCustomColumn } from '../../elements/ReactTable/utility/addCustomColumn.tsx'; + +import type { PeakRecord } from './PeaksPanel.tsx'; + +export function exportPeaksToTSV( + data: PeakRecord[], + tableColumns: Array>, +) { + const exportColumns = tableColumns.filter( + (col) => col.Header && typeof col.Header === 'string', + ); + + const headers: string[] = []; + for (const col of exportColumns) { + headers.push(col.Header as string); + } + + const rows: string[] = []; + for (let i = 0; i < data.length; i++) { + const record = data[i]; + const cells: string[] = []; + for (const col of exportColumns) { + const accessor = col.accessor; + if (typeof accessor === 'string') { + cells.push(String(dlv(record, accessor) ?? '')); + } else if (typeof accessor === 'function') { + cells.push( + String( + accessor(record, i, { + subRows: [], + depth: 0, + data: [], + }) ?? '', + ), + ); + } else { + cells.push(''); + } + } + rows.push(cells.join('\t')); + } + return `${headers.join('\t')}\n${rows.join('\n')}`; +}