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

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