Files
Portal-Chart-Builder/src/nodes.tsx
T

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