diff --git a/rsconcept/frontend/src/components/icons.tsx b/rsconcept/frontend/src/components/icons.tsx index 3c08ae7d..307e6c21 100644 --- a/rsconcept/frontend/src/components/icons.tsx +++ b/rsconcept/frontend/src/components/icons.tsx @@ -10,6 +10,7 @@ export { BiCheck as IconAccept } from 'react-icons/bi'; export { BiX as IconRemove } from 'react-icons/bi'; export { BiTrash as IconDestroy } from 'react-icons/bi'; export { BiReset as IconReset } from 'react-icons/bi'; +export { TbArrowsDiagonal2 as IconResize } from 'react-icons/tb'; export { LiaEdit as IconEdit } from 'react-icons/lia'; export { FiEdit as IconEdit2 } from 'react-icons/fi'; export { BiSearchAlt2 as IconSearch } from 'react-icons/bi'; @@ -72,6 +73,7 @@ export { BiBot as IconRobot } from 'react-icons/bi'; export { IoLibrary as IconLibrary2 } from 'react-icons/io5'; export { BiDiamond as IconTemplates } from 'react-icons/bi'; export { TbHexagons as IconOSS } from 'react-icons/tb'; +export { BiScreenshot as IconConceptBlock } from 'react-icons/bi'; export { TbHexagon as IconRSForm } from 'react-icons/tb'; export { TbAssembly as IconRSFormOwned } from 'react-icons/tb'; export { TbBallFootball as IconRSFormImported } from 'react-icons/tb'; diff --git a/rsconcept/frontend/src/features/oss/backend/oss-loader.ts b/rsconcept/frontend/src/features/oss/backend/oss-loader.ts index 95d83e42..913c3b37 100644 --- a/rsconcept/frontend/src/features/oss/backend/oss-loader.ts +++ b/rsconcept/frontend/src/features/oss/backend/oss-loader.ts @@ -6,18 +6,20 @@ import { type ILibraryItem } from '@/features/library'; import { Graph } from '@/models/graph'; -import { type IOperation, type IOperationSchema, type IOperationSchemaStats } from '../models/oss'; +import { type IBlock, type IOperation, type IOperationSchema, type IOperationSchemaStats } from '../models/oss'; import { type IOperationSchemaDTO, OperationType } from './types'; -/** - * Loads data into an {@link IOperationSchema} based on {@link IOperationSchemaDTO}. - * - */ +export const DEFAULT_BLOCK_WIDTH = 100; +export const DEFAULT_BLOCK_HEIGHT = 100; + +/** Loads data into an {@link IOperationSchema} based on {@link IOperationSchemaDTO}. */ export class OssLoader { private oss: IOperationSchema; private graph: Graph = new Graph(); + private hierarchy: Graph = new Graph(); private operationByID = new Map(); + private blockByID = new Map(); private schemaIDs: number[] = []; private items: ILibraryItem[]; @@ -32,9 +34,12 @@ export class OssLoader { this.createGraph(); this.extractSchemas(); this.inferOperationAttributes(); + this.inferBlockAttributes(); result.operationByID = this.operationByID; + result.blockByID = this.blockByID; result.graph = this.graph; + result.hierarchy = this.hierarchy; result.schemas = this.schemaIDs; result.stats = this.calculateStats(); return result; @@ -44,6 +49,17 @@ export class OssLoader { this.oss.operations.forEach(operation => { this.operationByID.set(operation.id, operation); this.graph.addNode(operation.id); + this.hierarchy.addNode(operation.id); + if (operation.parent) { + this.hierarchy.addEdge(-operation.parent, operation.id); + } + }); + this.oss.blocks.forEach(block => { + this.blockByID.set(block.id, block); + this.hierarchy.addNode(-block.id); + if (block.parent) { + this.graph.addEdge(-block.parent, -block.id); + } }); } @@ -71,6 +87,16 @@ export class OssLoader { }); } + private inferBlockAttributes() { + this.oss.blocks.forEach(block => { + const geometry = this.oss.layout.blocks.find(item => item.id === block.id); + block.x = geometry?.x ?? 0; + block.y = geometry?.y ?? 0; + block.width = geometry?.width ?? DEFAULT_BLOCK_WIDTH; + block.height = geometry?.height ?? DEFAULT_BLOCK_HEIGHT; + }); + } + private inferConsolidation(operationID: number): boolean { const inputs = this.graph.expandInputs([operationID]); if (inputs.length === 0) { @@ -85,13 +111,14 @@ export class OssLoader { } private calculateStats(): IOperationSchemaStats { - const items = this.oss.operations; + const operations = this.oss.operations; return { - count_operations: items.length, - count_inputs: items.filter(item => item.operation_type === OperationType.INPUT).length, - count_synthesis: items.filter(item => item.operation_type === OperationType.SYNTHESIS).length, + count_all: this.oss.operations.length + this.oss.blocks.length, + count_inputs: operations.filter(item => item.operation_type === OperationType.INPUT).length, + count_synthesis: operations.filter(item => item.operation_type === OperationType.SYNTHESIS).length, count_schemas: this.schemaIDs.length, - count_owned: items.filter(item => !!item.result && item.is_owned).length + count_owned: operations.filter(item => !!item.result && item.is_owned).length, + count_block: this.oss.blocks.length }; } } diff --git a/rsconcept/frontend/src/features/oss/backend/types.ts b/rsconcept/frontend/src/features/oss/backend/types.ts index 6fe043b8..706e2db5 100644 --- a/rsconcept/frontend/src/features/oss/backend/types.ts +++ b/rsconcept/frontend/src/features/oss/backend/types.ts @@ -18,7 +18,7 @@ export type ICstSubstituteInfo = z.infer; /** Represents {@link IOperation} data from server. */ export type IOperationDTO = z.infer; -/** Represents {@link IOperation} data from server. */ +/** Represents {@link IBlock} data from server. */ export type IBlockDTO = z.infer; /** Represents backend data for {@link IOperationSchema}. */ diff --git a/rsconcept/frontend/src/features/oss/models/oss-layout.ts b/rsconcept/frontend/src/features/oss/models/oss-layout.ts index 7189fadc..14d0b924 100644 --- a/rsconcept/frontend/src/features/oss/models/oss-layout.ts +++ b/rsconcept/frontend/src/features/oss/models/oss-layout.ts @@ -1,9 +1,9 @@ /** - * Module: OSS representation. + * Module: OSS graphical representation. */ import { type Node } from 'reactflow'; -import { type IOperation } from './oss'; +import { type IBlock, type IOperation } from './oss'; /** * Represents XY Position. @@ -13,9 +13,7 @@ export interface Position2D { y: number; } -/** - * Represents graph OSS node data. - */ +/** Represents graph OSS node data. */ export interface OssNode extends Node { id: string; data: { @@ -25,15 +23,27 @@ export interface OssNode extends Node { position: { x: number; y: number }; } -/** - * Represents graph OSS node internal data. - */ -export interface OssNodeInternal { +/** Represents graph OSS node internal data for {@link IOperation}. */ +export interface OperationInternalNode { id: string; data: { label: string; operation: IOperation; }; + selected: boolean; + dragging: boolean; + xPos: number; + yPos: number; +} + +/** Represents graph OSS node internal data for {@link IBlock}. */ +export interface BlockInternalNode { + id: string; + data: { + label: string; + block: IBlock; + }; + selected: boolean; dragging: boolean; xPos: number; yPos: number; diff --git a/rsconcept/frontend/src/features/oss/models/oss.ts b/rsconcept/frontend/src/features/oss/models/oss.ts index cea0da7e..cb144c7d 100644 --- a/rsconcept/frontend/src/features/oss/models/oss.ts +++ b/rsconcept/frontend/src/features/oss/models/oss.ts @@ -4,7 +4,12 @@ import { type Graph } from '@/models/graph'; -import { type ICstSubstituteInfo, type IOperationDTO, type IOperationSchemaDTO } from '../backend/types'; +import { + type IBlockDTO, + type ICstSubstituteInfo, + type IOperationDTO, + type IOperationSchemaDTO +} from '../backend/types'; /** Represents Operation. */ export interface IOperation extends IOperationDTO { @@ -16,23 +21,35 @@ export interface IOperation extends IOperationDTO { arguments: number[]; } +/** Represents Block. */ +export interface IBlock extends IBlockDTO { + x: number; + y: number; + width: number; + height: number; +} + /** Represents {@link IOperationSchema} statistics. */ export interface IOperationSchemaStats { - count_operations: number; + count_all: number; count_inputs: number; count_synthesis: number; count_schemas: number; count_owned: number; + count_block: number; } /** Represents OperationSchema. */ export interface IOperationSchema extends IOperationSchemaDTO { operations: IOperation[]; + blocks: IBlock[]; graph: Graph; + hierarchy: Graph; schemas: number[]; stats: IOperationSchemaStats; operationByID: Map; + blockByID: Map; } /** Represents substitution error description. */ diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/editor-oss-card.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/editor-oss-card.tsx index 97db59a7..1f27a25f 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/editor-oss-card.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/editor-oss-card.tsx @@ -54,7 +54,7 @@ export function EditorOssCard() { - + ); diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/oss-stats.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/oss-stats.tsx index 859a5ddd..fb221d77 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/oss-stats.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/oss-stats.tsx @@ -1,4 +1,11 @@ -import { IconDownload, IconRSForm, IconRSFormImported, IconRSFormOwned, IconSynthesis } from '@/components/icons'; +import { + IconConceptBlock, + IconDownload, + IconRSForm, + IconRSFormImported, + IconRSFormOwned, + IconSynthesis +} from '@/components/icons'; import { cn } from '@/components/utils'; import { ValueStats } from '@/components/view'; @@ -11,10 +18,10 @@ interface OssStatsProps { export function OssStats({ className, stats }: OssStatsProps) { return ( -
+
Всего - {stats.count_operations} + {stats.count_all}
} value={stats.count_synthesis} /> + } + value={stats.count_block} + /> + + + +
+
+ {node.data.label} +
+
+ + ); +} diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/input-node.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/input-node.tsx index 659546f1..ebf864cc 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/input-node.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/input-node.tsx @@ -1,10 +1,10 @@ import { Handle, Position } from 'reactflow'; -import { type OssNodeInternal } from '../../../../models/oss-layout'; +import { type OperationInternalNode } from '../../../../models/oss-layout'; import { NodeCore } from './node-core'; -export function InputNode(node: OssNodeInternal) { +export function InputNode(node: OperationInternalNode) { 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 5ffad6f6..e73d09ed 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 @@ -7,14 +7,17 @@ import { Indicator } from '@/components/view'; import { globalIDs } from '@/utils/constants'; import { OperationType } from '../../../../backend/types'; -import { type OssNodeInternal } from '../../../../models/oss-layout'; +import { type OperationInternalNode } from '../../../../models/oss-layout'; import { useOperationTooltipStore } from '../../../../stores/operation-tooltip'; +export const OPERATION_NODE_WIDTH = 150; +export const OPERATION_NODE_HEIGHT = 40; + // characters - threshold for long labels - small font const LONG_LABEL_CHARS = 14; interface NodeCoreProps { - node: OssNodeInternal; + node: OperationInternalNode; } export function NodeCore({ node }: NodeCoreProps) { diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/operation-node.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/operation-node.tsx index 5851cb4c..54264fb4 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/operation-node.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/operation-node.tsx @@ -2,11 +2,11 @@ import { Handle, Position } from 'reactflow'; -import { type OssNodeInternal } from '../../../../models/oss-layout'; +import { type OperationInternalNode } from '../../../../models/oss-layout'; import { NodeCore } from './node-core'; -export function OperationNode(node: OssNodeInternal) { +export function OperationNode(node: OperationInternalNode) { return ( <> diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/oss-node-types.ts b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/oss-node-types.ts index 1dcc3131..79bc87cb 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/oss-node-types.ts +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/oss-node-types.ts @@ -1,9 +1,11 @@ import { type NodeTypes } from 'reactflow'; +import { BlockNode } from './block-node'; import { InputNode } from './input-node'; import { OperationNode } from './operation-node'; export const OssNodeTypes: NodeTypes = { synthesis: OperationNode, - input: InputNode + input: InputNode, + block: BlockNode }; diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/styles.css b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/styles.css index 224e6ea8..6e287510 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/styles.css +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/styles.css @@ -2,12 +2,27 @@ /* stylelint-disable selector-class-pattern */ +.react-flow__resize-control.handle { + background-color: transparent; + + z-index: var(--z-index-navigation); + color: var(--color-muted-foreground); + + &:hover { + color: var(--color-foreground); + } + + width: 0; + height: 0; +} + .react-flow__node-input, .react-flow__node-synthesis { cursor: pointer; border-radius: 5px; padding: 2px; + width: 150px; height: fit-content; outline-offset: -2px; @@ -24,3 +39,35 @@ border-color: var(--color-foreground); } } + +.react-flow__node-block { + cursor: auto; + + border-radius: 5px; + border-width: 0; + + padding: 0; + margin: 0; + + background-color: transparent; + pointer-events: none; +} + +.cc-node-block { + border-radius: 5px; + border-style: dashed; + border-width: 2px; + + padding: 4px; + + color: var(--color-muted-foreground); + + .selected & { + color: var(--color-foreground); + border-color: var(--color-graph-selected); + } + + &:hover { + color: var(--color-foreground); + } +} 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 dc26931d..d70eca9c 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 @@ -8,9 +8,12 @@ import { useEdgesState, useNodesState, useOnSelectionChange, - useReactFlow + useReactFlow, + useStoreApi } from 'reactflow'; +import { type IOperationSchema } from '@/features/oss/models/oss'; + import { useMainHeight } from '@/stores/app-layout'; import { useDialogsStore } from '@/stores/dialogs'; import { PARAMETER } from '@/utils/constants'; @@ -18,7 +21,7 @@ import { PARAMETER } from '@/utils/constants'; import { useMutatingOss } from '../../../backend/use-mutating-oss'; import { useUpdateLayout } from '../../../backend/use-update-layout'; import { GRID_SIZE } from '../../../models/oss-api'; -import { type OssNode } from '../../../models/oss-layout'; +import { type OssNode, type Position2D } from '../../../models/oss-layout'; import { useOperationTooltipStore } from '../../../stores/operation-tooltip'; import { useOSSGraphStore } from '../../../stores/oss-graph'; import { useOssEdit } from '../oss-edit-context'; @@ -32,6 +35,10 @@ import './graph/styles.css'; const ZOOM_MAX = 2; const ZOOM_MIN = 0.5; + +const Z_BLOCK = 1; +const Z_SCHEMA = 10; + export const VIEW_PADDING = 0.2; export function OssFlow() { @@ -45,6 +52,8 @@ export function OssFlow() { canDeleteOperation: canDelete } = useOssEdit(); const { fitView, screenToFlowPosition } = useReactFlow(); + const store = useStoreApi(); + const { resetSelectedElements } = store.getState(); const isProcessing = useMutatingOss(); @@ -79,14 +88,36 @@ export function OssFlow() { }); useEffect(() => { - setNodes( - schema.operations.map(operation => ({ + setNodes([ + ...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), + width: block.width, + height: block.height, + parentId: block.parent ? `-${block.parent}` : undefined, + expandParent: true, + extent: 'parent' as const, + zIndex: Z_BLOCK + }; + }), + ...schema.operations.map(operation => ({ id: String(operation.id), + type: operation.operation_type.toString(), data: { label: operation.alias, operation: operation }, - position: { x: operation.x, y: operation.y }, - type: operation.operation_type.toString() + position: computeRelativePosition(schema, { x: operation.x, y: operation.y }, operation.parent), + parentId: operation.parent ? `-${operation.parent}` : undefined, + expandParent: true, + extent: 'parent' as const, + zIndex: Z_SCHEMA })) - ); + ]); setEdges( schema.arguments.map((argument, index) => ({ id: String(index), @@ -114,7 +145,7 @@ export function OssFlow() { defaultX: targetPosition.x, defaultY: targetPosition.y, layout: getLayout(), - initialInputs: selected, + initialInputs: selected.filter(id => id > 0), onCreate: () => setTimeout(() => fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING }), PARAMETER.refreshTimeout) }); @@ -157,7 +188,16 @@ export function OssFlow() { } function handleKeyDown(event: React.KeyboardEvent) { - if (isProcessing || !isMutable) { + if (isProcessing) { + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + resetSelectedElements(); + return; + } + if (!isMutable) { return; } if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') { @@ -217,3 +257,20 @@ export function OssFlow() {
); } + +// -------- Internals -------- +function computeRelativePosition(schema: IOperationSchema, position: Position2D, parent: number | null): Position2D { + if (!parent) { + return position; + } + + const parentBlock = schema.blockByID.get(parent); + if (!parentBlock) { + return position; + } + + return { + x: position.x - parentBlock.x, + y: position.y - parentBlock.y + }; +} 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 43ada875..467080d3 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 @@ -52,7 +52,7 @@ export function ToolbarOssGraph({ const { schema, selected, isMutable, canDeleteOperation: canDelete } = useOssEdit(); const isProcessing = useMutatingOss(); const { fitView } = useReactFlow(); - const selectedOperation = schema.operationByID.get(selected[0]); + const selectedOperation = selected.length !== 1 ? null : schema.operationByID.get(selected[0]) ?? null; const getLayout = useGetLayout(); const showGrid = useOSSGraphStore(state => state.showGrid); @@ -97,7 +97,7 @@ export function ToolbarOssGraph({ } function handleOperationExecute() { - if (selected.length !== 1 || !readyForSynthesis || !selectedOperation) { + if (!readyForSynthesis || !selectedOperation) { return; } void operationExecute({ @@ -106,8 +106,8 @@ export function ToolbarOssGraph({ }); } - function handleEditOperation() { - if (selected.length !== 1 || !selectedOperation) { + function handleEditItem() { + if (!selectedOperation) { return; } showEditOperation({ @@ -202,7 +202,7 @@ export function ToolbarOssGraph({ titleHtml={prepareTooltip('Редактировать выбранную', 'Двойной клик')} aria-label='Редактировать выбранную' icon={} - onClick={handleEditOperation} + onClick={handleEditItem} disabled={selected.length !== 1 || isProcessing} /> [node.id, node])); return { - operations: getNodes().map(node => ({ - id: Number(node.id), - x: node.position.x, - y: node.position.y - })), - blocks: [] + operations: nodes + .filter(node => node.type !== 'block') + .map(node => ({ + id: Number(node.id), + ...computeAbsolutePosition(node, schema, nodeById) + })), + blocks: nodes + .filter(node => node.type === 'block') + .map(node => ({ + id: -Number(node.id), + ...computeAbsolutePosition(node, schema, nodeById), + width: node.width ?? DEFAULT_BLOCK_WIDTH, + height: node.height ?? DEFAULT_BLOCK_HEIGHT + })) }; }; } + +// ------- Internals ------- +function computeAbsolutePosition(target: Node, schema: IOperationSchema, nodeById: Map): Position2D { + const nodes = schema.hierarchy.expandAllInputs([Number(target.id)]); + let x = target.position.x; + let y = target.position.y; + for (const nodeID of nodes) { + const node = nodeById.get(String(nodeID)); + if (node) { + x += node.position.x; + y += node.position.y; + } + } + return { x, y }; +} diff --git a/rsconcept/frontend/src/styling/reactflow.css b/rsconcept/frontend/src/styling/reactflow.css index 5b863963..a8d14771 100644 --- a/rsconcept/frontend/src/styling/reactflow.css +++ b/rsconcept/frontend/src/styling/reactflow.css @@ -47,27 +47,3 @@ box-shadow: 0 0 0 2px var(--color-selected) !important; } } - -.react-flow__node-token, -.react-flow__node-concept { - /* stylelint-disable-next-line at-rule-no-deprecated */ - cursor: default; - - border-radius: 100%; - width: 40px; - height: 40px; - - outline-offset: -2px; - outline-style: solid; - outline-color: transparent; - - transition-property: outline-offset; - transition-timing-function: var(--transition-bezier); - transition-duration: var(--duration-select); - - &.selected { - outline-offset: 4px; - outline-color: var(--color-graph-selected); - border-color: transparent; - } -}