diff --git a/package-lock.json b/package-lock.json index 0e600a3c4..0fdc8372b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "nmr-processing": "^22.1.0", "numeral": "^2.0.6", "openchemlib": "^9.18.2", - "openchemlib-utils": "^8.9.0", + "openchemlib-utils": "^8.12.1", "papaparse": "^5.5.3", "react-d3-utils": "^3.1.2", "react-dropzone": "^14.3.8", @@ -55,7 +55,7 @@ "react-icons": "^5.5.0", "react-inspector": "^9.0.0", "react-mf": "^3.1.1", - "react-ocl": "^8.4.0", + "react-ocl": "^8.5.0", "react-ocl-nmr": "^4.1.1", "react-plot": "^3.1.2", "react-rnd": "^10.5.2", @@ -9621,9 +9621,9 @@ "peer": true }, "node_modules/openchemlib-utils": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/openchemlib-utils/-/openchemlib-utils-8.9.0.tgz", - "integrity": "sha512-eSKIXOK/ybOf1EoWEUaKeFxARimjd2BOBuiw7P+NaZFuPa0Xvg6HZ57DcJby0g/pJxF5f0m5ChPz4MGM/AVRwg==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/openchemlib-utils/-/openchemlib-utils-8.12.1.tgz", + "integrity": "sha512-aQbEzLxunuRqwEVw6I98J9NL+Mxo3uASLbFjhsFe4ZuJZWN7G+s8tgmXO43+9kaNNeD8lAjcG6+EHNhwX+TV3w==", "license": "MIT", "dependencies": { "atom-sorter": "^2.2.1", @@ -10478,10 +10478,13 @@ } }, "node_modules/react-ocl": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/react-ocl/-/react-ocl-8.4.0.tgz", - "integrity": "sha512-HrKWK78u9XOeW9U6Qh869Z+ee1Kv0DsayrQeurkh+mTIkf4Gy/oeY31Q4WkzHV9Hcar44XH8d2jjEYyFCJVcJw==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/react-ocl/-/react-ocl-8.5.0.tgz", + "integrity": "sha512-IOgPuJCcGs36iiiWjVz1EuHzutlmFQak7H+YXEy0cjEodZeL7HWAqrjWzd1/uyKIpDL+Hkqzk8RNVyWt2j18xg==", "license": "MIT", + "dependencies": { + "@emotion/styled": "^11.14.1" + }, "peerDependencies": { "openchemlib": ">=8", "react": ">=18", diff --git a/package.json b/package.json index 487fe4ddc..fbff20b23 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "nmr-processing": "^22.1.0", "numeral": "^2.0.6", "openchemlib": "^9.18.2", - "openchemlib-utils": "^8.9.0", + "openchemlib-utils": "^8.12.1", "papaparse": "^5.5.3", "react-d3-utils": "^3.1.2", "react-dropzone": "^14.3.8", @@ -109,7 +109,7 @@ "react-icons": "^5.5.0", "react-inspector": "^9.0.0", "react-mf": "^3.1.1", - "react-ocl": "^8.4.0", + "react-ocl": "^8.5.0", "react-ocl-nmr": "^4.1.1", "react-plot": "^3.1.2", "react-rnd": "^10.5.2", diff --git a/src/component/modal/MoleculeAutoLabelsDatabaseModal.tsx b/src/component/modal/MoleculeAutoLabelsDatabaseModal.tsx new file mode 100644 index 000000000..835c34ae1 --- /dev/null +++ b/src/component/modal/MoleculeAutoLabelsDatabaseModal.tsx @@ -0,0 +1,219 @@ +import { Dialog, InputGroup } from '@blueprintjs/core'; +import styled from '@emotion/styled'; +import { Molecule } from 'openchemlib'; +import { autoLabelDatabase } from 'openchemlib-utils'; +import { useState } from 'react'; +import { MF } from 'react-mf'; +import { IdcodeSvgRenderer } from 'react-ocl'; +import { Button } from 'react-science/ui'; +import { filter } from 'smart-array-filter'; + +import { useDispatch } from '../context/DispatchContext.tsx'; +import { StyledDialogBody } from '../elements/StyledDialogBody.js'; + +interface LabelDatabaseItem { + idCode: string; + coordinates: string; + mf: string; + mw: number; + label: string; +} + +const ChemicalGrid = styled.div` + display: flex; + flex-wrap: wrap; + gap: 1.25rem; + justify-content: flex-start; + padding: 0.5rem 1rem; + flex: 1; +`; + +const AddButton = styled(Button)` + position: absolute; + top: 0; + left: 0; +`; + +const ChemicalCard = styled.div` + position: relative; + flex: 0 0 calc((100% - 2.5rem) / 3); + min-width: 200px; + border-radius: 8px; + overflow: hidden; + background: #fff; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + transition: all 0.25s ease; + /* stylelint-disable nesting-selector-no-missing-scoping-root */ + &:hover { + box-shadow: 0 8px 24px rgb(0 0 0 / 12%); + transform: translateY(-2px); + } +`; + +const CardLabel = styled.div` + padding: 0.75rem 1rem; + background: linear-gradient(180deg, #fff 0%, #f0f2f5 40%, #e2e6eb 100%); + border-bottom: 1px solid #d1d5db; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 80%); + + h3 { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + color: #374151; + text-align: center; + text-transform: capitalize; + letter-spacing: 0.02em; + text-shadow: 0 1px 0 rgb(255 255 255 / 50%); + } +`; + +const StructureContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + background: #fafafa; + border-bottom: 1px solid #eee; +`; + +const CardInfo = styled.div` + padding: 0.75rem 1rem; + display: flex; + justify-content: center; + align-items: center; + background: #fff; +`; + +const MolecularFormula = styled.div<{ color?: string }>` + font-size: 0.9rem; + font-weight: 500; + color: ${({ color }) => color ?? '#374151'}; +`; + +const SearchContainer = styled.div` + padding: 1.2rem; + margin-bottom: 1.5rem; + background-color: white; + border-bottom: 1px solid #e0e0e0; + position: sticky; + top: 0; + z-index: 1; +`; + +const SearchInput = styled(InputGroup)` + input { + background: #eceff7; + + &:focus { + background: #fff; + box-shadow: 0 1px 4px rgb(0 0 0 / 15%); + } + } +`; + +const NoResults = styled.p` + text-align: center; + color: #6b7280; + font-size: 1rem; + padding: 3rem; +`; +interface MoleculeAutoLabelsDatabaseModalProps { + onClose?: (element?: string) => void; +} + +export function MoleculeAutoLabelsDatabaseModal({ + onClose = () => null, +}: MoleculeAutoLabelsDatabaseModalProps) { + const dispatch = useDispatch(); + const [keywords, setSearch] = useState(''); + + function handleAddMolecule(options: LabelDatabaseItem) { + const { idCode, coordinates, label } = options; + const molecule = Molecule.fromIDCode(idCode, coordinates); + + if (molecule.getAllAtoms() <= 0) { + return; + } + + dispatch({ + type: 'ADD_MOLECULE', + payload: { molfile: molecule.toMolfileV3(), label }, + }); + + onClose(); + } + + const filteredLabelDatabase = filter(autoLabelDatabase, { keywords }); + + return ( + onClose()} + style={{ width: '90vw', maxWidth: 1000, height: '80vh' }} + title="Template database" + > + + + { + if (target.value !== undefined) { + setSearch(target.value); + } + }} + leftIcon="search" + type="search" + /> + + {filteredLabelDatabase.length === 0 ? ( + No results found for "{keywords}" + ) : ( + + {filteredLabelDatabase.map((compound) => ( + + + Add + - + {compound.mw.toFixed(2)} + molecule + + ), + }} + onClick={() => handleAddMolecule(compound)} + /> + +

{compound.label}

+
+ + + + + + + + - + {compound.mw.toFixed(2)} + + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx b/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx index 2cc37f02f..ef9b76da0 100644 --- a/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx +++ b/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx @@ -1,8 +1,10 @@ import { Molecule } from 'openchemlib'; +import { autoLabel } from 'openchemlib-utils'; import type { ReactNode } from 'react'; import { useCallback } from 'react'; import { FaCopy, + FaDatabase, FaDownload, FaFileExport, FaFileImage, @@ -13,7 +15,7 @@ import { } from 'react-icons/fa'; import { FaMaximize, FaMinimize } from 'react-icons/fa6'; import { IoOpenOutline } from 'react-icons/io5'; -import { MdNumbers, MdOutlineLabelOff } from 'react-icons/md'; +import { MdFlashAuto, MdNumbers, MdOutlineLabelOff } from 'react-icons/md'; import { PanelHeader, Toolbar } from 'react-science/ui'; import type { @@ -33,7 +35,9 @@ import { useToaster } from '../../context/ToasterContext.js'; import { useTopicMolecule } from '../../context/TopicMoleculeContext.js'; import type { ToolbarPopoverMenuItem } from '../../elements/ToolbarPopoverItem.js'; import { ToolbarPopoverItem } from '../../elements/ToolbarPopoverItem.js'; +import { useDialogToggle } from '../../hooks/useDialogToggle.ts'; import AboutPredictionModal from '../../modal/AboutPredictionModal.js'; +import { MoleculeAutoLabelsDatabaseModal } from '../../modal/MoleculeAutoLabelsDatabaseModal.tsx'; import PredictSpectraModal from '../../modal/PredictSpectraModal.js'; import { booleanToString } from '../../utility/booleanToString.ts'; import { @@ -67,30 +71,30 @@ const MOL_EXPORT_MENU: Array> = [ }, { icon: , - text: 'Copy as molfile V3', + text: 'Copy as molfile V2', data: { - id: 'CopyAsMolfileV3', + id: 'CopyAsMolfileV2', }, }, { icon: , - text: 'Copy as molfile V2', + text: 'Copy as molfile V3', data: { - id: 'CopyAsMolfileV2', + id: 'CopyAsMolfileV3', }, }, { icon: , - text: 'Save as molfile V3', + text: 'Save as molfile V2', data: { - id: 'SaveAsMolfileV3', + id: 'SaveAsMolfileV2', }, }, { icon: , - text: 'Save as molfile V2', + text: 'Save as molfile V3', data: { - id: 'SaveAsMolfileV2', + id: 'SaveAsMolfileV3', }, }, { @@ -139,6 +143,9 @@ export default function MoleculePanelHeader(props: MoleculePanelHeaderProps) { current: { defaultMoleculeSettings }, } = usePreferences(); const moleculeKey = molecules?.[currentIndex]?.id; + const { dialog, openDialog, closeDialog } = useDialogToggle({ + autoLabelDatabaseDialog: false, + }); const saveAsSVGHandler = useCallback(() => { if (!rootRef) return; exportAsSVG(`molSVG${currentIndex}`, { @@ -312,6 +319,20 @@ export default function MoleculePanelHeader(props: MoleculePanelHeaderProps) { }); } + function autoLabels() { + const currentMolecule = molecules[currentIndex]; + + if (!currentMolecule) return; + const { id, label, molfile } = currentMolecule; + + const molecule = Molecule.fromMolfile(molfile); + autoLabel(molecule); + dispatch({ + type: 'SET_MOLECULE', + payload: { id, label, molfile: molecule.toMolfileV3() }, + }); + } + const hasMolecules = molecules && molecules.length > 0; const showCounter = hasMolecules && renderSource !== 'predictionPanel'; const moreMenu: ToolbarPopoverMenuItem[] = [ @@ -333,6 +354,23 @@ export default function MoleculePanelHeader(props: MoleculePanelHeaderProps) { disabled: !hasMolecules, onClick: () => clearCustomAtomLabels(), }, + { + icon: , + text: 'Auto label atoms', + disabled: !hasMolecules, + tooltip: { + title: 'Auto label atoms', + description: + 'Atoms are automatically labeled according to a predefined template database', + link: 'https://docs.nmrium.org/help/structure-labelling', + }, + onClick: () => autoLabels(), + }, + { + icon: , + text: 'Template database', + onClick: () => openDialog('autoLabelDatabaseDialog'), + }, ]; const { handleChangeAtomAnnotation, isAnnotation } = @@ -343,6 +381,9 @@ export default function MoleculePanelHeader(props: MoleculePanelHeaderProps) { current={showCounter ? currentIndex + 1 : undefined} total={showCounter ? molecules.length : undefined} > + {dialog.autoLabelDatabaseDialog && ( + + )} {renderSource === 'predictionPanel' && } {renderSource === 'moleculePanel' && ( diff --git a/src/component/reducer/actions/MoleculeActions.ts b/src/component/reducer/actions/MoleculeActions.ts index 79d34a891..c3b89ca16 100644 --- a/src/component/reducer/actions/MoleculeActions.ts +++ b/src/component/reducer/actions/MoleculeActions.ts @@ -33,6 +33,7 @@ interface AddMoleculeProps { id?: string; floatMoleculeOnSave?: boolean; defaultMoleculeSettings?: MoleculeView; + label?: string; } type AddMoleculeAction = ActionType<'ADD_MOLECULE', AddMoleculeProps>; type AddMoleculesAction = ActionType< @@ -105,9 +106,10 @@ export type MoleculeActions = | ToggleMoleculeLabelAction; function addMolecule(draft: Draft, props: AddMoleculeProps) { - const { molfile, id, floatMoleculeOnSave, defaultMoleculeSettings } = props; + const { molfile, id, label, floatMoleculeOnSave, defaultMoleculeSettings } = + props; const isEmpty = draft.molecules.length === 0; - MoleculeManager.addMolfile(draft.molecules, molfile, id); + MoleculeManager.addMolfile(draft.molecules, molfile, { id, label }); /** * if it's the first creation of a molecule after the molecules list was empty, diff --git a/src/data/molecules/MoleculeManager.ts b/src/data/molecules/MoleculeManager.ts index 7237d1f45..47c6dcf12 100644 --- a/src/data/molecules/MoleculeManager.ts +++ b/src/data/molecules/MoleculeManager.ts @@ -35,11 +35,16 @@ export function fromJSON( return molecules; } +interface AddMolfileOptions { + id?: string; + label?: string; +} export function addMolfile( molecules: StateMoleculeExtended[], molfile: string, - id?: string, + options: AddMolfileOptions, ) { + const { id, label } = options; const reservedNumbers = extractLabelsNumbers(molecules); // try to parse molfile @@ -48,7 +53,7 @@ export function addMolfile( molecules.push( initMolecule({ molfile: molecule.toMolfileV3(), - label: `P${getLabelNumber(reservedNumbers)}`, + label: label ?? `P${getLabelNumber(reservedNumbers)}`, id, }), );