Добавление нового функционала

This commit is contained in:
NikitaKalitov
2026-07-14 17:55:53 +03:00
parent fed7df1bea
commit 8bbee2dc7e
8 changed files with 744 additions and 114 deletions
+127 -16
View File
@@ -1,5 +1,5 @@
import { createContext, useContext } from 'react';
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
import { createContext, useContext, type CSSProperties } from 'react';
import { Handle, NodeResizer, Position, type Node, type NodeProps } from '@xyflow/react';
import type { DiagramNodeData, NodeKind } from './types';
type ShapeNode = Node<DiagramNodeData, NodeKind>;
@@ -7,11 +7,64 @@ type ShapeNode = Node<DiagramNodeData, NodeKind>;
/** Ids of nodes that currently have children (i.e. render as a drill-into-able sub-process). */
export const NodesWithChildrenContext = createContext<Set<string>>(new Set());
/** Current editor mode — resize handles and connection points only show in 'edit'. */
export const ModeContext = createContext<'edit' | 'view'>('edit');
export const DEFAULT_NODE_COLOR: Record<NodeKind, string> = {
task: '#eef2ff',
startEvent: '#f0fdf4',
intermediateEvent: '#fefce8',
endEvent: '#fef2f2',
exclusiveGateway: '#fff7ed',
parallelGateway: '#faf5ff',
inclusiveGateway: '#f0fdfa',
};
const HANDLE_POSITIONS: { id: string; position: Position }[] = [
{ id: 'top', position: Position.Top },
{ id: 'right', position: Position.Right },
{ id: 'bottom', position: Position.Bottom },
{ id: 'left', position: Position.Left },
];
/**
* A single handle per side acts as both connection start and end
* (React Flow handles are connectable both ways by default) — paired with
* connectionMode="loose" on <ReactFlow> so any point can link to any point.
*/
function ShapeHandles() {
return (
<>
<Handle type="target" position={Position.Left} />
<Handle type="source" position={Position.Right} />
{HANDLE_POSITIONS.map(({ id, position }) => (
<Handle key={id} id={id} type="source" position={position} />
))}
</>
);
}
/**
* The diamond div is a rotated square (`transform: rotate(45deg)`). Handles
* placed at the square's own corners land on the diamond's tips once rotated
* — corners, not `Position.Top/Right/Bottom/Left` (those are edge-midpoints,
* which rotate onto the diamond's flat sides instead).
*/
const DIAMOND_HANDLES: { id: string; position: Position; style: CSSProperties }[] = [
{ id: 'top', position: Position.Top, style: { top: 0, left: 0, transform: 'translate(-50%, -50%)' } },
{ id: 'right', position: Position.Right, style: { top: 0, left: '100%', transform: 'translate(-50%, -50%)' } },
{
id: 'bottom',
position: Position.Bottom,
style: { top: '100%', left: '100%', transform: 'translate(-50%, -50%)' },
},
{ id: 'left', position: Position.Left, style: { top: '100%', left: 0, transform: 'translate(-50%, -50%)' } },
];
function DiamondHandles() {
return (
<>
{DIAMOND_HANDLES.map(({ id, position, style }) => (
<Handle key={id} id={id} type="source" position={position} style={style} />
))}
</>
);
}
@@ -26,38 +79,96 @@ function SubProcessBadge({ id }: { id: string }) {
);
}
function TaskNode({ id, data }: NodeProps<ShapeNode>) {
function ShapeResizer({
selected,
minWidth,
minHeight,
keepAspectRatio = true,
}: {
selected: boolean;
minWidth: number;
minHeight: number;
keepAspectRatio?: boolean;
}) {
const mode = useContext(ModeContext);
return (
<div className="diagram-node diagram-node--task">
<NodeResizer
isVisible={selected && mode === 'edit'}
minWidth={minWidth}
minHeight={minHeight}
keepAspectRatio={keepAspectRatio}
/>
);
}
function TaskNode({ id, data, selected }: NodeProps<ShapeNode>) {
return (
<div className="diagram-node diagram-node--task" style={{ background: data.color ?? DEFAULT_NODE_COLOR.task }}>
<ShapeResizer selected={selected} minWidth={70} minHeight={32} keepAspectRatio={false} />
<ShapeHandles />
<SubProcessBadge id={id} />
<span>{data.label}</span>
<span className="diagram-node__label">{data.label}</span>
</div>
);
}
function GatewayNode({ id, data }: NodeProps<ShapeNode>) {
const EVENT_BORDER_CLASS: Record<'startEvent' | 'intermediateEvent' | 'endEvent', string> = {
startEvent: 'diagram-node--startEvent',
intermediateEvent: 'diagram-node--intermediateEvent',
endEvent: 'diagram-node--endEvent',
};
function EventNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
const kind = (type as 'startEvent' | 'intermediateEvent' | 'endEvent') ?? 'startEvent';
return (
<div className="diagram-node diagram-node--gateway">
<div
className={`diagram-node diagram-node--circle ${EVENT_BORDER_CLASS[kind]}`}
style={{ background: data.color ?? DEFAULT_NODE_COLOR[kind] }}
>
<ShapeResizer selected={selected} minWidth={24} minHeight={24} />
<ShapeHandles />
<SubProcessBadge id={id} />
<span>{data.label}</span>
<span className="diagram-node__caption">{data.label}</span>
</div>
);
}
function EventNode({ id, data }: NodeProps<ShapeNode>) {
const GATEWAY_ICON: Record<'exclusiveGateway' | 'parallelGateway' | 'inclusiveGateway', string> = {
exclusiveGateway: '✕',
parallelGateway: '+',
inclusiveGateway: '○',
};
function GatewayNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
const kind = (type as 'exclusiveGateway' | 'parallelGateway' | 'inclusiveGateway') ?? 'exclusiveGateway';
return (
<div className="diagram-node diagram-node--event">
<ShapeHandles />
<div className="diagram-node--gateway-outer">
<ShapeResizer selected={selected} minWidth={32} minHeight={32} />
{/*
The diamond is an inner square inset to 1/sqrt(2) of the outer box and
rotated 45deg, so its tips exactly reach the outer box's edges without
clipping. The caption sits on the (unrotated) outer box instead of the
diamond itself, so it renders upright below the shape.
*/}
<div
className="diagram-node diagram-node--diamond"
style={{ background: data.color ?? DEFAULT_NODE_COLOR[kind] }}
>
<DiamondHandles />
<span className="diagram-node__icon">{GATEWAY_ICON[kind]}</span>
</div>
<SubProcessBadge id={id} />
<span>{data.label}</span>
<span className="diagram-node__caption">{data.label}</span>
</div>
);
}
export const nodeTypes = {
task: TaskNode,
gateway: GatewayNode,
event: EventNode,
startEvent: EventNode,
intermediateEvent: EventNode,
endEvent: EventNode,
exclusiveGateway: GatewayNode,
parallelGateway: GatewayNode,
inclusiveGateway: GatewayNode,
};