Начал переводить на consta UI

This commit is contained in:
NikitaKalitov
2026-07-20 16:42:34 +03:00
parent d823b3df47
commit 52e28e8dc6
10 changed files with 687 additions and 257 deletions
+6 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { Theme, presetGpnDefault } from '@consta/uikit/Theme';
import { DiagramEditor } from './DiagramEditor';
import { createDevAdapter } from './host/devAdapter';
import { createBitrixAdapter } from './host/bitrixAdapter';
@@ -83,7 +84,11 @@ function App() {
return <p style={{ padding: 16, fontFamily: 'system-ui, sans-serif' }}>Загрузка диаграммы…</p>;
}
return <DiagramEditor initialValue={doc} onChange={handleChange} />;
return (
<Theme preset={presetGpnDefault} style={{ height: '100%' }}>
<DiagramEditor initialValue={doc} onChange={handleChange} />
</Theme>
);
}
export default App;
+29 -24
View File
@@ -25,6 +25,9 @@ import {
type EdgeMouseHandler,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Button } from '@consta/uikit/Button';
import { ChoiceGroup } from '@consta/uikit/ChoiceGroup';
import { Breadcrumbs } from '@consta/uikit/Breadcrumbs';
import { nodeTypes, NodesWithChildrenContext, ModeContext } from './nodes';
import { NodeInspector } from './NodeInspector';
import { EdgeInspector } from './EdgeInspector';
@@ -52,6 +55,13 @@ const NODE_KINDS: { kind: NodeKind; label: string }[] = [
{ kind: 'inclusiveGateway', label: '+ Вкл. шлюз' },
];
// Stable references — ChoiceGroup's controlled `value` is compared against
// entries in `items` by identity, so this can't be recreated on every render.
const MODE_ITEMS: { label: string; mode: 'edit' | 'view' }[] = [
{ label: 'Просмотр', mode: 'view' },
{ label: 'Редактирование', mode: 'edit' },
];
const DEFAULT_NODE_SIZE: Record<NodeKind, { width: number; height: number }> = {
task: { width: 140, height: 50 },
startEvent: { width: 48, height: 48 },
@@ -634,38 +644,33 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
</Controls>
{mode === 'edit' && <MiniMap />}
<Panel position="top-right" className="diagram-mode-toggle">
<button type="button" disabled={mode === 'view'} onClick={() => setMode('view')}>
Просмотр
</button>
<button type="button" disabled={mode === 'edit'} onClick={() => setMode('edit')}>
Редактирование
</button>
<ChoiceGroup
size="xs"
name="diagram-mode"
items={MODE_ITEMS}
value={MODE_ITEMS.find((item) => item.mode === mode) ?? null}
onChange={(item) => setMode(item.mode)}
/>
</Panel>
{mode === 'edit' && (
<Panel position="top-left" className="diagram-toolbar">
{NODE_KINDS.map(({ kind, label }) => (
<button key={kind} type="button" onClick={() => addNode(kind)}>
{label}
</button>
<Button key={kind} size="xs" view="secondary" label={label} onClick={() => addNode(kind)} />
))}
</Panel>
)}
<Panel position="top-center" className="diagram-breadcrumbs">
<button type="button" disabled={!focusNodeId} onClick={() => goToLevel(null)}>
Корень
</button>
{breadcrumbPath.map((node, index) => (
<span key={node.id}>
{' / '}
<button
type="button"
disabled={index === breadcrumbPath.length - 1}
onClick={() => goToLevel(node.id)}
>
{(node.data as DiagramNodeData).label}
</button>
</span>
))}
<Breadcrumbs
size="xs"
items={[
{ label: 'Корень', onClick: focusNodeId ? () => goToLevel(null) : undefined },
...breadcrumbPath.map((node, index) => ({
label: (node.data as DiagramNodeData).label,
onClick: index === breadcrumbPath.length - 1 ? undefined : () => goToLevel(node.id),
})),
]}
getItemOnClick={(item) => item.onClick}
/>
</Panel>
</ReactFlow>
</ModeContext.Provider>
+14 -15
View File
@@ -1,3 +1,7 @@
import { Button } from '@consta/uikit/Button';
import { TextField } from '@consta/uikit/TextField';
import { Text } from '@consta/uikit/Text';
interface EdgeInspectorProps {
edgeId: string;
label: string;
@@ -18,29 +22,24 @@ export function EdgeInspector({
return (
<div className="node-inspector">
<div className="node-inspector__header">
<span className="node-inspector__type">Связь</span>
<code className="node-inspector__id">{edgeId}</code>
<Text size="s" weight="semibold">
Связь
</Text>
<Text size="xs" view="secondary" font="mono">
{edgeId}
</Text>
</div>
<div className="node-inspector__toolbar">
<button type="button" onClick={onDelete}>
Удалить
</button>
<Button size="s" view="secondary" label="Удалить" onClick={onDelete} />
</div>
<div className="node-inspector__toolbar">
<button type="button" onClick={onBringToFront}>
На передний план
</button>
<button type="button" onClick={onSendToBack}>
На задний план
</button>
<Button size="s" view="secondary" label="На передний план" onClick={onBringToFront} />
<Button size="s" view="secondary" label="На задний план" onClick={onSendToBack} />
</div>
<label className="node-inspector__field">
Подпись
<input type="text" value={label} onChange={(e) => onChange(e.target.value)} />
</label>
<TextField label="Подпись" size="s" value={label} onChange={(value) => onChange(value ?? '')} />
</div>
);
}
+107 -63
View File
@@ -1,6 +1,28 @@
import { useRef, useState, type RefObject } from 'react';
import { Button } from '@consta/uikit/Button';
import { TextField } from '@consta/uikit/TextField';
import { ColorControl, ColorPicker, hexModel } from '@consta/uikit/ColorPicker';
import { Text } from '@consta/uikit/Text';
import { DEFAULT_NODE_COLOR } from './nodes';
import type { DiagramNodeData, NodeKind } from './types';
// "Дополнительные цвета" from palette.css — the section the palette itself
// marks as meant for single-color accents (as opposed to the gradients used
// for shape defaults, which a flat-hex picker can't represent).
const NODE_COLOR_PRESETS = [
'#B5309A',
'#EC5665',
'#FFAB66',
'#C2DC5B',
'#32CA88',
'#22926D',
'#0EBAD5',
'#4EE7F4',
'#9152E6',
'#9D9AF7',
'#E88FDB',
];
const TYPE_LABELS: Record<NodeKind, string> = {
task: 'Задача',
startEvent: 'Начальное событие',
@@ -38,17 +60,29 @@ export function NodeInspector({
onBringToFront,
onSendToBack,
}: NodeInspectorProps) {
const [colorPickerOpen, setColorPickerOpen] = useState(false);
// Consta's ref props still use the pre-React-19 non-nullable RefObject shape.
const colorMarkerRef = useRef<HTMLButtonElement>(null) as RefObject<HTMLButtonElement>;
if (mode === 'view') {
const hasMeta = Boolean(data.responsible || data.duration);
return (
<div className="node-inspector">
<div className="node-inspector__header">
<span className="node-inspector__type">{TYPE_LABELS[nodeType]}</span>
<Text size="s" weight="semibold">
{TYPE_LABELS[nodeType]}
</Text>
</div>
<h3 className="node-inspector__label">{data.label}</h3>
<Text size="l" className="node-inspector__label">
{data.label}
</Text>
{data.description && <p className="node-inspector__description">{data.description}</p>}
{data.description && (
<Text size="s" view="secondary" className="node-inspector__description">
{data.description}
</Text>
)}
{hasMeta && (
<dl className="node-inspector__meta">
@@ -68,9 +102,12 @@ export function NodeInspector({
)}
{childCount > 0 && (
<button type="button" className="node-inspector__action" onClick={() => onEnterSubProcess(nodeId)}>
Открыть вложенный процесс →
</button>
<Button
size="s"
view="ghost"
label="Открыть вложенный процесс →"
onClick={() => onEnterSubProcess(nodeId)}
/>
)}
</div>
);
@@ -79,81 +116,88 @@ export function NodeInspector({
return (
<div className="node-inspector">
<div className="node-inspector__header">
<span className="node-inspector__type">{TYPE_LABELS[nodeType]}</span>
<code className="node-inspector__id">{nodeId}</code>
<Text size="s" weight="semibold">
{TYPE_LABELS[nodeType]}
</Text>
<Text size="xs" view="secondary" font="mono">
{nodeId}
</Text>
</div>
<div className="node-inspector__toolbar">
<button type="button" onClick={onDuplicate}>
Дублировать
</button>
<button type="button" onClick={onDelete}>
Удалить
</button>
<Button size="s" view="secondary" label="Дублировать" onClick={onDuplicate} />
<Button size="s" view="secondary" label="Удалить" onClick={onDelete} />
</div>
<div className="node-inspector__toolbar">
<button type="button" onClick={onBringToFront}>
На передний план
</button>
<button type="button" onClick={onSendToBack}>
На задний план
</button>
<Button size="s" view="secondary" label="На передний план" onClick={onBringToFront} />
<Button size="s" view="secondary" label="На задний план" onClick={onSendToBack} />
</div>
<label className="node-inspector__field node-inspector__field--color">
Цвет фона
<input
type="color"
<div className="node-inspector__field">
<Text size="xs" view="secondary">
Цвет фона
</Text>
<ColorControl
markerRef={colorMarkerRef}
model={hexModel}
value={data.color ?? DEFAULT_NODE_COLOR[nodeType]}
onChange={(e) => onChange({ color: e.target.value })}
onChange={(value) => onChange({ color: value })}
size="s"
onClick={() => setColorPickerOpen(true)}
/>
</label>
{colorPickerOpen && (
<ColorPicker
model={hexModel}
value={data.color ?? DEFAULT_NODE_COLOR[nodeType]}
onChange={(value) => onChange({ color: value })}
anchorRef={colorMarkerRef}
open={colorPickerOpen}
onOpen={setColorPickerOpen}
palette={NODE_COLOR_PRESETS}
paletteTitle="Пресеты"
/>
)}
</div>
<label className="node-inspector__field">
Название
<textarea
rows={2}
value={data.label}
onChange={(e) => onChange({ label: e.target.value })}
/>
</label>
<TextField
label="Название"
type="textarea"
rows={2}
size="s"
value={data.label}
onChange={(value) => onChange({ label: value ?? '' })}
/>
<label className="node-inspector__field">
Описание
<textarea
rows={3}
value={data.description ?? ''}
onChange={(e) => onChange({ description: e.target.value })}
/>
</label>
<TextField
label="Описание"
type="textarea"
rows={3}
size="s"
value={data.description ?? ''}
onChange={(value) => onChange({ description: value ?? '' })}
/>
<label className="node-inspector__field">
Ответственный
<input
type="text"
value={data.responsible ?? ''}
onChange={(e) => onChange({ responsible: e.target.value })}
/>
</label>
<TextField
label="Ответственный"
size="s"
value={data.responsible ?? ''}
onChange={(value) => onChange({ responsible: value ?? '' })}
/>
<label className="node-inspector__field">
Длительность
<input
type="text"
value={data.duration ?? ''}
onChange={(e) => onChange({ duration: e.target.value })}
/>
</label>
<TextField
label="Длительность"
size="s"
value={data.duration ?? ''}
onChange={(value) => onChange({ duration: value ?? '' })}
/>
<button type="button" className="node-inspector__action" onClick={() => onEnterSubProcess(nodeId)}>
Войти в под-процесс →
</button>
<p className="node-inspector__hint">
<Button size="s" view="ghost" label="Войти в под-процесс →" onClick={() => onEnterSubProcess(nodeId)} />
<Text size="xs" view="secondary" className="node-inspector__hint">
{childCount > 0
? `Внутри уже ${childCount} узл(ов).`
: 'Пока пусто — зайдя внутрь, можно добавить узлы детального разбора этого шага.'}
</p>
</Text>
</div>
);
}
+3 -140
View File
@@ -16,20 +16,6 @@
max-width: 280px;
}
.diagram-toolbar button {
font: inherit;
padding: 6px 10px;
border: 1px solid #999;
border-radius: 4px;
background: canvas;
color: canvastext;
cursor: pointer;
}
.diagram-toolbar button:hover {
background: color-mix(in srgb, canvastext 8%, canvas);
}
/*
* opacity/pointer-events, not display:none — hiding via display collapses the
* handle to 0x0, and xyflow computes edge paths from the handle's actual
@@ -57,56 +43,6 @@
z-index: 1000;
}
.diagram-mode-toggle {
display: flex;
border: 1px solid #999;
border-radius: 4px;
overflow: hidden;
}
.diagram-mode-toggle button {
font: inherit;
padding: 6px 10px;
border: none;
background: canvas;
color: canvastext;
cursor: pointer;
}
.diagram-mode-toggle button:disabled {
background: color-mix(in srgb, canvastext 12%, canvas);
font-weight: 600;
cursor: default;
}
.diagram-mode-toggle button:first-child {
border-right: 1px solid #999;
}
.diagram-breadcrumbs {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
}
.diagram-breadcrumbs button {
font: inherit;
padding: 2px 6px;
border: none;
background: none;
color: inherit;
cursor: pointer;
text-decoration: underline;
}
.diagram-breadcrumbs button:disabled {
text-decoration: none;
cursor: default;
opacity: 0.9;
font-weight: 600;
}
.diagram-node {
position: relative;
width: 100%;
@@ -125,6 +61,7 @@
}
.diagram-node__label {
color: white;
white-space: pre-wrap;
overflow-wrap: break-word;
}
@@ -205,6 +142,7 @@
.diagram-node__icon {
position: relative;
display: inline-block;
color: white;
font-size: 18px;
font-weight: 700;
line-height: 1;
@@ -257,89 +195,14 @@
gap: 8px;
}
.node-inspector__toolbar button {
font: inherit;
padding: 6px 10px;
border: 1px solid #999;
border-radius: 4px;
background: canvas;
color: canvastext;
cursor: pointer;
}
.node-inspector__toolbar button:hover {
background: color-mix(in srgb, canvastext 8%, canvas);
}
.node-inspector__type {
font-weight: 600;
}
.node-inspector__id {
font-size: 11px;
opacity: 0.6;
}
.node-inspector__field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.node-inspector__field input,
.node-inspector__field textarea,
.node-inspector__field select {
font: inherit;
padding: 6px 8px;
border: 1px solid #999;
border-radius: 4px;
background: canvas;
color: canvastext;
}
.node-inspector__field--color {
flex-direction: row;
align-items: center;
gap: 8px;
}
.node-inspector__field--color input[type='color'] {
width: 36px;
height: 28px;
padding: 2px;
cursor: pointer;
}
.node-inspector__action {
font: inherit;
padding: 8px 10px;
border: 1px solid #999;
border-radius: 4px;
background: canvas;
color: canvastext;
cursor: pointer;
align-self: flex-start;
}
.node-inspector__action:hover {
background: color-mix(in srgb, canvastext 8%, canvas);
}
.node-inspector__hint {
font-size: 11px;
opacity: 0.7;
}
.node-inspector__label {
margin: 0;
font-size: 15px;
white-space: pre-wrap;
}
.node-inspector__label,
.node-inspector__description {
margin: 0;
font-size: 13px;
white-space: pre-wrap;
}
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '../palette.css'
import './index.css'
import App from './App.tsx'
+25 -10
View File
@@ -10,14 +10,29 @@ 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)',
};
// 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: '#eef2ff',
startEvent: '#f0fdf4',
intermediateEvent: '#fefce8',
endEvent: '#fef2f2',
exclusiveGateway: '#fff7ed',
parallelGateway: '#faf5ff',
inclusiveGateway: '#f0fdfa',
task: '#0790CB',
startEvent: '#2CB587',
intermediateEvent: '#FF8E58',
endEvent: '#E13042',
exclusiveGateway: '#A12C7E',
parallelGateway: '#1BD3F7',
inclusiveGateway: '#BBBD39',
};
const HANDLE_POSITIONS: { id: string; position: Position }[] = [
@@ -76,7 +91,7 @@ function ShapeResizer({
function TaskNode({ id, data, selected }: NodeProps<ShapeNode>) {
return (
<div className="diagram-node diagram-node--task" style={{ background: data.color ?? DEFAULT_NODE_COLOR.task }}>
<div className="diagram-node diagram-node--task" style={{ background: data.color ?? DEFAULT_NODE_BACKGROUND.task }}>
<ShapeResizer selected={selected} minWidth={70} minHeight={32} keepAspectRatio={false} />
<ShapeHandles />
<SubProcessBadge id={id} />
@@ -96,7 +111,7 @@ function EventNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
return (
<div
className={`diagram-node diagram-node--circle ${EVENT_BORDER_CLASS[kind]}`}
style={{ background: data.color ?? DEFAULT_NODE_COLOR[kind] }}
style={{ background: data.color ?? DEFAULT_NODE_BACKGROUND[kind] }}
>
<ShapeResizer selected={selected} minWidth={24} minHeight={24} />
<ShapeHandles />
@@ -130,7 +145,7 @@ function GatewayNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
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_COLOR[kind] }} />
<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>