F: Implementing block UI pt1

This commit is contained in:
Ivan 2025-04-20 15:54:05 +03:00
parent da035478f6
commit 6783300339
17 changed files with 299 additions and 78 deletions

View File

@ -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';

View File

@ -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<number, IOperation>();
private blockByID = new Map<number, IBlock>();
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
};
}
}

View File

@ -18,7 +18,7 @@ export type ICstSubstituteInfo = z.infer<typeof schemaCstSubstituteInfo>;
/** Represents {@link IOperation} data from server. */
export type IOperationDTO = z.infer<typeof schemaOperation>;
/** Represents {@link IOperation} data from server. */
/** Represents {@link IBlock} data from server. */
export type IBlockDTO = z.infer<typeof schemaBlock>;
/** Represents backend data for {@link IOperationSchema}. */

View File

@ -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;

View File

@ -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<number, IOperation>;
blockByID: Map<number, IBlock>;
}
/** Represents substitution error description. */

View File

@ -54,7 +54,7 @@ export function EditorOssCard() {
<EditorLibraryItem schema={schema} isAttachedToOSS={false} />
</div>
<OssStats className='mt-3 md:mt-8 md:ml-5 w-56 md:w-48 mx-auto h-min' stats={schema.stats} />
<OssStats className='mt-3 md:mt-8 md:ml-5 w-80 md:w-56 mx-auto h-min' stats={schema.stats} />
</div>
</>
);

View File

@ -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 (
<div className={cn('grid grid-cols-3 gap-1 justify-items-end', className)}>
<div className={cn('grid grid-cols-4 gap-1 justify-items-end', className)}>
<div id='count_operations' className='w-fit flex gap-3 hover:cursor-default '>
<span>Всего</span>
<span>{stats.count_operations}</span>
<span>{stats.count_all}</span>
</div>
<ValueStats
id='count_inputs'
@ -28,6 +35,12 @@ export function OssStats({ className, stats }: OssStatsProps) {
icon={<IconSynthesis size='1.25rem' className='text-primary' />}
value={stats.count_synthesis}
/>
<ValueStats
id='count_block'
title='Блоки'
icon={<IconConceptBlock size='1.25rem' className='text-primary' />}
value={stats.count_block}
/>
<ValueStats
id='count_schemas'

View File

@ -0,0 +1,35 @@
'use client';
import { NodeResizeControl } from 'reactflow';
import clsx from 'clsx';
import { IconResize } from '@/components/icons';
import { type BlockInternalNode } from '../../../../models/oss-layout';
import { useOssEdit } from '../../oss-edit-context';
export function BlockNode(node: BlockInternalNode) {
const { selected, schema } = useOssEdit();
const singleSelected = selected.length === 1 ? selected[0] : null;
const isParent = !singleSelected ? false : schema.hierarchy.at(singleSelected)?.inputs.includes(node.data.block.id);
return (
<>
<NodeResizeControl minWidth={160} minHeight={100}>
<IconResize size={8} className='absolute bottom-[2px] right-[2px]' />
</NodeResizeControl>
<div className={clsx('cc-node-block h-full w-full', isParent && 'border-primary')}>
<div
className={clsx(
'w-fit mx-auto -translate-y-[14px]',
'px-2',
'bg-background rounded-lg',
'text-xs line-clamp-1 text-ellipsis',
'pointer-events-auto'
)}
>
{node.data.label}
</div>
</div>
</>
);
}

View File

@ -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 (
<>
<NodeCore node={node} />

View File

@ -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) {

View File

@ -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 (
<>
<Handle type='target' position={Position.Top} id='left' style={{ left: 40 }} />

View File

@ -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
};

View File

@ -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);
}
}

View File

@ -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<HTMLDivElement>) {
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() {
</div>
);
}
// -------- 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
};
}

View File

@ -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={<IconEdit2 size='1.25rem' className='icon-primary' />}
onClick={handleEditOperation}
onClick={handleEditItem}
disabled={selected.length !== 1 || isProcessing}
/>
<MiniButton

View File

@ -1,17 +1,49 @@
import { useReactFlow } from 'reactflow';
import { type Node, useReactFlow } from 'reactflow';
import { DEFAULT_BLOCK_HEIGHT, DEFAULT_BLOCK_WIDTH } from '@/features/oss/backend/oss-loader';
import { type IOssLayout } from '@/features/oss/backend/types';
import { type IOperationSchema } from '@/features/oss/models/oss';
import { type Position2D } from '@/features/oss/models/oss-layout';
import { useOssEdit } from '../oss-edit-context';
export function useGetLayout() {
const { getNodes } = useReactFlow();
const { schema } = useOssEdit();
return function getLayout(): IOssLayout {
const nodes = getNodes();
const nodeById = new Map(nodes.map(node => [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<string, Node>): 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 };
}

View File

@ -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;
}
}