From 4852e5596416e3a13a5f76ee6e227df5f23ec945 Mon Sep 17 00:00:00 2001 From: NikitaKalitov Date: Wed, 22 Jul 2026 09:42:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=B8=D0=B2=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BA=20=D1=81=D1=82=D0=B8=D0=BB=D1=83=20consta=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- palette.css | 25 ++++++ src/DiagramEditor.tsx | 56 ++++++++++++- src/EdgeInspector.tsx | 191 +++++++++++++++++++++++++++++++++++++++++- src/NodeInspector.tsx | 165 +++++++++++++++++++++++++++++------- src/diagram.css | 51 ++++++----- src/nodes.tsx | 47 ++++++++--- src/types.ts | 18 +++- 7 files changed, 478 insertions(+), 75 deletions(-) diff --git a/palette.css b/palette.css index 52f85ce..c04e229 100644 --- a/palette.css +++ b/palette.css @@ -106,4 +106,29 @@ --accent-violet: rgb(145, 82, 230); --accent-lavender: rgb(157, 154, 247); --accent-pink: rgb(232, 143, 219); + + /* ============================================================ + ЦВЕТА ТЕКСТА + ============================================================ */ + --text-color-blue: rgb(0, 121, 194); + --text-color-dark: rgb(82, 90, 100); + --text-color-gray: rgb(146, 154, 164); + --text-color-white: rgb(255, 255, 255); + + /* ============================================================ + ОСНОВНЫЕ ЦВЕТА ДЛЯ МЕЛКОЙ ГРАФИКИ, ЛИНИЙ + Два тональных ряда: синий (от --text-color-blue) и серый + (от --text-color-gray до белого) + ============================================================ */ + --graphic-blue-1: rgb(0, 121, 194); /* = --text-color-blue */ + --graphic-blue-2: rgb(67, 183, 247); + --graphic-blue-3: rgb(137, 207, 255); + --graphic-blue-4: rgb(117, 170, 197); + --graphic-blue-5: rgb(143, 215, 228); + --graphic-blue-6: rgb(0, 65, 129); + + --graphic-gray-1: rgb(146, 154, 164); /* = --text-color-gray */ + --graphic-gray-2: rgb(206, 210, 214); + --graphic-gray-3: rgb(233, 235, 237); + --graphic-gray-4: rgb(255, 255, 255); /* = --text-color-white */ } diff --git a/src/DiagramEditor.tsx b/src/DiagramEditor.tsx index cad2177..eba9772 100644 --- a/src/DiagramEditor.tsx +++ b/src/DiagramEditor.tsx @@ -32,7 +32,7 @@ import { nodeTypes, NodesWithChildrenContext, ModeContext } from './nodes'; import { NodeInspector } from './NodeInspector'; import { EdgeInspector } from './EdgeInspector'; import { debounce, serializeDocument } from './serialize'; -import type { DiagramDocument, DiagramNodeData, NodeKind } from './types'; +import type { DiagramDocument, DiagramEdgeData, DiagramNodeData, EdgeLineStyle, NodeKind } from './types'; import './diagram.css'; interface DiagramEditorProps { @@ -42,6 +42,16 @@ interface DiagramEditorProps { const DEFAULT_EDGE_TYPE = 'smoothstep'; const DEFAULT_MARKER_END = { type: MarkerType.ArrowClosed }; +const DEFAULT_EDGE_STROKE_WIDTH = 1; +const DEFAULT_EDGE_BORDER_RADIUS = 5; // matches getSmoothStepPath's own default +const DEFAULT_EDGE_COLOR = '#b1b1b7'; // xyflow's own default edge color +const DEFAULT_EDGE_TEXT_COLOR = '#FFFFFF'; +const DEFAULT_EDGE_FONT_SIZE = 11; +const EDGE_DASH_ARRAY: Record = { + solid: undefined, + dashed: '8 4', + dotted: '1.5 4', +}; const MAX_HISTORY = 50; const TYPING_HISTORY_THROTTLE_MS = 1000; @@ -240,7 +250,32 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const visibleEdges = useMemo(() => { const visibleIds = new Set(visibleNodes.map((node) => node.id)); - return edges.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target)); + return edges + .filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target)) + .map((edge) => { + const edgeData = edge.data as DiagramEdgeData | undefined; + const edgeColor = edgeData?.color ?? DEFAULT_EDGE_COLOR; + return { + ...edge, + style: { + ...edge.style, + stroke: edgeColor, + strokeWidth: edgeData?.strokeWidth ?? DEFAULT_EDGE_STROKE_WIDTH, + strokeDasharray: EDGE_DASH_ARRAY[edgeData?.lineStyle ?? 'solid'], + }, + pathOptions: { borderRadius: edgeData?.borderRadius ?? DEFAULT_EDGE_BORDER_RADIUS }, + markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor }, + // Label renders as its own small chip — same color as the line, configurable text color. + labelBgStyle: { fill: edgeColor }, + labelStyle: { + fill: edgeData?.textColor ?? DEFAULT_EDGE_TEXT_COLOR, + fontWeight: 600, + fontSize: edgeData?.fontSize ?? DEFAULT_EDGE_FONT_SIZE, + }, + labelBgPadding: [6, 4] as [number, number], + labelBgBorderRadius: 4, + }; + }); }, [edges, visibleNodes]); const breadcrumbPath = useMemo(() => { @@ -417,6 +452,19 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { [selectedEdgeId, nodes, edges, emitChange, pushHistoryThrottled], ); + const updateSelectedEdgeData = useCallback( + (patch: Partial) => { + if (!selectedEdgeId) return; + pushHistoryThrottled(nodes, edges); + const next = edges.map((edge) => + edge.id === selectedEdgeId ? { ...edge, data: { ...edge.data, ...patch } } : edge, + ); + setEdges(next); + emitChange(nodes, next); + }, + [selectedEdgeId, nodes, edges, emitChange, pushHistoryThrottled], + ); + const getViewportCenter = useCallback(() => { const rect = canvasWrapperRef.current?.getBoundingClientRect(); if (!rect) return { x: 0, y: 0 }; @@ -696,7 +744,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { void; + data: DiagramEdgeData; + onLabelChange: (label: string) => void; + onDataChange: (patch: Partial) => void; onDelete: () => void; onBringToFront: () => void; onSendToBack: () => void; @@ -14,11 +73,21 @@ interface EdgeInspectorProps { export function EdgeInspector({ edgeId, label, - onChange, + data, + onLabelChange, + onDataChange, onDelete, onBringToFront, onSendToBack, }: EdgeInspectorProps) { + const [colorPickerOpen, setColorPickerOpen] = useState(false); + const colorButtonRef = useRef(null) as RefObject; + const currentColor = data.color ?? DEFAULT_EDGE_COLOR; + + const [textColorPickerOpen, setTextColorPickerOpen] = useState(false); + const textColorButtonRef = useRef(null) as RefObject; + const currentTextColor = data.textColor ?? DEFAULT_TEXT_COLOR; + return (
@@ -39,7 +108,123 @@ export function EdgeInspector({
- onChange(value ?? '')} /> + onLabelChange(value ?? '')} /> + +
+ + Цвет линии + +
+ {LINE_COLOR_PRESETS.map(({ hex, label: presetLabel }) => ( +
+
+
+ {colorPickerOpen && ( + onDataChange({ color: value })} + anchorRef={colorButtonRef} + open={colorPickerOpen} + onOpen={setColorPickerOpen} + /> + )} +
+ +
+ + Цвет текста + +
+ {TEXT_COLOR_PRESETS.map(({ hex, label: presetLabel }) => ( +
+
+
+ {textColorPickerOpen && ( + onDataChange({ textColor: value })} + anchorRef={textColorButtonRef} + open={textColorPickerOpen} + onOpen={setTextColorPickerOpen} + /> + )} +
+ + { + const n = Number(value); + if (value && !Number.isNaN(n)) onDataChange({ fontSize: n }); + }} + /> + + { + const n = Number(value); + if (value && !Number.isNaN(n)) onDataChange({ strokeWidth: n }); + }} + /> + +
+ + Стиль линии + + item.style === (data.lineStyle ?? 'solid')) ?? null} + onChange={(item) => onDataChange({ lineStyle: item.style })} + /> +
+ + { + const n = Number(value); + if (value && !Number.isNaN(n)) onDataChange({ borderRadius: n }); + }} + />
); } diff --git a/src/NodeInspector.tsx b/src/NodeInspector.tsx index 77dbb4d..f6028b6 100644 --- a/src/NodeInspector.tsx +++ b/src/NodeInspector.tsx @@ -1,26 +1,50 @@ 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 { ColorPicker, hexModel } from '@consta/uikit/ColorPicker'; import { Text } from '@consta/uikit/Text'; -import { DEFAULT_NODE_COLOR } from './nodes'; +import { + DEFAULT_CAPTION_TEXT_COLOR, + DEFAULT_NODE_BACKGROUND, + DEFAULT_NODE_COLOR, + DEFAULT_NODE_FONT_SIZE, + DEFAULT_NODE_TEXT_COLOR, + DEFAULT_TASK_BORDER_RADIUS, +} 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', +// 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 = { @@ -62,7 +86,18 @@ export function NodeInspector({ }: NodeInspectorProps) { const [colorPickerOpen, setColorPickerOpen] = useState(false); // Consta's ref props still use the pre-React-19 non-nullable RefObject shape. - const colorMarkerRef = useRef(null) as RefObject; + const colorButtonRef = useRef(null) as RefObject; + // data.color can be a `var(--gradient-x)` reference from the preset row — + // hexModel tries to parse that as a hex color and produces NaN, so the + // hex-only ColorControl/ColorPicker always need a real flat hex fallback. + const flatColor = data.color && !data.color.startsWith('var(') ? data.color : DEFAULT_NODE_COLOR[nodeType]; + + const [textColorPickerOpen, setTextColorPickerOpen] = useState(false); + const textColorButtonRef = useRef(null) as RefObject; + // 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); @@ -138,28 +173,83 @@ export function NodeInspector({ Цвет фона - onChange({ color: value })} - size="s" - onClick={() => setColorPickerOpen(true)} - /> +
+ {GRADIENT_PRESETS.map(({ name, label }) => { + const value = `var(--gradient-${name})`; + const isActive = (data.color ?? DEFAULT_NODE_BACKGROUND[nodeType]) === value; + return ( +
+
+
{colorPickerOpen && ( onChange({ color: value })} - anchorRef={colorMarkerRef} + anchorRef={colorButtonRef} open={colorPickerOpen} onOpen={setColorPickerOpen} - palette={NODE_COLOR_PRESETS} - paletteTitle="Пресеты" /> )} +
+ + Цвет текста + +
+ {TEXT_COLOR_PRESETS.map(({ hex, label: presetLabel }) => ( +
+
+
+ {textColorPickerOpen && ( + onChange({ textColor: value })} + anchorRef={textColorButtonRef} + open={textColorPickerOpen} + onOpen={setTextColorPickerOpen} + /> + )} +
+ + { + const n = Number(value); + if (value && !Number.isNaN(n)) onChange({ fontSize: n }); + }} + /> + onChange({ duration: value ?? '' })} /> + {nodeType === 'task' && ( + { + const n = Number(value); + if (value && !Number.isNaN(n)) onChange({ borderRadius: n }); + }} + /> + )} +