433 lines
14 KiB
TypeScript
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,
|
|
};
|