diff --git a/packages/ketcher-core/src/application/actions/action.types.ts b/packages/ketcher-core/src/application/actions/action.types.ts index 394d0f87f7..d77e83c5c5 100644 --- a/packages/ketcher-core/src/application/actions/action.types.ts +++ b/packages/ketcher-core/src/application/actions/action.types.ts @@ -14,6 +14,13 @@ * limitations under the License. ***************************************************************************/ +// import { Operation } from "application/operations" +// import { ReStruct } from "application/render" + export interface Action { perform: () => void + // operations: Array + // mergeWith(action: Action): Action + // addOp(operation: Operation, restruct?: ReStruct): Operation + // isDummy(restruct?: ReStruct): boolean } diff --git a/packages/ketcher-core/src/application/editor/editor.types.ts b/packages/ketcher-core/src/application/editor/editor.types.ts index 436f43180e..35497fcbc4 100644 --- a/packages/ketcher-core/src/application/editor/editor.types.ts +++ b/packages/ketcher-core/src/application/editor/editor.types.ts @@ -52,4 +52,5 @@ export interface Editor { setOptions: (opts: string) => any zoom: (value?: any) => any structSelected: () => Struct + // update: (action: Action | true, ignoreHistory?: boolean) => void } diff --git a/packages/ketcher-core/src/application/operations/index.ts b/packages/ketcher-core/src/application/operations/index.ts index 90a41a9494..99fd45c1f0 100644 --- a/packages/ketcher-core/src/application/operations/index.ts +++ b/packages/ketcher-core/src/application/operations/index.ts @@ -25,3 +25,4 @@ export * from './rxnPlus' export * from './sgroup' export * from './simpleObject' export * from './text' +export * from './baseOperation' diff --git a/packages/ketcher-core/src/application/operations/operations.types.ts b/packages/ketcher-core/src/application/operations/operations.types.ts index 9eba6cd35e..f62a6d7545 100644 --- a/packages/ketcher-core/src/application/operations/operations.types.ts +++ b/packages/ketcher-core/src/application/operations/operations.types.ts @@ -14,6 +14,7 @@ * limitations under the License. ***************************************************************************/ +import { ReStruct } from 'application/render' import { Struct } from 'domain/entities' export type OperationType = @@ -70,8 +71,13 @@ export type OperationType = export interface Operation { readonly type: OperationType readonly priority: number + readonly _inverted: OperationType | undefined + data: any // eslint-disable-next-line no-use-before-define perform: (struct: Struct) => PerformOperationResult + invert(): Operation + isDummy(_restruct: ReStruct): boolean + execute(_restruct: ReStruct): void } export type PerformOperationResult = { diff --git a/packages/ketcher-react/src/script/editor/Editor.ts b/packages/ketcher-react/src/script/editor/Editor.ts index e89df388c3..b2026a67b9 100644 --- a/packages/ketcher-react/src/script/editor/Editor.ts +++ b/packages/ketcher-react/src/script/editor/Editor.ts @@ -387,7 +387,7 @@ class Editor implements KetcherEditor { } } - update(action: Action | true, ignoreHistory?) { + update(action: Action | true, ignoreHistory?: boolean) { if (action === true) { this.render.update(true) // force } else { diff --git a/packages/ketcher-react/src/script/editor/tool/select.ts b/packages/ketcher-react/src/script/editor/tool/select.ts index 4f207d4030..acebb0b07b 100644 --- a/packages/ketcher-react/src/script/editor/tool/select.ts +++ b/packages/ketcher-react/src/script/editor/tool/select.ts @@ -34,7 +34,7 @@ import { import LassoHelper from './helper/lasso' import { atomLongtapEvent } from './atom' -import { sgroupDialog } from './sgroup' +import SGroupTool from './sgroup' import utils from '../shared/utils' import { xor } from 'lodash/fp' import { Editor } from '../Editor' @@ -536,7 +536,7 @@ class SelectTool { ci.map === 'sgroupData' ) { editor.selection(closestToSel(ci)) - sgroupDialog(editor, ci.id, null) + SGroupTool.sgroupDialog(editor, ci.id, null) } else if (ci.map === 'texts') { editor.selection(closestToSel(ci)) const text = molecule.texts.get(ci.id) diff --git a/packages/ketcher-react/src/script/editor/tool/sgroup.ts b/packages/ketcher-react/src/script/editor/tool/sgroup.ts index 5ffb47dfe9..4be43ec1f9 100644 --- a/packages/ketcher-react/src/script/editor/tool/sgroup.ts +++ b/packages/ketcher-react/src/script/editor/tool/sgroup.ts @@ -143,7 +143,7 @@ class SGroupTool { return } - sgroupDialog(this.editor, id !== undefined ? id : null, this.type) + SGroupTool.sgroupDialog(this.editor, id ?? null, this.type) this.isNotActiveTool = true } } @@ -443,7 +443,7 @@ class SGroupTool { // TODO: handle click on an existing group? if (id !== null || (selection && selection.atoms)) - sgroupDialog(this.editor, id, this.type) + SGroupTool.sgroupDialog(this.editor, id, this.type) } cancel() { @@ -452,75 +452,75 @@ class SGroupTool { } this.editor.selection(null) } -} -export function sgroupDialog(editor, id, defaultType) { - const restruct = editor.render.ctab - const struct = restruct.molecule - const selection = editor.selection() || {} - const sg = id !== null ? struct.sgroups.get(id) : null - const type = sg ? sg.type : defaultType - const eventName = type === 'DAT' ? 'sdataEdit' : 'sgroupEdit' - - if (!selection.atoms && !selection.bonds && !sg) { - console.info('There is no selection or sgroup') - return - } + static sgroupDialog(editor, id, defaultType) { + const restruct = editor.render.ctab + const struct = restruct.molecule + const selection = editor.selection() || {} + const sg = id !== null ? struct.sgroups.get(id) : null + const type = sg ? sg.type : defaultType + const eventName = type === 'DAT' ? 'sdataEdit' : 'sgroupEdit' - let attrs - if (sg) { - attrs = sg.getAttrs() - if (!attrs.context) attrs.context = getContextBySgroup(restruct, sg.atoms) - } else { - attrs = { - context: getContextBySelection(restruct, selection) + if (!selection.atoms && !selection.bonds && !sg) { + console.info('There is no selection or sgroup') + return } - } - const res = editor.event[eventName].dispatch({ - type, - attrs - }) + let attrs + if (sg) { + attrs = sg.getAttrs() + if (!attrs.context) attrs.context = getContextBySgroup(restruct, sg.atoms) + } else { + attrs = { + context: getContextBySelection(restruct, selection) + } + } - Promise.resolve(res) - .then((newSg) => { - // TODO: check before signal - if ( - newSg.type !== 'DAT' && // when data s-group separates - checkOverlapping(struct, selection.atoms || []) - ) { - editor.event.message.dispatch({ - error: 'Partial S-group overlapping is not allowed.' - }) - } else { + const res = editor.event[eventName].dispatch({ + type, + attrs + }) + + Promise.resolve(res) + .then((newSg) => { + // TODO: check before signal if ( - !sg && - newSg.type !== 'DAT' && - (!selection.atoms || selection.atoms.length === 0) - ) - return + newSg.type !== 'DAT' && // when data s-group separates + checkOverlapping(struct, selection.atoms || []) + ) { + editor.event.message.dispatch({ + error: 'Partial S-group overlapping is not allowed.' + }) + } else { + if ( + !sg && + newSg.type !== 'DAT' && + (!selection.atoms || selection.atoms.length === 0) + ) + return - const isDataSg = sg && sg.getAttrs().context === newSg.attrs.context + const isDataSg = sg && sg.getAttrs().context === newSg.attrs.context - if (isDataSg) { - const action = fromSeveralSgroupAddition( - restruct, - newSg.type, - sg.atoms, - newSg.attrs - ).mergeWith(fromSgroupDeletion(restruct, id)) + if (isDataSg) { + const action = fromSeveralSgroupAddition( + restruct, + newSg.type, + sg.atoms, + newSg.attrs + ).mergeWith(fromSgroupDeletion(restruct, id)) - editor.update(action) - editor.selection(selection) - return - } + editor.update(action) + editor.selection(selection) + return + } - const result = fromContextType(id, editor, newSg, selection) - editor.update(result.action) - editor.selection(null) - } - }) - .catch(() => null) + const result = fromContextType(id, editor, newSg, selection) + editor.update(result.action) + editor.selection(null) + } + }) + .catch(() => null) + } } function getContextBySgroup(restruct, sgAtoms) { diff --git a/packages/ketcher-react/src/script/ui/action/tools.js b/packages/ketcher-react/src/script/ui/action/tools.js index 6d16db26f6..1ae087fb04 100644 --- a/packages/ketcher-react/src/script/ui/action/tools.js +++ b/packages/ketcher-react/src/script/ui/action/tools.js @@ -23,7 +23,7 @@ import { toBondType } from '../data/convert/structconv' const toolActions = { hand: { title: 'Hand tool', - shortcut: 'Mod+h', + shortcut: 'Mod+Alt+h', action: { tool: 'hand' }, hidden: (options) => isHidden(options, 'hand') }, diff --git a/packages/ketcher-react/src/script/ui/state/handleHotkeysOverAtom.ts b/packages/ketcher-react/src/script/ui/state/handleHotkeysOverAtom.ts new file mode 100644 index 0000000000..89be1857f9 --- /dev/null +++ b/packages/ketcher-react/src/script/ui/state/handleHotkeysOverAtom.ts @@ -0,0 +1,214 @@ +import { + fromAtomAddition, + fromAtomsAttrs, + fromBondAddition, + fromOneAtomDeletion, + FunctionalGroup, + SGroup, + Atom +} from 'ketcher-core' +import Tools from '../../editor/tool' +import { onAction } from './shared' + +type hotkeyOverAtomHandler = { + hoveredItemId: number + newAction: { + tool: string + opts?: any + } + render: any + editor: any +} + +export async function handleHotkeyOverAtom({ + hoveredItemId, + newAction, + render, + editor, + dispatch +}) { + const toolsMapping = { + atom: () => handleAtomTool({ hoveredItemId, newAction, render, editor }), + bond: () => handleBondTool({ hoveredItemId, newAction, render, editor }), + eraser: () => + handleEraserTool({ hoveredItemId, newAction, render, editor }), + select: () => + handleSelectionTool({ hoveredItemId, newAction, render, editor }), + charge: () => + handleChargeTool({ hoveredItemId, newAction, render, editor }), + rgroupatom: () => + handleRGroupAtomTool({ hoveredItemId, newAction, render, editor }), + sgroup: () => { + Tools.sgroup.sgroupDialog(editor, hoveredItemId, null) + }, + hand: () => + dispatch( + onAction({ + tool: 'hand' + }) + ) + } + const toolHandler = toolsMapping[newAction.tool] + const isChangeStructureTool = + newAction.tool !== 'hand' || newAction.tool !== 'select' + if (toolHandler) { + const isFunctionalGroupChange = await isChangingFunctionalGroup({ + hoveredItemId, + render, + editor, + newAction + }) + if (!isFunctionalGroupChange && isChangeStructureTool) { + return + } + toolHandler() + } +} + +function handleAtomTool({ + hoveredItemId, + newAction, + render, + editor +}: hotkeyOverAtomHandler) { + const atomProps = { ...newAction.opts } + const updatedAtoms = fromAtomsAttrs( + render.ctab, + hoveredItemId, + atomProps, + true + ) + editor.update(updatedAtoms) +} + +function handleBondTool({ + hoveredItemId, + newAction, + render, + editor +}: hotkeyOverAtomHandler) { + const newBond = fromBondAddition( + render.ctab, + newAction.opts, + hoveredItemId, + undefined + )[0] + editor.update(newBond) +} + +function handleEraserTool({ + hoveredItemId, + render, + editor +}: hotkeyOverAtomHandler) { + editor.update(fromOneAtomDeletion(render.ctab, hoveredItemId)) +} + +function handleSelectionTool({ hoveredItemId, editor }: hotkeyOverAtomHandler) { + editor.selection({ + atoms: [hoveredItemId] + }) +} + +function handleChargeTool({ + hoveredItemId, + newAction, + render, + editor +}: hotkeyOverAtomHandler) { + const existingAtom = render.ctab.atoms.get(hoveredItemId)?.a + if (existingAtom) { + const updatedAtom = fromAtomsAttrs( + render.ctab, + hoveredItemId, + { + charge: existingAtom.charge + newAction.opts + }, + null + ) + editor.update(updatedAtom) + } +} + +async function handleRGroupAtomTool({ + hoveredItemId, + render, + editor +}: hotkeyOverAtomHandler) { + const struct = render.ctab.molecule + const atom = + hoveredItemId || hoveredItemId === 0 + ? struct.atoms.get(hoveredItemId) + : null + const rglabel = atom ? atom.rglabel : 0 + const label = atom ? atom.label : 'R#' + + try { + let element = await editor.event.elementEdit.dispatch({ + label: 'R#', + rglabel, + fragId: atom ? atom.fragment : null + }) + element = Object.assign({}, Atom.attrlist, element) + + if (!hoveredItemId && hoveredItemId !== 0 && element.rglabel) { + editor.update(fromAtomAddition(editor.render.ctab, null, element)) + } else if (rglabel !== element.rglabel) { + if (!element.rglabel && label !== 'R#') { + element.label = label + } + + editor.update( + fromAtomsAttrs(editor.render.ctab, hoveredItemId, element, false) + ) + } + } catch (error) {} // w/o changes +} + +async function isChangingFunctionalGroup({ + hoveredItemId, + render, + editor +}: hotkeyOverAtomHandler) { + const atomResult: number[] = [] + const result: number[] = [] + const atom = render.ctab.atoms.get(hoveredItemId)?.a + const molecule = render.ctab.molecule + const functionalGroups = molecule.functionalGroups + const sgroupsOnCanvas = Array.from(molecule.sgroups.values()) + if (atom && functionalGroups) { + const atomId = FunctionalGroup.atomsInFunctionalGroup( + functionalGroups, + hoveredItemId + ) + + if (atomId !== null) { + atomResult.push(atomId) + } + } + + const isFunctionalGroup = atomResult.length > 0 + + if (isFunctionalGroup) { + if ( + SGroup.isAtomInSaltOrSolvent( + hoveredItemId as number, + sgroupsOnCanvas as SGroup[] + ) + ) { + return false + } + for (const id of atomResult) { + const fgId = FunctionalGroup.findFunctionalGroupByAtom( + functionalGroups, + id + ) + + if (fgId !== null && !result.includes(fgId)) { + result.push(fgId) + } + } + return await editor.event.removeFG.dispatch({ fgIds: result }) + } + return true +} diff --git a/packages/ketcher-react/src/script/ui/state/hotkeys.ts b/packages/ketcher-react/src/script/ui/state/hotkeys.ts index 455b8ad551..1be9da6c13 100644 --- a/packages/ketcher-react/src/script/ui/state/hotkeys.ts +++ b/packages/ketcher-react/src/script/ui/state/hotkeys.ts @@ -21,7 +21,6 @@ import { MolSerializer, formatProperties, ChemicalMimeType, - fromAtomsAttrs, ReAtom } from 'ketcher-core' import { debounce, isEqual } from 'lodash/fp' @@ -31,6 +30,7 @@ import actions from '../action' import keyNorm from '../data/convert/keynorm' import { openDialog } from './modal' import { isIE } from 'react-device-detect' +import { handleHotkeyOverAtom } from './handleHotkeysOverAtom' export function initKeydownListener(element) { return function (dispatch, getState) { @@ -74,20 +74,19 @@ function keyHandle(dispatch, state, hotKeys, event) { } if (clipArea.actions.indexOf(actName) === -1) { const newAction = actions[actName].action - const hoverItemId = getHoveredAtomId(render.ctab.atoms) - const isHoveringOverAtom = hoverItemId !== null + const hoveredItemId = getHoveredAtomId(render.ctab.atoms) + const isHoveringOverAtom = hoveredItemId !== null if (isHoveringOverAtom) { // check if atom is currently hovered over // in this case we do not want to activate the corresponding tool // and just insert the atom directly - const atomProps = { ...newAction.opts } - const updatedAtoms = fromAtomsAttrs( - render.ctab, - hoverItemId, - atomProps, - true - ) - editor.update(updatedAtoms) + handleHotkeyOverAtom({ + hoveredItemId, + newAction, + render, + editor, + dispatch + }) } else { dispatch(onAction(newAction)) }