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

433 lines
14 KiB
TypeScript

import { createContext, useContext } from "react";
import {
Handle,
NodeResizer,
Position,
type Node,
type NodeProps,
} from "@xyflow/react";
import { IconUser } from "@consta/icons/IconUser";
import { IconSettings } from "@consta/icons/IconSettings";
import { IconCode } from "@consta/icons/IconCode";
import { IconHand } from "@consta/icons/IconHand";
import { IconTable } from "@consta/icons/IconTable";
import { IconSendMessage } from "@consta/icons/IconSendMessage";
import { IconMail } from "@consta/icons/IconMail";
import { IconWatchStroked } from "@consta/icons/IconWatchStroked";
import { IconTable2 } from "@consta/icons/IconTable2";
import { IconAlert } from "@consta/icons/IconAlert";
import { IconLightningBolt } from "@consta/icons/IconLightningBolt";
import { IconArrowUp } from "@consta/icons/IconArrowUp";
import { IconArrowPrevious } from "@consta/icons/IconArrowPrevious";
import { IconRecord } from "@consta/icons/IconRecord";
import { IconPolygonMultiple } from "@consta/icons/IconPolygonMultiple";
import { IconGisMultiple } from "@consta/icons/IconGisMultiple";
import type { IconComponent } from "@consta/icons/Icon";
import type { DiagramNodeData, EventSubtype, NodeKind, TaskSubtype } 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)",
complexGateway: "var(--gradient-violet)",
eventBasedGateway: "var(--gradient-teal-dark)",
hint: "var(--gradient-frost)",
dataObject: "var(--gradient-ice)",
};
// 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",
complexGateway: "#8A5CE7",
eventBasedGateway: "#3C81A0",
hint: "#D7E5ED",
dataObject: "#CEEAFF",
};
// BPMN task-type icon shown as a small badge in the shape's top-left corner —
// 'none' (the plain abstract Task) intentionally has no entry, so it renders bare.
export const TASK_SUBTYPE_ICON: Partial<Record<TaskSubtype, IconComponent>> = {
user: IconUser,
service: IconSettings,
script: IconCode,
manual: IconHand,
businessRule: IconTable,
send: IconSendMessage,
receive: IconMail,
};
export const TASK_SUBTYPE_LABEL: Record<TaskSubtype, string> = {
none: "Действие",
user: "Действие пользователя",
service: "Сервисное действие",
script: "Действие-скрипт",
manual: "Ручное действие",
businessRule: "Действие по бизнес-правилам",
send: "Действие отправки",
receive: "Действие получения",
};
// BPMN event-trigger icon shown centered inside the event circle — 'none'
// (the plain, untyped event) intentionally has no entry.
export const EVENT_SUBTYPE_ICON: Partial<Record<EventSubtype, IconComponent>> = {
message: IconMail,
timer: IconWatchStroked,
conditional: IconTable2,
signal: IconAlert,
error: IconLightningBolt,
escalation: IconArrowUp,
compensation: IconArrowPrevious,
terminate: IconRecord,
multiple: IconPolygonMultiple,
parallelMultiple: IconGisMultiple,
};
export const EVENT_SUBTYPE_LABEL: Record<EventSubtype, string> = {
none: "Без триггера",
message: "Сообщение",
timer: "Таймер",
conditional: "Условие",
signal: "Сигнал",
error: "Ошибка",
escalation: "Эскалация",
compensation: "Компенсация",
terminate: "Завершение процесса",
multiple: "Множественное",
parallelMultiple: "Параллельное множественное",
};
// Which trigger subtypes are valid on each event kind, per BPMN 2.0 — used to
// filter the subtype picker in NodeInspector so it never offers an invalid combination.
export const EVENT_SUBTYPES_BY_KIND: Record<
"startEvent" | "intermediateEvent" | "endEvent",
EventSubtype[]
> = {
startEvent: ["none", "message", "timer", "conditional", "signal", "multiple", "parallelMultiple"],
intermediateEvent: [
"none",
"message",
"timer",
"conditional",
"signal",
"escalation",
"compensation",
"multiple",
"parallelMultiple",
],
endEvent: [
"none",
"message",
"error",
"escalation",
"compensation",
"signal",
"terminate",
"multiple",
],
};
export const DEFAULT_TASK_BORDER_RADIUS = 6;
// Text on the colored shape body (task label, gateway icon) needs a light
// default; the caption below events/gateways sits on the plain canvas
// background instead and needs a dark one. Both are overridable via the
// same `data.textColor`/`data.fontSize`.
export const DEFAULT_NODE_TEXT_COLOR = "#FFFFFF";
export const DEFAULT_CAPTION_TEXT_COLOR = "#525A64";
export const DEFAULT_NODE_FONT_SIZE = 12;
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>) {
const subtype = (data.subtype as TaskSubtype | undefined) ?? "none";
const SubtypeIcon = TASK_SUBTYPE_ICON[subtype];
const textColor = data.textColor ?? DEFAULT_NODE_TEXT_COLOR;
return (
<div
className="diagram-node diagram-node--task"
style={{
background: data.color ?? DEFAULT_NODE_BACKGROUND.task,
borderRadius: data.borderRadius ?? DEFAULT_TASK_BORDER_RADIUS,
}}
>
<ShapeResizer
selected={selected}
minWidth={70}
minHeight={32}
keepAspectRatio={false}
/>
<ShapeHandles />
<SubProcessBadge id={id} />
{SubtypeIcon && (
<span
className="diagram-node__subtype-badge"
style={{ color: textColor }}
title={TASK_SUBTYPE_LABEL[subtype]}
>
<SubtypeIcon size="xs" />
</span>
)}
<span
className="diagram-node__label"
style={{
color: textColor,
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
}}
>
{data.label}
</span>
</div>
);
}
function EventNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
const kind =
(type as "startEvent" | "intermediateEvent" | "endEvent") ?? "startEvent";
const subtype = (data.subtype as EventSubtype | undefined) ?? "none";
const SubtypeIcon = EVENT_SUBTYPE_ICON[subtype];
return (
<div
className="diagram-node diagram-node--circle"
style={{ background: data.color ?? DEFAULT_NODE_BACKGROUND[kind] }}
>
<ShapeResizer selected={selected} minWidth={24} minHeight={24} />
<ShapeHandles />
<SubProcessBadge id={id} />
{SubtypeIcon && (
<span
className="diagram-node__event-icon"
style={{ color: data.textColor ?? DEFAULT_NODE_TEXT_COLOR }}
title={EVENT_SUBTYPE_LABEL[subtype]}
>
<SubtypeIcon size="s" />
</span>
)}
<span
className="diagram-node__caption"
style={{
color: data.textColor ?? DEFAULT_CAPTION_TEXT_COLOR,
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
}}
>
{data.label}
</span>
</div>
);
}
const GATEWAY_ICON: Record<
"exclusiveGateway" | "parallelGateway" | "inclusiveGateway" | "complexGateway" | "eventBasedGateway",
string
> = {
exclusiveGateway: "✕",
parallelGateway: "+",
inclusiveGateway: "○",
complexGateway: "✳",
eventBasedGateway: "⬠",
};
function GatewayNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
const kind =
(type as
| "exclusiveGateway"
| "parallelGateway"
| "inclusiveGateway"
| "complexGateway"
| "eventBasedGateway") ?? "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"
style={{
color: data.textColor ?? DEFAULT_CAPTION_TEXT_COLOR,
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
}}
>
{data.label}
</span>
</div>
);
}
// Not a BPMN element — a free-floating annotation the user places next to
// other shapes. No handles: it's never meant to be wired into the flow.
function HintNode({ data, selected }: NodeProps<ShapeNode>) {
return (
<div
className="diagram-node diagram-node--hint"
style={{
background: data.color ?? DEFAULT_NODE_BACKGROUND.hint,
borderRadius: data.borderRadius ?? DEFAULT_TASK_BORDER_RADIUS,
}}
>
<ShapeResizer
selected={selected}
minWidth={60}
minHeight={32}
keepAspectRatio={false}
/>
<span
className="diagram-node__label"
style={{
// The default hint fill (--gradient-frost) is pale, unlike the
// vivid task backgrounds, so it needs the dark caption color, not
// the light one — same reasoning as DataObjectNode below.
color: data.textColor ?? DEFAULT_CAPTION_TEXT_COLOR,
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
}}
>
{data.label}
</span>
</div>
);
}
// BPMN Data Object — represents a document/input/output attached to a task
// via a (typically dashed) Data Association edge. The folded top-right corner
// is clipped on an inner div, not the outer one, so ShapeHandles — siblings of
// it — still land at the plain bounding-box edges instead of being cut off
// (same reasoning as the gateway diamond above).
function DataObjectNode({ data, selected }: NodeProps<ShapeNode>) {
return (
<div className="diagram-node--data-object-outer">
<ShapeResizer
selected={selected}
minWidth={50}
minHeight={60}
keepAspectRatio={false}
/>
<div
className="diagram-node--data-object-bg"
style={{ background: data.color ?? DEFAULT_NODE_BACKGROUND.dataObject }}
/>
<ShapeHandles />
<span
className="diagram-node__label"
style={{
// The folded-corner bg's `clip-path` gives it its own stacking
// context (z-index: 0, set in CSS), so the label needs to be
// positioned with a higher z-index to paint above it.
position: "relative",
zIndex: 1,
color: data.textColor ?? DEFAULT_CAPTION_TEXT_COLOR,
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
}}
>
{data.label}
</span>
</div>
);
}
export const nodeTypes = {
task: TaskNode,
startEvent: EventNode,
intermediateEvent: EventNode,
endEvent: EventNode,
exclusiveGateway: GatewayNode,
parallelGateway: GatewayNode,
inclusiveGateway: GatewayNode,
complexGateway: GatewayNode,
eventBasedGateway: GatewayNode,
hint: HintNode,
dataObject: DataObjectNode,
};