483 lines
15 KiB
TypeScript
483 lines
15 KiB
TypeScript
import { useRef, useState, type RefObject } from "react";
|
|
import { Button } from "@consta/uikit/Button";
|
|
import { TextField } from "@consta/uikit/TextField";
|
|
import { ColorPicker, hexModel } from "@consta/uikit/ColorPicker";
|
|
import { Text } from "@consta/uikit/Text";
|
|
import { IconCancel } from "@consta/icons/IconCancel";
|
|
import { TRANSPARENT_COLOR, toPickerHex } from "./colors";
|
|
import {
|
|
DEFAULT_CAPTION_TEXT_COLOR,
|
|
DEFAULT_NODE_BACKGROUND,
|
|
DEFAULT_NODE_COLOR,
|
|
DEFAULT_NODE_FONT_SIZE,
|
|
DEFAULT_NODE_TEXT_COLOR,
|
|
DEFAULT_TASK_BORDER_RADIUS,
|
|
EVENT_SUBTYPE_ICON,
|
|
EVENT_SUBTYPE_LABEL,
|
|
EVENT_SUBTYPES_BY_KIND,
|
|
TASK_SUBTYPE_ICON,
|
|
TASK_SUBTYPE_LABEL,
|
|
} from "./nodes";
|
|
import type {
|
|
DiagramNodeData,
|
|
EventSubtype,
|
|
NodeKind,
|
|
TaskSubtype,
|
|
} from "./types";
|
|
|
|
const TASK_SUBTYPES: TaskSubtype[] = [
|
|
"none",
|
|
"user",
|
|
"service",
|
|
"script",
|
|
"manual",
|
|
"businessRule",
|
|
"send",
|
|
"receive",
|
|
];
|
|
|
|
// Every gradient defined in palette.css, across its three "for diagrams /
|
|
// flowcharts / semantic highlights" sections. Consta's ColorPicker `palette`
|
|
// prop can't render these — it goes through hexModel, which only understands
|
|
// flat colors — so these render as our own swatch row instead.
|
|
const GRADIENT_PRESETS: { name: string; label: string }[] = [
|
|
{ name: "navy", label: "Тёмно-синий" },
|
|
{ name: "azure", label: "Лазурный" },
|
|
{ name: "sky", label: "Небесный" },
|
|
{ name: "teal-dark", label: "Тёмная бирюза" },
|
|
{ name: "teal-light", label: "Светлая бирюза" },
|
|
{ name: "navy-dark", label: "Глубокий синий" },
|
|
{ name: "ice", label: "Лёд" },
|
|
{ name: "frost", label: "Иней" },
|
|
{ name: "purple", label: "Пурпурный" },
|
|
{ name: "red", label: "Красный" },
|
|
{ name: "orange", label: "Оранжевый" },
|
|
{ name: "olive", label: "Оливковый" },
|
|
{ name: "green", label: "Зелёный" },
|
|
{ name: "green-dark", label: "Тёмно-зелёный" },
|
|
{ name: "blue", label: "Синий" },
|
|
{ name: "cyan", label: "Циан" },
|
|
{ name: "violet", label: "Виолет" },
|
|
{ name: "lavender", label: "Лавандовый" },
|
|
{ name: "pink", label: "Розовый" },
|
|
];
|
|
|
|
// "Цвета текста" from palette.css.
|
|
const TEXT_COLOR_PRESETS: { hex: string; label: string }[] = [
|
|
{ hex: "#0079C2", label: "Синий" },
|
|
{ hex: "#525A64", label: "Тёмный" },
|
|
{ hex: "#929AA4", label: "Серый" },
|
|
{ hex: "#FFFFFF", label: "Белый" },
|
|
];
|
|
|
|
const TYPE_LABELS: Record<NodeKind, string> = {
|
|
task: "Действие (Task)",
|
|
startEvent: "Начальное событие (Start Event)",
|
|
intermediateEvent: "Промежуточное событие (Intermediate Event)",
|
|
endEvent: "Конечное событие (End Event)",
|
|
exclusiveGateway: "Исключающий шлюз (XOR)",
|
|
parallelGateway: "Параллельный шлюз (AND)",
|
|
inclusiveGateway: "Включающий шлюз (OR)",
|
|
complexGateway: "Сложный шлюз (Complex)",
|
|
eventBasedGateway: "Шлюз на основе событий (Event-based)",
|
|
hint: "Подсказка",
|
|
dataObject: "Документ (Data Object)",
|
|
};
|
|
|
|
const EVENT_KINDS = new Set<NodeKind>([
|
|
"startEvent",
|
|
"intermediateEvent",
|
|
"endEvent",
|
|
]);
|
|
|
|
interface NodeInspectorProps {
|
|
nodeId: string;
|
|
nodeType: NodeKind;
|
|
data: DiagramNodeData;
|
|
childCount: number;
|
|
mode: "edit" | "view";
|
|
onChange: (patch: Partial<DiagramNodeData>) => void;
|
|
onEnterSubProcess: (nodeId: string) => void;
|
|
onDuplicate: () => void;
|
|
onDelete: () => void;
|
|
onBringToFront: () => void;
|
|
onSendToBack: () => void;
|
|
}
|
|
|
|
export function NodeInspector({
|
|
nodeId,
|
|
nodeType,
|
|
data,
|
|
childCount,
|
|
mode,
|
|
onChange,
|
|
onEnterSubProcess,
|
|
onDuplicate,
|
|
onDelete,
|
|
onBringToFront,
|
|
onSendToBack,
|
|
}: NodeInspectorProps) {
|
|
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
|
// Consta's ref props still use the pre-React-19 non-nullable RefObject shape.
|
|
const colorButtonRef = useRef<HTMLDivElement>(
|
|
null,
|
|
) as RefObject<HTMLDivElement>;
|
|
// data.color бывает не-hex: `var(--gradient-x)` от пресета-градиента или
|
|
// `transparent` — hexModel такое не разбирает, нужен hex-дефолт.
|
|
const flatColor = toPickerHex(data.color, DEFAULT_NODE_COLOR[nodeType]);
|
|
|
|
const [textColorPickerOpen, setTextColorPickerOpen] = useState(false);
|
|
const textColorButtonRef = useRef<HTMLDivElement>(
|
|
null,
|
|
) as RefObject<HTMLDivElement>;
|
|
// Text sits directly on the colored fill for tasks (needs a light default)
|
|
// but on the plain canvas for every other shape's caption (needs a dark one).
|
|
const defaultTextColor =
|
|
nodeType === "task" ? DEFAULT_NODE_TEXT_COLOR : DEFAULT_CAPTION_TEXT_COLOR;
|
|
const currentTextColor = data.textColor ?? defaultTextColor;
|
|
|
|
if (mode === "view") {
|
|
const hasMeta = Boolean(data.responsible || data.duration);
|
|
const subtypeLabel =
|
|
nodeType === "task" ?
|
|
TASK_SUBTYPE_LABEL[(data.subtype as TaskSubtype | undefined) ?? "none"]
|
|
: EVENT_KINDS.has(nodeType) ?
|
|
EVENT_SUBTYPE_LABEL[
|
|
(data.subtype as EventSubtype | undefined) ?? "none"
|
|
]
|
|
: undefined;
|
|
return (
|
|
<div className="node-inspector">
|
|
<div className="node-inspector__header">
|
|
<Text size="s" weight="semibold">
|
|
{TYPE_LABELS[nodeType]}
|
|
</Text>
|
|
</div>
|
|
|
|
{subtypeLabel && (
|
|
<Text size="xs" view="secondary">
|
|
{subtypeLabel}
|
|
</Text>
|
|
)}
|
|
|
|
<Text size="l" className="node-inspector__label">
|
|
{data.label}
|
|
</Text>
|
|
|
|
{data.description && (
|
|
<Text
|
|
size="s"
|
|
view="secondary"
|
|
className="node-inspector__description"
|
|
>
|
|
{data.description}
|
|
</Text>
|
|
)}
|
|
|
|
{hasMeta && (
|
|
<dl className="node-inspector__meta">
|
|
{data.responsible && (
|
|
<>
|
|
<dt>Ответственный</dt>
|
|
<dd>{data.responsible}</dd>
|
|
</>
|
|
)}
|
|
{data.duration && (
|
|
<>
|
|
<dt>Длительность</dt>
|
|
<dd>{data.duration}</dd>
|
|
</>
|
|
)}
|
|
</dl>
|
|
)}
|
|
|
|
{childCount > 0 && (
|
|
<Button
|
|
size="s"
|
|
view="ghost"
|
|
label="Открыть вложенный процесс →"
|
|
onClick={() => onEnterSubProcess(nodeId)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="node-inspector">
|
|
<div className="node-inspector__header">
|
|
<Text size="s" weight="semibold">
|
|
{TYPE_LABELS[nodeType]}
|
|
</Text>
|
|
</div>
|
|
|
|
<div className="node-inspector__toolbar">
|
|
<Button
|
|
size="s"
|
|
view="secondary"
|
|
label="Дублировать"
|
|
onClick={onDuplicate}
|
|
/>
|
|
<Button size="s" view="secondary" label="Удалить" onClick={onDelete} />
|
|
</div>
|
|
|
|
<div className="node-inspector__toolbar">
|
|
<Button
|
|
size="s"
|
|
view="secondary"
|
|
label="На передний план"
|
|
onClick={onBringToFront}
|
|
/>
|
|
<Button
|
|
size="s"
|
|
view="secondary"
|
|
label="На задний план"
|
|
onClick={onSendToBack}
|
|
/>
|
|
</div>
|
|
|
|
{nodeType === "task" && (
|
|
<div className="node-inspector__field">
|
|
<Text size="xs" view="secondary">
|
|
Тип задачи
|
|
</Text>
|
|
<div className="node-inspector__presets">
|
|
{TASK_SUBTYPES.map((subtype) => {
|
|
const Icon = TASK_SUBTYPE_ICON[subtype];
|
|
const isActive = (data.subtype ?? "none") === subtype;
|
|
return (
|
|
<button
|
|
key={subtype}
|
|
type="button"
|
|
title={TASK_SUBTYPE_LABEL[subtype]}
|
|
aria-label={TASK_SUBTYPE_LABEL[subtype]}
|
|
className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`}
|
|
onClick={() => onChange({ subtype })}
|
|
>
|
|
{Icon ?
|
|
<Icon size="s" />
|
|
: <Text size="xs">—</Text>}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{EVENT_KINDS.has(nodeType) && (
|
|
<div className="node-inspector__field">
|
|
<Text size="xs" view="secondary">
|
|
Тип триггера
|
|
</Text>
|
|
<div className="node-inspector__presets">
|
|
{EVENT_SUBTYPES_BY_KIND[
|
|
nodeType as "startEvent" | "intermediateEvent" | "endEvent"
|
|
].map((subtype: EventSubtype) => {
|
|
const Icon = EVENT_SUBTYPE_ICON[subtype];
|
|
const isActive = (data.subtype ?? "none") === subtype;
|
|
return (
|
|
<button
|
|
key={subtype}
|
|
type="button"
|
|
title={EVENT_SUBTYPE_LABEL[subtype]}
|
|
aria-label={EVENT_SUBTYPE_LABEL[subtype]}
|
|
className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`}
|
|
onClick={() => onChange({ subtype })}
|
|
>
|
|
{Icon ?
|
|
<Icon size="s" />
|
|
: <Text size="xs">—</Text>}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="node-inspector__field">
|
|
<Text size="xs" view="secondary">
|
|
Цвет фона
|
|
</Text>
|
|
<div className="node-inspector__presets">
|
|
{GRADIENT_PRESETS.map(({ name, label }) => {
|
|
const value = `var(--gradient-${name})`;
|
|
const isActive =
|
|
(data.color ?? DEFAULT_NODE_BACKGROUND[nodeType]) === value;
|
|
return (
|
|
<button
|
|
key={name}
|
|
type="button"
|
|
title={label}
|
|
aria-label={label}
|
|
className={`node-inspector__preset${isActive ? " node-inspector__preset--active" : ""}`}
|
|
style={{ background: value }}
|
|
onClick={() => onChange({ color: value })}
|
|
/>
|
|
);
|
|
})}
|
|
<button
|
|
type="button"
|
|
title="Прозрачный"
|
|
aria-label="Прозрачный"
|
|
className={`node-inspector__preset node-inspector__preset--none${
|
|
data.color === TRANSPARENT_COLOR ?
|
|
" node-inspector__preset--active"
|
|
: ""
|
|
}`}
|
|
onClick={() => onChange({ color: TRANSPARENT_COLOR })}
|
|
>
|
|
<IconCancel size="xs" />
|
|
</button>
|
|
</div>
|
|
<div ref={colorButtonRef} className="node-inspector__color-trigger">
|
|
<Button
|
|
size="s"
|
|
view="secondary"
|
|
label="Настроить цвет"
|
|
onClick={() => setColorPickerOpen(true)}
|
|
/>
|
|
</div>
|
|
{colorPickerOpen && (
|
|
<ColorPicker
|
|
model={hexModel}
|
|
value={flatColor}
|
|
onChange={(value) => onChange({ color: value })}
|
|
anchorRef={colorButtonRef}
|
|
open={colorPickerOpen}
|
|
onOpen={setColorPickerOpen}
|
|
style={{ zIndex: 100 }}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="node-inspector__field">
|
|
<Text size="xs" view="secondary">
|
|
Цвет текста
|
|
</Text>
|
|
<div className="node-inspector__presets">
|
|
{TEXT_COLOR_PRESETS.map(({ hex, label: presetLabel }) => (
|
|
<button
|
|
key={hex}
|
|
type="button"
|
|
title={presetLabel}
|
|
aria-label={presetLabel}
|
|
className={`node-inspector__preset${currentTextColor === hex ? " node-inspector__preset--active" : ""}`}
|
|
style={{ background: hex }}
|
|
onClick={() => onChange({ textColor: hex })}
|
|
/>
|
|
))}
|
|
<button
|
|
type="button"
|
|
title="Прозрачный"
|
|
aria-label="Прозрачный"
|
|
className={`node-inspector__preset node-inspector__preset--none${
|
|
currentTextColor === TRANSPARENT_COLOR ?
|
|
" node-inspector__preset--active"
|
|
: ""
|
|
}`}
|
|
onClick={() => onChange({ textColor: TRANSPARENT_COLOR })}
|
|
>
|
|
<IconCancel size="xs" />
|
|
</button>
|
|
</div>
|
|
<div ref={textColorButtonRef} className="node-inspector__color-trigger">
|
|
<Button
|
|
size="s"
|
|
view="secondary"
|
|
label="Настроить цвет"
|
|
onClick={() => setTextColorPickerOpen(true)}
|
|
/>
|
|
</div>
|
|
{textColorPickerOpen && (
|
|
<ColorPicker
|
|
model={hexModel}
|
|
value={toPickerHex(currentTextColor, defaultTextColor)}
|
|
onChange={(value) => onChange({ textColor: value })}
|
|
anchorRef={textColorButtonRef}
|
|
open={textColorPickerOpen}
|
|
onOpen={setTextColorPickerOpen}
|
|
style={{ zIndex: 100 }}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<TextField
|
|
label="Размер текста"
|
|
type="number"
|
|
size="s"
|
|
min={8}
|
|
max={32}
|
|
value={String(data.fontSize ?? DEFAULT_NODE_FONT_SIZE)}
|
|
onChange={(value) => {
|
|
const n = Number(value);
|
|
if (value && !Number.isNaN(n)) onChange({ fontSize: n });
|
|
}}
|
|
/>
|
|
|
|
<TextField
|
|
label="Название"
|
|
type="textarea"
|
|
rows={2}
|
|
size="s"
|
|
value={data.label}
|
|
onChange={(value) => onChange({ label: value ?? "" })}
|
|
/>
|
|
|
|
<TextField
|
|
label="Описание"
|
|
type="textarea"
|
|
rows={3}
|
|
size="s"
|
|
value={data.description ?? ""}
|
|
onChange={(value) => onChange({ description: value ?? "" })}
|
|
/>
|
|
|
|
{(nodeType === "task" || nodeType === "hint") && (
|
|
<TextField
|
|
label="Скругление углов"
|
|
type="number"
|
|
size="s"
|
|
min={0}
|
|
max={25}
|
|
value={String(data.borderRadius ?? DEFAULT_TASK_BORDER_RADIUS)}
|
|
onChange={(value) => {
|
|
const n = Number(value);
|
|
if (value && !Number.isNaN(n)) onChange({ borderRadius: n });
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{nodeType === "task" && (
|
|
<>
|
|
<TextField
|
|
label="Ответственный"
|
|
size="s"
|
|
value={data.responsible ?? ""}
|
|
onChange={(value) => onChange({ responsible: value ?? "" })}
|
|
/>
|
|
|
|
<TextField
|
|
label="Длительность"
|
|
size="s"
|
|
value={data.duration ?? ""}
|
|
onChange={(value) => onChange({ duration: value ?? "" })}
|
|
/>
|
|
|
|
<Button
|
|
size="s"
|
|
view="ghost"
|
|
label="Войти в под-процесс →"
|
|
onClick={() => onEnterSubProcess(nodeId)}
|
|
/>
|
|
<Text size="xs" view="secondary" className="node-inspector__hint">
|
|
{childCount > 0 ?
|
|
`Внутри уже ${childCount} узл(ов).`
|
|
: "Пока пусто — зайдя внутрь, можно добавить узлы детального разбора этого шага."
|
|
}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|