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; /** Ids of nodes that currently have children (i.e. render as a drill-into-able sub-process). */ export const NodesWithChildrenContext = createContext>(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 = { 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 = { 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> = { user: IconUser, service: IconSettings, script: IconCode, manual: IconHand, businessRule: IconTable, send: IconSendMessage, receive: IconMail, }; export const TASK_SUBTYPE_LABEL: Record = { 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> = { 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 = { 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 so any point can link to any point. */ function ShapeHandles() { return ( <> {HANDLE_POSITIONS.map(({ id, position }) => ( ))} ); } function SubProcessBadge({ id }: { id: string }) { const withChildren = useContext(NodesWithChildrenContext); if (!withChildren.has(id)) return null; return ( ⇒ ); } function ShapeResizer({ selected, minWidth, minHeight, keepAspectRatio = true, }: { selected: boolean; minWidth: number; minHeight: number; keepAspectRatio?: boolean; }) { const mode = useContext(ModeContext); return ( ); } function TaskNode({ id, data, selected }: NodeProps) { const subtype = (data.subtype as TaskSubtype | undefined) ?? "none"; const SubtypeIcon = TASK_SUBTYPE_ICON[subtype]; const textColor = data.textColor ?? DEFAULT_NODE_TEXT_COLOR; return (
{SubtypeIcon && ( )} {data.label}
); } function EventNode({ id, data, type, selected }: NodeProps) { const kind = (type as "startEvent" | "intermediateEvent" | "endEvent") ?? "startEvent"; const subtype = (data.subtype as EventSubtype | undefined) ?? "none"; const SubtypeIcon = EVENT_SUBTYPE_ICON[subtype]; return (
{SubtypeIcon && ( )} {data.label}
); } const GATEWAY_ICON: Record< "exclusiveGateway" | "parallelGateway" | "inclusiveGateway" | "complexGateway" | "eventBasedGateway", string > = { exclusiveGateway: "✕", parallelGateway: "+", inclusiveGateway: "○", complexGateway: "✳", eventBasedGateway: "⬠", }; function GatewayNode({ id, data, type, selected }: NodeProps) { const kind = (type as | "exclusiveGateway" | "parallelGateway" | "inclusiveGateway" | "complexGateway" | "eventBasedGateway") ?? "exclusiveGateway"; return (
{/* 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. */}
{GATEWAY_ICON[kind]} {data.label}
); } // 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) { return (
{data.label}
); } // 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) { return (
{data.label}
); } 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, };