diff --git a/docs/backend-quickstart.md b/docs/backend-quickstart.md index 450b2c5..a14cb4a 100644 --- a/docs/backend-quickstart.md +++ b/docs/backend-quickstart.md @@ -16,7 +16,7 @@ 3. Готово — но с оговоркой: приложение доступно **не** по фиксированному `index.html`. **Имя `index.html` в `dist/` — с хешем содержимого**, например `index-2fddeca6.html`, а не просто `index.html` (см. `vite.config.ts`, плагин `hashIndexHtml` — хеширует и сам HTML-файл, не только JS/CSS, которые Vite и так хеширует по умолчанию). Хеш меняется при каждой значимой пересборке и не меняется, если содержимое не менялось. Значит: -- **`src` у iframe нельзя хардкодить строкой** — нужно на своей стороне подставлять актуальное имя файла. Готовый способ: рядом с билдом сборка кладёт `app.json` со **стабильным** именем и актуальным именем точки входа внутри — `{ "index": "index-2fddeca6.html" }`. Прочитать его можно и с бэкенда, и прямо из браузера (`fetch('app.json', { cache: 'no-store' })`). Альтернативы — взять единственный `index-*.html` в каталоге или отдать имя отдельным методом; выбор за бэкендом. +- **`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-.html`/`index-.js`/`index-.css` от прошлых версий стоит убирать** из `local/diagram-app/` — они не удаляются сами, и если способ поиска актуального файла — «взять единственный файл по маске», их накопление сломает эту логику. diff --git a/docs/bitrix-integration.md b/docs/bitrix-integration.md index d3b43d4..aad9ca2 100644 --- a/docs/bitrix-integration.md +++ b/docs/bitrix-integration.md @@ -219,13 +219,13 @@ Backend не обязан ничего валидировать глубже « Из этого следует главное отличие от более ранней версии данного документа: **`$appUrl` в PHP-классе (раздел 3) больше нельзя хардкодить как строку** — конкретное имя `index-.html` меняется при каждой пересборке приложения. Бэкенду нужен свой способ узнавать актуальное имя файла на момент рендера страницы (например, взять единственный `index-*.html` из каталога деплоя, или через отдельный метод/эндпоинт, который вернёт актуальное имя, — механизм на усмотрение бэкенда, здесь не фиксирован, уточняйте на момент внедрения). - **Готовый механизм — `app.json` рядом с билдом.** Тот же плагин `hashIndexHtml` кладёт в `dist/` файл со стабильным, никогда не меняющимся именем: + **Готовый механизм — `app-manifest.js` рядом с билдом.** Тот же плагин `hashIndexHtml` кладёт в `dist/` файл со стабильным, никогда не меняющимся именем: ```json { "index": "index-2fddeca6.html" } ``` - Его можно прочитать как с бэкенда при рендере страницы, так и на клиенте — `fetch('<каталог>/app.json', { cache: 'no-store' })`, — и уже к полученному имени дописывать `?mode=view`. `no-store` здесь обязателен: имя файла стабильно, значит сам манифест попадает под тот же 30-дневный `Cache-Control`, что и остальная статика. Этим сейчас пользуется portal-front (`src/helpers/chart-builder.ts`), пока у бэка нет своего метода. + Его можно прочитать как с бэкенда при рендере страницы, так и на клиенте — `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` просто никогда больше не запрашивается, как только хост узнаёт новое имя — что убирает разом оба уровня кэш-проблемы, описанной в более ранней версии этого раздела. diff --git a/docs/portal-front-integration.md b/docs/portal-front-integration.md index 58b8718..98922bb 100644 --- a/docs/portal-front-integration.md +++ b/docs/portal-front-integration.md @@ -20,7 +20,7 @@ { "index": "index-300d7ef5.html" } ``` -Портал читает его при открытии модалки — `fetch('app.json', { cache: 'no-store' })` +Портал читает его при открытии модалки — `fetch('app-manifest.js', { cache: 'no-store' })` в `src/helpers/chart-builder.ts` — и уже к полученному имени дописывает параметры. Поэтому выкладка конструктора **не требует пересборки портала**. @@ -83,7 +83,7 @@ cd D:\Development\Work\portal-front; npm run dev Проверка, что связка поднята: ```powershell -curl.exe -s http://localhost:5173/local/modules/vniigaz.bpmn/assets/app.json +curl.exe -s http://localhost:5173/local/modules/vniigaz.bpmn/assets/app-manifest.js ``` Пустой ответ — не поднят `preview` или не собран конструктор. @@ -108,7 +108,7 @@ cd D:\Development\Work\portal-front; npm run build Проверка после выкладки: ```powershell -curl.exe -s https://ВАШ-ДОМЕН/local/modules/vniigaz.bpmn/assets/app.json +curl.exe -s https://ВАШ-ДОМЕН/local/modules/vniigaz.bpmn/assets/app-manifest.js ``` Имя в ответе должно совпадать с реально лежащим файлом. Открыть `index-.html` @@ -132,7 +132,7 @@ curl.exe -s https://ВАШ-ДОМЕН/local/modules/vniigaz.bpmn/assets/app.json состоится, ошибок в консоли не будет. Второе требование к встраивающей стороне — не писать имя `index-.html` -строкой, а брать актуальное из `app.json` (или искать единственный `index-*.html`, +строкой, а брать актуальное из `app-manifest.js` (или искать единственный `index-*.html`, но тогда очистка каталога при деплое обязательна). Подробности — в `backend-quickstart.md`, раздел 1. @@ -147,7 +147,7 @@ curl.exe -s https://ВАШ-ДОМЕН/local/modules/vniigaz.bpmn/assets/app.json | Что видно | Причина | Что делать | |---|---|---| -| «Не удалось загрузить схему» в модалке | манифест не отдался либо не пришёл `diagram:ready` за 10 секунд | проверить `app.json` через curl, затем в Network — дошёл ли `index-.js` | +| «Не удалось загрузить схему» в модалке | манифест не отдался либо не пришёл `diagram:ready` за 10 секунд | проверить `app-manifest.js` через curl, затем в Network — дошёл ли `index-.js` | | Пустой iframe, в консоли 404 на `index-.js` | файлы сборки не докопированы или скопированы не в тот каталог | перекопировать `dist/` целиком | | Приложение висит на «Загрузка диаграммы…» | `diagram:ready` ушёл не туда: origin хоста не совпал с ожидаемым | для админки — сверить `VITE_BITRIX_ORIGIN` с адресной строкой; для dev — проверить, что конструктор открывается через прокси портала, а не напрямую с 5800 | -| Портал открывает старую версию схемы | в каталоге лежат файлы прошлых сборок, `app.json` указывает на новый, а старый html закеширован браузером | очистить каталог при деплое; хеш в имени сам по себе кэш не победит, если старый файл остаётся на месте | +| Портал открывает старую версию схемы | в каталоге лежат файлы прошлых сборок, `app-manifest.js` указывает на новый, а старый html закеширован браузером | очистить каталог при деплое; хеш в имени сам по себе кэш не победит, если старый файл остаётся на месте | diff --git a/src/DiagramEditor.tsx b/src/DiagramEditor.tsx index 26908fc..b7a0142 100644 --- a/src/DiagramEditor.tsx +++ b/src/DiagramEditor.tsx @@ -26,12 +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, @@ -70,25 +73,27 @@ const EDGE_DASH_ARRAY: Record = { 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 = { @@ -205,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(() => toRfNodes(initialValue)); const [edges, setEdges] = useState(() => toRfEdges(initialValue)); const [selectedNodeId, setSelectedNodeId] = useState(null); @@ -214,6 +223,9 @@ function DiagramCanvas({ initialValue, onChange, lockedMode }: DiagramEditorProp const [selectedMode, setSelectedMode] = useState<"edit" | "view">("edit"); const mode = lockedMode ?? selectedMode; const [alignmentGuides, setAlignmentGuides] = useState([]); + const [isAddMenuOpen, setIsAddMenuOpen] = useState(false); + const [isHelpOpen, setIsHelpOpen] = useState(false); + const addButtonRef = useRef(null); const nodeCounter = useRef(0); const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]); const redoRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]); @@ -490,24 +502,38 @@ function DiagramCanvas({ initialValue, onChange, lockedMode }: DiagramEditorProp [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) => { @@ -719,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]); @@ -756,15 +783,33 @@ function DiagramCanvas({ initialValue, onChange, lockedMode }: DiagramEditorProp edges.find((edge) => edge.id === selectedEdgeId) : undefined; - // В режиме просмотра панель — не постоянная часть интерфейса, а всплывающая - // деталь выбранной фигуры: без выбора её нечем наполнить (подсказки про - // «Дублировать»/«Удалить» там неуместны), поэтому холст занимает всю ширину. - // В режиме редактирования панель нужна всегда — там она несёт инструменты, - // а не только описание выбранного узла. - const isAsideVisible = mode === "edit" || Boolean(selectedNode || selectedEdge); + // Панель — всплывающая деталь выбранной фигуры, одинаково в обоих режимах: + // без выбора её нечем наполнить. Она лежит оверлеем поверх холста, а не + // сжимает его — поэтому камеру при открытии трогать не нужно вообще: + // ничего никуда не уезжает, и вид остаётся ровно там, где его оставил + // пользователь. + 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 ( -
+
@@ -849,32 +894,83 @@ function DiagramCanvas({ initialValue, onChange, lockedMode }: DiagramEditorProp )} + {/* + Переключатель режима живёт здесь, а не отдельной панелью: + скрыт, когда режим зафиксирован хостом (`?mode=view`) — + тогда переключать нечего. `title` служит подсказкой при + наведении, как и у остальных кнопок этой панели. + */} + {!lockedMode && ( + + setSelectedMode(mode === "edit" ? "view" : "edit") + } + title={ + mode === "edit" ? + "Режим просмотра: схема только для чтения" + : "Вернуться к редактированию" + } + > + {mode === "edit" ? "👁" : "✎"} + + )} {mode === "edit" && } - {!lockedMode && ( - - item.mode === mode) ?? null} - onChange={(item) => setSelectedMode(item.mode)} + {mode === "edit" && ( + +
+
+ item.kind} + getItemLabel={(item) => item.label} + getItemGroupId={(item) => item.groupId} + getGroupId={(group) => group.id} + getGroupLabel={(group) => group.label} + anchorRef={addButtonRef as React.RefObject} + direction="downStartLeft" + size="s" + onItemClick={(item) => { + addNode(item.kind); + setIsAddMenuOpen(false); + }} + onClickOutside={() => setIsAddMenuOpen(false)} + onEsc={() => setIsAddMenuOpen(false)} />
)} - {mode === "edit" && ( - - {NODE_KINDS.map(({ kind, label }) => ( -
{isAsideVisible && ( - )}
); diff --git a/src/EdgeInspector.tsx b/src/EdgeInspector.tsx index ff52f9b..817b478 100644 --- a/src/EdgeInspector.tsx +++ b/src/EdgeInspector.tsx @@ -94,9 +94,6 @@ export function EdgeInspector({ Поток управления (Sequence Flow) - - {edgeId} -
diff --git a/src/HelpPanel.tsx b/src/HelpPanel.tsx new file mode 100644 index 0000000..839dd3f --- /dev/null +++ b/src/HelpPanel.tsx @@ -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 ( +
+ {sections.map((section) => ( +
+ + {section.title} + +
+ {section.rows.map((row) => ( +
+
+ + {row.term} + +
+
+ + {row.description} + +
+
+ ))} +
+
+ ))} +
+ ); +} diff --git a/src/NodeInspector.tsx b/src/NodeInspector.tsx index 3c9e66f..1359cd8 100644 --- a/src/NodeInspector.tsx +++ b/src/NodeInspector.tsx @@ -200,9 +200,6 @@ export function NodeInspector({ {TYPE_LABELS[nodeType]} - - {nodeId} -
diff --git a/src/diagram.css b/src/diagram.css index 840b37e..fe518f2 100644 --- a/src/diagram.css +++ b/src/diagram.css @@ -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; diff --git a/src/nodes.tsx b/src/nodes.tsx index 85eae2f..202b6ff 100644 --- a/src/nodes.tsx +++ b/src/nodes.tsx @@ -186,7 +186,7 @@ function SubProcessBadge({ id }: { id: string }) { return ( ⇒ diff --git a/vite.config.ts b/vite.config.ts index 4e1f131..f8e915f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,7 +15,14 @@ import react from '@vitejs/plugin-react' // 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. -const MANIFEST_FILE_NAME = 'app.json' +// +// 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'