Расширил возможности и поправил границы фигур

This commit is contained in:
NikitaKalitov
2026-08-28 09:22:12 +03:00
parent 762f43dad4
commit 6a4f463bf2
4 changed files with 271 additions and 83 deletions
+116 -49
View File
@@ -1,62 +1,64 @@
import { useRef, useState, type RefObject } from 'react'; import { useRef, useState, type RefObject } from "react";
import { Button } from '@consta/uikit/Button'; import { Button } from "@consta/uikit/Button";
import { TextField } from '@consta/uikit/TextField'; import { TextField } from "@consta/uikit/TextField";
import { ChoiceGroup } from '@consta/uikit/ChoiceGroup'; import { ChoiceGroup } from "@consta/uikit/ChoiceGroup";
import { ColorPicker, hexModel } from '@consta/uikit/ColorPicker'; import { ColorPicker, hexModel } from "@consta/uikit/ColorPicker";
import { Text } from '@consta/uikit/Text'; import { Text } from "@consta/uikit/Text";
import type { DiagramEdgeData, EdgeLineStyle } from './types'; import { IconCancel } from "@consta/icons/IconCancel";
import { TRANSPARENT_COLOR, toPickerHex } from "./colors";
import type { DiagramEdgeData, EdgeLineStyle } from "./types";
const LINE_STYLE_ITEMS: { label: string; style: EdgeLineStyle }[] = [ const LINE_STYLE_ITEMS: { label: string; style: EdgeLineStyle }[] = [
{ label: 'Сплошная', style: 'solid' }, { label: "Сплошная", style: "solid" },
{ label: 'Пунктирная', style: 'dashed' }, { label: "Пунктирная", style: "dashed" },
{ label: 'Точечная', style: 'dotted' }, { label: "Точечная", style: "dotted" },
]; ];
// "Основные цвета для мелкой графики, линий" — two tonal rows (blue, gray) // "Основные цвета для мелкой графики, линий" — two tonal rows (blue, gray)
// meant specifically for lines, shown first. // meant specifically for lines, shown first.
const GRAPHIC_LINE_PRESETS: { hex: string; label: string }[] = [ const GRAPHIC_LINE_PRESETS: { hex: string; label: string }[] = [
{ hex: '#0079C2', label: 'Синий 1' }, { hex: "#0079C2", label: "Синий 1" },
{ hex: '#43B7F7', label: 'Синий 2' }, { hex: "#43B7F7", label: "Синий 2" },
{ hex: '#89CFFF', label: 'Синий 3' }, { hex: "#89CFFF", label: "Синий 3" },
{ hex: '#75AAC5', label: 'Синий 4' }, { hex: "#75AAC5", label: "Синий 4" },
{ hex: '#8FD7E4', label: 'Синий 5' }, { hex: "#8FD7E4", label: "Синий 5" },
{ hex: '#004181', label: 'Синий 6' }, { hex: "#004181", label: "Синий 6" },
{ hex: '#929AA4', label: 'Серый 1' }, { hex: "#929AA4", label: "Серый 1" },
{ hex: '#CED2D6', label: 'Серый 2' }, { hex: "#CED2D6", label: "Серый 2" },
{ hex: '#E9EBED', label: 'Серый 3' }, { hex: "#E9EBED", label: "Серый 3" },
{ hex: '#FFFFFF', label: 'Серый 4' }, { hex: "#FFFFFF", label: "Серый 4" },
]; ];
// "Дополнительные цвета" — the section palette.css marks as meant for // "Дополнительные цвета" — the section palette.css marks as meant for
// lines/small graphics too (single accents), shown after the row above. // lines/small graphics too (single accents), shown after the row above.
const ACCENT_LINE_PRESETS: { hex: string; label: string }[] = [ const ACCENT_LINE_PRESETS: { hex: string; label: string }[] = [
{ hex: '#B5309A', label: 'Пурпурный' }, { hex: "#B5309A", label: "Пурпурный" },
{ hex: '#EC5665', label: 'Красный' }, { hex: "#EC5665", label: "Красный" },
{ hex: '#FFAB66', label: 'Оранжевый' }, { hex: "#FFAB66", label: "Оранжевый" },
{ hex: '#C2DC5B', label: 'Оливковый' }, { hex: "#C2DC5B", label: "Оливковый" },
{ hex: '#32CA88', label: 'Зелёный' }, { hex: "#32CA88", label: "Зелёный" },
{ hex: '#22926D', label: 'Тёмно-зелёный' }, { hex: "#22926D", label: "Тёмно-зелёный" },
{ hex: '#0EBAD5', label: 'Синий' }, { hex: "#0EBAD5", label: "Синий" },
{ hex: '#4EE7F4', label: 'Циан' }, { hex: "#4EE7F4", label: "Циан" },
{ hex: '#9152E6', label: 'Виолет' }, { hex: "#9152E6", label: "Виолет" },
{ hex: '#9D9AF7', label: 'Лавандовый' }, { hex: "#9D9AF7", label: "Лавандовый" },
{ hex: '#E88FDB', label: 'Розовый' }, { hex: "#E88FDB", label: "Розовый" },
]; ];
const LINE_COLOR_PRESETS = [...GRAPHIC_LINE_PRESETS, ...ACCENT_LINE_PRESETS]; const LINE_COLOR_PRESETS = [...GRAPHIC_LINE_PRESETS, ...ACCENT_LINE_PRESETS];
// "Цвета текста" from palette.css. // "Цвета текста" from palette.css.
const TEXT_COLOR_PRESETS: { hex: string; label: string }[] = [ const TEXT_COLOR_PRESETS: { hex: string; label: string }[] = [
{ hex: '#0079C2', label: 'Синий' }, { hex: "#0079C2", label: "Синий" },
{ hex: '#525A64', label: 'Тёмный' }, { hex: "#525A64", label: "Тёмный" },
{ hex: '#929AA4', label: 'Серый' }, { hex: "#929AA4", label: "Серый" },
{ hex: '#FFFFFF', label: 'Белый' }, { hex: "#FFFFFF", label: "Белый" },
]; ];
const DEFAULT_STROKE_WIDTH = 1; const DEFAULT_STROKE_WIDTH = 1;
const DEFAULT_BORDER_RADIUS = 5; const DEFAULT_BORDER_RADIUS = 5;
const DEFAULT_EDGE_COLOR = '#b1b1b7'; const DEFAULT_EDGE_COLOR = "#b1b1b7";
const DEFAULT_TEXT_COLOR = '#FFFFFF'; const DEFAULT_TEXT_COLOR = "#FFFFFF";
const DEFAULT_FONT_SIZE = 11; const DEFAULT_FONT_SIZE = 11;
interface EdgeInspectorProps { interface EdgeInspectorProps {
@@ -81,11 +83,15 @@ export function EdgeInspector({
onSendToBack, onSendToBack,
}: EdgeInspectorProps) { }: EdgeInspectorProps) {
const [colorPickerOpen, setColorPickerOpen] = useState(false); const [colorPickerOpen, setColorPickerOpen] = useState(false);
const colorButtonRef = useRef<HTMLDivElement>(null) as RefObject<HTMLDivElement>; const colorButtonRef = useRef<HTMLDivElement>(
null,
) as RefObject<HTMLDivElement>;
const currentColor = data.color ?? DEFAULT_EDGE_COLOR; const currentColor = data.color ?? DEFAULT_EDGE_COLOR;
const [textColorPickerOpen, setTextColorPickerOpen] = useState(false); const [textColorPickerOpen, setTextColorPickerOpen] = useState(false);
const textColorButtonRef = useRef<HTMLDivElement>(null) as RefObject<HTMLDivElement>; const textColorButtonRef = useRef<HTMLDivElement>(
null,
) as RefObject<HTMLDivElement>;
const currentTextColor = data.textColor ?? DEFAULT_TEXT_COLOR; const currentTextColor = data.textColor ?? DEFAULT_TEXT_COLOR;
return ( return (
@@ -101,11 +107,26 @@ export function EdgeInspector({
</div> </div>
<div className="node-inspector__toolbar"> <div className="node-inspector__toolbar">
<Button size="s" view="secondary" label="На передний план" onClick={onBringToFront} /> <Button
<Button size="s" view="secondary" label="На задний план" onClick={onSendToBack} /> size="s"
view="secondary"
label="На передний план"
onClick={onBringToFront}
/>
<Button
size="s"
view="secondary"
label="На задний план"
onClick={onSendToBack}
/>
</div> </div>
<TextField label="Подпись" size="s" value={label} onChange={(value) => onLabelChange(value ?? '')} /> <TextField
label="Подпись"
size="s"
value={label}
onChange={(value) => onLabelChange(value ?? "")}
/>
<div className="node-inspector__field"> <div className="node-inspector__field">
<Text size="xs" view="secondary"> <Text size="xs" view="secondary">
@@ -118,23 +139,46 @@ export function EdgeInspector({
type="button" type="button"
title={presetLabel} title={presetLabel}
aria-label={presetLabel} aria-label={presetLabel}
className={`node-inspector__preset${currentColor === hex ? ' node-inspector__preset--active' : ''}`} className={`node-inspector__preset${currentColor === hex ? " node-inspector__preset--active" : ""}`}
style={{ background: hex }} style={{ background: hex }}
onClick={() => onDataChange({ color: hex })} onClick={() => onDataChange({ color: hex })}
/> />
))} ))}
{/*
Цвет линии заодно красит стрелку и подложку подписи (см.
labelBgStyle в DiagramEditor) — прозрачный здесь убирает и её.
*/}
<button
type="button"
title="Прозрачный"
aria-label="Прозрачный"
className={`node-inspector__preset node-inspector__preset--none${
currentColor === TRANSPARENT_COLOR ?
" node-inspector__preset--active"
: ""
}`}
onClick={() => onDataChange({ color: TRANSPARENT_COLOR })}
>
<IconCancel size="xs" />
</button>
</div> </div>
<div ref={colorButtonRef} className="node-inspector__color-trigger"> <div ref={colorButtonRef} className="node-inspector__color-trigger">
<Button size="s" view="secondary" label="Настроить цвет" onClick={() => setColorPickerOpen(true)} /> <Button
size="s"
view="secondary"
label="Настроить цвет"
onClick={() => setColorPickerOpen(true)}
/>
</div> </div>
{colorPickerOpen && ( {colorPickerOpen && (
<ColorPicker <ColorPicker
model={hexModel} model={hexModel}
value={currentColor} value={toPickerHex(currentColor, DEFAULT_EDGE_COLOR)}
onChange={(value) => onDataChange({ color: value })} onChange={(value) => onDataChange({ color: value })}
anchorRef={colorButtonRef} anchorRef={colorButtonRef}
open={colorPickerOpen} open={colorPickerOpen}
onOpen={setColorPickerOpen} onOpen={setColorPickerOpen}
style={{ zIndex: 100 }}
/> />
)} )}
</div> </div>
@@ -150,23 +194,42 @@ export function EdgeInspector({
type="button" type="button"
title={presetLabel} title={presetLabel}
aria-label={presetLabel} aria-label={presetLabel}
className={`node-inspector__preset${currentTextColor === hex ? ' node-inspector__preset--active' : ''}`} className={`node-inspector__preset${currentTextColor === hex ? " node-inspector__preset--active" : ""}`}
style={{ background: hex }} style={{ background: hex }}
onClick={() => onDataChange({ textColor: hex })} onClick={() => onDataChange({ textColor: hex })}
/> />
))} ))}
<button
type="button"
title="Прозрачный"
aria-label="Прозрачный"
className={`node-inspector__preset node-inspector__preset--none${
currentTextColor === TRANSPARENT_COLOR ?
" node-inspector__preset--active"
: ""
}`}
onClick={() => onDataChange({ textColor: TRANSPARENT_COLOR })}
>
<IconCancel size="xs" />
</button>
</div> </div>
<div ref={textColorButtonRef} className="node-inspector__color-trigger"> <div ref={textColorButtonRef} className="node-inspector__color-trigger">
<Button size="s" view="secondary" label="Настроить цвет" onClick={() => setTextColorPickerOpen(true)} /> <Button
size="s"
view="secondary"
label="Настроить цвет"
onClick={() => setTextColorPickerOpen(true)}
/>
</div> </div>
{textColorPickerOpen && ( {textColorPickerOpen && (
<ColorPicker <ColorPicker
model={hexModel} model={hexModel}
value={currentTextColor} value={toPickerHex(currentTextColor, DEFAULT_TEXT_COLOR)}
onChange={(value) => onDataChange({ textColor: value })} onChange={(value) => onDataChange({ textColor: value })}
anchorRef={textColorButtonRef} anchorRef={textColorButtonRef}
open={textColorPickerOpen} open={textColorPickerOpen}
onOpen={setTextColorPickerOpen} onOpen={setTextColorPickerOpen}
style={{ zIndex: 100 }}
/> />
)} )}
</div> </div>
@@ -205,7 +268,11 @@ export function EdgeInspector({
size="xs" size="xs"
name={`edge-line-style-${edgeId}`} name={`edge-line-style-${edgeId}`}
items={LINE_STYLE_ITEMS} items={LINE_STYLE_ITEMS}
value={LINE_STYLE_ITEMS.find((item) => item.style === (data.lineStyle ?? 'solid')) ?? null} value={
LINE_STYLE_ITEMS.find(
(item) => item.style === (data.lineStyle ?? "solid"),
) ?? null
}
onChange={(item) => onDataChange({ lineStyle: item.style })} onChange={(item) => onDataChange({ lineStyle: item.style })}
/> />
</div> </div>
+61 -20
View File
@@ -3,6 +3,8 @@ import { Button } from "@consta/uikit/Button";
import { TextField } from "@consta/uikit/TextField"; import { TextField } from "@consta/uikit/TextField";
import { ColorPicker, hexModel } from "@consta/uikit/ColorPicker"; import { ColorPicker, hexModel } from "@consta/uikit/ColorPicker";
import { Text } from "@consta/uikit/Text"; import { Text } from "@consta/uikit/Text";
import { IconCancel } from "@consta/icons/IconCancel";
import { TRANSPARENT_COLOR, toPickerHex } from "./colors";
import { import {
DEFAULT_CAPTION_TEXT_COLOR, DEFAULT_CAPTION_TEXT_COLOR,
DEFAULT_NODE_BACKGROUND, DEFAULT_NODE_BACKGROUND,
@@ -16,7 +18,12 @@ import {
TASK_SUBTYPE_ICON, TASK_SUBTYPE_ICON,
TASK_SUBTYPE_LABEL, TASK_SUBTYPE_LABEL,
} from "./nodes"; } from "./nodes";
import type { DiagramNodeData, EventSubtype, NodeKind, TaskSubtype } from "./types"; import type {
DiagramNodeData,
EventSubtype,
NodeKind,
TaskSubtype,
} from "./types";
const TASK_SUBTYPES: TaskSubtype[] = [ const TASK_SUBTYPES: TaskSubtype[] = [
"none", "none",
@@ -77,7 +84,11 @@ const TYPE_LABELS: Record<NodeKind, string> = {
dataObject: "Документ (Data Object)", dataObject: "Документ (Data Object)",
}; };
const EVENT_KINDS = new Set<NodeKind>(["startEvent", "intermediateEvent", "endEvent"]); const EVENT_KINDS = new Set<NodeKind>([
"startEvent",
"intermediateEvent",
"endEvent",
]);
interface NodeInspectorProps { interface NodeInspectorProps {
nodeId: string; nodeId: string;
@@ -111,13 +122,9 @@ export function NodeInspector({
const colorButtonRef = useRef<HTMLDivElement>( const colorButtonRef = useRef<HTMLDivElement>(
null, null,
) as RefObject<HTMLDivElement>; ) as RefObject<HTMLDivElement>;
// data.color can be a `var(--gradient-x)` reference from the preset row — // data.color бывает не-hex: `var(--gradient-x)` от пресета-градиента или
// hexModel tries to parse that as a hex color and produces NaN, so the // `transparent` — hexModel такое не разбирает, нужен hex-дефолт.
// hex-only ColorControl/ColorPicker always need a real flat hex fallback. const flatColor = toPickerHex(data.color, DEFAULT_NODE_COLOR[nodeType]);
const flatColor =
data.color && !data.color.startsWith("var(") ?
data.color
: DEFAULT_NODE_COLOR[nodeType];
const [textColorPickerOpen, setTextColorPickerOpen] = useState(false); const [textColorPickerOpen, setTextColorPickerOpen] = useState(false);
const textColorButtonRef = useRef<HTMLDivElement>( const textColorButtonRef = useRef<HTMLDivElement>(
@@ -132,10 +139,12 @@ export function NodeInspector({
if (mode === "view") { if (mode === "view") {
const hasMeta = Boolean(data.responsible || data.duration); const hasMeta = Boolean(data.responsible || data.duration);
const subtypeLabel = const subtypeLabel =
nodeType === "task" nodeType === "task" ?
? TASK_SUBTYPE_LABEL[(data.subtype as TaskSubtype | undefined) ?? "none"] TASK_SUBTYPE_LABEL[(data.subtype as TaskSubtype | undefined) ?? "none"]
: EVENT_KINDS.has(nodeType) : EVENT_KINDS.has(nodeType) ?
? EVENT_SUBTYPE_LABEL[(data.subtype as EventSubtype | undefined) ?? "none"] EVENT_SUBTYPE_LABEL[
(data.subtype as EventSubtype | undefined) ?? "none"
]
: undefined; : undefined;
return ( return (
<div className="node-inspector"> <div className="node-inspector">
@@ -245,7 +254,9 @@ export function NodeInspector({
className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`} className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`}
onClick={() => onChange({ subtype })} onClick={() => onChange({ subtype })}
> >
{Icon ? <Icon size="s" /> : <Text size="xs">—</Text>} {Icon ?
<Icon size="s" />
: <Text size="xs">—</Text>}
</button> </button>
); );
})} })}
@@ -259,8 +270,9 @@ export function NodeInspector({
Тип триггера Тип триггера
</Text> </Text>
<div className="node-inspector__presets"> <div className="node-inspector__presets">
{EVENT_SUBTYPES_BY_KIND[nodeType as "startEvent" | "intermediateEvent" | "endEvent"].map( {EVENT_SUBTYPES_BY_KIND[
(subtype: EventSubtype) => { nodeType as "startEvent" | "intermediateEvent" | "endEvent"
].map((subtype: EventSubtype) => {
const Icon = EVENT_SUBTYPE_ICON[subtype]; const Icon = EVENT_SUBTYPE_ICON[subtype];
const isActive = (data.subtype ?? "none") === subtype; const isActive = (data.subtype ?? "none") === subtype;
return ( return (
@@ -272,11 +284,12 @@ export function NodeInspector({
className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`} className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`}
onClick={() => onChange({ subtype })} onClick={() => onChange({ subtype })}
> >
{Icon ? <Icon size="s" /> : <Text size="xs">—</Text>} {Icon ?
<Icon size="s" />
: <Text size="xs">—</Text>}
</button> </button>
); );
}, })}
)}
</div> </div>
</div> </div>
)} )}
@@ -302,6 +315,19 @@ export function NodeInspector({
/> />
); );
})} })}
<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>
<div ref={colorButtonRef} className="node-inspector__color-trigger"> <div ref={colorButtonRef} className="node-inspector__color-trigger">
<Button <Button
@@ -319,6 +345,7 @@ export function NodeInspector({
anchorRef={colorButtonRef} anchorRef={colorButtonRef}
open={colorPickerOpen} open={colorPickerOpen}
onOpen={setColorPickerOpen} onOpen={setColorPickerOpen}
style={{ zIndex: 100 }}
/> />
)} )}
</div> </div>
@@ -339,6 +366,19 @@ export function NodeInspector({
onClick={() => onChange({ textColor: 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>
<div ref={textColorButtonRef} className="node-inspector__color-trigger"> <div ref={textColorButtonRef} className="node-inspector__color-trigger">
<Button <Button
@@ -351,11 +391,12 @@ export function NodeInspector({
{textColorPickerOpen && ( {textColorPickerOpen && (
<ColorPicker <ColorPicker
model={hexModel} model={hexModel}
value={currentTextColor} value={toPickerHex(currentTextColor, defaultTextColor)}
onChange={(value) => onChange({ textColor: value })} onChange={(value) => onChange({ textColor: value })}
anchorRef={textColorButtonRef} anchorRef={textColorButtonRef}
open={textColorPickerOpen} open={textColorPickerOpen}
onOpen={setTextColorPickerOpen} onOpen={setTextColorPickerOpen}
style={{ zIndex: 100 }}
/> />
)} )}
</div> </div>
+17
View File
@@ -0,0 +1,17 @@
/**
* «Без заливки». Валидное значение и для CSS `background`/`color`, и для
* SVG `stroke`/`fill`, поэтому одинаково годится фигурам, линиям и подписям.
*/
export const TRANSPARENT_COLOR = "transparent";
/**
* Consta's ColorPicker/ColorControl работают через hexModel и понимают только
* плоский hex. В data цвет может лежать и как `var(--gradient-x)` (пресет-
* градиент), и как `transparent` — если отдать такое в hexModel, разбор даст
* NaN и поле покажет «NaNNaNNaN». Поэтому всё, что не hex, подменяем дефолтом.
*/
export function toPickerHex(value: string | undefined, fallback: string): string {
return value && /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) ?
value
: fallback;
}
+63
View File
@@ -86,6 +86,51 @@
opacity: 0.4; opacity: 0.4;
} }
/*
* Зона захвата для изменения размера. По умолчанию xyflow даёт углам маркеры
* 5×5 px, а сторонам — линии толщиной 1 px: попасть курсором почти нереально.
* Видимую графику оставляем скромной, а кликабельную область расширяем
* прозрачным ::after — так фигура не обрастает крупными маркерами.
*
* z-index намеренно не трогаем: точки соединения (.react-flow__handle) идут
* в разметке после ресайзера и потому остаются сверху в местах пересечения —
* на серединах сторон, где расширенная полоса линии проходит поверх них.
* Иначе расширение зоны захвата отобрало бы у них клик и сломало создание
* связей.
*/
.react-flow__resize-control.handle {
width: 8px;
height: 8px;
border-radius: 2px;
}
.react-flow__resize-control.handle::after {
content: "";
position: absolute;
inset: -8px;
}
.react-flow__resize-control.line::after {
content: "";
position: absolute;
}
.react-flow__resize-control.line.left::after,
.react-flow__resize-control.line.right::after {
top: 0;
bottom: 0;
left: -6px;
right: -6px;
}
.react-flow__resize-control.line.top::after,
.react-flow__resize-control.line.bottom::after {
left: 0;
right: 0;
top: -6px;
bottom: -6px;
}
.diagram-alignment-guide { .diagram-alignment-guide {
position: absolute; position: absolute;
background: #ff0072; background: #ff0072;
@@ -312,6 +357,24 @@
outline-offset: 2px; outline-offset: 2px;
} }
/*
* «Прозрачный» — не цвет, а его отсутствие, поэтому вместо заливки внутри
* значок-перечёркивание. Шахматка под ним — привычная в редакторах пометка
* прозрачности, иначе кружок читался бы просто как белый.
*/
.node-inspector__preset--none {
display: flex;
align-items: center;
justify-content: center;
color: #525a64;
background:
linear-gradient(45deg, #d7dae0 25%, transparent 25%, transparent 75%, #d7dae0 75%),
linear-gradient(45deg, #d7dae0 25%, transparent 25%, transparent 75%, #d7dae0 75%),
#fff;
background-size: 8px 8px;
background-position: 0 0, 4px 4px;
}
.node-inspector__subtype { .node-inspector__subtype {
width: 28px; width: 28px;
height: 28px; height: 28px;