Compare commits

..
15 changed files with 1096 additions and 232 deletions
+7
View File
@@ -3,3 +3,10 @@
Линтер: oxlint Линтер: oxlint
Библиотека компонентов: consta (https://www.npmjs.com/package/@consta/uikit) Библиотека компонентов: consta (https://www.npmjs.com/package/@consta/uikit)
Стили (цвета) взяты из презентации Стили (цвета) взяты из презентации
## Документация
- [`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, варианты деплоя
+12 -6
View File
@@ -8,14 +8,19 @@
1. Собрать в репозитории `portal-chart-builder`: 1. Собрать в репозитории `portal-chart-builder`:
``` ```
VITE_BITRIX_ORIGIN=https://ВАШ-ДОМЕН-ПОРТАЛА VITE_BASE_PATH=/local/diagram-app/ npm run build npm run build
``` ```
- `VITE_BITRIX_ORIGIN` — origin портала (без пути). Обязателен — без него приложение намеренно не запустится (защита от случайного `postMessage('*')`). - Переменные окружения не нужны. Ссылки на JS/CSS внутри `index.html` относительные (`base: './'` в `vite.config.ts`), поэтому сборка работает из любого каталога, куда её положат, — подпуть на этапе сборки знать не требуется.
- `VITE_BASE_PATH` — подпуть, под которым будет отдаваться приложение. Должен совпадать с тем, куда положите `dist/` на шаге 2. - `VITE_BITRIX_ORIGIN` здесь **не нужен**: приложение и портал на одном домене, origin хоста приложение берёт из `window.location.origin` в рантайме. Задавать переменную нужно только если хост на другом origin — например, локальная разработка (dev-сервер хоста и `vite preview` этого приложения на разных портах) или деплой отдельным контейнером (см. `bitrix-integration.md`, раздел 7, вариант B).
2. Скопировать содержимое `dist/` в `local/diagram-app/` внутри каталога сайта на сервере (тот же каталог, где лежит остальной код портала — `bitrix_www`/аналог на проде). 2. Скопировать содержимое `dist/` в `local/diagram-app/` внутри каталога сайта на сервере (тот же каталог, где лежит остальной код портала — `bitrix_www`/аналог на проде).
3. Готово: приложение доступно по `https://ВАШ-ДОМЕН-ПОРТАЛА/local/diagram-app/index.html`. 3. Готово — но с оговоркой: приложение доступно **не** по фиксированному `index.html`.
**При каждом повторном деплое** (обновили React-приложение, положили новый `dist/` поверх старого) — добавляйте к `src` iframe cache-busting параметр (`?v=<дата/хэш билда>`). У `.html`-файлов на большинстве Bitrix-стендов стоит `Cache-Control` на 30 дней — без версии в URL браузер администратора может продолжать открывать старый `index.html` со старыми хэшами JS/CSS ещё месяц. **Имя `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 ## 2. Как сформировать HTML
@@ -23,7 +28,8 @@
```html ```html
<input type="hidden" id="DIAGRAM-DATA" name="DIAGRAM-DATA" value="<?= htmlspecialcharsbx($currentValue) ?>"> <input type="hidden" id="DIAGRAM-DATA" name="DIAGRAM-DATA" value="<?= htmlspecialcharsbx($currentValue) ?>">
<iframe id="diagram-frame" src="/local/diagram-app/index.html" style="width:100%;height:600px;border:1px solid #d7dae0;border-radius:6px"></iframe> <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> <script>
(function () { (function () {
+38 -8
View File
@@ -44,8 +44,12 @@ class DiagramProperty
// Вариант 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';
@@ -120,6 +124,18 @@ 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. Требования безопасности (обязательны, не опциональны)
- Значение всегда попадает на страницу только через `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`. - Значение всегда попадает на страницу только через `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`.
@@ -197,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-контейнер
@@ -245,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 этой проблемы не имеет вообще, так как всегда живёт на домене портала.
+153
View File
@@ -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 закеширован браузером | очистить каталог при деплое; хеш в имени сам по себе кэш не победит, если старый файл остаётся на месте |
+23 -3
View File
@@ -64,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;
@@ -81,12 +97,16 @@ function App() {
}, []); }, []);
if (!doc) { if (!doc) {
return <p style={{ padding: 16, fontFamily: 'system-ui, sans-serif' }}>Загрузка диаграммы…</p>; return <div className="diagram-loading">Загрузка диаграммы…</div>;
} }
return ( return (
<Theme preset={presetGpnDefault} style={{ height: '100%' }}> <Theme preset={presetGpnDefault} style={{ height: '100%' }}>
<DiagramEditor initialValue={doc} onChange={handleChange} /> <DiagramEditor
initialValue={doc}
onChange={handleChange}
lockedMode={lockedMode}
/>
</Theme> </Theme>
); );
} }
+172 -57
View File
@@ -26,11 +26,15 @@ import {
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { Button } from "@consta/uikit/Button"; import { Button } from "@consta/uikit/Button";
import { ChoiceGroup } from "@consta/uikit/ChoiceGroup"; import { ContextMenu } from "@consta/uikit/ContextMenu";
import { Breadcrumbs } from "@consta/uikit/Breadcrumbs"; import { Breadcrumbs } from "@consta/uikit/Breadcrumbs";
import { IconArrowRight } from "@consta/icons/IconArrowRight";
import { IconAdd } from "@consta/icons/IconAdd";
import { IconQuestion } from "@consta/icons/IconQuestion";
import { nodeTypes, NodesWithChildrenContext, ModeContext } from "./nodes"; import { nodeTypes, NodesWithChildrenContext, ModeContext } from "./nodes";
import { NodeInspector } from "./NodeInspector"; import { NodeInspector } from "./NodeInspector";
import { EdgeInspector } from "./EdgeInspector"; import { EdgeInspector } from "./EdgeInspector";
import { HelpPanel } from "./HelpPanel";
import { debounce, serializeDocument } from "./serialize"; import { debounce, serializeDocument } from "./serialize";
import type { import type {
DiagramDocument, DiagramDocument,
@@ -44,6 +48,14 @@ import "./diagram.css";
interface DiagramEditorProps { interface DiagramEditorProps {
initialValue: DiagramDocument | null; initialValue: DiagramDocument | null;
onChange: (doc: DiagramDocument) => void; onChange: (doc: DiagramDocument) => void;
/**
* Режим, зафиксированный хостом (см. `?mode=view` в `src/App.tsx`).
* Когда задан, переключатель «Просмотр/Редактирование» скрыт: хост,
* встроивший приложение только на чтение, всё равно игнорирует
* `diagram:change`, и предлагать правки, которые никуда не сохранятся, —
* обман пользователя.
*/
lockedMode?: "edit" | "view";
} }
const DEFAULT_EDGE_TYPE = "smoothstep"; const DEFAULT_EDGE_TYPE = "smoothstep";
@@ -61,25 +73,27 @@ const EDGE_DASH_ARRAY: Record<EdgeLineStyle, string | undefined> = {
const MAX_HISTORY = 50; const MAX_HISTORY = 50;
const TYPING_HISTORY_THROTTLE_MS = 1000; const TYPING_HISTORY_THROTTLE_MS = 1000;
const NODE_KINDS: { kind: NodeKind; label: string }[] = [ // Группы для выпадающего меню «Добавить элемент» — 11 типов плоским списком
{ kind: "task", label: "+ Действие" }, // читаются плохо, поэтому разложены по смыслу нотации.
{ kind: "startEvent", label: "+ Начало" }, const NODE_GROUPS: { id: number; label: string }[] = [
{ kind: "intermediateEvent", label: "+ Промеж. событие" }, { id: 1, label: "Действия" },
{ kind: "endEvent", label: "+ Конец" }, { id: 2, label: "События" },
{ kind: "exclusiveGateway", label: "+ Искл. шлюз (XOR)" }, { id: 3, label: "Шлюзы" },
{ kind: "parallelGateway", label: "+ Паралл. шлюз (AND)" }, { id: 4, label: "Прочее" },
{ kind: "inclusiveGateway", label: "+ Вкл. шлюз (OR)" },
{ kind: "complexGateway", label: "+ Сложный шлюз" },
{ kind: "eventBasedGateway", label: "+ Шлюз по событию" },
{ kind: "hint", label: "+ Подсказка" },
{ kind: "dataObject", label: "+ Документ" },
]; ];
// Stable references — ChoiceGroup's controlled `value` is compared against const NODE_KINDS: { kind: NodeKind; label: string; groupId: number }[] = [
// entries in `items` by identity, so this can't be recreated on every render. { kind: "task", label: "Действие", groupId: 1 },
const MODE_ITEMS: { label: string; mode: "edit" | "view" }[] = [ { kind: "startEvent", label: "Начало", groupId: 2 },
{ label: "Просмотр", mode: "view" }, { kind: "intermediateEvent", label: "Промежуточное событие", groupId: 2 },
{ label: "Редактирование", mode: "edit" }, { kind: "endEvent", label: "Конец", groupId: 2 },
{ kind: "exclusiveGateway", label: "Исключающий шлюз (XOR)", groupId: 3 },
{ kind: "parallelGateway", label: "Параллельный шлюз (AND)", groupId: 3 },
{ kind: "inclusiveGateway", label: "Включающий шлюз (OR)", groupId: 3 },
{ kind: "complexGateway", label: "Сложный шлюз", groupId: 3 },
{ kind: "eventBasedGateway", label: "Шлюз по событию", groupId: 3 },
{ kind: "hint", label: "Подсказка", groupId: 4 },
{ kind: "dataObject", label: "Документ", groupId: 4 },
]; ];
const DEFAULT_NODE_SIZE: Record<NodeKind, { width: number; height: number }> = { const DEFAULT_NODE_SIZE: Record<NodeKind, { width: number; height: number }> = {
@@ -196,14 +210,22 @@ function isEditableTarget(target: EventTarget | null): boolean {
); );
} }
function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { function DiagramCanvas({
initialValue,
onChange,
lockedMode,
}: DiagramEditorProps) {
const [nodes, setNodes] = useState<Node[]>(() => toRfNodes(initialValue)); const [nodes, setNodes] = useState<Node[]>(() => toRfNodes(initialValue));
const [edges, setEdges] = useState<Edge[]>(() => toRfEdges(initialValue)); const [edges, setEdges] = useState<Edge[]>(() => toRfEdges(initialValue));
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null); const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null); const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [focusNodeId, setFocusNodeId] = useState<string | null>(null); const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
const [mode, setMode] = useState<"edit" | "view">("edit"); const [selectedMode, setSelectedMode] = useState<"edit" | "view">("edit");
const mode = lockedMode ?? selectedMode;
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]); const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
const [isAddMenuOpen, setIsAddMenuOpen] = useState(false);
const [isHelpOpen, setIsHelpOpen] = useState(false);
const addButtonRef = useRef<HTMLDivElement>(null);
const nodeCounter = useRef(0); const nodeCounter = useRef(0);
const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]); const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]);
const redoRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]); const redoRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]);
@@ -480,24 +502,38 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
[nodes, edges, emitChange, pushHistory], [nodes, edges, emitChange, pushHistory],
); );
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => { // React Flow гасит всплытие событий на узлах и связях, поэтому глобальные
// «клик снаружи» у выпадающих меню на них не срабатывают — закрываем сами
// из каждого обработчика холста.
const closePopups = useCallback(() => {
setIsAddMenuOpen(false);
setIsHelpOpen(false);
}, []);
const onNodeClick: NodeMouseHandler = useCallback(
(_event, node) => {
closePopups();
setSelectedNodeId(node.id); setSelectedNodeId(node.id);
setSelectedEdgeId(null); setSelectedEdgeId(null);
}, []); },
[closePopups],
);
const onEdgeClick: EdgeMouseHandler = useCallback( const onEdgeClick: EdgeMouseHandler = useCallback(
(_event, edge) => { (_event, edge) => {
if (mode !== "edit") return; if (mode !== "edit") return;
closePopups();
setSelectedEdgeId(edge.id); setSelectedEdgeId(edge.id);
setSelectedNodeId(null); setSelectedNodeId(null);
}, },
[mode], [mode, closePopups],
); );
const onPaneClick = useCallback(() => { const onPaneClick = useCallback(() => {
closePopups();
setSelectedNodeId(null); setSelectedNodeId(null);
setSelectedEdgeId(null); setSelectedEdgeId(null);
}, []); }, [closePopups]);
const updateSelectedNodeData = useCallback( const updateSelectedNodeData = useCallback(
(patch: Partial<DiagramNodeData>) => { (patch: Partial<DiagramNodeData>) => {
@@ -709,6 +745,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
const next = edges.map((e) => const next = edges.map((e) =>
e.id === selectedEdgeId ? { ...e, zIndex: z } : e, e.id === selectedEdgeId ? { ...e, zIndex: z } : e,
); );
console.log();
setEdges(next); setEdges(next);
emitChange(nodes, next); emitChange(nodes, next);
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory, minZIndex]); }, [selectedEdgeId, nodes, edges, emitChange, pushHistory, minZIndex]);
@@ -746,8 +783,33 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
edges.find((edge) => edge.id === selectedEdgeId) edges.find((edge) => edge.id === selectedEdgeId)
: undefined; : undefined;
// Панель — всплывающая деталь выбранной фигуры, одинаково в обоих режимах:
// без выбора её нечем наполнить. Она лежит оверлеем поверх холста, а не
// сжимает его — поэтому камеру при открытии трогать не нужно вообще:
// ничего никуда не уезжает, и вид остаётся ровно там, где его оставил
// пользователь.
const isAsideVisible = Boolean(selectedNode || selectedEdge);
// «Клик вне панели закрывает её». Клики по самим элементам схемы сюда не
// попадают: они переключают панель на выбранный элемент, а не закрывают её
// (иначе просмотр нескольких элементов подряд требовал бы двойных кликов).
//
// Обработчик висит на всём shell и срабатывает по всплытию — то есть уже
// ПОСЛЕ onNodeClick/onEdgeClick. Пропустить здесь элемент схемы означает
// мгновенно сбросить только что выставленное выделение, поэтому исключения
// должны покрывать и узлы, и связи (у связей кликабельны path и подпись —
// все они лежат внутри группы .react-flow__edge).
const onShellClick = useCallback((event: React.MouseEvent) => {
const target = event.target as Element;
if (target.closest(".diagram-aside")) return;
if (target.closest(".react-flow__node")) return;
if (target.closest(".react-flow__edge")) return;
setSelectedNodeId(null);
setSelectedEdgeId(null);
}, []);
return ( return (
<div className="diagram-shell"> <div className="diagram-shell" onClick={onShellClick}>
<div className="diagram-canvas" ref={canvasWrapperRef}> <div className="diagram-canvas" ref={canvasWrapperRef}>
<NodesWithChildrenContext.Provider value={nodesWithChildren}> <NodesWithChildrenContext.Provider value={nodesWithChildren}>
<ModeContext.Provider value={mode}> <ModeContext.Provider value={mode}>
@@ -832,30 +894,83 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
</ControlButton> </ControlButton>
</> </>
)} )}
{/*
Переключатель режима живёт здесь, а не отдельной панелью:
скрыт, когда режим зафиксирован хостом (`?mode=view`) —
тогда переключать нечего. `title` служит подсказкой при
наведении, как и у остальных кнопок этой панели.
*/}
{!lockedMode && (
<ControlButton
onClick={() =>
setSelectedMode(mode === "edit" ? "view" : "edit")
}
title={
mode === "edit" ?
"Режим просмотра: схема только для чтения"
: "Вернуться к редактированию"
}
>
{mode === "edit" ? "👁" : "✎"}
</ControlButton>
)}
</Controls> </Controls>
{mode === "edit" && <MiniMap />} {mode === "edit" && <MiniMap />}
<Panel position="top-right" className="diagram-mode-toggle">
<ChoiceGroup
size="xs"
name="diagram-mode"
items={MODE_ITEMS}
value={MODE_ITEMS.find((item) => item.mode === mode) ?? null}
onChange={(item) => setMode(item.mode)}
/>
</Panel>
{mode === "edit" && ( {mode === "edit" && (
<Panel position="top-left" className="diagram-toolbar"> <Panel position="top-left" className="diagram-toolbar">
{NODE_KINDS.map(({ kind, label }) => ( <div ref={addButtonRef}>
<Button <Button
key={kind} size="s"
size="xs" view="primary"
view="ghost" label="Добавить элемент"
label={label} iconLeft={IconAdd}
onClick={() => addNode(kind)} onClick={() => setIsAddMenuOpen((open) => !open)}
/>
</div>
<ContextMenu
isOpen={isAddMenuOpen}
items={NODE_KINDS}
groups={NODE_GROUPS}
getItemKey={(item) => item.kind}
getItemLabel={(item) => item.label}
getItemGroupId={(item) => item.groupId}
getGroupId={(group) => group.id}
getGroupLabel={(group) => group.label}
anchorRef={addButtonRef as React.RefObject<HTMLElement>}
direction="downStartLeft"
size="s"
onItemClick={(item) => {
addNode(item.kind);
setIsAddMenuOpen(false);
}}
onClickOutside={() => setIsAddMenuOpen(false)}
onEsc={() => setIsAddMenuOpen(false)}
/> />
))}
</Panel> </Panel>
)} )}
<Panel position="top-right" className="diagram-help">
<Button
size="s"
view="ghost"
form="round"
onlyIcon
iconLeft={IconQuestion}
title="Справка по работе с конструктором"
aria-label="Справка по работе с конструктором"
onClick={() => {
setIsAddMenuOpen(false);
setIsHelpOpen((open) => !open);
}}
/>
{isHelpOpen && (
<HelpPanel mode={mode} canSwitchMode={!lockedMode} />
)}
</Panel>
{/*
Крошки прячутся, пока открыта боковая панель: холст сужается,
и они наезжают на кнопки «Добавить элемент» и «?» по краям.
*/}
{!isAsideVisible && (
<Panel position="top-center" className="diagram-breadcrumbs"> <Panel position="top-center" className="diagram-breadcrumbs">
<Breadcrumbs <Breadcrumbs
size="xs" size="xs"
@@ -875,12 +990,26 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
getItemOnClick={(item) => item.onClick} getItemOnClick={(item) => item.onClick}
/> />
</Panel> </Panel>
)}
</ReactFlow> </ReactFlow>
</ModeContext.Provider> </ModeContext.Provider>
</NodesWithChildrenContext.Provider> </NodesWithChildrenContext.Provider>
</div> </div>
{isAsideVisible && (
<aside className="diagram-aside"> <aside className="diagram-aside">
<div className="diagram-aside__close">
<Button
size="s"
view="clear"
form="round"
onlyIcon
iconLeft={IconArrowRight}
title="Скрыть панель"
aria-label="Скрыть панель"
onClick={onPaneClick}
/>
</div>
{selectedNode ? {selectedNode ?
<NodeInspector <NodeInspector
nodeId={selectedNode.id} nodeId={selectedNode.id}
@@ -908,23 +1037,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
onBringToFront={bringSelectedEdgeToFront} onBringToFront={bringSelectedEdgeToFront}
onSendToBack={sendSelectedEdgeToBack} onSendToBack={sendSelectedEdgeToBack}
/> />
: <p className="diagram-aside__hint"> : null}
{mode === "edit" ?
"Выберите узел или связь на схеме — появятся кнопки «Дублировать»/«Удалить» и поля данных."
: "Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода."
}
</p>
}
{mode === "edit" && (
<details className="diagram-aside__json">
<summary>Текущий doc (для демо)</summary>
<pre>
{JSON.stringify(serializeDocument(nodes, edges), null, 2)}
</pre>
</details>
)}
</aside> </aside>
)}
</div> </div>
); );
} }
+116 -52
View File
@@ -1,62 +1,64 @@
import { useRef, useState, type RefObject } from 'react'; import { useRef, useState, type RefObject } from "react";
import { Button } from '@consta/uikit/Button'; import { Button } from "@consta/uikit/Button";
import { TextField } from '@consta/uikit/TextField'; import { TextField } from "@consta/uikit/TextField";
import { ChoiceGroup } from '@consta/uikit/ChoiceGroup'; import { ChoiceGroup } from "@consta/uikit/ChoiceGroup";
import { ColorPicker, hexModel } from '@consta/uikit/ColorPicker'; import { ColorPicker, hexModel } from "@consta/uikit/ColorPicker";
import { Text } from '@consta/uikit/Text'; import { Text } from "@consta/uikit/Text";
import type { DiagramEdgeData, EdgeLineStyle } from './types'; import { IconCancel } from "@consta/icons/IconCancel";
import { TRANSPARENT_COLOR, toPickerHex } from "./colors";
import type { DiagramEdgeData, EdgeLineStyle } from "./types";
const LINE_STYLE_ITEMS: { label: string; style: EdgeLineStyle }[] = [ const LINE_STYLE_ITEMS: { label: string; style: EdgeLineStyle }[] = [
{ label: 'Сплошная', style: 'solid' }, { label: "Сплошная", style: "solid" },
{ label: 'Пунктирная', style: 'dashed' }, { label: "Пунктирная", style: "dashed" },
{ label: 'Точечная', style: 'dotted' }, { label: "Точечная", style: "dotted" },
]; ];
// "Основные цвета для мелкой графики, линий" — two tonal rows (blue, gray) // "Основные цвета для мелкой графики, линий" — two tonal rows (blue, gray)
// meant specifically for lines, shown first. // meant specifically for lines, shown first.
const GRAPHIC_LINE_PRESETS: { hex: string; label: string }[] = [ const GRAPHIC_LINE_PRESETS: { hex: string; label: string }[] = [
{ hex: '#0079C2', label: 'Синий 1' }, { hex: "#0079C2", label: "Синий 1" },
{ hex: '#43B7F7', label: 'Синий 2' }, { hex: "#43B7F7", label: "Синий 2" },
{ hex: '#89CFFF', label: 'Синий 3' }, { hex: "#89CFFF", label: "Синий 3" },
{ hex: '#75AAC5', label: 'Синий 4' }, { hex: "#75AAC5", label: "Синий 4" },
{ hex: '#8FD7E4', label: 'Синий 5' }, { hex: "#8FD7E4", label: "Синий 5" },
{ hex: '#004181', label: 'Синий 6' }, { hex: "#004181", label: "Синий 6" },
{ hex: '#929AA4', label: 'Серый 1' }, { hex: "#929AA4", label: "Серый 1" },
{ hex: '#CED2D6', label: 'Серый 2' }, { hex: "#CED2D6", label: "Серый 2" },
{ hex: '#E9EBED', label: 'Серый 3' }, { hex: "#E9EBED", label: "Серый 3" },
{ hex: '#FFFFFF', label: 'Серый 4' }, { hex: "#FFFFFF", label: "Серый 4" },
]; ];
// "Дополнительные цвета" — the section palette.css marks as meant for // "Дополнительные цвета" — the section palette.css marks as meant for
// lines/small graphics too (single accents), shown after the row above. // lines/small graphics too (single accents), shown after the row above.
const ACCENT_LINE_PRESETS: { hex: string; label: string }[] = [ const ACCENT_LINE_PRESETS: { hex: string; label: string }[] = [
{ hex: '#B5309A', label: 'Пурпурный' }, { hex: "#B5309A", label: "Пурпурный" },
{ hex: '#EC5665', label: 'Красный' }, { hex: "#EC5665", label: "Красный" },
{ hex: '#FFAB66', label: 'Оранжевый' }, { hex: "#FFAB66", label: "Оранжевый" },
{ hex: '#C2DC5B', label: 'Оливковый' }, { hex: "#C2DC5B", label: "Оливковый" },
{ hex: '#32CA88', label: 'Зелёный' }, { hex: "#32CA88", label: "Зелёный" },
{ hex: '#22926D', label: 'Тёмно-зелёный' }, { hex: "#22926D", label: "Тёмно-зелёный" },
{ hex: '#0EBAD5', label: 'Синий' }, { hex: "#0EBAD5", label: "Синий" },
{ hex: '#4EE7F4', label: 'Циан' }, { hex: "#4EE7F4", label: "Циан" },
{ hex: '#9152E6', label: 'Виолет' }, { hex: "#9152E6", label: "Виолет" },
{ hex: '#9D9AF7', label: 'Лавандовый' }, { hex: "#9D9AF7", label: "Лавандовый" },
{ hex: '#E88FDB', label: 'Розовый' }, { hex: "#E88FDB", label: "Розовый" },
]; ];
const LINE_COLOR_PRESETS = [...GRAPHIC_LINE_PRESETS, ...ACCENT_LINE_PRESETS]; const LINE_COLOR_PRESETS = [...GRAPHIC_LINE_PRESETS, ...ACCENT_LINE_PRESETS];
// "Цвета текста" from palette.css. // "Цвета текста" from palette.css.
const TEXT_COLOR_PRESETS: { hex: string; label: string }[] = [ const TEXT_COLOR_PRESETS: { hex: string; label: string }[] = [
{ hex: '#0079C2', label: 'Синий' }, { hex: "#0079C2", label: "Синий" },
{ hex: '#525A64', label: 'Тёмный' }, { hex: "#525A64", label: "Тёмный" },
{ hex: '#929AA4', label: 'Серый' }, { hex: "#929AA4", label: "Серый" },
{ hex: '#FFFFFF', label: 'Белый' }, { hex: "#FFFFFF", label: "Белый" },
]; ];
const DEFAULT_STROKE_WIDTH = 1; const DEFAULT_STROKE_WIDTH = 1;
const DEFAULT_BORDER_RADIUS = 5; const DEFAULT_BORDER_RADIUS = 5;
const DEFAULT_EDGE_COLOR = '#b1b1b7'; const DEFAULT_EDGE_COLOR = "#b1b1b7";
const DEFAULT_TEXT_COLOR = '#FFFFFF'; const DEFAULT_TEXT_COLOR = "#FFFFFF";
const DEFAULT_FONT_SIZE = 11; const DEFAULT_FONT_SIZE = 11;
interface EdgeInspectorProps { interface EdgeInspectorProps {
@@ -81,11 +83,15 @@ export function EdgeInspector({
onSendToBack, onSendToBack,
}: EdgeInspectorProps) { }: EdgeInspectorProps) {
const [colorPickerOpen, setColorPickerOpen] = useState(false); const [colorPickerOpen, setColorPickerOpen] = useState(false);
const colorButtonRef = useRef<HTMLDivElement>(null) as RefObject<HTMLDivElement>; const colorButtonRef = useRef<HTMLDivElement>(
null,
) as RefObject<HTMLDivElement>;
const currentColor = data.color ?? DEFAULT_EDGE_COLOR; const currentColor = data.color ?? DEFAULT_EDGE_COLOR;
const [textColorPickerOpen, setTextColorPickerOpen] = useState(false); const [textColorPickerOpen, setTextColorPickerOpen] = useState(false);
const textColorButtonRef = useRef<HTMLDivElement>(null) as RefObject<HTMLDivElement>; const textColorButtonRef = useRef<HTMLDivElement>(
null,
) as RefObject<HTMLDivElement>;
const currentTextColor = data.textColor ?? DEFAULT_TEXT_COLOR; const currentTextColor = data.textColor ?? DEFAULT_TEXT_COLOR;
return ( return (
@@ -94,9 +100,6 @@ export function EdgeInspector({
<Text size="s" weight="semibold"> <Text size="s" weight="semibold">
Поток управления (Sequence Flow) Поток управления (Sequence Flow)
</Text> </Text>
<Text size="xs" view="secondary" font="mono">
{edgeId}
</Text>
</div> </div>
<div className="node-inspector__toolbar"> <div className="node-inspector__toolbar">
@@ -104,11 +107,26 @@ export function EdgeInspector({
</div> </div>
<div className="node-inspector__toolbar"> <div className="node-inspector__toolbar">
<Button size="s" view="secondary" label="На передний план" onClick={onBringToFront} /> <Button
<Button size="s" view="secondary" label="На задний план" onClick={onSendToBack} /> size="s"
view="secondary"
label="На передний план"
onClick={onBringToFront}
/>
<Button
size="s"
view="secondary"
label="На задний план"
onClick={onSendToBack}
/>
</div> </div>
<TextField label="Подпись" size="s" value={label} onChange={(value) => onLabelChange(value ?? '')} /> <TextField
label="Подпись"
size="s"
value={label}
onChange={(value) => onLabelChange(value ?? "")}
/>
<div className="node-inspector__field"> <div className="node-inspector__field">
<Text size="xs" view="secondary"> <Text size="xs" view="secondary">
@@ -121,23 +139,46 @@ export function EdgeInspector({
type="button" type="button"
title={presetLabel} title={presetLabel}
aria-label={presetLabel} aria-label={presetLabel}
className={`node-inspector__preset${currentColor === hex ? ' node-inspector__preset--active' : ''}`} className={`node-inspector__preset${currentColor === hex ? " node-inspector__preset--active" : ""}`}
style={{ background: hex }} style={{ background: hex }}
onClick={() => onDataChange({ color: hex })} onClick={() => onDataChange({ color: hex })}
/> />
))} ))}
{/*
Цвет линии заодно красит стрелку и подложку подписи (см.
labelBgStyle в DiagramEditor) — прозрачный здесь убирает и её.
*/}
<button
type="button"
title="Прозрачный"
aria-label="Прозрачный"
className={`node-inspector__preset node-inspector__preset--none${
currentColor === TRANSPARENT_COLOR ?
" node-inspector__preset--active"
: ""
}`}
onClick={() => onDataChange({ color: TRANSPARENT_COLOR })}
>
<IconCancel size="xs" />
</button>
</div> </div>
<div ref={colorButtonRef} className="node-inspector__color-trigger"> <div ref={colorButtonRef} className="node-inspector__color-trigger">
<Button size="s" view="secondary" label="Настроить цвет" onClick={() => setColorPickerOpen(true)} /> <Button
size="s"
view="secondary"
label="Настроить цвет"
onClick={() => setColorPickerOpen(true)}
/>
</div> </div>
{colorPickerOpen && ( {colorPickerOpen && (
<ColorPicker <ColorPicker
model={hexModel} model={hexModel}
value={currentColor} value={toPickerHex(currentColor, DEFAULT_EDGE_COLOR)}
onChange={(value) => onDataChange({ color: value })} onChange={(value) => onDataChange({ color: value })}
anchorRef={colorButtonRef} anchorRef={colorButtonRef}
open={colorPickerOpen} open={colorPickerOpen}
onOpen={setColorPickerOpen} onOpen={setColorPickerOpen}
style={{ zIndex: 100 }}
/> />
)} )}
</div> </div>
@@ -153,23 +194,42 @@ export function EdgeInspector({
type="button" type="button"
title={presetLabel} title={presetLabel}
aria-label={presetLabel} aria-label={presetLabel}
className={`node-inspector__preset${currentTextColor === hex ? ' node-inspector__preset--active' : ''}`} className={`node-inspector__preset${currentTextColor === hex ? " node-inspector__preset--active" : ""}`}
style={{ background: hex }} style={{ background: hex }}
onClick={() => onDataChange({ textColor: hex })} onClick={() => onDataChange({ textColor: hex })}
/> />
))} ))}
<button
type="button"
title="Прозрачный"
aria-label="Прозрачный"
className={`node-inspector__preset node-inspector__preset--none${
currentTextColor === TRANSPARENT_COLOR ?
" node-inspector__preset--active"
: ""
}`}
onClick={() => onDataChange({ textColor: TRANSPARENT_COLOR })}
>
<IconCancel size="xs" />
</button>
</div> </div>
<div ref={textColorButtonRef} className="node-inspector__color-trigger"> <div ref={textColorButtonRef} className="node-inspector__color-trigger">
<Button size="s" view="secondary" label="Настроить цвет" onClick={() => setTextColorPickerOpen(true)} /> <Button
size="s"
view="secondary"
label="Настроить цвет"
onClick={() => setTextColorPickerOpen(true)}
/>
</div> </div>
{textColorPickerOpen && ( {textColorPickerOpen && (
<ColorPicker <ColorPicker
model={hexModel} model={hexModel}
value={currentTextColor} value={toPickerHex(currentTextColor, DEFAULT_TEXT_COLOR)}
onChange={(value) => onDataChange({ textColor: value })} onChange={(value) => onDataChange({ textColor: value })}
anchorRef={textColorButtonRef} anchorRef={textColorButtonRef}
open={textColorPickerOpen} open={textColorPickerOpen}
onOpen={setTextColorPickerOpen} onOpen={setTextColorPickerOpen}
style={{ zIndex: 100 }}
/> />
)} )}
</div> </div>
@@ -208,7 +268,11 @@ export function EdgeInspector({
size="xs" size="xs"
name={`edge-line-style-${edgeId}`} name={`edge-line-style-${edgeId}`}
items={LINE_STYLE_ITEMS} items={LINE_STYLE_ITEMS}
value={LINE_STYLE_ITEMS.find((item) => item.style === (data.lineStyle ?? 'solid')) ?? null} value={
LINE_STYLE_ITEMS.find(
(item) => item.style === (data.lineStyle ?? "solid"),
) ?? null
}
onChange={(item) => onDataChange({ lineStyle: item.style })} onChange={(item) => onDataChange({ lineStyle: item.style })}
/> />
</div> </div>
+182
View File
@@ -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>
);
}
+61 -23
View File
@@ -3,6 +3,8 @@ import { Button } from "@consta/uikit/Button";
import { TextField } from "@consta/uikit/TextField"; import { TextField } from "@consta/uikit/TextField";
import { ColorPicker, hexModel } from "@consta/uikit/ColorPicker"; import { ColorPicker, hexModel } from "@consta/uikit/ColorPicker";
import { Text } from "@consta/uikit/Text"; import { Text } from "@consta/uikit/Text";
import { IconCancel } from "@consta/icons/IconCancel";
import { TRANSPARENT_COLOR, toPickerHex } from "./colors";
import { import {
DEFAULT_CAPTION_TEXT_COLOR, DEFAULT_CAPTION_TEXT_COLOR,
DEFAULT_NODE_BACKGROUND, DEFAULT_NODE_BACKGROUND,
@@ -16,7 +18,12 @@ import {
TASK_SUBTYPE_ICON, TASK_SUBTYPE_ICON,
TASK_SUBTYPE_LABEL, TASK_SUBTYPE_LABEL,
} from "./nodes"; } from "./nodes";
import type { DiagramNodeData, EventSubtype, NodeKind, TaskSubtype } from "./types"; import type {
DiagramNodeData,
EventSubtype,
NodeKind,
TaskSubtype,
} from "./types";
const TASK_SUBTYPES: TaskSubtype[] = [ const TASK_SUBTYPES: TaskSubtype[] = [
"none", "none",
@@ -77,7 +84,11 @@ const TYPE_LABELS: Record<NodeKind, string> = {
dataObject: "Документ (Data Object)", dataObject: "Документ (Data Object)",
}; };
const EVENT_KINDS = new Set<NodeKind>(["startEvent", "intermediateEvent", "endEvent"]); const EVENT_KINDS = new Set<NodeKind>([
"startEvent",
"intermediateEvent",
"endEvent",
]);
interface NodeInspectorProps { interface NodeInspectorProps {
nodeId: string; nodeId: string;
@@ -111,13 +122,9 @@ export function NodeInspector({
const colorButtonRef = useRef<HTMLDivElement>( const colorButtonRef = useRef<HTMLDivElement>(
null, null,
) as RefObject<HTMLDivElement>; ) as RefObject<HTMLDivElement>;
// data.color can be a `var(--gradient-x)` reference from the preset row — // data.color бывает не-hex: `var(--gradient-x)` от пресета-градиента или
// hexModel tries to parse that as a hex color and produces NaN, so the // `transparent` — hexModel такое не разбирает, нужен hex-дефолт.
// hex-only ColorControl/ColorPicker always need a real flat hex fallback. const flatColor = toPickerHex(data.color, DEFAULT_NODE_COLOR[nodeType]);
const flatColor =
data.color && !data.color.startsWith("var(") ?
data.color
: DEFAULT_NODE_COLOR[nodeType];
const [textColorPickerOpen, setTextColorPickerOpen] = useState(false); const [textColorPickerOpen, setTextColorPickerOpen] = useState(false);
const textColorButtonRef = useRef<HTMLDivElement>( const textColorButtonRef = useRef<HTMLDivElement>(
@@ -132,10 +139,12 @@ export function NodeInspector({
if (mode === "view") { if (mode === "view") {
const hasMeta = Boolean(data.responsible || data.duration); const hasMeta = Boolean(data.responsible || data.duration);
const subtypeLabel = const subtypeLabel =
nodeType === "task" nodeType === "task" ?
? TASK_SUBTYPE_LABEL[(data.subtype as TaskSubtype | undefined) ?? "none"] TASK_SUBTYPE_LABEL[(data.subtype as TaskSubtype | undefined) ?? "none"]
: EVENT_KINDS.has(nodeType) : EVENT_KINDS.has(nodeType) ?
? EVENT_SUBTYPE_LABEL[(data.subtype as EventSubtype | undefined) ?? "none"] EVENT_SUBTYPE_LABEL[
(data.subtype as EventSubtype | undefined) ?? "none"
]
: undefined; : undefined;
return ( return (
<div className="node-inspector"> <div className="node-inspector">
@@ -200,9 +209,6 @@ export function NodeInspector({
<Text size="s" weight="semibold"> <Text size="s" weight="semibold">
{TYPE_LABELS[nodeType]} {TYPE_LABELS[nodeType]}
</Text> </Text>
<Text size="xs" view="secondary" font="mono">
{nodeId}
</Text>
</div> </div>
<div className="node-inspector__toolbar"> <div className="node-inspector__toolbar">
@@ -248,7 +254,9 @@ export function NodeInspector({
className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`} className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`}
onClick={() => onChange({ subtype })} onClick={() => onChange({ subtype })}
> >
{Icon ? <Icon size="s" /> : <Text size="xs">—</Text>} {Icon ?
<Icon size="s" />
: <Text size="xs">—</Text>}
</button> </button>
); );
})} })}
@@ -262,8 +270,9 @@ export function NodeInspector({
Тип триггера Тип триггера
</Text> </Text>
<div className="node-inspector__presets"> <div className="node-inspector__presets">
{EVENT_SUBTYPES_BY_KIND[nodeType as "startEvent" | "intermediateEvent" | "endEvent"].map( {EVENT_SUBTYPES_BY_KIND[
(subtype: EventSubtype) => { nodeType as "startEvent" | "intermediateEvent" | "endEvent"
].map((subtype: EventSubtype) => {
const Icon = EVENT_SUBTYPE_ICON[subtype]; const Icon = EVENT_SUBTYPE_ICON[subtype];
const isActive = (data.subtype ?? "none") === subtype; const isActive = (data.subtype ?? "none") === subtype;
return ( return (
@@ -275,11 +284,12 @@ export function NodeInspector({
className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`} className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`}
onClick={() => onChange({ subtype })} onClick={() => onChange({ subtype })}
> >
{Icon ? <Icon size="s" /> : <Text size="xs">—</Text>} {Icon ?
<Icon size="s" />
: <Text size="xs">—</Text>}
</button> </button>
); );
}, })}
)}
</div> </div>
</div> </div>
)} )}
@@ -305,6 +315,19 @@ export function NodeInspector({
/> />
); );
})} })}
<button
type="button"
title="Прозрачный"
aria-label="Прозрачный"
className={`node-inspector__preset node-inspector__preset--none${
data.color === TRANSPARENT_COLOR ?
" node-inspector__preset--active"
: ""
}`}
onClick={() => onChange({ color: TRANSPARENT_COLOR })}
>
<IconCancel size="xs" />
</button>
</div> </div>
<div ref={colorButtonRef} className="node-inspector__color-trigger"> <div ref={colorButtonRef} className="node-inspector__color-trigger">
<Button <Button
@@ -322,6 +345,7 @@ export function NodeInspector({
anchorRef={colorButtonRef} anchorRef={colorButtonRef}
open={colorPickerOpen} open={colorPickerOpen}
onOpen={setColorPickerOpen} onOpen={setColorPickerOpen}
style={{ zIndex: 100 }}
/> />
)} )}
</div> </div>
@@ -342,6 +366,19 @@ export function NodeInspector({
onClick={() => onChange({ textColor: hex })} onClick={() => onChange({ textColor: hex })}
/> />
))} ))}
<button
type="button"
title="Прозрачный"
aria-label="Прозрачный"
className={`node-inspector__preset node-inspector__preset--none${
currentTextColor === TRANSPARENT_COLOR ?
" node-inspector__preset--active"
: ""
}`}
onClick={() => onChange({ textColor: TRANSPARENT_COLOR })}
>
<IconCancel size="xs" />
</button>
</div> </div>
<div ref={textColorButtonRef} className="node-inspector__color-trigger"> <div ref={textColorButtonRef} className="node-inspector__color-trigger">
<Button <Button
@@ -354,11 +391,12 @@ export function NodeInspector({
{textColorPickerOpen && ( {textColorPickerOpen && (
<ColorPicker <ColorPicker
model={hexModel} model={hexModel}
value={currentTextColor} value={toPickerHex(currentTextColor, defaultTextColor)}
onChange={(value) => onChange({ textColor: value })} onChange={(value) => onChange({ textColor: value })}
anchorRef={textColorButtonRef} anchorRef={textColorButtonRef}
open={textColorPickerOpen} open={textColorPickerOpen}
onOpen={setTextColorPickerOpen} onOpen={setTextColorPickerOpen}
style={{ zIndex: 100 }}
/> />
)} )}
</div> </div>
+17
View File
@@ -0,0 +1,17 @@
/**
* «Без заливки». Валидное значение и для CSS `background`/`color`, и для
* SVG `stroke`/`fill`, поэтому одинаково годится фигурам, линиям и подписям.
*/
export const TRANSPARENT_COLOR = "transparent";
/**
* Consta's ColorPicker/ColorControl работают через hexModel и понимают только
* плоский hex. В data цвет может лежать и как `var(--gradient-x)` (пресет-
* градиент), и как `transparent` — если отдать такое в hexModel, разбор даст
* NaN и поле покажет «NaNNaNNaN». Поэтому всё, что не hex, подменяем дефолтом.
*/
export function toPickerHex(value: string | undefined, fallback: string): string {
return value && /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) ?
value
: fallback;
}
+147 -7
View File
@@ -1,19 +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-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;
border: 1px solid color-mix(in srgb, canvastext 15%, transparent);
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
cursor: default;
}
.help-panel__section-title {
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;
} }
/* /*
@@ -36,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;
@@ -162,10 +257,27 @@
clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 0 100%); 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;
@@ -178,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;
@@ -235,6 +357,24 @@
outline-offset: 2px; outline-offset: 2px;
} }
/*
* «Прозрачный» — не цвет, а его отсутствие, поэтому вместо заливки внутри
* значок-перечёркивание. Шахматка под ним — привычная в редакторах пометка
* прозрачности, иначе кружок читался бы просто как белый.
*/
.node-inspector__preset--none {
display: flex;
align-items: center;
justify-content: center;
color: #525a64;
background:
linear-gradient(45deg, #d7dae0 25%, transparent 25%, transparent 75%, #d7dae0 75%),
linear-gradient(45deg, #d7dae0 25%, transparent 25%, transparent 75%, #d7dae0 75%),
#fff;
background-size: 8px 8px;
background-position: 0 0, 4px 4px;
}
.node-inspector__subtype { .node-inspector__subtype {
width: 28px; width: 28px;
height: 28px; height: 28px;
+28 -10
View File
@@ -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) {
+17
View File
@@ -21,3 +21,20 @@ body,
.diagram-breadcrumbs:hover { .diagram-breadcrumbs:hover {
cursor: pointer; 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 -1
View File
@@ -186,7 +186,7 @@ function SubProcessBadge({ id }: { id: string }) {
return ( return (
<span <span
className="diagram-node__badge" className="diagram-node__badge"
title="Есть вложенные узлы — двойной клик, чтобы открыть" title="Есть вложенные узлы — выберите фигуру, чтобы открыть их из боковой панели"
> >
⇒ ⇒
</span> </span>
+78 -21
View File
@@ -1,31 +1,88 @@
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: { build: {
// No content hashes in output filenames — cache-busting on redeploy is // Same content hash Vite computes by default — just without its default
// handled at the hosting/nginx level instead (see docs/bitrix-integration.md). // "assets/" subfolder prefix. The deploy target's own folder is itself
// Flat layout (no "assets/" subfolder): one deployment target's own // named assets (local/modules/vniigaz.bpmn/assets/), so keeping Vite's
// folder is literally named "assets" (local/modules/vniigaz.bpmn/assets/), // default pattern would nest output at .../assets/assets/index-*.js (404).
// and nesting output under another "assets/" there doubled the path
// segment (.../assets/assets/index.js, 404). Flat works everywhere.
rolldownOptions: { rolldownOptions: {
output: { output: {
entryFileNames: '[name].js', entryFileNames: '[name]-[hash].js',
chunkFileNames: '[name].js', chunkFileNames: '[name]-[hash].js',
assetFileNames: '[name].[ext]', assetFileNames: '[name]-[hash][extname]',
}, },
}, },
}, },