From 4c5e894505bc3849bf92be238700423cb085c657 Mon Sep 17 00:00:00 2001 From: Daniil Sloboda Date: Tue, 13 Aug 2024 19:00:37 +0400 Subject: [PATCH] #5104 - save\load to ket and tails add\remove --- .../editor/actions/multitailArrow.ts | 20 +++ .../src/application/editor/actions/paste.ts | 14 +- .../editor/operations/OperationType.ts | 2 + .../editor/operations/multitailArrow/index.ts | 1 + .../multitailArrowAddRemoveTail.ts | 48 +++++++ .../src/application/render/pathBuilder.ts | 5 + .../render/restruct/remultitailArrow.ts | 13 +- .../src/domain/constants/multitailArrow.ts | 2 + .../src/domain/entities/multitailArrow.ts | 134 +++++++++++++++++- .../ketcher-core/src/domain/entities/pool.ts | 6 + .../src/domain/entities/struct.ts | 13 ++ .../ket/fromKet/multitailArrowToStruct.ts | 7 + .../src/domain/serializers/ket/helpers.ts | 2 +- .../domain/serializers/ket/ketSerializer.ts | 14 +- .../ket/multitailArrowsValidator.ts | 54 +++++++ .../src/domain/serializers/ket/schema.json | 77 +++++++++- .../ket/toKet/multitailArrowToKet.ts | 10 ++ .../domain/serializers/ket/toKet/prepare.ts | 4 + .../src/domain/serializers/ket/validate.ts | 4 +- .../ketcher-react/src/script/editor/Editor.ts | 6 +- .../components/ContextMenu/ContextMenu.tsx | 10 ++ .../ContextMenu/ContextMenuTrigger.tsx | 13 +- .../ContextMenu/ContextMenuTrigger.utils.ts | 63 ++++---- .../ContextMenu/contextMenu.types.ts | 69 +++++++-- .../hooks/useAddAttachmentPoint.ts | 6 +- .../ContextMenu/hooks/useAtomEdit.ts | 8 +- .../ContextMenu/hooks/useAtomStereo.ts | 8 +- .../ContextMenu/hooks/useBondEdit.ts | 8 +- .../ContextMenu/hooks/useBondSGroupAttach.ts | 8 +- .../ContextMenu/hooks/useBondSGroupEdit.ts | 6 +- .../ContextMenu/hooks/useBondTypeChange.ts | 8 +- .../components/ContextMenu/hooks/useDelete.ts | 9 +- .../hooks/useFunctionalGroupEoc.ts | 28 ++-- .../hooks/useFunctionalGroupRemove.ts | 9 +- .../hooks/useMultitailArrowTails.ts | 71 ++++++++++ .../hooks/useRGroupAttachmentPointEdit.ts | 15 +- .../hooks/useRGroupAttachmentPointRemove.ts | 9 +- .../hooks/useRemoveAttachmentPoint.ts | 6 +- .../ContextMenu/menuItems/AtomMenuItems.tsx | 4 +- .../ContextMenu/menuItems/BondMenuItems.tsx | 8 +- .../menuItems/FunctionalGroupMenuItems.tsx | 9 +- .../menuItems/MultitailArrowMenuItems.tsx | 26 ++++ .../RGroupAttachmentPointMenuItems.tsx | 9 +- .../menuItems/SelectionMenuItems.tsx | 9 +- 44 files changed, 740 insertions(+), 115 deletions(-) create mode 100644 packages/ketcher-core/src/application/editor/operations/multitailArrow/multitailArrowAddRemoveTail.ts create mode 100644 packages/ketcher-core/src/domain/serializers/ket/fromKet/multitailArrowToStruct.ts create mode 100644 packages/ketcher-core/src/domain/serializers/ket/multitailArrowsValidator.ts create mode 100644 packages/ketcher-core/src/domain/serializers/ket/toKet/multitailArrowToKet.ts create mode 100644 packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useMultitailArrowTails.ts create mode 100644 packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/MultitailArrowMenuItems.tsx diff --git a/packages/ketcher-core/src/application/editor/actions/multitailArrow.ts b/packages/ketcher-core/src/application/editor/actions/multitailArrow.ts index 4b312b5deb..06f7608385 100644 --- a/packages/ketcher-core/src/application/editor/actions/multitailArrow.ts +++ b/packages/ketcher-core/src/application/editor/actions/multitailArrow.ts @@ -4,6 +4,8 @@ import { MultitailArrowDelete, MultitailArrowUpsert, MultitailArrowMove, + MultitailArrowAddTail, + MultitailArrowRemoveTail, } from 'application/editor'; import { Vec2, MultitailArrow } from 'domain/entities'; @@ -34,3 +36,21 @@ export function fromMultitailArrowMove( action.addOp(new MultitailArrowMove(id, offset)); return action.perform(reStruct); } + +export function fromMultitailArrowTailAdd(reStruct: ReStruct, id: number) { + const action = new Action(); + + action.addOp(new MultitailArrowAddTail(id)); + return action.perform(reStruct); +} + +export function fromMultitailArrowTailRemove( + reStruct: ReStruct, + id: number, + tailId: number, +) { + const action = new Action(); + + action.addOp(new MultitailArrowRemoveTail(id, tailId)); + return action.perform(reStruct); +} diff --git a/packages/ketcher-core/src/application/editor/actions/paste.ts b/packages/ketcher-core/src/application/editor/actions/paste.ts index 1acd6f9d22..e5192279a9 100644 --- a/packages/ketcher-core/src/application/editor/actions/paste.ts +++ b/packages/ketcher-core/src/application/editor/actions/paste.ts @@ -29,11 +29,12 @@ import { BondAttr, AtomAttr, ImageUpsert, + MultitailArrowUpsert, } from '../operations'; import { fromRGroupAttrs, fromUpdateIfThen } from './rgroup'; import { Action } from './action'; -import { SGroup, Struct, Vec2 } from 'domain/entities'; +import { MultitailArrow, SGroup, Struct, Vec2 } from 'domain/entities'; import { fromSgroupAddition } from './sgroup'; import { fromRGroupAttachmentPointAddition } from './rgroupAttachmentPoint'; import { MonomerMicromolecule } from 'domain/entities/monomerMicromolecule'; @@ -197,6 +198,14 @@ export function fromPaste( action.addOp(new ImageUpsert(clonedImage).perform(restruct)); }); + pstruct.multitailArrows.forEach((multitailArrow: MultitailArrow) => { + const clonedMultitailArrow = multitailArrow.clone(); + clonedMultitailArrow.move(offset); + action.addOp( + new MultitailArrowUpsert(clonedMultitailArrow).perform(restruct), + ); + }); + pstruct.rgroups.forEach((rg, rgid) => { rg.frags.forEach((__frag, frid) => { action.addOp( @@ -248,6 +257,9 @@ function getStructCenter(struct: Struct): Vec2 { if (struct.texts.size > 0) return struct.texts.get(0)!.position; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (struct.images.size > 0) return struct.images.get(0)!.center(); + if (struct.multitailArrows.size > 0) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return struct.multitailArrows.get(0)!.center(); return new Vec2(0, 0); } diff --git a/packages/ketcher-core/src/application/editor/operations/OperationType.ts b/packages/ketcher-core/src/application/editor/operations/OperationType.ts index 79db9612fd..178edb790b 100644 --- a/packages/ketcher-core/src/application/editor/operations/OperationType.ts +++ b/packages/ketcher-core/src/application/editor/operations/OperationType.ts @@ -89,6 +89,8 @@ export const OperationType = Object.freeze({ MULTITAIL_ARROW_UPSERT: 'Upsert multitail arrow', MULTITAIL_ARROW_DELETE: 'Delete multitail arrow', MULTITAIL_ARROW_MOVE: 'Move multitail arrow', + MULTITAIL_ARROW_ADD_TAIL: 'Add multitail arrow tail', + MULTITAIL_ARROW_REMOVE_TAIL: 'Remove multitail arrow tail', }); export enum OperationPriority { diff --git a/packages/ketcher-core/src/application/editor/operations/multitailArrow/index.ts b/packages/ketcher-core/src/application/editor/operations/multitailArrow/index.ts index 90676b70dd..a170970d8d 100644 --- a/packages/ketcher-core/src/application/editor/operations/multitailArrow/index.ts +++ b/packages/ketcher-core/src/application/editor/operations/multitailArrow/index.ts @@ -1,2 +1,3 @@ +export * from './multitailArrowAddRemoveTail'; export * from './multitailArrowMove'; export * from './multitailArrowUpsertDelete'; diff --git a/packages/ketcher-core/src/application/editor/operations/multitailArrow/multitailArrowAddRemoveTail.ts b/packages/ketcher-core/src/application/editor/operations/multitailArrow/multitailArrowAddRemoveTail.ts new file mode 100644 index 0000000000..f3667da22c --- /dev/null +++ b/packages/ketcher-core/src/application/editor/operations/multitailArrow/multitailArrowAddRemoveTail.ts @@ -0,0 +1,48 @@ +/* eslint-disable @typescript-eslint/no-use-before-define,@typescript-eslint/no-non-null-assertion */ +import { BaseOperation } from 'application/editor/operations/base'; +import { OperationType } from 'application/editor'; +import { ReStruct } from 'application/render'; + +export class MultitailArrowAddTail extends BaseOperation { + constructor(private itemId: number, private tailId?: number) { + super(OperationType.MULTITAIL_ARROW_ADD_TAIL); + } + + execute(reStruct: ReStruct) { + const reMultitailArrow = reStruct.multitailArrows.get(this.itemId); + const multitailArrow = reStruct.molecule.multitailArrows.get(this.itemId); + + if (!reMultitailArrow || !multitailArrow) { + return; + } + + this.tailId = multitailArrow.addTail(this.tailId); + BaseOperation.invalidateItem(reStruct, 'multitailArrows', this.itemId, 1); + } + + invert() { + return new MultitailArrowRemoveTail(this.itemId, this.tailId!); + } +} + +export class MultitailArrowRemoveTail extends BaseOperation { + constructor(private itemId: number, private tailId: number) { + super(OperationType.MULTITAIL_ARROW_REMOVE_TAIL); + } + + execute(reStruct: ReStruct) { + const reMultitailArrow = reStruct.multitailArrows.get(this.itemId); + const multitailArrow = reStruct.molecule.multitailArrows.get(this.itemId); + + if (!reMultitailArrow || !multitailArrow) { + return; + } + + multitailArrow.removeTail(this.tailId); + BaseOperation.invalidateItem(reStruct, 'multitailArrows', this.itemId, 1); + } + + invert(): BaseOperation { + return new MultitailArrowAddTail(this.itemId, this.tailId); + } +} diff --git a/packages/ketcher-core/src/application/render/pathBuilder.ts b/packages/ketcher-core/src/application/render/pathBuilder.ts index 942df01814..16cb04c6f1 100644 --- a/packages/ketcher-core/src/application/render/pathBuilder.ts +++ b/packages/ketcher-core/src/application/render/pathBuilder.ts @@ -47,6 +47,11 @@ export class PathBuilder { return this; } + addPathParts(pathParts: Array): PathBuilder { + this.pathParts = this.pathParts.concat(pathParts); + return this; + } + addOpenArrowPathParts( start: Vec2, arrowLength: number, diff --git a/packages/ketcher-core/src/application/render/restruct/remultitailArrow.ts b/packages/ketcher-core/src/application/render/restruct/remultitailArrow.ts index 0b43fc0f41..cb81b1220f 100644 --- a/packages/ketcher-core/src/application/render/restruct/remultitailArrow.ts +++ b/packages/ketcher-core/src/application/render/restruct/remultitailArrow.ts @@ -14,10 +14,21 @@ interface ClosestReferencePosition { } export class ReMultitailArrow extends ReObject { + static TAILS_NAME = 'tails'; + static isSelectable(): boolean { return true; } + static getTailIdFromRef( + ref?: ClosestReferencePosition['ref'], + ): number | null { + if (ref && ref.name.startsWith(ReMultitailArrow.TAILS_NAME)) { + return parseInt(ref.name.replace(`${ReMultitailArrow.TAILS_NAME}-`, '')); + } + return null; + } + constructor(public multitailArrow: MultitailArrow) { super(MULTITAIL_ARROW_KEY); } @@ -111,7 +122,7 @@ export class ReMultitailArrow extends ReObject { ); const { tails, ...rest } = referenceLines; const tailsLines: Array<[string, Line]> = Array.from(tails.entries()).map( - ([key, value]) => [`tails-${key}`, value], + ([key, value]) => [`${ReMultitailArrow.TAILS_NAME}-${key}`, value], ); const lines: Array<[string, Line]> = Object.entries(rest).concat(tailsLines); diff --git a/packages/ketcher-core/src/domain/constants/multitailArrow.ts b/packages/ketcher-core/src/domain/constants/multitailArrow.ts index 4661087a6f..70477a56c8 100644 --- a/packages/ketcher-core/src/domain/constants/multitailArrow.ts +++ b/packages/ketcher-core/src/domain/constants/multitailArrow.ts @@ -1,3 +1,5 @@ export const MULTITAIL_ARROW_KEY = 'multitailArrows'; export const MULTITAIL_ARROW_TOOL_NAME = 'reaction-arrow-multitail'; + +export const MULTITAIL_ARROW_SERIALIZE_KEY = 'multi-tailed-arrow'; diff --git a/packages/ketcher-core/src/domain/entities/multitailArrow.ts b/packages/ketcher-core/src/domain/entities/multitailArrow.ts index a97a5a4210..c3520c0604 100644 --- a/packages/ketcher-core/src/domain/entities/multitailArrow.ts +++ b/packages/ketcher-core/src/domain/entities/multitailArrow.ts @@ -1,6 +1,8 @@ import { BaseMicromoleculeEntity } from 'domain/entities/BaseMicromoleculeEntity'; import { Vec2 } from 'domain/entities/vec2'; import { Pool } from 'domain/entities/pool'; +import { getNodeWithInvertedYCoord, KetFileNode } from 'domain/serializers'; +import { MULTITAIL_ARROW_SERIALIZE_KEY } from 'domain/constants'; export type Line = [Vec2, Vec2]; @@ -21,7 +23,31 @@ export interface MultitailArrowsReferenceLines { tails: Pool; } +export interface KetFileMultitailArrowNode { + head: { + position: Vec2; + }; + spine: { + pos: [Vec2, Vec2]; + }; + tails: { + pos: Array; + }; + zOrder: 0; +} + +interface TailDistance { + distance: number; + center: number; +} + export class MultitailArrow extends BaseMicromoleculeEntity { + static MIN_TAIL_DISTANCE = 0.7; + + static canAddTail(distance: TailDistance['distance']): boolean { + return distance >= MultitailArrow.MIN_TAIL_DISTANCE; + } + static fromTwoPoints(topLeft: Vec2, bottomRight: Vec2) { const center = Vec2.centre(topLeft, bottomRight); const spineX = topLeft.x + (bottomRight.x - topLeft.x) * 0.33; @@ -36,6 +62,31 @@ export class MultitailArrow extends BaseMicromoleculeEntity { ); } + static fromKetNode(ketFileNode: KetFileNode) { + const data = getNodeWithInvertedYCoord( + ketFileNode.data, + ) as KetFileMultitailArrowNode; + const [spineStart, spineEnd] = data.spine.pos; + const spineTop = new Vec2(spineStart); + const headOffset = new Vec2(data.head.position).sub(spineStart); + const tails = data.tails.pos.sort((a, b) => a.y - b.y); + const tailsLength = spineTop.x - tails[0].x; + const tailsYOffset = new Pool(); + tails.slice(1, -1).forEach((tail) => { + tailsYOffset.add(tail.y - spineTop.y); + }); + + const height = spineEnd.y - spineTop.y; + + return new MultitailArrow( + spineTop, + height, + headOffset, + tailsLength, + tailsYOffset, + ); + } + constructor( private spineTop: Vec2, private height: number, @@ -103,6 +154,41 @@ export class MultitailArrow extends BaseMicromoleculeEntity { }; } + getTailsMaxDistance(): TailDistance { + const allTailsOffsets = Array.from(this.tailsYOffset.values()) + .concat([0, this.height]) + .sort((a, b) => a - b); + return allTailsOffsets.reduce( + (acc: TailDistance, item, index, array): TailDistance => { + if (index === 0) { + return acc; + } + const distance = item - array[index - 1]; + return distance > acc.distance + ? { distance, center: item - distance / 2 } + : acc; + }, + { distance: 0, center: 0 }, + ); + } + + addTail(id?: number): number { + const { center, distance } = this.getTailsMaxDistance(); + if (!MultitailArrow.canAddTail(distance)) { + throw new Error('Cannot add tail because no minimal distance found'); + } + if (id) { + this.tailsYOffset.set(id, center); + return id; + } else { + return this.tailsYOffset.add(center); + } + } + + removeTail(id: number) { + this.tailsYOffset.delete(id); + } + center(): Vec2 { return Vec2.centre( new Vec2(this.spineTop.x - this.tailLength, this.spineTop.y), @@ -119,11 +205,57 @@ export class MultitailArrow extends BaseMicromoleculeEntity { this.height, new Vec2(this.headOffset), this.tailLength, - new Pool(this.tailsYOffset), + this.tailsYOffset.clone(), ); } + rescaleSize(scale: number) { + this.spineTop = this.spineTop.scaled(scale); + this.headOffset = this.headOffset.scaled(scale); + this.height = this.height * scale; + this.tailLength = this.tailLength * scale; + this.tailsYOffset.forEach((item, index) => { + this.tailsYOffset.set(index, item * scale); + }); + } + move(offset: Vec2): void { this.spineTop = this.spineTop.add(offset); } + + toKetNode(): KetFileNode { + const head = this.spineTop.add(this.headOffset); + const bottomY = this.spineTop.y + this.height; + const spine: [Vec2, Vec2] = [ + this.spineTop, + new Vec2(this.spineTop.x, bottomY), + ]; + const tailX = this.spineTop.x - this.tailLength; + const nonBorderTails = Array.from(this.tailsYOffset.values()).map( + (yOffset) => this.spineTop.y + yOffset, + ); + const convertTail = (y: number) => new Vec2(tailX, y); + const tails = [this.spineTop.y] + .concat(nonBorderTails) + .concat(bottomY) + .map(convertTail); + + return { + type: MULTITAIL_ARROW_SERIALIZE_KEY, + center: this.center(), + selected: this.getInitiallySelected(), + data: getNodeWithInvertedYCoord({ + head: { + position: head, + }, + spine: { + pos: spine, + }, + tails: { + pos: tails, + }, + zOrder: 0, + }), + }; + } } diff --git a/packages/ketcher-core/src/domain/entities/pool.ts b/packages/ketcher-core/src/domain/entities/pool.ts index b1cc96184e..287e10ce32 100644 --- a/packages/ketcher-core/src/domain/entities/pool.ts +++ b/packages/ketcher-core/src/domain/entities/pool.ts @@ -71,4 +71,10 @@ export class Pool extends Map { } }); } + + clone(): Pool { + const newPool = new Pool(this); + newPool.nextId = this.nextId; + return newPool; + } } diff --git a/packages/ketcher-core/src/domain/entities/struct.ts b/packages/ketcher-core/src/domain/entities/struct.ts index e774428c49..03a22380d0 100644 --- a/packages/ketcher-core/src/domain/entities/struct.ts +++ b/packages/ketcher-core/src/domain/entities/struct.ts @@ -149,6 +149,7 @@ export class Struct { textsSet?: Pile | null, rgroupAttachmentPointSet?: Pile | null, imagesSet?: Pile | null, + multitailArrowsSet?: Pile | null, bidMap?: Map | null, ): Struct { return this.mergeInto( @@ -162,6 +163,7 @@ export class Struct { textsSet, rgroupAttachmentPointSet, imagesSet, + multitailArrowsSet, bidMap, ); } @@ -207,6 +209,7 @@ export class Struct { copyNonFragmentObjects ? undefined : new Pile(), copyNonFragmentObjects ? undefined : new Pile(), copyNonFragmentObjects ? undefined : new Pile(), + copyNonFragmentObjects ? undefined : new Pile(), ); } @@ -221,6 +224,7 @@ export class Struct { textsSet?: Pile | null, rgroupAttachmentPointSet?: Pile | null, imagesSet?: Pile | null, + multitailArrowsSet?: Pile | null, bidMapEntity?: Map | null, ): Struct { atomSet = atomSet || new Pile(this.atoms.keys()); @@ -229,6 +233,8 @@ export class Struct { simpleObjectsSet || new Pile(this.simpleObjects.keys()); textsSet = textsSet || new Pile(this.texts.keys()); imagesSet = imagesSet || new Pile(this.images.keys()); + multitailArrowsSet = + multitailArrowsSet || new Pile(this.multitailArrows.keys()); rgroupAttachmentPointSet = rgroupAttachmentPointSet || new Pile(this.rgroupAttachmentPoints.keys()); @@ -345,6 +351,10 @@ export class Struct { cp.images.add(this.images.get(id)!.clone()); }); + multitailArrowsSet.forEach((id) => { + cp.multitailArrows.add(this.multitailArrows.get(id)!.clone()); + }); + rgroupAttachmentPointSet.forEach((id) => { const rgroupAttachmentPoint = this.rgroupAttachmentPoints.get(id); assert(rgroupAttachmentPoint != null); @@ -844,6 +854,9 @@ export class Struct { }); this.images.forEach((image) => image.rescaleSize(scale)); + this.multitailArrows.forEach((multitailArrow) => + multitailArrow.rescaleSize(scale), + ); } rescale() { diff --git a/packages/ketcher-core/src/domain/serializers/ket/fromKet/multitailArrowToStruct.ts b/packages/ketcher-core/src/domain/serializers/ket/fromKet/multitailArrowToStruct.ts new file mode 100644 index 0000000000..9b48c084ec --- /dev/null +++ b/packages/ketcher-core/src/domain/serializers/ket/fromKet/multitailArrowToStruct.ts @@ -0,0 +1,7 @@ +import { Struct, MultitailArrow } from 'domain/entities'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function multitailArrowToStruct(ketItem: any, struct: Struct) { + struct.multitailArrows.add(MultitailArrow.fromKetNode(ketItem)); + return struct; +} diff --git a/packages/ketcher-core/src/domain/serializers/ket/helpers.ts b/packages/ketcher-core/src/domain/serializers/ket/helpers.ts index 12bc29627c..2545211de3 100644 --- a/packages/ketcher-core/src/domain/serializers/ket/helpers.ts +++ b/packages/ketcher-core/src/domain/serializers/ket/helpers.ts @@ -26,7 +26,7 @@ const customizer = (value: any) => { } }; -export const getNodeWithInvertedYCoord = (node: object) => +export const getNodeWithInvertedYCoord = (node: T): T => cloneDeepWith(node, customizer); export const setMonomerTemplatePrefix = (templateName: string) => diff --git a/packages/ketcher-core/src/domain/serializers/ket/ketSerializer.ts b/packages/ketcher-core/src/domain/serializers/ket/ketSerializer.ts index 5cdb6f9a52..7e86322959 100644 --- a/packages/ketcher-core/src/domain/serializers/ket/ketSerializer.ts +++ b/packages/ketcher-core/src/domain/serializers/ket/ketSerializer.ts @@ -84,7 +84,12 @@ import { MonomerItemType, AmbiguousMonomerType } from 'domain/types'; import { PolymerBond } from 'domain/entities/PolymerBond'; import { imageToKet } from 'domain/serializers/ket/toKet/imageToKet'; import { imageToStruct } from 'domain/serializers/ket/fromKet/imageToStruct'; -import { IMAGE_SERIALIZE_KEY } from 'domain/constants'; +import { + IMAGE_SERIALIZE_KEY, + MULTITAIL_ARROW_SERIALIZE_KEY, +} from 'domain/constants'; +import { multitailArrowToKet } from 'domain/serializers/ket/toKet/multitailArrowToKet'; +import { multitailArrowToStruct } from 'domain/serializers/ket/fromKet/multitailArrowToStruct'; import { AmbiguousMonomer } from 'domain/entities/AmbiguousMonomer'; function parseNode(node: any, struct: any) { @@ -120,6 +125,10 @@ function parseNode(node: any, struct: any) { textToStruct(node, struct); break; } + case MULTITAIL_ARROW_SERIALIZE_KEY: { + multitailArrowToStruct(node, struct); + break; + } case IMAGE_SERIALIZE_KEY: { imageToStruct(node, struct); break; @@ -198,6 +207,9 @@ export class KetSerializer implements Serializer { result.root.nodes.push(imageToKet(item)); break; } + case MULTITAIL_ARROW_SERIALIZE_KEY: + result.root.nodes.push(multitailArrowToKet(item)); + break; default: break; } diff --git a/packages/ketcher-core/src/domain/serializers/ket/multitailArrowsValidator.ts b/packages/ketcher-core/src/domain/serializers/ket/multitailArrowsValidator.ts new file mode 100644 index 0000000000..5f68819d48 --- /dev/null +++ b/packages/ketcher-core/src/domain/serializers/ket/multitailArrowsValidator.ts @@ -0,0 +1,54 @@ +import { KetFileNode } from 'domain/serializers'; +import { KetFileMultitailArrowNode } from 'domain/entities'; +import { MULTITAIL_ARROW_SERIALIZE_KEY } from 'domain/constants'; + +// Y coordinates are inverted during the validation +function validateKetFileMultitailArrowNode({ + head, + spine, + tails, +}: KetFileMultitailArrowNode): boolean { + const [spineStart, spineEnd] = spine.pos; + if (spineStart.x !== spineEnd.x || spineStart.y <= spineEnd.y) { + return false; + } + const headPoint = head.position; + if ( + headPoint.x <= spineStart.x || + headPoint.y <= spineEnd.y || + headPoint.y >= spineStart.y + ) { + return false; + } + const tailsPositions = [...tails.pos].sort((a, b) => b.y - a.y); + + if ( + tailsPositions.at(0)?.y !== spineStart.y || + tailsPositions.at(-1)?.y !== spineEnd.y + ) { + return false; + } + + const firstTailX = tails.pos[0].x; + if (firstTailX >= spineStart.x) { + return false; + } + + return tails.pos.every( + (tail) => + tail.x === firstTailX && tail.y >= spineEnd.y && tail.y <= spineStart.y, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const validateMultitailArrows = (json: any): boolean => { + const nodes: Array> = + json.root.nodes; + return nodes.every((node) => { + return node.type === MULTITAIL_ARROW_SERIALIZE_KEY + ? validateKetFileMultitailArrowNode( + node.data as KetFileMultitailArrowNode, + ) + : true; + }); +}; diff --git a/packages/ketcher-core/src/domain/serializers/ket/schema.json b/packages/ketcher-core/src/domain/serializers/ket/schema.json index 71f646c756..d7ab412247 100644 --- a/packages/ketcher-core/src/domain/serializers/ket/schema.json +++ b/packages/ketcher-core/src/domain/serializers/ket/schema.json @@ -29,6 +29,9 @@ { "$ref": "#/definitions/image" }, + { + "$ref": "#/definitions/multitailArrow" + }, { "type": "object", "required": ["$ref"], @@ -66,6 +69,21 @@ } } }, + "point": { + "type": "object", + "required": ["x", "y"], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + } + }, "rsite": { "type": "object", "required": ["type", "location"], @@ -385,8 +403,12 @@ "items": { "type": "object", "properties": { - "key": { "type": "string" }, - "value": { "type": "string" } + "key": { + "type": "string" + }, + "value": { + "type": "string" + } } } }, @@ -820,6 +842,57 @@ "minLength": 160 } } + }, + "multitailArrow": { + "type": "object", + "required": ["type", "data"], + "properties": { + "type": { + "const": "multi-tailed-arrow" + }, + "data": { + "type": "object", + "required": ["head", "spine", "tails"], + "properties": { + "head": { + "type": "object", + "required": ["position"], + "properties": { + "position": { + "$ref": "#/definitions/point" + } + } + }, + "spine": { + "type": "object", + "required": ["pos"], + "properties": { + "pos": { + "type": "array", + "items": { + "$ref": "#/definitions/point" + }, + "minItems": 2, + "maxItems": 2 + } + } + }, + "tails": { + "type": "object", + "required": ["pos"], + "properties": { + "pos": { + "type": "array", + "items": { + "$ref": "#/definitions/point" + }, + "minItems": 2 + } + } + } + } + } + } } } } diff --git a/packages/ketcher-core/src/domain/serializers/ket/toKet/multitailArrowToKet.ts b/packages/ketcher-core/src/domain/serializers/ket/toKet/multitailArrowToKet.ts new file mode 100644 index 0000000000..8e52d0ef28 --- /dev/null +++ b/packages/ketcher-core/src/domain/serializers/ket/toKet/multitailArrowToKet.ts @@ -0,0 +1,10 @@ +import { KetFileNode } from 'domain/serializers'; +import { MULTITAIL_ARROW_SERIALIZE_KEY } from 'domain/constants'; + +export function multitailArrowToKet(node: KetFileNode) { + return { + type: MULTITAIL_ARROW_SERIALIZE_KEY, + data: node.data, + selected: node.selected, + }; +} diff --git a/packages/ketcher-core/src/domain/serializers/ket/toKet/prepare.ts b/packages/ketcher-core/src/domain/serializers/ket/toKet/prepare.ts index 36f284d748..212f303a9e 100644 --- a/packages/ketcher-core/src/domain/serializers/ket/toKet/prepare.ts +++ b/packages/ketcher-core/src/domain/serializers/ket/toKet/prepare.ts @@ -93,6 +93,10 @@ export function prepareStructForKet(struct: Struct) { ketNodes.push(image.toKetNode()); }); + struct.multitailArrows.forEach((multitailArrow) => { + ketNodes.push(multitailArrow.toKetNode()); + }); + ketNodes.forEach((ketNode) => { if (ketNode.fragment) { const sgroups: SGroup[] = Array.from(ketNode.fragment.sgroups.values()); diff --git a/packages/ketcher-core/src/domain/serializers/ket/validate.ts b/packages/ketcher-core/src/domain/serializers/ket/validate.ts index b99761efdd..f526a4f775 100644 --- a/packages/ketcher-core/src/domain/serializers/ket/validate.ts +++ b/packages/ketcher-core/src/domain/serializers/ket/validate.ts @@ -16,9 +16,11 @@ import Ajv from 'ajv'; import schema from './schema.json'; +import { validateMultitailArrows } from './multitailArrowsValidator'; export function validate(ket: any): boolean { const ajv = new Ajv(); const validate = ajv.compile(schema); - return validate(ket); + const result = validate(ket); + return result ? validateMultitailArrows(ket) : result; } diff --git a/packages/ketcher-react/src/script/editor/Editor.ts b/packages/ketcher-react/src/script/editor/Editor.ts index cad4383165..818d6b2d36 100644 --- a/packages/ketcher-react/src/script/editor/Editor.ts +++ b/packages/ketcher-react/src/script/editor/Editor.ts @@ -43,7 +43,7 @@ import { isEqual } from 'lodash/fp'; import { toolsMap } from './tool'; import { Highlighter } from './highlighter'; import { setFunctionalGroupsTooltip } from './utils/functionalGroupsTooltip'; -import { contextMenuInfo } from '../ui/views/components/ContextMenu/contextMenu.types'; +import { ContextMenuInfo } from '../ui/views/components/ContextMenu/contextMenu.types'; import { HoverIcon } from './HoverIcon'; import RotateController from './tool/rotate-controller'; import { @@ -135,7 +135,7 @@ class Editor implements KetcherEditor { highlights: Highlighter; hoverIcon: HoverIcon; lastCursorPosition: { x: number; y: number }; - contextMenu: contextMenuInfo; + contextMenu: ContextMenuInfo; rotateController: RotateController; event: { message: Subscription; @@ -749,7 +749,7 @@ function updateLastCursorPosition(editor: Editor, event) { } } -function isContextMenuClosed(contextMenu: contextMenuInfo) { +function isContextMenuClosed(contextMenu: ContextMenuInfo) { return !Object.values(contextMenu).some(Boolean); } diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/ContextMenu.tsx b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/ContextMenu.tsx index 8f2c2c7a12..40eddebc6f 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/ContextMenu.tsx +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/ContextMenu.tsx @@ -27,6 +27,7 @@ import SelectionMenuItems from './menuItems/SelectionMenuItems'; import RGroupAttachmentPointMenuItems from './menuItems/RGroupAttachmentPointMenuItems'; import { createPortal } from 'react-dom'; import { KETCHER_ROOT_NODE_CSS_SELECTOR } from 'src/constants'; +import { MultitailArrowMenuItems } from './menuItems/MultitailArrowMenuItems'; const props: Partial = { animation: false, @@ -214,6 +215,15 @@ const ContextMenu: React.FC = () => { > + + trackVisibility(CONTEXT_MENU_ID.FOR_MULTITAIL_ARROW, visible) + } + > + + , ketcherEditorRootElement, ) diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/ContextMenuTrigger.tsx b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/ContextMenuTrigger.tsx index ded8ddb710..f319b61514 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/ContextMenuTrigger.tsx +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/ContextMenuTrigger.tsx @@ -15,14 +15,11 @@ ***************************************************************************/ import { FunctionalGroup } from 'ketcher-core'; -import { PropsWithChildren, useCallback } from 'react'; +import { FC, PropsWithChildren, useCallback } from 'react'; import { useContextMenu } from 'react-contexify'; import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; -import { - ContextMenuShowProps, - ContextMenuTriggerType, -} from './contextMenu.types'; +import { ContextMenuProps, ContextMenuTriggerType } from './contextMenu.types'; import { getMenuPropsForClosestItem, getIsItemInSelection, @@ -30,9 +27,9 @@ import { } from './ContextMenuTrigger.utils'; import TemplateTool from 'src/script/editor/tool/template'; -const ContextMenuTrigger: React.FC = ({ children }) => { +const ContextMenuTrigger: FC = ({ children }) => { const { getKetcherInstance } = useAppContext(); - const { show } = useContextMenu(); + const { show } = useContextMenu(); const getSelectedGroupsInfo = useCallback(() => { const editor = getKetcherInstance().editor as Editor; @@ -87,7 +84,7 @@ const ContextMenuTrigger: React.FC = ({ children }) => { const { selectedFunctionalGroups, selectedSGroupsIds } = getSelectedGroupsInfo(); - let showProps: ContextMenuShowProps = null; + let showProps: ContextMenuProps | null = null; let triggerType: ContextMenuTriggerType; if (!closestItem) { diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/ContextMenuTrigger.utils.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/ContextMenuTrigger.utils.ts index 4c8fda600e..325ed42dab 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/ContextMenuTrigger.utils.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/ContextMenuTrigger.utils.ts @@ -1,9 +1,13 @@ -import { FunctionalGroup, MonomerMicromolecule } from 'ketcher-core'; -import Editor from 'src/script/editor'; import { - ClosestItem, + FunctionalGroup, + MonomerMicromolecule, + MULTITAIL_ARROW_KEY, + ReMultitailArrow, +} from 'ketcher-core'; +import Editor, { ClosestItemWithMap } from 'src/script/editor'; +import { CONTEXT_MENU_ID, - ContextMenuShowProps, + ContextMenuProps, GetIsItemInSelectionArgs, } from './contextMenu.types'; import { Selection } from '../../../../editor/Editor'; @@ -37,8 +41,8 @@ export const getIsItemInSelection = ({ export function getMenuPropsForClosestItem( editor: Editor, - closestItem: ClosestItem, -): ContextMenuShowProps | null { + closestItem: ClosestItemWithMap, +): ContextMenuProps | null { const struct = editor.struct(); switch (closestItem.map) { @@ -108,6 +112,18 @@ export function getMenuPropsForClosestItem( }; } + case MULTITAIL_ARROW_KEY: { + const closestItemTyped = closestItem as unknown as ReturnType< + ReMultitailArrow['calculateDistanceToPoint'] + >; + const tailId = ReMultitailArrow.getTailIdFromRef(closestItemTyped.ref); + return { + id: CONTEXT_MENU_ID.FOR_MULTITAIL_ARROW, + itemId: closestItem.id, + tailId, + }; + } + default: return null; } @@ -118,15 +134,12 @@ const IGNORED_MAPS_LIST = ['enhancedFlags']; export function getMenuPropsForSelection( selection: Selection | null, selectedFunctionalGroups: Map, -): ContextMenuShowProps | null { +): ContextMenuProps | null { if (!selection) { return null; } - const bondsInSelection = 'bonds' in selection; - const atomsInSelection = 'atoms' in selection; - const isRGroupAttachmentPointsSelected = - 'rgroupAttachmentPoints' in selection; + const { bonds, atoms, rgroupAttachmentPoints } = selection; if (selectedFunctionalGroups.size > 0) { const functionalGroups = Array.from(selectedFunctionalGroups.values()); @@ -134,42 +147,30 @@ export function getMenuPropsForSelection( id: CONTEXT_MENU_ID.FOR_FUNCTIONAL_GROUPS, functionalGroups, }; - } else if ( - bondsInSelection && - !atomsInSelection && - !isRGroupAttachmentPointsSelected - ) { + } else if (bonds && !atoms && !rgroupAttachmentPoints) { return { id: CONTEXT_MENU_ID.FOR_BONDS, - bondIds: selection.bonds, + bondIds: bonds, extraItemsSelected: !onlyHasProperty( selection, 'bonds', IGNORED_MAPS_LIST, ), }; - } else if ( - atomsInSelection && - !bondsInSelection && - !isRGroupAttachmentPointsSelected - ) { + } else if (atoms && !bonds && !rgroupAttachmentPoints) { return { id: CONTEXT_MENU_ID.FOR_ATOMS, - atomIds: selection.atoms, + atomIds: atoms, extraItemsSelected: !onlyHasProperty( selection, 'atoms', IGNORED_MAPS_LIST, ), }; - } else if ( - isRGroupAttachmentPointsSelected && - !bondsInSelection && - !atomsInSelection - ) { + } else if (rgroupAttachmentPoints && !bonds && !atoms) { return { id: CONTEXT_MENU_ID.FOR_R_GROUP_ATTACHMENT_POINT, - rgroupAttachmentPoints: selection.rgroupAttachmentPoints, + rgroupAttachmentPoints, extraItemsSelected: !onlyHasProperty( selection, 'rgroupAttachmentPoints', @@ -179,8 +180,8 @@ export function getMenuPropsForSelection( } else { return { id: CONTEXT_MENU_ID.FOR_SELECTION, - bondIds: selection.bonds, - atomIds: selection.atoms, + bondIds: bonds, + atomIds: atoms, }; } } diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/contextMenu.types.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/contextMenu.types.ts index c2131df26d..26d6ab9073 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/contextMenu.types.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/contextMenu.types.ts @@ -8,27 +8,76 @@ export enum CONTEXT_MENU_ID { FOR_SELECTION = 'context-menu-for-selection', FOR_FUNCTIONAL_GROUPS = 'context-menu-for-functional-groups', FOR_R_GROUP_ATTACHMENT_POINT = 'context-menu-for-rgroup-attachment-point', + FOR_MULTITAIL_ARROW = 'context-menu-for-multiple-arrowed', } export type ItemData = unknown; -export type ContextMenuShowProps = { +interface BaseContextMenuProps { id: CONTEXT_MENU_ID; - functionalGroups?: FunctionalGroup[]; - bondIds?: number[]; - atomIds?: number[]; +} + +interface WithExtraItems { extraItemsSelected?: boolean; - rgroupAttachmentPoints?: number[]; -} | null; +} + +export interface BondsContextMenuProps + extends BaseContextMenuProps, + WithExtraItems { + id: CONTEXT_MENU_ID.FOR_BONDS; + bondIds: Array; +} + +export interface AtomContextMenuProps + extends BaseContextMenuProps, + WithExtraItems { + id: CONTEXT_MENU_ID.FOR_ATOMS; + atomIds: Array; +} + +export interface SelectionContextMenuProps + extends BaseContextMenuProps, + Partial>, + Partial> { + id: CONTEXT_MENU_ID.FOR_SELECTION; +} + +export interface FunctionalGroupsContextMenuProps extends BaseContextMenuProps { + id: CONTEXT_MENU_ID.FOR_FUNCTIONAL_GROUPS; + functionalGroups: FunctionalGroup[]; +} + +export interface RGroupAttachmentPointContextMenuProps + extends BaseContextMenuProps, + WithExtraItems { + id: CONTEXT_MENU_ID.FOR_R_GROUP_ATTACHMENT_POINT; + rgroupAttachmentPoints: Array; + atomIds?: AtomContextMenuProps['atomIds']; +} + +export interface MultitailArrowContextMenuProps { + id: CONTEXT_MENU_ID.FOR_MULTITAIL_ARROW; + itemId: number; + tailId: number | null; +} + +export type ContextMenuProps = + | BondsContextMenuProps + | AtomContextMenuProps + | SelectionContextMenuProps + | FunctionalGroupsContextMenuProps + | RGroupAttachmentPointContextMenuProps + | MultitailArrowContextMenuProps; -export interface MenuItemsProps { +export interface MenuItemsProps { triggerEvent?: TriggerEvent; - propsFromTrigger?: ContextMenuShowProps; + propsFromTrigger?: T; } -export type ItemEventParams = PredicateParams; +export type ItemEventParams = + PredicateParams; -export type contextMenuInfo = { +export type ContextMenuInfo = { [id in CONTEXT_MENU_ID]?: boolean; }; diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useAddAttachmentPoint.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useAddAttachmentPoint.ts index 32dd6bde3c..6a725a80bf 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useAddAttachmentPoint.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useAddAttachmentPoint.ts @@ -9,14 +9,16 @@ import { import { useCallback } from 'react'; import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; -import { ItemEventParams } from '../contextMenu.types'; +import { AtomContextMenuProps, ItemEventParams } from '../contextMenu.types'; import { isNumber } from 'lodash'; +type Params = ItemEventParams; + const useAddAttachmentPoint = () => { const { getKetcherInstance } = useAppContext(); const handler = useCallback( - async ({ props }: ItemEventParams) => { + async ({ props }: Params) => { const editor = getKetcherInstance().editor as Editor; const restruct = editor.render.ctab; const struct = editor.struct(); diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useAtomEdit.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useAtomEdit.ts index 761b1a38ad..e3e46db057 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useAtomEdit.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useAtomEdit.ts @@ -3,13 +3,15 @@ import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; import { mapAtomIdsToAtoms } from 'src/script/editor/tool/select'; import { updateSelectedAtoms } from 'src/script/ui/state/modal/atoms'; -import { ItemEventParams } from '../contextMenu.types'; +import { AtomContextMenuProps, ItemEventParams } from '../contextMenu.types'; + +type Params = ItemEventParams; const useAtomEdit = () => { const { getKetcherInstance } = useAppContext(); const handler = useCallback( - async ({ props }: ItemEventParams) => { + async ({ props }: Params) => { const editor = getKetcherInstance().editor as Editor; const molecule = editor.render.ctab; const atomIds = props?.atomIds || []; @@ -26,7 +28,7 @@ const useAtomEdit = () => { [getKetcherInstance], ); - const disabled = useCallback(({ props }: ItemEventParams) => { + const disabled = useCallback(({ props }: Params) => { const atomIds = props?.atomIds; if (Array.isArray(atomIds) && atomIds.length !== 0) { return false; diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useAtomStereo.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useAtomStereo.ts index 9ad9c490ba..a82700432d 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useAtomStereo.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useAtomStereo.ts @@ -3,15 +3,17 @@ import { useCallback, useRef } from 'react'; import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; import EnhancedStereoTool from 'src/script/editor/tool/enhanced-stereo'; -import { ItemEventParams } from '../contextMenu.types'; +import { AtomContextMenuProps, ItemEventParams } from '../contextMenu.types'; import { noOperation } from '../utils'; +type Params = ItemEventParams; + const useAtomStereo = () => { const { getKetcherInstance } = useAppContext(); const stereoAtomIdsRef = useRef(); const handler = useCallback( - async ({ props }: ItemEventParams) => { + async ({ props }: Params) => { if (!props || !stereoAtomIdsRef.current) { return; } @@ -34,7 +36,7 @@ const useAtomStereo = () => { ); const disabled = useCallback( - ({ props }: ItemEventParams) => { + ({ props }: Params) => { const editor = getKetcherInstance().editor as Editor; const stereoAtomIds: number[] = findStereoAtoms( editor.struct(), diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondEdit.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondEdit.ts index 86866bd074..0eeb6bc78f 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondEdit.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondEdit.ts @@ -3,15 +3,17 @@ import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; import { updateSelectedBonds } from 'src/script/ui/state/modal/bonds'; import { mapBondIdsToBonds } from 'src/script/editor/tool/select'; -import { ItemEventParams } from '../contextMenu.types'; +import { BondsContextMenuProps, ItemEventParams } from '../contextMenu.types'; import { noOperation } from '../utils'; import { KetcherLogger } from 'ketcher-core'; +type Params = ItemEventParams; + const useBondEdit = () => { const { getKetcherInstance } = useAppContext(); const handler = useCallback( - async ({ props }: ItemEventParams) => { + async ({ props }: Params) => { const editor = getKetcherInstance().editor as Editor; const bondIds = props?.bondIds || []; const molecule = editor.render.ctab; @@ -27,7 +29,7 @@ const useBondEdit = () => { [getKetcherInstance], ); - const disabled = useCallback(({ props }: ItemEventParams) => { + const disabled = useCallback(({ props }: Params) => { const selectedBondIds = props?.bondIds; if (Array.isArray(selectedBondIds) && selectedBondIds.length !== 0) { return false; diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondSGroupAttach.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondSGroupAttach.ts index ff4388e277..63730480eb 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondSGroupAttach.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondSGroupAttach.ts @@ -3,13 +3,15 @@ import { useCallback } from 'react'; import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; import SGroupTool from 'src/script/editor/tool/sgroup'; -import { ItemEventParams } from '../contextMenu.types'; +import { BondsContextMenuProps, ItemEventParams } from '../contextMenu.types'; + +type Params = ItemEventParams; const useBondSGroupAttach = () => { const { getKetcherInstance } = useAppContext(); const handler = useCallback( - ({ props }: ItemEventParams) => { + ({ props }: Params) => { const editor = getKetcherInstance().editor as Editor; const struct: ReStruct = editor.render.ctab; const bondId = props!.bondIds![0]; @@ -27,7 +29,7 @@ const useBondSGroupAttach = () => { ); const hidden = useCallback( - ({ props }: ItemEventParams) => { + ({ props }: Params) => { const editor = getKetcherInstance().editor as Editor; const struct: ReStruct = editor.render.ctab; const bondIds = props!.bondIds!; diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondSGroupEdit.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondSGroupEdit.ts index 5652aeca16..913b04258a 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondSGroupEdit.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondSGroupEdit.ts @@ -3,7 +3,9 @@ import { useCallback, useRef } from 'react'; import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; import SGroupTool from 'src/script/editor/tool/sgroup'; -import { ItemEventParams } from '../contextMenu.types'; +import { BondsContextMenuProps, ItemEventParams } from '../contextMenu.types'; + +type Params = ItemEventParams; const useBondSGroupEdit = () => { const { getKetcherInstance } = useAppContext(); @@ -17,7 +19,7 @@ const useBondSGroupEdit = () => { // In react-contexify, `disabled` is executed before `hidden` const disabled = useCallback( - ({ props }: ItemEventParams) => { + ({ props }: Params) => { const editor = getKetcherInstance().editor as Editor; const struct: ReStruct = editor.render.ctab; const bondIds = props!.bondIds!; diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondTypeChange.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondTypeChange.ts index 3e66345c3e..da392f17e0 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondTypeChange.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useBondTypeChange.ts @@ -3,12 +3,14 @@ import { useCallback } from 'react'; import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; import tools from 'src/script/ui/action/tools'; -import { ItemEventParams } from '../contextMenu.types'; +import { BondsContextMenuProps, ItemEventParams } from '../contextMenu.types'; + +type Params = ItemEventParams; const useBondTypeChange = () => { const { getKetcherInstance } = useAppContext(); const handler = useCallback( - ({ id, props }: ItemEventParams) => { + ({ id, props }: Params) => { const editor = getKetcherInstance().editor as Editor; const molecule = editor.render.ctab; const bondIds = props?.bondIds || []; @@ -24,7 +26,7 @@ const useBondTypeChange = () => { [getKetcherInstance], ); - const disabled = useCallback(({ props }: ItemEventParams) => { + const disabled = useCallback(({ props }: Params) => { const selectedBondIds = props?.bondIds; const editor = getKetcherInstance().editor; diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useDelete.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useDelete.ts index 3e1fbf8a50..eed76a4879 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useDelete.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useDelete.ts @@ -2,13 +2,18 @@ import { fromFragmentDeletion } from 'ketcher-core'; import { useCallback } from 'react'; import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; -import { ItemEventParams } from '../contextMenu.types'; +import { + ItemEventParams, + SelectionContextMenuProps, +} from '../contextMenu.types'; + +type Params = ItemEventParams; const useDelete = () => { const { getKetcherInstance } = useAppContext(); const handler = useCallback( - async ({ props }: ItemEventParams) => { + async ({ props }: Params) => { const editor = getKetcherInstance().editor as Editor; const molecule = editor.render.ctab; const itemsToDelete = editor.selection() || { diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useFunctionalGroupEoc.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useFunctionalGroupEoc.ts index e506f4975d..69c9d7dfff 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useFunctionalGroupEoc.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useFunctionalGroupEoc.ts @@ -4,7 +4,12 @@ import { useDispatch } from 'react-redux'; import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; import { highlightFG } from 'src/script/ui/state/functionalGroups'; -import { ItemEventParams } from '../contextMenu.types'; +import { + FunctionalGroupsContextMenuProps, + ItemEventParams, +} from '../contextMenu.types'; + +type Params = ItemEventParams; /** * Fullname: useFunctionalGroupExpandOrContract @@ -14,7 +19,7 @@ const useFunctionalGroupEoc = () => { const dispatch = useDispatch(); const handler = useCallback( - ({ props }: ItemEventParams, toExpand: boolean) => { + ({ props }: Params, toExpand: boolean) => { const editor = getKetcherInstance().editor as Editor; const molecule = editor.render.ctab; const selectedFunctionalGroups = props?.functionalGroups; @@ -35,17 +40,14 @@ const useFunctionalGroupEoc = () => { [dispatch, getKetcherInstance], ); - const hidden = useCallback( - ({ props }: ItemEventParams, toExpand: boolean) => { - return Boolean( - props?.functionalGroups?.every((functionalGroup) => - toExpand ? functionalGroup.isExpanded : !functionalGroup.isExpanded, - ), - ); - }, - [], - ); - const disabled = useCallback(({ props }: ItemEventParams) => { + const hidden = useCallback(({ props }: Params, toExpand: boolean) => { + return Boolean( + props?.functionalGroups?.every((functionalGroup) => + toExpand ? functionalGroup.isExpanded : !functionalGroup.isExpanded, + ), + ); + }, []); + const disabled = useCallback(({ props }: Params) => { const editor = getKetcherInstance().editor as Editor; const molecule = editor.render.ctab.molecule; return Boolean( diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useFunctionalGroupRemove.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useFunctionalGroupRemove.ts index f3b12e114a..1209615ccf 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useFunctionalGroupRemove.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useFunctionalGroupRemove.ts @@ -4,14 +4,19 @@ import { useDispatch } from 'react-redux'; import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; import { highlightFG } from 'src/script/ui/state/functionalGroups'; -import { ItemEventParams } from '../contextMenu.types'; +import { + FunctionalGroupsContextMenuProps, + ItemEventParams, +} from '../contextMenu.types'; + +type Params = ItemEventParams; const useFunctionalGroupRemove = () => { const { getKetcherInstance } = useAppContext(); const dispatch = useDispatch(); const handler = useCallback( - ({ props }: ItemEventParams) => { + ({ props }: Params) => { const editor = getKetcherInstance().editor as Editor; const selectedFunctionalGroups = props?.functionalGroups; const action = new Action(); diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useMultitailArrowTails.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useMultitailArrowTails.ts new file mode 100644 index 0000000000..6e6e87867d --- /dev/null +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useMultitailArrowTails.ts @@ -0,0 +1,71 @@ +import { useCallback } from 'react'; +import { + ItemEventParams, + MultitailArrowContextMenuProps, +} from '../contextMenu.types'; +import { useAppContext } from '../../../../../../hooks'; +import { + fromMultitailArrowTailAdd, + fromMultitailArrowTailRemove, + MultitailArrow, +} from 'ketcher-core'; +import Editor from '../../../../../editor'; + +type Params = ItemEventParams; + +export const useMultitailArrowTailsAdd = () => { + const { getKetcherInstance } = useAppContext(); + + const addTail = useCallback( + ({ props }: Params) => { + const editor = getKetcherInstance().editor; + const operation = fromMultitailArrowTailAdd( + editor.render.ctab, + props?.itemId as number, + ); + editor.update(operation); + }, + [getKetcherInstance], + ); + + const isAddTailDisabled = useCallback( + ({ props }: Params) => { + const editor = getKetcherInstance().editor as Editor; + const multitailArrow = editor.render.ctab.molecule.multitailArrows.get( + props?.itemId as number, + ); + return ( + multitailArrow && + !MultitailArrow.canAddTail( + multitailArrow.getTailsMaxDistance().distance, + ) + ); + }, + [getKetcherInstance], + ); + + return { addTail, isAddTailDisabled }; +}; + +export const useMultitailArrowTailsRemove = () => { + const { getKetcherInstance } = useAppContext(); + + const removeTail = useCallback( + ({ props }: Params) => { + const editor = getKetcherInstance().editor; + const operation = fromMultitailArrowTailRemove( + editor.render.ctab, + props?.itemId as number, + props?.tailId as number, + ); + editor.update(operation); + }, + [getKetcherInstance], + ); + + const removeTailHidden = useCallback(({ props }: Params) => { + return props?.tailId === null; + }, []); + + return { removeTail, removeTailHidden }; +}; diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useRGroupAttachmentPointEdit.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useRGroupAttachmentPointEdit.ts index e36e406216..f8e7759a52 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useRGroupAttachmentPointEdit.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useRGroupAttachmentPointEdit.ts @@ -2,12 +2,17 @@ import { useCallback } from 'react'; import assert from 'assert'; import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; -import { ContextMenuShowProps, ItemEventParams } from '../contextMenu.types'; +import { + ItemEventParams, + RGroupAttachmentPointContextMenuProps, +} from '../contextMenu.types'; import { editRGroupAttachmentPoint } from 'src/script/editor/tool/apoint.utils'; import { Ketcher } from 'ketcher-core'; +type Params = ItemEventParams; + const getAtomIdByProps = ( - props: ContextMenuShowProps | undefined, + props: RGroupAttachmentPointContextMenuProps | undefined, ketcher: Ketcher, ): number => { const editor = ketcher.editor as Editor; @@ -29,7 +34,7 @@ const useRGroupAttachmentPointEdit = () => { const { getKetcherInstance } = useAppContext(); const handler = useCallback( - ({ props }: ItemEventParams) => { + ({ props }: Params) => { const ketcher = getKetcherInstance(); const restruct = ketcher.editor.render.ctab; const atomId = getAtomIdByProps(props, ketcher); @@ -42,7 +47,7 @@ const useRGroupAttachmentPointEdit = () => { ); const disabled = useCallback( - ({ props }: ItemEventParams) => { + ({ props }: Params) => { const ketcher = getKetcherInstance(); const restruct = ketcher.editor.render.ctab; const atomId = getAtomIdByProps(props, ketcher); @@ -54,7 +59,7 @@ const useRGroupAttachmentPointEdit = () => { [getKetcherInstance], ); - const hidden = useCallback(({ props }: ItemEventParams) => { + const hidden = useCallback(({ props }: Params) => { const atomLength = props?.atomIds?.length || 0; return atomLength > 1; }, []); diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useRGroupAttachmentPointRemove.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useRGroupAttachmentPointRemove.ts index 0d97649d84..57f69817e3 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useRGroupAttachmentPointRemove.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useRGroupAttachmentPointRemove.ts @@ -2,13 +2,18 @@ import { Action, fromRGroupAttachmentPointDeletion } from 'ketcher-core'; import { useCallback } from 'react'; import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; -import { ItemEventParams } from '../contextMenu.types'; +import { + ItemEventParams, + RGroupAttachmentPointContextMenuProps, +} from '../contextMenu.types'; + +type Params = ItemEventParams; const useDelete = () => { const { getKetcherInstance } = useAppContext(); const handler = useCallback( - async ({ props }: ItemEventParams) => { + async ({ props }: Params) => { const editor = getKetcherInstance().editor as Editor; const restruct = editor.render.ctab; const pointsToDelete = props?.rgroupAttachmentPoints || []; diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useRemoveAttachmentPoint.ts b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useRemoveAttachmentPoint.ts index 1504c2edd9..1e05c99e35 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useRemoveAttachmentPoint.ts +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/hooks/useRemoveAttachmentPoint.ts @@ -2,14 +2,16 @@ import { Atom, fromOneAtomDeletion } from 'ketcher-core'; import { useCallback } from 'react'; import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; -import { ItemEventParams } from '../contextMenu.types'; +import { AtomContextMenuProps, ItemEventParams } from '../contextMenu.types'; import { isNumber } from 'lodash'; +type Params = ItemEventParams; + const useRemoveAttachmentPoint = () => { const { getKetcherInstance } = useAppContext(); const handler = useCallback( - async ({ props }: ItemEventParams) => { + async ({ props }: Params) => { const editor = getKetcherInstance().editor as Editor; const restruct = editor.render.ctab; const struct = editor.struct(); diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/AtomMenuItems.tsx b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/AtomMenuItems.tsx index 8dee9474bf..eb812c78a3 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/AtomMenuItems.tsx +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/AtomMenuItems.tsx @@ -3,7 +3,7 @@ import { Item, Submenu } from 'react-contexify'; import useAtomEdit from '../hooks/useAtomEdit'; import useAtomStereo from '../hooks/useAtomStereo'; import useDelete from '../hooks/useDelete'; -import { MenuItemsProps } from '../contextMenu.types'; +import { AtomContextMenuProps, MenuItemsProps } from '../contextMenu.types'; import { updateSelectedAtoms } from 'src/script/ui/state/modal/atoms'; import { useAppContext } from 'src/hooks'; import Editor from 'src/script/editor'; @@ -95,7 +95,7 @@ const atomPropertiesForSubMenu: { })), ]; -const AtomMenuItems: FC = (props) => { +const AtomMenuItems: FC> = (props) => { const [handleEdit] = useAtomEdit(); const [handleAddAttachmentPoint] = useAddAttachmentPoint(); const [handleRemoveAttachmentPoint] = useRemoveAttachmentPoint(); diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/BondMenuItems.tsx b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/BondMenuItems.tsx index 1a3c05a2c7..9f0261a0b0 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/BondMenuItems.tsx +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/BondMenuItems.tsx @@ -8,12 +8,16 @@ import useBondSGroupEdit from '../hooks/useBondSGroupEdit'; import useBondTypeChange from '../hooks/useBondTypeChange'; import useDelete from '../hooks/useDelete'; import { formatTitle, getNonQueryBondNames, queryBondNames } from '../utils'; -import { ItemEventParams, MenuItemsProps } from '../contextMenu.types'; +import { + BondsContextMenuProps, + ItemEventParams, + MenuItemsProps, +} from '../contextMenu.types'; import { getIconName, Icon } from 'components'; const nonQueryBondNames = getNonQueryBondNames(tools); -const BondMenuItems: FC = (props) => { +const BondMenuItems: FC> = (props) => { const [handleEdit] = useBondEdit(); const [handleTypeChange, disabled] = useBondTypeChange(); const [handleSGroupAttach, sGroupAttachHidden] = useBondSGroupAttach(); diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/FunctionalGroupMenuItems.tsx b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/FunctionalGroupMenuItems.tsx index 540efe9d6e..6655a3745c 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/FunctionalGroupMenuItems.tsx +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/FunctionalGroupMenuItems.tsx @@ -2,9 +2,14 @@ import { FC } from 'react'; import { Item } from 'react-contexify'; import useFunctionalGroupEoc from '../hooks/useFunctionalGroupEoc'; import useFunctionalGroupRemove from '../hooks/useFunctionalGroupRemove'; -import { MenuItemsProps } from '../contextMenu.types'; +import { + FunctionalGroupsContextMenuProps, + MenuItemsProps, +} from '../contextMenu.types'; -const FunctionalGroupMenuItems: FC = (props) => { +const FunctionalGroupMenuItems: FC< + MenuItemsProps +> = (props) => { const [ handleExpandOrContract, ExpandOrContractHidden, diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/MultitailArrowMenuItems.tsx b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/MultitailArrowMenuItems.tsx new file mode 100644 index 0000000000..0de4d7adf0 --- /dev/null +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/MultitailArrowMenuItems.tsx @@ -0,0 +1,26 @@ +import { Item } from 'react-contexify'; +import { + useMultitailArrowTailsAdd, + useMultitailArrowTailsRemove, +} from '../hooks/useMultitailArrowTails'; +import { + MenuItemsProps, + MultitailArrowContextMenuProps, +} from '../contextMenu.types'; + +export function MultitailArrowMenuItems( + props: MenuItemsProps, +) { + const { addTail, isAddTailDisabled } = useMultitailArrowTailsAdd(); + const { removeTail, removeTailHidden } = useMultitailArrowTailsRemove(); + return ( + <> + + + Add new tail + + + ); +} diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/RGroupAttachmentPointMenuItems.tsx b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/RGroupAttachmentPointMenuItems.tsx index 3480da90a4..a12f12e858 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/RGroupAttachmentPointMenuItems.tsx +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/RGroupAttachmentPointMenuItems.tsx @@ -1,10 +1,15 @@ import { FC } from 'react'; import { Item } from 'react-contexify'; -import { MenuItemsProps } from '../contextMenu.types'; +import { + MenuItemsProps, + RGroupAttachmentPointContextMenuProps, +} from '../contextMenu.types'; import useRGroupAttachmentPointRemove from '../hooks/useRGroupAttachmentPointRemove'; import useRGroupAttachmentPointEdit from '../hooks/useRGroupAttachmentPointEdit'; -const RGroupAttachmentPointMenuItems: FC = (props) => { +const RGroupAttachmentPointMenuItems: FC< + MenuItemsProps +> = (props) => { const handleRemove = useRGroupAttachmentPointRemove(); const [ handleEditRGroupAttachmentPoint, diff --git a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/SelectionMenuItems.tsx b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/SelectionMenuItems.tsx index 2d8c5d375c..161fbfd8d1 100644 --- a/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/SelectionMenuItems.tsx +++ b/packages/ketcher-react/src/script/ui/views/components/ContextMenu/menuItems/SelectionMenuItems.tsx @@ -8,12 +8,17 @@ import useBondEdit from '../hooks/useBondEdit'; import useBondTypeChange from '../hooks/useBondTypeChange'; import useDelete from '../hooks/useDelete'; import { formatTitle, getBondNames } from '../utils'; -import { MenuItemsProps } from '../contextMenu.types'; +import { + MenuItemsProps, + SelectionContextMenuProps, +} from '../contextMenu.types'; import { getIconName, Icon } from 'components'; const bondNames = getBondNames(tools); -const SelectionMenuItems: FC = (props) => { +const SelectionMenuItems: FC> = ( + props, +) => { const [handleBondEdit, bondEditDisabled] = useBondEdit(); const [handleAtomEdit, atomEditDisabled] = useAtomEdit(); const [handleTypeChange, bondTypeChangeDisabled] = useBondTypeChange();