Skip to content

Commit

Permalink
Add new right-click menu for bonds
Browse files Browse the repository at this point in the history
Resolves: #1872
  • Loading branch information
yuleicul committed Dec 8, 2022
1 parent 3b993d5 commit 586adc4
Show file tree
Hide file tree
Showing 5 changed files with 390 additions and 20 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/****************************************************************************
* Copyright 2022 EPAM Systems
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
***************************************************************************/

@import 'src/style/variables';
@import 'src/style/mixins';

.contextMenu {
// Reference https://fkhadra.github.io/react-contexify/how-to-style
--contexify-menu-bgColor: @color-background-primary;
--contexify-item-color: @color-text-secondary;
--contexify-activeItem-color: @color-text-secondary;
--contexify-activeItem-bgColor: @color-dropdown-hover;
--contexify-rightSlot-color: @color-text-secondary;
--contexify-activeRightSlot-color: @color-text-secondary;
--contexify-arrow-color: @color-grey-5;
--contexify-activeArrow-color: @color-grey-5;

--contexify-menu-shadow: 0 2px 5px 0 rgba(@main-color, 0.54);
--contexify-zIndex: 666;
--contexify-menu-minWidth: 180px;
--contexify-menu-padding: 4px;
--contexify-menu-radius: 4px;
--contexify-menu-negatePadding: var(--contexify-menu-padding);
--contexify-itemContent-padding: 3px 20px;
--contexify-activeItem-radius: 0px;

line-height: 1.5;

.subMenu {
:global(.contexify_rightSlot) {
margin-right: -5px;
}
}

.icon {
width: 20px;
height: 20px;
margin: 0 10px 0 -10px;
}

:global(.contexify_item):not(:last-child) {
&::after {
content: '';
display: block;
border-top: 1px solid @border-color;
margin: 3px 0;
}
}

:global(.contexify_submenu) {
max-height: 300px;
overflow: auto;
.scrollbar();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/****************************************************************************
* Copyright 2022 EPAM Systems
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
***************************************************************************/

import { Action, fromBondsAttrs, fromOneBondDeletion } from 'ketcher-core'
import { useCallback } from 'react'
import type { ItemParams, PredicateParams } from 'react-contexify'
import { Item, Menu, Submenu } from 'react-contexify'
import 'react-contexify/ReactContexify.css'
import { useAppContext } from 'src/hooks'
import Editor from 'src/script/editor'
import tools from 'src/script/ui/action/tools'
import Icon from 'src/script/ui/component/view/icon'
import styles from './ContextMenu.module.less'

export const CONTEXT_MENU_ID = 'KeTcHeR-CoNtExT-MeNu'
const bondTools = Object.keys(tools).filter((key) => key.startsWith('bond-'))

export interface ContextMenuItemProps {
selected: boolean
ci: any
}
type ItemData = unknown

const ContextMenu: React.FC = () => {
const { getKetcherInstance } = useAppContext()

const handleEdit = useCallback(
async ({ props }: ItemParams<ContextMenuItemProps, ItemData>) => {
const editor = getKetcherInstance().editor as Editor
const bondId = props?.ci.id
const bond = editor.render.ctab.bonds.get(bondId)?.b

try {
const newBond = await editor.event.bondEdit.dispatch(bond)
editor.update(fromBondsAttrs(editor.render.ctab, bondId, newBond))
} catch (error) {
console.error(error)
}
},
[getKetcherInstance]
)

const handleBatchEdit = useCallback(
async ({ props }: ItemParams<ContextMenuItemProps, ItemData>) => {
const editor = getKetcherInstance().editor as Editor
const bondId = props?.ci.id
const bond = editor.render.ctab.bonds.get(bondId)?.b

try {
const newBond = await editor.event.bondEdit.dispatch(bond)
const action = new Action()
const selectedBonds = editor.selection()?.bonds

selectedBonds?.forEach((selectedBondId) => {
action.mergeWith(
fromBondsAttrs(editor.render.ctab, selectedBondId, newBond)
)
})

editor.update(action)
} catch (error) {
console.error(error)
}
},
[getKetcherInstance]
)

const handleDelete = useCallback(
async ({ props }: ItemParams<ContextMenuItemProps, ItemData>) => {
const editor = getKetcherInstance().editor as Editor
const bondId = props?.ci.id

editor.update(fromOneBondDeletion(editor.render.ctab, bondId))
},
[getKetcherInstance]
)

const handleBatchDelete = useCallback(() => {
const editor = getKetcherInstance().editor as Editor
const action = new Action()
const selectedBonds = editor.selection()?.bonds

selectedBonds?.forEach((bondId) => {
action.mergeWith(fromOneBondDeletion(editor.render.ctab, bondId))
})

editor.update(action)
}, [getKetcherInstance])

const handleTypeChange = useCallback(
({ id, props }: ItemParams<ContextMenuItemProps, ItemData>) => {
const editor = getKetcherInstance().editor as Editor
const bondId = props?.ci.id
const bondProps = tools[id].action.opts

editor.update(fromBondsAttrs(editor.render.ctab, bondId, bondProps))
},
[getKetcherInstance]
)

const handleBatchTypeChange = useCallback(
({ id }: ItemParams<ContextMenuItemProps, ItemData>) => {
const editor = getKetcherInstance().editor as Editor
const action = new Action()
const selectedBonds = editor.selection()?.bonds
const bondProps = tools[id].action.opts

selectedBonds?.forEach((bondId) => {
action.mergeWith(fromBondsAttrs(editor.render.ctab, bondId, bondProps))
})

editor.update(action)
},
[getKetcherInstance]
)

const isHidden = ({
props
}: PredicateParams<ContextMenuItemProps, ItemData>) => !!props?.selected
const isBatchOpHidden = ({
props
}: PredicateParams<ContextMenuItemProps, ItemData>) => !props?.selected

return (
<Menu id={CONTEXT_MENU_ID} animation={false} className={styles.contextMenu}>
{/** ****** Menu items for single update: ********/}
<Item hidden={isHidden} onClick={handleEdit}>
Edit
</Item>

<Submenu label="Bond type" hidden={isHidden} className={styles.subMenu}>
{bondTools.map((name) => (
<Item id={name} onClick={handleTypeChange} key={name}>
<Icon name={name} className={styles.icon} />
<span>{tools[name].title}</span>
</Item>
))}
</Submenu>

<Item hidden={isHidden} onClick={handleDelete}>
Delete
</Item>

{/** ****** Menu items for Batch updates: ********/}
<Item hidden={isBatchOpHidden} onClick={handleBatchEdit}>
Edit selected bond(s)
</Item>

<Submenu
label="Bond type"
hidden={isBatchOpHidden}
className={styles.subMenu}
>
{bondTools.map((name) => (
<Item id={name} onClick={handleBatchTypeChange} key={name}>
<Icon name={name} className={styles.icon} />
<span>{tools[name].title}</span>
</Item>
))}
</Submenu>

<Item hidden={isBatchOpHidden} onClick={handleBatchDelete}>
Delete selected bonds(s)
</Item>
</Menu>
)
}

export default ContextMenu
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/****************************************************************************
* Copyright 2022 EPAM Systems
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
***************************************************************************/

import { FunctionalGroup } from 'ketcher-core'
import { useCallback } from 'react'
import { useContextMenu } from 'react-contexify'
import { useAppContext } from 'src/hooks'
import Editor from 'src/script/editor'
import { ContextMenuItemProps, CONTEXT_MENU_ID } from './ContextMenu'

const ContextMenuTrigger: React.FC = ({ children }) => {
const { getKetcherInstance } = useAppContext()
const { show, hideAll } = useContextMenu<ContextMenuItemProps>({
id: CONTEXT_MENU_ID
})

const handleDisplay = useCallback(
(event) => {
const editor = getKetcherInstance().editor as Editor
const ci = editor.findItem(event, ['bonds'])

if (!ci) {
hideAll()
return
}

// Resolve conflict with existing functional group context menu
// (Need refactor after refactoring functional group context menu @Yulei)
// Resolving begins
const struct = editor.struct()
const functionalGroupId = FunctionalGroup.findFunctionalGroupByBond(
struct,
struct.functionalGroups,
ci.id
)
const hasRelatedSGroup = struct.functionalGroups.some(
(item) => item.relatedSGroupId === functionalGroupId
)

if (functionalGroupId !== null && hasRelatedSGroup) {
hideAll()
return
}

const selection = editor.selection()
if (selection && selection.atoms) {
const hasSelectedFunctionalGroup = selection.atoms.some((atomId) => {
const functionalGroupId = FunctionalGroup.findFunctionalGroupByAtom(
struct.functionalGroups,
atomId
)
const hasRelatedSGroupId = struct.functionalGroups.some(
(item) => item.relatedSGroupId === functionalGroupId
)
return functionalGroupId !== null && hasRelatedSGroupId
})
if (hasSelectedFunctionalGroup) return
}
// Resolving ends

const isRightClickingSelection: number | undefined = selection?.[
ci.map
]?.findIndex((selectedItemId) => selectedItemId === ci.id)

if (
isRightClickingSelection !== undefined &&
isRightClickingSelection !== -1
) {
// Show menu items for batch updates
show({
event,
props: {
selected: true,
ci
}
})
} else if (ci.map === 'bonds') {
// Show menu items for single update
if (selection) {
editor.render.ctab.setSelection(null)
}
show({
event,
props: { selected: false, ci }
})
}
},
[getKetcherInstance, hideAll, show]
)

return (
<div style={{ height: '100%' }} onContextMenu={handleDisplay}>
{children}
</div>
)
}

export default ContextMenuTrigger
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as ContextMenu } from './ContextMenu'
export { default as ContextMenuTrigger } from './ContextMenuTrigger'
Loading

0 comments on commit 586adc4

Please sign in to comment.