Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a4f463bf2 | ||
|
|
762f43dad4 | ||
|
|
e8c05d307c | ||
|
|
0d714ab38e | ||
|
|
1a9f24e53f | ||
|
|
6e420470b3 | ||
|
|
7a7fe80c94 | ||
|
|
33939dffaf | ||
|
|
99d48f393a | ||
|
|
88997a338b | ||
|
|
6692028045 | ||
|
|
231b78c8d1 | ||
|
|
5b8aaf0577 | ||
|
|
4852e55964 | ||
|
|
52e28e8dc6 | ||
|
|
d823b3df47 |
@@ -1,32 +1,12 @@
|
|||||||
# React + TypeScript + Vite
|
Стек: React + TypeScript + Vite
|
||||||
|
Библиотека для диаграмм: xyflow/react (https://www.npmjs.com/package/@xyflow/react)
|
||||||
|
Линтер: oxlint
|
||||||
|
Библиотека компонентов: consta (https://www.npmjs.com/package/@consta/uikit)
|
||||||
|
Стили (цвета) взяты из презентации
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
## Документация
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
- [`docs/portal-front-integration.md`](docs/portal-front-integration.md) — связка с portal-front: запуск в dev, деплой в prod, тестовый стенд, диагностика
|
||||||
|
- [`docs/backend-quickstart.md`](docs/backend-quickstart.md) — короткий гайд по встраиванию в поле Bitrix
|
||||||
|
- [`docs/bitrix-integration.md`](docs/bitrix-integration.md) — полная спецификация для бэкенда: контракт postMessage, формат JSON, варианты деплоя
|
||||||
|
|
||||||
- [@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.
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Гайд: как встроить конструктор диаграмм в поле Bitrix
|
||||||
|
|
||||||
|
Короткая практическая версия — только три вопроса: куда положить билд, как собрать HTML и куда писать начальное значение. Про полноценный переиспользуемый тип свойства инфоблока (регистрация, `ConvertToDB` и т.п.) — отдельный документ, [`docs/bitrix-integration.md`](./bitrix-integration.md). Если поле одно и на одной странице — этого гайда достаточно.
|
||||||
|
|
||||||
|
## 1. Куда положить билд конструктора
|
||||||
|
|
||||||
|
Приложение — статическая сборка (`npm run build` → `dist/`), которая раздаётся тем же nginx, что и сам портал. Ничего разворачивать отдельно не нужно.
|
||||||
|
|
||||||
|
1. Собрать в репозитории `portal-chart-builder`:
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
- Переменные окружения не нужны. Ссылки на JS/CSS внутри `index.html` относительные (`base: './'` в `vite.config.ts`), поэтому сборка работает из любого каталога, куда её положат, — подпуть на этапе сборки знать не требуется.
|
||||||
|
- `VITE_BITRIX_ORIGIN` здесь **не нужен**: приложение и портал на одном домене, origin хоста приложение берёт из `window.location.origin` в рантайме. Задавать переменную нужно только если хост на другом origin — например, локальная разработка (dev-сервер хоста и `vite preview` этого приложения на разных портах) или деплой отдельным контейнером (см. `bitrix-integration.md`, раздел 7, вариант B).
|
||||||
|
2. Скопировать содержимое `dist/` в `local/diagram-app/` внутри каталога сайта на сервере (тот же каталог, где лежит остальной код портала — `bitrix_www`/аналог на проде).
|
||||||
|
3. Готово — но с оговоркой: приложение доступно **не** по фиксированному `index.html`.
|
||||||
|
|
||||||
|
**Имя `index.html` в `dist/` — с хешем содержимого**, например `index-2fddeca6.html`, а не просто `index.html` (см. `vite.config.ts`, плагин `hashIndexHtml` — хеширует и сам HTML-файл, не только JS/CSS, которые Vite и так хеширует по умолчанию). Хеш меняется при каждой значимой пересборке и не меняется, если содержимое не менялось. Значит:
|
||||||
|
- **`src` у iframe нельзя хардкодить строкой** — нужно на своей стороне подставлять актуальное имя файла. Готовый способ: рядом с билдом сборка кладёт `app-manifest.js` со **стабильным** именем и актуальным именем точки входа внутри — `{ "index": "index-2fddeca6.html" }`. Прочитать его можно и с бэкенда, и прямо из браузера (`fetch('app-manifest.js', { cache: 'no-store' })`). Альтернативы — взять единственный `index-*.html` в каталоге или отдать имя отдельным методом; выбор за бэкендом.
|
||||||
|
- Ручной cache-busting (`?v=...`) для JS/CSS/HTML **больше не нужен**: раз URL меняется вместе с содержимым, старый закэшированный файл просто никогда больше не запрашивается, как только известно новое имя.
|
||||||
|
- **После каждого редеплоя старые `index-<hash>.html`/`index-<hash>.js`/`index-<hash>.css` от прошлых версий стоит убирать** из `local/diagram-app/` — они не удаляются сами, и если способ поиска актуального файла — «взять единственный файл по маске», их накопление сломает эту логику.
|
||||||
|
|
||||||
|
Если схему нужно только показать, без правки, — добавьте к `src` iframe `?mode=view`: редактор откроется в режиме просмотра, переключатель режимов будет скрыт (параметр дописывается к уже вычисленному актуальному имени файла).
|
||||||
|
|
||||||
|
## 2. Как сформировать HTML
|
||||||
|
|
||||||
|
Три части: скрытое поле со значением, iframe с приложением, скрипт-мост между ними.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<input type="hidden" id="DIAGRAM-DATA" name="DIAGRAM-DATA" value="<?= htmlspecialcharsbx($currentValue) ?>">
|
||||||
|
<iframe id="diagram-frame" src="/local/diagram-app/<?= htmlspecialcharsbx($actualIndexFileName) ?>" style="width:100%;height:600px;border:1px solid #d7dae0;border-radius:6px"></iframe>
|
||||||
|
<!-- $actualIndexFileName — актуальное имя index-<hash>.html, см. раздел 1 -->
|
||||||
|
|
||||||
|
<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.
|
||||||
+44
-13
@@ -41,13 +41,15 @@ class DiagramProperty
|
|||||||
{
|
{
|
||||||
$inputName = htmlspecialcharsbx($strHTMLControlName['VALUE']);
|
$inputName = htmlspecialcharsbx($strHTMLControlName['VALUE']);
|
||||||
$rawValue = $value['VALUE'] ?? '';
|
$rawValue = $value['VALUE'] ?? '';
|
||||||
// JSON_HEX_* флаги обязательны — см. раздел 5.
|
|
||||||
$jsValue = json_encode($rawValue, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT);
|
|
||||||
|
|
||||||
// Вариант A — рекомендуемый, приложение лежит внутри дерева портала (см. раздел 7):
|
// Вариант A — рекомендуемый, приложение лежит внутри дерева портала (см. раздел 7):
|
||||||
// appOrigin — это просто origin текущего запроса, appUrl — конкретный файл.
|
// appOrigin — это просто origin текущего запроса, appUrl — конкретный файл.
|
||||||
|
// ВАЖНО: имя файла — не константа. dist/index.html выходит из сборки как
|
||||||
|
// index-<hash>.html (хеш меняется при каждой значимой пересборке, см.
|
||||||
|
// раздел 7) — 'index.html' строкой здесь писать нельзя, нужно получать
|
||||||
|
// актуальное имя каким-то способом на своей стороне (см. раздел 7).
|
||||||
$appOrigin = (CMain::IsHTTPS() ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'];
|
$appOrigin = (CMain::IsHTTPS() ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'];
|
||||||
$appUrl = $appOrigin . '/local/diagram-app/index.html';
|
$appUrl = $appOrigin . '/local/diagram-app/' . GetActualDiagramAppIndexFile(); // пример, не готовая функция
|
||||||
// Вариант B — если приложение развёрнуто отдельным контейнером (см. раздел 7),
|
// Вариант B — если приложение развёрнуто отдельным контейнером (см. раздел 7),
|
||||||
// appUrl/appOrigin вместо этого указывают на его отдельный адрес, например:
|
// appUrl/appOrigin вместо этого указывают на его отдельный адрес, например:
|
||||||
// $appOrigin = 'http://localhost:5178';
|
// $appOrigin = 'http://localhost:5178';
|
||||||
@@ -60,8 +62,11 @@ class DiagramProperty
|
|||||||
style="width:100%;height:600px;border:1px solid #ccc"></iframe>
|
style="width:100%;height:600px;border:1px solid #ccc"></iframe>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
// Читаем initialValue из самого input.value, а не встраиваем JSON в
|
||||||
|
// инлайн-скрипт — так не нужны JSON_HEX_* флаги вообще (см. раздел 5):
|
||||||
|
// htmlspecialcharsbx() выше уже достаточно экранирует значение
|
||||||
|
// HTML-атрибута, а браузер сам декодирует его обратно в текст.
|
||||||
var appOrigin = <?= json_encode($appOrigin) ?>;
|
var appOrigin = <?= json_encode($appOrigin) ?>;
|
||||||
var initialValue = <?= $jsValue ?>; // строка JSON или ""
|
|
||||||
var input = document.getElementById(<?= json_encode($inputName) ?>);
|
var input = document.getElementById(<?= json_encode($inputName) ?>);
|
||||||
var frame = document.getElementById('diagram-frame-<?= $inputName ?>');
|
var frame = document.getElementById('diagram-frame-<?= $inputName ?>');
|
||||||
|
|
||||||
@@ -71,7 +76,7 @@ class DiagramProperty
|
|||||||
|
|
||||||
if (event.data.type === 'diagram:ready') {
|
if (event.data.type === 'diagram:ready') {
|
||||||
frame.contentWindow.postMessage(
|
frame.contentWindow.postMessage(
|
||||||
{ type: 'diagram:init', payload: initialValue || null },
|
{ type: 'diagram:init', payload: input.value || null },
|
||||||
appOrigin // никогда '*', см. раздел 5
|
appOrigin // никогда '*', см. раздел 5
|
||||||
);
|
);
|
||||||
} else if (event.data.type === 'diagram:change') {
|
} else if (event.data.type === 'diagram:change') {
|
||||||
@@ -119,9 +124,21 @@ class DiagramProperty
|
|||||||
|
|
||||||
`diagram:init` с `payload: null` (или пустой строкой) — валидный случай: новый элемент без диаграммы, приложение стартует с чистого холста.
|
`diagram:init` с `payload: null` (или пустой строкой) — валидный случай: новый элемент без диаграммы, приложение стартует с чистого холста.
|
||||||
|
|
||||||
|
### Режим только для просмотра
|
||||||
|
|
||||||
|
Хост может открыть приложение на чтение, добавив `?mode=view` к URL iframe:
|
||||||
|
|
||||||
|
```
|
||||||
|
/local/diagram-app/index.html?mode=view
|
||||||
|
```
|
||||||
|
|
||||||
|
Тогда редактор стартует в режиме «Просмотр» и переключатель «Просмотр/Редактирование» скрыт — фигуры нельзя двигать, создавать и удалять. Без параметра поведение прежнее: редактирование с переключателем. Параметр читается один раз при загрузке (`src/App.tsx`), в контракте `postMessage` ничего не меняется — `diagram:init`/`diagram:change` работают одинаково в обоих режимах.
|
||||||
|
|
||||||
|
Зачем: встраивающая сторона, которая не слушает `diagram:change` (например, страница портала, показывающая утверждённую схему), иначе предлагала бы правки, которые молча никуда не сохраняются.
|
||||||
|
|
||||||
## 5. Требования безопасности (обязательны, не опциональны)
|
## 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).
|
- `event.origin` проверяется **в обе стороны**: и в мосте (когда он слушает сообщения от iframe), и в приложении (уже реализовано во frontend).
|
||||||
- `targetOrigin` у любого `postMessage` — всегда конкретный домен (`appOrigin`), **никогда `'*'`**.
|
- `targetOrigin` у любого `postMessage` — всегда конкретный домен (`appOrigin`), **никогда `'*'`**.
|
||||||
- `name` у hidden input должен **точно** совпадать с `$strHTMLControlName['VALUE']` — это единственная связь между кастомным рендером и штатным сохранением формы.
|
- `name` у hidden input должен **точно** совпадать с `$strHTMLControlName['VALUE']` — это единственная связь между кастомным рендером и штатным сохранением формы.
|
||||||
@@ -196,20 +213,32 @@ Backend не обязан ничего валидировать глубже «
|
|||||||
Что это даёт технически:
|
Что это даёт технически:
|
||||||
- Nginx-конфиг стенда отдаёт файлы по расширению (`js|css|html|svg|png|...`) напрямую с диска — это срабатывает раньше, чем запрос попадёт в PHP-роутер Bitrix. Любой статический файл под корнем сайта отдаётся сам, никаких правок nginx не требуется.
|
- Nginx-конфиг стенда отдаёт файлы по расширению (`js|css|html|svg|png|...`) напрямую с диска — это срабатывает раньше, чем запрос попадёт в PHP-роутер Bitrix. Любой статический файл под корнем сайта отдаётся сам, никаких правок nginx не требуется.
|
||||||
- `local/` уже существует как каталог для кастомного кода (`ai`, `components`, `modules`, `php_interface`, `templates` — по факту на дев-стенде). Новый подкаталог, например `local/diagram-app/`, вписывается в эту конвенцию и не пересекается с `local/modules`/`local/php_interface` — они у nginx явно помечены `internal` (заблокированы для прямого внешнего доступа), новый подкаталог под этот блок не попадает.
|
- `local/` уже существует как каталог для кастомного кода (`ai`, `components`, `modules`, `php_interface`, `templates` — по факту на дев-стенде). Новый подкаталог, например `local/diagram-app/`, вписывается в эту конвенцию и не пересекается с `local/modules`/`local/php_interface` — они у nginx явно помечены `internal` (заблокированы для прямого внешнего доступа), новый подкаталог под этот блок не попадает.
|
||||||
- Нюанс: `index` на уровне сервера — `index.php`, не `index.html`. Открыть голый `/local/diagram-app/` (без имени файла) не выйдет — nginx попытается найти там `index.php` и не найдёт. Не проблема: PHP-класс (раздел 3) сам подставляет `src` у iframe, поэтому в `$appUrl` достаточно явно указывать файл — `/local/diagram-app/index.html`.
|
- Нюанс: `index` на уровне сервера — `index.php`, не `index.html`. Открыть голый `/local/diagram-app/` (без имени файла) не выйдет — nginx попытается найти там `index.php` и не найдёт. PHP-класс (раздел 3) сам подставляет `src` у iframe — но, в отличие от более ранней версии этого документа, **больше нельзя просто написать `/local/diagram-app/index.html` строкой** — см. следующий пункт.
|
||||||
- Так как приложение и портал теперь на одном домене, `VITE_BITRIX_ORIGIN`/`appOrigin` — это просто origin самого портала, а не адрес отдельного сервиса.
|
- Так как приложение и портал теперь на одном домене, `VITE_BITRIX_ORIGIN`/`appOrigin` — это просто origin самого портала, а не адрес отдельного сервиса.
|
||||||
- **Важный нюанс, найден живым тестом на дев-стенде:** тот же regex-location в nginx, что раздаёт статику, ставит `.html`-файлам `Cache-Control: public` с `expires 30d`. Значит после **повторного** деплоя (обновили React-приложение, положили новый `dist/` поверх старого) браузеры, уже открывавшие `/local/diagram-app/index.html` раньше, могут до 30 дней получать из своего кэша старый `index.html` — а в нём старые хэши JS/CSS-файлов, которых уже нет на диске (404) или которые не соответствуют новой версии приложения. При первом же открытии URL это не проявляется (кэшировать ещё нечего), но при каждом обновлении приложения — да. Решение: в `$appUrl`/`src` iframe добавлять cache-busting query-параметр, привязанный к версии сборки (например, `?v=<git-hash>` или `?v=<дата билда>`), а не просто `/local/diagram-app/index.html` без параметров — тогда каждая новая версия получает свой URL и обходит браузерный кэш. То же самое верно и для самой страницы со свойством (если её тоже будете кэшировать).
|
- **Имена файлов в `dist/` — с хешем содержимого, включая сам `index.html`.** `index.js`/`index.css`/шрифты хеширует сам Vite при сборке (стандартное поведение, как в любом Vite-проекте). Кроме того, в `vite.config.ts` есть небольшой плагин (`hashIndexHtml`), который **дополнительно хеширует и сам `index.html`** — на выходе получается что-то вроде `index-2fddeca6.html`, а не просто `index.html`. Имя меняется всякий раз, когда меняется что-то значимое (сама HTML-обёртка либо то, что она подключает), и не меняется при пересборке без реальных изменений.
|
||||||
|
|
||||||
|
Из этого следует главное отличие от более ранней версии данного документа: **`$appUrl` в PHP-классе (раздел 3) больше нельзя хардкодить как строку** — конкретное имя `index-<hash>.html` меняется при каждой пересборке приложения. Бэкенду нужен свой способ узнавать актуальное имя файла на момент рендера страницы (например, взять единственный `index-*.html` из каталога деплоя, или через отдельный метод/эндпоинт, который вернёт актуальное имя, — механизм на усмотрение бэкенда, здесь не фиксирован, уточняйте на момент внедрения).
|
||||||
|
|
||||||
|
**Готовый механизм — `app-manifest.js` рядом с билдом.** Тот же плагин `hashIndexHtml` кладёт в `dist/` файл со стабильным, никогда не меняющимся именем:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "index": "index-2fddeca6.html" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Его можно прочитать как с бэкенда при рендере страницы, так и на клиенте — `fetch('<каталог>/app-manifest.js', { cache: 'no-store' })`, — и уже к полученному имени дописывать `?mode=view`. `no-store` здесь обязателен: имя файла стабильно, значит сам манифест попадает под тот же 30-дневный `Cache-Control`, что и остальная статика. Этим сейчас пользуется portal-front (`src/helpers/chart-builder.ts`), пока у бэка нет своего метода.
|
||||||
|
|
||||||
|
Взамен это полностью снимает необходимость в отдельном cache-busting query-параметре (`?v=...`) — раньше он был обязателен и для самого `index.html`, и (до появления хеша у самого файла) для `index.js`/`index.css` внутри него. Теперь URL меняется вместе с содержимым: старый закэшированный `index-OLDHASH.html` просто никогда больше не запрашивается, как только хост узнаёт новое имя — что убирает разом оба уровня кэш-проблемы, описанной в более ранней версии этого раздела.
|
||||||
|
|
||||||
Порядок действий:
|
Порядок действий:
|
||||||
|
|
||||||
1. В репозитории `portal-chart-builder` собрать статику с двумя build-time переменными:
|
1. В репозитории `portal-chart-builder` собрать статику с двумя build-time переменными:
|
||||||
```
|
```
|
||||||
VITE_BITRIX_ORIGIN=https://ВАШ-ДОМЕН-ПОРТАЛА VITE_BASE_PATH=/local/diagram-app/ npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
`VITE_BASE_PATH` — путь, под которым будет отдаваться приложение (нужен, чтобы ссылки на JS/CSS внутри `dist/index.html` были корректными под этим подпутём; см. `vite.config.ts`). По умолчанию, если переменная не задана, `base` — `/` (не влияет на локальную разработку, `npm run dev`).
|
Переменные окружения не нужны. `VITE_BITRIX_ORIGIN` в этом варианте не задаётся — см. примечание в конце раздела. Пути к JS/CSS внутри `index.html` относительные (`base: './'` в `vite.config.ts`), поэтому один и тот же `dist/` работает из любого каталога размещения — раньше это задавалось переменной `VITE_BASE_PATH`, которая должна была точно совпадать с каталогом деплоя и при расхождении давала пустой iframe (html грузится, его JS уходит в 404).
|
||||||
2. Скопировать содержимое `dist/` в `local/diagram-app/` внутри host-каталога, который смонтирован как `bitrix_www` (на текущем дев-стенде это `/home/nikit/bitrix/www/local/diagram-app/` — точный путь для конкретного стенда уточняется у того, кто его поднимал/администрирует).
|
2. Скопировать содержимое `dist/` в `local/diagram-app/` внутри host-каталога, который смонтирован как `bitrix_www` (на текущем дев-стенде это `/home/nikit/bitrix/www/local/diagram-app/` — точный путь для конкретного стенда уточняется у того, кто его поднимал/администрирует).
|
||||||
3. В PHP-классе (раздел 3): `$appUrl = $appOrigin . '/local/diagram-app/index.html';`, `$appOrigin` — origin портала (без пути).
|
3. В PHP-классе (раздел 3): `$appUrl = $appOrigin . '/local/diagram-app/' . $actualIndexFileName;`, `$appOrigin` — origin портала (без пути), `$actualIndexFileName` — актуальное имя `index-<hash>.html` в `local/diagram-app/`, полученное способом на усмотрение бэкенда (см. врезку про кэш выше — хардкодить `index.html` строкой уже нельзя).
|
||||||
4. Обновление после правок в React-приложении — тот же `npm run build` + копирование поверх старых файлов. Пересборка/перезапуск контейнеров Bitrix не нужны.
|
4. Обновление после правок в React-приложении — тот же `npm run build` + копирование поверх старых файлов. Пересборка/перезапуск контейнеров Bitrix не нужны. Старый `index-<hash>.html` от предыдущей сборки при этом не удаляется автоматически — если способ поиска актуального файла (шаг 3) предполагает «взять единственный файл по маске», после нескольких деплоев в каталоге стоит убирать файлы прошлых версий, иначе маска перестанет быть однозначной.
|
||||||
|
|
||||||
### Вариант B — отдельный Docker-контейнер
|
### Вариант B — отдельный Docker-контейнер
|
||||||
|
|
||||||
@@ -244,7 +273,9 @@ services:
|
|||||||
|
|
||||||
Собрал и проверил локально (`docker build` + `docker run -p 5178:80`) — контейнер поднимается и корректно отдаёт приложение по `http://localhost:5178`.
|
Собрал и проверил локально (`docker build` + `docker run -p 5178:80`) — контейнер поднимается и корректно отдаёт приложение по `http://localhost:5178`.
|
||||||
|
|
||||||
**Важно (общее для обоих вариантов):** `VITE_BITRIX_ORIGIN` — build-time переменная, вшивается в JS-бандл на этапе `npm run build`/`docker build`, не читается заново при каждом запуске/обновлении. Без неё приложение намеренно упадёт при загрузке (см. раздел 5) — чтобы нельзя было собрать билд с `postMessage('*')`.
|
**Важно (общее для обоих вариантов):** `VITE_BITRIX_ORIGIN` — build-time переменная, вшивается в JS-бандл на этапе `npm run build`/`docker build`, не читается заново при каждом запуске/обновлении.
|
||||||
|
|
||||||
|
В варианте A она **не нужна**: приложение и портал на одном домене, и origin хоста приложение берёт из `window.location.origin`. Более того, заданная переменная там вредна — она перебьёт рантайм-значение, и на любом домене, кроме зашитого, обмен молча не состоится. Нужна она только в варианте B (или при локальной разработке), где хост и приложение на разных origin. Требование «никогда `postMessage('*')`» соблюдается в обоих случаях: targetOrigin всегда конкретный origin.
|
||||||
|
|
||||||
Схема с `localhost` (вариант B) работает, пока браузер администратора и Docker-хост — одна машина (текущий дев-стенд). Если позже появится реальный удалённый прод с админами не с этой машины — понадобится либо настоящий домен, либо reverse-proxy; это отдельная, более поздняя задача. Вариант A этой проблемы не имеет вообще, так как всегда живёт на домене портала.
|
Схема с `localhost` (вариант B) работает, пока браузер администратора и Docker-хост — одна машина (текущий дев-стенд). Если позже появится реальный удалённый прод с админами не с этой машины — понадобится либо настоящий домен, либо reverse-proxy; это отдельная, более поздняя задача. Вариант A этой проблемы не имеет вообще, так как всегда живёт на домене портала.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Связка с portal-front: запуск в dev, деплой в prod, тестовый стенд
|
||||||
|
|
||||||
|
Документ про встраивание конструктора в **portal-front** — React-приложение
|
||||||
|
портала. Про встраивание в админку Bitrix (пользовательский тип свойства
|
||||||
|
инфоблока) — отдельные документы:
|
||||||
|
[`bitrix-integration.md`](./bitrix-integration.md) и
|
||||||
|
[`backend-quickstart.md`](./backend-quickstart.md).
|
||||||
|
|
||||||
|
## 1. Как это устроено
|
||||||
|
|
||||||
|
Портал открывает конструктор в `<iframe>` **в режиме просмотра** и передаёт ему
|
||||||
|
схему через `postMessage`. Три вещи, которые надо знать, прежде чем что-то
|
||||||
|
запускать.
|
||||||
|
|
||||||
|
**Имя точки входа не константа.** Сборка выдаёт `index-<hash>.html`
|
||||||
|
(см. `vite.config.ts`, плагин `hashIndexHtml`), имя меняется при каждой
|
||||||
|
значимой пересборке. Рядом кладётся манифест со стабильным именем:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "index": "index-300d7ef5.html" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Портал читает его при открытии модалки — `fetch('app-manifest.js', { cache: 'no-store' })`
|
||||||
|
в `src/helpers/chart-builder.ts` — и уже к полученному имени дописывает
|
||||||
|
параметры. Поэтому выкладка конструктора **не требует пересборки портала**.
|
||||||
|
|
||||||
|
**Режим просмотра включается параметром.** Портал сам дописывает `?mode=view`:
|
||||||
|
редактор стартует в просмотре, переключатель режимов скрыт, `diagram:change`
|
||||||
|
портал игнорирует. В этом режиме приложение **всегда** берёт origin хоста из
|
||||||
|
`window.location.origin` и игнорирует `VITE_BITRIX_ORIGIN` — переменная нужна
|
||||||
|
только админке Bitrix, где встраивание идёт в режиме редактирования.
|
||||||
|
|
||||||
|
**Пути внутри сборки относительные** (`base: './'`), поэтому один и тот же
|
||||||
|
`dist/` работает из любого каталога размещения и переменной с путём больше нет.
|
||||||
|
|
||||||
|
Контракт обмена — `bitrix-integration.md`, раздел 4: приложение шлёт
|
||||||
|
`diagram:ready`, хост в ответ `diagram:init` с JSON-строкой схемы. На стороне
|
||||||
|
портала это `src/hooks/useDiagramBridge.ts`; если `diagram:ready` не пришёл за
|
||||||
|
10 секунд, в модалке появляется «Не удалось загрузить схему».
|
||||||
|
|
||||||
|
## 2. Dev
|
||||||
|
|
||||||
|
Портал на `http://localhost:5173`, конструктор на `http://localhost:5800`.
|
||||||
|
Порты разные, но портал проксирует конструктор на свой origin — без этого
|
||||||
|
режим просмотра не заработает (приложение отправит сообщения на 5800, то есть
|
||||||
|
самому себе, а не порталу).
|
||||||
|
|
||||||
|
Прокси уже прописан в `vite.config.ts` портала:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
'/local/modules/vniigaz.bpmn/assets': {
|
||||||
|
target: 'http://localhost:5800',
|
||||||
|
changeOrigin: false,
|
||||||
|
rewrite: (path) => path.replace('/local/modules/vniigaz.bpmn/assets', '')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Порядок запуска:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\Development\Work\portal-chart-builder; npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\Development\Work\portal-chart-builder; npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Порт 5800 зафиксирован в `vite.config.ts` (`preview.port` + `strictPort`), флаги
|
||||||
|
передавать не нужно. Если порт занят, `preview` упадёт с ошибкой, а не уедет на
|
||||||
|
соседний порт молча — иначе прокси портала указывал бы в пустоту.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\Development\Work\portal-front; npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Переменные окружения не нужны ни там, ни там.
|
||||||
|
|
||||||
|
`npm run dev` самого конструктора для связки **не подходит**: `postMessage`-мост
|
||||||
|
включается только в production-сборке (`isProd` в `src/App.tsx`), в dev работает
|
||||||
|
`devAdapter` поверх localStorage. После правок в конструкторе — заново
|
||||||
|
`npm run build`; `preview` перезапускать не нужно, он читает файлы с диска.
|
||||||
|
|
||||||
|
Проверка, что связка поднята:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -s http://localhost:5173/local/modules/vniigaz.bpmn/assets/app-manifest.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Пустой ответ — не поднят `preview` или не собран конструктор.
|
||||||
|
|
||||||
|
## 3. Prod (портал, бэк и конструктор на одном домене)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\Development\Work\portal-chart-builder; npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Дальше **очистить** каталог `local/modules/vniigaz.bpmn/assets/` в дереве сайта
|
||||||
|
и скопировать туда содержимое `dist/`. Именно очистить: имена файлов содержат
|
||||||
|
хеш, старые сборки не перезаписываются, а накапливаются — и ломают запасной
|
||||||
|
способ поиска точки входа «взять единственный `index-*.html`».
|
||||||
|
|
||||||
|
Портал пересобирается **только когда меняется сам портал**:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\Development\Work\portal-front; npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка после выкладки:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -s https://ВАШ-ДОМЕН/local/modules/vniigaz.bpmn/assets/app-manifest.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Имя в ответе должно совпадать с реально лежащим файлом. Открыть `index-<hash>.html`
|
||||||
|
напрямую в браузере тоже полезно: вечное «Загрузка диаграммы…» там — норма,
|
||||||
|
приложение ждёт `diagram:init` от родителя, которого при прямом открытии нет.
|
||||||
|
Значит, статика раздаётся правильно.
|
||||||
|
|
||||||
|
## 4. Тестовый стенд и админка Bitrix
|
||||||
|
|
||||||
|
Отличие одно: там встраивание идёт **в режиме редактирования**, без `?mode=view`,
|
||||||
|
и только в этом случае учитывается `VITE_BITRIX_ORIGIN`.
|
||||||
|
|
||||||
|
Рекомендация — не задавать её вообще (удалить `.env.production.local`): origin
|
||||||
|
возьмётся из `window.location.origin` и совпадёт с любым адресом, по которому
|
||||||
|
открыли админку — `localhost:8590`, IP или домен. Приложение и админка на одном
|
||||||
|
домене, так что это корректно всегда.
|
||||||
|
|
||||||
|
Если переменная всё-таки задаётся, её значение должно совпадать с адресной
|
||||||
|
строкой браузера **посимвольно, включая порт**: `http://192.168.100.146` и
|
||||||
|
`http://192.168.100.146:8590` — разные origin. При расхождении обмен молча не
|
||||||
|
состоится, ошибок в консоли не будет.
|
||||||
|
|
||||||
|
Второе требование к встраивающей стороне — не писать имя `index-<hash>.html`
|
||||||
|
строкой, а брать актуальное из `app-manifest.js` (или искать единственный `index-*.html`,
|
||||||
|
но тогда очистка каталога при деплое обязательна). Подробности — в
|
||||||
|
`backend-quickstart.md`, раздел 1.
|
||||||
|
|
||||||
|
## 5. Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Dev | Prod (портал) | Админка Bitrix |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `VITE_BASE_PATH` | не существует, `base: './'` | то же | то же |
|
||||||
|
| `VITE_BITRIX_ORIGIN` | игнорируется в `mode=view` | игнорируется в `mode=view` | учитывается; лучше не задавать |
|
||||||
|
|
||||||
|
## 6. Если схема не появилась
|
||||||
|
|
||||||
|
| Что видно | Причина | Что делать |
|
||||||
|
|---|---|---|
|
||||||
|
| «Не удалось загрузить схему» в модалке | манифест не отдался либо не пришёл `diagram:ready` за 10 секунд | проверить `app-manifest.js` через curl, затем в Network — дошёл ли `index-<hash>.js` |
|
||||||
|
| Пустой iframe, в консоли 404 на `index-<hash>.js` | файлы сборки не докопированы или скопированы не в тот каталог | перекопировать `dist/` целиком |
|
||||||
|
| Приложение висит на «Загрузка диаграммы…» | `diagram:ready` ушёл не туда: origin хоста не совпал с ожидаемым | для админки — сверить `VITE_BITRIX_ORIGIN` с адресной строкой; для dev — проверить, что конструктор открывается через прокси портала, а не напрямую с 5800 |
|
||||||
|
| Портал открывает старую версию схемы | в каталоге лежат файлы прошлых сборок, `app-manifest.js` указывает на новый, а старый html закеширован браузером | очистить каталог при деплое; хеш в имени сам по себе кэш не победит, если старый файл остаётся на месте |
|
||||||
Generated
+391
-4
@@ -8,6 +8,8 @@
|
|||||||
"name": "portal-chart-builder",
|
"name": "portal-chart-builder",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@consta/icons": "^1.7.1",
|
||||||
|
"@consta/uikit": "^5.33.2",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.7"
|
||||||
@@ -22,6 +24,71 @@
|
|||||||
"vite": "^8.1.1"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
@@ -408,6 +475,85 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"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": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
"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"
|
"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": {
|
"node_modules/classcat": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-color": {
|
"node_modules/d3-color": {
|
||||||
@@ -948,6 +1122,17 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -958,6 +1143,17 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"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": "^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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -1252,6 +1481,19 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"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": "^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": {
|
"node_modules/oxlint": {
|
||||||
"version": "1.73.0",
|
"version": "1.73.0",
|
||||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.73.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.73.0.tgz",
|
||||||
@@ -1369,6 +1621,18 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||||
@@ -1390,6 +1654,83 @@
|
|||||||
"react": "^19.2.7"
|
"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": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||||
@@ -1461,9 +1802,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
@@ -1486,6 +1825,54 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@consta/icons": "^1.7.1",
|
||||||
|
"@consta/uikit": "^5.33.2",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^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 */
|
||||||
|
}
|
||||||
+28
-3
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Theme, presetGpnDefault } from '@consta/uikit/Theme';
|
||||||
import { DiagramEditor } from './DiagramEditor';
|
import { DiagramEditor } from './DiagramEditor';
|
||||||
import { createDevAdapter } from './host/devAdapter';
|
import { createDevAdapter } from './host/devAdapter';
|
||||||
import { createBitrixAdapter } from './host/bitrixAdapter';
|
import { createBitrixAdapter } from './host/bitrixAdapter';
|
||||||
@@ -63,8 +64,24 @@ const SAMPLE_DOCUMENT: DiagramDocument = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хост может открыть приложение только на просмотр, добавив `?mode=view`
|
||||||
|
* к URL iframe (портал встраивает схему как картинку и `diagram:change`
|
||||||
|
* не слушает). Без параметра — прежнее поведение, редактирование.
|
||||||
|
*/
|
||||||
|
function getLockedMode(): "view" | undefined {
|
||||||
|
return new URLSearchParams(window.location.search).get("mode") === "view" ?
|
||||||
|
"view"
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockedMode = getLockedMode();
|
||||||
|
|
||||||
const isProd = import.meta.env.PROD;
|
const isProd = import.meta.env.PROD;
|
||||||
const adapter = isProd ? createBitrixAdapter() : createDevAdapter();
|
const adapter =
|
||||||
|
isProd ?
|
||||||
|
createBitrixAdapter({ ignoreConfiguredOrigin: lockedMode === 'view' })
|
||||||
|
: createDevAdapter();
|
||||||
// In Bitrix, no value means a brand-new element — start blank, not with dev demo content.
|
// In Bitrix, no value means a brand-new element — start blank, not with dev demo content.
|
||||||
const fallbackDocument = isProd ? EMPTY_DOCUMENT : SAMPLE_DOCUMENT;
|
const fallbackDocument = isProd ? EMPTY_DOCUMENT : SAMPLE_DOCUMENT;
|
||||||
|
|
||||||
@@ -80,10 +97,18 @@ function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
return <p style={{ padding: 16, fontFamily: 'system-ui, sans-serif' }}>Загрузка диаграммы…</p>;
|
return <div className="diagram-loading">Загрузка диаграммы…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <DiagramEditor initialValue={doc} onChange={handleChange} />;
|
return (
|
||||||
|
<Theme preset={presetGpnDefault} style={{ height: '100%' }}>
|
||||||
|
<DiagramEditor
|
||||||
|
initialValue={doc}
|
||||||
|
onChange={handleChange}
|
||||||
|
lockedMode={lockedMode}
|
||||||
|
/>
|
||||||
|
</Theme>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
+552
-214
File diff suppressed because it is too large
Load Diff
+265
-17
@@ -1,7 +1,72 @@
|
|||||||
|
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 { IconCancel } from "@consta/icons/IconCancel";
|
||||||
|
import { TRANSPARENT_COLOR, toPickerHex } from "./colors";
|
||||||
|
import type { DiagramEdgeData, EdgeLineStyle } from "./types";
|
||||||
|
|
||||||
|
const LINE_STYLE_ITEMS: { label: string; style: EdgeLineStyle }[] = [
|
||||||
|
{ 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 {
|
interface EdgeInspectorProps {
|
||||||
edgeId: string;
|
edgeId: string;
|
||||||
label: string;
|
label: string;
|
||||||
onChange: (label: string) => void;
|
data: DiagramEdgeData;
|
||||||
|
onLabelChange: (label: string) => void;
|
||||||
|
onDataChange: (patch: Partial<DiagramEdgeData>) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onBringToFront: () => void;
|
onBringToFront: () => void;
|
||||||
onSendToBack: () => void;
|
onSendToBack: () => void;
|
||||||
@@ -10,37 +75,220 @@ interface EdgeInspectorProps {
|
|||||||
export function EdgeInspector({
|
export function EdgeInspector({
|
||||||
edgeId,
|
edgeId,
|
||||||
label,
|
label,
|
||||||
onChange,
|
data,
|
||||||
|
onLabelChange,
|
||||||
|
onDataChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
onBringToFront,
|
onBringToFront,
|
||||||
onSendToBack,
|
onSendToBack,
|
||||||
}: EdgeInspectorProps) {
|
}: 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 (
|
return (
|
||||||
<div className="node-inspector">
|
<div className="node-inspector">
|
||||||
<div className="node-inspector__header">
|
<div className="node-inspector__header">
|
||||||
<span className="node-inspector__type">Связь</span>
|
<Text size="s" weight="semibold">
|
||||||
<code className="node-inspector__id">{edgeId}</code>
|
Поток управления (Sequence Flow)
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="node-inspector__toolbar">
|
<div className="node-inspector__toolbar">
|
||||||
<button type="button" onClick={onDelete}>
|
<Button size="s" view="secondary" label="Удалить" onClick={onDelete} />
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="node-inspector__toolbar">
|
<div className="node-inspector__toolbar">
|
||||||
<button type="button" onClick={onBringToFront}>
|
<Button
|
||||||
На передний план
|
size="s"
|
||||||
</button>
|
view="secondary"
|
||||||
<button type="button" onClick={onSendToBack}>
|
label="На передний план"
|
||||||
На задний план
|
onClick={onBringToFront}
|
||||||
</button>
|
/>
|
||||||
|
<Button
|
||||||
|
size="s"
|
||||||
|
view="secondary"
|
||||||
|
label="На задний план"
|
||||||
|
onClick={onSendToBack}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="node-inspector__field">
|
<TextField
|
||||||
Подпись
|
label="Подпись"
|
||||||
<input type="text" value={label} onChange={(e) => onChange(e.target.value)} />
|
size="s"
|
||||||
</label>
|
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 })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/*
|
||||||
|
Цвет линии заодно красит стрелку и подложку подписи (см.
|
||||||
|
labelBgStyle в DiagramEditor) — прозрачный здесь убирает и её.
|
||||||
|
*/}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Прозрачный"
|
||||||
|
aria-label="Прозрачный"
|
||||||
|
className={`node-inspector__preset node-inspector__preset--none${
|
||||||
|
currentColor === TRANSPARENT_COLOR ?
|
||||||
|
" node-inspector__preset--active"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => onDataChange({ color: TRANSPARENT_COLOR })}
|
||||||
|
>
|
||||||
|
<IconCancel size="xs" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref={colorButtonRef} className="node-inspector__color-trigger">
|
||||||
|
<Button
|
||||||
|
size="s"
|
||||||
|
view="secondary"
|
||||||
|
label="Настроить цвет"
|
||||||
|
onClick={() => setColorPickerOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{colorPickerOpen && (
|
||||||
|
<ColorPicker
|
||||||
|
model={hexModel}
|
||||||
|
value={toPickerHex(currentColor, DEFAULT_EDGE_COLOR)}
|
||||||
|
onChange={(value) => onDataChange({ color: value })}
|
||||||
|
anchorRef={colorButtonRef}
|
||||||
|
open={colorPickerOpen}
|
||||||
|
onOpen={setColorPickerOpen}
|
||||||
|
style={{ zIndex: 100 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="node-inspector__field">
|
||||||
|
<Text size="xs" view="secondary">
|
||||||
|
Цвет текста
|
||||||
|
</Text>
|
||||||
|
<div className="node-inspector__presets">
|
||||||
|
{TEXT_COLOR_PRESETS.map(({ hex, label: presetLabel }) => (
|
||||||
|
<button
|
||||||
|
key={hex}
|
||||||
|
type="button"
|
||||||
|
title={presetLabel}
|
||||||
|
aria-label={presetLabel}
|
||||||
|
className={`node-inspector__preset${currentTextColor === hex ? " node-inspector__preset--active" : ""}`}
|
||||||
|
style={{ background: hex }}
|
||||||
|
onClick={() => onDataChange({ textColor: hex })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Прозрачный"
|
||||||
|
aria-label="Прозрачный"
|
||||||
|
className={`node-inspector__preset node-inspector__preset--none${
|
||||||
|
currentTextColor === TRANSPARENT_COLOR ?
|
||||||
|
" node-inspector__preset--active"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => onDataChange({ textColor: TRANSPARENT_COLOR })}
|
||||||
|
>
|
||||||
|
<IconCancel size="xs" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref={textColorButtonRef} className="node-inspector__color-trigger">
|
||||||
|
<Button
|
||||||
|
size="s"
|
||||||
|
view="secondary"
|
||||||
|
label="Настроить цвет"
|
||||||
|
onClick={() => setTextColorPickerOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{textColorPickerOpen && (
|
||||||
|
<ColorPicker
|
||||||
|
model={hexModel}
|
||||||
|
value={toPickerHex(currentTextColor, DEFAULT_TEXT_COLOR)}
|
||||||
|
onChange={(value) => onDataChange({ textColor: value })}
|
||||||
|
anchorRef={textColorButtonRef}
|
||||||
|
open={textColorPickerOpen}
|
||||||
|
onOpen={setTextColorPickerOpen}
|
||||||
|
style={{ zIndex: 100 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Размер текста"
|
||||||
|
type="number"
|
||||||
|
size="s"
|
||||||
|
min={8}
|
||||||
|
max={32}
|
||||||
|
value={String(data.fontSize ?? DEFAULT_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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { Text } from "@consta/uikit/Text";
|
||||||
|
|
||||||
|
/** Раздел справки: заголовок + список строк «что» → «как». */
|
||||||
|
interface HelpSection {
|
||||||
|
title: string;
|
||||||
|
rows: { term: string; description: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDIT_SECTIONS: HelpSection[] = [
|
||||||
|
{
|
||||||
|
title: "Работа со схемой",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
term: "Выбор и редактирование",
|
||||||
|
description:
|
||||||
|
"Клик по фигуре или связи — справа откроется панель со свойствами: название, описание, цвет, размер текста. Закрыть панель — стрелкой в её углу или кликом по свободному месту холста.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Добавление",
|
||||||
|
description:
|
||||||
|
"Кнопка «Добавить элемент» слева сверху — фигура появится в центре видимой области.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Соединение фигур",
|
||||||
|
description:
|
||||||
|
"Навести курсор на фигуру и потянуть от точки на её границе к другой фигуре. Точка есть с каждой из четырёх сторон.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Вложенные процессы",
|
||||||
|
description:
|
||||||
|
"Выбрать действие и нажать «Войти в под-процесс →» в боковой панели — внутри можно собрать детальную схему этого шага. Вернуться на уровень выше — через «хлебные крошки» сверху (они появляются, когда боковая панель закрыта). У фигур с вложением есть значок ⇒.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Выравнивание",
|
||||||
|
description:
|
||||||
|
"При перетаскивании фигура сама примагничивается к центрам соседних по горизонтали и вертикали — по линиям видно, к чему именно.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Горячие клавиши",
|
||||||
|
rows: [
|
||||||
|
{ term: "Ctrl + Z", description: "Отменить последнее действие" },
|
||||||
|
{
|
||||||
|
term: "Ctrl + C / Ctrl + V",
|
||||||
|
description: "Копировать и вставить выбранную фигуру",
|
||||||
|
},
|
||||||
|
{ term: "Delete", description: "Удалить выбранную фигуру или связь" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Режим просмотра — только то, что в нём действительно доступно: фигуры
|
||||||
|
* нельзя двигать, создавать, соединять и удалять, горячие клавиши и
|
||||||
|
* отмена отключены, связи не выделяются. Поэтому здесь нет ни слова про
|
||||||
|
* перетаскивание, добавление и правку — иначе справка обещала бы то,
|
||||||
|
* чего в этом режиме нет.
|
||||||
|
*/
|
||||||
|
const VIEW_SECTIONS: HelpSection[] = [
|
||||||
|
{
|
||||||
|
title: "Просмотр схемы",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
term: "Описание шага",
|
||||||
|
description:
|
||||||
|
"Клик по фигуре — справа откроется панель с её названием, описанием, ответственным и длительностью, если они заданы. Закрыть панель — стрелкой в её углу или кликом по свободному месту холста.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Вложенные процессы",
|
||||||
|
description:
|
||||||
|
"У фигур со значком ⇒ внутри есть своя схема: выберите такую фигуру и нажмите «Открыть вложенный процесс →». Вернуться на уровень выше — через «хлебные крошки» сверху (они видны, когда боковая панель закрыта).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Масштаб и навигация",
|
||||||
|
description:
|
||||||
|
"Кнопки «+» и «−» в панели слева внизу, либо колесо мыши. Кнопка «⛶» вписывает всю схему в экран. Чтобы сдвинуть схему, зажмите пустое место холста и ведите мышью.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const NOTATION_SECTION: HelpSection = {
|
||||||
|
title: "Элементы нотации BPMN",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
term: "Действие (Task)",
|
||||||
|
description:
|
||||||
|
"Шаг процесса — то, что делается. Подтип задаёт характер работы: пользователем, сервисом, скриптом, вручную, по бизнес-правилам, отправка или получение сообщения.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "События (Event)",
|
||||||
|
description:
|
||||||
|
"Круги: начальное — с чего процесс стартует, промежуточное — что происходит по ходу, конечное — чем завершается. Иконка внутри показывает триггер: сообщение, таймер, условие, сигнал, ошибка и другие. Для каждого вида события доступны только допустимые триггеры.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Шлюзы (Gateway)",
|
||||||
|
description:
|
||||||
|
"Ромбы — точки ветвления. XOR (✕): ровно один путь из нескольких. AND (+): все пути одновременно. OR (○): один или несколько сразу. Сложный: нестандартная логика, описывается текстом. По событию: путь выбирается тем, какое событие произойдёт первым.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Документ (Data Object)",
|
||||||
|
description:
|
||||||
|
"Вход или выход шага — документ, файл, данные. Соединяется с действием пунктирной линией (это связь данных, а не поток управления).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Подсказка",
|
||||||
|
description:
|
||||||
|
"Не элемент нотации, а поясняющая заметка: цветной блок с текстом, который размещается рядом с фигурами. Связи к нему не подключаются.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HelpPanelProps {
|
||||||
|
mode: "edit" | "view";
|
||||||
|
/**
|
||||||
|
* Можно ли вернуться к редактированию кнопкой с карандашом. Когда режим
|
||||||
|
* зафиксирован хостом (`?mode=view`), кнопки нет — и обещать её нельзя.
|
||||||
|
*/
|
||||||
|
canSwitchMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Справка — выпадающая панель под кнопкой «?», а не модалка во весь экран:
|
||||||
|
* приложение живёт внутри iframe на странице портала, и оверлей на весь
|
||||||
|
* viewport перекрыл бы только сам iframe, выглядя оторванным от страницы.
|
||||||
|
* Рендерится обычным потоком внутри Panel холста, без портала в body —
|
||||||
|
* так панель гарантированно остаётся в границах приложения.
|
||||||
|
*/
|
||||||
|
export function HelpPanel({ mode, canSwitchMode }: HelpPanelProps) {
|
||||||
|
const modeSections = mode === "edit" ? EDIT_SECTIONS : VIEW_SECTIONS;
|
||||||
|
|
||||||
|
// Про режимы рассказываем только в редактировании — там это осмысленный
|
||||||
|
// переход «сейчас правлю, хочу посмотреть». В просмотре не рассказываем
|
||||||
|
// вовсе: читателю схемы незачем знать про режим редактирования, а когда
|
||||||
|
// просмотр зафиксирован хостом (`?mode=view`), выйти из него и нельзя.
|
||||||
|
const modeSwitchSection: HelpSection[] =
|
||||||
|
mode === "edit" && canSwitchMode ?
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: "Режимы",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
term: "Перейти к просмотру",
|
||||||
|
description:
|
||||||
|
"Кнопка с глазом в панели слева внизу — схема станет только для чтения: фигуры нельзя двигать, создавать и удалять.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const sections = [...modeSections, ...modeSwitchSection, NOTATION_SECTION];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="help-panel">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<div key={section.title} className="help-panel__section">
|
||||||
|
<Text size="xs" weight="semibold" className="help-panel__section-title">
|
||||||
|
{section.title}
|
||||||
|
</Text>
|
||||||
|
<dl className="help-panel__list">
|
||||||
|
{section.rows.map((row) => (
|
||||||
|
<div key={row.term} className="help-panel__row">
|
||||||
|
<dt>
|
||||||
|
<Text size="2xs" weight="semibold">
|
||||||
|
{row.term}
|
||||||
|
</Text>
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<Text size="2xs" view="secondary">
|
||||||
|
{row.description}
|
||||||
|
</Text>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+402
-79
@@ -1,22 +1,101 @@
|
|||||||
import { DEFAULT_NODE_COLOR } from './nodes';
|
import { useRef, useState, type RefObject } from "react";
|
||||||
import type { DiagramNodeData, NodeKind } from './types';
|
import { Button } from "@consta/uikit/Button";
|
||||||
|
import { TextField } from "@consta/uikit/TextField";
|
||||||
|
import { ColorPicker, hexModel } from "@consta/uikit/ColorPicker";
|
||||||
|
import { Text } from "@consta/uikit/Text";
|
||||||
|
import { IconCancel } from "@consta/icons/IconCancel";
|
||||||
|
import { TRANSPARENT_COLOR, toPickerHex } from "./colors";
|
||||||
|
import {
|
||||||
|
DEFAULT_CAPTION_TEXT_COLOR,
|
||||||
|
DEFAULT_NODE_BACKGROUND,
|
||||||
|
DEFAULT_NODE_COLOR,
|
||||||
|
DEFAULT_NODE_FONT_SIZE,
|
||||||
|
DEFAULT_NODE_TEXT_COLOR,
|
||||||
|
DEFAULT_TASK_BORDER_RADIUS,
|
||||||
|
EVENT_SUBTYPE_ICON,
|
||||||
|
EVENT_SUBTYPE_LABEL,
|
||||||
|
EVENT_SUBTYPES_BY_KIND,
|
||||||
|
TASK_SUBTYPE_ICON,
|
||||||
|
TASK_SUBTYPE_LABEL,
|
||||||
|
} from "./nodes";
|
||||||
|
import type {
|
||||||
|
DiagramNodeData,
|
||||||
|
EventSubtype,
|
||||||
|
NodeKind,
|
||||||
|
TaskSubtype,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const TASK_SUBTYPES: TaskSubtype[] = [
|
||||||
|
"none",
|
||||||
|
"user",
|
||||||
|
"service",
|
||||||
|
"script",
|
||||||
|
"manual",
|
||||||
|
"businessRule",
|
||||||
|
"send",
|
||||||
|
"receive",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Every gradient defined in palette.css, across its three "for diagrams /
|
||||||
|
// flowcharts / semantic highlights" sections. Consta's ColorPicker `palette`
|
||||||
|
// prop can't render these — it goes through hexModel, which only understands
|
||||||
|
// flat colors — so these render as our own swatch row instead.
|
||||||
|
const GRADIENT_PRESETS: { name: string; label: string }[] = [
|
||||||
|
{ name: "navy", label: "Тёмно-синий" },
|
||||||
|
{ name: "azure", label: "Лазурный" },
|
||||||
|
{ name: "sky", label: "Небесный" },
|
||||||
|
{ name: "teal-dark", label: "Тёмная бирюза" },
|
||||||
|
{ name: "teal-light", label: "Светлая бирюза" },
|
||||||
|
{ name: "navy-dark", label: "Глубокий синий" },
|
||||||
|
{ name: "ice", label: "Лёд" },
|
||||||
|
{ name: "frost", label: "Иней" },
|
||||||
|
{ name: "purple", label: "Пурпурный" },
|
||||||
|
{ name: "red", label: "Красный" },
|
||||||
|
{ name: "orange", label: "Оранжевый" },
|
||||||
|
{ name: "olive", label: "Оливковый" },
|
||||||
|
{ name: "green", label: "Зелёный" },
|
||||||
|
{ name: "green-dark", label: "Тёмно-зелёный" },
|
||||||
|
{ name: "blue", label: "Синий" },
|
||||||
|
{ name: "cyan", label: "Циан" },
|
||||||
|
{ name: "violet", label: "Виолет" },
|
||||||
|
{ name: "lavender", label: "Лавандовый" },
|
||||||
|
{ name: "pink", label: "Розовый" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// "Цвета текста" from palette.css.
|
||||||
|
const TEXT_COLOR_PRESETS: { hex: string; label: string }[] = [
|
||||||
|
{ hex: "#0079C2", label: "Синий" },
|
||||||
|
{ hex: "#525A64", label: "Тёмный" },
|
||||||
|
{ hex: "#929AA4", label: "Серый" },
|
||||||
|
{ hex: "#FFFFFF", label: "Белый" },
|
||||||
|
];
|
||||||
|
|
||||||
const TYPE_LABELS: Record<NodeKind, string> = {
|
const TYPE_LABELS: Record<NodeKind, string> = {
|
||||||
task: 'Задача',
|
task: "Действие (Task)",
|
||||||
startEvent: 'Начальное событие',
|
startEvent: "Начальное событие (Start Event)",
|
||||||
intermediateEvent: 'Промежуточное событие',
|
intermediateEvent: "Промежуточное событие (Intermediate Event)",
|
||||||
endEvent: 'Конечное событие',
|
endEvent: "Конечное событие (End Event)",
|
||||||
exclusiveGateway: 'Исключающий шлюз',
|
exclusiveGateway: "Исключающий шлюз (XOR)",
|
||||||
parallelGateway: 'Параллельный шлюз',
|
parallelGateway: "Параллельный шлюз (AND)",
|
||||||
inclusiveGateway: 'Включающий шлюз',
|
inclusiveGateway: "Включающий шлюз (OR)",
|
||||||
|
complexGateway: "Сложный шлюз (Complex)",
|
||||||
|
eventBasedGateway: "Шлюз на основе событий (Event-based)",
|
||||||
|
hint: "Подсказка",
|
||||||
|
dataObject: "Документ (Data Object)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EVENT_KINDS = new Set<NodeKind>([
|
||||||
|
"startEvent",
|
||||||
|
"intermediateEvent",
|
||||||
|
"endEvent",
|
||||||
|
]);
|
||||||
|
|
||||||
interface NodeInspectorProps {
|
interface NodeInspectorProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
nodeType: NodeKind;
|
nodeType: NodeKind;
|
||||||
data: DiagramNodeData;
|
data: DiagramNodeData;
|
||||||
childCount: number;
|
childCount: number;
|
||||||
mode: 'edit' | 'view';
|
mode: "edit" | "view";
|
||||||
onChange: (patch: Partial<DiagramNodeData>) => void;
|
onChange: (patch: Partial<DiagramNodeData>) => void;
|
||||||
onEnterSubProcess: (nodeId: string) => void;
|
onEnterSubProcess: (nodeId: string) => void;
|
||||||
onDuplicate: () => void;
|
onDuplicate: () => void;
|
||||||
@@ -38,17 +117,62 @@ export function NodeInspector({
|
|||||||
onBringToFront,
|
onBringToFront,
|
||||||
onSendToBack,
|
onSendToBack,
|
||||||
}: NodeInspectorProps) {
|
}: 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 бывает не-hex: `var(--gradient-x)` от пресета-градиента или
|
||||||
|
// `transparent` — hexModel такое не разбирает, нужен hex-дефолт.
|
||||||
|
const flatColor = toPickerHex(data.color, DEFAULT_NODE_COLOR[nodeType]);
|
||||||
|
|
||||||
|
const [textColorPickerOpen, setTextColorPickerOpen] = useState(false);
|
||||||
|
const textColorButtonRef = useRef<HTMLDivElement>(
|
||||||
|
null,
|
||||||
|
) as RefObject<HTMLDivElement>;
|
||||||
|
// Text sits directly on the colored fill for tasks (needs a light default)
|
||||||
|
// but on the plain canvas for every other shape's caption (needs a dark one).
|
||||||
|
const defaultTextColor =
|
||||||
|
nodeType === "task" ? DEFAULT_NODE_TEXT_COLOR : DEFAULT_CAPTION_TEXT_COLOR;
|
||||||
|
const currentTextColor = data.textColor ?? defaultTextColor;
|
||||||
|
|
||||||
|
if (mode === "view") {
|
||||||
const hasMeta = Boolean(data.responsible || data.duration);
|
const 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 (
|
return (
|
||||||
<div className="node-inspector">
|
<div className="node-inspector">
|
||||||
<div className="node-inspector__header">
|
<div className="node-inspector__header">
|
||||||
<span className="node-inspector__type">{TYPE_LABELS[nodeType]}</span>
|
<Text size="s" weight="semibold">
|
||||||
|
{TYPE_LABELS[nodeType]}
|
||||||
|
</Text>
|
||||||
</div>
|
</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 && (
|
{hasMeta && (
|
||||||
<dl className="node-inspector__meta">
|
<dl className="node-inspector__meta">
|
||||||
@@ -68,9 +192,12 @@ export function NodeInspector({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{childCount > 0 && (
|
{childCount > 0 && (
|
||||||
<button type="button" className="node-inspector__action" onClick={() => onEnterSubProcess(nodeId)}>
|
<Button
|
||||||
Открыть вложенный процесс →
|
size="s"
|
||||||
</button>
|
view="ghost"
|
||||||
|
label="Открыть вложенный процесс →"
|
||||||
|
onClick={() => onEnterSubProcess(nodeId)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -79,81 +206,277 @@ export function NodeInspector({
|
|||||||
return (
|
return (
|
||||||
<div className="node-inspector">
|
<div className="node-inspector">
|
||||||
<div className="node-inspector__header">
|
<div className="node-inspector__header">
|
||||||
<span className="node-inspector__type">{TYPE_LABELS[nodeType]}</span>
|
<Text size="s" weight="semibold">
|
||||||
<code className="node-inspector__id">{nodeId}</code>
|
{TYPE_LABELS[nodeType]}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="node-inspector__toolbar">
|
<div className="node-inspector__toolbar">
|
||||||
<button type="button" onClick={onDuplicate}>
|
<Button
|
||||||
Дублировать
|
size="s"
|
||||||
</button>
|
view="secondary"
|
||||||
<button type="button" onClick={onDelete}>
|
label="Дублировать"
|
||||||
Удалить
|
onClick={onDuplicate}
|
||||||
</button>
|
/>
|
||||||
|
<Button size="s" view="secondary" label="Удалить" onClick={onDelete} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="node-inspector__toolbar">
|
<div className="node-inspector__toolbar">
|
||||||
<button type="button" onClick={onBringToFront}>
|
<Button
|
||||||
На передний план
|
size="s"
|
||||||
</button>
|
view="secondary"
|
||||||
<button type="button" onClick={onSendToBack}>
|
label="На передний план"
|
||||||
На задний план
|
onClick={onBringToFront}
|
||||||
</button>
|
/>
|
||||||
|
<Button
|
||||||
|
size="s"
|
||||||
|
view="secondary"
|
||||||
|
label="На задний план"
|
||||||
|
onClick={onSendToBack}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="node-inspector__field node-inspector__field--color">
|
{nodeType === "task" && (
|
||||||
Цвет фона
|
<div className="node-inspector__field">
|
||||||
<input
|
<Text size="xs" view="secondary">
|
||||||
type="color"
|
Тип задачи
|
||||||
value={data.color ?? DEFAULT_NODE_COLOR[nodeType]}
|
</Text>
|
||||||
onChange={(e) => onChange({ color: e.target.value })}
|
<div className="node-inspector__presets">
|
||||||
/>
|
{TASK_SUBTYPES.map((subtype) => {
|
||||||
</label>
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="node-inspector__field">
|
{EVENT_KINDS.has(nodeType) && (
|
||||||
Название
|
<div className="node-inspector__field">
|
||||||
<textarea
|
<Text size="xs" view="secondary">
|
||||||
rows={2}
|
Тип триггера
|
||||||
value={data.label}
|
</Text>
|
||||||
onChange={(e) => onChange({ label: e.target.value })}
|
<div className="node-inspector__presets">
|
||||||
/>
|
{EVENT_SUBTYPES_BY_KIND[
|
||||||
</label>
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="node-inspector__field">
|
<div className="node-inspector__field">
|
||||||
Описание
|
<Text size="xs" view="secondary">
|
||||||
<textarea
|
Цвет фона
|
||||||
rows={3}
|
</Text>
|
||||||
value={data.description ?? ''}
|
<div className="node-inspector__presets">
|
||||||
onChange={(e) => onChange({ description: e.target.value })}
|
{GRADIENT_PRESETS.map(({ name, label }) => {
|
||||||
/>
|
const value = `var(--gradient-${name})`;
|
||||||
</label>
|
const isActive =
|
||||||
|
(data.color ?? DEFAULT_NODE_BACKGROUND[nodeType]) === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
className={`node-inspector__preset${isActive ? " node-inspector__preset--active" : ""}`}
|
||||||
|
style={{ background: value }}
|
||||||
|
onClick={() => onChange({ color: value })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Прозрачный"
|
||||||
|
aria-label="Прозрачный"
|
||||||
|
className={`node-inspector__preset node-inspector__preset--none${
|
||||||
|
data.color === TRANSPARENT_COLOR ?
|
||||||
|
" node-inspector__preset--active"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => onChange({ color: TRANSPARENT_COLOR })}
|
||||||
|
>
|
||||||
|
<IconCancel size="xs" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref={colorButtonRef} className="node-inspector__color-trigger">
|
||||||
|
<Button
|
||||||
|
size="s"
|
||||||
|
view="secondary"
|
||||||
|
label="Настроить цвет"
|
||||||
|
onClick={() => setColorPickerOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{colorPickerOpen && (
|
||||||
|
<ColorPicker
|
||||||
|
model={hexModel}
|
||||||
|
value={flatColor}
|
||||||
|
onChange={(value) => onChange({ color: value })}
|
||||||
|
anchorRef={colorButtonRef}
|
||||||
|
open={colorPickerOpen}
|
||||||
|
onOpen={setColorPickerOpen}
|
||||||
|
style={{ zIndex: 100 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="node-inspector__field">
|
<div className="node-inspector__field">
|
||||||
Ответственный
|
<Text size="xs" view="secondary">
|
||||||
<input
|
Цвет текста
|
||||||
type="text"
|
</Text>
|
||||||
value={data.responsible ?? ''}
|
<div className="node-inspector__presets">
|
||||||
onChange={(e) => onChange({ responsible: e.target.value })}
|
{TEXT_COLOR_PRESETS.map(({ hex, label: presetLabel }) => (
|
||||||
/>
|
<button
|
||||||
</label>
|
key={hex}
|
||||||
|
type="button"
|
||||||
|
title={presetLabel}
|
||||||
|
aria-label={presetLabel}
|
||||||
|
className={`node-inspector__preset${currentTextColor === hex ? " node-inspector__preset--active" : ""}`}
|
||||||
|
style={{ background: hex }}
|
||||||
|
onClick={() => onChange({ textColor: hex })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Прозрачный"
|
||||||
|
aria-label="Прозрачный"
|
||||||
|
className={`node-inspector__preset node-inspector__preset--none${
|
||||||
|
currentTextColor === TRANSPARENT_COLOR ?
|
||||||
|
" node-inspector__preset--active"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => onChange({ textColor: TRANSPARENT_COLOR })}
|
||||||
|
>
|
||||||
|
<IconCancel size="xs" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref={textColorButtonRef} className="node-inspector__color-trigger">
|
||||||
|
<Button
|
||||||
|
size="s"
|
||||||
|
view="secondary"
|
||||||
|
label="Настроить цвет"
|
||||||
|
onClick={() => setTextColorPickerOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{textColorPickerOpen && (
|
||||||
|
<ColorPicker
|
||||||
|
model={hexModel}
|
||||||
|
value={toPickerHex(currentTextColor, defaultTextColor)}
|
||||||
|
onChange={(value) => onChange({ textColor: value })}
|
||||||
|
anchorRef={textColorButtonRef}
|
||||||
|
open={textColorPickerOpen}
|
||||||
|
onOpen={setTextColorPickerOpen}
|
||||||
|
style={{ zIndex: 100 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="node-inspector__field">
|
<TextField
|
||||||
Длительность
|
label="Размер текста"
|
||||||
<input
|
type="number"
|
||||||
type="text"
|
size="s"
|
||||||
value={data.duration ?? ''}
|
min={8}
|
||||||
onChange={(e) => onChange({ duration: e.target.value })}
|
max={32}
|
||||||
/>
|
value={String(data.fontSize ?? DEFAULT_NODE_FONT_SIZE)}
|
||||||
</label>
|
onChange={(value) => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (value && !Number.isNaN(n)) onChange({ fontSize: n });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<button type="button" className="node-inspector__action" onClick={() => onEnterSubProcess(nodeId)}>
|
<TextField
|
||||||
Войти в под-процесс →
|
label="Название"
|
||||||
</button>
|
type="textarea"
|
||||||
<p className="node-inspector__hint">
|
rows={2}
|
||||||
{childCount > 0
|
size="s"
|
||||||
? `Внутри уже ${childCount} узл(ов).`
|
value={data.label}
|
||||||
: 'Пока пусто — зайдя внутрь, можно добавить узлы детального разбора этого шага.'}
|
onChange={(value) => onChange({ label: value ?? "" })}
|
||||||
</p>
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Описание"
|
||||||
|
type="textarea"
|
||||||
|
rows={3}
|
||||||
|
size="s"
|
||||||
|
value={data.description ?? ""}
|
||||||
|
onChange={(value) => onChange({ description: value ?? "" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(nodeType === "task" || nodeType === "hint") && (
|
||||||
|
<TextField
|
||||||
|
label="Скругление углов"
|
||||||
|
type="number"
|
||||||
|
size="s"
|
||||||
|
min={0}
|
||||||
|
max={25}
|
||||||
|
value={String(data.borderRadius ?? DEFAULT_TASK_BORDER_RADIUS)}
|
||||||
|
onChange={(value) => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (value && !Number.isNaN(n)) onChange({ borderRadius: n });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nodeType === "task" && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Ответственный"
|
||||||
|
size="s"
|
||||||
|
value={data.responsible ?? ""}
|
||||||
|
onChange={(value) => onChange({ responsible: value ?? "" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Длительность"
|
||||||
|
size="s"
|
||||||
|
value={data.duration ?? ""}
|
||||||
|
onChange={(value) => onChange({ duration: value ?? "" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="s"
|
||||||
|
view="ghost"
|
||||||
|
label="Войти в под-процесс →"
|
||||||
|
onClick={() => onEnterSubProcess(nodeId)}
|
||||||
|
/>
|
||||||
|
<Text size="xs" view="secondary" className="node-inspector__hint">
|
||||||
|
{childCount > 0 ?
|
||||||
|
`Внутри уже ${childCount} узл(ов).`
|
||||||
|
: "Пока пусто — зайдя внутрь, можно добавить узлы детального разбора этого шага."
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* «Без заливки». Валидное значение и для CSS `background`/`color`, и для
|
||||||
|
* SVG `stroke`/`fill`, поэтому одинаково годится фигурам, линиям и подписям.
|
||||||
|
*/
|
||||||
|
export const TRANSPARENT_COLOR = "transparent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consta's ColorPicker/ColorControl работают через hexModel и понимают только
|
||||||
|
* плоский hex. В data цвет может лежать и как `var(--gradient-x)` (пресет-
|
||||||
|
* градиент), и как `transparent` — если отдать такое в hexModel, разбор даст
|
||||||
|
* NaN и поле покажет «NaNNaNNaN». Поэтому всё, что не hex, подменяем дефолтом.
|
||||||
|
*/
|
||||||
|
export function toPickerHex(value: string | undefined, fallback: string): string {
|
||||||
|
return value && /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) ?
|
||||||
|
value
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
+215
-155
@@ -1,33 +1,69 @@
|
|||||||
.diagram-shell {
|
.diagram-shell {
|
||||||
display: flex;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagram-canvas {
|
.diagram-canvas {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
min-width: 0;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagram-toolbar {
|
.diagram-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-width: 280px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagram-toolbar button {
|
/*
|
||||||
font: inherit;
|
* Кнопка справки в правом верхнем углу холста. Раньше это место занимал
|
||||||
padding: 6px 10px;
|
* переключатель режимов — он переехал в панель управления слева внизу,
|
||||||
border: 1px solid #999;
|
* к остальным кнопкам работы с холстом.
|
||||||
border-radius: 4px;
|
*/
|
||||||
|
.diagram-help {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Выпадающая справка под кнопкой «?». Не модалка и не портал в body:
|
||||||
|
* приложение работает внутри iframe, и оверлей во весь экран перекрыл бы
|
||||||
|
* только сам iframe — панель должна оставаться в границах приложения.
|
||||||
|
*/
|
||||||
|
.help-panel {
|
||||||
|
width: min(420px, 60vw);
|
||||||
|
max-height: min(460px, 70vh);
|
||||||
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4px 14px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
background: canvas;
|
background: canvas;
|
||||||
color: canvastext;
|
border: 1px solid color-mix(in srgb, canvastext 15%, transparent);
|
||||||
cursor: pointer;
|
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagram-toolbar button:hover {
|
.help-panel__section-title {
|
||||||
background: color-mix(in srgb, canvastext 8%, canvas);
|
display: block;
|
||||||
|
margin: 12px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-panel__list {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-panel__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(90px, 130px) 1fr;
|
||||||
|
gap: 2px 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-panel__row dd {
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -50,6 +86,51 @@
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Зона захвата для изменения размера. По умолчанию xyflow даёт углам маркеры
|
||||||
|
* 5×5 px, а сторонам — линии толщиной 1 px: попасть курсором почти нереально.
|
||||||
|
* Видимую графику оставляем скромной, а кликабельную область расширяем
|
||||||
|
* прозрачным ::after — так фигура не обрастает крупными маркерами.
|
||||||
|
*
|
||||||
|
* z-index намеренно не трогаем: точки соединения (.react-flow__handle) идут
|
||||||
|
* в разметке после ресайзера и потому остаются сверху в местах пересечения —
|
||||||
|
* на серединах сторон, где расширенная полоса линии проходит поверх них.
|
||||||
|
* Иначе расширение зоны захвата отобрало бы у них клик и сломало создание
|
||||||
|
* связей.
|
||||||
|
*/
|
||||||
|
.react-flow__resize-control.handle {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__resize-control.handle::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__resize-control.line::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__resize-control.line.left::after,
|
||||||
|
.react-flow__resize-control.line.right::after {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: -6px;
|
||||||
|
right: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__resize-control.line.top::after,
|
||||||
|
.react-flow__resize-control.line.bottom::after {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: -6px;
|
||||||
|
bottom: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
.diagram-alignment-guide {
|
.diagram-alignment-guide {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: #ff0072;
|
background: #ff0072;
|
||||||
@@ -57,63 +138,11 @@
|
|||||||
z-index: 1000;
|
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 {
|
.diagram-node {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border: 1px solid #555;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: canvas;
|
background: canvas;
|
||||||
color: canvastext;
|
color: canvastext;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -127,6 +156,7 @@
|
|||||||
.diagram-node__label {
|
.diagram-node__label {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagram-node__badge {
|
.diagram-node__badge {
|
||||||
@@ -153,10 +183,9 @@
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
max-width: 120px;
|
max-width: 120px;
|
||||||
font-size: 11px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: canvastext;
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagram-node--circle {
|
.diagram-node--circle {
|
||||||
@@ -164,26 +193,6 @@
|
|||||||
border-radius: 50%;
|
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 {
|
.diagram-node--gateway-outer {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -197,23 +206,78 @@
|
|||||||
.diagram-node--diamond-bg {
|
.diagram-node--diamond-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
border: 1px solid #555;
|
|
||||||
box-sizing: border-box;
|
|
||||||
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
|
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagram-node__icon {
|
.diagram-node__icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
color: white;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
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 {
|
.diagram-aside {
|
||||||
|
position: absolute;
|
||||||
|
top: 52px;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
width: 340px;
|
width: 340px;
|
||||||
flex-shrink: 0;
|
z-index: 5;
|
||||||
border-left: 1px solid color-mix(in srgb, currentcolor 20%, transparent);
|
box-sizing: border-box;
|
||||||
|
background: canvas;
|
||||||
|
border: 1px solid color-mix(in srgb, canvastext 15%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -226,6 +290,16 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Кнопка «скрыть панель» — только в режиме просмотра. Прижата вправо, к
|
||||||
|
* внешнему краю: стрелка указывает наружу, в сторону, куда панель уезжает.
|
||||||
|
*/
|
||||||
|
.diagram-aside__close {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
.diagram-aside__json summary {
|
.diagram-aside__json summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -257,89 +331,75 @@
|
|||||||
gap: 8px;
|
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 {
|
.node-inspector__field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-inspector__field input,
|
.node-inspector__presets {
|
||||||
.node-inspector__field textarea,
|
display: flex;
|
||||||
.node-inspector__field select {
|
flex-wrap: wrap;
|
||||||
font: inherit;
|
gap: 6px;
|
||||||
padding: 6px 8px;
|
|
||||||
border: 1px solid #999;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: canvas;
|
|
||||||
color: canvastext;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-inspector__field--color {
|
.node-inspector__preset {
|
||||||
flex-direction: row;
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid color-mix(in srgb, canvastext 25%, transparent);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__preset--active {
|
||||||
|
outline: 2px solid canvastext;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* «Прозрачный» — не цвет, а его отсутствие, поэтому вместо заливки внутри
|
||||||
|
* значок-перечёркивание. Шахматка под ним — привычная в редакторах пометка
|
||||||
|
* прозрачности, иначе кружок читался бы просто как белый.
|
||||||
|
*/
|
||||||
|
.node-inspector__preset--none {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: center;
|
||||||
|
color: #525a64;
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, #d7dae0 25%, transparent 25%, transparent 75%, #d7dae0 75%),
|
||||||
|
linear-gradient(45deg, #d7dae0 25%, transparent 25%, transparent 75%, #d7dae0 75%),
|
||||||
|
#fff;
|
||||||
|
background-size: 8px 8px;
|
||||||
|
background-position: 0 0, 4px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-inspector__field--color input[type='color'] {
|
.node-inspector__subtype {
|
||||||
width: 36px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 2px;
|
padding: 0;
|
||||||
cursor: pointer;
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
.node-inspector__action {
|
border: 1px solid color-mix(in srgb, canvastext 25%, transparent);
|
||||||
font: inherit;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid #999;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: canvas;
|
background: canvas;
|
||||||
color: canvastext;
|
color: canvastext;
|
||||||
cursor: pointer;
|
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;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-inspector__action:hover {
|
.node-inspector__label,
|
||||||
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__description {
|
.node-inspector__description {
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-10
@@ -11,18 +11,36 @@ const READY_MESSAGE = "diagram:ready";
|
|||||||
const INIT_MESSAGE = "diagram:init";
|
const INIT_MESSAGE = "diagram:init";
|
||||||
const CHANGE_MESSAGE = "diagram:change";
|
const CHANGE_MESSAGE = "diagram:change";
|
||||||
|
|
||||||
function getBitrixOrigin(): string {
|
/**
|
||||||
const origin = import.meta.env.VITE_BITRIX_ORIGIN;
|
* Origin хоста, встроившего приложение.
|
||||||
if (!origin) {
|
*
|
||||||
throw new Error(
|
* Обычный случай — приложение и хост на одном домене (статика лежит в дереве
|
||||||
'VITE_BITRIX_ORIGIN is not set. The bridge needs a concrete parent origin — postMessage must never target "*".',
|
* портала, см. docs/bitrix-integration.md §7, вариант A): тогда origin
|
||||||
);
|
* известен в рантайме и задавать его на этапе сборки не нужно.
|
||||||
}
|
* VITE_BITRIX_ORIGIN нужен только когда хост на другом origin — локальная
|
||||||
return origin;
|
* разработка (dev-сервер хоста и `vite preview` этого приложения на разных
|
||||||
|
* портах) или деплой отдельным контейнером (§7, вариант B).
|
||||||
|
*
|
||||||
|
* `ignoreConfiguredOrigin` — режим просмотра (`?mode=view`, см. `App.tsx`)
|
||||||
|
* всегда игнорирует VITE_BITRIX_ORIGIN, даже если он задан. Один и тот же
|
||||||
|
* билд встраивается из двух разных мест с разным origin: админка Bitrix
|
||||||
|
* (фиксированный адрес, туда и печётся переменная) и portal-front в режиме
|
||||||
|
* просмотра (свой адрес, может не совпадать с зашитым в сборку) — если бы
|
||||||
|
* переменная действовала и там, просмотр молча ломался бы всякий раз, когда
|
||||||
|
* portal-front открыт не с того origin, что был на момент сборки конструктора.
|
||||||
|
*
|
||||||
|
* Требование «никогда не `postMessage('*')`» соблюдено во всех случаях:
|
||||||
|
* значение всегда конкретный origin. Если хост окажется на другом домене,
|
||||||
|
* а переменная не задана (или проигнорирована), обмен просто не состоится —
|
||||||
|
* это безопасный отказ, а не утечка сообщений куда попало.
|
||||||
|
*/
|
||||||
|
function getBitrixOrigin(ignoreConfiguredOrigin: boolean): string {
|
||||||
|
if (ignoreConfiguredOrigin) return window.location.origin;
|
||||||
|
return import.meta.env.VITE_BITRIX_ORIGIN || window.location.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBitrixAdapter(): HostAdapter {
|
export function createBitrixAdapter(options: { ignoreConfiguredOrigin: boolean }): HostAdapter {
|
||||||
const targetOrigin = getBitrixOrigin();
|
const targetOrigin = getBitrixOrigin(options.ignoreConfiguredOrigin);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onReady(callback) {
|
onReady(callback) {
|
||||||
|
|||||||
+26
-1
@@ -1,6 +1,10 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
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,24 @@ body,
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diagram-breadcrumbs:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Экран ожидания `diagram:init` от хоста. Занимает ровно высоту вьюпорта и
|
||||||
|
* центрирует надпись. Раньше это был <p> с отступом: его собственный
|
||||||
|
* margin выходил за пределы 100%-высоты предка и давал вертикальный скролл
|
||||||
|
* на пустом экране.
|
||||||
|
*/
|
||||||
|
.diagram-loading {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
color: #525a64;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import '../palette.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
|||||||
+321
-39
@@ -1,6 +1,29 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from "react";
|
||||||
import { Handle, NodeResizer, Position, type Node, type NodeProps } from '@xyflow/react';
|
import {
|
||||||
import type { DiagramNodeData, NodeKind } from './types';
|
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>;
|
type ShapeNode = Node<DiagramNodeData, NodeKind>;
|
||||||
|
|
||||||
@@ -8,23 +31,138 @@ type ShapeNode = Node<DiagramNodeData, NodeKind>;
|
|||||||
export const NodesWithChildrenContext = createContext<Set<string>>(new Set());
|
export const NodesWithChildrenContext = createContext<Set<string>>(new Set());
|
||||||
|
|
||||||
/** Current editor mode — resize handles and connection points only show in 'edit'. */
|
/** 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> = {
|
// Gradients from palette.css's "ОСНОВНАЯ ПАЛИТРА" section — the one the
|
||||||
task: '#eef2ff',
|
// palette itself labels as meant for diagrams/flowcharts/semantic highlights.
|
||||||
startEvent: '#f0fdf4',
|
// This is what nodes actually render with until a user picks a custom color.
|
||||||
intermediateEvent: '#fefce8',
|
export const DEFAULT_NODE_BACKGROUND: Record<NodeKind, string> = {
|
||||||
endEvent: '#fef2f2',
|
task: "var(--gradient-blue)",
|
||||||
exclusiveGateway: '#fff7ed',
|
startEvent: "var(--gradient-green)",
|
||||||
parallelGateway: '#faf5ff',
|
intermediateEvent: "var(--gradient-orange)",
|
||||||
inclusiveGateway: '#f0fdfa',
|
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 }[] = [
|
const HANDLE_POSITIONS: { id: string; position: Position }[] = [
|
||||||
{ id: 'top', position: Position.Top },
|
{ id: "top", position: Position.Top },
|
||||||
{ id: 'right', position: Position.Right },
|
{ id: "right", position: Position.Right },
|
||||||
{ id: 'bottom', position: Position.Bottom },
|
{ id: "bottom", position: Position.Bottom },
|
||||||
{ id: 'left', position: Position.Left },
|
{ id: "left", position: Position.Left },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,8 +184,11 @@ function SubProcessBadge({ id }: { id: string }) {
|
|||||||
const withChildren = useContext(NodesWithChildrenContext);
|
const withChildren = useContext(NodesWithChildrenContext);
|
||||||
if (!withChildren.has(id)) return null;
|
if (!withChildren.has(id)) return null;
|
||||||
return (
|
return (
|
||||||
<span className="diagram-node__badge" title="Есть вложенные узлы — двойной клик, чтобы открыть">
|
<span
|
||||||
⊞
|
className="diagram-node__badge"
|
||||||
|
title="Есть вложенные узлы — выберите фигуру, чтобы открыть их из боковой панели"
|
||||||
|
>
|
||||||
|
⇒
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -66,7 +207,7 @@ function ShapeResizer({
|
|||||||
const mode = useContext(ModeContext);
|
const mode = useContext(ModeContext);
|
||||||
return (
|
return (
|
||||||
<NodeResizer
|
<NodeResizer
|
||||||
isVisible={selected && mode === 'edit'}
|
isVisible={selected && mode === "edit"}
|
||||||
minWidth={minWidth}
|
minWidth={minWidth}
|
||||||
minHeight={minHeight}
|
minHeight={minHeight}
|
||||||
keepAspectRatio={keepAspectRatio}
|
keepAspectRatio={keepAspectRatio}
|
||||||
@@ -75,45 +216,101 @@ function ShapeResizer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TaskNode({ id, data, selected }: NodeProps<ShapeNode>) {
|
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 (
|
return (
|
||||||
<div className="diagram-node diagram-node--task" style={{ background: data.color ?? DEFAULT_NODE_COLOR.task }}>
|
<div
|
||||||
<ShapeResizer selected={selected} minWidth={70} minHeight={32} keepAspectRatio={false} />
|
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 />
|
<ShapeHandles />
|
||||||
<SubProcessBadge id={id} />
|
<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>
|
</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>) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`diagram-node diagram-node--circle ${EVENT_BORDER_CLASS[kind]}`}
|
className="diagram-node diagram-node--circle"
|
||||||
style={{ background: data.color ?? DEFAULT_NODE_COLOR[kind] }}
|
style={{ background: data.color ?? DEFAULT_NODE_BACKGROUND[kind] }}
|
||||||
>
|
>
|
||||||
<ShapeResizer selected={selected} minWidth={24} minHeight={24} />
|
<ShapeResizer selected={selected} minWidth={24} minHeight={24} />
|
||||||
<ShapeHandles />
|
<ShapeHandles />
|
||||||
<SubProcessBadge id={id} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const GATEWAY_ICON: Record<'exclusiveGateway' | 'parallelGateway' | 'inclusiveGateway', string> = {
|
const GATEWAY_ICON: Record<
|
||||||
exclusiveGateway: '✕',
|
"exclusiveGateway" | "parallelGateway" | "inclusiveGateway" | "complexGateway" | "eventBasedGateway",
|
||||||
parallelGateway: '+',
|
string
|
||||||
inclusiveGateway: '○',
|
> = {
|
||||||
|
exclusiveGateway: "✕",
|
||||||
|
parallelGateway: "+",
|
||||||
|
inclusiveGateway: "○",
|
||||||
|
complexGateway: "✳",
|
||||||
|
eventBasedGateway: "⬠",
|
||||||
};
|
};
|
||||||
|
|
||||||
function GatewayNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
|
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 (
|
return (
|
||||||
<div className="diagram-node--gateway-outer">
|
<div className="diagram-node--gateway-outer">
|
||||||
<ShapeResizer selected={selected} minWidth={32} minHeight={32} />
|
<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
|
of it, since clip-path would otherwise cut off anything of theirs
|
||||||
that renders outside the diamond outline.
|
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 />
|
<ShapeHandles />
|
||||||
<SubProcessBadge id={id} />
|
<SubProcessBadge id={id} />
|
||||||
<span className="diagram-node__icon">{GATEWAY_ICON[kind]}</span>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -147,4 +425,8 @@ export const nodeTypes = {
|
|||||||
exclusiveGateway: GatewayNode,
|
exclusiveGateway: GatewayNode,
|
||||||
parallelGateway: GatewayNode,
|
parallelGateway: GatewayNode,
|
||||||
inclusiveGateway: GatewayNode,
|
inclusiveGateway: GatewayNode,
|
||||||
|
complexGateway: GatewayNode,
|
||||||
|
eventBasedGateway: GatewayNode,
|
||||||
|
hint: HintNode,
|
||||||
|
dataObject: DataObjectNode,
|
||||||
};
|
};
|
||||||
|
|||||||
+49
-2
@@ -5,7 +5,36 @@ export type NodeKind =
|
|||||||
| 'endEvent'
|
| 'endEvent'
|
||||||
| 'exclusiveGateway'
|
| 'exclusiveGateway'
|
||||||
| 'parallelGateway'
|
| '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> {
|
export interface DiagramNodeData extends Record<string, unknown> {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -13,6 +42,12 @@ export interface DiagramNodeData extends Record<string, unknown> {
|
|||||||
responsible?: string;
|
responsible?: string;
|
||||||
duration?: string;
|
duration?: string;
|
||||||
color?: 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 {
|
export interface DiagramNode {
|
||||||
@@ -28,7 +63,19 @@ export interface DiagramNode {
|
|||||||
zIndex?: number;
|
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 {
|
export interface DiagramEdge {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
+84
-12
@@ -1,18 +1,90 @@
|
|||||||
import { defineConfig, loadEnv } from 'vite'
|
import { createHash } from 'node:crypto'
|
||||||
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { defineConfig, type Plugin } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// Vite always writes the HTML entry under its source name (index.html stays
|
||||||
export default defineConfig(({ mode }) => {
|
// index.html) — it only content-hashes what that HTML references (JS/CSS),
|
||||||
// VITE_BASE_PATH is read here (config/Node context), so it needs an explicit
|
// never the HTML file itself. The backend now reads the actual deployed
|
||||||
// loadEnv call — unlike VITE_BITRIX_ORIGIN, which Vite injects into
|
// filename at deploy time instead of hardcoding it on the main portal, so
|
||||||
// import.meta.env automatically because it's only read from app code.
|
// index.html needs a unique name too.
|
||||||
const env = loadEnv(mode, process.cwd(), '')
|
//
|
||||||
|
// index.html isn't part of the same bundle object the JS/CSS live in
|
||||||
|
// (confirmed empirically — it never showed up in generateBundle's `bundle`),
|
||||||
|
// so this hashes and renames the file directly on disk after the whole build
|
||||||
|
// has finished writing it, rather than fighting the bundler's own emit step.
|
||||||
|
// Stable entry point for hosts: never hashed, always present next to the build.
|
||||||
|
//
|
||||||
|
// Content is JSON, but the extension is .js on purpose: the Bitrix stand's
|
||||||
|
// nginx serves static straight from disk only for a fixed extension list
|
||||||
|
// (js|css|html|svg|png|...), and `json` is not in it — a .json file falls
|
||||||
|
// through to the Bitrix PHP router and comes back as a 404 HTML page
|
||||||
|
// (verified on the stand). `.js` is the one extension guaranteed to be served
|
||||||
|
// anywhere this build is deployed, since the build itself depends on it.
|
||||||
|
const MANIFEST_FILE_NAME = 'app-manifest.js'
|
||||||
|
|
||||||
|
function hashIndexHtml(): Plugin {
|
||||||
|
let outDir = 'dist'
|
||||||
return {
|
return {
|
||||||
// Needed when the build is served from a subpath (e.g. local/diagram-app/
|
name: 'hash-index-html',
|
||||||
// inside the Bitrix docroot) instead of domain root — see
|
apply: 'build',
|
||||||
// docs/bitrix-integration.md §7.
|
configResolved(config) {
|
||||||
base: env.VITE_BASE_PATH || '/',
|
outDir = config.build.outDir
|
||||||
plugins: [react()],
|
},
|
||||||
|
closeBundle() {
|
||||||
|
const htmlPath = path.resolve(outDir, 'index.html')
|
||||||
|
if (!existsSync(htmlPath)) return
|
||||||
|
|
||||||
|
// Hashing the final content (already contains the hashed JS/CSS refs)
|
||||||
|
// means this name only changes when something meaningful actually did.
|
||||||
|
const hash = createHash('md5').update(readFileSync(htmlPath)).digest('hex').slice(0, 8)
|
||||||
|
const fileName = `index-${hash}.html`
|
||||||
|
renameSync(htmlPath, path.resolve(outDir, fileName))
|
||||||
|
|
||||||
|
// Manifest with a STABLE name, so an embedding host can look the hashed
|
||||||
|
// entry up at runtime instead of hardcoding it (and re-deploying itself
|
||||||
|
// on every rebuild of this app). See docs/bitrix-integration.md §7.
|
||||||
|
writeFileSync(
|
||||||
|
path.resolve(outDir, MANIFEST_FILE_NAME),
|
||||||
|
`${JSON.stringify({ index: fileName }, null, 2)}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(() => {
|
||||||
|
return {
|
||||||
|
// Relative, so the build works from any directory it gets deployed to
|
||||||
|
// without being told where that is. The host always opens a concrete file
|
||||||
|
// (index-<hash>.html, see hashIndexHtml below), so asset URLs resolve
|
||||||
|
// against that file's own location — there is no client-side routing that
|
||||||
|
// would need an absolute base. This replaces the earlier VITE_BASE_PATH:
|
||||||
|
// a build-time path that had to match the deploy directory exactly, and
|
||||||
|
// silently produced a blank iframe when it didn't (html loads, its JS 404s).
|
||||||
|
base: './',
|
||||||
|
plugins: [react(), hashIndexHtml()],
|
||||||
|
// Порт фиксирован: portal-front проксирует сюда сборку в dev-режиме
|
||||||
|
// (см. docs/portal-front-integration.md, раздел 2). strictPort, чтобы
|
||||||
|
// при занятом порте preview падал, а не уезжал на соседний молча.
|
||||||
|
preview: {
|
||||||
|
port: 5800,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
// Same content hash Vite computes by default — just without its default
|
||||||
|
// "assets/" subfolder prefix. The deploy target's own folder is itself
|
||||||
|
// named assets (local/modules/vniigaz.bpmn/assets/), so keeping Vite's
|
||||||
|
// default pattern would nest output at .../assets/assets/index-*.js (404).
|
||||||
|
rolldownOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: '[name]-[hash].js',
|
||||||
|
chunkFileNames: '[name]-[hash].js',
|
||||||
|
assetFileNames: '[name]-[hash][extname]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user