diff --git a/packages/ketcher-core/src/application/editor/actions/closelyFusing.ts b/packages/ketcher-core/src/application/editor/actions/closelyFusing.ts index 886f6d38a1..fb2f35a00a 100644 --- a/packages/ketcher-core/src/application/editor/actions/closelyFusing.ts +++ b/packages/ketcher-core/src/application/editor/actions/closelyFusing.ts @@ -67,7 +67,10 @@ export function getHoverToFuse(items) { const hoverItems = { atoms: Array.from(items.atoms.values()), - bonds: Array.from(items.bonds.values()) + bonds: Array.from(items.bonds.values()), + ...(items.functionalGroups && { + functionalGroups: Array.from(items.functionalGroups.values()) + }) } return { map: 'merge', id: +Date.now(), items: hoverItems } diff --git a/packages/ketcher-core/src/application/editor/actions/template.ts b/packages/ketcher-core/src/application/editor/actions/template.ts index 5e8bd93f51..7ff4c4a45c 100644 --- a/packages/ketcher-core/src/application/editor/actions/template.ts +++ b/packages/ketcher-core/src/application/editor/actions/template.ts @@ -16,9 +16,9 @@ import { Atom, Vec2 } from 'domain/entities' import { AtomAdd, BondAdd, CalcImplicitH } from '../operations' -import { atomForNewBond, atomGetAttr } from './utils' +import { atomForNewBond, atomGetAttr, extraBondAction } from './utils' import { fromAtomsAttrs, mergeSgroups } from './atom' -import { fromBondAddition, fromBondStereoUpdate, fromBondsAttrs } from './bond' +import { fromBondStereoUpdate, fromBondsAttrs } from './bond' import { Action } from './action' import closest from '../shared/closest' @@ -40,49 +40,14 @@ export function fromTemplateOnCanvas(restruct, template, pos, angle) { return [action, pasteItems] } -function extraBondAction(restruct, aid, angle) { - let action = new Action() - const frid = atomGetAttr(restruct, aid, 'fragment') - let additionalAtom: any = null - - if (angle === null) { - const middleAtom = atomForNewBond(restruct, aid) - const actionRes = fromBondAddition( - restruct, - { type: 1 }, - aid, - middleAtom.atom, - middleAtom.pos.get_xy0() - ) - action = actionRes[0] - action.operations.reverse() - additionalAtom = actionRes[2] - } else { - const operation = new AtomAdd( - { label: 'C', fragment: frid }, - new Vec2(1, 0) - .rotate(angle) - .add(restruct.molecule.atoms.get(aid).pp) - .get_xy0() - ).perform(restruct) as AtomAdd - - action.addOp(operation) - action.addOp( - new BondAdd(aid, operation.data.aid, { type: 1 }).perform(restruct) - ) - - additionalAtom = operation.data.aid - } - - return { action, aid1: additionalAtom } -} - export function fromTemplateOnAtom(restruct, template, aid, angle, extraBond) { let action = new Action() const tmpl = template.molecule const struct = restruct.molecule + const isTmplSingleGroup = template.molecule.isSingleGroup() + let atom = struct.atoms.get(aid) // aid - the atom that was clicked on let aid1 = aid // aid1 - the atom on the other end of the extra bond || aid @@ -134,7 +99,8 @@ export function fromTemplateOnAtom(restruct, template, aid, angle, extraBond) { pasteItems.atoms.push(operation.data.aid) } }) - mergeSgroups(action, restruct, pasteItems.atoms, aid) + + if (!isTmplSingleGroup) mergeSgroups(action, restruct, pasteItems.atoms, aid) tmpl.bonds.forEach((bond) => { const operation = new BondAdd( diff --git a/packages/ketcher-core/src/application/editor/actions/utils.ts b/packages/ketcher-core/src/application/editor/actions/utils.ts index 096c91c36c..3547c78752 100644 --- a/packages/ketcher-core/src/application/editor/actions/utils.ts +++ b/packages/ketcher-core/src/application/editor/actions/utils.ts @@ -18,6 +18,7 @@ import { Bond, Vec2 } from 'domain/entities' import closest from '../shared/closest' import { difference } from 'lodash' +import { Action, AtomAdd, BondAdd, fromBondAddition } from 'application/editor' export function atomGetAttr(restruct, aid, name) { return restruct.molecule.atoms.get(aid)[name] @@ -198,3 +199,40 @@ export function isAttachmentBond({ begin, end }: Bond, selection): boolean { isBondEndsInSelectionAndStartsOutside ) } + +export function extraBondAction(restruct, aid, angle) { + let action = new Action() + const frid = atomGetAttr(restruct, aid, 'fragment') + let additionalAtom: any = null + + if (angle === null) { + const middleAtom = atomForNewBond(restruct, aid) + const actionRes = fromBondAddition( + restruct, + { type: 1 }, + aid, + middleAtom.atom, + middleAtom.pos.get_xy0() + ) + action = actionRes[0] + action.operations.reverse() + additionalAtom = actionRes[2] + } else { + const operation = new AtomAdd( + { label: 'C', fragment: frid }, + new Vec2(1, 0) + .rotate(angle) + .add(restruct.molecule.atoms.get(aid).pp) + .get_xy0() + ).perform(restruct) as AtomAdd + + action.addOp(operation) + action.addOp( + new BondAdd(aid, operation.data.aid, { type: 1 }).perform(restruct) + ) + + additionalAtom = operation.data.aid + } + + return { action, aid1: additionalAtom } +} diff --git a/packages/ketcher-core/src/application/render/restruct/rebond.ts b/packages/ketcher-core/src/application/render/restruct/rebond.ts index dbf67940c6..2068cc1cd1 100644 --- a/packages/ketcher-core/src/application/render/restruct/rebond.ts +++ b/packages/ketcher-core/src/application/render/restruct/rebond.ts @@ -109,13 +109,15 @@ class ReBond extends ReObject { const bond = restruct.molecule.bonds.get(bid) const sgroups = restruct.molecule.sgroups const functionalGroups = restruct.molecule.functionalGroups + const sgroupsIds = struct.getGroupsIdsFromBondId(bid) if ( FunctionalGroup.isBondInContractedFunctionalGroup( bond, sgroups, functionalGroups, false - ) + ) && + sgroupsIds.length < 2 ) { return } diff --git a/packages/ketcher-core/src/domain/entities/struct.ts b/packages/ketcher-core/src/domain/entities/struct.ts index ccda5c531f..4529385c52 100644 --- a/packages/ketcher-core/src/domain/entities/struct.ts +++ b/packages/ketcher-core/src/domain/entities/struct.ts @@ -1079,6 +1079,7 @@ export class Struct { } // TODO: simplify if bonds ids ever appear in sgroup + // ! deprecate getGroupIdFromBondId(bondId: number): number | null { const bond = this.bonds.get(bondId) if (!bond) return null @@ -1092,4 +1093,21 @@ export class Struct { } return null } + + getGroupsIdsFromBondId(bondId: number): number[] { + const bond = this.bonds.get(bondId) + if (!bond) return [] + + const groupsIds: number[] = [] + + for (const [groupId, sgroup] of Array.from(this.sgroups)) { + if ( + sgroup.atoms.includes(bond.begin) || + sgroup.atoms.includes(bond.end) + ) { + groupsIds.push(groupId) + } + } + return groupsIds + } } diff --git a/packages/ketcher-react/src/script/editor/shared/closest.js b/packages/ketcher-react/src/script/editor/shared/closest.js index df16672f3c..57acea9c89 100644 --- a/packages/ketcher-react/src/script/editor/shared/closest.js +++ b/packages/ketcher-react/src/script/editor/shared/closest.js @@ -427,9 +427,12 @@ function findClosestSGroup(restruct, pos) { return null } -function findClosestFG(restruct, pos) { +function findClosestFG(restruct, pos, skip) { const sGroups = restruct.sgroups - for (const reSGroup of sGroups.values()) { + const skipId = skip && skip.map === 'functionalGroups' ? skip.id : null + for (const [reSGroupId, reSGroup] of sGroups.entries()) { + if (reSGroupId === skipId) continue + const { startX, startY, width, height } = reSGroup.getTextHighlightDimensions() const { x, y } = Scale.obj2scaled(pos, restruct.render.options) diff --git a/packages/ketcher-react/src/script/editor/tool/helper/handleSaltsAndSolvents.ts b/packages/ketcher-react/src/script/editor/tool/helper/handleSaltsAndSolvents.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/ketcher-react/src/script/editor/tool/paste.ts b/packages/ketcher-react/src/script/editor/tool/paste.ts index fe4a981299..44bd74b05c 100644 --- a/packages/ketcher-react/src/script/editor/tool/paste.ts +++ b/packages/ketcher-react/src/script/editor/tool/paste.ts @@ -14,23 +14,41 @@ * limitations under the License. ***************************************************************************/ -import { fromPaste, getHoverToFuse, getItemsToFuse, Struct } from 'ketcher-core' +import { + fromItemsFuse, + fromPaste, + fromTemplateOnAtom, + getHoverToFuse, + getItemsToFuse, + SGroup, + Struct, + Vec2 +} from 'ketcher-core' import Editor from '../Editor' import { dropAndMerge } from './helper/dropAndMerge' import { getGroupIdsFromItemArrays } from './helper/getGroupIdsFromItems' import { getMergeItems } from './helper/getMergeItems' +import utils from '../shared/utils' +// import utils from "../shared/utils"; class PasteTool { editor: Editor struct: Struct action: any + templateAction: any + dragCtx: any + findItems: string[] mergeItems: any + isSingleContractedGroup: boolean constructor(editor, struct) { this.editor = editor this.editor.selection(null) this.struct = struct + this.isSingleContractedGroup = + struct.isSingleGroup() && !struct.functionalGroups.get(0).isExpanded + const rnd = this.editor.render const { clientHeight, clientWidth } = rnd.clientArea const point = this.editor.lastEvent @@ -44,33 +62,116 @@ class PasteTool { this.action = action this.editor.update(this.action, true) + this.findItems = ['functionalGroups'] this.mergeItems = getItemsToFuse(this.editor, pasteItems) this.editor.hover(getHoverToFuse(this.mergeItems), this) } - mousemove(event) { - const rnd = this.editor.render + mousedown(event) { + if ( + !this.isSingleContractedGroup || + SGroup.isSaltOrSolvent(this.struct.sgroups.get(0)?.data.name) + ) { + return + } if (this.action) { - this.action.perform(rnd.ctab) + // remove pasted group from canvas to find closest group correctly + this.action?.perform(this.editor.render.ctab) } - const [action, pasteItems] = fromPaste( - rnd.ctab, - this.struct, - rnd.page2obj(event) - ) - this.action = action - this.editor.update(this.action, true) + const closestGroupItem = this.editor.findItem(event, ['functionalGroups']) + const closestGroup = this.editor.struct().sgroups.get(closestGroupItem.id) + + // not dropping on a group (tmp, should be removed when dealing with other entities) + if (!closestGroupItem || SGroup.isSaltOrSolvent(closestGroup?.data.name)) { + // recreate action and continue as usual + const [action] = fromPaste( + this.editor.render.ctab, + this.struct, + this.editor.render.page2obj(event) + ) + this.action = action + return + } + + // remove action to prevent error when trying to "perform" it again in mousemove + this.action = null - this.mergeItems = getMergeItems(this.editor, pasteItems) - this.editor.hover(getHoverToFuse(this.mergeItems)) + this.dragCtx = { + xy0: this.editor.render.page2obj(event), + item: closestGroupItem + } } - mouseup() { - const struct = this.editor.render.ctab - const molecule = struct.molecule + mousemove(event) { + if (this.action) { + this.action?.perform(this.editor.render.ctab) + } + + if (this.dragCtx) { + // template-like logic for group-on-group actions + let pos0: Vec2 | null | undefined = null + const pos1 = this.editor.render.page2obj(event) + + const extraBond = true + + const targetGroup = this.editor.struct().sgroups.get(this.dragCtx.item.id) + const atomId = targetGroup?.getAttAtomId(this.editor.struct()) + + if (atomId !== undefined) { + const atom = this.editor.struct().atoms.get(atomId) + pos0 = atom?.pp + } + + // calc angle + let angle = utils.calcAngle(pos0, pos1) + + if (!event.ctrlKey) { + angle = utils.fracAngle(angle, null) + } + const degrees = utils.degrees(angle) + + // check if anything changed since last time + if ( + this.dragCtx.hasOwnProperty('angle') && + this.dragCtx.angle === degrees + ) + return + + if (this.dragCtx.action) { + this.dragCtx.action.perform(this.editor.render.ctab) + } + + this.dragCtx.angle = degrees + + const [action] = fromTemplateOnAtom( + this.editor.render.ctab, + prepareTemplateFromSingleGroup(this.struct), + atomId, + angle, + extraBond + ) + + this.dragCtx.action = action + this.editor.update(this.dragCtx.action, true) + } else { + // common paste logic + const [action, pasteItems] = fromPaste( + this.editor.render.ctab, + this.struct, + this.editor.render.page2obj(event) + ) + this.action = action + this.editor.update(this.action, true) + + this.mergeItems = getMergeItems(this.editor, pasteItems) + this.editor.hover(getHoverToFuse(this.mergeItems)) + } + } + + mouseup() { const idsOfItemsMerged = this.mergeItems && { ...(this.mergeItems.atoms && { atoms: Array.from(this.mergeItems.atoms.values()) @@ -81,7 +182,7 @@ class PasteTool { } const groupsIdsInvolvedInMerge = getGroupIdsFromItemArrays( - molecule, + this.editor.struct(), idsOfItemsMerged ) @@ -90,10 +191,25 @@ class PasteTool { return } - // need to delete action first, because editor.update calls this.cancel() and thus action revert 🤦‍♂️ - const action = this.action - delete this.action - dropAndMerge(this.editor, this.mergeItems, action) + if (this.dragCtx) { + const dragCtx = this.dragCtx + delete this.dragCtx + + dragCtx.action = dragCtx.action + ? fromItemsFuse(this.editor.render.ctab, dragCtx.mergeItems).mergeWith( + dragCtx.action + ) + : fromItemsFuse(this.editor.render.ctab, dragCtx.mergeItems) + + this.editor.hover(null) + this.editor.update(dragCtx.action) + this.editor.event.message.dispatch({ info: false }) + } else { + // need to delete action first, because editor.update calls this.cancel() and thus action revert 🤦‍♂️ + const action = this.action + delete this.action + dropAndMerge(this.editor, this.mergeItems, action) + } } cancel() { @@ -112,4 +228,36 @@ class PasteTool { } } +type Template = { + aid?: number + molecule?: Struct + xy0?: Vec2 + angle0?: number +} +function prepareTemplateFromSingleGroup(molecule: Struct): Template | null { + const template: Template = {} + const sgroup = molecule.sgroups.get(0) + const xy0 = new Vec2() + + molecule.atoms.forEach((atom) => { + xy0.add_(atom.pp) // eslint-disable-line no-underscore-dangle + }) + + template.aid = sgroup?.getAttAtomId(molecule) || 0 + template.molecule = molecule + template.xy0 = xy0.scaled(1 / (molecule.atoms.size || 1)) // template center + + const atom = molecule.atoms.get(template.aid) + if (atom) { + template.angle0 = utils.calcAngle(atom.pp, template.xy0) // center tilt + } + + return template +} + +/** ID is constantly changing while group is dragged, so we have to get it every time */ +// function getPastedGroupCurrentId(editor: Editor, pasteItems) { +// return editor.struct().getGroupIdFromAtomId(pasteItems.atoms[0]) +// } + export default PasteTool diff --git a/packages/ketcher-react/src/script/editor/tool/template.ts b/packages/ketcher-react/src/script/editor/tool/template.ts index 9f089f1f19..c65378b29a 100644 --- a/packages/ketcher-react/src/script/editor/tool/template.ts +++ b/packages/ketcher-react/src/script/editor/tool/template.ts @@ -24,8 +24,8 @@ import { getItemsToFuse, FunctionalGroup, SGroup, - ReStruct, - Struct + Struct, + ReStruct } from 'ketcher-core' import utils from '../shared/utils' @@ -38,6 +38,7 @@ class TemplateTool { template: any findItems: Array dragCtx: any + targetGroupsIds: Array = [] constructor(editor, tmpl) { this.editor = editor @@ -76,7 +77,7 @@ class TemplateTool { const sgroup = frag.sgroups.size if (sgroup) { - this.findItems.push('sgroups') + this.findItems.push('functionalGroups') } } @@ -90,9 +91,8 @@ class TemplateTool { const ctab = this.editor.render.ctab const struct = ctab.molecule - /* break if merging into FG */ if (struct.functionalGroups.size) { - const groupsIdsInvolvedInMerge = getGroupIdsFromItemArrays(struct, { + this.targetGroupsIds = getGroupIdsFromItemArrays(struct, { ...(closestItem?.map === 'atoms' && { atoms: [closestItem.id] }), ...(closestItem?.map === 'bonds' && { bonds: [closestItem.id] }) }) @@ -104,15 +104,9 @@ class TemplateTool { struct.functionalGroups ) ) { - groupsIdsInvolvedInMerge.push(closestItem.id) - } - - if (groupsIdsInvolvedInMerge.length) { - this.editor.event.removeFG.dispatch({ fgIds: groupsIdsInvolvedInMerge }) - return + this.targetGroupsIds.push(closestItem.id) } } - /* end */ this.editor.hover(null) @@ -236,11 +230,16 @@ class TemplateTool { if (!ci) { // ci.type == 'Canvas' pos0 = dragCtx.xy0 - } else if (ci.map === 'atoms') { - pos0 = struct.atoms.get(ci.id)?.pp + } else if (ci.map === 'atoms' || ci.map === 'functionalGroups') { + const atomId = getTargetAtomId(struct, ci) + + if (atomId !== undefined) { + const atom = struct.atoms.get(atomId) + pos0 = atom?.pp - if (pos0) { - extraBond = this.mode === 'fg' ? true : Vec2.dist(pos0, pos1) > 1 + if (pos0) { + extraBond = this.mode === 'fg' ? true : Vec2.dist(pos0, pos1) > 1 + } } } @@ -276,11 +275,13 @@ class TemplateTool { let action = null let pasteItems - if (ci?.map === 'atoms') { + if (ci?.map === 'atoms' || ci?.map === 'functionalGroups') { + const atomId = getTargetAtomId(struct, ci) + ;[action, pasteItems] = fromTemplateOnAtom( restruct, this.template, - ci.id, + atomId, angle, extraBond ) @@ -295,12 +296,20 @@ class TemplateTool { this.editor.hover(getHoverToFuse(dragCtx.mergeItems)) } + // TODO: refactor after #2195 comes into effect + if (this.targetGroupsIds.length) this.targetGroupsIds.length = 0 + return true } mouseup(event) { const dragCtx = this.dragCtx + if (this.targetGroupsIds.length) { + this.editor.event.removeFG.dispatch({ fgIds: this.targetGroupsIds }) + return + } + if (!dragCtx) { return true } @@ -480,4 +489,15 @@ function getSign(molecule, bond, v) { return 0 } +function getTargetAtomId(struct: Struct, ci): number | void { + if (ci.map === 'atoms') { + return ci.id + } + + if (ci.map === 'functionalGroups') { + const group = struct.sgroups.get(ci.id) + return group?.getAttAtomId(struct) + } +} + export default TemplateTool