From c9bc3401b00d353ba70ee93aa7366ca1a36403cd Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:15:00 +0300 Subject: [PATCH] R: Extract action handlers --- .../apps/rsform/serializers/data_access.py | 3 +- .../oss-page/editor-oss-graph/oss-flow.tsx | 272 +---------------- .../editor-oss-graph/toolbar-oss-graph.tsx | 53 ++-- .../editor-oss-graph/use-handle-actions.ts | 286 ++++++++++++++++++ .../components/toolbar-graph-selection.tsx | 2 +- .../editor-rslist/toolbar-rslist.tsx | 14 +- .../rsform-page/editor-term-graph/tg-flow.tsx | 227 +------------- .../editor-term-graph/toolbar-term-graph.tsx | 94 ++---- .../editor-term-graph/use-handle-actions.ts | 281 +++++++++++++++++ 9 files changed, 636 insertions(+), 596 deletions(-) create mode 100644 rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-handle-actions.ts create mode 100644 rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-term-graph/use-handle-actions.ts diff --git a/rsconcept/backend/apps/rsform/serializers/data_access.py b/rsconcept/backend/apps/rsform/serializers/data_access.py index 9e7e4c30..146eac2a 100644 --- a/rsconcept/backend/apps/rsform/serializers/data_access.py +++ b/rsconcept/backend/apps/rsform/serializers/data_access.py @@ -116,7 +116,8 @@ class CstUpdateSerializer(StrictSerializer): raise serializers.ValidationError({ 'alias': msg.aliasTaken(new_alias) }) - if 'definition_formal' in attrs['item_data'] and cst.definition_formal != attrs['item_data']['definition_formal']: + if 'definition_formal' in attrs['item_data'] \ + and cst.definition_formal != attrs['item_data']['definition_formal']: if Inheritance.objects.filter(child=cst).exists(): raise serializers.ValidationError({ 'definition_formal': msg.changeInheritedDefinition() diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow.tsx index 4cd0a72f..d524dbac 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow.tsx @@ -3,21 +3,14 @@ import { useState } from 'react'; import clsx from 'clsx'; -import { DiagramFlow, useReactFlow, useStoreApi } from '@/components/flow/diagram-flow'; +import { DiagramFlow, useReactFlow } from '@/components/flow/diagram-flow'; import { useMainHeight } from '@/stores/app-layout'; import { useDialogsStore } from '@/stores/dialogs'; import { usePreferencesStore } from '@/stores/preferences'; import { PARAMETER } from '@/utils/constants'; -import { promptText } from '@/utils/labels'; -import { withPreventDefault } from '@/utils/utils'; -import { OperationType } from '../../../backend/types'; -import { useDeleteBlock } from '../../../backend/use-delete-block'; -import { useMutatingOss } from '../../../backend/use-mutating-oss'; -import { useUpdateLayout } from '../../../backend/use-update-layout'; -import { type IOssItem, NodeType } from '../../../models/oss'; import { type OssNode, type Position2D } from '../../../models/oss-layout'; -import { GRID_SIZE, LayoutManager } from '../../../models/oss-layout-api'; +import { GRID_SIZE } from '../../../models/oss-layout-api'; import { useOSSGraphStore } from '../../../stores/oss-graph'; import { useOssEdit } from '../oss-edit-context'; @@ -30,6 +23,7 @@ import { SidePanel } from './side-panel'; import { ToolbarOssGraph } from './toolbar-oss-graph'; import { useDragging } from './use-dragging'; import { useGetLayout } from './use-get-layout'; +import { useHandleActions } from './use-handle-actions'; export const flowOptions = { fitView: true, @@ -46,158 +40,22 @@ export const flowOptions = { export function OssFlow() { const mainHeight = useMainHeight(); - const { - navigateOperationSchema, - schema, - selected, - setSelected, - selectedItems, - isMutable, - deselectAll, - canDeleteOperation - } = useOssEdit(); + const { navigateOperationSchema, schema } = useOssEdit(); const { screenToFlowPosition } = useReactFlow(); - const { containMovement, nodes, onNodesChange, edges, onEdgesChange, resetGraph, resetView } = useOssFlow(); - const store = useStoreApi(); - const { resetSelectedElements } = store.getState(); - - const isProcessing = useMutatingOss(); + const { containMovement, nodes, onNodesChange, edges, onEdgesChange } = useOssFlow(); const showGrid = useOSSGraphStore(state => state.showGrid); const showCoordinates = useOSSGraphStore(state => state.showCoordinates); const showPanel = usePreferencesStore(state => state.showOssSidePanel); const getLayout = useGetLayout(); - const { updateLayout } = useUpdateLayout(); - const { deleteBlock } = useDeleteBlock(); const [mouseCoords, setMouseCoords] = useState({ x: 0, y: 0 }); - const showCreateOperation = useDialogsStore(state => state.showCreateSynthesis); - const showCreateBlock = useDialogsStore(state => state.showCreateBlock); - const showCreateSchema = useDialogsStore(state => state.showCreateSchema); - const showDeleteOperation = useDialogsStore(state => state.showDeleteOperation); - const showDeleteReference = useDialogsStore(state => state.showDeleteReference); const showEditBlock = useDialogsStore(state => state.showEditBlock); - const showImportSchema = useDialogsStore(state => state.showImportSchema); - const { isOpen: isContextMenuOpen, menuProps, openContextMenu, hideContextMenu } = useContextMenu(); const { handleDragStart, handleDrag, handleDragStop } = useDragging({ hideContextMenu }); - - function handleSavePositions() { - void updateLayout({ itemID: schema.id, data: getLayout() }); - } - - function handleCreateSynthesis() { - const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); - showCreateOperation({ - ossID: schema.id, - layout: getLayout(), - defaultX: targetPosition.x, - defaultY: targetPosition.y, - initialInputs: selectedItems.filter(item => item?.nodeType === NodeType.OPERATION).map(item => item.id), - initialParent: extractBlockParent(selectedItems), - onCreate: newID => { - resetView(); - setTimeout(() => setSelected([`o${newID}`]), PARAMETER.minimalTimeout); - } - }); - } - - function handleCreateBlock() { - const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); - const parent = extractBlockParent(selectedItems); - const needChildren = parent === null || selectedItems.length !== 1 || parent !== selectedItems[0].id; - showCreateBlock({ - ossID: schema.id, - layout: getLayout(), - defaultX: targetPosition.x, - defaultY: targetPosition.y, - childrenBlocks: !needChildren - ? [] - : selectedItems.filter(item => item.nodeType === NodeType.BLOCK).map(item => item.id), - childrenOperations: !needChildren - ? [] - : selectedItems.filter(item => item.nodeType === NodeType.OPERATION).map(item => item.id), - initialParent: parent, - onCreate: newID => { - resetView(); - setTimeout(() => setSelected([`b${newID}`]), PARAMETER.minimalTimeout); - } - }); - } - - function handleCreateSchema() { - const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); - showCreateSchema({ - ossID: schema.id, - layout: getLayout(), - defaultX: targetPosition.x, - defaultY: targetPosition.y, - initialParent: extractBlockParent(selectedItems), - onCreate: newID => { - resetView(); - setTimeout(() => setSelected([`o${newID}`]), PARAMETER.minimalTimeout); - } - }); - } - - function handleImportSchema() { - const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); - showImportSchema({ - ossID: schema.id, - layout: getLayout(), - defaultX: targetPosition.x, - defaultY: targetPosition.y, - initialParent: extractBlockParent(selectedItems), - onCreate: newID => { - resetView(); - setTimeout(() => setSelected([`o${newID}`]), PARAMETER.minimalTimeout); - } - }); - } - - function handleDeleteSelected() { - if (selected.length !== 1) { - return; - } - const item = schema.itemByNodeID.get(selected[0]); - if (!item) { - return; - } - if (item.nodeType === NodeType.OPERATION) { - if (!canDeleteOperation(item)) { - return; - } - switch (item.operation_type) { - case OperationType.REPLICA: - showDeleteReference({ - ossID: schema.id, - targetID: item.id, - layout: getLayout(), - beforeDelete: deselectAll - }); - break; - case OperationType.INPUT: - case OperationType.SYNTHESIS: - showDeleteOperation({ - ossID: schema.id, - targetID: item.id, - layout: getLayout(), - beforeDelete: deselectAll - }); - } - } else { - if (!window.confirm(promptText.deleteBlock)) { - return; - } - void deleteBlock({ - itemID: schema.id, - data: { target: item.id, layout: getLayout() }, - beforeUpdate: deselectAll - }); - } - } + const { handleKeyDown } = useHandleActions(); function handleNodeDoubleClick(event: React.MouseEvent, node: OssNode) { event.preventDefault(); @@ -219,109 +77,6 @@ export function OssFlow() { } } - function handleSelectLeft() { - const selectedOperation = selectedItems.find(item => item.nodeType === NodeType.OPERATION); - if (!selectedOperation) { - return; - } - const manager = new LayoutManager(schema, getLayout()); - const newNodeID = manager.selectLeft(selectedOperation.nodeID); - if (newNodeID) { - setSelected([newNodeID]); - } - } - - function handleSelectRight() { - const selectedOperation = selectedItems.find(item => item.nodeType === NodeType.OPERATION); - if (!selectedOperation) { - return; - } - const manager = new LayoutManager(schema, getLayout()); - const newNodeID = manager.selectRight(selectedOperation.nodeID); - if (newNodeID) { - setSelected([newNodeID]); - } - } - function handleSelectUp() { - const selectedOperation = selectedItems.find(item => item.nodeType === NodeType.OPERATION); - if (!selectedOperation) { - return; - } - const manager = new LayoutManager(schema, getLayout()); - const newNodeID = manager.selectUp(selectedOperation.nodeID); - if (newNodeID) { - setSelected([newNodeID]); - } - } - - function handleSelectDown() { - const selectedOperation = selectedItems.find(item => item.nodeType === NodeType.OPERATION); - if (!selectedOperation) { - return; - } - const manager = new LayoutManager(schema, getLayout()); - const newNodeID = manager.selectDown(selectedOperation.nodeID); - if (newNodeID) { - setSelected([newNodeID]); - } - } - - function handleKeyDown(event: React.KeyboardEvent) { - if (isProcessing) { - return; - } - if (event.key === 'Escape') { - withPreventDefault(resetSelectedElements)(event); - return; - } - if (!isMutable) { - return; - } - if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') { - withPreventDefault(handleSavePositions)(event); - return; - } - if (event.altKey) { - if (event.code === 'Digit1') { - withPreventDefault(handleCreateBlock)(event); - return; - } - if (event.code === 'Digit2') { - withPreventDefault(handleCreateSynthesis)(event); - return; - } - if (event.code === 'Digit3') { - withPreventDefault(handleImportSchema)(event); - return; - } - if (event.code === 'Digit4') { - withPreventDefault(handleCreateSynthesis)(event); - return; - } - } - if (event.code === 'Delete') { - withPreventDefault(handleDeleteSelected)(event); - return; - } - - if (event.code === 'ArrowLeft') { - withPreventDefault(handleSelectLeft)(event); - return; - } - if (event.code === 'ArrowRight') { - withPreventDefault(handleSelectRight)(event); - return; - } - if (event.code === 'ArrowUp') { - withPreventDefault(handleSelectUp)(event); - return; - } - if (event.code === 'ArrowDown') { - withPreventDefault(handleSelectDown)(event); - return; - } - } - function handleMouseMove(event: React.MouseEvent) { const targetPosition = screenToFlowPosition({ x: event.clientX, y: event.clientY }); setMouseCoords(targetPosition); @@ -346,12 +101,6 @@ export function OssFlow() { ); } - -// -------- Internals -------- -function extractBlockParent(selectedItems: IOssItem[]): number | null { - if (selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.BLOCK) { - return selectedItems[0].id; - } - const parents = selectedItems.map(item => item.parent).filter(id => id !== null); - return parents.length === 0 ? null : parents[0]; -} diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx index eda6bf82..cba8feeb 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx @@ -26,54 +26,45 @@ import { } from '@/components/icons'; import { type Styling } from '@/components/props'; import { cn } from '@/components/utils'; -import { useDialogsStore } from '@/stores/dialogs'; import { usePreferencesStore } from '@/stores/preferences'; import { isIOS, isMac, notImplemented, prepareTooltip } from '@/utils/utils'; import { useMutatingOss } from '../../../backend/use-mutating-oss'; -import { useUpdateLayout } from '../../../backend/use-update-layout'; import { NodeType } from '../../../models/oss'; import { useOssEdit } from '../oss-edit-context'; import { useOssFlow } from './oss-flow-context'; -import { useGetLayout } from './use-get-layout'; +import { useHandleActions } from './use-handle-actions'; interface ToolbarOssGraphProps extends Styling { - onCreateBlock: () => void; - onCreateSchema: () => void; - onImportSchema: () => void; - onCreateSynthesis: () => void; - onDelete: () => void; - onResetPositions: () => void; - isContextMenuOpen: boolean; openContextMenu: (node: OssNode, clientX: number, clientY: number) => void; hideContextMenu: () => void; } export function ToolbarOssGraph({ - onCreateBlock, - onCreateSchema, - onImportSchema, - onCreateSynthesis, - onDelete, - onResetPositions, - isContextMenuOpen, openContextMenu, hideContextMenu, className, ...restProps }: ToolbarOssGraphProps) { - const { schema, selectedItems, isMutable, canDeleteOperation: canDelete } = useOssEdit(); + const { selectedItems, isMutable, canDeleteOperation: canDelete } = useOssEdit(); const isProcessing = useMutatingOss(); const { resetView, nodes } = useOssFlow(); - const getLayout = useGetLayout(); - const { updateLayout } = useUpdateLayout(); const { user } = useAuthSuspense(); const { elementRef: menuRef, isOpen: isMenuOpen, toggle: toggleMenu, handleBlur: handleMenuBlur } = useDropdown(); + const { + handleSavePositions, + handleCreateSynthesis, + handleCreateBlock, + handleCreateSchema, + handleImportSchema, + handleDeleteSelected, + handleResetPositions, + handleShowOptions + } = useHandleActions(); - const showOptions = useDialogsStore(state => state.showOssOptions); const showSidePanel = usePreferencesStore(state => state.showOssSidePanel); const toggleShowSidePanel = usePreferencesStore(state => state.toggleShowOssSidePanel); @@ -87,14 +78,6 @@ export function ToolbarOssGraph({ toggleMenu(); } - function handleShowOptions() { - showOptions(); - } - - function handleSavePositions() { - void updateLayout({ itemID: schema.id, data: getLayout() }); - } - function handleEditItem(event: React.MouseEvent) { if (isContextMenuOpen) { hideContextMenu(); @@ -123,7 +106,7 @@ export function ToolbarOssGraph({ } - onClick={onResetPositions} + onClick={handleResetPositions} /> } - onClick={onCreateBlock} + onClick={handleCreateBlock} /> } - onClick={onCreateSchema} + onClick={handleCreateSchema} /> } - onClick={onImportSchema} + onClick={handleImportSchema} /> } - onClick={onCreateSynthesis} + onClick={handleCreateSynthesis} /> {user.is_staff ? ( } - onClick={onDelete} + onClick={handleDeleteSelected} disabled={ isProcessing || (!selectedOperation && !selectedBlock) || diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-handle-actions.ts b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-handle-actions.ts new file mode 100644 index 00000000..2682ea39 --- /dev/null +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-handle-actions.ts @@ -0,0 +1,286 @@ +import { useReactFlow, useStoreApi } from 'reactflow'; + +import { useDialogsStore } from '@/stores/dialogs'; +import { PARAMETER } from '@/utils/constants'; +import { promptText } from '@/utils/labels'; +import { withPreventDefault } from '@/utils/utils'; + +import { OperationType } from '../../../backend/types'; +import { useDeleteBlock } from '../../../backend/use-delete-block'; +import { useMutatingOss } from '../../../backend/use-mutating-oss'; +import { useUpdateLayout } from '../../../backend/use-update-layout'; +import { type IOssItem, NodeType } from '../../../models/oss'; +import { LayoutManager } from '../../../models/oss-layout-api'; +import { useOssEdit } from '../oss-edit-context'; + +import { useOssFlow } from './oss-flow-context'; +import { useGetLayout } from './use-get-layout'; + +export function useHandleActions() { + const { screenToFlowPosition } = useReactFlow(); + const { schema, selected, setSelected, selectedItems, isMutable, deselectAll, canDeleteOperation } = useOssEdit(); + const { resetView, resetGraph } = useOssFlow(); + const isProcessing = useMutatingOss(); + const store = useStoreApi(); + const { resetSelectedElements } = store.getState(); + + const getLayout = useGetLayout(); + const { updateLayout } = useUpdateLayout(); + const { deleteBlock } = useDeleteBlock(); + + const showCreateOperation = useDialogsStore(state => state.showCreateSynthesis); + const showCreateBlock = useDialogsStore(state => state.showCreateBlock); + const showCreateSchema = useDialogsStore(state => state.showCreateSchema); + const showDeleteOperation = useDialogsStore(state => state.showDeleteOperation); + const showDeleteReference = useDialogsStore(state => state.showDeleteReference); + const showImportSchema = useDialogsStore(state => state.showImportSchema); + const showOptions = useDialogsStore(state => state.showOssOptions); + + function handleShowOptions() { + showOptions(); + } + + function handleSavePositions() { + void updateLayout({ itemID: schema.id, data: getLayout() }); + } + + function handleCreateSynthesis() { + const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); + showCreateOperation({ + ossID: schema.id, + layout: getLayout(), + defaultX: targetPosition.x, + defaultY: targetPosition.y, + initialInputs: selectedItems.filter(item => item?.nodeType === NodeType.OPERATION).map(item => item.id), + initialParent: extractBlockParent(selectedItems), + onCreate: newID => { + resetView(); + setTimeout(() => setSelected([`o${newID}`]), PARAMETER.minimalTimeout); + } + }); + } + + function handleCreateBlock() { + const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); + const parent = extractBlockParent(selectedItems); + const needChildren = parent === null || selectedItems.length !== 1 || parent !== selectedItems[0].id; + showCreateBlock({ + ossID: schema.id, + layout: getLayout(), + defaultX: targetPosition.x, + defaultY: targetPosition.y, + childrenBlocks: !needChildren + ? [] + : selectedItems.filter(item => item.nodeType === NodeType.BLOCK).map(item => item.id), + childrenOperations: !needChildren + ? [] + : selectedItems.filter(item => item.nodeType === NodeType.OPERATION).map(item => item.id), + initialParent: parent, + onCreate: newID => { + resetView(); + setTimeout(() => setSelected([`b${newID}`]), PARAMETER.minimalTimeout); + } + }); + } + + function handleCreateSchema() { + const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); + showCreateSchema({ + ossID: schema.id, + layout: getLayout(), + defaultX: targetPosition.x, + defaultY: targetPosition.y, + initialParent: extractBlockParent(selectedItems), + onCreate: newID => { + resetView(); + setTimeout(() => setSelected([`o${newID}`]), PARAMETER.minimalTimeout); + } + }); + } + + function handleImportSchema() { + const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); + showImportSchema({ + ossID: schema.id, + layout: getLayout(), + defaultX: targetPosition.x, + defaultY: targetPosition.y, + initialParent: extractBlockParent(selectedItems), + onCreate: newID => { + resetView(); + setTimeout(() => setSelected([`o${newID}`]), PARAMETER.minimalTimeout); + } + }); + } + + function handleDeleteSelected() { + if (selected.length !== 1) { + return; + } + const item = schema.itemByNodeID.get(selected[0]); + if (!item) { + return; + } + if (item.nodeType === NodeType.OPERATION) { + if (!canDeleteOperation(item)) { + return; + } + switch (item.operation_type) { + case OperationType.REPLICA: + showDeleteReference({ + ossID: schema.id, + targetID: item.id, + layout: getLayout(), + beforeDelete: deselectAll + }); + break; + case OperationType.INPUT: + case OperationType.SYNTHESIS: + showDeleteOperation({ + ossID: schema.id, + targetID: item.id, + layout: getLayout(), + beforeDelete: deselectAll + }); + } + } else { + if (!window.confirm(promptText.deleteBlock)) { + return; + } + void deleteBlock({ + itemID: schema.id, + data: { target: item.id, layout: getLayout() }, + beforeUpdate: deselectAll + }); + } + } + + function handleSelectLeft() { + const selectedOperation = selectedItems.find(item => item.nodeType === NodeType.OPERATION); + if (!selectedOperation) { + return; + } + const manager = new LayoutManager(schema, getLayout()); + const newNodeID = manager.selectLeft(selectedOperation.nodeID); + if (newNodeID) { + setSelected([newNodeID]); + } + } + + function handleSelectRight() { + const selectedOperation = selectedItems.find(item => item.nodeType === NodeType.OPERATION); + if (!selectedOperation) { + return; + } + const manager = new LayoutManager(schema, getLayout()); + const newNodeID = manager.selectRight(selectedOperation.nodeID); + if (newNodeID) { + setSelected([newNodeID]); + } + } + function handleSelectUp() { + const selectedOperation = selectedItems.find(item => item.nodeType === NodeType.OPERATION); + if (!selectedOperation) { + return; + } + const manager = new LayoutManager(schema, getLayout()); + const newNodeID = manager.selectUp(selectedOperation.nodeID); + if (newNodeID) { + setSelected([newNodeID]); + } + } + + function handleSelectDown() { + const selectedOperation = selectedItems.find(item => item.nodeType === NodeType.OPERATION); + if (!selectedOperation) { + return; + } + const manager = new LayoutManager(schema, getLayout()); + const newNodeID = manager.selectDown(selectedOperation.nodeID); + if (newNodeID) { + setSelected([newNodeID]); + } + } + + function handleKeyDown(event: React.KeyboardEvent) { + if (isProcessing) { + return; + } + if (event.key === 'Escape') { + withPreventDefault(resetSelectedElements)(event); + return; + } + if (!isMutable) { + return; + } + if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') { + withPreventDefault(handleSavePositions)(event); + return; + } + if (event.altKey) { + if (event.code === 'Digit1') { + withPreventDefault(handleCreateBlock)(event); + return; + } + if (event.code === 'Digit2') { + withPreventDefault(handleCreateSynthesis)(event); + return; + } + if (event.code === 'Digit3') { + withPreventDefault(handleImportSchema)(event); + return; + } + if (event.code === 'Digit4') { + withPreventDefault(handleCreateSynthesis)(event); + return; + } + } + if (event.code === 'Delete') { + withPreventDefault(handleDeleteSelected)(event); + return; + } + + if (event.code === 'ArrowLeft') { + withPreventDefault(handleSelectLeft)(event); + return; + } + if (event.code === 'ArrowRight') { + withPreventDefault(handleSelectRight)(event); + return; + } + if (event.code === 'ArrowUp') { + withPreventDefault(handleSelectUp)(event); + return; + } + if (event.code === 'ArrowDown') { + withPreventDefault(handleSelectDown)(event); + return; + } + } + + return { + handleKeyDown, + + handleSelectLeft, + handleSelectRight, + handleSelectUp, + handleSelectDown, + handleSavePositions, + handleCreateSynthesis, + handleCreateBlock, + handleCreateSchema, + handleImportSchema, + handleDeleteSelected, + handleResetPositions: resetGraph, + handleShowOptions + }; +} + +// -------- Internals -------- +function extractBlockParent(selectedItems: IOssItem[]): number | null { + if (selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.BLOCK) { + return selectedItems[0].id; + } + const parents = selectedItems.map(item => item.parent).filter(id => id !== null); + return parents.length === 0 ? null : parents[0]; +} diff --git a/rsconcept/frontend/src/features/rsform/components/toolbar-graph-selection.tsx b/rsconcept/frontend/src/features/rsform/components/toolbar-graph-selection.tsx index 981980b6..584db350 100644 --- a/rsconcept/frontend/src/features/rsform/components/toolbar-graph-selection.tsx +++ b/rsconcept/frontend/src/features/rsform/components/toolbar-graph-selection.tsx @@ -114,7 +114,7 @@ export function ToolbarGraphSelection({
} onClick={handleSelectReset} disabled={emptySelection} diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rslist/toolbar-rslist.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rslist/toolbar-rslist.tsx index ac51d582..38627383 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rslist/toolbar-rslist.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rslist/toolbar-rslist.tsx @@ -37,7 +37,7 @@ export function ToolbarRSList({ className }: ToolbarRSListProps) { const { elementRef: menuRef, isOpen: isMenuOpen, toggle: toggleMenu, handleBlur: handleMenuBlur } = useDropdown(); const { schema, - selectedCst: selected, + selectedCst, activeCst, navigateOss, deselectAll, @@ -57,7 +57,7 @@ export function ToolbarRSList({ className }: ToolbarRSListProps) { void updateCrucial({ itemID: schema.id, data: { - target: selected, + target: selectedCst, value: !activeCst.crucial } }); @@ -76,28 +76,28 @@ export function ToolbarRSList({ className }: ToolbarRSListProps) { aria-label='Сбросить выделение' icon={} onClick={deselectAll} - disabled={selected.length === 0} + disabled={selectedCst.length === 0} /> } onClick={moveUp} - disabled={isProcessing || selected.length === 0 || selected.length === schema.items.length} + disabled={isProcessing || selectedCst.length === 0 || selectedCst.length === schema.items.length} /> } onClick={moveDown} - disabled={isProcessing || selected.length === 0 || selected.length === schema.items.length} + disabled={isProcessing || selectedCst.length === 0 || selectedCst.length === schema.items.length} /> } onClick={handleToggleCrucial} - disabled={isProcessing || selected.length === 0} + disabled={isProcessing || selectedCst.length === 0} />
} onClick={cloneCst} - disabled={isProcessing || selected.length !== 1} + disabled={isProcessing || selectedCst.length !== 1} /> state.mode); - const toggleMode = useTermGraphStore(state => state.toggleMode); - const toggleEdgeType = useTGConnectionStore(state => state.toggleConnectionType); + const setConnectionStart = useTGConnectionStore(state => state.setStart); const connectionType = useTGConnectionStore(state => state.connectionType); - const toggleText = useTermGraphStore(state => state.toggleText); - const toggleClustering = useTermGraphStore(state => state.toggleClustering); - const toggleHermits = useTermGraphStore(state => state.toggleHermits); - const showEditCst = useDialogsStore(state => state.showEditCst); const { createAttribution } = useCreateAttribution(); const { updateConstituenta } = useUpdateConstituenta(); - const { updateCrucial } = useUpdateCrucial(); const isProcessing = useMutatingRSForm(); const { @@ -83,15 +73,12 @@ export function TGFlow() { schema, selectedCst, setSelectedCst, - promptDeleteSelected, focusCst, setFocus, toggleSelectCst, navigateCst, selectedEdges, - setSelectedEdges, - deselectAll, - createCst + setSelectedEdges } = useRSEdit(); const [nodes, setNodes, onNodesChange] = useNodesState([]); @@ -100,6 +87,7 @@ export function TGFlow() { const filter = useTermGraphStore(state => state.filter); const { filteredGraph, hidden } = useFilteredGraph(); const hiddenHeight = useFitHeight(isSmall ? '15rem + 2px' : '13.5rem + 2px', '4rem'); + const { handleKeyDown } = useHandleActions(filteredGraph); function onSelectionChange({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) { const ids = nodes.map(node => Number(node.id)); @@ -224,10 +212,7 @@ export function TGFlow() { ]); useEffect(() => { - setTimeout( - () => fitView({ ...flowOptions.fitViewOptions, duration: PARAMETER.graphLayoutDuration }), - PARAMETER.refreshTimeout - ); + setTimeout(() => fitView(flowOptions.fitViewOptions), PARAMETER.refreshTimeout); }, [schema.id, filter.noText, filter.graphType, focusCst, fitView]); const prevSelectedNodes = useRef([]); @@ -269,13 +254,6 @@ export function TGFlow() { ); }, [selectedEdges, setEdges]); - function handleDeleteSelected() { - if (isProcessing) { - return; - } - promptDeleteSelected(); - } - function handleNodeContextMenu(event: React.MouseEvent, node: TGNodeData) { event.preventDefault(); event.stopPropagation(); @@ -367,189 +345,6 @@ export function TGFlow() { } } - function handleToggleMode() { - toggleMode(); - deselectAll(); - } - - function handleToggleCrucial() { - if (selectedCst.length === 0) { - return; - } - const isCrucial = !schema.cstByID.get(selectedCst[0])!.crucial; - void updateCrucial({ - itemID: schema.id, - data: { - target: selectedCst, - value: isCrucial - } - }); - } - - function handleCreateCst() { - const definition = selectedCst.map(id => schema.cstByID.get(id)!.alias).join(' '); - createCst(selectedCst.length === 0 ? CstType.BASE : CstType.TERM, false, definition); - } - - function handelFastEdit() { - if (selectedCst.length !== 1) { - return; - } - showEditCst({ schemaID: schema.id, targetID: selectedCst[0] }); - } - - function handleSelectCore() { - const isCore = (cstID: number) => { - const cst = schema.cstByID.get(cstID); - return !!cst && isBasicConcept(cst.cst_type); - }; - const core = [...filteredGraph.nodes.keys()].filter(isCore); - setSelectedCst([...core, ...filteredGraph.expandInputs(core)]); - } - - function handleSelectOwned() { - setSelectedCst([...filteredGraph.nodes.keys()].filter(cstID => !schema.cstByID.get(cstID)?.is_inherited)); - } - - function handleSelectInherited() { - setSelectedCst([...filteredGraph.nodes.keys()].filter(cstID => schema.cstByID.get(cstID)?.is_inherited ?? false)); - } - - function handleSelectCrucial() { - setSelectedCst([...filteredGraph.nodes.keys()].filter(cstID => schema.cstByID.get(cstID)?.crucial ?? false)); - } - - function handleExpandOutputs() { - setSelectedCst(prev => [...prev, ...filteredGraph.expandOutputs(prev)]); - } - - function handleExpandInputs() { - setSelectedCst(prev => [...prev, ...filteredGraph.expandInputs(prev)]); - } - - function handleSelectMaximize() { - setSelectedCst(prev => filteredGraph.maximizePart(prev)); - } - - function handleSelectInvert() { - setSelectedCst(prev => [...filteredGraph.nodes.keys()].filter(item => !prev.includes(item))); - } - - function handleSelectAllInputs() { - setSelectedCst(prev => [...prev, ...filteredGraph.expandAllInputs(prev)]); - } - - function handleSelectAllOutputs() { - setSelectedCst(prev => [...prev, ...filteredGraph.expandAllOutputs(prev)]); - } - - function handleSelectionHotkey(eventCode: string): boolean { - if (eventCode === 'Escape') { - setFocus(null); - return true; - } - if (eventCode === 'Digit1') { - handleExpandInputs(); - return true; - } - if (eventCode === 'Digit2') { - handleExpandOutputs(); - return true; - } - if (eventCode === 'Digit3') { - handleSelectAllInputs(); - return true; - } - if (eventCode === 'Digit4') { - handleSelectAllOutputs(); - return true; - } - if (eventCode === 'Digit5') { - handleSelectMaximize(); - return true; - } - if (eventCode === 'Digit6') { - handleSelectInvert(); - return true; - } - if (eventCode === 'KeyZ') { - handleSelectCore(); - return true; - } - if (eventCode === 'KeyX') { - handleSelectCrucial(); - return true; - } - if (eventCode === 'KeyC') { - handleSelectOwned(); - return true; - } - if (eventCode === 'KeyY') { - handleSelectInherited(); - return true; - } - - return false; - } - - function handleKeyDown(event: React.KeyboardEvent) { - if (isProcessing) { - return; - } - if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) { - return; - } - if (handleSelectionHotkey(event.code)) { - event.preventDefault(); - event.stopPropagation(); - return; - } - - if (event.code === 'KeyG') { - withPreventDefault(() => fitView(flowOptions.fitViewOptions))(event); - return; - } - if (event.code === 'KeyT') { - withPreventDefault(toggleText)(event); - return; - } - if (event.code === 'KeyB') { - withPreventDefault(toggleClustering)(event); - return; - } - if (event.code === 'KeyH') { - withPreventDefault(toggleHermits)(event); - return; - } - - if (isContentEditable) { - if (event.code === 'KeyF') { - withPreventDefault(handleToggleCrucial)(event); - return; - } - if (event.code === 'KeyQ') { - withPreventDefault(handleToggleMode)(event); - return; - } - if (event.code === 'KeyE' && mode === InteractionMode.edit) { - withPreventDefault(toggleEdgeType)(event); - return; - } - if (event.code === 'KeyR') { - withPreventDefault(handleCreateCst)(event); - return; - } - if (event.code === 'KeyV') { - withPreventDefault(handelFastEdit)(event); - return; - } - if (event.code === 'Delete' || event.code === 'Backquote') { - withPreventDefault(handleDeleteSelected)(event); - return; - } - } - } - return (
- +
diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-term-graph/toolbar-term-graph.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-term-graph/toolbar-term-graph.tsx index 1071fb67..cd10f9e6 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-term-graph/toolbar-term-graph.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-term-graph/toolbar-term-graph.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useReactFlow, useStoreApi } from 'reactflow'; +import { useStoreApi } from 'reactflow'; import { HelpTopic } from '@/features/help'; import { BadgeHelp } from '@/features/help/components/badge-help'; @@ -22,11 +22,10 @@ import { IconTypeGraph } from '@/components/icons'; import { cn } from '@/components/utils'; +import { type Graph } from '@/models/graph'; import { useDialogsStore } from '@/stores/dialogs'; -import { PARAMETER } from '@/utils/constants'; import { prepareTooltip } from '@/utils/utils'; -import { CstType } from '../../../backend/types'; import { useMutatingRSForm } from '../../../backend/use-mutating-rsform'; import { IconEdgeType } from '../../../components/icon-edge-type'; import { IconGraphMode } from '../../../components/icon-graph-mode'; @@ -38,75 +37,39 @@ import { isBasicConcept } from '../../../models/rsform-api'; import { InteractionMode, useTermGraphStore, useTGConnectionStore } from '../../../stores/term-graph'; import { useRSEdit } from '../rsedit-context'; -import { fitViewOptions } from './tg-flow'; -import { useFilteredGraph } from './use-filtered-graph'; +import { useHandleActions } from './use-handle-actions'; interface ToolbarTermGraphProps { className?: string; - - onDeleteSelected: () => void; - onToggleCrucial: () => void; + graph: Graph; } -export function ToolbarTermGraph({ className, onDeleteSelected, onToggleCrucial }: ToolbarTermGraphProps) { +export function ToolbarTermGraph({ className, graph }: ToolbarTermGraphProps) { const isProcessing = useMutatingRSForm(); - const { - schema, - selectedCst, - setSelectedCst, - setFocus, - navigateOss, - isContentEditable, - canDeleteSelected, - createCst, - focusCst, - deselectAll - } = useRSEdit(); + const { schema, selectedCst, setSelectedCst, setFocus, navigateOss, isContentEditable, canDeleteSelected, focusCst } = + useRSEdit(); + + const { + handleShowTypeGraph, + handleSetFocus, + handleFitView, + handleToggleMode, + handleToggleCrucial, + handleCreateCst, + handleDeleteSelected, + handleToggleEdgeType, + handleToggleText, + handleToggleClustering + } = useHandleActions(graph); - const showTypeGraph = useDialogsStore(state => state.showShowTypeGraph); const showParams = useDialogsStore(state => state.showGraphParams); const mode = useTermGraphStore(state => state.mode); - const toggleMode = useTermGraphStore(state => state.toggleMode); const edgeType = useTGConnectionStore(state => state.connectionType); - const toggleEdgeType = useTGConnectionStore(state => state.toggleConnectionType); const filter = useTermGraphStore(state => state.filter); - const toggleText = useTermGraphStore(state => state.toggleText); - const toggleClustering = useTermGraphStore(state => state.toggleClustering); - const { filteredGraph } = useFilteredGraph(); - const { fitView } = useReactFlow(); const store = useStoreApi(); const { addSelectedNodes } = store.getState(); - function handleShowTypeGraph() { - const typeInfo = schema.items - .filter(item => !!item.parse) - .map(item => ({ - alias: item.alias, - result: item.parse!.typification, - args: item.parse!.args - })); - showTypeGraph({ items: typeInfo }); - } - - function handleCreateCst() { - const definition = selectedCst.map(id => schema.cstByID.get(id)!.alias).join(' '); - createCst(selectedCst.length === 0 ? CstType.BASE : CstType.TERM, false, definition); - } - - function handleFitView() { - setTimeout(() => { - fitView(fitViewOptions); - }, PARAMETER.minimalTimeout); - } - - function handleSetFocus() { - const target = schema.cstByID.get(selectedCst[0]); - if (target) { - setFocus(target); - } - } - function handleSelectOss(event: React.MouseEvent, newValue: ILibraryItemReference) { navigateOss(newValue.id, event.ctrlKey || event.metaKey); } @@ -116,11 +79,6 @@ export function ToolbarTermGraph({ className, onDeleteSelected, onToggleCrucial addSelectedNodes(newSelection.map(id => String(id))); } - function handleToggleMode() { - toggleMode(); - deselectAll(); - } - return (
) } - onClick={toggleText} + onClick={handleToggleText} /> ) } - onClick={toggleClustering} + onClick={handleToggleClustering} /> @@ -177,7 +135,7 @@ export function ToolbarTermGraph({ className, onDeleteSelected, onToggleCrucial {!focusCst && mode === InteractionMode.explore ? ( { const cst = schema.cstByID.get(cstID); return !!cst && isBasicConcept(cst.cst_type); @@ -193,7 +151,7 @@ export function ToolbarTermGraph({ className, onDeleteSelected, onToggleCrucial titleHtml={prepareTooltip('Ключевая конституента', 'F')} aria-label='Переключатель статуса ключевой конституенты' icon={} - onClick={onToggleCrucial} + onClick={handleToggleCrucial} disabled={isProcessing || selectedCst.length === 0} /> ) : null} @@ -209,7 +167,7 @@ export function ToolbarTermGraph({ className, onDeleteSelected, onToggleCrucial {isContentEditable && mode === InteractionMode.edit ? ( } /> ) : null} @@ -225,7 +183,7 @@ export function ToolbarTermGraph({ className, onDeleteSelected, onToggleCrucial } - onClick={onDeleteSelected} + onClick={handleDeleteSelected} disabled={!canDeleteSelected || isProcessing} /> ) : null} diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-term-graph/use-handle-actions.ts b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-term-graph/use-handle-actions.ts new file mode 100644 index 00000000..5df578c8 --- /dev/null +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-term-graph/use-handle-actions.ts @@ -0,0 +1,281 @@ +import { useReactFlow } from 'reactflow'; + +import { type Graph } from '@/models/graph'; +import { useDialogsStore } from '@/stores/dialogs'; +import { PARAMETER } from '@/utils/constants'; +import { withPreventDefault } from '@/utils/utils'; + +import { CstType } from '../../../backend/types'; +import { useMutatingRSForm } from '../../../backend/use-mutating-rsform'; +import { useUpdateCrucial } from '../../../backend/use-update-crucial'; +import { isBasicConcept } from '../../../models/rsform-api'; +import { InteractionMode, useTermGraphStore, useTGConnectionStore } from '../../../stores/term-graph'; +import { useRSEdit } from '../rsedit-context'; + +/** Options for graph fit view. */ +export const fitViewOptions = { padding: 0.3, duration: PARAMETER.zoomDuration }; + +export function useHandleActions(graph: Graph) { + const isProcessing = useMutatingRSForm(); + const { fitView } = useReactFlow(); + const { + schema, + selectedCst, + isContentEditable, + setSelectedCst, + deselectAll, + createCst, + setFocus, + promptDeleteSelected + } = useRSEdit(); + + const mode = useTermGraphStore(state => state.mode); + + const toggleText = useTermGraphStore(state => state.toggleText); + const toggleClustering = useTermGraphStore(state => state.toggleClustering); + const toggleHermits = useTermGraphStore(state => state.toggleHermits); + const toggleMode = useTermGraphStore(state => state.toggleMode); + const toggleEdgeType = useTGConnectionStore(state => state.toggleConnectionType); + + const { updateCrucial } = useUpdateCrucial(); + const showEditCst = useDialogsStore(state => state.showEditCst); + const showTypeGraph = useDialogsStore(state => state.showShowTypeGraph); + + function handleShowTypeGraph() { + const typeInfo = schema.items + .filter(item => !!item.parse) + .map(item => ({ + alias: item.alias, + result: item.parse!.typification, + args: item.parse!.args + })); + showTypeGraph({ items: typeInfo }); + } + + function handleSetFocus() { + const target = schema.cstByID.get(selectedCst[0]); + if (target) { + setFocus(target); + } + } + + function handleToggleMode() { + toggleMode(); + deselectAll(); + } + + function handleToggleCrucial() { + if (selectedCst.length === 0) { + return; + } + const isCrucial = !schema.cstByID.get(selectedCst[0])!.crucial; + void updateCrucial({ + itemID: schema.id, + data: { + target: selectedCst, + value: isCrucial + } + }); + } + + function handleCreateCst() { + const definition = selectedCst.map(id => schema.cstByID.get(id)!.alias).join(' '); + createCst(selectedCst.length === 0 ? CstType.BASE : CstType.TERM, false, definition); + } + + function handleDeleteSelected() { + if (isProcessing) { + return; + } + promptDeleteSelected(); + } + + function handelFastEdit() { + if (selectedCst.length !== 1) { + return; + } + showEditCst({ schemaID: schema.id, targetID: selectedCst[0] }); + } + + function handleSelectCore() { + const isCore = (cstID: number) => { + const cst = schema.cstByID.get(cstID); + return !!cst && isBasicConcept(cst.cst_type); + }; + const core = [...graph.nodes.keys()].filter(isCore); + setSelectedCst([...core, ...graph.expandInputs(core)]); + } + + function handleSelectOwned() { + setSelectedCst([...graph.nodes.keys()].filter(cstID => !schema.cstByID.get(cstID)?.is_inherited)); + } + + function handleSelectInherited() { + setSelectedCst([...graph.nodes.keys()].filter(cstID => schema.cstByID.get(cstID)?.is_inherited ?? false)); + } + + function handleSelectCrucial() { + setSelectedCst([...graph.nodes.keys()].filter(cstID => schema.cstByID.get(cstID)?.crucial ?? false)); + } + + function handleExpandOutputs() { + setSelectedCst(prev => [...prev, ...graph.expandOutputs(prev)]); + } + + function handleExpandInputs() { + setSelectedCst(prev => [...prev, ...graph.expandInputs(prev)]); + } + + function handleSelectMaximize() { + setSelectedCst(prev => graph.maximizePart(prev)); + } + + function handleSelectInvert() { + setSelectedCst(prev => [...graph.nodes.keys()].filter(item => !prev.includes(item))); + } + + function handleSelectAllInputs() { + setSelectedCst(prev => [...prev, ...graph.expandAllInputs(prev)]); + } + + function handleSelectAllOutputs() { + setSelectedCst(prev => [...prev, ...graph.expandAllOutputs(prev)]); + } + + function handleFitView() { + fitView(fitViewOptions); + } + + function handleSelectionHotkey(eventCode: string): boolean { + if (eventCode === 'Escape') { + setFocus(null); + return true; + } + if (eventCode === 'Digit1') { + handleExpandInputs(); + return true; + } + if (eventCode === 'Digit2') { + handleExpandOutputs(); + return true; + } + if (eventCode === 'Digit3') { + handleSelectAllInputs(); + return true; + } + if (eventCode === 'Digit4') { + handleSelectAllOutputs(); + return true; + } + if (eventCode === 'Digit5') { + handleSelectMaximize(); + return true; + } + if (eventCode === 'Digit6') { + handleSelectInvert(); + return true; + } + if (eventCode === 'KeyZ') { + handleSelectCore(); + return true; + } + if (eventCode === 'KeyX') { + handleSelectCrucial(); + return true; + } + if (eventCode === 'KeyC') { + handleSelectOwned(); + return true; + } + if (eventCode === 'KeyY') { + handleSelectInherited(); + return true; + } + + return false; + } + + function handleKeyDown(event: React.KeyboardEvent) { + if (isProcessing) { + return; + } + if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) { + return; + } + if (handleSelectionHotkey(event.code)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + if (event.code === 'KeyG') { + withPreventDefault(handleFitView)(event); + return; + } + if (event.code === 'KeyT') { + withPreventDefault(toggleText)(event); + return; + } + if (event.code === 'KeyB') { + withPreventDefault(toggleClustering)(event); + return; + } + if (event.code === 'KeyH') { + withPreventDefault(toggleHermits)(event); + return; + } + + if (isContentEditable) { + if (event.code === 'KeyF') { + withPreventDefault(handleToggleCrucial)(event); + return; + } + if (event.code === 'KeyQ') { + withPreventDefault(handleToggleMode)(event); + return; + } + if (event.code === 'KeyE' && mode === InteractionMode.edit) { + withPreventDefault(toggleEdgeType)(event); + return; + } + if (event.code === 'KeyR') { + withPreventDefault(handleCreateCst)(event); + return; + } + if (event.code === 'KeyV') { + withPreventDefault(handelFastEdit)(event); + return; + } + if (event.code === 'Delete' || event.code === 'Backquote') { + withPreventDefault(handleDeleteSelected)(event); + return; + } + } + } + + return { + handleKeyDown, + + handleShowTypeGraph, + handleSetFocus, + handleFitView, + handleExpandInputs, + handleExpandOutputs, + handleSelectAllInputs, + handleSelectAllOutputs, + handleSelectMaximize, + handleSelectInvert, + handleSelectCore, + handleSelectOwned, + handleSelectInherited, + handleSelectCrucial, + handleToggleMode, + handleToggleCrucial, + handleCreateCst, + handleDeleteSelected, + handleToggleEdgeType: toggleEdgeType, + handleToggleText: toggleText, + handleToggleClustering: toggleClustering, + handleToggleHermits: toggleHermits + }; +}