Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a4f463bf2 | ||
|
|
762f43dad4 | ||
|
|
e8c05d307c | ||
|
|
0d714ab38e | ||
|
|
1a9f24e53f | ||
|
|
6e420470b3 |
@@ -3,3 +3,10 @@
|
||||
Линтер: oxlint
|
||||
Библиотека компонентов: 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, варианты деплоя
|
||||
|
||||
|
||||
@@ -8,14 +8,19 @@
|
||||
|
||||
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('*')`).
|
||||
- `VITE_BASE_PATH` — подпуть, под которым будет отдаваться приложение. Должен совпадать с тем, куда положите `dist/` на шаге 2.
|
||||
- Переменные окружения не нужны. Ссылки на 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. Готово: приложение доступно по `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
|
||||
|
||||
@@ -23,7 +28,8 @@
|
||||
|
||||
```html
|
||||
<input type="hidden" id="DIAGRAM-DATA" name="DIAGRAM-DATA" value="<?= htmlspecialcharsbx($currentValue) ?>">
|
||||
<iframe id="diagram-frame" src="/local/diagram-app/index.html" style="width:100%;height:600px;border:1px solid #d7dae0;border-radius:6px"></iframe>
|
||||
<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 () {
|
||||
|
||||
@@ -44,8 +44,12 @@ class DiagramProperty
|
||||
|
||||
// Вариант A — рекомендуемый, приложение лежит внутри дерева портала (см. раздел 7):
|
||||
// appOrigin — это просто origin текущего запроса, appUrl — конкретный файл.
|
||||
// ВАЖНО: имя файла — не константа. dist/index.html выходит из сборки как
|
||||
// index-<hash>.html (хеш меняется при каждой значимой пересборке, см.
|
||||
// раздел 7) — 'index.html' строкой здесь писать нельзя, нужно получать
|
||||
// актуальное имя каким-то способом на своей стороне (см. раздел 7).
|
||||
$appOrigin = (CMain::IsHTTPS() ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'];
|
||||
$appUrl = $appOrigin . '/local/diagram-app/index.html';
|
||||
$appUrl = $appOrigin . '/local/diagram-app/' . GetActualDiagramAppIndexFile(); // пример, не готовая функция
|
||||
// Вариант B — если приложение развёрнуто отдельным контейнером (см. раздел 7),
|
||||
// appUrl/appOrigin вместо этого указывают на его отдельный адрес, например:
|
||||
// $appOrigin = 'http://localhost:5178';
|
||||
@@ -120,6 +124,18 @@ class DiagramProperty
|
||||
|
||||
`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. Требования безопасности (обязательны, не опциональны)
|
||||
|
||||
- Значение всегда попадает на страницу только через `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 не требуется.
|
||||
- `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 самого портала, а не адрес отдельного сервиса.
|
||||
- **Важный нюанс, найден живым тестом на дев-стенде:** тот же 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 переменными:
|
||||
```
|
||||
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/` — точный путь для конкретного стенда уточняется у того, кто его поднимал/администрирует).
|
||||
3. В PHP-классе (раздел 3): `$appUrl = $appOrigin . '/local/diagram-app/index.html';`, `$appOrigin` — origin портала (без пути).
|
||||
4. Обновление после правок в React-приложении — тот же `npm run build` + копирование поверх старых файлов. Пересборка/перезапуск контейнеров Bitrix не нужны.
|
||||
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 не нужны. Старый `index-<hash>.html` от предыдущей сборки при этом не удаляется автоматически — если способ поиска актуального файла (шаг 3) предполагает «взять единственный файл по маске», после нескольких деплоев в каталоге стоит убирать файлы прошлых версий, иначе маска перестанет быть однозначной.
|
||||
|
||||
### Вариант B — отдельный Docker-контейнер
|
||||
|
||||
@@ -245,7 +273,9 @@ services:
|
||||
|
||||
Собрал и проверил локально (`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 этой проблемы не имеет вообще, так как всегда живёт на домене портала.
|
||||
|
||||
|
||||
@@ -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
@@ -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 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.
|
||||
const fallbackDocument = isProd ? EMPTY_DOCUMENT : SAMPLE_DOCUMENT;
|
||||
|
||||
@@ -81,12 +97,16 @@ function App() {
|
||||
}, []);
|
||||
|
||||
if (!doc) {
|
||||
return <p style={{ padding: 16, fontFamily: 'system-ui, sans-serif' }}>Загрузка диаграммы…</p>;
|
||||
return <div className="diagram-loading">Загрузка диаграммы…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Theme preset={presetGpnDefault} style={{ height: '100%' }}>
|
||||
<DiagramEditor initialValue={doc} onChange={handleChange} />
|
||||
<DiagramEditor
|
||||
initialValue={doc}
|
||||
onChange={handleChange}
|
||||
lockedMode={lockedMode}
|
||||
/>
|
||||
</Theme>
|
||||
);
|
||||
}
|
||||
|
||||
+202
-87
@@ -26,11 +26,15 @@ import {
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
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 { IconArrowRight } from "@consta/icons/IconArrowRight";
|
||||
import { IconAdd } from "@consta/icons/IconAdd";
|
||||
import { IconQuestion } from "@consta/icons/IconQuestion";
|
||||
import { nodeTypes, NodesWithChildrenContext, ModeContext } from "./nodes";
|
||||
import { NodeInspector } from "./NodeInspector";
|
||||
import { EdgeInspector } from "./EdgeInspector";
|
||||
import { HelpPanel } from "./HelpPanel";
|
||||
import { debounce, serializeDocument } from "./serialize";
|
||||
import type {
|
||||
DiagramDocument,
|
||||
@@ -44,6 +48,14 @@ import "./diagram.css";
|
||||
interface DiagramEditorProps {
|
||||
initialValue: DiagramDocument | null;
|
||||
onChange: (doc: DiagramDocument) => void;
|
||||
/**
|
||||
* Режим, зафиксированный хостом (см. `?mode=view` в `src/App.tsx`).
|
||||
* Когда задан, переключатель «Просмотр/Редактирование» скрыт: хост,
|
||||
* встроивший приложение только на чтение, всё равно игнорирует
|
||||
* `diagram:change`, и предлагать правки, которые никуда не сохранятся, —
|
||||
* обман пользователя.
|
||||
*/
|
||||
lockedMode?: "edit" | "view";
|
||||
}
|
||||
|
||||
const DEFAULT_EDGE_TYPE = "smoothstep";
|
||||
@@ -61,25 +73,27 @@ const EDGE_DASH_ARRAY: Record<EdgeLineStyle, string | undefined> = {
|
||||
const MAX_HISTORY = 50;
|
||||
const TYPING_HISTORY_THROTTLE_MS = 1000;
|
||||
|
||||
const NODE_KINDS: { kind: NodeKind; label: string }[] = [
|
||||
{ kind: "task", label: "+ Действие" },
|
||||
{ kind: "startEvent", label: "+ Начало" },
|
||||
{ kind: "intermediateEvent", label: "+ Промеж. событие" },
|
||||
{ kind: "endEvent", label: "+ Конец" },
|
||||
{ kind: "exclusiveGateway", label: "+ Искл. шлюз (XOR)" },
|
||||
{ kind: "parallelGateway", label: "+ Паралл. шлюз (AND)" },
|
||||
{ kind: "inclusiveGateway", label: "+ Вкл. шлюз (OR)" },
|
||||
{ kind: "complexGateway", label: "+ Сложный шлюз" },
|
||||
{ kind: "eventBasedGateway", label: "+ Шлюз по событию" },
|
||||
{ kind: "hint", label: "+ Подсказка" },
|
||||
{ kind: "dataObject", label: "+ Документ" },
|
||||
// Группы для выпадающего меню «Добавить элемент» — 11 типов плоским списком
|
||||
// читаются плохо, поэтому разложены по смыслу нотации.
|
||||
const NODE_GROUPS: { id: number; label: string }[] = [
|
||||
{ id: 1, label: "Действия" },
|
||||
{ id: 2, label: "События" },
|
||||
{ id: 3, label: "Шлюзы" },
|
||||
{ id: 4, label: "Прочее" },
|
||||
];
|
||||
|
||||
// Stable references — ChoiceGroup's controlled `value` is compared against
|
||||
// entries in `items` by identity, so this can't be recreated on every render.
|
||||
const MODE_ITEMS: { label: string; mode: "edit" | "view" }[] = [
|
||||
{ label: "Просмотр", mode: "view" },
|
||||
{ label: "Редактирование", mode: "edit" },
|
||||
const NODE_KINDS: { kind: NodeKind; label: string; groupId: number }[] = [
|
||||
{ kind: "task", label: "Действие", groupId: 1 },
|
||||
{ kind: "startEvent", label: "Начало", groupId: 2 },
|
||||
{ kind: "intermediateEvent", label: "Промежуточное событие", groupId: 2 },
|
||||
{ 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 }> = {
|
||||
@@ -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 [edges, setEdges] = useState<Edge[]>(() => toRfEdges(initialValue));
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<"edit" | "view">("edit");
|
||||
const [selectedMode, setSelectedMode] = useState<"edit" | "view">("edit");
|
||||
const mode = lockedMode ?? selectedMode;
|
||||
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 historyRef = 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],
|
||||
);
|
||||
|
||||
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
|
||||
setSelectedNodeId(node.id);
|
||||
setSelectedEdgeId(null);
|
||||
// React Flow гасит всплытие событий на узлах и связях, поэтому глобальные
|
||||
// «клик снаружи» у выпадающих меню на них не срабатывают — закрываем сами
|
||||
// из каждого обработчика холста.
|
||||
const closePopups = useCallback(() => {
|
||||
setIsAddMenuOpen(false);
|
||||
setIsHelpOpen(false);
|
||||
}, []);
|
||||
|
||||
const onNodeClick: NodeMouseHandler = useCallback(
|
||||
(_event, node) => {
|
||||
closePopups();
|
||||
setSelectedNodeId(node.id);
|
||||
setSelectedEdgeId(null);
|
||||
},
|
||||
[closePopups],
|
||||
);
|
||||
|
||||
const onEdgeClick: EdgeMouseHandler = useCallback(
|
||||
(_event, edge) => {
|
||||
if (mode !== "edit") return;
|
||||
closePopups();
|
||||
setSelectedEdgeId(edge.id);
|
||||
setSelectedNodeId(null);
|
||||
},
|
||||
[mode],
|
||||
[mode, closePopups],
|
||||
);
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
closePopups();
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
}, []);
|
||||
}, [closePopups]);
|
||||
|
||||
const updateSelectedNodeData = useCallback(
|
||||
(patch: Partial<DiagramNodeData>) => {
|
||||
@@ -709,6 +745,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
const next = edges.map((e) =>
|
||||
e.id === selectedEdgeId ? { ...e, zIndex: z } : e,
|
||||
);
|
||||
console.log();
|
||||
setEdges(next);
|
||||
emitChange(nodes, next);
|
||||
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory, minZIndex]);
|
||||
@@ -746,8 +783,33 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
edges.find((edge) => edge.id === selectedEdgeId)
|
||||
: 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 (
|
||||
<div className="diagram-shell">
|
||||
<div className="diagram-shell" onClick={onShellClick}>
|
||||
<div className="diagram-canvas" ref={canvasWrapperRef}>
|
||||
<NodesWithChildrenContext.Provider value={nodesWithChildren}>
|
||||
<ModeContext.Provider value={mode}>
|
||||
@@ -832,30 +894,83 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
</ControlButton>
|
||||
</>
|
||||
)}
|
||||
{/*
|
||||
Переключатель режима живёт здесь, а не отдельной панелью:
|
||||
скрыт, когда режим зафиксирован хостом (`?mode=view`) —
|
||||
тогда переключать нечего. `title` служит подсказкой при
|
||||
наведении, как и у остальных кнопок этой панели.
|
||||
*/}
|
||||
{!lockedMode && (
|
||||
<ControlButton
|
||||
onClick={() =>
|
||||
setSelectedMode(mode === "edit" ? "view" : "edit")
|
||||
}
|
||||
title={
|
||||
mode === "edit" ?
|
||||
"Режим просмотра: схема только для чтения"
|
||||
: "Вернуться к редактированию"
|
||||
}
|
||||
>
|
||||
{mode === "edit" ? "👁" : "✎"}
|
||||
</ControlButton>
|
||||
)}
|
||||
</Controls>
|
||||
{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" && (
|
||||
<Panel position="top-left" className="diagram-toolbar">
|
||||
{NODE_KINDS.map(({ kind, label }) => (
|
||||
<div ref={addButtonRef}>
|
||||
<Button
|
||||
key={kind}
|
||||
size="xs"
|
||||
view="ghost"
|
||||
label={label}
|
||||
onClick={() => addNode(kind)}
|
||||
size="s"
|
||||
view="primary"
|
||||
label="Добавить элемент"
|
||||
iconLeft={IconAdd}
|
||||
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 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">
|
||||
<Breadcrumbs
|
||||
size="xs"
|
||||
@@ -875,56 +990,56 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
getItemOnClick={(item) => item.onClick}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</ModeContext.Provider>
|
||||
</NodesWithChildrenContext.Provider>
|
||||
</div>
|
||||
|
||||
<aside className="diagram-aside">
|
||||
{selectedNode ?
|
||||
<NodeInspector
|
||||
nodeId={selectedNode.id}
|
||||
nodeType={(selectedNode.type ?? "task") as NodeKind}
|
||||
data={selectedNode.data as DiagramNodeData}
|
||||
childCount={selectedNodeChildCount}
|
||||
mode={mode}
|
||||
onChange={updateSelectedNodeData}
|
||||
onEnterSubProcess={enterSubProcess}
|
||||
onDuplicate={duplicateSelectedNode}
|
||||
onDelete={deleteSelectedNode}
|
||||
onBringToFront={bringSelectedNodeToFront}
|
||||
onSendToBack={sendSelectedNodeToBack}
|
||||
/>
|
||||
: selectedEdge ?
|
||||
<EdgeInspector
|
||||
edgeId={selectedEdge.id}
|
||||
label={
|
||||
typeof selectedEdge.label === "string" ? selectedEdge.label : ""
|
||||
}
|
||||
data={(selectedEdge.data as DiagramEdgeData) ?? {}}
|
||||
onLabelChange={updateSelectedEdgeLabel}
|
||||
onDataChange={updateSelectedEdgeData}
|
||||
onDelete={deleteSelectedEdge}
|
||||
onBringToFront={bringSelectedEdgeToFront}
|
||||
onSendToBack={sendSelectedEdgeToBack}
|
||||
/>
|
||||
: <p className="diagram-aside__hint">
|
||||
{mode === "edit" ?
|
||||
"Выберите узел или связь на схеме — появятся кнопки «Дублировать»/«Удалить» и поля данных."
|
||||
: "Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода."
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
{mode === "edit" && (
|
||||
<details className="diagram-aside__json">
|
||||
<summary>Текущий doc (для демо)</summary>
|
||||
<pre>
|
||||
{JSON.stringify(serializeDocument(nodes, edges), null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</aside>
|
||||
{isAsideVisible && (
|
||||
<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 ?
|
||||
<NodeInspector
|
||||
nodeId={selectedNode.id}
|
||||
nodeType={(selectedNode.type ?? "task") as NodeKind}
|
||||
data={selectedNode.data as DiagramNodeData}
|
||||
childCount={selectedNodeChildCount}
|
||||
mode={mode}
|
||||
onChange={updateSelectedNodeData}
|
||||
onEnterSubProcess={enterSubProcess}
|
||||
onDuplicate={duplicateSelectedNode}
|
||||
onDelete={deleteSelectedNode}
|
||||
onBringToFront={bringSelectedNodeToFront}
|
||||
onSendToBack={sendSelectedNodeToBack}
|
||||
/>
|
||||
: selectedEdge ?
|
||||
<EdgeInspector
|
||||
edgeId={selectedEdge.id}
|
||||
label={
|
||||
typeof selectedEdge.label === "string" ? selectedEdge.label : ""
|
||||
}
|
||||
data={(selectedEdge.data as DiagramEdgeData) ?? {}}
|
||||
onLabelChange={updateSelectedEdgeLabel}
|
||||
onDataChange={updateSelectedEdgeData}
|
||||
onDelete={deleteSelectedEdge}
|
||||
onBringToFront={bringSelectedEdgeToFront}
|
||||
onSendToBack={sendSelectedEdgeToBack}
|
||||
/>
|
||||
: null}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+116
-52
@@ -1,62 +1,64 @@
|
||||
import { useRef, useState, type RefObject } from 'react';
|
||||
import { Button } from '@consta/uikit/Button';
|
||||
import { TextField } from '@consta/uikit/TextField';
|
||||
import { ChoiceGroup } from '@consta/uikit/ChoiceGroup';
|
||||
import { ColorPicker, hexModel } from '@consta/uikit/ColorPicker';
|
||||
import { Text } from '@consta/uikit/Text';
|
||||
import type { DiagramEdgeData, EdgeLineStyle } from './types';
|
||||
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' },
|
||||
{ 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' },
|
||||
{ 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: 'Розовый' },
|
||||
{ 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: 'Белый' },
|
||||
{ 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_EDGE_COLOR = "#b1b1b7";
|
||||
const DEFAULT_TEXT_COLOR = "#FFFFFF";
|
||||
const DEFAULT_FONT_SIZE = 11;
|
||||
|
||||
interface EdgeInspectorProps {
|
||||
@@ -81,11 +83,15 @@ export function EdgeInspector({
|
||||
onSendToBack,
|
||||
}: EdgeInspectorProps) {
|
||||
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 [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;
|
||||
|
||||
return (
|
||||
@@ -94,9 +100,6 @@ export function EdgeInspector({
|
||||
<Text size="s" weight="semibold">
|
||||
Поток управления (Sequence Flow)
|
||||
</Text>
|
||||
<Text size="xs" view="secondary" font="mono">
|
||||
{edgeId}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="node-inspector__toolbar">
|
||||
@@ -104,11 +107,26 @@ export function EdgeInspector({
|
||||
</div>
|
||||
|
||||
<div className="node-inspector__toolbar">
|
||||
<Button size="s" view="secondary" label="На передний план" onClick={onBringToFront} />
|
||||
<Button size="s" view="secondary" label="На задний план" onClick={onSendToBack} />
|
||||
<Button
|
||||
size="s"
|
||||
view="secondary"
|
||||
label="На передний план"
|
||||
onClick={onBringToFront}
|
||||
/>
|
||||
<Button
|
||||
size="s"
|
||||
view="secondary"
|
||||
label="На задний план"
|
||||
onClick={onSendToBack}
|
||||
/>
|
||||
</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">
|
||||
<Text size="xs" view="secondary">
|
||||
@@ -121,23 +139,46 @@ export function EdgeInspector({
|
||||
type="button"
|
||||
title={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 }}
|
||||
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)} />
|
||||
<Button
|
||||
size="s"
|
||||
view="secondary"
|
||||
label="Настроить цвет"
|
||||
onClick={() => setColorPickerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
{colorPickerOpen && (
|
||||
<ColorPicker
|
||||
model={hexModel}
|
||||
value={currentColor}
|
||||
value={toPickerHex(currentColor, DEFAULT_EDGE_COLOR)}
|
||||
onChange={(value) => onDataChange({ color: value })}
|
||||
anchorRef={colorButtonRef}
|
||||
open={colorPickerOpen}
|
||||
onOpen={setColorPickerOpen}
|
||||
style={{ zIndex: 100 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -153,23 +194,42 @@ export function EdgeInspector({
|
||||
type="button"
|
||||
title={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 }}
|
||||
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)} />
|
||||
<Button
|
||||
size="s"
|
||||
view="secondary"
|
||||
label="Настроить цвет"
|
||||
onClick={() => setTextColorPickerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
{textColorPickerOpen && (
|
||||
<ColorPicker
|
||||
model={hexModel}
|
||||
value={currentTextColor}
|
||||
value={toPickerHex(currentTextColor, DEFAULT_TEXT_COLOR)}
|
||||
onChange={(value) => onDataChange({ textColor: value })}
|
||||
anchorRef={textColorButtonRef}
|
||||
open={textColorPickerOpen}
|
||||
onOpen={setTextColorPickerOpen}
|
||||
style={{ zIndex: 100 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -208,7 +268,11 @@ export function EdgeInspector({
|
||||
size="xs"
|
||||
name={`edge-line-style-${edgeId}`}
|
||||
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 })}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+75
-37
@@ -3,6 +3,8 @@ 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,
|
||||
@@ -16,7 +18,12 @@ import {
|
||||
TASK_SUBTYPE_ICON,
|
||||
TASK_SUBTYPE_LABEL,
|
||||
} from "./nodes";
|
||||
import type { DiagramNodeData, EventSubtype, NodeKind, TaskSubtype } from "./types";
|
||||
import type {
|
||||
DiagramNodeData,
|
||||
EventSubtype,
|
||||
NodeKind,
|
||||
TaskSubtype,
|
||||
} from "./types";
|
||||
|
||||
const TASK_SUBTYPES: TaskSubtype[] = [
|
||||
"none",
|
||||
@@ -77,7 +84,11 @@ const TYPE_LABELS: Record<NodeKind, string> = {
|
||||
dataObject: "Документ (Data Object)",
|
||||
};
|
||||
|
||||
const EVENT_KINDS = new Set<NodeKind>(["startEvent", "intermediateEvent", "endEvent"]);
|
||||
const EVENT_KINDS = new Set<NodeKind>([
|
||||
"startEvent",
|
||||
"intermediateEvent",
|
||||
"endEvent",
|
||||
]);
|
||||
|
||||
interface NodeInspectorProps {
|
||||
nodeId: string;
|
||||
@@ -111,13 +122,9 @@ export function NodeInspector({
|
||||
const colorButtonRef = useRef<HTMLDivElement>(
|
||||
null,
|
||||
) as RefObject<HTMLDivElement>;
|
||||
// data.color can be a `var(--gradient-x)` reference from the preset row —
|
||||
// hexModel tries to parse that as a hex color and produces NaN, so the
|
||||
// hex-only ColorControl/ColorPicker always need a real flat hex fallback.
|
||||
const flatColor =
|
||||
data.color && !data.color.startsWith("var(") ?
|
||||
data.color
|
||||
: DEFAULT_NODE_COLOR[nodeType];
|
||||
// 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>(
|
||||
@@ -132,11 +139,13 @@ export function NodeInspector({
|
||||
if (mode === "view") {
|
||||
const hasMeta = Boolean(data.responsible || data.duration);
|
||||
const subtypeLabel =
|
||||
nodeType === "task"
|
||||
? TASK_SUBTYPE_LABEL[(data.subtype as TaskSubtype | undefined) ?? "none"]
|
||||
: EVENT_KINDS.has(nodeType)
|
||||
? EVENT_SUBTYPE_LABEL[(data.subtype as EventSubtype | undefined) ?? "none"]
|
||||
: undefined;
|
||||
nodeType === "task" ?
|
||||
TASK_SUBTYPE_LABEL[(data.subtype as TaskSubtype | undefined) ?? "none"]
|
||||
: EVENT_KINDS.has(nodeType) ?
|
||||
EVENT_SUBTYPE_LABEL[
|
||||
(data.subtype as EventSubtype | undefined) ?? "none"
|
||||
]
|
||||
: undefined;
|
||||
return (
|
||||
<div className="node-inspector">
|
||||
<div className="node-inspector__header">
|
||||
@@ -200,9 +209,6 @@ export function NodeInspector({
|
||||
<Text size="s" weight="semibold">
|
||||
{TYPE_LABELS[nodeType]}
|
||||
</Text>
|
||||
<Text size="xs" view="secondary" font="mono">
|
||||
{nodeId}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="node-inspector__toolbar">
|
||||
@@ -248,7 +254,9 @@ export function NodeInspector({
|
||||
className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`}
|
||||
onClick={() => onChange({ subtype })}
|
||||
>
|
||||
{Icon ? <Icon size="s" /> : <Text size="xs">—</Text>}
|
||||
{Icon ?
|
||||
<Icon size="s" />
|
||||
: <Text size="xs">—</Text>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -262,24 +270,26 @@ export function NodeInspector({
|
||||
Тип триггера
|
||||
</Text>
|
||||
<div className="node-inspector__presets">
|
||||
{EVENT_SUBTYPES_BY_KIND[nodeType as "startEvent" | "intermediateEvent" | "endEvent"].map(
|
||||
(subtype: EventSubtype) => {
|
||||
const Icon = EVENT_SUBTYPE_ICON[subtype];
|
||||
const isActive = (data.subtype ?? "none") === subtype;
|
||||
return (
|
||||
<button
|
||||
key={subtype}
|
||||
type="button"
|
||||
title={EVENT_SUBTYPE_LABEL[subtype]}
|
||||
aria-label={EVENT_SUBTYPE_LABEL[subtype]}
|
||||
className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`}
|
||||
onClick={() => onChange({ subtype })}
|
||||
>
|
||||
{Icon ? <Icon size="s" /> : <Text size="xs">—</Text>}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{EVENT_SUBTYPES_BY_KIND[
|
||||
nodeType as "startEvent" | "intermediateEvent" | "endEvent"
|
||||
].map((subtype: EventSubtype) => {
|
||||
const Icon = EVENT_SUBTYPE_ICON[subtype];
|
||||
const isActive = (data.subtype ?? "none") === subtype;
|
||||
return (
|
||||
<button
|
||||
key={subtype}
|
||||
type="button"
|
||||
title={EVENT_SUBTYPE_LABEL[subtype]}
|
||||
aria-label={EVENT_SUBTYPE_LABEL[subtype]}
|
||||
className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`}
|
||||
onClick={() => onChange({ subtype })}
|
||||
>
|
||||
{Icon ?
|
||||
<Icon size="s" />
|
||||
: <Text size="xs">—</Text>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -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 ref={colorButtonRef} className="node-inspector__color-trigger">
|
||||
<Button
|
||||
@@ -322,6 +345,7 @@ export function NodeInspector({
|
||||
anchorRef={colorButtonRef}
|
||||
open={colorPickerOpen}
|
||||
onOpen={setColorPickerOpen}
|
||||
style={{ zIndex: 100 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -342,6 +366,19 @@ export function NodeInspector({
|
||||
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
|
||||
@@ -354,11 +391,12 @@ export function NodeInspector({
|
||||
{textColorPickerOpen && (
|
||||
<ColorPicker
|
||||
model={hexModel}
|
||||
value={currentTextColor}
|
||||
value={toPickerHex(currentTextColor, defaultTextColor)}
|
||||
onChange={(value) => onChange({ textColor: value })}
|
||||
anchorRef={textColorButtonRef}
|
||||
open={textColorPickerOpen}
|
||||
onOpen={setTextColorPickerOpen}
|
||||
style={{ zIndex: 100 }}
|
||||
/>
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
+147
-7
@@ -1,19 +1,69 @@
|
||||
.diagram-shell {
|
||||
display: flex;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.diagram-canvas {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.diagram-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
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;
|
||||
}
|
||||
|
||||
/*
|
||||
* Зона захвата для изменения размера. По умолчанию 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 {
|
||||
position: absolute;
|
||||
background: #ff0072;
|
||||
@@ -162,10 +257,27 @@
|
||||
clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 0 100%);
|
||||
}
|
||||
|
||||
/*
|
||||
* Плавающая карточка поверх холста, а не колонка рядом с ним. Так холст не
|
||||
* меняет ширину при открытии панели: фигуры остаются на своих экранных
|
||||
* местах, и камеру подстраивать не нужно.
|
||||
*
|
||||
* Отступ сверху уводит карточку из-под кнопки справки в правом верхнем углу —
|
||||
* иначе оверлей перекрывал бы её и справку нельзя было бы открыть, пока
|
||||
* выбрана фигура.
|
||||
*/
|
||||
.diagram-aside {
|
||||
position: absolute;
|
||||
top: 52px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid color-mix(in srgb, currentcolor 20%, transparent);
|
||||
z-index: 5;
|
||||
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;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
@@ -178,6 +290,16 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Кнопка «скрыть панель» — только в режиме просмотра. Прижата вправо, к
|
||||
* внешнему краю: стрелка указывает наружу, в сторону, куда панель уезжает.
|
||||
*/
|
||||
.diagram-aside__close {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
|
||||
.diagram-aside__json summary {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
@@ -235,6 +357,24 @@
|
||||
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 {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
+28
-10
@@ -11,18 +11,36 @@ const READY_MESSAGE = "diagram:ready";
|
||||
const INIT_MESSAGE = "diagram:init";
|
||||
const CHANGE_MESSAGE = "diagram:change";
|
||||
|
||||
function getBitrixOrigin(): string {
|
||||
const origin = import.meta.env.VITE_BITRIX_ORIGIN;
|
||||
if (!origin) {
|
||||
throw new Error(
|
||||
'VITE_BITRIX_ORIGIN is not set. The bridge needs a concrete parent origin — postMessage must never target "*".',
|
||||
);
|
||||
}
|
||||
return origin;
|
||||
/**
|
||||
* Origin хоста, встроившего приложение.
|
||||
*
|
||||
* Обычный случай — приложение и хост на одном домене (статика лежит в дереве
|
||||
* портала, см. docs/bitrix-integration.md §7, вариант A): тогда origin
|
||||
* известен в рантайме и задавать его на этапе сборки не нужно.
|
||||
* VITE_BITRIX_ORIGIN нужен только когда хост на другом 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 {
|
||||
const targetOrigin = getBitrixOrigin();
|
||||
export function createBitrixAdapter(options: { ignoreConfiguredOrigin: boolean }): HostAdapter {
|
||||
const targetOrigin = getBitrixOrigin(options.ignoreConfiguredOrigin);
|
||||
|
||||
return {
|
||||
onReady(callback) {
|
||||
|
||||
@@ -21,3 +21,20 @@ body,
|
||||
.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
-1
@@ -186,7 +186,7 @@ function SubProcessBadge({ id }: { id: string }) {
|
||||
return (
|
||||
<span
|
||||
className="diagram-node__badge"
|
||||
title="Есть вложенные узлы — двойной клик, чтобы открыть"
|
||||
title="Есть вложенные узлы — выберите фигуру, чтобы открыть их из боковой панели"
|
||||
>
|
||||
⇒
|
||||
</span>
|
||||
|
||||
+78
-21
@@ -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'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
// VITE_BASE_PATH is read here (config/Node context), so it needs an explicit
|
||||
// loadEnv call — unlike VITE_BITRIX_ORIGIN, which Vite injects into
|
||||
// import.meta.env automatically because it's only read from app code.
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
// Vite always writes the HTML entry under its source name (index.html stays
|
||||
// index.html) — it only content-hashes what that HTML references (JS/CSS),
|
||||
// never the HTML file itself. The backend now reads the actual deployed
|
||||
// filename at deploy time instead of hardcoding it on the main portal, so
|
||||
// index.html needs a unique name too.
|
||||
//
|
||||
// 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 {
|
||||
// Needed when the build is served from a subpath (e.g. local/diagram-app/
|
||||
// inside the Bitrix docroot) instead of domain root — see
|
||||
// docs/bitrix-integration.md §7.
|
||||
base: env.VITE_BASE_PATH || '/',
|
||||
plugins: [react()],
|
||||
name: 'hash-index-html',
|
||||
apply: 'build',
|
||||
configResolved(config) {
|
||||
outDir = config.build.outDir
|
||||
},
|
||||
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: {
|
||||
// No content hashes in output filenames — cache-busting on redeploy is
|
||||
// handled at the hosting/nginx level instead (see docs/bitrix-integration.md).
|
||||
// Flat layout (no "assets/" subfolder): one deployment target's own
|
||||
// folder is literally named "assets" (local/modules/vniigaz.bpmn/assets/),
|
||||
// and nesting output under another "assets/" there doubled the path
|
||||
// segment (.../assets/assets/index.js, 404). Flat works everywhere.
|
||||
// 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].js',
|
||||
chunkFileNames: '[name].js',
|
||||
assetFileNames: '[name].[ext]',
|
||||
entryFileNames: '[name]-[hash].js',
|
||||
chunkFileNames: '[name]-[hash].js',
|
||||
assetFileNames: '[name]-[hash][extname]',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user