Skip to content

Commit

Permalink
#1872 Add right-click menu for bonds (#1896)
Browse files Browse the repository at this point in the history
* Add new dependency react-contexify

* Add new right-click menu for bonds

Resolves: #1872

* Fix stylelint and typo

* Divide context menu items into two components

* Change error handler to do nothing

* Remove the word `bond` out of context submenu item

* Extract common util functions to `utils.ts`

* Rename `ci` to `closestItem`

* Fix context menu not hiding when click left/bottom toolbar

* Make context menu id more readable

* Simplify usage of `fromBondsAttrs`
  • Loading branch information
yuleicul authored Jan 3, 2023
1 parent 2a94636 commit 06be47a
Show file tree
Hide file tree
Showing 12 changed files with 553 additions and 22 deletions.
1 change: 1 addition & 0 deletions packages/ketcher-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"lodash": "^4.17.21",
"miew-react": "^1.0.0",
"react-colorful": "^5.4.0",
"react-contexify": "^6.0.0",
"react-contextmenu": "^2.14.0",
"react-device-detect": "^2.2.2",
"react-dropzone": "^11.7.1",
Expand Down
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.
***************************************************************************/

/* stylelint-disable selector-pseudo-class-no-unknown */
@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: 0;

line-height: 1.5;

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

:global(.contexify_submenu) {
max-height: 300px;
overflow: auto;
.scrollbar();
}

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

.subMenu {
:global(.contexify_rightSlot) {
margin-right: -5px;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/****************************************************************************
* 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 { Menu } from 'react-contexify'
import 'react-contexify/ReactContexify.css'
import styles from './ContextMenu.module.less'
import BondBatchOperations from './items/BondBatchOperations'
import BondSingleOperations from './items/BondSingleOperations'

export const CONTEXT_MENU_ID = 'ketcherBondContextMenu'

const ContextMenu: React.FC = () => {
return (
<Menu id={CONTEXT_MENU_ID} animation={false} className={styles.contextMenu}>
<BondSingleOperations />
<BondBatchOperations />
</Menu>
)
}

export default ContextMenu
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/****************************************************************************
* 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 { CONTEXT_MENU_ID } from './ContextMenu'
import type { ContextMenuItemProps } from './contextMenu.types'

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

/**
* Resolve conflicts with the existing functional-group context menu
* Note: the following scenarios are compatible with the existing logic process,
* but maybe will change after refactoring the functional-group context menu.
*
* Scenario 1
* Given: an expanded functional group
* When: the user right-clicks a bond in the f-group
* Then: only the functional-group context menu shows
*
* Scenario 2
* Given: the user has selected a bunch of things including functional groups
* When: the user right-clicks a selected or non-selected bond
* Then: only the functional-group context menu shows
*
* More details: https://github.com/epam/ketcher/pull/1896
*/
const hasConflictWithFunctionalGroupMenu = useCallback(
(closestItem: any) => {
const editor = getKetcherInstance().editor as Editor
const struct = editor.struct()

const functionalGroupId = FunctionalGroup.findFunctionalGroupByBond(
struct,
struct.functionalGroups,
closestItem.id
)
const hasRelatedSGroup = struct.functionalGroups.some(
(item) => item.relatedSGroupId === functionalGroupId
)

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

const selection = editor.selection()

if (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 true
}
}

return false
},
[getKetcherInstance]
)

const handleDisplay = useCallback<React.MouseEventHandler<HTMLDivElement>>(
(event) => {
const editor = getKetcherInstance().editor as Editor
const closestItem = editor.findItem(event, ['bonds'])

if (!closestItem) {
hideAll()
return
}

if (hasConflictWithFunctionalGroupMenu(closestItem)) {
hideAll()
return
}

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

if (
isRightClickingSelection !== undefined &&
isRightClickingSelection !== -1
) {
// Show menu items for batch updates
show({
event,
props: {
selected: true,
closestItem
}
})
} else if (closestItem.map === 'bonds') {
// Show menu items for single update
if (selection) {
editor.render.ctab.setSelection(null)
}
show({
event,
props: { selected: false, closestItem }
})
}
},
[getKetcherInstance, hasConflictWithFunctionalGroupMenu, 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,6 @@
export type ContextMenuItemProps = {
selected: boolean
closestItem: any
}

export type ContextMenuItemData = unknown
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'
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/****************************************************************************
* 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, 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'
import type {
ContextMenuItemData,
ContextMenuItemProps
} from '../contextMenu.types'
import { formatTitle, getBondNames, noOperation } from './utils'

const bondNames = getBondNames(tools)

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

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

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

selectedBonds &&
editor.update(
fromBondsAttrs(editor.render.ctab, selectedBonds, newBond)
)
} catch (error) {
noOperation()
}
},
[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 handleBatchTypeChange = useCallback(
({ id }: ItemParams<ContextMenuItemProps, ContextMenuItemData>) => {
const editor = getKetcherInstance().editor as Editor
const selectedBonds = editor.selection()?.bonds
const bondProps = tools[id].action.opts

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

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

return (
<>
<Item hidden={isHidden} onClick={handleBatchEdit} {...props}>
Edit selected bond(s)
</Item>

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

<Item hidden={isHidden} onClick={handleBatchDelete} {...props}>
Delete selected bond(s)
</Item>
</>
)
}

export default BondBatchOperations
Loading

0 comments on commit 06be47a

Please sign in to comment.