From 5de8b23d6a95f9da5f48cde7c4c65a400d85d3bb Mon Sep 17 00:00:00 2001 From: hamed musallam Date: Tue, 27 Oct 2026 09:55:16 +0100 Subject: [PATCH 1/2] feat: support pasting chemical group closes #4414 --- package-lock.json | 15 +++++++++ package.json | 1 + .../MoleculesPanel/MoleculePanelHeader.tsx | 6 ++-- src/data/molecules/Molecule.ts | 33 +++++++++++++++---- src/data/molecules/MoleculeManager.ts | 20 ++++++----- 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 54dae962f..1cfa89524 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "d3-zoom": "^3.0.0", "dlv": "^1.1.3", "eventemitter3": "^5.0.4", + "expand-chemical-groups": "^1.0.0", "fifo-logger": "^2.0.1", "file-collection": "^6.7.0", "file-saver": "^2.0.5", @@ -6803,6 +6804,20 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expand-chemical-groups": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/expand-chemical-groups/-/expand-chemical-groups-1.0.0.tgz", + "integrity": "sha512-jStn7mgdSVlnRFtDtpXN1dlzkK9+z6Wa0QiNTJybqrBt3+PfvIzsqnLFrPGlBpvbAqMmwGQ5NYcZ6gKwfbdk3w==", + "license": "MIT", + "workspaces": [ + "dev" + ], + "dependencies": { + "chemical-groups": "^2.2.4", + "mf-parser": "^3.9.3", + "openchemlib": "^9.25.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", diff --git a/package.json b/package.json index 4b5f48d5a..242145478 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "d3-zoom": "^3.0.0", "dlv": "^1.1.3", "eventemitter3": "^5.0.4", + "expand-chemical-groups": "^1.0.0", "fifo-logger": "^2.0.1", "file-collection": "^6.7.0", "file-saver": "^2.0.5", diff --git a/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx b/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx index 56a896283..89ee26c8a 100644 --- a/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx +++ b/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx @@ -251,7 +251,7 @@ export default function MoleculePanelHeader(props: MoleculePanelHeaderProps) { } /** - * The moleecule pasted could be SMILES, molfile or SDF + * The molecule pasted could be SMILES, molfile, SDF or chemical groups * @param text - text from clipboard * @returns */ @@ -430,7 +430,7 @@ export default function MoleculePanelHeader(props: MoleculePanelHeaderProps) { /> )} } onClick={handlePasteMoleculeAction} /> @@ -502,7 +502,7 @@ export default function MoleculePanelHeader(props: MoleculePanelHeaderProps) { onDismiss={cleanShouldFallback} onReadText={handlePasteMolecule} text={text} - label="Enter here a molfile or SMILES" + label="Enter here a molfile, SMILES or chemical groups (e.g. HAlaGlyOH)" /> ); diff --git a/src/data/molecules/Molecule.ts b/src/data/molecules/Molecule.ts index 5bd7e5fbe..83aa3454f 100644 --- a/src/data/molecules/Molecule.ts +++ b/src/data/molecules/Molecule.ts @@ -1,4 +1,5 @@ import type { MoleculeView, StateMolecule } from '@zakodium/nmrium-core'; +import { expandChemicalGroups } from 'expand-chemical-groups'; import { Molecule } from 'openchemlib'; import getAtomsFromMF from '../utilities/getAtomsFromMF.js'; @@ -36,17 +37,37 @@ type InitializeMoleculeOptions = { text: string } & Omit< 'molfile' >; +export const parseErrorMessage = + 'Failed to parse SMILES, molfile or chemical groups. Please paste a valid format'; + +function parseMoleculeText(text: string): Molecule { + const moleculeText = text.trim(); + if (!moleculeText) { + throw new Error(parseErrorMessage); + } + + if (/v[23]000/i.test(text)) { + return Molecule.fromMolfile(text); + } + + try { + return Molecule.fromSmiles(moleculeText); + } catch { + // fallback to expanding chemical groups if SMILES parsing fails + // for example HAlaGlyOH, BocAlaOH ...etc. + // TODO a way to check if the text is a valid chemical group instead of fallback if can not read as SMILES + return expandChemicalGroups(moleculeText); + } +} + export function initMolecule( options: InitializeMoleculeOptions, ): StateMoleculeExtended { const { id = crypto.randomUUID(), label = 'p#', text, selector } = options; + const molecule = parseMoleculeText(text); - const molecule = Molecule.fromText(text); - - if (!molecule) { - throw new Error( - 'Failed to parse SMILES or molfile. Please paste a valid format', - ); + if (molecule.getAllAtoms() === 0) { + throw new Error(parseErrorMessage); } const molfile = molecule.toMolfileV3(); const mfInfo = molecule.getMolecularFormula(); diff --git a/src/data/molecules/MoleculeManager.ts b/src/data/molecules/MoleculeManager.ts index e40a9aef1..a32ffcb03 100644 --- a/src/data/molecules/MoleculeManager.ts +++ b/src/data/molecules/MoleculeManager.ts @@ -3,7 +3,9 @@ import { readSDF } from '@zakodium/nmrium-core-plugins'; import { Molecule } from 'openchemlib'; import type { StateMoleculeExtended } from './Molecule.js'; -import { initMolecule } from './Molecule.js'; +import { initMolecule, parseErrorMessage } from './Molecule.js'; + +export { parseErrorMessage } from './Molecule.js'; //TODO: Move molecule initialization to nmrium-core and log an error if the molecule cannot be parsed. export function fromJSON( @@ -105,9 +107,6 @@ function getLabelNumber(reserveNumbers: number[]): number { return 1; } -export const parseErrorMessage = - 'Failed to parse SMILES or molfile. Please paste a valid format'; - function validateMolecules(molecules: StateMolecule[]) { if (!molecules?.length) { throw new Error(parseErrorMessage); @@ -132,10 +131,8 @@ function validateMolecules(molecules: StateMolecule[]) { } /** - * We will extract the molecule(s) from the text. - * Because a molfile is actually a SDF we can just call 'readSDF' method - * @param text - text containing one or several molecules in SMILES, molfile or SDF format - * @returns + * Read SDF records and validate their structures. + * @param text - SDF content */ function parseSDF(text: string) { @@ -148,12 +145,17 @@ function parseSDF(text: string) { } } +/** + * Parse SDF records or a single molfile, SMILES or chemical-group. + * @param text - Pasted structure text + */ export function getMolecules(text?: string) { if (!text?.trim()) { throw new Error(parseErrorMessage); } - const isSDF = /v[23]000/i.test(text); + // identifies an SDF file by checking for the presence of the '$$$$' separator + const isSDF = text.includes('$$$$'); return isSDF ? parseSDF(text) : [initMolecule({ text })]; } From f1fc0f99eebe6acb7c513147a336a9e6f40e414f Mon Sep 17 00:00:00 2001 From: hamed musallam Date: Thu, 29 Oct 2026 09:27:51 +0100 Subject: [PATCH 2/2] test: update test for paste molecule action --- src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx | 1 + test-e2e/panels/structures.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx b/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx index 89ee26c8a..dea5fde3d 100644 --- a/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx +++ b/src/component/panels/MoleculesPanel/MoleculePanelHeader.tsx @@ -433,6 +433,7 @@ export default function MoleculePanelHeader(props: MoleculePanelHeaderProps) { tooltip="Paste SMILES, molfile or chemical groups" icon={} onClick={handlePasteMoleculeAction} + data-testid="paste-molecule-action" /> {renderSource === 'moleculePanel' && ( { if (browserName !== 'webkit') { await test.step('Check copy as molfile V3 or V2 and paste', async () => { await clickExportMenuOption(nmrium, 'text=Copy as molfile V3'); - await nmrium.clickToolByTitle('Paste SMILES or molfile'); + await nmrium.page.getByTestId('paste-molecule-action').click(); await clickExportMenuOption(nmrium, 'text=Copy as molfile V2'); - await nmrium.clickToolByTitle('Paste SMILES or molfile'); + await nmrium.page.getByTestId('paste-molecule-action').click(); await expect( nmrium.page.locator('_react=MoleculePanel >> text=5 / 5'),