Подготовил к интеграции с битриксом
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
.git
|
||||||
|
docs
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# Static build served by nginx. VITE_BITRIX_ORIGIN must be provided at build
|
||||||
|
# time (build arg) — it gets baked into the JS bundle, see docs/bitrix-integration.md §7.
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
ARG VITE_BITRIX_ORIGIN
|
||||||
|
ENV VITE_BITRIX_ORIGIN=$VITE_BITRIX_ORIGIN
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
# Интеграция с Bitrix — спецификация для backend-разработчика
|
||||||
|
|
||||||
|
Это документ для человека, который не участвовал в разработке самого React-приложения (`portal-chart-builder`) и не обязан знать TypeScript — здесь только то, что нужно на стороне PHP/Bitrix.
|
||||||
|
|
||||||
|
## 1. Что это
|
||||||
|
|
||||||
|
Конструктор диаграмм — отдельное React-приложение (не часть Bitrix24, продукт — «1С-Битрикс: Управление сайтом»). Встраивается как **пользовательский тип свойства инфоблока**: в админке, на странице редактирования элемента, вместо обычного поля отображается iframe с редактором диаграммы. Значение свойства — JSON-строка со схемой диаграммы, хранится как обычное строковое свойство (`PROPERTY_TYPE => 'S'`).
|
||||||
|
|
||||||
|
Никакого REST-модуля, вебхуков или JS SDK Bitrix24 не требуется — это нативный механизм модуля `iblock`.
|
||||||
|
|
||||||
|
## 2. Регистрация типа свойства
|
||||||
|
|
||||||
|
В `local/php_interface/init.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
AddEventHandler("iblock", "OnIBlockPropertyBuildList", ["DiagramProperty", "GetUserTypeDescription"]);
|
||||||
|
```
|
||||||
|
|
||||||
|
После регистрации новый тип появляется в **Контент → Инфоблоки → [нужный] → Свойства → Тип свойства**. Свойство должно быть **одиночным** (`MULTIPLE = N`) — одна диаграмма на элемент.
|
||||||
|
|
||||||
|
## 3. Скелет PHP-класса
|
||||||
|
|
||||||
|
Ниже — иллюстративный каркас, не готовый к продакшену код. Реализация валидации, экранирования и т.д. — на усмотрение разработчика, но с обязательным соблюдением требований из раздела 5.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class DiagramProperty
|
||||||
|
{
|
||||||
|
public static function GetUserTypeDescription(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'PROPERTY_TYPE' => 'S',
|
||||||
|
'USER_TYPE' => 'DiagramEditor',
|
||||||
|
'DESCRIPTION' => 'Конструктор диаграмм процессов',
|
||||||
|
'GetPropertyFieldHtml' => [__CLASS__, 'GetPropertyFieldHtml'],
|
||||||
|
'ConvertToDB' => [__CLASS__, 'ConvertToDB'],
|
||||||
|
'ConvertFromDB' => [__CLASS__, 'ConvertFromDB'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function GetPropertyFieldHtml($arProperty, $value, $strHTMLControlName): string
|
||||||
|
{
|
||||||
|
$inputName = htmlspecialcharsbx($strHTMLControlName['VALUE']);
|
||||||
|
$rawValue = $value['VALUE'] ?? '';
|
||||||
|
// JSON_HEX_* флаги обязательны — см. раздел 5.
|
||||||
|
$jsValue = json_encode($rawValue, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||||
|
|
||||||
|
// Вариант A — рекомендуемый, приложение лежит внутри дерева портала (см. раздел 7):
|
||||||
|
// appOrigin — это просто origin текущего запроса, appUrl — конкретный файл.
|
||||||
|
$appOrigin = (CMain::IsHTTPS() ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'];
|
||||||
|
$appUrl = $appOrigin . '/local/diagram-app/index.html';
|
||||||
|
// Вариант B — если приложение развёрнуто отдельным контейнером (см. раздел 7),
|
||||||
|
// appUrl/appOrigin вместо этого указывают на его отдельный адрес, например:
|
||||||
|
// $appOrigin = 'http://localhost:5178';
|
||||||
|
// $appUrl = $appOrigin;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<input type="hidden" name="<?= $inputName ?>" id="<?= $inputName ?>" value="<?= htmlspecialcharsbx($rawValue) ?>">
|
||||||
|
<iframe id="diagram-frame-<?= $inputName ?>" src="<?= htmlspecialcharsbx($appUrl) ?>"
|
||||||
|
style="width:100%;height:600px;border:1px solid #ccc"></iframe>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var appOrigin = <?= json_encode($appOrigin) ?>;
|
||||||
|
var initialValue = <?= $jsValue ?>; // строка JSON или ""
|
||||||
|
var input = document.getElementById(<?= json_encode($inputName) ?>);
|
||||||
|
var frame = document.getElementById('diagram-frame-<?= $inputName ?>');
|
||||||
|
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
if (event.origin !== appOrigin) return; // обязательная проверка, см. раздел 5
|
||||||
|
if (!event.data || typeof event.data !== 'object') return;
|
||||||
|
|
||||||
|
if (event.data.type === 'diagram:ready') {
|
||||||
|
frame.contentWindow.postMessage(
|
||||||
|
{ type: 'diagram:init', payload: initialValue || null },
|
||||||
|
appOrigin // никогда '*', см. раздел 5
|
||||||
|
);
|
||||||
|
} else if (event.data.type === 'diagram:change') {
|
||||||
|
input.value = event.data.payload;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ConvertToDB($arProperty, $value): array
|
||||||
|
{
|
||||||
|
// Провалидировать, что VALUE — либо пустая строка, либо синтаксически валидный JSON,
|
||||||
|
// прежде чем отдавать штатному механизму сохранения.
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ConvertFromDB($arProperty, $value): array
|
||||||
|
{
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GetPublicViewHTML`/`GetPublicEditHTML` сейчас не реализуются — публичная часть сайта не нужна в первой версии (диаграмма видна только в админке при редактировании элемента).
|
||||||
|
|
||||||
|
## 4. Контракт postMessage (реализован во frontend, `src/host/bitrixAdapter.ts`)
|
||||||
|
|
||||||
|
Обмен — три типа сообщений, строго в этом порядке:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Страница элемента загружается → мост-скрипт (см. раздел 3) готов, держит initialValue
|
||||||
|
2. iframe с приложением загружается
|
||||||
|
3. Приложение → мост: { type: 'diagram:ready' }
|
||||||
|
4. Мост → приложение: { type: 'diagram:init', payload: <JSON-строка диаграммы или null> }
|
||||||
|
5. Пользователь редактирует → с debounce ~400мс:
|
||||||
|
Приложение → мост: { type: 'diagram:change', payload: '<JSON-строка диаграммы>' }
|
||||||
|
6. Мост при каждом diagram:change пишет payload в value hidden input
|
||||||
|
7. Пользователь жмёт штатное «Сохранить» → браузер сабмитит форму → ConvertToDB → сохранение
|
||||||
|
```
|
||||||
|
|
||||||
|
Важно: `payload` в `diagram:change` — **уже сериализованная JSON-строка** (`JSON.stringify` со стороны приложения), не объект. Мост должен положить её в `input.value` как есть, без повторной обработки.
|
||||||
|
|
||||||
|
`diagram:init` с `payload: null` (или пустой строкой) — валидный случай: новый элемент без диаграммы, приложение стартует с чистого холста.
|
||||||
|
|
||||||
|
## 5. Требования безопасности (обязательны, не опциональны)
|
||||||
|
|
||||||
|
- При встраивании `initialValue` в инлайн-скрипт — **обязательно** `JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT` (см. пример в разделе 3). Без этого — риск XSS через выход из `<script>`-тега при вредоносном содержимом свойства.
|
||||||
|
- `event.origin` проверяется **в обе стороны**: и в мосте (когда он слушает сообщения от iframe), и в приложении (уже реализовано во frontend).
|
||||||
|
- `targetOrigin` у любого `postMessage` — всегда конкретный домен (`appOrigin`), **никогда `'*'`**.
|
||||||
|
- `name` у hidden input должен **точно** совпадать с `$strHTMLControlName['VALUE']` — это единственная связь между кастомным рендером и штатным сохранением формы.
|
||||||
|
|
||||||
|
## 6. Формат JSON диаграммы
|
||||||
|
|
||||||
|
Пример реального значения свойства (то, что лежит в hidden input и уходит в `ConvertToDB`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "start",
|
||||||
|
"type": "startEvent",
|
||||||
|
"position": { "x": 0, "y": 80 },
|
||||||
|
"width": 48,
|
||||||
|
"height": 48,
|
||||||
|
"data": { "label": "Заявка получена" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "review",
|
||||||
|
"type": "task",
|
||||||
|
"position": { "x": 220, "y": 60 },
|
||||||
|
"width": 140,
|
||||||
|
"height": 50,
|
||||||
|
"data": {
|
||||||
|
"label": "Приём заявок",
|
||||||
|
"description": "Проводится ответственным лицом, 1-2 дня.",
|
||||||
|
"responsible": "Иван Иванов",
|
||||||
|
"duration": "1-2 дня",
|
||||||
|
"color": "#eef2ff"
|
||||||
|
},
|
||||||
|
"parentId": "some-other-node-id",
|
||||||
|
"zIndex": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "start-review",
|
||||||
|
"source": "start",
|
||||||
|
"target": "review",
|
||||||
|
"sourceHandle": "right",
|
||||||
|
"targetHandle": "left",
|
||||||
|
"label": "Да"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Пояснения по полям (все, кроме перечисленных обязательными, — опциональны и могут отсутствовать):
|
||||||
|
|
||||||
|
- `nodes[].id` / `edges[].id` — уникальные строки, обязательны.
|
||||||
|
- `nodes[].type` — один из: `task`, `startEvent`, `intermediateEvent`, `endEvent`, `exclusiveGateway`, `parallelGateway`, `inclusiveGateway`.
|
||||||
|
- `nodes[].position` — координаты на холсте, обязательны.
|
||||||
|
- `nodes[].width`/`height` — размер фигуры (пользователь может менять руками).
|
||||||
|
- `nodes[].data.label` — название, обязательно; остальные поля `data` — произвольные метаданные, отображаемые в форме редактирования.
|
||||||
|
- `nodes[].parentId` — если узел вложен в другой (drill-down «под-процесс» внутри той же диаграммы, не переход на другой элемент).
|
||||||
|
- `nodes[].zIndex` / `edges[].zIndex` — порядок наложения друг на друга.
|
||||||
|
- `edges[].source`/`target` — id узлов, обязательны.
|
||||||
|
- `edges[].sourceHandle`/`targetHandle` — с какой стороны фигуры выходит/входит линия (`top`/`right`/`bottom`/`left`).
|
||||||
|
|
||||||
|
Backend не обязан ничего валидировать глубже «это либо пустая строка, либо синтаксически корректный JSON» — вся содержательная валидация (обязательные поля и т.п.) — на стороне приложения.
|
||||||
|
|
||||||
|
## 7. Деплой приложения
|
||||||
|
|
||||||
|
Два варианта. Вариант A проще и ближе к тому, как уже устроен сам портал — рекомендуется. Вариант B (отдельный контейнер) остаётся как альтернатива на случай, если приложение когда-нибудь понадобится хостить отдельно от Bitrix-стенда (другой домен, отдельный CI/CD и т.п.).
|
||||||
|
|
||||||
|
### Вариант A (рекомендуется) — статика прямо в дереве Bitrix
|
||||||
|
|
||||||
|
Проверено на текущем дев-стенде (`portal-bitrix-docker`): каталог сайта — не часть Docker-образа, а volume `bitrix_www`, смонтированный в контейнеры `nginx` и `php` как bind-mount на каталог **хоста** (`docker volume inspect bitrix_www` → `device: ..., o: bind`). Именно так уже хостится сам портал: файлы лежат на диске хоста, штатный nginx их и раздаёт — значит собранную статику приложения можно положить туда же, без отдельного контейнера.
|
||||||
|
|
||||||
|
Что это даёт технически:
|
||||||
|
- Nginx-конфиг стенда отдаёт файлы по расширению (`js|css|html|svg|png|...`) напрямую с диска — это срабатывает раньше, чем запрос попадёт в PHP-роутер Bitrix. Любой статический файл под корнем сайта отдаётся сам, никаких правок nginx не требуется.
|
||||||
|
- `local/` уже существует как каталог для кастомного кода (`ai`, `components`, `modules`, `php_interface`, `templates` — по факту на дев-стенде). Новый подкаталог, например `local/diagram-app/`, вписывается в эту конвенцию и не пересекается с `local/modules`/`local/php_interface` — они у nginx явно помечены `internal` (заблокированы для прямого внешнего доступа), новый подкаталог под этот блок не попадает.
|
||||||
|
- Нюанс: `index` на уровне сервера — `index.php`, не `index.html`. Открыть голый `/local/diagram-app/` (без имени файла) не выйдет — nginx попытается найти там `index.php` и не найдёт. Не проблема: PHP-класс (раздел 3) сам подставляет `src` у iframe, поэтому в `$appUrl` достаточно явно указывать файл — `/local/diagram-app/index.html`.
|
||||||
|
- Так как приложение и портал теперь на одном домене, `VITE_BITRIX_ORIGIN`/`appOrigin` — это просто origin самого портала, а не адрес отдельного сервиса.
|
||||||
|
|
||||||
|
Порядок действий:
|
||||||
|
|
||||||
|
1. В репозитории `portal-chart-builder` собрать статику с двумя build-time переменными:
|
||||||
|
```
|
||||||
|
VITE_BITRIX_ORIGIN=https://ВАШ-ДОМЕН-ПОРТАЛА VITE_BASE_PATH=/local/diagram-app/ npm run build
|
||||||
|
```
|
||||||
|
`VITE_BASE_PATH` — путь, под которым будет отдаваться приложение (нужен, чтобы ссылки на JS/CSS внутри `dist/index.html` были корректными под этим подпутём; см. `vite.config.ts`). По умолчанию, если переменная не задана, `base` — `/` (не влияет на локальную разработку, `npm run dev`).
|
||||||
|
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 не нужны.
|
||||||
|
|
||||||
|
### Вариант B — отдельный Docker-контейнер
|
||||||
|
|
||||||
|
В репозитории `portal-chart-builder` есть `Dockerfile` (multi-stage: сборка через node, раздача статики через nginx) и `.dockerignore` — для случая, если приложение должно жить отдельно от Bitrix-стенда (свой домен, свой деплой).
|
||||||
|
|
||||||
|
Ключевое рассуждение: iframe грузит **браузер администратора**, а не сервер Bitrix — запроса «сервер → приложение» не происходит вообще. Значит, если контейнер с приложением опубликован портом на тот же хост, откуда админ открывает Bitrix (текущий дев-стенд: это одна и та же машина), достаточно сослаться на `http://localhost:<порт>` — отдельный домен или туннель не нужны.
|
||||||
|
|
||||||
|
Пример сервиса для добавления в `docker-compose.yml` Bitrix-стенда (иллюстративный — правки самого файла делает тот, кто ведёт стенд):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
diagram-app:
|
||||||
|
build:
|
||||||
|
context: ../portal-chart-builder
|
||||||
|
args:
|
||||||
|
VITE_BITRIX_ORIGIN: http://portal.local # origin самого Bitrix-стенда
|
||||||
|
container_name: portal_diagram_app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5178:80" # публикуем на хост
|
||||||
|
```
|
||||||
|
|
||||||
|
Тогда:
|
||||||
|
- `VITE_BITRIX_ORIGIN` (build-arg, см. раздел 5) = origin Bitrix-портала — `http://portal.local`.
|
||||||
|
- `$appUrl`/`$appOrigin` в PHP-классе (раздел 3) = адрес самого приложения — `http://localhost:5178`.
|
||||||
|
|
||||||
|
Как передать сам аргумент:
|
||||||
|
|
||||||
|
- **Через docker-compose** (как в примере выше) — значение прописывается в `build.args` сервиса; при `docker compose up --build`/`docker compose build` compose сам подставит его в сборку, никаких дополнительных флагов не нужно.
|
||||||
|
- **Через `docker build` напрямую** (ручная сборка, например для локальной проверки) — флагом `--build-arg`: `docker build --build-arg VITE_BITRIX_ORIGIN=http://portal.local -t portal-diagram-app .` — именно так проверялось выше.
|
||||||
|
- Чтобы не хардкодить реальный адрес прямо в `docker-compose.yml` (он попадёт в git) — вынести значение в `.env`-файл рядом с `docker-compose.yml` (`VITE_BITRIX_ORIGIN=http://portal.local`) и сослаться на него в compose как `VITE_BITRIX_ORIGIN: ${VITE_BITRIX_ORIGIN}` — Compose сам подхватывает `.env` из той же папки.
|
||||||
|
|
||||||
|
Собрал и проверил локально (`docker build` + `docker run -p 5178:80`) — контейнер поднимается и корректно отдаёт приложение по `http://localhost:5178`.
|
||||||
|
|
||||||
|
**Важно (общее для обоих вариантов):** `VITE_BITRIX_ORIGIN` — build-time переменная, вшивается в JS-бандл на этапе `npm run build`/`docker build`, не читается заново при каждом запуске/обновлении. Без неё приложение намеренно упадёт при загрузке (см. раздел 5) — чтобы нельзя было собрать билд с `postMessage('*')`.
|
||||||
|
|
||||||
|
Схема с `localhost` (вариант B) работает, пока браузер администратора и Docker-хост — одна машина (текущий дев-стенд). Если позже появится реальный удалённый прод с админами не с этой машины — понадобится либо настоящий домен, либо reverse-proxy; это отдельная, более поздняя задача. Вариант A этой проблемы не имеет вообще, так как всегда живёт на домене портала.
|
||||||
|
|
||||||
|
## 8. Открытые решения (нужно определить до/во время внедрения)
|
||||||
|
|
||||||
|
- **Конкретный инфоблок**, к которому подключается свойство, — не выбран, определяется пользователем/владельцем задачи отдельно. Код из раздела 3 не зависит от конкретного инфоблока — тип свойства регистрируется глобально и включается точечно через админку (см. раздел 2).
|
||||||
|
- **Публичная часть сайта** — решено, что в первой версии не нужна (только админка). React-приложение уже поддерживает read-only «режим просмотра» — если публичная часть понадобится позже, доработка небольшая (реализовать `GetPublicViewHTML`, встроить тот же iframe в режиме просмотра).
|
||||||
|
|
||||||
|
## 9. Definition of done
|
||||||
|
|
||||||
|
1. Открыть страницу редактирования элемента (для которого включено свойство) — поле показывает iframe с пустой диаграммой (для нового элемента) или с ранее сохранённой.
|
||||||
|
2. Отредактировать диаграмму (подвигать фигуры, поменять текст).
|
||||||
|
3. Нажать штатную кнопку «Сохранить» на странице элемента (не внутри iframe).
|
||||||
|
4. Перезагрузить страницу — изменения на месте.
|
||||||
|
5. Открыть значение свойства напрямую в БД/через API инфоблоков — там лежит валидный JSON вида из раздела 6.
|
||||||
+17
-5
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { DiagramEditor } from './DiagramEditor';
|
import { DiagramEditor } from './DiagramEditor';
|
||||||
import { createDevAdapter } from './host/devAdapter';
|
import { createDevAdapter } from './host/devAdapter';
|
||||||
import type { DiagramDocument } from './types';
|
import { createBitrixAdapter } from './host/bitrixAdapter';
|
||||||
|
import { EMPTY_DOCUMENT, type DiagramDocument } from './types';
|
||||||
|
|
||||||
const SAMPLE_DOCUMENT: DiagramDocument = {
|
const SAMPLE_DOCUMENT: DiagramDocument = {
|
||||||
nodes: [
|
nodes: [
|
||||||
@@ -62,16 +63,27 @@ const SAMPLE_DOCUMENT: DiagramDocument = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const adapter = createDevAdapter();
|
const isProd = import.meta.env.PROD;
|
||||||
|
const adapter = isProd ? createBitrixAdapter() : createDevAdapter();
|
||||||
|
// In Bitrix, no value means a brand-new element — start blank, not with dev demo content.
|
||||||
|
const fallbackDocument = isProd ? EMPTY_DOCUMENT : SAMPLE_DOCUMENT;
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [initialDoc] = useState<DiagramDocument>(() => adapter.getInitialValue() ?? SAMPLE_DOCUMENT);
|
const [doc, setDoc] = useState<DiagramDocument | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adapter.onReady((initial) => setDoc(initial ?? fallbackDocument));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleChange = useCallback((next: DiagramDocument) => {
|
const handleChange = useCallback((next: DiagramDocument) => {
|
||||||
adapter.onChange(next);
|
adapter.onChange(next);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <DiagramEditor initialValue={initialDoc} onChange={handleChange} />;
|
if (!doc) {
|
||||||
|
return <p style={{ padding: 16, fontFamily: 'system-ui, sans-serif' }}>Загрузка диаграммы…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DiagramEditor initialValue={doc} onChange={handleChange} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import type { DiagramDocument } from '../types';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Seam between the diagram app and whatever embeds it.
|
* Seam between the diagram app and whatever embeds it.
|
||||||
* Dev builds read/write localStorage; the Bitrix build will read the
|
* `onReady` is callback-based rather than a synchronous getter because the
|
||||||
* initial value from the bridged hidden input and push changes back
|
* Bitrix bridge only has the initial value after a postMessage round trip
|
||||||
* to it (see bitrix-diagram-constructor-summary.md).
|
* (see docs/bitrix-integration.md) — dev builds just call back immediately.
|
||||||
*/
|
*/
|
||||||
export interface HostAdapter {
|
export interface HostAdapter {
|
||||||
getInitialValue: () => DiagramDocument | null;
|
onReady: (callback: (doc: DiagramDocument | null) => void) => void;
|
||||||
onChange: (doc: DiagramDocument) => void;
|
onChange: (doc: DiagramDocument) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { DiagramDocument } from '../types';
|
||||||
|
import type { HostAdapter } from './HostAdapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* postMessage handshake with the Bitrix bridge script (see
|
||||||
|
* docs/bitrix-integration.md for the PHP side). `targetOrigin` is always a
|
||||||
|
* concrete origin, never '*' — matches the security requirement from the
|
||||||
|
* original design doc.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBitrixAdapter(): HostAdapter {
|
||||||
|
const targetOrigin = getBitrixOrigin();
|
||||||
|
|
||||||
|
return {
|
||||||
|
onReady(callback) {
|
||||||
|
let delivered = false;
|
||||||
|
|
||||||
|
function onMessage(event: MessageEvent) {
|
||||||
|
if (event.origin !== targetOrigin || event.source !== window.parent) return;
|
||||||
|
if (delivered || event.data?.type !== INIT_MESSAGE) return;
|
||||||
|
delivered = true;
|
||||||
|
window.removeEventListener('message', onMessage);
|
||||||
|
|
||||||
|
const payload = event.data.payload as string | null | undefined;
|
||||||
|
if (!payload) {
|
||||||
|
callback(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
callback(JSON.parse(payload) as DiagramDocument);
|
||||||
|
} catch {
|
||||||
|
callback(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', onMessage);
|
||||||
|
window.parent.postMessage({ type: READY_MESSAGE }, targetOrigin);
|
||||||
|
},
|
||||||
|
onChange(doc) {
|
||||||
|
window.parent.postMessage({ type: CHANGE_MESSAGE, payload: JSON.stringify(doc) }, targetOrigin);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,13 +5,16 @@ const STORAGE_KEY = 'diagram-builder:dev-doc';
|
|||||||
|
|
||||||
export function createDevAdapter(): HostAdapter {
|
export function createDevAdapter(): HostAdapter {
|
||||||
return {
|
return {
|
||||||
getInitialValue() {
|
onReady(callback) {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) {
|
||||||
|
callback(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw) as DiagramDocument;
|
callback(JSON.parse(raw) as DiagramDocument);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
callback(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onChange(doc) {
|
onChange(doc) {
|
||||||
|
|||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
/** Bitrix portal origin the postMessage bridge is allowed to talk to (e.g. https://portal.local). Required in production builds. */
|
||||||
|
readonly VITE_BITRIX_ORIGIN?: string;
|
||||||
|
}
|
||||||
@@ -3,5 +3,9 @@ import react from '@vitejs/plugin-react'
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// 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: process.env.VITE_BASE_PATH || '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user