Compare commits
10
Commits
179be1f63b
...
7a7fe80c94
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a7fe80c94 | ||
|
|
33939dffaf | ||
|
|
99d48f393a | ||
|
|
88997a338b | ||
|
|
6692028045 | ||
|
|
231b78c8d1 | ||
|
|
5b8aaf0577 | ||
|
|
4852e55964 | ||
|
|
52e28e8dc6 | ||
|
|
d823b3df47 |
@@ -1,32 +1,5 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
Стек: React + TypeScript + Vite
|
||||
Библиотека для диаграмм: xyflow/react (https://www.npmjs.com/package/@xyflow/react)
|
||||
Линтер: oxlint
|
||||
Библиотека компонентов: consta (https://www.npmjs.com/package/@consta/uikit)
|
||||
Стили (цвета) взяты из презентации
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Гайд: как встроить конструктор диаграмм в поле Bitrix
|
||||
|
||||
Короткая практическая версия — только три вопроса: куда положить билд, как собрать HTML и куда писать начальное значение. Про полноценный переиспользуемый тип свойства инфоблока (регистрация, `ConvertToDB` и т.п.) — отдельный документ, [`docs/bitrix-integration.md`](./bitrix-integration.md). Если поле одно и на одной странице — этого гайда достаточно.
|
||||
|
||||
## 1. Куда положить билд конструктора
|
||||
|
||||
Приложение — статическая сборка (`npm run build` → `dist/`), которая раздаётся тем же nginx, что и сам портал. Ничего разворачивать отдельно не нужно.
|
||||
|
||||
1. Собрать в репозитории `portal-chart-builder`:
|
||||
```
|
||||
VITE_BITRIX_ORIGIN=https://ВАШ-ДОМЕН-ПОРТАЛА VITE_BASE_PATH=/local/diagram-app/ npm run build
|
||||
```
|
||||
- `VITE_BITRIX_ORIGIN` — origin портала (без пути). Обязателен — без него приложение намеренно не запустится (защита от случайного `postMessage('*')`).
|
||||
- `VITE_BASE_PATH` — подпуть, под которым будет отдаваться приложение. Должен совпадать с тем, куда положите `dist/` на шаге 2.
|
||||
2. Скопировать содержимое `dist/` в `local/diagram-app/` внутри каталога сайта на сервере (тот же каталог, где лежит остальной код портала — `bitrix_www`/аналог на проде).
|
||||
3. Готово: приложение доступно по `https://ВАШ-ДОМЕН-ПОРТАЛА/local/diagram-app/index.html`.
|
||||
|
||||
**При каждом повторном деплое** (обновили React-приложение, положили новый `dist/` поверх старого) — добавляйте к `src` iframe cache-busting параметр (`?v=<дата/хэш билда>`). У `.html`-файлов на большинстве Bitrix-стендов стоит `Cache-Control` на 30 дней — без версии в URL браузер администратора может продолжать открывать старый `index.html` со старыми хэшами JS/CSS ещё месяц.
|
||||
|
||||
## 2. Как сформировать HTML
|
||||
|
||||
Три части: скрытое поле со значением, iframe с приложением, скрипт-мост между ними.
|
||||
|
||||
```html
|
||||
<input type="hidden" id="DIAGRAM-DATA" name="DIAGRAM-DATA" value="<?= htmlspecialcharsbx($currentValue) ?>">
|
||||
<iframe id="diagram-frame" src="/local/diagram-app/index.html" style="width:100%;height:600px;border:1px solid #d7dae0;border-radius:6px"></iframe>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var APP_ORIGIN = window.location.origin; // приложение и портал на одном домене
|
||||
var input = document.getElementById('DIAGRAM-DATA');
|
||||
var frame = document.getElementById('diagram-frame');
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
if (event.origin !== APP_ORIGIN || event.source !== frame.contentWindow) return;
|
||||
if (!event.data || typeof event.data !== 'object') return;
|
||||
|
||||
if (event.data.type === 'diagram:ready') {
|
||||
frame.contentWindow.postMessage({ type: 'diagram:init', payload: input.value || null }, APP_ORIGIN);
|
||||
} else if (event.data.type === 'diagram:change') {
|
||||
input.value = event.data.payload;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
Как это работает:
|
||||
- iframe грузит приложение → оно шлёт мосту `{ type: 'diagram:ready' }`.
|
||||
- Мост в ответ шлёт `{ type: 'diagram:init', payload: input.value }` — приложение рисует диаграмму из этого значения (или пустой холст, если `payload` пуст).
|
||||
- Каждая правка в редакторе прилетает мосту как `{ type: 'diagram:change', payload: '<JSON-строка>' }` — мост тут же кладёт её в `input.value`.
|
||||
- Дальше это обычный `<input>` внутри формы: если у него есть `id`, `name` и он лежит в `<form>` — браузер сам отправит его значение при обычном сабмите формы. Никакого дополнительного JS для сохранения не нужно.
|
||||
|
||||
**`id`/`name` = `"DIAGRAM-DATA"` захардкожены** — это ок, если поле на странице ровно одно. Если полей несколько (например, полноценный переиспользуемый тип свойства, который может стоять на разных элементах) — `name` должен быть уникальным и совпадать с тем, что ожидает обработчик сохранения на бэкенде (в терминах Bitrix custom property type — `$strHTMLControlName['VALUE']`, см. `docs/bitrix-integration.md`, раздел 3).
|
||||
|
||||
**Обязательно к соблюдению, не опционально:**
|
||||
- `event.origin` проверяется у входящих сообщений (уже в сниппете выше).
|
||||
- `targetOrigin` у `postMessage` — всегда конкретный домен (`APP_ORIGIN`), никогда `'*'`.
|
||||
- Значение попадает на страницу только через `value`-атрибут (`htmlspecialcharsbx()`), не через встраивание в JS-код — так не нужно никакого JSON-специфичного экранирования, обычного `htmlspecialcharsbx()` достаточно.
|
||||
|
||||
## 3. Куда записывать начальное значение
|
||||
|
||||
`value="<?= htmlspecialcharsbx($currentValue) ?>"` — сюда подставляется то, что уже сохранено для этого элемента/записи (JSON-строка со схемой диаграммы) или пустая строка, если диаграммы ещё нет.
|
||||
|
||||
Откуда взять `$currentValue` — зависит от того, где именно хранится значение на вашей стороне (поле БД, свойство инфоблока, опция и т.п.) — это единственное, что специфично для конкретной интеграции, дальше всё стандартно:
|
||||
|
||||
```php
|
||||
$currentValue = /* ваш способ прочитать сохранённое значение, например: */
|
||||
$arElement['PROPERTY_DIAGRAM_VALUE'] ?? '';
|
||||
```
|
||||
|
||||
Требований к содержимому со стороны бэкенда — минимум: это либо пустая строка, либо синтаксически валидный JSON. Всю содержательную валидацию (обязательные поля узлов и т.п.) делает само React-приложение — бэкенду глубже проверять не нужно. Пример реального значения — в `docs/bitrix-integration.md`, раздел 6.
|
||||
|
||||
## Проверка
|
||||
|
||||
1. Открыть страницу — iframe показывает пустую диаграмму (для нового элемента) или ранее сохранённую.
|
||||
2. Отредактировать (подвигать фигуры, поменять текст).
|
||||
3. Сабмитнуть форму как обычно (не внутри iframe).
|
||||
4. Перезагрузить страницу — изменения на месте.
|
||||
5. Проверить напрямую в БД/хранилище — там лежит валидный JSON.
|
||||
@@ -41,8 +41,6 @@ class DiagramProperty
|
||||
{
|
||||
$inputName = htmlspecialcharsbx($strHTMLControlName['VALUE']);
|
||||
$rawValue = $value['VALUE'] ?? '';
|
||||
// JSON_HEX_* флаги обязательны — см. раздел 5.
|
||||
$jsValue = json_encode($rawValue, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
|
||||
// Вариант A — рекомендуемый, приложение лежит внутри дерева портала (см. раздел 7):
|
||||
// appOrigin — это просто origin текущего запроса, appUrl — конкретный файл.
|
||||
@@ -60,8 +58,11 @@ class DiagramProperty
|
||||
style="width:100%;height:600px;border:1px solid #ccc"></iframe>
|
||||
<script>
|
||||
(function () {
|
||||
// Читаем initialValue из самого input.value, а не встраиваем JSON в
|
||||
// инлайн-скрипт — так не нужны JSON_HEX_* флаги вообще (см. раздел 5):
|
||||
// htmlspecialcharsbx() выше уже достаточно экранирует значение
|
||||
// HTML-атрибута, а браузер сам декодирует его обратно в текст.
|
||||
var appOrigin = <?= json_encode($appOrigin) ?>;
|
||||
var initialValue = <?= $jsValue ?>; // строка JSON или ""
|
||||
var input = document.getElementById(<?= json_encode($inputName) ?>);
|
||||
var frame = document.getElementById('diagram-frame-<?= $inputName ?>');
|
||||
|
||||
@@ -71,7 +72,7 @@ class DiagramProperty
|
||||
|
||||
if (event.data.type === 'diagram:ready') {
|
||||
frame.contentWindow.postMessage(
|
||||
{ type: 'diagram:init', payload: initialValue || null },
|
||||
{ type: 'diagram:init', payload: input.value || null },
|
||||
appOrigin // никогда '*', см. раздел 5
|
||||
);
|
||||
} else if (event.data.type === 'diagram:change') {
|
||||
@@ -121,7 +122,7 @@ class DiagramProperty
|
||||
|
||||
## 5. Требования безопасности (обязательны, не опциональны)
|
||||
|
||||
- При встраивании `initialValue` в инлайн-скрипт — **обязательно** `JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT` (см. пример в разделе 3). Без этого — риск XSS через выход из `<script>`-тега при вредоносном содержимом свойства.
|
||||
- Значение всегда попадает на страницу только через `value`-атрибут hidden input (`htmlspecialcharsbx($rawValue)`, см. раздел 3), не через встраивание в инлайн-скрипт — так JSON-содержимое никогда не оказывается внутри JS-исходника, и `JSON_HEX_*`-экранирование не требуется вообще. Мост читает `input.value` напрямую в JS. Если по каким-то причинам всё же понадобится встроить значение в инлайн-скрипт (например, отдельный `initialValue` вместо чтения из DOM) — тогда обязателен `JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT` при `json_encode`.
|
||||
- `event.origin` проверяется **в обе стороны**: и в мосте (когда он слушает сообщения от iframe), и в приложении (уже реализовано во frontend).
|
||||
- `targetOrigin` у любого `postMessage` — всегда конкретный домен (`appOrigin`), **никогда `'*'`**.
|
||||
- `name` у hidden input должен **точно** совпадать с `$strHTMLControlName['VALUE']` — это единственная связь между кастомным рендером и штатным сохранением формы.
|
||||
|
||||
Generated
+391
-4
@@ -8,6 +8,8 @@
|
||||
"name": "portal-chart-builder",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@consta/icons": "^1.7.1",
|
||||
"@consta/uikit": "^5.33.2",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
@@ -22,6 +24,71 @@
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime-corejs3": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.7.tgz",
|
||||
"integrity": "sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-js-pure": "^3.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bem-react/classname": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@bem-react/classname/-/classname-1.8.0.tgz",
|
||||
"integrity": "sha512-uazGRFKTMNbGR8KLvEh5qT+aP0yuym+sB+YFFaAyCkFodtjI0SskV6BfkiZ9+E3FloIFYPkwqJ1owxp48WCS9g==",
|
||||
"license": "MPL-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@bem-react/classnames": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@bem-react/classnames/-/classnames-1.5.0.tgz",
|
||||
"integrity": "sha512-kHa04vgeU0pJbqE/ZDzOn0bRkkVdbo0ZuV2h71h8PtopfDDGzfrgPIn+L6ohW3uF4cbdWUE49sQ0nyVT+O555g==",
|
||||
"license": "MPL-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@consta/icons": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@consta/icons/-/icons-1.7.1.tgz",
|
||||
"integrity": "sha512-ZFngpjo9MlVyNnRU0SJqg2kbzHmh41ydqCYLOWokvhPjlgqUS7W+R38+dHSpIMyMe33wKBvF8LciSqhGwmuP9w==",
|
||||
"peerDependencies": {
|
||||
"@consta/uikit": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@consta/uikit": {
|
||||
"version": "5.33.2",
|
||||
"resolved": "https://registry.npmjs.org/@consta/uikit/-/uikit-5.33.2.tgz",
|
||||
"integrity": "sha512-akF8UkXkJuIg3RKEzorGOZ9/aIPC9v8t3ZDSC+jFbHobP4h0AhGF8NgltB4BW1aXBLkiVN3ee8uGid1edwGcMA==",
|
||||
"peerDependencies": {
|
||||
"@bem-react/classname": "^1.6.0",
|
||||
"@bem-react/classnames": "^1.3.10",
|
||||
"@consta/icons": "^1.7.1",
|
||||
"@reatom/core": "^3.10.1",
|
||||
"@reatom/npm-react": "^3.10.6",
|
||||
"compute-scroll-into-view": "^3.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": ">= 16.8.0",
|
||||
"react-dom": ">= 16.8.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-imask": "^7.2.1",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-transition-group": "^4.4.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
@@ -408,6 +475,85 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reatom/core": {
|
||||
"version": "3.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@reatom/core/-/core-3.10.3.tgz",
|
||||
"integrity": "sha512-uctBfmL/ZNvHWRX92Y+VQZON37fXNja4FM1fzevX7RYOWy6R7evx42VM6K4kuOaIci7bVWPhPZzHWeAu0J2lHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@reatom/effects": {
|
||||
"version": "3.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@reatom/effects/-/effects-3.11.3.tgz",
|
||||
"integrity": "sha512-0qxr7m6e+GrOvt0pESONl4aRZxGjsU1HWXIsDR2Ghw0mNGjuStnEDUZnO+MVbKOArMIAvZ8ZoMrQWqXEBfOrVg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@reatom/core": "^3.2.0",
|
||||
"@reatom/utils": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reatom/hooks": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@reatom/hooks/-/hooks-3.6.1.tgz",
|
||||
"integrity": "sha512-1q8qXAOkQlDKc/Y94alPHWqMnXvJhCG4Rr9hQxPMPG1Qf3WpeKm7Zdxs4v3DC2Kcw6oG6djVk3i5duIjPygGWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@reatom/core": "^3.2.0",
|
||||
"@reatom/effects": "^3.7.0",
|
||||
"@reatom/utils": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reatom/lens": {
|
||||
"version": "3.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reatom/lens/-/lens-3.12.0.tgz",
|
||||
"integrity": "sha512-YsEnYYHi58ePDscXomnbDPC9NSggfJQHvhcpqEAmvZuHH4nbsUpoQj2W69nSQyUDH/+X+TxURvsGUOHaXMl9pQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@reatom/core": "^3.4.0",
|
||||
"@reatom/effects": "^3.2.0",
|
||||
"@reatom/hooks": "^3.3.1",
|
||||
"@reatom/primitives": "^3.6.0",
|
||||
"@reatom/utils": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reatom/npm-react": {
|
||||
"version": "3.10.6",
|
||||
"resolved": "https://registry.npmjs.org/@reatom/npm-react/-/npm-react-3.10.6.tgz",
|
||||
"integrity": "sha512-lyoJD+pF2/P6B5bzSYjUQCzUSa0zkUQNAm1Lj8VXmVbILuiWid+n/9o/fJa8eEyH+w6KaFRunz1WZcOg/NW91g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@reatom/core": "^3.5.0",
|
||||
"@reatom/effects": "^3.7.3",
|
||||
"@reatom/lens": "^3.1.0",
|
||||
"@reatom/utils": "^3.9.0",
|
||||
"use-sync-external-store": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reatom/primitives": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@reatom/primitives/-/primitives-3.11.0.tgz",
|
||||
"integrity": "sha512-b+jtK7qpQxSP83mYQXpPRMlFyg+C9WPv4sZDWSmm00mwmwetW0KbltftkWiwQjQM1TLwpDLZ6R7DX7fcTraIgg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@reatom/core": "^3.1.1",
|
||||
"@reatom/utils": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@reatom/utils": {
|
||||
"version": "3.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@reatom/utils/-/utils-3.11.3.tgz",
|
||||
"integrity": "sha512-H2FQf9xra7Twf0PxS6L0DtuRRC79NfHRB3V/YhnhPHyUE/UFscXrin/I2eGj3FEKcgOfC2BWnzCOrkXjKWgECQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
@@ -830,17 +976,45 @@
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/attr-accept": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/compute-scroll-into-view": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
|
||||
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/core-js-pure": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz",
|
||||
"integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
@@ -948,6 +1122,17 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -958,6 +1143,17 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -976,6 +1172,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -991,6 +1200,26 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imask": {
|
||||
"version": "7.6.1",
|
||||
"resolved": "https://registry.npmjs.org/imask/-/imask-7.6.1.tgz",
|
||||
"integrity": "sha512-sJlIFM7eathUEMChTh9Mrfw/IgiWgJqBKq2VNbyXvBZ7ev/IlO6/KQTKlV/Fm+viQMLrFLG/zCuudrLIwgK2dg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime-corejs3": "^7.24.4"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -1252,6 +1481,19 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
@@ -1271,6 +1513,16 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint": {
|
||||
"version": "1.73.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.73.0.tgz",
|
||||
@@ -1369,6 +1621,18 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
@@ -1390,6 +1654,83 @@
|
||||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dropzone": {
|
||||
"version": "14.4.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz",
|
||||
"integrity": "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"attr-accept": "^2.2.4",
|
||||
"file-selector": "^2.1.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-imask": {
|
||||
"version": "7.6.1",
|
||||
"resolved": "https://registry.npmjs.org/react-imask/-/react-imask-7.6.1.tgz",
|
||||
"integrity": "sha512-vLNfzcCz62Yzx/GRGh5tiCph9Gbh2cZu+Tz8OiO5it2eNuuhpA0DWhhSlOtVtSJ80+Bx+vFK5De8eQ9AmbkXzA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"imask": "^7.6.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-textarea-autosize": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
|
||||
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"use-composed-ref": "^1.3.0",
|
||||
"use-latest": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
@@ -1461,9 +1802,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
@@ -1486,6 +1825,54 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-composed-ref": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
|
||||
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-isomorphic-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-latest": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
|
||||
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"use-isomorphic-layout-effect": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@consta/icons": "^1.7.1",
|
||||
"@consta/uikit": "^5.33.2",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
:root {
|
||||
/* ============================================================
|
||||
ОСНОВНЫЕ ГРАДИЕНТЫ (синие) — для инфоблоков, диаграмм, блок-схем
|
||||
============================================================ */
|
||||
--gradient-navy-start: rgb(0, 75, 158);
|
||||
--gradient-navy-end: rgb(0, 164, 230);
|
||||
--gradient-navy: linear-gradient(60deg, var(--gradient-navy-start), var(--gradient-navy-end));
|
||||
|
||||
--gradient-azure-start: rgb(36, 139, 224);
|
||||
--gradient-azure-end: rgb(65, 205, 255);
|
||||
--gradient-azure: linear-gradient(60deg, var(--gradient-azure-start), var(--gradient-azure-end));
|
||||
|
||||
--gradient-sky-start: rgb(94, 179, 236);
|
||||
--gradient-sky-end: rgb(173, 214, 247);
|
||||
--gradient-sky: linear-gradient(60deg, var(--gradient-sky-start), var(--gradient-sky-end));
|
||||
|
||||
--gradient-teal-dark-start: rgb(60, 129, 160);
|
||||
--gradient-teal-dark-end: rgb(151, 195, 219);
|
||||
--gradient-teal-dark: linear-gradient(60deg, var(--gradient-teal-dark-start), var(--gradient-teal-dark-end));
|
||||
|
||||
--gradient-teal-light-start: rgb(102, 190, 218);
|
||||
--gradient-teal-light-end: rgb(161, 226, 233);
|
||||
--gradient-teal-light: linear-gradient(60deg, var(--gradient-teal-light-start), var(--gradient-teal-light-end));
|
||||
|
||||
--gradient-navy-dark-start: rgb(0, 33, 92);
|
||||
--gradient-navy-dark-end: rgb(0, 101, 170);
|
||||
--gradient-navy-dark: linear-gradient(60deg, var(--gradient-navy-dark-start), var(--gradient-navy-dark-end));
|
||||
|
||||
--gradient-ice-start: rgb(206, 234, 255);
|
||||
--gradient-ice-end: rgb(241, 249, 255);
|
||||
--gradient-ice: linear-gradient(60deg, var(--gradient-ice-start), var(--gradient-ice-end));
|
||||
|
||||
--gradient-frost-start: rgb(215, 229, 237);
|
||||
--gradient-frost-end: rgb(243, 245, 247);
|
||||
--gradient-frost: linear-gradient(60deg, var(--gradient-frost-start), var(--gradient-frost-end));
|
||||
|
||||
/* ============================================================
|
||||
ОСНОВНАЯ ПАЛИТРА — для диаграмм, блок-схем, смысловых выделений
|
||||
============================================================ */
|
||||
--gradient-purple-start: rgb(161, 44, 126);
|
||||
--gradient-purple-end: rgb(191, 51, 168);
|
||||
--gradient-purple: linear-gradient(60deg, var(--gradient-purple-start), var(--gradient-purple-end));
|
||||
|
||||
--gradient-red-start: rgb(225, 48, 66);
|
||||
--gradient-red-end: rgb(241, 103, 117);
|
||||
--gradient-red: linear-gradient(60deg, var(--gradient-red-start), var(--gradient-red-end));
|
||||
|
||||
--gradient-orange-start: rgb(255, 142, 88);
|
||||
--gradient-orange-end: rgb(254, 180, 106);
|
||||
--gradient-orange: linear-gradient(60deg, var(--gradient-orange-start), var(--gradient-orange-end));
|
||||
|
||||
--gradient-olive-start: rgb(187, 189, 57);
|
||||
--gradient-olive-end: rgb(197, 231, 103);
|
||||
--gradient-olive: linear-gradient(60deg, var(--gradient-olive-start), var(--gradient-olive-end));
|
||||
|
||||
--gradient-green-start: rgb(44, 181, 135);
|
||||
--gradient-green-end: rgb(57, 224, 137);
|
||||
--gradient-green: linear-gradient(60deg, var(--gradient-green-start), var(--gradient-green-end));
|
||||
|
||||
--gradient-green-dark-start: rgb(25, 111, 82);
|
||||
--gradient-green-dark-end: rgb(38, 160, 119);
|
||||
--gradient-green-dark: linear-gradient(60deg, var(--gradient-green-dark-start), var(--gradient-green-dark-end));
|
||||
|
||||
--gradient-blue-start: rgb(7, 144, 203);
|
||||
--gradient-blue-end: rgb(17, 203, 217);
|
||||
--gradient-blue: linear-gradient(60deg, var(--gradient-blue-start), var(--gradient-blue-end));
|
||||
|
||||
--gradient-cyan-start: rgb(27, 211, 247);
|
||||
--gradient-cyan-end: rgb(101, 240, 238);
|
||||
--gradient-cyan: linear-gradient(60deg, var(--gradient-cyan-start), var(--gradient-cyan-end));
|
||||
|
||||
/* ============================================================
|
||||
ДОПОЛНИТЕЛЬНЫЕ ГРАДИЕНТЫ — для диаграмм, блок-схем, смысловых выделений
|
||||
============================================================ */
|
||||
--gradient-violet-start: rgb(138, 92, 231);
|
||||
--gradient-violet-end: rgb(148, 57, 227);
|
||||
--gradient-violet: linear-gradient(60deg, var(--gradient-violet-start), var(--gradient-violet-end));
|
||||
|
||||
--gradient-lavender-start: rgb(168, 143, 248);
|
||||
--gradient-lavender-end: rgb(132, 159, 246);
|
||||
--gradient-lavender: linear-gradient(60deg, var(--gradient-lavender-start), var(--gradient-lavender-end));
|
||||
|
||||
--gradient-pink-start: rgb(241, 141, 206);
|
||||
--gradient-pink-end: rgb(222, 146, 232);
|
||||
--gradient-pink: linear-gradient(60deg, var(--gradient-pink-start), var(--gradient-pink-end));
|
||||
|
||||
/* ============================================================
|
||||
ДОПОЛНИТЕЛЬНЫЕ ЦВЕТА — для мелкой графики, линий (одиночные акценты)
|
||||
Соответствуют по цветовому тону основной палитре и доп. градиентам
|
||||
============================================================ */
|
||||
--accent-purple: rgb(181, 48, 154);
|
||||
--accent-red: rgb(236, 86, 101);
|
||||
--accent-orange: rgb(255, 171, 102);
|
||||
--accent-olive: rgb(194, 220, 91);
|
||||
--accent-green: rgb(50, 202, 136);
|
||||
--accent-green-dark: rgb(34, 146, 109);
|
||||
--accent-blue: rgb(14, 186, 213);
|
||||
--accent-cyan: rgb(78, 231, 244);
|
||||
--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 */
|
||||
}
|
||||
+6
-1
@@ -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;
|
||||
|
||||
+349
-126
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
@@ -23,33 +23,63 @@ import {
|
||||
type EdgeChange,
|
||||
type NodeMouseHandler,
|
||||
type EdgeMouseHandler,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
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 './diagram.css';
|
||||
} 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";
|
||||
import { debounce, serializeDocument } from "./serialize";
|
||||
import type {
|
||||
DiagramDocument,
|
||||
DiagramEdgeData,
|
||||
DiagramNodeData,
|
||||
EdgeLineStyle,
|
||||
NodeKind,
|
||||
} from "./types";
|
||||
import "./diagram.css";
|
||||
|
||||
interface DiagramEditorProps {
|
||||
initialValue: DiagramDocument | null;
|
||||
onChange: (doc: DiagramDocument) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_EDGE_TYPE = 'smoothstep';
|
||||
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<EdgeLineStyle, string | undefined> = {
|
||||
solid: undefined,
|
||||
dashed: "8 4",
|
||||
dotted: "1.5 4",
|
||||
};
|
||||
const MAX_HISTORY = 50;
|
||||
const TYPING_HISTORY_THROTTLE_MS = 1000;
|
||||
|
||||
const NODE_KINDS: { kind: NodeKind; label: string }[] = [
|
||||
{ kind: 'task', label: '+ Задача' },
|
||||
{ kind: 'startEvent', label: '+ Начало' },
|
||||
{ kind: 'intermediateEvent', label: '+ Промеж. событие' },
|
||||
{ kind: 'endEvent', label: '+ Конец' },
|
||||
{ kind: 'exclusiveGateway', label: '+ Искл. шлюз' },
|
||||
{ kind: 'parallelGateway', label: '+ Паралл. шлюз' },
|
||||
{ kind: 'inclusiveGateway', label: '+ Вкл. шлюз' },
|
||||
{ kind: "task", label: "+ Действие" },
|
||||
{ kind: "startEvent", label: "+ Начало" },
|
||||
{ kind: "intermediateEvent", label: "+ Промеж. событие" },
|
||||
{ kind: "endEvent", label: "+ Конец" },
|
||||
{ kind: "exclusiveGateway", label: "+ Искл. шлюз (XOR)" },
|
||||
{ kind: "parallelGateway", label: "+ Паралл. шлюз (AND)" },
|
||||
{ kind: "inclusiveGateway", label: "+ Вкл. шлюз (OR)" },
|
||||
{ kind: "complexGateway", label: "+ Сложный шлюз" },
|
||||
{ kind: "eventBasedGateway", label: "+ Шлюз по событию" },
|
||||
{ kind: "hint", label: "+ Подсказка" },
|
||||
{ kind: "dataObject", 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 }> = {
|
||||
@@ -60,10 +90,14 @@ const DEFAULT_NODE_SIZE: Record<NodeKind, { width: number; height: number }> = {
|
||||
exclusiveGateway: { width: 64, height: 64 },
|
||||
parallelGateway: { width: 64, height: 64 },
|
||||
inclusiveGateway: { width: 64, height: 64 },
|
||||
complexGateway: { width: 64, height: 64 },
|
||||
eventBasedGateway: { width: 64, height: 64 },
|
||||
hint: { width: 160, height: 70 },
|
||||
dataObject: { width: 90, height: 70 },
|
||||
};
|
||||
|
||||
interface AlignmentGuide {
|
||||
axis: 'x' | 'y';
|
||||
axis: "x" | "y";
|
||||
position: number;
|
||||
}
|
||||
|
||||
@@ -81,7 +115,10 @@ const SNAP_THRESHOLD = 6;
|
||||
* same level and, for the first close-enough match on each axis, snaps to it
|
||||
* and records a guide line to draw across the canvas.
|
||||
*/
|
||||
function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: number; guides: AlignmentGuide[] } {
|
||||
function computeSnapAndGuides(
|
||||
dragged: Box,
|
||||
others: Box[],
|
||||
): { x: number; y: number; guides: AlignmentGuide[] } {
|
||||
const guides: AlignmentGuide[] = [];
|
||||
let snappedX = dragged.x;
|
||||
let snappedY = dragged.y;
|
||||
@@ -95,7 +132,7 @@ function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: numb
|
||||
const otherCenterX = other.x + other.width / 2;
|
||||
if (Math.abs(draggedCenterX - otherCenterX) <= SNAP_THRESHOLD) {
|
||||
snappedX = dragged.x + (otherCenterX - draggedCenterX);
|
||||
guides.push({ axis: 'x', position: otherCenterX });
|
||||
guides.push({ axis: "x", position: otherCenterX });
|
||||
matchedX = true;
|
||||
}
|
||||
}
|
||||
@@ -103,7 +140,7 @@ function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: numb
|
||||
const otherCenterY = other.y + other.height / 2;
|
||||
if (Math.abs(draggedCenterY - otherCenterY) <= SNAP_THRESHOLD) {
|
||||
snappedY = dragged.y + (otherCenterY - draggedCenterY);
|
||||
guides.push({ axis: 'y', position: otherCenterY });
|
||||
guides.push({ axis: "y", position: otherCenterY });
|
||||
matchedY = true;
|
||||
}
|
||||
}
|
||||
@@ -152,7 +189,11 @@ function toRfEdges(doc: DiagramDocument | null): Edge[] {
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
return (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
@@ -161,7 +202,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<'edit' | 'view'>('edit');
|
||||
const [mode, setMode] = useState<"edit" | "view">("edit");
|
||||
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
|
||||
const nodeCounter = useRef(0);
|
||||
const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]);
|
||||
@@ -172,7 +213,11 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
const { screenToFlowPosition, zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
|
||||
const emitChange = useMemo(
|
||||
() => debounce((n: Node[], e: Edge[]) => onChange(serializeDocument(n, e)), 400),
|
||||
() =>
|
||||
debounce(
|
||||
(n: Node[], e: Edge[]) => onChange(serializeDocument(n, e)),
|
||||
400,
|
||||
),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
@@ -230,7 +275,36 @@ 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(() => {
|
||||
@@ -259,25 +333,49 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
setSelectedEdgeId(null);
|
||||
}, []);
|
||||
|
||||
// Runs before React Flow computes any removal changes at all (for both
|
||||
// nodes and their connected edges), so blocking here — unlike filtering
|
||||
// inside onNodesChange — stops the connected edges from being deleted too.
|
||||
const onBeforeDelete = useCallback(
|
||||
async ({ nodes: nodesToDelete }: { nodes: Node[]; edges: Edge[] }) => {
|
||||
const hasContainerNode = nodesToDelete.some((node) =>
|
||||
nodes.some((n) => n.parentId === node.id),
|
||||
);
|
||||
if (hasContainerNode) {
|
||||
window.alert(
|
||||
"Сначала удалите вложенные узлы — у этого узла есть под-процесс.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[nodes],
|
||||
);
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
const withChildren = new Set(nodes.filter((n) => n.parentId).map((n) => n.parentId!));
|
||||
const filteredChanges = changes.filter((change) => {
|
||||
if (change.type !== 'remove' || !withChildren.has(change.id)) return true;
|
||||
window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.');
|
||||
return false;
|
||||
});
|
||||
|
||||
const positionChanges = filteredChanges.filter((c) => c.type === 'position' && c.position);
|
||||
let adjustedChanges = filteredChanges;
|
||||
const positionChanges = changes.filter(
|
||||
(c) => c.type === "position" && c.position,
|
||||
);
|
||||
let adjustedChanges = changes;
|
||||
if (positionChanges.length === 1) {
|
||||
const change = positionChanges[0];
|
||||
const draggedNode = change.type === 'position' ? nodes.find((n) => n.id === change.id) : undefined;
|
||||
if (change.type === 'position' && change.position && draggedNode) {
|
||||
const draggedNode =
|
||||
change.type === "position" ?
|
||||
nodes.find((n) => n.id === change.id)
|
||||
: undefined;
|
||||
if (change.type === "position" && change.position && draggedNode) {
|
||||
const level = draggedNode.parentId ?? null;
|
||||
const others: Box[] = nodes
|
||||
.filter((n) => n.id !== draggedNode.id && (n.parentId ?? null) === level)
|
||||
.map((n) => ({ x: n.position.x, y: n.position.y, width: n.width ?? 0, height: n.height ?? 0 }));
|
||||
.filter(
|
||||
(n) => n.id !== draggedNode.id && (n.parentId ?? null) === level,
|
||||
)
|
||||
.map((n) => ({
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
width: n.width ?? 0,
|
||||
height: n.height ?? 0,
|
||||
}));
|
||||
const dragged: Box = {
|
||||
x: change.position.x,
|
||||
y: change.position.y,
|
||||
@@ -286,19 +384,21 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
};
|
||||
const snapped = computeSnapAndGuides(dragged, others);
|
||||
setAlignmentGuides(change.dragging === false ? [] : snapped.guides);
|
||||
adjustedChanges = filteredChanges.map((c) =>
|
||||
c === change ? { ...c, position: { x: snapped.x, y: snapped.y } } : c,
|
||||
adjustedChanges = changes.map((c) =>
|
||||
c === change ?
|
||||
{ ...c, position: { x: snapped.x, y: snapped.y } }
|
||||
: c,
|
||||
);
|
||||
}
|
||||
} else if (alignmentGuides.length > 0) {
|
||||
setAlignmentGuides([]);
|
||||
}
|
||||
|
||||
const isHistoryWorthy = filteredChanges.some(
|
||||
const isHistoryWorthy = changes.some(
|
||||
(change) =>
|
||||
change.type === 'remove' ||
|
||||
(change.type === 'position' && change.dragging === false) ||
|
||||
(change.type === 'dimensions' && change.resizing === false),
|
||||
change.type === "remove" ||
|
||||
(change.type === "position" && change.dragging === false) ||
|
||||
(change.type === "dimensions" && change.resizing === false),
|
||||
);
|
||||
if (isHistoryWorthy) pushHistory(nodes, edges);
|
||||
const next = applyNodeChanges(adjustedChanges, nodes);
|
||||
@@ -308,10 +408,16 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
// emitChange — filtering edges here too (redundantly with onEdgesChange)
|
||||
// guarantees a removed node never leaves a dangling edge behind.
|
||||
const removedIds = new Set(
|
||||
filteredChanges.filter((change) => change.type === 'remove').map((change) => change.id),
|
||||
changes
|
||||
.filter((change) => change.type === "remove")
|
||||
.map((change) => change.id),
|
||||
);
|
||||
const nextEdges = removedIds.size
|
||||
? edges.filter((edge) => !removedIds.has(edge.source) && !removedIds.has(edge.target))
|
||||
const nextEdges =
|
||||
removedIds.size ?
|
||||
edges.filter(
|
||||
(edge) =>
|
||||
!removedIds.has(edge.source) && !removedIds.has(edge.target),
|
||||
)
|
||||
: edges;
|
||||
|
||||
setNodes(next);
|
||||
@@ -323,7 +429,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: EdgeChange[]) => {
|
||||
const isHistoryWorthy = changes.some((change) => change.type === 'remove');
|
||||
const isHistoryWorthy = changes.some(
|
||||
(change) => change.type === "remove",
|
||||
);
|
||||
if (isHistoryWorthy) pushHistory(nodes, edges);
|
||||
const next = applyEdgeChanges(changes, edges);
|
||||
setEdges(next);
|
||||
@@ -335,8 +443,22 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
const onConnect: OnConnect = useCallback(
|
||||
(connection) => {
|
||||
pushHistory(nodes, edges);
|
||||
// A Data Object endpoint means this is a BPMN Data Association, not a
|
||||
// Sequence Flow — those are conventionally drawn dashed, so default to
|
||||
// that (still just a starting point — line style stays editable after).
|
||||
const involvesDataObject = [connection.source, connection.target].some(
|
||||
(id) => nodes.find((n) => n.id === id)?.type === "dataObject",
|
||||
);
|
||||
const next = addEdge(
|
||||
{ ...connection, id: crypto.randomUUID(), type: DEFAULT_EDGE_TYPE, markerEnd: DEFAULT_MARKER_END },
|
||||
{
|
||||
...connection,
|
||||
id: crypto.randomUUID(),
|
||||
type: DEFAULT_EDGE_TYPE,
|
||||
markerEnd: DEFAULT_MARKER_END,
|
||||
...(involvesDataObject ?
|
||||
{ data: { lineStyle: "dashed" } as DiagramEdgeData }
|
||||
: {}),
|
||||
},
|
||||
edges,
|
||||
);
|
||||
setEdges(next);
|
||||
@@ -349,7 +471,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
(oldEdge: Edge, connection: Connection) => {
|
||||
pushHistory(nodes, edges);
|
||||
// Keep the original edge id stable so selection/history don't go stale mid-drag.
|
||||
const next = reconnectEdge(oldEdge, connection, edges, { shouldReplaceId: false });
|
||||
const next = reconnectEdge(oldEdge, connection, edges, {
|
||||
shouldReplaceId: false,
|
||||
});
|
||||
setEdges(next);
|
||||
emitChange(nodes, next);
|
||||
},
|
||||
@@ -363,7 +487,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
|
||||
const onEdgeClick: EdgeMouseHandler = useCallback(
|
||||
(_event, edge) => {
|
||||
if (mode !== 'edit') return;
|
||||
if (mode !== "edit") return;
|
||||
setSelectedEdgeId(edge.id);
|
||||
setSelectedNodeId(null);
|
||||
},
|
||||
@@ -380,7 +504,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
if (!selectedNodeId) return;
|
||||
pushHistoryThrottled(nodes, edges);
|
||||
const next = nodes.map((node) =>
|
||||
node.id === selectedNodeId ? { ...node, data: { ...node.data, ...patch } } : node,
|
||||
node.id === selectedNodeId ?
|
||||
{ ...node, data: { ...node.data, ...patch } }
|
||||
: node,
|
||||
);
|
||||
setNodes(next);
|
||||
emitChange(next, edges);
|
||||
@@ -392,7 +518,24 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
(label: string) => {
|
||||
if (!selectedEdgeId) return;
|
||||
pushHistoryThrottled(nodes, edges);
|
||||
const next = edges.map((edge) => (edge.id === selectedEdgeId ? { ...edge, label } : edge));
|
||||
const next = edges.map((edge) =>
|
||||
edge.id === selectedEdgeId ? { ...edge, label } : edge,
|
||||
);
|
||||
setEdges(next);
|
||||
emitChange(nodes, next);
|
||||
},
|
||||
[selectedEdgeId, nodes, edges, emitChange, pushHistoryThrottled],
|
||||
);
|
||||
|
||||
const updateSelectedEdgeData = useCallback(
|
||||
(patch: Partial<DiagramEdgeData>) => {
|
||||
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);
|
||||
},
|
||||
@@ -402,7 +545,10 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
const getViewportCenter = useCallback(() => {
|
||||
const rect = canvasWrapperRef.current?.getBoundingClientRect();
|
||||
if (!rect) return { x: 0, y: 0 };
|
||||
return screenToFlowPosition({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
|
||||
return screenToFlowPosition({
|
||||
x: rect.x + rect.width / 2,
|
||||
y: rect.y + rect.height / 2,
|
||||
});
|
||||
}, [screenToFlowPosition]);
|
||||
|
||||
const addNode = useCallback(
|
||||
@@ -421,7 +567,12 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
},
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
data: { label: NODE_KINDS.find((n) => n.kind === kind)!.label.replace('+ ', '') } as DiagramNodeData,
|
||||
data: {
|
||||
label: NODE_KINDS.find((n) => n.kind === kind)!.label.replace(
|
||||
"+ ",
|
||||
"",
|
||||
),
|
||||
} as DiagramNodeData,
|
||||
...(focusNodeId ? { parentId: focusNodeId } : {}),
|
||||
};
|
||||
pushHistory(nodes, edges);
|
||||
@@ -433,7 +584,8 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
);
|
||||
|
||||
const copySelectedNode = useCallback(() => {
|
||||
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) : undefined;
|
||||
const node =
|
||||
selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) : undefined;
|
||||
if (!node) return;
|
||||
clipboardRef.current = node;
|
||||
}, [selectedNodeId, nodes]);
|
||||
@@ -443,7 +595,11 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
if (!source) return;
|
||||
nodeCounter.current += 1;
|
||||
const newId = crypto.randomUUID();
|
||||
const { parentId: _sourceParentId, selected: _sourceSelected, ...restSource } = source;
|
||||
const {
|
||||
parentId: _sourceParentId,
|
||||
selected: _sourceSelected,
|
||||
...restSource
|
||||
} = source;
|
||||
const newNode: Node = {
|
||||
...restSource,
|
||||
id: newId,
|
||||
@@ -467,12 +623,16 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
const deleteSelectedNode = useCallback(() => {
|
||||
if (!selectedNodeId) return;
|
||||
if (nodes.some((n) => n.parentId === selectedNodeId)) {
|
||||
window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.');
|
||||
window.alert(
|
||||
"Сначала удалите вложенные узлы — у этого узла есть под-процесс.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
pushHistory(nodes, edges);
|
||||
const nextNodes = nodes.filter((n) => n.id !== selectedNodeId);
|
||||
const nextEdges = edges.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId);
|
||||
const nextEdges = edges.filter(
|
||||
(e) => e.source !== selectedNodeId && e.target !== selectedNodeId,
|
||||
);
|
||||
setNodes(nextNodes);
|
||||
setEdges(nextEdges);
|
||||
emitChange(nextNodes, nextEdges);
|
||||
@@ -491,11 +651,21 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
// Nodes and edges share one z-index space (zIndexMode="manual"), so front/back
|
||||
// always means "above/below everything currently on the canvas", not just siblings.
|
||||
const maxZIndex = useCallback(
|
||||
() => Math.max(0, ...nodes.map((n) => n.zIndex ?? 0), ...edges.map((e) => e.zIndex ?? 0)),
|
||||
() =>
|
||||
Math.max(
|
||||
0,
|
||||
...nodes.map((n) => n.zIndex ?? 0),
|
||||
...edges.map((e) => e.zIndex ?? 0),
|
||||
),
|
||||
[nodes, edges],
|
||||
);
|
||||
const minZIndex = useCallback(
|
||||
() => Math.min(0, ...nodes.map((n) => n.zIndex ?? 0), ...edges.map((e) => e.zIndex ?? 0)),
|
||||
() =>
|
||||
Math.min(
|
||||
0,
|
||||
...nodes.map((n) => n.zIndex ?? 0),
|
||||
...edges.map((e) => e.zIndex ?? 0),
|
||||
),
|
||||
[nodes, edges],
|
||||
);
|
||||
|
||||
@@ -503,7 +673,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
if (!selectedNodeId) return;
|
||||
const z = maxZIndex() + 1;
|
||||
pushHistory(nodes, edges);
|
||||
const next = nodes.map((n) => (n.id === selectedNodeId ? { ...n, zIndex: z } : n));
|
||||
const next = nodes.map((n) =>
|
||||
n.id === selectedNodeId ? { ...n, zIndex: z } : n,
|
||||
);
|
||||
setNodes(next);
|
||||
emitChange(next, edges);
|
||||
}, [selectedNodeId, nodes, edges, emitChange, pushHistory, maxZIndex]);
|
||||
@@ -512,7 +684,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
if (!selectedNodeId) return;
|
||||
const z = minZIndex() - 1;
|
||||
pushHistory(nodes, edges);
|
||||
const next = nodes.map((n) => (n.id === selectedNodeId ? { ...n, zIndex: z } : n));
|
||||
const next = nodes.map((n) =>
|
||||
n.id === selectedNodeId ? { ...n, zIndex: z } : n,
|
||||
);
|
||||
setNodes(next);
|
||||
emitChange(next, edges);
|
||||
}, [selectedNodeId, nodes, edges, emitChange, pushHistory, minZIndex]);
|
||||
@@ -521,7 +695,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
if (!selectedEdgeId) return;
|
||||
const z = maxZIndex() + 1;
|
||||
pushHistory(nodes, edges);
|
||||
const next = edges.map((e) => (e.id === selectedEdgeId ? { ...e, zIndex: z } : e));
|
||||
const next = edges.map((e) =>
|
||||
e.id === selectedEdgeId ? { ...e, zIndex: z } : e,
|
||||
);
|
||||
setEdges(next);
|
||||
emitChange(nodes, next);
|
||||
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory, maxZIndex]);
|
||||
@@ -530,36 +706,45 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
if (!selectedEdgeId) return;
|
||||
const z = minZIndex() - 1;
|
||||
pushHistory(nodes, edges);
|
||||
const next = edges.map((e) => (e.id === selectedEdgeId ? { ...e, zIndex: z } : e));
|
||||
const next = edges.map((e) =>
|
||||
e.id === selectedEdgeId ? { ...e, zIndex: z } : e,
|
||||
);
|
||||
setEdges(next);
|
||||
emitChange(nodes, next);
|
||||
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory, minZIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (mode !== 'edit' || isEditableTarget(event.target)) return;
|
||||
if (mode !== "edit" || isEditableTarget(event.target)) return;
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'z') {
|
||||
if (key === "z") {
|
||||
event.preventDefault();
|
||||
undo();
|
||||
} else if (key === 'c') {
|
||||
} else if (key === "c") {
|
||||
event.preventDefault();
|
||||
copySelectedNode();
|
||||
} else if (key === 'v') {
|
||||
} else if (key === "v") {
|
||||
event.preventDefault();
|
||||
pasteClipboardNode();
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [mode, undo, copySelectedNode, pasteClipboardNode]);
|
||||
|
||||
const selectedNode = selectedNodeId ? nodes.find((node) => node.id === selectedNodeId) : undefined;
|
||||
const selectedNodeChildCount = selectedNode
|
||||
? nodes.filter((node) => node.parentId === selectedNode.id).length
|
||||
const selectedNode =
|
||||
selectedNodeId ?
|
||||
nodes.find((node) => node.id === selectedNodeId)
|
||||
: undefined;
|
||||
const selectedNodeChildCount =
|
||||
selectedNode ?
|
||||
nodes.filter((node) => node.parentId === selectedNode.id).length
|
||||
: 0;
|
||||
const selectedEdge = selectedEdgeId ? edges.find((edge) => edge.id === selectedEdgeId) : undefined;
|
||||
const selectedEdge =
|
||||
selectedEdgeId ?
|
||||
edges.find((edge) => edge.id === selectedEdgeId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="diagram-shell">
|
||||
@@ -574,17 +759,20 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onReconnect={onReconnect}
|
||||
edgesReconnectable={mode === 'edit'}
|
||||
onBeforeDelete={onBeforeDelete}
|
||||
edgesReconnectable={mode === "edit"}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodesDraggable={mode === 'edit'}
|
||||
nodesConnectable={mode === 'edit'}
|
||||
deleteKeyCode={mode === 'edit' ? undefined : null}
|
||||
nodesDraggable={mode === "edit"}
|
||||
nodesConnectable={mode === "edit"}
|
||||
deleteKeyCode={mode === "edit" ? undefined : null}
|
||||
zoomOnDoubleClick={false}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
zIndexMode="manual"
|
||||
className={mode === 'view' ? 'diagram-flow--view-mode' : undefined}
|
||||
className={
|
||||
mode === "view" ? "diagram-flow--view-mode" : undefined
|
||||
}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
@@ -595,14 +783,28 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
key={`${guide.axis}-${index}`}
|
||||
className="diagram-alignment-guide"
|
||||
style={
|
||||
guide.axis === 'x'
|
||||
? { left: guide.position, top: -10000, width: 1, height: 20000 }
|
||||
: { top: guide.position, left: -10000, height: 1, width: 20000 }
|
||||
guide.axis === "x" ?
|
||||
{
|
||||
left: guide.position,
|
||||
top: -10000,
|
||||
width: 1,
|
||||
height: 20000,
|
||||
}
|
||||
: {
|
||||
top: guide.position,
|
||||
left: -10000,
|
||||
height: 1,
|
||||
width: 20000,
|
||||
}
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ViewportPortal>
|
||||
<Controls showZoom={false} showFitView={false} showInteractive={false}>
|
||||
<Controls
|
||||
showZoom={false}
|
||||
showFitView={false}
|
||||
showInteractive={false}
|
||||
>
|
||||
<ControlButton onClick={() => zoomIn()} title="Увеличить">
|
||||
+
|
||||
</ControlButton>
|
||||
@@ -612,51 +814,66 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
<ControlButton onClick={() => fitView()} title="Показать всё">
|
||||
⛶
|
||||
</ControlButton>
|
||||
{mode === 'edit' && (
|
||||
{mode === "edit" && (
|
||||
<>
|
||||
<ControlButton onClick={undo} disabled={historyRef.current.length === 0} title="Отменить (Ctrl+Z)">
|
||||
<ControlButton
|
||||
onClick={undo}
|
||||
disabled={historyRef.current.length === 0}
|
||||
title="Отменить (Ctrl+Z)"
|
||||
>
|
||||
↶
|
||||
</ControlButton>
|
||||
<ControlButton onClick={redo} disabled={redoRef.current.length === 0} title="Повторить">
|
||||
<ControlButton
|
||||
onClick={redo}
|
||||
disabled={redoRef.current.length === 0}
|
||||
title="Повторить"
|
||||
>
|
||||
↷
|
||||
</ControlButton>
|
||||
</>
|
||||
)}
|
||||
</Controls>
|
||||
{mode === 'edit' && <MiniMap />}
|
||||
{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' && (
|
||||
{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="ghost"
|
||||
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>
|
||||
@@ -664,10 +881,10 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
</div>
|
||||
|
||||
<aside className="diagram-aside">
|
||||
{selectedNode ? (
|
||||
{selectedNode ?
|
||||
<NodeInspector
|
||||
nodeId={selectedNode.id}
|
||||
nodeType={(selectedNode.type ?? 'task') as NodeKind}
|
||||
nodeType={(selectedNode.type ?? "task") as NodeKind}
|
||||
data={selectedNode.data as DiagramNodeData}
|
||||
childCount={selectedNodeChildCount}
|
||||
mode={mode}
|
||||
@@ -678,27 +895,33 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
onBringToFront={bringSelectedNodeToFront}
|
||||
onSendToBack={sendSelectedNodeToBack}
|
||||
/>
|
||||
) : selectedEdge ? (
|
||||
: selectedEdge ?
|
||||
<EdgeInspector
|
||||
edgeId={selectedEdge.id}
|
||||
label={typeof selectedEdge.label === 'string' ? selectedEdge.label : ''}
|
||||
onChange={updateSelectedEdgeLabel}
|
||||
label={
|
||||
typeof selectedEdge.label === "string" ? selectedEdge.label : ""
|
||||
}
|
||||
data={(selectedEdge.data as DiagramEdgeData) ?? {}}
|
||||
onLabelChange={updateSelectedEdgeLabel}
|
||||
onDataChange={updateSelectedEdgeData}
|
||||
onDelete={deleteSelectedEdge}
|
||||
onBringToFront={bringSelectedEdgeToFront}
|
||||
onSendToBack={sendSelectedEdgeToBack}
|
||||
/>
|
||||
) : (
|
||||
<p className="diagram-aside__hint">
|
||||
{mode === 'edit'
|
||||
? 'Выберите узел или связь на схеме — появятся кнопки «Дублировать»/«Удалить» и поля данных.'
|
||||
: 'Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода.'}
|
||||
: <p className="diagram-aside__hint">
|
||||
{mode === "edit" ?
|
||||
"Выберите узел или связь на схеме — появятся кнопки «Дублировать»/«Удалить» и поля данных."
|
||||
: "Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода."
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
}
|
||||
|
||||
{mode === 'edit' && (
|
||||
{mode === "edit" && (
|
||||
<details className="diagram-aside__json">
|
||||
<summary>Текущий doc (для демо)</summary>
|
||||
<pre>{JSON.stringify(serializeDocument(nodes, edges), null, 2)}</pre>
|
||||
<pre>
|
||||
{JSON.stringify(serializeDocument(nodes, edges), null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
+201
-17
@@ -1,7 +1,70 @@
|
||||
import { useRef, useState, type RefObject } from 'react';
|
||||
import { Button } from '@consta/uikit/Button';
|
||||
import { TextField } from '@consta/uikit/TextField';
|
||||
import { ChoiceGroup } from '@consta/uikit/ChoiceGroup';
|
||||
import { ColorPicker, hexModel } from '@consta/uikit/ColorPicker';
|
||||
import { Text } from '@consta/uikit/Text';
|
||||
import type { DiagramEdgeData, EdgeLineStyle } from './types';
|
||||
|
||||
const LINE_STYLE_ITEMS: { label: string; style: EdgeLineStyle }[] = [
|
||||
{ label: 'Сплошная', style: 'solid' },
|
||||
{ label: 'Пунктирная', style: 'dashed' },
|
||||
{ label: 'Точечная', style: 'dotted' },
|
||||
];
|
||||
|
||||
// "Основные цвета для мелкой графики, линий" — two tonal rows (blue, gray)
|
||||
// meant specifically for lines, shown first.
|
||||
const GRAPHIC_LINE_PRESETS: { hex: string; label: string }[] = [
|
||||
{ hex: '#0079C2', label: 'Синий 1' },
|
||||
{ hex: '#43B7F7', label: 'Синий 2' },
|
||||
{ hex: '#89CFFF', label: 'Синий 3' },
|
||||
{ hex: '#75AAC5', label: 'Синий 4' },
|
||||
{ hex: '#8FD7E4', label: 'Синий 5' },
|
||||
{ hex: '#004181', label: 'Синий 6' },
|
||||
{ hex: '#929AA4', label: 'Серый 1' },
|
||||
{ hex: '#CED2D6', label: 'Серый 2' },
|
||||
{ hex: '#E9EBED', label: 'Серый 3' },
|
||||
{ hex: '#FFFFFF', label: 'Серый 4' },
|
||||
];
|
||||
|
||||
// "Дополнительные цвета" — the section palette.css marks as meant for
|
||||
// lines/small graphics too (single accents), shown after the row above.
|
||||
const ACCENT_LINE_PRESETS: { hex: string; label: string }[] = [
|
||||
{ hex: '#B5309A', label: 'Пурпурный' },
|
||||
{ hex: '#EC5665', label: 'Красный' },
|
||||
{ hex: '#FFAB66', label: 'Оранжевый' },
|
||||
{ hex: '#C2DC5B', label: 'Оливковый' },
|
||||
{ hex: '#32CA88', label: 'Зелёный' },
|
||||
{ hex: '#22926D', label: 'Тёмно-зелёный' },
|
||||
{ hex: '#0EBAD5', label: 'Синий' },
|
||||
{ hex: '#4EE7F4', label: 'Циан' },
|
||||
{ hex: '#9152E6', label: 'Виолет' },
|
||||
{ hex: '#9D9AF7', label: 'Лавандовый' },
|
||||
{ hex: '#E88FDB', label: 'Розовый' },
|
||||
];
|
||||
|
||||
const LINE_COLOR_PRESETS = [...GRAPHIC_LINE_PRESETS, ...ACCENT_LINE_PRESETS];
|
||||
|
||||
// "Цвета текста" from palette.css.
|
||||
const TEXT_COLOR_PRESETS: { hex: string; label: string }[] = [
|
||||
{ hex: '#0079C2', label: 'Синий' },
|
||||
{ hex: '#525A64', label: 'Тёмный' },
|
||||
{ hex: '#929AA4', label: 'Серый' },
|
||||
{ hex: '#FFFFFF', label: 'Белый' },
|
||||
];
|
||||
|
||||
const DEFAULT_STROKE_WIDTH = 1;
|
||||
const DEFAULT_BORDER_RADIUS = 5;
|
||||
const DEFAULT_EDGE_COLOR = '#b1b1b7';
|
||||
const DEFAULT_TEXT_COLOR = '#FFFFFF';
|
||||
const DEFAULT_FONT_SIZE = 11;
|
||||
|
||||
interface EdgeInspectorProps {
|
||||
edgeId: string;
|
||||
label: string;
|
||||
onChange: (label: string) => void;
|
||||
data: DiagramEdgeData;
|
||||
onLabelChange: (label: string) => void;
|
||||
onDataChange: (patch: Partial<DiagramEdgeData>) => void;
|
||||
onDelete: () => void;
|
||||
onBringToFront: () => void;
|
||||
onSendToBack: () => void;
|
||||
@@ -10,37 +73,158 @@ interface EdgeInspectorProps {
|
||||
export function EdgeInspector({
|
||||
edgeId,
|
||||
label,
|
||||
onChange,
|
||||
data,
|
||||
onLabelChange,
|
||||
onDataChange,
|
||||
onDelete,
|
||||
onBringToFront,
|
||||
onSendToBack,
|
||||
}: EdgeInspectorProps) {
|
||||
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
||||
const colorButtonRef = useRef<HTMLDivElement>(null) as RefObject<HTMLDivElement>;
|
||||
const currentColor = data.color ?? DEFAULT_EDGE_COLOR;
|
||||
|
||||
const [textColorPickerOpen, setTextColorPickerOpen] = useState(false);
|
||||
const textColorButtonRef = useRef<HTMLDivElement>(null) as RefObject<HTMLDivElement>;
|
||||
const currentTextColor = data.textColor ?? DEFAULT_TEXT_COLOR;
|
||||
|
||||
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">
|
||||
Поток управления (Sequence Flow)
|
||||
</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) => onLabelChange(value ?? '')} />
|
||||
|
||||
<div className="node-inspector__field">
|
||||
<Text size="xs" view="secondary">
|
||||
Цвет линии
|
||||
</Text>
|
||||
<div className="node-inspector__presets">
|
||||
{LINE_COLOR_PRESETS.map(({ hex, label: presetLabel }) => (
|
||||
<button
|
||||
key={hex}
|
||||
type="button"
|
||||
title={presetLabel}
|
||||
aria-label={presetLabel}
|
||||
className={`node-inspector__preset${currentColor === hex ? ' node-inspector__preset--active' : ''}`}
|
||||
style={{ background: hex }}
|
||||
onClick={() => onDataChange({ color: hex })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div ref={colorButtonRef} className="node-inspector__color-trigger">
|
||||
<Button size="s" view="secondary" label="Настроить цвет" onClick={() => setColorPickerOpen(true)} />
|
||||
</div>
|
||||
{colorPickerOpen && (
|
||||
<ColorPicker
|
||||
model={hexModel}
|
||||
value={currentColor}
|
||||
onChange={(value) => onDataChange({ color: value })}
|
||||
anchorRef={colorButtonRef}
|
||||
open={colorPickerOpen}
|
||||
onOpen={setColorPickerOpen}
|
||||
/>
|
||||
)}
|
||||
</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={() => onDataChange({ textColor: hex })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div ref={textColorButtonRef} className="node-inspector__color-trigger">
|
||||
<Button size="s" view="secondary" label="Настроить цвет" onClick={() => setTextColorPickerOpen(true)} />
|
||||
</div>
|
||||
{textColorPickerOpen && (
|
||||
<ColorPicker
|
||||
model={hexModel}
|
||||
value={currentTextColor}
|
||||
onChange={(value) => onDataChange({ textColor: value })}
|
||||
anchorRef={textColorButtonRef}
|
||||
open={textColorPickerOpen}
|
||||
onOpen={setTextColorPickerOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label="Размер текста"
|
||||
type="number"
|
||||
size="s"
|
||||
min={8}
|
||||
max={32}
|
||||
value={String(data.fontSize ?? DEFAULT_FONT_SIZE)}
|
||||
onChange={(value) => {
|
||||
const n = Number(value);
|
||||
if (value && !Number.isNaN(n)) onDataChange({ fontSize: n });
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Толщина линии"
|
||||
type="number"
|
||||
size="s"
|
||||
min={1}
|
||||
max={8}
|
||||
value={String(data.strokeWidth ?? DEFAULT_STROKE_WIDTH)}
|
||||
onChange={(value) => {
|
||||
const n = Number(value);
|
||||
if (value && !Number.isNaN(n)) onDataChange({ strokeWidth: n });
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="node-inspector__field">
|
||||
<Text size="xs" view="secondary">
|
||||
Стиль линии
|
||||
</Text>
|
||||
<ChoiceGroup
|
||||
size="xs"
|
||||
name={`edge-line-style-${edgeId}`}
|
||||
items={LINE_STYLE_ITEMS}
|
||||
value={LINE_STYLE_ITEMS.find((item) => item.style === (data.lineStyle ?? 'solid')) ?? null}
|
||||
onChange={(item) => onDataChange({ lineStyle: item.style })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label="Скругление углов"
|
||||
type="number"
|
||||
size="s"
|
||||
min={0}
|
||||
max={20}
|
||||
value={String(data.borderRadius ?? DEFAULT_BORDER_RADIUS)}
|
||||
onChange={(value) => {
|
||||
const n = Number(value);
|
||||
if (value && !Number.isNaN(n)) onDataChange({ borderRadius: n });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+355
-70
@@ -1,22 +1,90 @@
|
||||
import { DEFAULT_NODE_COLOR } from './nodes';
|
||||
import type { DiagramNodeData, NodeKind } from './types';
|
||||
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 {
|
||||
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: 'Задача',
|
||||
startEvent: 'Начальное событие',
|
||||
intermediateEvent: 'Промежуточное событие',
|
||||
endEvent: 'Конечное событие',
|
||||
exclusiveGateway: 'Исключающий шлюз',
|
||||
parallelGateway: 'Параллельный шлюз',
|
||||
inclusiveGateway: 'Включающий шлюз',
|
||||
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';
|
||||
mode: "edit" | "view";
|
||||
onChange: (patch: Partial<DiagramNodeData>) => void;
|
||||
onEnterSubProcess: (nodeId: string) => void;
|
||||
onDuplicate: () => void;
|
||||
@@ -38,17 +106,64 @@ export function NodeInspector({
|
||||
onBringToFront,
|
||||
onSendToBack,
|
||||
}: NodeInspectorProps) {
|
||||
if (mode === 'view') {
|
||||
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 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<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">
|
||||
<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>
|
||||
{subtypeLabel && (
|
||||
<Text size="xs" view="secondary">
|
||||
{subtypeLabel}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{data.description && <p className="node-inspector__description">{data.description}</p>}
|
||||
<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">
|
||||
@@ -68,9 +183,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 +197,248 @@ 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">
|
||||
{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">
|
||||
Цвет фона
|
||||
<input
|
||||
type="color"
|
||||
value={data.color ?? DEFAULT_NODE_COLOR[nodeType]}
|
||||
onChange={(e) => onChange({ color: e.target.value })}
|
||||
</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 })}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="node-inspector__field">
|
||||
Название
|
||||
<textarea
|
||||
<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 })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div ref={textColorButtonRef} className="node-inspector__color-trigger">
|
||||
<Button
|
||||
size="s"
|
||||
view="secondary"
|
||||
label="Настроить цвет"
|
||||
onClick={() => setTextColorPickerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
{textColorPickerOpen && (
|
||||
<ColorPicker
|
||||
model={hexModel}
|
||||
value={currentTextColor}
|
||||
onChange={(value) => onChange({ textColor: value })}
|
||||
anchorRef={textColorButtonRef}
|
||||
open={textColorPickerOpen}
|
||||
onOpen={setTextColorPickerOpen}
|
||||
/>
|
||||
)}
|
||||
</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={(e) => onChange({ label: e.target.value })}
|
||||
onChange={(value) => onChange({ label: value ?? "" })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="node-inspector__field">
|
||||
Описание
|
||||
<textarea
|
||||
<TextField
|
||||
label="Описание"
|
||||
type="textarea"
|
||||
rows={3}
|
||||
value={data.description ?? ''}
|
||||
onChange={(e) => onChange({ description: e.target.value })}
|
||||
size="s"
|
||||
value={data.description ?? ""}
|
||||
onChange={(value) => onChange({ description: value ?? "" })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="node-inspector__field">
|
||||
Ответственный
|
||||
<input
|
||||
type="text"
|
||||
value={data.responsible ?? ''}
|
||||
onChange={(e) => onChange({ responsible: e.target.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 });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="node-inspector__field">
|
||||
Длительность
|
||||
<input
|
||||
type="text"
|
||||
value={data.duration ?? ''}
|
||||
onChange={(e) => onChange({ duration: e.target.value })}
|
||||
{nodeType === "task" && (
|
||||
<>
|
||||
<TextField
|
||||
label="Ответственный"
|
||||
size="s"
|
||||
value={data.responsible ?? ""}
|
||||
onChange={(value) => onChange({ responsible: value ?? "" })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="button" className="node-inspector__action" onClick={() => onEnterSubProcess(nodeId)}>
|
||||
Войти в под-процесс →
|
||||
</button>
|
||||
<p className="node-inspector__hint">
|
||||
{childCount > 0
|
||||
? `Внутри уже ${childCount} узл(ов).`
|
||||
: 'Пока пусто — зайдя внутрь, можно добавить узлы детального разбора этого шага.'}
|
||||
</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
+74
-154
@@ -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,63 +43,11 @@
|
||||
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%;
|
||||
height: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
background: canvas;
|
||||
color: canvastext;
|
||||
font-size: 12px;
|
||||
@@ -127,6 +61,7 @@
|
||||
.diagram-node__label {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diagram-node__badge {
|
||||
@@ -153,10 +88,9 @@
|
||||
transform: translateX(-50%);
|
||||
margin-top: 4px;
|
||||
max-width: 120px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
color: canvastext;
|
||||
white-space: pre-wrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diagram-node--circle {
|
||||
@@ -164,26 +98,6 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.diagram-node--startEvent {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.diagram-node--endEvent {
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.diagram-node--intermediateEvent {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.diagram-node--intermediateEvent::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.diagram-node--gateway-outer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -197,19 +111,57 @@
|
||||
.diagram-node--diamond-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid #555;
|
||||
box-sizing: border-box;
|
||||
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
|
||||
}
|
||||
|
||||
.diagram-node__icon {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.diagram-node__subtype-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
display: inline-flex;
|
||||
line-height: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.diagram-node__event-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.diagram-node--data-object-outer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/*
|
||||
* Folded top-right corner — the classic BPMN Data Object glyph. `clip-path`
|
||||
* establishes its own stacking context, so a later, non-`z-index`d sibling
|
||||
* (the label) doesn't reliably paint on top by DOM order alone — z-index
|
||||
* makes the stacking explicit instead of relying on that.
|
||||
*/
|
||||
.diagram-node--data-object-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 0 100%);
|
||||
}
|
||||
|
||||
.diagram-aside {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
@@ -257,89 +209,57 @@
|
||||
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__presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.node-inspector__field--color {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.node-inspector__preset {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, canvastext 25%, transparent);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-inspector__field--color input[type='color'] {
|
||||
width: 36px;
|
||||
.node-inspector__preset--active {
|
||||
outline: 2px solid canvastext;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.node-inspector__subtype {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-inspector__action {
|
||||
font: inherit;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #999;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid color-mix(in srgb, canvastext 25%, transparent);
|
||||
border-radius: 4px;
|
||||
background: canvas;
|
||||
color: canvastext;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-inspector__subtype--active {
|
||||
border-color: canvastext;
|
||||
background: color-mix(in srgb, canvastext 12%, canvas);
|
||||
}
|
||||
|
||||
.node-inspector__color-trigger {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -1,6 +1,10 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font: 14px/1.4 system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
font:
|
||||
14px/1.4 system-ui,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -13,3 +17,7 @@ body,
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.diagram-breadcrumbs:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
+321
-39
@@ -1,6 +1,29 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { Handle, NodeResizer, Position, type Node, type NodeProps } from '@xyflow/react';
|
||||
import type { DiagramNodeData, NodeKind } from './types';
|
||||
import { createContext, useContext } from "react";
|
||||
import {
|
||||
Handle,
|
||||
NodeResizer,
|
||||
Position,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
} from "@xyflow/react";
|
||||
import { IconUser } from "@consta/icons/IconUser";
|
||||
import { IconSettings } from "@consta/icons/IconSettings";
|
||||
import { IconCode } from "@consta/icons/IconCode";
|
||||
import { IconHand } from "@consta/icons/IconHand";
|
||||
import { IconTable } from "@consta/icons/IconTable";
|
||||
import { IconSendMessage } from "@consta/icons/IconSendMessage";
|
||||
import { IconMail } from "@consta/icons/IconMail";
|
||||
import { IconWatchStroked } from "@consta/icons/IconWatchStroked";
|
||||
import { IconTable2 } from "@consta/icons/IconTable2";
|
||||
import { IconAlert } from "@consta/icons/IconAlert";
|
||||
import { IconLightningBolt } from "@consta/icons/IconLightningBolt";
|
||||
import { IconArrowUp } from "@consta/icons/IconArrowUp";
|
||||
import { IconArrowPrevious } from "@consta/icons/IconArrowPrevious";
|
||||
import { IconRecord } from "@consta/icons/IconRecord";
|
||||
import { IconPolygonMultiple } from "@consta/icons/IconPolygonMultiple";
|
||||
import { IconGisMultiple } from "@consta/icons/IconGisMultiple";
|
||||
import type { IconComponent } from "@consta/icons/Icon";
|
||||
import type { DiagramNodeData, EventSubtype, NodeKind, TaskSubtype } from "./types";
|
||||
|
||||
type ShapeNode = Node<DiagramNodeData, NodeKind>;
|
||||
|
||||
@@ -8,23 +31,138 @@ type ShapeNode = Node<DiagramNodeData, NodeKind>;
|
||||
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');
|
||||
export const ModeContext = createContext<"edit" | "view">("edit");
|
||||
|
||||
export const DEFAULT_NODE_COLOR: Record<NodeKind, string> = {
|
||||
task: '#eef2ff',
|
||||
startEvent: '#f0fdf4',
|
||||
intermediateEvent: '#fefce8',
|
||||
endEvent: '#fef2f2',
|
||||
exclusiveGateway: '#fff7ed',
|
||||
parallelGateway: '#faf5ff',
|
||||
inclusiveGateway: '#f0fdfa',
|
||||
// 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)",
|
||||
complexGateway: "var(--gradient-violet)",
|
||||
eventBasedGateway: "var(--gradient-teal-dark)",
|
||||
hint: "var(--gradient-frost)",
|
||||
dataObject: "var(--gradient-ice)",
|
||||
};
|
||||
|
||||
// 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: "#0790CB",
|
||||
startEvent: "#2CB587",
|
||||
intermediateEvent: "#FF8E58",
|
||||
endEvent: "#E13042",
|
||||
exclusiveGateway: "#A12C7E",
|
||||
parallelGateway: "#1BD3F7",
|
||||
inclusiveGateway: "#BBBD39",
|
||||
complexGateway: "#8A5CE7",
|
||||
eventBasedGateway: "#3C81A0",
|
||||
hint: "#D7E5ED",
|
||||
dataObject: "#CEEAFF",
|
||||
};
|
||||
|
||||
// BPMN task-type icon shown as a small badge in the shape's top-left corner —
|
||||
// 'none' (the plain abstract Task) intentionally has no entry, so it renders bare.
|
||||
export const TASK_SUBTYPE_ICON: Partial<Record<TaskSubtype, IconComponent>> = {
|
||||
user: IconUser,
|
||||
service: IconSettings,
|
||||
script: IconCode,
|
||||
manual: IconHand,
|
||||
businessRule: IconTable,
|
||||
send: IconSendMessage,
|
||||
receive: IconMail,
|
||||
};
|
||||
|
||||
export const TASK_SUBTYPE_LABEL: Record<TaskSubtype, string> = {
|
||||
none: "Действие",
|
||||
user: "Действие пользователя",
|
||||
service: "Сервисное действие",
|
||||
script: "Действие-скрипт",
|
||||
manual: "Ручное действие",
|
||||
businessRule: "Действие по бизнес-правилам",
|
||||
send: "Действие отправки",
|
||||
receive: "Действие получения",
|
||||
};
|
||||
|
||||
// BPMN event-trigger icon shown centered inside the event circle — 'none'
|
||||
// (the plain, untyped event) intentionally has no entry.
|
||||
export const EVENT_SUBTYPE_ICON: Partial<Record<EventSubtype, IconComponent>> = {
|
||||
message: IconMail,
|
||||
timer: IconWatchStroked,
|
||||
conditional: IconTable2,
|
||||
signal: IconAlert,
|
||||
error: IconLightningBolt,
|
||||
escalation: IconArrowUp,
|
||||
compensation: IconArrowPrevious,
|
||||
terminate: IconRecord,
|
||||
multiple: IconPolygonMultiple,
|
||||
parallelMultiple: IconGisMultiple,
|
||||
};
|
||||
|
||||
export const EVENT_SUBTYPE_LABEL: Record<EventSubtype, string> = {
|
||||
none: "Без триггера",
|
||||
message: "Сообщение",
|
||||
timer: "Таймер",
|
||||
conditional: "Условие",
|
||||
signal: "Сигнал",
|
||||
error: "Ошибка",
|
||||
escalation: "Эскалация",
|
||||
compensation: "Компенсация",
|
||||
terminate: "Завершение процесса",
|
||||
multiple: "Множественное",
|
||||
parallelMultiple: "Параллельное множественное",
|
||||
};
|
||||
|
||||
// Which trigger subtypes are valid on each event kind, per BPMN 2.0 — used to
|
||||
// filter the subtype picker in NodeInspector so it never offers an invalid combination.
|
||||
export const EVENT_SUBTYPES_BY_KIND: Record<
|
||||
"startEvent" | "intermediateEvent" | "endEvent",
|
||||
EventSubtype[]
|
||||
> = {
|
||||
startEvent: ["none", "message", "timer", "conditional", "signal", "multiple", "parallelMultiple"],
|
||||
intermediateEvent: [
|
||||
"none",
|
||||
"message",
|
||||
"timer",
|
||||
"conditional",
|
||||
"signal",
|
||||
"escalation",
|
||||
"compensation",
|
||||
"multiple",
|
||||
"parallelMultiple",
|
||||
],
|
||||
endEvent: [
|
||||
"none",
|
||||
"message",
|
||||
"error",
|
||||
"escalation",
|
||||
"compensation",
|
||||
"signal",
|
||||
"terminate",
|
||||
"multiple",
|
||||
],
|
||||
};
|
||||
|
||||
export const DEFAULT_TASK_BORDER_RADIUS = 6;
|
||||
|
||||
// Text on the colored shape body (task label, gateway icon) needs a light
|
||||
// default; the caption below events/gateways sits on the plain canvas
|
||||
// background instead and needs a dark one. Both are overridable via the
|
||||
// same `data.textColor`/`data.fontSize`.
|
||||
export const DEFAULT_NODE_TEXT_COLOR = "#FFFFFF";
|
||||
export const DEFAULT_CAPTION_TEXT_COLOR = "#525A64";
|
||||
export const DEFAULT_NODE_FONT_SIZE = 12;
|
||||
|
||||
const HANDLE_POSITIONS: { id: string; position: Position }[] = [
|
||||
{ id: 'top', position: Position.Top },
|
||||
{ id: 'right', position: Position.Right },
|
||||
{ id: 'bottom', position: Position.Bottom },
|
||||
{ id: 'left', position: Position.Left },
|
||||
{ id: "top", position: Position.Top },
|
||||
{ id: "right", position: Position.Right },
|
||||
{ id: "bottom", position: Position.Bottom },
|
||||
{ id: "left", position: Position.Left },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -46,8 +184,11 @@ function SubProcessBadge({ id }: { id: string }) {
|
||||
const withChildren = useContext(NodesWithChildrenContext);
|
||||
if (!withChildren.has(id)) return null;
|
||||
return (
|
||||
<span className="diagram-node__badge" title="Есть вложенные узлы — двойной клик, чтобы открыть">
|
||||
⊞
|
||||
<span
|
||||
className="diagram-node__badge"
|
||||
title="Есть вложенные узлы — двойной клик, чтобы открыть"
|
||||
>
|
||||
⇒
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +207,7 @@ function ShapeResizer({
|
||||
const mode = useContext(ModeContext);
|
||||
return (
|
||||
<NodeResizer
|
||||
isVisible={selected && mode === 'edit'}
|
||||
isVisible={selected && mode === "edit"}
|
||||
minWidth={minWidth}
|
||||
minHeight={minHeight}
|
||||
keepAspectRatio={keepAspectRatio}
|
||||
@@ -75,45 +216,101 @@ function ShapeResizer({
|
||||
}
|
||||
|
||||
function TaskNode({ id, data, selected }: NodeProps<ShapeNode>) {
|
||||
const subtype = (data.subtype as TaskSubtype | undefined) ?? "none";
|
||||
const SubtypeIcon = TASK_SUBTYPE_ICON[subtype];
|
||||
const textColor = data.textColor ?? DEFAULT_NODE_TEXT_COLOR;
|
||||
return (
|
||||
<div className="diagram-node diagram-node--task" style={{ background: data.color ?? DEFAULT_NODE_COLOR.task }}>
|
||||
<ShapeResizer selected={selected} minWidth={70} minHeight={32} keepAspectRatio={false} />
|
||||
<div
|
||||
className="diagram-node diagram-node--task"
|
||||
style={{
|
||||
background: data.color ?? DEFAULT_NODE_BACKGROUND.task,
|
||||
borderRadius: data.borderRadius ?? DEFAULT_TASK_BORDER_RADIUS,
|
||||
}}
|
||||
>
|
||||
<ShapeResizer
|
||||
selected={selected}
|
||||
minWidth={70}
|
||||
minHeight={32}
|
||||
keepAspectRatio={false}
|
||||
/>
|
||||
<ShapeHandles />
|
||||
<SubProcessBadge id={id} />
|
||||
<span className="diagram-node__label">{data.label}</span>
|
||||
{SubtypeIcon && (
|
||||
<span
|
||||
className="diagram-node__subtype-badge"
|
||||
style={{ color: textColor }}
|
||||
title={TASK_SUBTYPE_LABEL[subtype]}
|
||||
>
|
||||
<SubtypeIcon size="xs" />
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="diagram-node__label"
|
||||
style={{
|
||||
color: textColor,
|
||||
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
|
||||
}}
|
||||
>
|
||||
{data.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const EVENT_BORDER_CLASS: Record<'startEvent' | 'intermediateEvent' | 'endEvent', string> = {
|
||||
startEvent: 'diagram-node--startEvent',
|
||||
intermediateEvent: 'diagram-node--intermediateEvent',
|
||||
endEvent: 'diagram-node--endEvent',
|
||||
};
|
||||
|
||||
function EventNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
|
||||
const kind = (type as 'startEvent' | 'intermediateEvent' | 'endEvent') ?? 'startEvent';
|
||||
const kind =
|
||||
(type as "startEvent" | "intermediateEvent" | "endEvent") ?? "startEvent";
|
||||
const subtype = (data.subtype as EventSubtype | undefined) ?? "none";
|
||||
const SubtypeIcon = EVENT_SUBTYPE_ICON[subtype];
|
||||
return (
|
||||
<div
|
||||
className={`diagram-node diagram-node--circle ${EVENT_BORDER_CLASS[kind]}`}
|
||||
style={{ background: data.color ?? DEFAULT_NODE_COLOR[kind] }}
|
||||
className="diagram-node diagram-node--circle"
|
||||
style={{ background: data.color ?? DEFAULT_NODE_BACKGROUND[kind] }}
|
||||
>
|
||||
<ShapeResizer selected={selected} minWidth={24} minHeight={24} />
|
||||
<ShapeHandles />
|
||||
<SubProcessBadge id={id} />
|
||||
<span className="diagram-node__caption">{data.label}</span>
|
||||
{SubtypeIcon && (
|
||||
<span
|
||||
className="diagram-node__event-icon"
|
||||
style={{ color: data.textColor ?? DEFAULT_NODE_TEXT_COLOR }}
|
||||
title={EVENT_SUBTYPE_LABEL[subtype]}
|
||||
>
|
||||
<SubtypeIcon size="s" />
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="diagram-node__caption"
|
||||
style={{
|
||||
color: data.textColor ?? DEFAULT_CAPTION_TEXT_COLOR,
|
||||
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
|
||||
}}
|
||||
>
|
||||
{data.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GATEWAY_ICON: Record<'exclusiveGateway' | 'parallelGateway' | 'inclusiveGateway', string> = {
|
||||
exclusiveGateway: '✕',
|
||||
parallelGateway: '+',
|
||||
inclusiveGateway: '○',
|
||||
const GATEWAY_ICON: Record<
|
||||
"exclusiveGateway" | "parallelGateway" | "inclusiveGateway" | "complexGateway" | "eventBasedGateway",
|
||||
string
|
||||
> = {
|
||||
exclusiveGateway: "✕",
|
||||
parallelGateway: "+",
|
||||
inclusiveGateway: "○",
|
||||
complexGateway: "✳",
|
||||
eventBasedGateway: "⬠",
|
||||
};
|
||||
|
||||
function GatewayNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
|
||||
const kind = (type as 'exclusiveGateway' | 'parallelGateway' | 'inclusiveGateway') ?? 'exclusiveGateway';
|
||||
const kind =
|
||||
(type as
|
||||
| "exclusiveGateway"
|
||||
| "parallelGateway"
|
||||
| "inclusiveGateway"
|
||||
| "complexGateway"
|
||||
| "eventBasedGateway") ?? "exclusiveGateway";
|
||||
return (
|
||||
<div className="diagram-node--gateway-outer">
|
||||
<ShapeResizer selected={selected} minWidth={32} minHeight={32} />
|
||||
@@ -130,11 +327,92 @@ 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>
|
||||
<span className="diagram-node__caption">{data.label}</span>
|
||||
<span
|
||||
className="diagram-node__caption"
|
||||
style={{
|
||||
color: data.textColor ?? DEFAULT_CAPTION_TEXT_COLOR,
|
||||
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
|
||||
}}
|
||||
>
|
||||
{data.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not a BPMN element — a free-floating annotation the user places next to
|
||||
// other shapes. No handles: it's never meant to be wired into the flow.
|
||||
function HintNode({ data, selected }: NodeProps<ShapeNode>) {
|
||||
return (
|
||||
<div
|
||||
className="diagram-node diagram-node--hint"
|
||||
style={{
|
||||
background: data.color ?? DEFAULT_NODE_BACKGROUND.hint,
|
||||
borderRadius: data.borderRadius ?? DEFAULT_TASK_BORDER_RADIUS,
|
||||
}}
|
||||
>
|
||||
<ShapeResizer
|
||||
selected={selected}
|
||||
minWidth={60}
|
||||
minHeight={32}
|
||||
keepAspectRatio={false}
|
||||
/>
|
||||
<span
|
||||
className="diagram-node__label"
|
||||
style={{
|
||||
// The default hint fill (--gradient-frost) is pale, unlike the
|
||||
// vivid task backgrounds, so it needs the dark caption color, not
|
||||
// the light one — same reasoning as DataObjectNode below.
|
||||
color: data.textColor ?? DEFAULT_CAPTION_TEXT_COLOR,
|
||||
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
|
||||
}}
|
||||
>
|
||||
{data.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// BPMN Data Object — represents a document/input/output attached to a task
|
||||
// via a (typically dashed) Data Association edge. The folded top-right corner
|
||||
// is clipped on an inner div, not the outer one, so ShapeHandles — siblings of
|
||||
// it — still land at the plain bounding-box edges instead of being cut off
|
||||
// (same reasoning as the gateway diamond above).
|
||||
function DataObjectNode({ data, selected }: NodeProps<ShapeNode>) {
|
||||
return (
|
||||
<div className="diagram-node--data-object-outer">
|
||||
<ShapeResizer
|
||||
selected={selected}
|
||||
minWidth={50}
|
||||
minHeight={60}
|
||||
keepAspectRatio={false}
|
||||
/>
|
||||
<div
|
||||
className="diagram-node--data-object-bg"
|
||||
style={{ background: data.color ?? DEFAULT_NODE_BACKGROUND.dataObject }}
|
||||
/>
|
||||
<ShapeHandles />
|
||||
<span
|
||||
className="diagram-node__label"
|
||||
style={{
|
||||
// The folded-corner bg's `clip-path` gives it its own stacking
|
||||
// context (z-index: 0, set in CSS), so the label needs to be
|
||||
// positioned with a higher z-index to paint above it.
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
color: data.textColor ?? DEFAULT_CAPTION_TEXT_COLOR,
|
||||
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
|
||||
}}
|
||||
>
|
||||
{data.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -147,4 +425,8 @@ export const nodeTypes = {
|
||||
exclusiveGateway: GatewayNode,
|
||||
parallelGateway: GatewayNode,
|
||||
inclusiveGateway: GatewayNode,
|
||||
complexGateway: GatewayNode,
|
||||
eventBasedGateway: GatewayNode,
|
||||
hint: HintNode,
|
||||
dataObject: DataObjectNode,
|
||||
};
|
||||
|
||||
+49
-2
@@ -5,7 +5,36 @@ export type NodeKind =
|
||||
| 'endEvent'
|
||||
| 'exclusiveGateway'
|
||||
| 'parallelGateway'
|
||||
| 'inclusiveGateway';
|
||||
| 'inclusiveGateway'
|
||||
| 'complexGateway'
|
||||
| 'eventBasedGateway'
|
||||
| 'hint'
|
||||
| 'dataObject';
|
||||
|
||||
/** BPMN task subtypes — rendered as a small icon badge in the shape's corner. */
|
||||
export type TaskSubtype =
|
||||
| 'none'
|
||||
| 'user'
|
||||
| 'service'
|
||||
| 'script'
|
||||
| 'manual'
|
||||
| 'businessRule'
|
||||
| 'send'
|
||||
| 'receive';
|
||||
|
||||
/** BPMN event trigger subtypes — which ones are valid depends on the event kind (start/intermediate/end). */
|
||||
export type EventSubtype =
|
||||
| 'none'
|
||||
| 'message'
|
||||
| 'timer'
|
||||
| 'conditional'
|
||||
| 'signal'
|
||||
| 'error'
|
||||
| 'escalation'
|
||||
| 'compensation'
|
||||
| 'terminate'
|
||||
| 'multiple'
|
||||
| 'parallelMultiple';
|
||||
|
||||
export interface DiagramNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
@@ -13,6 +42,12 @@ export interface DiagramNodeData extends Record<string, unknown> {
|
||||
responsible?: string;
|
||||
duration?: string;
|
||||
color?: string;
|
||||
/** Corner rounding, in px — only meaningful for the rectangular task shape. */
|
||||
borderRadius?: number;
|
||||
textColor?: string;
|
||||
fontSize?: number;
|
||||
/** BPMN subtype — a TaskSubtype for `task` nodes, an EventSubtype for event nodes. */
|
||||
subtype?: TaskSubtype | EventSubtype;
|
||||
}
|
||||
|
||||
export interface DiagramNode {
|
||||
@@ -28,7 +63,19 @@ export interface DiagramNode {
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export interface DiagramEdgeData extends Record<string, unknown> {}
|
||||
export type EdgeLineStyle = 'solid' | 'dashed' | 'dotted';
|
||||
|
||||
export interface DiagramEdgeData extends Record<string, unknown> {
|
||||
strokeWidth?: number;
|
||||
lineStyle?: EdgeLineStyle;
|
||||
/** Corner rounding for the smoothstep path, in px. */
|
||||
borderRadius?: number;
|
||||
/** Stroke color — also tints the arrowhead marker and the label's background chip. */
|
||||
color?: string;
|
||||
/** Label text color — the chip's own background still follows `color`. */
|
||||
textColor?: string;
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
export interface DiagramEdge {
|
||||
id: string;
|
||||
|
||||
@@ -14,5 +14,20 @@ export default defineConfig(({ mode }) => {
|
||||
// docs/bitrix-integration.md §7.
|
||||
base: env.VITE_BASE_PATH || '/',
|
||||
plugins: [react()],
|
||||
build: {
|
||||
// No content hashes in output filenames — cache-busting on redeploy is
|
||||
// handled at the hosting/nginx level instead (see docs/bitrix-integration.md).
|
||||
// Flat layout (no "assets/" subfolder): one deployment target's own
|
||||
// folder is literally named "assets" (local/modules/vniigaz.bpmn/assets/),
|
||||
// and nesting output under another "assets/" there doubled the path
|
||||
// segment (.../assets/assets/index.js, 404). Flat works everywhere.
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: '[name].js',
|
||||
assetFileNames: '[name].[ext]',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user