166 lines
6.0 KiB
TypeScript
166 lines
6.0 KiB
TypeScript
import { createContext, useContext } from 'react';
|
|
import { Handle, NodeResizer, Position, type Node, type NodeProps } from '@xyflow/react';
|
|
import type { DiagramNodeData, NodeKind } from './types';
|
|
|
|
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');
|
|
|
|
// Gradients from palette.css's "ОСНОВНАЯ ПАЛИТРА" section — the one the
|
|
// palette itself labels as meant for diagrams/flowcharts/semantic highlights.
|
|
// This is what nodes actually render with until a user picks a custom color.
|
|
export const DEFAULT_NODE_BACKGROUND: Record<NodeKind, string> = {
|
|
task: 'var(--gradient-blue)',
|
|
startEvent: 'var(--gradient-green)',
|
|
intermediateEvent: 'var(--gradient-orange)',
|
|
endEvent: 'var(--gradient-red)',
|
|
exclusiveGateway: 'var(--gradient-purple)',
|
|
parallelGateway: 'var(--gradient-cyan)',
|
|
inclusiveGateway: 'var(--gradient-olive)',
|
|
};
|
|
|
|
// The color picker only understands flat hex, so it needs a solid fallback
|
|
// distinct from the gradient rendered on canvas — the gradient's start color.
|
|
export const DEFAULT_NODE_COLOR: Record<NodeKind, string> = {
|
|
task: '#0790CB',
|
|
startEvent: '#2CB587',
|
|
intermediateEvent: '#FF8E58',
|
|
endEvent: '#E13042',
|
|
exclusiveGateway: '#A12C7E',
|
|
parallelGateway: '#1BD3F7',
|
|
inclusiveGateway: '#BBBD39',
|
|
};
|
|
|
|
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_POSITIONS.map(({ id, position }) => (
|
|
<Handle key={id} id={id} type="source" position={position} />
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SubProcessBadge({ id }: { id: string }) {
|
|
const withChildren = useContext(NodesWithChildrenContext);
|
|
if (!withChildren.has(id)) return null;
|
|
return (
|
|
<span className="diagram-node__badge" title="Есть вложенные узлы — двойной клик, чтобы открыть">
|
|
⊞
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ShapeResizer({
|
|
selected,
|
|
minWidth,
|
|
minHeight,
|
|
keepAspectRatio = true,
|
|
}: {
|
|
selected: boolean;
|
|
minWidth: number;
|
|
minHeight: number;
|
|
keepAspectRatio?: boolean;
|
|
}) {
|
|
const mode = useContext(ModeContext);
|
|
return (
|
|
<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_BACKGROUND.task }}>
|
|
<ShapeResizer selected={selected} minWidth={70} minHeight={32} keepAspectRatio={false} />
|
|
<ShapeHandles />
|
|
<SubProcessBadge id={id} />
|
|
<span className="diagram-node__label">{data.label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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--circle ${EVENT_BORDER_CLASS[kind]}`}
|
|
style={{ background: data.color ?? DEFAULT_NODE_BACKGROUND[kind] }}
|
|
>
|
|
<ShapeResizer selected={selected} minWidth={24} minHeight={24} />
|
|
<ShapeHandles />
|
|
<SubProcessBadge id={id} />
|
|
<span className="diagram-node__caption">{data.label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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--gateway-outer">
|
|
<ShapeResizer selected={selected} minWidth={32} minHeight={32} />
|
|
{/*
|
|
The diamond is drawn with clip-path on a plain (unrotated) box, not
|
|
`transform: rotate(45deg)` — a rotated element makes React Flow's
|
|
getBoundingClientRect-based handle measurement pick up sub-pixel
|
|
rounding from the rotation, which showed up as a visible kink in
|
|
otherwise-straight horizontal/vertical edges. With clip-path, the
|
|
standard Position.Top/Right/Bottom/Left handles already land exactly
|
|
on the diamond's tips (they're at the box's edge midpoints either
|
|
way), so this reuses the same ShapeHandles as every other shape.
|
|
Handles/badge/caption are siblings of the clipped div, not children
|
|
of it, since clip-path would otherwise cut off anything of theirs
|
|
that renders outside the diamond outline.
|
|
*/}
|
|
<div className="diagram-node--diamond-bg" style={{ background: data.color ?? DEFAULT_NODE_BACKGROUND[kind] }} />
|
|
<ShapeHandles />
|
|
<SubProcessBadge id={id} />
|
|
<span className="diagram-node__icon">{GATEWAY_ICON[kind]}</span>
|
|
<span className="diagram-node__caption">{data.label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const nodeTypes = {
|
|
task: TaskNode,
|
|
startEvent: EventNode,
|
|
intermediateEvent: EventNode,
|
|
endEvent: EventNode,
|
|
exclusiveGateway: GatewayNode,
|
|
parallelGateway: GatewayNode,
|
|
inclusiveGateway: GatewayNode,
|
|
};
|