From ece60e565de66cffa9e3f43eb46fc424ba135568 Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Thu, 5 Jun 2025 15:26:06 +0300 Subject: [PATCH] F: Refactor node ID and improve layout for new items --- .../src/features/oss/backend/oss-loader.ts | 28 +- .../src/features/oss/backend/types.ts | 6 + .../features/oss/components/pick-contents.tsx | 51 ++-- .../oss/components/tooltip-oss-item.tsx | 9 +- .../dlg-create-block/dlg-create-block.tsx | 10 +- .../dlg-create-block/tab-block-card.tsx | 10 +- .../dlg-create-block/tab-block-children.tsx | 23 +- .../dlg-create-operation.tsx | 3 +- .../features/oss/dialogs/dlg-edit-block.tsx | 1 + .../dlg-edit-operation/dlg-edit-operation.tsx | 1 + rsconcept/frontend/src/features/oss/labels.ts | 4 +- .../src/features/oss/models/oss-api.ts | 13 +- .../src/features/oss/models/oss-layout-api.ts | 269 +++++++++++++----- .../frontend/src/features/oss/models/oss.ts | 20 +- .../context-menu/context-menu.tsx | 10 +- .../editor-oss-graph/graph/block-node.tsx | 8 +- .../editor-oss-graph/graph/node-core.tsx | 6 +- .../editor-oss-graph/oss-flow-state.tsx | 89 +++--- .../oss-page/editor-oss-graph/oss-flow.tsx | 38 +-- .../editor-oss-graph/toolbar-oss-graph.tsx | 17 +- .../editor-oss-graph/use-dragging.tsx | 59 ++-- .../editor-oss-graph/use-drop-target.tsx | 15 +- .../editor-oss-graph/use-get-layout.tsx | 6 +- .../oss/pages/oss-page/oss-edit-context.tsx | 7 +- .../oss/pages/oss-page/oss-edit-state.tsx | 4 +- rsconcept/frontend/src/models/graph.ts | 100 +++---- 26 files changed, 496 insertions(+), 311 deletions(-) diff --git a/rsconcept/frontend/src/features/oss/backend/oss-loader.ts b/rsconcept/frontend/src/features/oss/backend/oss-loader.ts index 5ef5123f..3260c1a9 100644 --- a/rsconcept/frontend/src/features/oss/backend/oss-loader.ts +++ b/rsconcept/frontend/src/features/oss/backend/oss-loader.ts @@ -7,7 +7,15 @@ import { type ILibraryItem } from '@/features/library'; import { Graph } from '@/models/graph'; import { type RO } from '@/utils/meta'; -import { type IBlock, type IOperation, type IOperationSchema, type IOperationSchemaStats } from '../models/oss'; +import { + type IBlock, + type IOperation, + type IOperationSchema, + type IOperationSchemaStats, + type IOssItem, + NodeType +} from '../models/oss'; +import { constructNodeID } from '../models/oss-api'; import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from '../pages/oss-page/editor-oss-graph/graph/block-node'; import { type IOperationSchemaDTO, OperationType } from './types'; @@ -16,8 +24,9 @@ import { type IOperationSchemaDTO, OperationType } from './types'; export class OssLoader { private oss: IOperationSchema; private graph: Graph = new Graph(); - private hierarchy: Graph = new Graph(); + private hierarchy: Graph = new Graph(); private operationByID = new Map(); + private itemByNodeID = new Map(); private blockByID = new Map(); private schemaIDs: number[] = []; private items: RO; @@ -37,6 +46,7 @@ export class OssLoader { result.operationByID = this.operationByID; result.blockByID = this.blockByID; + result.itemByNodeID = this.itemByNodeID; result.graph = this.graph; result.hierarchy = this.hierarchy; result.schemas = this.schemaIDs; @@ -46,18 +56,24 @@ export class OssLoader { private prepareLookups() { this.oss.operations.forEach(operation => { + operation.nodeID = constructNodeID(NodeType.OPERATION, operation.id); + operation.nodeType = NodeType.OPERATION; + this.itemByNodeID.set(operation.nodeID, operation); this.operationByID.set(operation.id, operation); this.graph.addNode(operation.id); - this.hierarchy.addNode(operation.id); + this.hierarchy.addNode(operation.nodeID); if (operation.parent) { - this.hierarchy.addEdge(-operation.parent, operation.id); + this.hierarchy.addEdge(constructNodeID(NodeType.BLOCK, operation.parent), operation.nodeID); } }); this.oss.blocks.forEach(block => { + block.nodeID = constructNodeID(NodeType.BLOCK, block.id); + block.nodeType = NodeType.BLOCK; + this.itemByNodeID.set(block.nodeID, block); this.blockByID.set(block.id, block); - this.hierarchy.addNode(-block.id); + this.hierarchy.addNode(block.nodeID); if (block.parent) { - this.hierarchy.addEdge(-block.parent, -block.id); + this.hierarchy.addEdge(constructNodeID(NodeType.BLOCK, block.parent), block.nodeID); } }); } diff --git a/rsconcept/frontend/src/features/oss/backend/types.ts b/rsconcept/frontend/src/features/oss/backend/types.ts index eee8d75a..84e78a49 100644 --- a/rsconcept/frontend/src/features/oss/backend/types.ts +++ b/rsconcept/frontend/src/features/oss/backend/types.ts @@ -72,6 +72,12 @@ export type IRelocateConstituentsDTO = z.infer; +/** Represents {@link IOperation} position. */ +export type IOperationPosition = z.infer; + +/** Represents {@link IBlock} position. */ +export type IBlockPosition = z.infer; + // ====== Schemas ====== export const schemaOperationType = z.enum(Object.values(OperationType) as [OperationType, ...OperationType[]]); diff --git a/rsconcept/frontend/src/features/oss/components/pick-contents.tsx b/rsconcept/frontend/src/features/oss/components/pick-contents.tsx index ca4103ba..8136df76 100644 --- a/rsconcept/frontend/src/features/oss/components/pick-contents.tsx +++ b/rsconcept/frontend/src/features/oss/components/pick-contents.tsx @@ -9,24 +9,22 @@ import { ComboBox } from '@/components/input/combo-box'; import { type Styling } from '@/components/props'; import { cn } from '@/components/utils'; import { NoData } from '@/components/view'; -import { type RO } from '@/utils/meta'; import { labelOssItem } from '../labels'; -import { type IOperationSchema, type IOssItem } from '../models/oss'; -import { getItemID, isOperation } from '../models/oss-api'; +import { type IOperationSchema, type IOssItem, NodeType } from '../models/oss'; const SELECTION_CLEAR_TIMEOUT = 1000; -interface PickMultiOperationProps extends Styling { - value: number[]; - onChange: (newValue: number[]) => void; +interface PickContentsProps extends Styling { + value: IOssItem[]; + onChange: (newValue: IOssItem[]) => void; schema: IOperationSchema; rows?: number; - exclude?: number[]; + exclude?: IOssItem[]; disallowBlocks?: boolean; } -const columnHelper = createColumnHelper>(); +const columnHelper = createColumnHelper(); export function PickContents({ rows, @@ -37,29 +35,26 @@ export function PickContents({ onChange, className, ...restProps -}: PickMultiOperationProps) { - const selectedItems = value - .map(itemID => (itemID > 0 ? schema.operationByID.get(itemID) : schema.blockByID.get(-itemID))) - .filter(item => item !== undefined); - const [lastSelected, setLastSelected] = useState | null>(null); - const items = [ - ...(disallowBlocks ? [] : schema.blocks.filter(item => !value.includes(-item.id) && !exclude?.includes(-item.id))), - ...schema.operations.filter(item => !value.includes(item.id) && !exclude?.includes(item.id)) +}: PickContentsProps) { + const [lastSelected, setLastSelected] = useState(null); + const items: IOssItem[] = [ + ...(disallowBlocks ? [] : schema.blocks.filter(item => !value.includes(item) && !exclude?.includes(item))), + ...schema.operations.filter(item => !value.includes(item) && !exclude?.includes(item)) ]; - function handleDelete(target: number) { + function handleDelete(target: IOssItem) { onChange(value.filter(item => item !== target)); } - function handleSelect(target: RO | null) { + function handleSelect(target: IOssItem | null) { if (target) { setLastSelected(target); - onChange([...value, getItemID(target)]); + onChange([...value, target]); setTimeout(() => setLastSelected(null), SELECTION_CLEAR_TIMEOUT); } } - function handleMoveUp(target: number) { + function handleMoveUp(target: IOssItem) { const index = value.indexOf(target); if (index > 0) { const newSelected = [...value]; @@ -69,7 +64,7 @@ export function PickContents({ } } - function handleMoveDown(target: number) { + function handleMoveDown(target: IOssItem) { const index = value.indexOf(target); if (index < value.length - 1) { const newSelected = [...value]; @@ -80,13 +75,13 @@ export function PickContents({ } const columns = [ - columnHelper.accessor(item => isOperation(item), { + columnHelper.accessor(item => item.nodeType === NodeType.OPERATION, { id: 'type', header: 'Тип', size: 150, minSize: 150, maxSize: 150, - cell: props =>
{isOperation(props.row.original) ? 'Операция' : 'Блок'}
+ cell: props =>
{props.getValue() ? 'Операция' : 'Блок'}
}), columnHelper.accessor('title', { id: 'title', @@ -106,21 +101,21 @@ export function PickContents({ noHover className='px-0' icon={} - onClick={() => handleDelete(getItemID(props.row.original))} + onClick={() => handleDelete(props.row.original)} /> } - onClick={() => handleMoveUp(getItemID(props.row.original))} + onClick={() => handleMoveUp(props.row.original)} /> } - onClick={() => handleMoveDown(getItemID(props.row.original))} + onClick={() => handleMoveDown(props.row.original)} /> ) @@ -134,7 +129,7 @@ export function PickContents({ items={items} value={lastSelected} placeholder='Выберите операцию или блок' - idFunc={item => String(getItemID(item))} + idFunc={item => item.nodeID} labelValueFunc={item => labelOssItem(item)} labelOptionFunc={item => labelOssItem(item)} onChange={handleSelect} @@ -145,7 +140,7 @@ export function PickContents({ rows={rows} contentHeight='1.3rem' className='cc-scroll-y text-sm select-none border-y rounded-b-md' - data={selectedItems} + data={value} columns={columns} headPosition='0rem' noDataComponent={ diff --git a/rsconcept/frontend/src/features/oss/components/tooltip-oss-item.tsx b/rsconcept/frontend/src/features/oss/components/tooltip-oss-item.tsx index bed24b3a..3f292e10 100644 --- a/rsconcept/frontend/src/features/oss/components/tooltip-oss-item.tsx +++ b/rsconcept/frontend/src/features/oss/components/tooltip-oss-item.tsx @@ -1,8 +1,7 @@ import { Tooltip } from '@/components/container'; import { globalIDs } from '@/utils/constants'; -import { type IBlock, type IOperation } from '../models/oss'; -import { isOperation } from '../models/oss-api'; +import { NodeType } from '../models/oss'; import { useOperationTooltipStore } from '../stores/operation-tooltip'; import { InfoBlock } from './info-block'; @@ -10,7 +9,7 @@ import { InfoOperation } from './info-operation'; export function OperationTooltip() { const hoverItem = useOperationTooltipStore(state => state.hoverItem); - const isOperationNode = isOperation(hoverItem); + const isOperationNode = hoverItem?.nodeType === NodeType.OPERATION; return ( ); } diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/dlg-create-block.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/dlg-create-block.tsx index 7ac0aa39..f8701e40 100644 --- a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/dlg-create-block.tsx +++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/dlg-create-block.tsx @@ -12,6 +12,7 @@ import { useDialogsStore } from '@/stores/dialogs'; import { type ICreateBlockDTO, schemaCreateBlock } from '../../backend/types'; import { useCreateBlock } from '../../backend/use-create-block'; +import { type IOssItem, NodeType } from '../../models/oss'; import { type LayoutManager } from '../../models/oss-layout-api'; import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from '../../pages/oss-page/editor-oss-graph/graph/block-node'; @@ -20,7 +21,7 @@ import { TabBlockChildren } from './tab-block-children'; export interface DlgCreateBlockProps { manager: LayoutManager; - initialChildren: number[]; + initialChildren: IOssItem[]; initialParent: number | null; defaultX: number; defaultY: number; @@ -52,8 +53,8 @@ export function DlgCreateBlock() { position_y: defaultY, width: BLOCK_NODE_MIN_WIDTH, height: BLOCK_NODE_MIN_HEIGHT, - children_blocks: initialChildren.filter(id => id < 0).map(id => -id), - children_operations: initialChildren.filter(id => id > 0), + children_blocks: initialChildren.filter(item => item.nodeType === NodeType.BLOCK).map(item => item.id), + children_operations: initialChildren.filter(item => item.nodeType === NodeType.OPERATION).map(item => item.id), layout: manager.layout }, mode: 'onChange' @@ -65,11 +66,12 @@ export function DlgCreateBlock() { const isValid = !!title && !manager.oss.blocks.some(block => block.title === title); function onSubmit(data: ICreateBlockDTO) { - const rectangle = manager.calculateNewBlockPosition(data); + const rectangle = manager.newBlockPosition(data); data.position_x = rectangle.x; data.position_y = rectangle.y; data.width = rectangle.width; data.height = rectangle.height; + data.layout = manager.layout; void createBlock({ itemID: manager.oss.id, data: data }).then(response => onCreate?.(response.new_block.id)); } diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-card.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-card.tsx index b2139c7b..d7010329 100644 --- a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-card.tsx +++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-card.tsx @@ -7,6 +7,8 @@ import { useDialogsStore } from '@/stores/dialogs'; import { type ICreateBlockDTO } from '../../backend/types'; import { SelectParent } from '../../components/select-parent'; +import { NodeType } from '../../models/oss'; +import { constructNodeID } from '../../models/oss-api'; import { type DlgCreateBlockProps } from './dlg-create-block'; @@ -18,10 +20,8 @@ export function TabBlockCard() { formState: { errors } } = useFormContext(); const children_blocks = useWatch({ control, name: 'children_blocks' }); - const all_children = [ - ...children_blocks, - ...manager.oss.hierarchy.expandAllOutputs(children_blocks.filter(id => id < 0).map(id => -id)).map(id => -id) - ]; + const block_ids = children_blocks.map(id => constructNodeID(NodeType.BLOCK, id)); + const all_children = [...block_ids, ...manager.oss.hierarchy.expandAllOutputs(block_ids)]; return (
@@ -36,7 +36,7 @@ export function TabBlockCard() { control={control} render={({ field }) => ( !all_children.includes(block.id))} + items={manager.oss.blocks.filter(block => !all_children.includes(block.nodeID))} value={field.value ? manager.oss.blockByID.get(field.value) ?? null : null} placeholder='Блок содержания не выбран' onChange={value => field.onChange(value ? value.id : null)} diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-children.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-children.tsx index 4f7d6146..0ebf8a41 100644 --- a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-children.tsx +++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-block/tab-block-children.tsx @@ -6,6 +6,7 @@ import { useDialogsStore } from '@/stores/dialogs'; import { type ICreateBlockDTO } from '../../backend/types'; import { PickContents } from '../../components/pick-contents'; +import { type IOssItem, NodeType } from '../../models/oss'; import { type DlgCreateBlockProps } from './dlg-create-block'; @@ -15,19 +16,31 @@ export function TabBlockChildren() { const parent = useWatch({ control, name: 'item_data.parent' }); const children_blocks = useWatch({ control, name: 'children_blocks' }); const children_operations = useWatch({ control, name: 'children_operations' }); - const exclude = parent ? [-parent, ...manager.oss.hierarchy.expandAllInputs([-parent]).filter(id => id < 0)] : []; - const value = [...children_blocks.map(id => -id), ...children_operations]; + const parentItem = parent ? manager.oss.blockByID.get(parent) : null; + const internalBlocks = parentItem + ? manager.oss.hierarchy + .expandAllInputs([parentItem.nodeID]) + .map(id => manager.oss.itemByNodeID.get(id)) + .filter(item => item !== null && item?.nodeType === NodeType.BLOCK) + : []; - function handleChangeSelected(newValue: number[]) { + const exclude = parentItem ? [parentItem, ...internalBlocks] : []; + + const value = [ + ...children_blocks.map(id => manager.oss.blockByID.get(id)!), + ...children_operations.map(id => manager.oss.operationByID.get(id)!) + ]; + + function handleChangeSelected(newValue: IOssItem[]) { setValue( 'children_blocks', - newValue.filter(id => id < 0).map(id => -id), + newValue.filter(item => item.nodeType === NodeType.BLOCK).map(item => item.id), { shouldValidate: true } ); setValue( 'children_operations', - newValue.filter(id => id > 0), + newValue.filter(item => item.nodeType === NodeType.OPERATION).map(item => item.id), { shouldValidate: true } ); } diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-operation/dlg-create-operation.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-operation/dlg-create-operation.tsx index 73d68687..561f8d6e 100644 --- a/rsconcept/frontend/src/features/oss/dialogs/dlg-create-operation/dlg-create-operation.tsx +++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-create-operation/dlg-create-operation.tsx @@ -64,9 +64,10 @@ export function DlgCreateOperation() { const isValid = !!alias && !manager.oss.operations.some(operation => operation.alias === alias); function onSubmit(data: ICreateOperationDTO) { - const target = manager.calculateNewOperationPosition(data); + const target = manager.newOperationPosition(data); data.position_x = target.x; data.position_y = target.y; + data.layout = manager.layout; void createOperation({ itemID: manager.oss.id, data: data }).then(response => onCreate?.(response.new_operation.id) ); diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-block.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-block.tsx index 3f947f47..04192dc4 100644 --- a/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-block.tsx +++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-block.tsx @@ -44,6 +44,7 @@ export function DlgEditBlock() { function onSubmit(data: IUpdateBlockDTO) { if (data.item_data.parent !== target.parent) { manager.onBlockChangeParent(data.target, data.item_data.parent); + data.layout = manager.layout; } return updateBlock({ itemID: manager.oss.id, data }); } diff --git a/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-operation/dlg-edit-operation.tsx b/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-operation/dlg-edit-operation.tsx index 9c8c5383..2104a12a 100644 --- a/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-operation/dlg-edit-operation.tsx +++ b/rsconcept/frontend/src/features/oss/dialogs/dlg-edit-operation/dlg-edit-operation.tsx @@ -60,6 +60,7 @@ export function DlgEditOperation() { function onSubmit(data: IUpdateOperationDTO) { if (data.item_data.parent !== target.parent) { manager.onOperationChangeParent(data.target, data.item_data.parent); + data.layout = manager.layout; } return updateOperation({ itemID: manager.oss.id, data }); } diff --git a/rsconcept/frontend/src/features/oss/labels.ts b/rsconcept/frontend/src/features/oss/labels.ts index 47ac360c..64be1c19 100644 --- a/rsconcept/frontend/src/features/oss/labels.ts +++ b/rsconcept/frontend/src/features/oss/labels.ts @@ -5,9 +5,9 @@ import { type IOperation, type IOssItem, type ISubstitutionErrorDescription, + NodeType, SubstitutionErrorType } from './models/oss'; -import { isOperation } from './models/oss-api'; /** Retrieves label for {@link OperationType}. */ export function labelOperationType(itemType: OperationType): string { @@ -58,7 +58,7 @@ export function describeSubstitutionError(error: RO): string { - if (isOperation(item)) { + if (item.nodeType === NodeType.OPERATION) { return `${(item as IOperation).alias}: ${item.title}`; } else { return `Блок: ${item.title}`; diff --git a/rsconcept/frontend/src/features/oss/models/oss-api.ts b/rsconcept/frontend/src/features/oss/models/oss-api.ts index 80ac64bb..9a267b14 100644 --- a/rsconcept/frontend/src/features/oss/models/oss-api.ts +++ b/rsconcept/frontend/src/features/oss/models/oss-api.ts @@ -20,23 +20,16 @@ import { } from '@/features/rsform/models/rslang-api'; import { infoMsg } from '@/utils/labels'; -import { type RO } from '@/utils/meta'; import { Graph } from '../../../models/graph'; import { describeSubstitutionError } from '../labels'; -import { type IOperationSchema, type IOssItem, SubstitutionErrorType } from './oss'; +import { type IOperationSchema, NodeType, SubstitutionErrorType } from './oss'; const STARTING_SUB_INDEX = 900; // max semantic index for starting substitution -/** Checks if element is {@link IOperation} or {@link IBlock}. */ -export function isOperation(item: RO | null): boolean { - return !!item && 'arguments' in item; -} - -/** Extract contiguous ID of {@link IOperation} or {@link IBlock}. */ -export function getItemID(item: RO): number { - return isOperation(item) ? item.id : -item.id; +export function constructNodeID(type: NodeType, itemID: number): string { + return type === NodeType.OPERATION ? 'o' + String(itemID) : 'b' + String(itemID); } /** Sorts library items relevant for the specified {@link IOperationSchema}. */ diff --git a/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts b/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts index cb9a965d..860e8142 100644 --- a/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts +++ b/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts @@ -1,4 +1,10 @@ -import { type ICreateBlockDTO, type ICreateOperationDTO, type IOssLayout } from '../backend/types'; +import { + type IBlockPosition, + type ICreateBlockDTO, + type ICreateOperationDTO, + type IOperationPosition, + type IOssLayout +} from '../backend/types'; import { type IOperationSchema } from './oss'; import { type Position2D, type Rectangle2D } from './oss-layout'; @@ -26,94 +32,105 @@ export class LayoutManager { } /** Calculate insert position for a new {@link IOperation} */ - calculateNewOperationPosition(data: ICreateOperationDTO): Position2D { - // TODO: check parent node - - const result = { x: data.position_x, y: data.position_y }; + newOperationPosition(data: ICreateOperationDTO): Position2D { + let result = { x: data.position_x, y: data.position_y }; const operations = this.layout.operations; + const parentNode = this.layout.blocks.find(pos => pos.id === data.item_data.parent); if (operations.length === 0) { return result; } - if (data.arguments.length === 0) { - let inputsPositions = operations.filter(pos => - this.oss.operations.find(operation => operation.arguments.length === 0 && operation.id === pos.id) - ); - if (inputsPositions.length === 0) { - inputsPositions = operations; - } - const maxX = Math.max(...inputsPositions.map(node => node.x)); - const minY = Math.min(...inputsPositions.map(node => node.y)); - result.x = maxX + DISTANCE_X; - result.y = minY; + if (data.arguments.length !== 0) { + result = calculatePositionFromArgs(data.arguments, operations); + } else if (parentNode) { + result.x = parentNode.x + MIN_DISTANCE; + result.y = parentNode.y + MIN_DISTANCE; } else { - const argNodes = operations.filter(pos => data.arguments.includes(pos.id)); - const maxY = Math.max(...argNodes.map(node => node.y)); - const minX = Math.min(...argNodes.map(node => node.x)); - const maxX = Math.max(...argNodes.map(node => node.x)); - result.x = Math.ceil((maxX + minX) / 2 / GRID_SIZE) * GRID_SIZE; - result.y = maxY + DISTANCE_Y; + result = this.calculatePositionForFreeOperation(result); } - let flagIntersect = false; - do { - flagIntersect = operations.some( - position => Math.abs(position.x - result.x) < MIN_DISTANCE && Math.abs(position.y - result.y) < MIN_DISTANCE - ); - if (flagIntersect) { - result.x += MIN_DISTANCE; - result.y += MIN_DISTANCE; + result = preventOverlap( + { ...result, width: OPERATION_NODE_WIDTH, height: OPERATION_NODE_HEIGHT }, + operations.map(node => ({ ...node, width: OPERATION_NODE_WIDTH, height: OPERATION_NODE_HEIGHT })) + ); + + if (parentNode) { + const borderX = result.x + OPERATION_NODE_WIDTH + MIN_DISTANCE; + const borderY = result.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE; + if (borderX > parentNode.x + parentNode.width) { + parentNode.width = borderX - parentNode.x; } - } while (flagIntersect); - return result; + if (borderY > parentNode.y + parentNode.height) { + parentNode.height = borderY - parentNode.y; + } + // TODO: trigger cascading updates + } + + return { x: result.x, y: result.y }; } /** Calculate insert position for a new {@link IBlock} */ - calculateNewBlockPosition(data: ICreateBlockDTO): Rectangle2D { + newBlockPosition(data: ICreateBlockDTO): Rectangle2D { const block_nodes = data.children_blocks .map(id => this.layout.blocks.find(block => block.id === id)) .filter(node => !!node); const operation_nodes = data.children_operations .map(id => this.layout.operations.find(operation => operation.id === id)) .filter(node => !!node); + const parentNode = this.layout.blocks.find(pos => pos.id === data.item_data.parent); + + let result: Rectangle2D = { x: data.position_x, y: data.position_y, width: data.width, height: data.height }; + + if (block_nodes.length !== 0 || operation_nodes.length !== 0) { + result = calculatePositionFromChildren( + { x: data.position_x, y: data.position_y, width: data.width, height: data.height }, + operation_nodes, + block_nodes + ); + } else if (parentNode) { + result = { + x: parentNode.x + MIN_DISTANCE, + y: parentNode.y + MIN_DISTANCE, + width: data.width, + height: data.height + }; + } else { + result = this.calculatePositionForFreeBlock(result); + } if (block_nodes.length === 0 && operation_nodes.length === 0) { - return { x: data.position_x, y: data.position_y, width: data.width, height: data.height }; + if (parentNode) { + const siblings = this.oss.blocks.filter(block => block.parent === parentNode.id).map(block => block.id); + if (siblings.length > 0) { + result = preventOverlap( + result, + this.layout.blocks.filter(block => siblings.includes(block.id)) + ); + } + } else { + const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.id); + if (rootBlocks.length > 0) { + result = preventOverlap( + result, + this.layout.blocks.filter(block => rootBlocks.includes(block.id)) + ); + } + } } - let left = undefined; - let top = undefined; - let right = undefined; - let bottom = undefined; - - for (const block of block_nodes) { - left = !left ? block.x - MIN_DISTANCE : Math.min(left, block.x - MIN_DISTANCE); - top = !top ? block.y - MIN_DISTANCE : Math.min(top, block.y - MIN_DISTANCE); - right = !right - ? Math.max(left + data.width, block.x + block.width + MIN_DISTANCE) - : Math.max(right, block.x + block.width + MIN_DISTANCE); - bottom = !bottom - ? Math.max(top + data.height, block.y + block.height + MIN_DISTANCE) - : Math.max(bottom, block.y + block.height + MIN_DISTANCE); + if (parentNode) { + const borderX = result.x + result.width + MIN_DISTANCE; + const borderY = result.y + result.height + MIN_DISTANCE; + if (borderX > parentNode.x + parentNode.width) { + parentNode.width = borderX - parentNode.x; + } + if (borderY > parentNode.y + parentNode.height) { + parentNode.height = borderY - parentNode.y; + } + // TODO: trigger cascading updates } - for (const operation of operation_nodes) { - left = !left ? operation.x - MIN_DISTANCE : Math.min(left, operation.x - MIN_DISTANCE); - top = !top ? operation.y - MIN_DISTANCE : Math.min(top, operation.y - MIN_DISTANCE); - right = !right - ? Math.max(left + data.width, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE) - : Math.max(right, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE); - bottom = !bottom - ? Math.max(top + data.height, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE) - : Math.max(bottom, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE); - } - - return { - x: left ?? data.position_x, - y: top ?? data.position_y, - width: right && left ? right - left : data.width, - height: bottom && top ? bottom - top : data.height - }; + return result; } /** Update layout when parent changes */ @@ -125,4 +142,126 @@ export class LayoutManager { onBlockChangeParent(targetID: number, newParent: number | null) { console.error('not implemented', targetID, newParent); } + + private calculatePositionForFreeOperation(initial: Position2D): Position2D { + const operations = this.layout.operations; + if (operations.length === 0) { + return initial; + } + + const freeInputs = this.oss.operations + .filter(operation => operation.arguments.length === 0 && operation.parent === null) + .map(operation => operation.id); + let inputsPositions = operations.filter(pos => freeInputs.includes(pos.id)); + if (inputsPositions.length === 0) { + inputsPositions = operations; + } + const maxX = Math.max(...inputsPositions.map(node => node.x)); + const minY = Math.min(...inputsPositions.map(node => node.y)); + return { + x: maxX + DISTANCE_X, + y: minY + }; + } + + private calculatePositionForFreeBlock(initial: Rectangle2D): Rectangle2D { + const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.id); + const blocksPositions = this.layout.blocks.filter(pos => rootBlocks.includes(pos.id)); + if (blocksPositions.length === 0) { + return initial; + } + const maxX = Math.max(...blocksPositions.map(node => node.x + node.width)); + const minY = Math.min(...blocksPositions.map(node => node.y)); + return { ...initial, x: maxX + MIN_DISTANCE, y: minY }; + } +} + +// ======= Internals ======= +function rectanglesOverlap(a: Rectangle2D, b: Rectangle2D): boolean { + return !( + a.x + a.width + MIN_DISTANCE <= b.x || + b.x + b.width + MIN_DISTANCE <= a.x || + a.y + a.height + MIN_DISTANCE <= b.y || + b.y + b.height + MIN_DISTANCE <= a.y + ); +} + +function getOverlapAmount(a: Rectangle2D, b: Rectangle2D): Position2D { + const xOverlap = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)); + const yOverlap = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)); + return { x: xOverlap, y: yOverlap }; +} + +function preventOverlap(target: Rectangle2D, fixedRectangles: Rectangle2D[]): Rectangle2D { + let hasOverlap: boolean; + do { + hasOverlap = false; + for (const fixed of fixedRectangles) { + if (rectanglesOverlap(target, fixed)) { + hasOverlap = true; + const overlap = getOverlapAmount(target, fixed); + if (overlap.x >= overlap.y) { + target.x += overlap.x + MIN_DISTANCE; + } else { + target.y += overlap.y + MIN_DISTANCE; + } + break; + } + } + } while (hasOverlap); + + return target; +} + +function calculatePositionFromArgs(args: number[], operations: IOperationPosition[]): Position2D { + const argNodes = operations.filter(pos => args.includes(pos.id)); + const maxY = Math.max(...argNodes.map(node => node.y)); + const minX = Math.min(...argNodes.map(node => node.x)); + const maxX = Math.max(...argNodes.map(node => node.x)); + return { + x: Math.ceil((maxX + minX) / 2 / GRID_SIZE) * GRID_SIZE, + y: maxY + DISTANCE_Y + }; +} + +function calculatePositionFromChildren( + initial: Rectangle2D, + operations: IOperationPosition[], + blocks: IBlockPosition[] +): Rectangle2D { + let left = undefined; + let top = undefined; + let right = undefined; + let bottom = undefined; + + for (const block of blocks) { + left = left === undefined ? block.x - MIN_DISTANCE : Math.min(left, block.x - MIN_DISTANCE); + top = top === undefined ? block.y - MIN_DISTANCE : Math.min(top, block.y - MIN_DISTANCE); + right = + right === undefined + ? Math.max(left + initial.width, block.x + block.width + MIN_DISTANCE) + : Math.max(right, block.x + block.width + MIN_DISTANCE); + bottom = !bottom + ? Math.max(top + initial.height, block.y + block.height + MIN_DISTANCE) + : Math.max(bottom, block.y + block.height + MIN_DISTANCE); + } + + for (const operation of operations) { + left = left === undefined ? operation.x - MIN_DISTANCE : Math.min(left, operation.x - MIN_DISTANCE); + top = top === undefined ? operation.y - MIN_DISTANCE : Math.min(top, operation.y - MIN_DISTANCE); + right = + right === undefined + ? Math.max(left + initial.width, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE) + : Math.max(right, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE); + bottom = !bottom + ? Math.max(top + initial.height, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE) + : Math.max(bottom, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE); + } + + return { + x: left ?? initial.x, + y: top ?? initial.y, + width: right !== undefined && left !== undefined ? right - left : initial.width, + height: bottom !== undefined && top !== undefined ? bottom - top : initial.height + }; } diff --git a/rsconcept/frontend/src/features/oss/models/oss.ts b/rsconcept/frontend/src/features/oss/models/oss.ts index 8c26c811..df847245 100644 --- a/rsconcept/frontend/src/features/oss/models/oss.ts +++ b/rsconcept/frontend/src/features/oss/models/oss.ts @@ -11,8 +11,17 @@ import { type IOperationSchemaDTO } from '../backend/types'; +/** Represents OSS node type. */ +export const NodeType = { + OPERATION: 1, + BLOCK: 2 +} as const; +export type NodeType = (typeof NodeType)[keyof typeof NodeType]; + /** Represents Operation. */ export interface IOperation extends IOperationDTO { + nodeID: string; + nodeType: typeof NodeType.OPERATION; x: number; y: number; is_owned: boolean; @@ -23,12 +32,17 @@ export interface IOperation extends IOperationDTO { /** Represents Block. */ export interface IBlock extends IBlockDTO { + nodeID: string; + nodeType: typeof NodeType.BLOCK; x: number; y: number; width: number; height: number; } +/** Represents item of OperationSchema. */ +export type IOssItem = IOperation | IBlock; + /** Represents {@link IOperationSchema} statistics. */ export interface IOperationSchemaStats { count_all: number; @@ -45,16 +59,14 @@ export interface IOperationSchema extends IOperationSchemaDTO { blocks: IBlock[]; graph: Graph; - hierarchy: Graph; + hierarchy: Graph; schemas: number[]; stats: IOperationSchemaStats; operationByID: Map; blockByID: Map; + itemByNodeID: Map; } -/** Represents item of OperationSchema. */ -export type IOssItem = IOperation | IBlock; - /** Represents substitution error description. */ export interface ISubstitutionErrorDescription { errorType: SubstitutionErrorType; diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/context-menu.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/context-menu.tsx index eeadf16f..8d2af18a 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/context-menu.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/context-menu.tsx @@ -4,8 +4,7 @@ import { useRef } from 'react'; import { Dropdown } from '@/components/dropdown'; -import { type IBlock, type IOperation, type IOssItem } from '../../../../models/oss'; -import { isOperation } from '../../../../models/oss-api'; +import { type IOssItem, NodeType } from '../../../../models/oss'; import { MenuBlock } from './menu-block'; import { MenuOperation } from './menu-operation'; @@ -27,7 +26,6 @@ interface ContextMenuProps extends ContextMenuData { export function ContextMenu({ isOpen, item, cursorX, cursorY, onHide }: ContextMenuProps) { const ref = useRef(null); - const isOperationNode = isOperation(item); function handleBlur(event: React.FocusEvent) { if (!ref.current?.contains(event.relatedTarget as Node)) { @@ -49,10 +47,10 @@ export function ContextMenu({ isOpen, item, cursorX, cursorY, onHide }: ContextM margin={cursorY >= window.innerHeight - MENU_HEIGHT ? 'mb-3' : 'mt-3'} > {!!item ? ( - isOperationNode ? ( - + item.nodeType === NodeType.OPERATION ? ( + ) : ( - + ) ) : null} diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/block-node.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/block-node.tsx index be20a380..004812c1 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/block-node.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/block-node.tsx @@ -16,15 +16,15 @@ export const BLOCK_NODE_MIN_WIDTH = 160; export const BLOCK_NODE_MIN_HEIGHT = 100; export function BlockNode(node: BlockInternalNode) { - const { selected, schema } = useOssEdit(); + const { selectedItems, schema } = useOssEdit(); const dropTarget = useDraggingStore(state => state.dropTarget); const isDragging = useDraggingStore(state => state.isDragging); const showCoordinates = useOSSGraphStore(state => state.showCoordinates); const setHover = useOperationTooltipStore(state => state.setHoverItem); - const focus = selected.length === 1 ? selected[0] : null; - const isParent = (!!focus && schema.hierarchy.at(focus)?.inputs.includes(-node.data.block.id)) ?? false; - const isChild = (!!focus && schema.hierarchy.at(focus)?.outputs.includes(-node.data.block.id)) ?? false; + const focus = selectedItems.length === 1 ? selectedItems[0] : null; + const isParent = (!!focus && schema.hierarchy.at(focus.nodeID)?.inputs.includes(node.data.block.nodeID)) ?? false; + const isChild = (!!focus && schema.hierarchy.at(focus.nodeID)?.outputs.includes(node.data.block.nodeID)) ?? false; return ( <> diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/node-core.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/node-core.tsx index dc6ab6b2..e8e63536 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/node-core.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/node-core.tsx @@ -21,9 +21,9 @@ interface NodeCoreProps { } export function NodeCore({ node }: NodeCoreProps) { - const { selected, schema } = useOssEdit(); - const focus = selected.length === 1 ? selected[0] : null; - const isChild = (!!focus && schema.hierarchy.at(focus)?.outputs.includes(node.data.operation.id)) ?? false; + const { selectedItems, schema } = useOssEdit(); + const focus = selectedItems.length === 1 ? selectedItems[0] : null; + const isChild = (!!focus && schema.hierarchy.at(focus.nodeID)?.outputs.includes(node.data.operation.nodeID)) ?? false; const setHover = useOperationTooltipStore(state => state.setHoverItem); const showCoordinates = useOSSGraphStore(state => state.showCoordinates); diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-state.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-state.tsx index 79db73f0..0c2b7b7e 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-state.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-state.tsx @@ -3,12 +3,12 @@ import { useCallback, useEffect, useState } from 'react'; import { type Edge, type Node, useEdgesState, useNodesState, useOnSelectionChange, useReactFlow } from 'reactflow'; -import { type IOperationSchema } from '@/features/oss/models/oss'; -import { type Position2D } from '@/features/oss/models/oss-layout'; -import { useOSSGraphStore } from '@/features/oss/stores/oss-graph'; - import { PARAMETER } from '@/utils/constants'; +import { type IOperationSchema, NodeType } from '../../../models/oss'; +import { constructNodeID } from '../../../models/oss-api'; +import { type Position2D } from '../../../models/oss-layout'; +import { useOSSGraphStore } from '../../../stores/oss-graph'; import { useOssEdit } from '../oss-edit-context'; import { flowOptions } from './oss-flow'; @@ -29,10 +29,10 @@ export const OssFlowState = ({ children }: React.PropsWithChildren) => { const [edges, setEdges, onEdgesChange] = useEdgesState([]); function onSelectionChange({ nodes }: { nodes: Node[] }) { - const ids = nodes.map(node => Number(node.id)); + const ids = nodes.map(node => node.id); setSelected(prev => [ ...prev.filter(nodeID => ids.includes(nodeID)), - ...ids.filter(nodeID => !prev.includes(Number(nodeID))) + ...ids.filter(nodeID => !prev.includes(nodeID)) ]); } @@ -41,46 +41,45 @@ export const OssFlowState = ({ children }: React.PropsWithChildren) => { }); const resetGraph = useCallback(() => { - const newNodes: Node[] = [ - ...schema.hierarchy - .topologicalOrder() - .filter(id => id < 0) - .map(id => { - const block = schema.blockByID.get(-id)!; - return { - id: String(id), - type: 'block', - data: { label: block.title, block: block }, - position: computeRelativePosition(schema, { x: block.x, y: block.y }, block.parent), - style: { - width: block.width, - height: block.height - }, - parentId: block.parent ? `-${block.parent}` : undefined, - zIndex: Z_BLOCK - }; - }), - ...schema.operations.map(operation => ({ - id: String(operation.id), - type: operation.operation_type.toString(), - data: { label: operation.alias, operation: operation }, - position: computeRelativePosition(schema, { x: operation.x, y: operation.y }, operation.parent), - parentId: operation.parent ? `-${operation.parent}` : undefined, - zIndex: Z_SCHEMA - })) - ]; + const newNodes: Node[] = schema.hierarchy.topologicalOrder().map(nodeID => { + const item = schema.itemByNodeID.get(nodeID)!; + if (item.nodeType === NodeType.BLOCK) { + return { + id: nodeID, + type: 'block', + data: { label: item.title, block: item }, + position: computeRelativePosition(schema, { x: item.x, y: item.y }, item.parent), + style: { + width: item.width, + height: item.height + }, + parentId: item.parent ? constructNodeID(NodeType.BLOCK, item.parent) : undefined, + zIndex: Z_BLOCK + }; + } else { + return { + id: item.nodeID, + type: item.operation_type.toString(), + data: { label: item.alias, operation: item }, + position: computeRelativePosition(schema, { x: item.x, y: item.y }, item.parent), + parentId: item.parent ? constructNodeID(NodeType.BLOCK, item.parent) : undefined, + zIndex: Z_SCHEMA + }; + } + }); - const newEdges: Edge[] = schema.arguments.map((argument, index) => ({ - id: String(index), - source: String(argument.argument), - target: String(argument.operation), - type: edgeStraight ? 'straight' : 'simplebezier', - animated: edgeAnimate, - targetHandle: - schema.operationByID.get(argument.argument)!.x > schema.operationByID.get(argument.operation)!.x - ? 'right' - : 'left' - })); + const newEdges: Edge[] = schema.arguments.map((argument, index) => { + const source = schema.operationByID.get(argument.argument)!; + const target = schema.operationByID.get(argument.operation)!; + return { + id: String(index), + source: source.nodeID, + target: target.nodeID, + type: edgeStraight ? 'straight' : 'simplebezier', + animated: edgeAnimate, + targetHandle: source.x > target.x ? 'right' : 'left' + }; + }); setNodes(newNodes); setEdges(newEdges); 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 c7d9b66e..34b8c0f0 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 @@ -12,6 +12,7 @@ import { promptText } from '@/utils/labels'; 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 { useOSSGraphStore } from '../../../stores/oss-graph'; @@ -41,7 +42,7 @@ export const flowOptions = { export function OssFlow() { const mainHeight = useMainHeight(); - const { navigateOperationSchema, schema, selected, isMutable, canDeleteOperation } = useOssEdit(); + const { navigateOperationSchema, schema, selected, selectedItems, isMutable, canDeleteOperation } = useOssEdit(); const { screenToFlowPosition } = useReactFlow(); const { containMovement, nodes, onNodesChange, edges, onEdgesChange, resetGraph, resetView } = useOssFlow(); const store = useStoreApi(); @@ -76,20 +77,21 @@ export function OssFlow() { manager: new LayoutManager(schema, getLayout()), defaultX: targetPosition.x, defaultY: targetPosition.y, - initialInputs: selected.filter(id => id > 0), - initialParent: extractSingleBlock(selected), + initialInputs: selectedItems.filter(item => item?.nodeType === NodeType.OPERATION).map(item => item.id), + initialParent: extractBlockParent(selectedItems), onCreate: resetView }); } function handleCreateBlock() { const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); - const parent = extractSingleBlock(selected); + const parent = extractBlockParent(selectedItems); showCreateBlock({ manager: new LayoutManager(schema, getLayout()), defaultX: targetPosition.x, defaultY: targetPosition.y, - initialChildren: parent !== null ? [] : selected, + initialChildren: + parent !== null && selectedItems.length === 1 && parent === selectedItems[0].id ? [] : selectedItems, initialParent: parent, onCreate: resetView }); @@ -99,25 +101,24 @@ export function OssFlow() { if (selected.length !== 1) { return; } - if (selected[0] > 0) { - const operation = schema.operationByID.get(selected[0]); - if (!operation || !canDeleteOperation(operation)) { + const item = schema.itemByNodeID.get(selected[0]); + if (!item) { + return; + } + if (item.nodeType === NodeType.OPERATION) { + if (!canDeleteOperation(item)) { return; } showDeleteOperation({ oss: schema, - target: operation, + target: item, layout: getLayout() }); } else { - const block = schema.blockByID.get(-selected[0]); - if (!block) { - return; - } if (!window.confirm(promptText.deleteBlock)) { return; } - void deleteBlock({ itemID: schema.id, data: { target: block.id, layout: getLayout() } }); + void deleteBlock({ itemID: schema.id, data: { target: item.id, layout: getLayout() } }); } } @@ -219,7 +220,10 @@ export function OssFlow() { } // -------- Internals -------- -function extractSingleBlock(selected: number[]): number | null { - const blocks = selected.filter(id => id < 0); - return blocks.length === 1 ? -blocks[0] : null; +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 0dd13ea8..6499108f 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 @@ -27,6 +27,7 @@ import { OperationType } from '../../../backend/types'; import { useExecuteOperation } from '../../../backend/use-execute-operation'; import { useMutatingOss } from '../../../backend/use-mutating-oss'; import { useUpdateLayout } from '../../../backend/use-update-layout'; +import { NodeType } from '../../../models/oss'; import { LayoutManager } from '../../../models/oss-layout-api'; import { useOssEdit } from '../oss-edit-context'; @@ -48,11 +49,13 @@ export function ToolbarOssGraph({ className, ...restProps }: ToolbarOssGraphProps) { - const { schema, selected, isMutable, canDeleteOperation: canDelete } = useOssEdit(); + const { schema, selectedItems, isMutable, canDeleteOperation: canDelete } = useOssEdit(); const isProcessing = useMutatingOss(); const { resetView } = useOssFlow(); - const selectedOperation = selected.length !== 1 ? null : schema.operationByID.get(selected[0]) ?? null; - const selectedBlock = selected.length !== 1 ? null : schema.blockByID.get(-selected[0]) ?? null; + const selectedOperation = + selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.OPERATION ? selectedItems[0] : null; + const selectedBlock = + selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.BLOCK ? selectedItems[0] : null; const getLayout = useGetLayout(); const { updateLayout } = useUpdateLayout(); @@ -145,7 +148,9 @@ export function ToolbarOssGraph({ title='Исправить позиции узлов' icon={} onClick={handleFixLayout} - disabled={selected.length > 1 || selected[0] > 0} + disabled={ + selectedItems.length > 1 || (selectedItems.length > 0 && selectedItems[0].nodeType === NodeType.OPERATION) + } /> } onClick={handleOperationExecute} - disabled={isProcessing || selected.length !== 1 || !readyForSynthesis} + disabled={isProcessing || selectedItems.length !== 1 || !readyForSynthesis} /> } onClick={handleEditItem} - disabled={selected.length !== 1 || isProcessing} + disabled={selectedItems.length !== 1 || isProcessing} /> state.setIsDragging); + const isDragging = useDraggingStore(state => state.isDragging); const getLayout = useGetLayout(); const { selected, schema } = useOssEdit(); const dropTarget = useDropTarget(); @@ -43,7 +44,7 @@ export function useDragging({ hideContextMenu }: DraggingProps) { function handleDragStart(event: React.MouseEvent, target: Node) { if (event.shiftKey) { setContainMovement(true); - applyContainMovement([target.id, ...selected.map(id => String(id))], true); + applyContainMovement([target.id, ...selected], true); } else { setContainMovement(false); dropTarget.update(event); @@ -61,41 +62,37 @@ export function useDragging({ hideContextMenu }: DraggingProps) { function handleDragStop(event: React.MouseEvent, target: Node) { if (containMovement) { - applyContainMovement([target.id, ...selected.map(id => String(id))], false); + applyContainMovement([target.id, ...selected], false); } else { event.preventDefault(); event.stopPropagation(); - const new_parent = dropTarget.evaluate(event); - const allSelected = [...selected.filter(id => id != Number(target.id)), Number(target.id)]; - const operations = allSelected - .filter(id => id > 0) - .map(id => schema.operationByID.get(id)) - .filter(operation => !!operation); - const blocks = allSelected - .filter(id => id < 0) - .map(id => schema.blockByID.get(-id)) - .filter(operation => !!operation); - const parents = new Set( - [...blocks.map(block => block.parent), ...operations.map(operation => operation.parent)].filter(id => !!id) - ); - if ( - (parents.size !== 1 || parents.values().next().value !== new_parent) && - (parents.size !== 0 || new_parent !== null) - ) { - void moveItems({ - itemID: schema.id, - data: { - layout: getLayout(), - operations: operations.map(operation => operation.id), - blocks: blocks.map(block => block.id), - destination: new_parent - } - }); + if (isDragging) { + setIsDragging(false); + const new_parent = dropTarget.evaluate(event); + const allSelected = [...selected.filter(id => id != target.id), target.id].map(id => + schema.itemByNodeID.get(id) + ); + const parents = new Set(allSelected.map(item => item?.parent).filter(id => !!id)); + const operations = allSelected.filter(item => item?.nodeType === NodeType.OPERATION); + const blocks = allSelected.filter(item => item?.nodeType === NodeType.BLOCK); + if ( + (parents.size !== 1 || parents.values().next().value !== new_parent) && + (parents.size !== 0 || new_parent !== null) + ) { + void moveItems({ + itemID: schema.id, + data: { + layout: getLayout(), + operations: operations.map(operation => operation.id), + blocks: blocks.map(block => block.id), + destination: new_parent + } + }); + } } } - setIsDragging(false); setContainMovement(false); dropTarget.reset(); } diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-drop-target.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-drop-target.tsx index 0029fe1d..6eb2668d 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-drop-target.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-drop-target.tsx @@ -1,5 +1,7 @@ import { useReactFlow } from 'reactflow'; +import { NodeType } from '@/features/oss/models/oss'; + import { useDraggingStore } from '@/stores/dragging'; import { useOssEdit } from '../oss-edit-context'; @@ -7,7 +9,7 @@ import { useOssEdit } from '../oss-edit-context'; /** Hook to encapsulate drop target logic. */ export function useDropTarget() { const { getIntersectingNodes, screenToFlowPosition } = useReactFlow(); - const { selected, schema } = useOssEdit(); + const { selectedItems, selected, schema } = useOssEdit(); const dropTarget = useDraggingStore(state => state.dropTarget); const setDropTarget = useDraggingStore(state => state.setDropTarget); @@ -19,17 +21,16 @@ export function useDropTarget() { width: 1, height: 1 }) - .map(node => Number(node.id)) - .filter(id => id < 0 && !selected.includes(id)) - .map(id => schema.blockByID.get(-id)) - .filter(block => !!block); + .filter(node => !selected.includes(node.id)) + .map(node => schema.itemByNodeID.get(node.id)) + .filter(item => item?.nodeType === NodeType.BLOCK); if (blocks.length === 0) { return null; } - const successors = schema.hierarchy.expandAllOutputs([...selected]).filter(id => id < 0); - blocks = blocks.filter(block => !successors.includes(-block.id)); + const successors = schema.hierarchy.expandAllOutputs(selectedItems.map(item => item.nodeID)); + blocks = blocks.filter(block => !successors.includes(block.nodeID)); if (blocks.length === 0) { return null; } diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-get-layout.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-get-layout.tsx index ac81cb18..87b23bec 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-get-layout.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-get-layout.tsx @@ -18,13 +18,13 @@ export function useGetLayout() { operations: nodes .filter(node => node.type !== 'block') .map(node => ({ - id: Number(node.id), + id: schema.itemByNodeID.get(node.id)!.id, ...computeAbsolutePosition(node, schema, nodeById) })), blocks: nodes .filter(node => node.type === 'block') .map(node => ({ - id: -Number(node.id), + id: schema.itemByNodeID.get(node.id)!.id, ...computeAbsolutePosition(node, schema, nodeById), width: node.width ?? BLOCK_NODE_MIN_WIDTH, height: node.height ?? BLOCK_NODE_MIN_HEIGHT @@ -35,7 +35,7 @@ export function useGetLayout() { // ------- Internals ------- function computeAbsolutePosition(target: Node, schema: IOperationSchema, nodeById: Map): Position2D { - const nodes = schema.hierarchy.expandAllInputs([Number(target.id)]); + const nodes = schema.hierarchy.expandAllInputs([target.id]); let x = target.position.x; let y = target.position.y; for (const nodeID of nodes) { diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-context.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-context.tsx index 7d315e1e..df790c13 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-context.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-context.tsx @@ -2,7 +2,7 @@ import { createContext, use } from 'react'; -import { type IOperation, type IOperationSchema } from '../../models/oss'; +import { type IOperation, type IOperationSchema, type IOssItem } from '../../models/oss'; export const OssTabID = { CARD: 0, @@ -12,7 +12,8 @@ export type OssTabID = (typeof OssTabID)[keyof typeof OssTabID]; interface IOssEditContext { schema: IOperationSchema; - selected: number[]; + selected: string[]; + selectedItems: IOssItem[]; isOwned: boolean; isMutable: boolean; @@ -22,7 +23,7 @@ interface IOssEditContext { canDeleteOperation: (target: IOperation) => boolean; deleteSchema: () => void; - setSelected: React.Dispatch>; + setSelected: React.Dispatch>; } export const OssEditContext = createContext(null); diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-state.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-state.tsx index 75afd3fc..345a4cb8 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-state.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-state.tsx @@ -38,7 +38,8 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren UserRole.READER && !schema.read_only; const isEditor = !!user.id && schema.editors.includes(user.id); - const [selected, setSelected] = useState([]); + const [selected, setSelected] = useState([]); + const selectedItems = selected.map(id => schema.itemByNodeID.get(id)).filter(item => !!item); const { deleteItem } = useDeleteItem(); @@ -92,6 +93,7 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren { /** Unique identifier of the node. */ - id: number; + id: NodeID; /** List of outgoing nodes. */ - outputs: number[]; + outputs: NodeID[]; /** List of incoming nodes. */ - inputs: number[]; + inputs: NodeID[]; - constructor(id: number) { + constructor(id: NodeID) { this.id = id; this.outputs = []; this.inputs = []; } - clone(): GraphNode { + clone(): GraphNode { const result = new GraphNode(this.id); result.outputs = [...this.outputs]; result.inputs = [...this.inputs]; return result; } - addOutput(node: number): void { + addOutput(node: NodeID): void { this.outputs.push(node); } - addInput(node: number): void { + addInput(node: NodeID): void { this.inputs.push(node); } - removeInput(target: number): number | null { + removeInput(target: NodeID): NodeID | null { const index = this.inputs.findIndex(node => node === target); return index > -1 ? this.inputs.splice(index, 1)[0] : null; } - removeOutput(target: number): number | null { + removeOutput(target: NodeID): NodeID | null { const index = this.outputs.findIndex(node => node === target); return index > -1 ? this.outputs.splice(index, 1)[0] : null; } @@ -50,11 +50,11 @@ export class GraphNode { * * This class is optimized for TermGraph use case and not supposed to be used as generic graph implementation. */ -export class Graph { +export class Graph { /** Map of nodes. */ - nodes = new Map(); + nodes = new Map>(); - constructor(arr?: number[][]) { + constructor(arr?: NodeID[][]) { if (!arr) { return; } @@ -67,17 +67,17 @@ export class Graph { }); } - clone(): Graph { - const result = new Graph(); + clone(): Graph { + const result = new Graph(); this.nodes.forEach(node => result.nodes.set(node.id, node.clone())); return result; } - at(target: number): GraphNode | undefined { + at(target: NodeID): GraphNode | undefined { return this.nodes.get(target); } - addNode(target: number): GraphNode { + addNode(target: NodeID): GraphNode { let node = this.nodes.get(target); if (!node) { node = new GraphNode(target); @@ -86,11 +86,11 @@ export class Graph { return node; } - hasNode(target: number): boolean { + hasNode(target: NodeID): boolean { return !!this.nodes.get(target); } - removeNode(target: number): void { + removeNode(target: NodeID): void { this.nodes.forEach(node => { node.removeInput(target); node.removeOutput(target); @@ -98,7 +98,7 @@ export class Graph { this.nodes.delete(target); } - foldNode(target: number): void { + foldNode(target: NodeID): void { const nodeToRemove = this.nodes.get(target); if (!nodeToRemove) { return; @@ -111,8 +111,8 @@ export class Graph { this.removeNode(target); } - removeIsolated(): GraphNode[] { - const result: GraphNode[] = []; + removeIsolated(): GraphNode[] { + const result: GraphNode[] = []; this.nodes.forEach(node => { if (node.outputs.length === 0 && node.inputs.length === 0) { result.push(node); @@ -122,7 +122,7 @@ export class Graph { return result; } - addEdge(source: number, destination: number): void { + addEdge(source: NodeID, destination: NodeID): void { if (this.hasEdge(source, destination)) { return; } @@ -132,7 +132,7 @@ export class Graph { destinationNode.addInput(sourceNode.id); } - removeEdge(source: number, destination: number): void { + removeEdge(source: NodeID, destination: NodeID): void { const sourceNode = this.nodes.get(source); const destinationNode = this.nodes.get(destination); if (sourceNode && destinationNode) { @@ -141,7 +141,7 @@ export class Graph { } } - hasEdge(source: number, destination: number): boolean { + hasEdge(source: NodeID, destination: NodeID): boolean { const sourceNode = this.nodes.get(source); if (!sourceNode) { return false; @@ -149,8 +149,8 @@ export class Graph { return !!sourceNode.outputs.find(id => id === destination); } - expandOutputs(origin: number[]): number[] { - const result: number[] = []; + expandOutputs(origin: NodeID[]): NodeID[] { + const result: NodeID[] = []; origin.forEach(id => { const node = this.nodes.get(id); if (node) { @@ -164,8 +164,8 @@ export class Graph { return result; } - expandInputs(origin: number[]): number[] { - const result: number[] = []; + expandInputs(origin: NodeID[]): NodeID[] { + const result: NodeID[] = []; origin.forEach(id => { const node = this.nodes.get(id); if (node) { @@ -179,13 +179,13 @@ export class Graph { return result; } - expandAllOutputs(origin: number[]): number[] { - const result: number[] = this.expandOutputs(origin); + expandAllOutputs(origin: NodeID[]): NodeID[] { + const result: NodeID[] = this.expandOutputs(origin); if (result.length === 0) { return []; } - const marked = new Map(); + const marked = new Map(); origin.forEach(id => marked.set(id, true)); let position = 0; while (position < result.length) { @@ -203,13 +203,13 @@ export class Graph { return result; } - expandAllInputs(origin: number[]): number[] { - const result: number[] = this.expandInputs(origin); + expandAllInputs(origin: NodeID[]): NodeID[] { + const result: NodeID[] = this.expandInputs(origin); if (result.length === 0) { return []; } - const marked = new Map(); + const marked = new Map(); origin.forEach(id => marked.set(id, true)); let position = 0; while (position < result.length) { @@ -227,8 +227,8 @@ export class Graph { return result; } - maximizePart(origin: number[]): number[] { - const outputs: number[] = this.expandAllOutputs(origin); + maximizePart(origin: NodeID[]): NodeID[] { + const outputs: NodeID[] = this.expandAllOutputs(origin); const result = [...origin]; this.topologicalOrder() .filter(id => outputs.includes(id)) @@ -241,10 +241,10 @@ export class Graph { return result; } - topologicalOrder(): number[] { - const result: number[] = []; - const marked = new Set(); - const nodeStack: number[] = []; + topologicalOrder(): NodeID[] { + const result: NodeID[] = []; + const marked = new Set(); + const nodeStack: NodeID[] = []; this.nodes.forEach(node => { if (marked.has(node.id)) { return; @@ -275,12 +275,12 @@ export class Graph { transitiveReduction() { const order = this.topologicalOrder(); - const marked = new Map(); + const marked = new Map(); order.forEach(nodeID => { if (marked.get(nodeID)) { return; } - const stack: { id: number; parents: number[] }[] = []; + const stack: { id: NodeID; parents: NodeID[] }[] = []; stack.push({ id: nodeID, parents: [] }); while (stack.length > 0) { const item = stack.splice(0, 1)[0]; @@ -299,20 +299,20 @@ export class Graph { /** * Finds a cycle in the graph. * - * @returns {number[] | null} The cycle if found, otherwise `null`. + * @returns {NodeID[] | null} The cycle if found, otherwise `null`. * Uses non-recursive DFS. */ - findCycle(): number[] | null { - const visited = new Set(); - const nodeStack = new Set(); - const parents = new Map(); + findCycle(): NodeID[] | null { + const visited = new Set(); + const nodeStack = new Set(); + const parents = new Map(); for (const nodeId of this.nodes.keys()) { if (visited.has(nodeId)) { continue; } - const callStack: { nodeId: number; parentId: number | null }[] = []; + const callStack: { nodeId: NodeID; parentId: NodeID | null }[] = []; callStack.push({ nodeId: nodeId, parentId: null }); while (callStack.length > 0) { const { nodeId, parentId } = callStack[callStack.length - 1]; @@ -336,7 +336,7 @@ export class Graph { if (!nodeStack.has(child)) { continue; } - const cycle: number[] = []; + const cycle: NodeID[] = []; let current = nodeId; cycle.push(child); while (current !== child) {