Compare commits

..
2 Commits
11 changed files with 674 additions and 124 deletions
+7
View File
@@ -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, варианты деплоя
+3 -3
View File
@@ -8,15 +8,15 @@
1. Собрать в репозитории `portal-chart-builder`:
```
VITE_BASE_PATH=/local/diagram-app/ npm run build
npm run build
```
- `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. Готово — но с оговоркой: приложение доступно **не** по фиксированному `index.html`.
**Имя `index.html` в `dist/` — с хешем содержимого**, например `index-2fddeca6.html`, а не просто `index.html` (см. `vite.config.ts`, плагин `hashIndexHtml` — хеширует и сам HTML-файл, не только JS/CSS, которые Vite и так хеширует по умолчанию). Хеш меняется при каждой значимой пересборке и не меняется, если содержимое не менялось. Значит:
- **`src` у iframe нельзя хардкодить строкой** — нужно на своей стороне подставлять актуальное имя файла (например, взять единственный `index-*.html` в `local/diagram-app/`, или через отдельный метод получения актуального имени — решение за бэкендом).
- **`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/` — они не удаляются сами, и если способ поиска актуального файла — «взять единственный файл по маске», их накопление сломает эту логику.
+10 -3
View File
@@ -219,16 +219,23 @@ Backend не обязан ничего валидировать глубже «
Из этого следует главное отличие от более ранней версии данного документа: **`$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_BASE_PATH=/local/diagram-app/ npm run build
npm run build
```
`VITE_BITRIX_ORIGIN` в этом варианте не задаётся — см. примечание в конце раздела.
`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/' . $actualIndexFileName;`, `$appOrigin` — origin портала (без пути), `$actualIndexFileName` — актуальное имя `index-<hash>.html` в `local/diagram-app/`, полученное способом на усмотрение бэкенда (см. врезку про кэш выше — хардкодить `index.html` строкой уже нельзя).
4. Обновление после правок в React-приложении — тот же `npm run build` + копирование поверх старых файлов. Пересборка/перезапуск контейнеров Bitrix не нужны. Старый `index-<hash>.html` от предыдущей сборки при этом не удаляется автоматически — если способ поиска актуального файла (шаг 3) предполагает «взять единственный файл по маске», после нескольких деплоев в каталоге стоит убирать файлы прошлых версий, иначе маска перестанет быть однозначной.
+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 закеширован браузером | очистить каталог при деплое; хеш в имени сам по себе кэш не победит, если старый файл остаётся на месте |
+162 -58
View File
@@ -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,
@@ -69,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 }> = {
@@ -204,7 +210,11 @@ function isEditableTarget(target: EventTarget | null): boolean {
);
}
function DiagramCanvas({ initialValue, onChange, lockedMode }: 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);
@@ -213,6 +223,9 @@ function DiagramCanvas({ initialValue, onChange, lockedMode }: DiagramEditorProp
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[] }[]>([]);
@@ -489,24 +502,38 @@ function DiagramCanvas({ initialValue, onChange, lockedMode }: DiagramEditorProp
[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);
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>) => {
@@ -718,6 +745,7 @@ function DiagramCanvas({ initialValue, onChange, lockedMode }: DiagramEditorProp
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]);
@@ -755,8 +783,33 @@ function DiagramCanvas({ initialValue, onChange, lockedMode }: DiagramEditorProp
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}>
@@ -841,32 +894,83 @@ function DiagramCanvas({ initialValue, onChange, lockedMode }: DiagramEditorProp
</ControlButton>
</>
)}
{/*
Переключатель режима живёт здесь, а не отдельной панелью:
скрыт, когда режим зафиксирован хостом (`?mode=view`) —
тогда переключать нечего. `title` служит подсказкой при
наведении, как и у остальных кнопок этой панели.
*/}
{!lockedMode && (
<ControlButton
onClick={() =>
setSelectedMode(mode === "edit" ? "view" : "edit")
}
title={
mode === "edit" ?
"Режим просмотра: схема только для чтения"
: "Вернуться к редактированию"
}
>
{mode === "edit" ? "👁" : "✎"}
</ControlButton>
)}
</Controls>
{mode === "edit" && <MiniMap />}
{!lockedMode && (
<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) => setSelectedMode(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"
@@ -886,12 +990,26 @@ function DiagramCanvas({ initialValue, onChange, lockedMode }: DiagramEditorProp
getItemOnClick={(item) => item.onClick}
/>
</Panel>
)}
</ReactFlow>
</ModeContext.Provider>
</NodesWithChildrenContext.Provider>
</div>
{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}
@@ -919,23 +1037,9 @@ function DiagramCanvas({ initialValue, onChange, lockedMode }: DiagramEditorProp
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>
)}
: null}
</aside>
)}
</div>
);
}
-3
View File
@@ -94,9 +94,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">
+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>
);
}
-3
View File
@@ -200,9 +200,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">
+84 -7
View File
@@ -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;
}
/*
@@ -162,10 +212,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 +245,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;
+1 -1
View File
@@ -186,7 +186,7 @@ function SubProcessBadge({ id }: { id: string }) {
return (
<span
className="diagram-node__badge"
title="Есть вложенные узлы — двойной клик, чтобы открыть"
title="Есть вложенные узлы — выберите фигуру, чтобы открыть их из боковой панели"
>
⇒
</span>
+39 -13
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, renameSync } from 'node:fs'
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { defineConfig, loadEnv, type Plugin } from 'vite'
import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
// Vite always writes the HTML entry under its source name (index.html stays
@@ -14,6 +14,16 @@ import react from '@vitejs/plugin-react'
// (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 {
@@ -29,24 +39,40 @@ function hashIndexHtml(): Plugin {
// 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)
renameSync(htmlPath, path.resolve(outDir, `index-${hash}.html`))
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(({ 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(), '')
export default defineConfig(() => {
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 || '/',
// Relative, so the build works from any directory it gets deployed to
// without being told where that is. The host always opens a concrete file
// (index-<hash>.html, see hashIndexHtml below), so asset URLs resolve
// against that file's own location — there is no client-side routing that
// would need an absolute base. This replaces the earlier VITE_BASE_PATH:
// a build-time path that had to match the deploy directory exactly, and
// silently produced a blank iframe when it didn't (html loads, its JS 404s).
base: './',
plugins: [react(), hashIndexHtml()],
// Порт фиксирован: portal-front проксирует сюда сборку в dev-режиме
// (см. docs/portal-front-integration.md, раздел 2). strictPort, чтобы
// при занятом порте preview падал, а не уезжал на соседний молча.
preview: {
port: 5800,
strictPort: true,
},
build: {
// Same content hash Vite computes by default — just without its default
// "assets/" subfolder prefix. The deploy target's own folder is itself