К содержимому

React Flow + DSK ​

Используем ту же панель DSK, линейки и комментарий в редакторе узлов. React Flow отвечает за выделение и перемещение узлов, связи и навигацию камеры. Нативные поля DSK меняют позицию выбранного узла и поворот его собственного визуального содержимого.

Редактор загружается автоматически. Перетащите любой узел, выделите его и измените X, Y или Поворот. Перетаскивайте пустую область для перемещения камеры, используйте колесо или трекпад для масштаба и нажмите Уместить, чтобы увидеть оба узла. Выбрать прямоугольник возвращает исходное выделение, Сбросить восстанавливает начальное расположение. Вытяните направляющую из линейки. Комментарий остаётся привязан к прямоугольнику, в том числе после поворота.

Связь между узлами - настоящее ребро React Flow. В этом небольшом примере его концы фиксированы, а удаление узлов отключено. Изменения хранятся в памяти и сбрасываются при уходе со страницы. Направляющие DSK служат визуальными ориентирами и не добавляют привязки узлам React Flow.

1. Установите зависимости ​

В приложении Vite на TypeScript:

sh
npm i @flowscape-ui/dsk @xyflow/react@12.12.0 react@18.3.1 react-dom@18.3.1
npm i -D @types/react@18 @types/react-dom@18

React Flow использует React. Элементы DSK остаются нативными custom elements вне его React root. createElement позволяет использовать обычный TypeScript без настройки JSX.

text
index.html
src/
  main.ts
  shared/
    engine-error-boundary.ts
    example-shell.ts
    example-shell.css
  reactflow/
    mount-example.ts
    shape-node.ts
    style.css

React Flow распространяется по лицензии MIT. Пример сохраняет встроенную подпись проекта. См. официальное руководство по началу работы.

2. Создайте контейнер и общую панель ​

Добавьте контейнер в index.html:

html
<div id="example"></div>
<script type="module" src="/src/main.ts"></script>

Сохраните общую оболочку в src/shared. Она создаёт три поля DSK, две линейки и слой комментария без CSS-трансформации. Только shell.surface принадлежит React Flow. У всех дополнительных слоёв одно начало координат viewport.

ts
import '@flowscape-ui/dsk';
import type { CanvasCamera, DskInput } from '@flowscape-ui/dsk';
import './example-shell.css';

export type TransformField = 'x' | 'y' | 'rotation';
export type ExampleAction = 'select' | 'fit' | 'reset';
export interface ExampleSelection {
  x: number;
  y: number;
  rotation: number;
  name: string;
}

/** The native DSK inspector and overlay stay separate from the rendering engine. */
export function createExampleShell(root: HTMLElement, ru = false) {
  const t = (en: string, russian: string) => (ru ? russian : en);
  const listeners = new AbortController();
  root.classList.add('dsk-engine-example');
  root.innerHTML = `
    <div class="dsk-engine-toolbar">
      <button type="button" data-action="select">${t('Select rectangle', 'Выбрать прямоугольник')}</button>
      <button type="button" data-action="fit">${t('Fit', 'Уместить')}</button>
      <button type="button" data-action="reset">${t('Reset', 'Сбросить')}</button>
    </div>
    <div class="dsk-engine-inspector">
      <label>X <dsk-input data-field="x" step="1" disabled aria-label="X"></dsk-input></label>
      <label>Y <dsk-input data-field="y" step="1" disabled aria-label="Y"></dsk-input></label>
      <label>${t('Rotation', 'Поворот')} <dsk-input data-field="rotation" step="1" tag="°" disabled aria-label="${t('Rotation', 'Поворот')}"></dsk-input></label>
    </div>
    <output class="dsk-engine-selection" aria-live="polite"></output>
    <div class="dsk-engine-layout">
      <span class="dsk-engine-corner"></span>
      <dsk-ruler class="dsk-engine-x" snap="10" aria-label="X ruler"></dsk-ruler>
      <dsk-ruler class="dsk-engine-y" snap="10" orientation="vertical" aria-label="Y ruler"></dsk-ruler>
      <div class="dsk-engine-viewport">
        <div class="dsk-engine-surface"></div>
        <div class="dsk-engine-overlay"><dsk-canvas-marker label="${t('Comment: check spacing', 'Комментарий: проверить отступ')}" hidden></dsk-canvas-marker></div>
      </div>
    </div>`;
  const surface = root.querySelector<HTMLElement>('.dsk-engine-surface')!;
  const viewport = root.querySelector<HTMLElement>('.dsk-engine-viewport')!;
  const rulers = Array.from(root.querySelectorAll('dsk-ruler'));
  const marker = root.querySelector('dsk-canvas-marker')!;
  const status = root.querySelector<HTMLOutputElement>('output')!;
  const inputs: Record<TransformField, DskInput> = {
    x: root.querySelector('dsk-input[data-field="x"]')!,
    y: root.querySelector('dsk-input[data-field="y"]')!,
    rotation: root.querySelector('dsk-input[data-field="rotation"]')!,
  };
  for (const ruler of rulers) ruler.target = viewport;
  let current: ExampleSelection | null = null;
  let editing: TransformField | undefined;
  let transform: ((field: TransformField, value: number) => void) | undefined;
  let action: ((value: ExampleAction) => void) | undefined;
  const sync = () => {
    status.textContent =
      current?.name ?? t('Select one object.', 'Выберите один объект.');
    for (const key of ['x', 'y', 'rotation'] as const) {
      inputs[key].disabled = !current;
      if (current && editing !== key)
        inputs[key].value = Math.round(current[key] * 100) / 100;
    }
  };
  for (const key of ['x', 'y', 'rotation'] as const) {
    const input = inputs[key];
    input.addEventListener(
      'input',
      () => {
        if (!current || !Number.isFinite(input.valueAsNumber)) return;
        editing = key;
        transform?.(key, input.valueAsNumber);
      },
      { signal: listeners.signal },
    );
    const finish = () => {
      editing = undefined;
      sync();
    };
    input.addEventListener('change', finish, { signal: listeners.signal });
    input.addEventListener('focusout', finish, { signal: listeners.signal });
  }
  for (const name of ['select', 'fit', 'reset'] as const) {
    root
      .querySelector<HTMLButtonElement>(`[data-action="${name}"]`)!
      .addEventListener('click', () => action?.(name), {
        signal: listeners.signal,
      });
  }
  sync();
  return {
    surface,
    viewport,
    inputs,
    setSelection(value: ExampleSelection | null) {
      if (current?.name !== value?.name) editing = undefined;
      current = value;
      sync();
    },
    setCamera(camera: CanvasCamera) {
      for (const ruler of rulers) ruler.camera = camera;
      marker.camera = camera;
    },
    setMarker(x: number, y: number, visible = true) {
      marker.x = x;
      marker.y = y;
      marker.hidden = !visible;
    },
    onTransform(callback: (field: TransformField, value: number) => void) {
      transform = callback;
    },
    onAction(callback: (value: ExampleAction) => void) {
      action = callback;
    },
    destroy() {
      listeners.abort();
      for (const ruler of rulers) ruler.target = null;
      root.replaceChildren();
      delete root.dataset.ready;
    },
  };
}
css
.dsk-engine-example {
  border: 1px solid var(--vp-c-divider, #39393f);
  background: #111;
  color: #fff;
  --dsk-input-background: #171717;
  --dsk-input-color: #fff;
  --dsk-input-border: 1px solid #39393f;
  --dsk-input-border-radius: 0px;
  --dsk-input-height: 36px;
  --dsk-input-focus-color: #ff0073;
  --dsk-ruler-background: #171717;
  --dsk-ruler-color: #bbbbc4;
  --dsk-marker-background: #ff0073;
  --dsk-marker-radius: 0px;
}
.dsk-engine-toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  padding: 12px;
}
.dsk-engine-toolbar button {
  padding: 6px 10px;
  border: 1px solid #39393f;
  background: #171717;
  color: #fff;
  cursor: pointer;
  font: inherit;
  font-size: 13px;
}
.dsk-engine-toolbar button:hover {
  background: #29232a;
}
.dsk-engine-toolbar button:focus-visible {
  outline: 2px solid #ff0073;
  outline-offset: 2px;
}
.dsk-engine-inspector {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 12px;
  padding: 0 12px 12px;
}
.dsk-engine-inspector label {
  display: grid;
  gap: 6px;
  font-size: 13px;
  min-width: 0;
}
.dsk-engine-inspector dsk-input {
  width: 100%;
  min-width: 0;
}
.dsk-engine-selection {
  display: block;
  padding: 0 12px 12px;
  font: 12px/1.5 monospace;
  overflow-wrap: anywhere;
}
.dsk-engine-layout {
  display: grid;
  grid-template-columns: 24px minmax(0, 1fr);
  grid-template-rows: 24px 420px;
}
.dsk-engine-x {
  grid-column: 2;
  grid-row: 1;
}
.dsk-engine-y {
  grid-column: 1;
  grid-row: 2;
}
.dsk-engine-corner {
  background: #171717;
}
.dsk-engine-viewport {
  grid-column: 2;
  grid-row: 2;
  position: relative;
  min-width: 0;
  overflow: hidden;
}
.dsk-engine-surface,
.dsk-engine-overlay {
  position: absolute;
  inset: 0;
}
.dsk-engine-overlay {
  pointer-events: none;
  z-index: 300;
}
.dsk-engine-overlay dsk-canvas-marker {
  transform: translate(-100%, 12px);
}
.dsk-engine-surface > canvas {
  display: block;
}
.dsk-engine-surface:focus-visible {
  outline: 2px solid #ff0073;
  outline-offset: -2px;
}
@media (max-width: 648px) {
  .dsk-engine-inspector {
    grid-template-columns: 1fr;
  }
  .dsk-engine-inspector label {
    grid-template-columns: 72px minmax(0, 1fr);
    align-items: center;
  }
}

Оболочка обрабатывает нативные события input и вызывает адаптер движка. Программное изменение значения DSK не создаёт новое событие input, а активное поле не перезаписывается во время перетаскивания.

3. Определите собственный узел ​

Сохраните файл src/reactflow/shape-node.ts:

ts
import { createElement } from 'react';
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';

export type ShapeNode = Node<
  { label: string; rotation: number; kind: 'rectangle' | 'ellipse' },
  'shape'
>;

export const rectangleId = 'dsk-rectangle';

export function createNodes(ru = false): ShapeNode[] {
  return [
    {
      id: rectangleId,
      type: 'shape',
      position: { x: 80, y: 80 },
      selected: true,
      data: {
        label: ru ? 'Прямоугольник' : 'Rectangle',
        rotation: 0,
        kind: 'rectangle',
      },
    },
    {
      id: 'dsk-ellipse',
      type: 'shape',
      position: { x: 350, y: 190 },
      data: {
        label: ru ? 'Эллипс' : 'Ellipse',
        rotation: 0,
        kind: 'ellipse',
      },
    },
  ];
}

/** Rotate the visual body, leaving the node's connection handles stable. */
export function Shape({ data, selected }: NodeProps<ShapeNode>) {
  return createElement(
    'div',
    { className: 'dsk-flow-shape' },
    createElement(Handle, { type: 'target', position: Position.Left }),
    createElement(
      'div',
      {
        className: `dsk-flow-body dsk-flow-${data.kind}${selected ? ' is-selected' : ''}`,
        style: { transform: `rotate(${data.rotation}deg)` },
      },
      createElement('span', { className: 'dsk-flow-brand' }, 'dsk'),
      createElement('span', { className: 'dsk-flow-label' }, data.label),
    ),
    createElement(Handle, { type: 'source', position: Position.Right }),
  );
}

// Stable component identities prevent remounting nodes during input changes.
export const nodeTypes = { shape: Shape };

Модель узла хранит position для React Flow и data.rotation для нашего визуального содержимого. nodeTypes находится вне React-компонента, поэтому его ссылка не меняется при редактировании. Каждый узел содержит настоящие Handle из React Flow для соединяющего ребра.

Что здесь означает поворот ​

Стандартная позиция узла и рамка выделения React Flow остаются параллельны осям. В этом примере поворот применяется через CSS к внутреннему визуальному содержимому узла. Точки соединения остаются на внешнем узле, поэтому связь не вращается вокруг фигуры при изменении угла. Это выбранное устройство нашего custom node, а не встроенное свойство поворота React Flow.

Чтобы вращать и точки соединения, поместите их внутрь поворачиваемого содержимого и обновляйте их геометрию через useUpdateNodeInternals после каждого поворота. См. собственные узлы и пример поворачиваемого узла.

4. Подключите React Flow и DSK ​

Сохраните файл src/reactflow/mount-example.ts:

ts
import { createElement, useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import {
  Background,
  BackgroundVariant,
  ReactFlow,
  useNodesState,
  type Edge,
  type ReactFlowInstance,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { createExampleShell } from '../shared/example-shell.ts';
import { EngineErrorBoundary } from '../shared/engine-error-boundary.ts';
import {
  createNodes,
  nodeTypes,
  rectangleId,
  type ShapeNode,
} from './shape-node.ts';
import './style.css';

const edges: Edge[] = [
  {
    id: 'rectangle-to-ellipse',
    source: rectangleId,
    target: 'dsk-ellipse',
    animated: true,
    style: { stroke: '#ff7bb7', strokeWidth: 2 },
  },
];

export function mountExample(root: HTMLElement, ru = false): () => void {
  const shell = createExampleShell(root, ru);
  root.classList.add('dsk-reactflow-example');
  const reactRoot = createRoot(shell.surface);
  let disposed = false;

  function Flow() {
    const [nodes, setNodes, onNodesChange] = useNodesState(createNodes(ru));
    const instance = useRef<ReactFlowInstance<ShapeNode, Edge> | null>(null);
    // Native DSK events read the latest model, not a stale React render closure.
    const currentNodes = useRef(nodes);
    currentNodes.current = nodes;

    useEffect(() => {
      shell.onTransform((field, value) => {
        const selected = currentNodes.current.filter((node) => node.selected);
        if (selected.length !== 1) return;
        const id = selected[0]?.id;
        setNodes((previous) =>
          previous.map((node) =>
            node.id !== id
              ? node
              : field === 'rotation'
                ? { ...node, data: { ...node.data, rotation: value } }
                : { ...node, position: { ...node.position, [field]: value } },
          ),
        );
      });
      shell.onAction((action) => {
        if (action === 'fit') {
          void instance.current?.fitView({ padding: 0.3, duration: 0 });
        } else if (action === 'reset') {
          setNodes(createNodes(ru));
        } else {
          setNodes((previous) =>
            previous.map((node) => ({
              ...node,
              selected: node.id === rectangleId,
            })),
          );
        }
      });
      return () => {
        shell.onTransform(() => {});
        shell.onAction(() => {});
      };
    }, [setNodes]);

    useEffect(() => {
      const selected = nodes.filter((node) => node.selected);
      const node = selected.length === 1 ? selected[0] : undefined;
      shell.setSelection(
        node
          ? {
              ...node.position,
              rotation: node.data.rotation,
              name: `${node.data.label} · ${node.id}`,
            }
          : null,
      );
      const rectangle = nodes.find((node) => node.id === rectangleId);
      if (rectangle) {
        // The body is 180 x 120. Attach the comment to its rotated bounds.
        const radians = (rectangle.data.rotation * Math.PI) / 180;
        const halfWidth =
          90 * Math.abs(Math.cos(radians)) + 60 * Math.abs(Math.sin(radians));
        const halfHeight =
          90 * Math.abs(Math.sin(radians)) + 60 * Math.abs(Math.cos(radians));
        shell.setMarker(
          rectangle.position.x + 90 + halfWidth,
          rectangle.position.y + 60 + halfHeight,
        );
      } else {
        shell.setMarker(0, 0, false);
      }
    }, [nodes]);

    return createElement(
      ReactFlow<ShapeNode, Edge>,
      {
        nodes,
        edges,
        nodeTypes,
        onNodesChange,
        colorMode: 'dark',
        fitView: true,
        fitViewOptions: { padding: 0.3 },
        minZoom: 0.2,
        maxZoom: 3,
        nodesConnectable: false,
        deleteKeyCode: null,
        onInit(api) {
          instance.current = api;
          const { x, y, zoom } = api.getViewport();
          shell.setCamera({ panX: x, panY: y, zoom });
          if (!disposed) root.dataset.ready = 'true';
        },
        onMove(_event, { x, y, zoom }) {
          shell.setCamera({ panX: x, panY: y, zoom });
        },
      },
      createElement(Background, {
        variant: BackgroundVariant.Dots,
        color: '#44444f',
        gap: 20,
        size: 1,
      }),
    );
  }

  reactRoot.render(
    createElement(EngineErrorBoundary, { host: root }, createElement(Flow)),
  );
  return () => {
    disposed = true;
    reactRoot.unmount();
    shell.destroy();
    root.classList.remove('dsk-reactflow-example');
    delete root.dataset.ready;
    delete root.dataset.error;
  };
}

Сохраните общую React error boundary в src/shared/engine-error-boundary.ts. Она сообщает контейнеру об ошибках отрисовки, чтобы индикатор загрузки не оставался активным бесконечно:

ts
import { Component, type ReactNode } from 'react';

interface Props {
  host: HTMLElement;
  children?: ReactNode;
}

/** Let the host replace a failed asynchronous React render with its retry UI. */
export class EngineErrorBoundary extends Component<Props, { failed: boolean }> {
  override state = { failed: false };

  static getDerivedStateFromError(): { failed: boolean } {
    return { failed: true };
  }

  override componentDidCatch(error: Error): void {
    this.props.host.dataset.error = 'true';
    console.error('The example engine could not render', error);
  }

  override render(): ReactNode {
    return this.state.failed ? null : this.props.children;
  }
}

useNodesState управляет массивом узлов. Изменения с холста приходят через onNodesChange; нативные изменения DSK вызывают setNodes с новым узлом и новым объектом position или data. Ref открывает нативным слушателям актуальные узлы, чтобы старое замыкание React не изменяло предыдущее выделение.

Редактируется только один выделенный узел. Пустое или множественное выделение отключает панель. React effects обновляют значения полей после изменений узлов. Комментарий использует постоянный ID прямоугольника, поэтому смена выделения не переносит его на эллипс.

5. Синхронизируйте камеру и комментарий ​

React Flow и DSK используют одинаковую формулу камеры:

ts
// React Flow viewport: screen = world * zoom + { x, y }
onMove(_event, { x, y, zoom }) {
  shell.setCamera({ panX: x, panY: y, zoom });
}

В отличие от примеров Excalidraw и tldraw, дополнительное умножение не нужно. X/Y viewport в React Flow уже измеряются в экранных пикселях. Адаптер также читает getViewport() в onInit, поэтому линейки получают камеру до первого жеста.

Размер прямоугольника - 180 × 120 пикселей. Его комментарий привязан к правому нижнему углу границ повёрнутого содержимого:

ts
const radians = (rotation * Math.PI) / 180;
const halfWidth =
  90 * Math.abs(Math.cos(radians)) + 60 * Math.abs(Math.sin(radians));
const halfHeight =
  90 * Math.abs(Math.sin(radians)) + 60 * Math.abs(Math.cos(radians));
shell.setMarker(x + 90 + halfWidth, y + 60 + halfHeight);

Маркер получает мировые координаты. Его .camera самостоятельно переводит их в экранные, поэтому не добавляйте слою ещё одну CSS-трансформацию камеры.

6. Добавьте стили, запустите и настройте очистку ​

Сохраните файл src/reactflow/style.css. Все правила ограничены этим примером. Обязательная базовая таблица стилей React Flow импортируется отдельно в модуле подключения.

css
.dsk-reactflow-example .react-flow {
  --xy-background-color: #151517;
  --xy-node-color: #fff;
  --xy-edge-stroke: #ff7bb7;
  --xy-handle-background-color: #ff7bb7;
  --xy-handle-border-color: #151517;
}

.dsk-reactflow-example .dsk-flow-shape {
  position: relative;
  width: 180px;
  height: 120px;
}

.dsk-reactflow-example .dsk-flow-body {
  display: flex;
  height: 100%;
  box-sizing: border-box;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  border: 2px solid #ff7bb7;
  background: #69213f;
  color: #fff;
  transform-origin: center;
}

.dsk-reactflow-example .dsk-flow-body.is-selected {
  outline: 1px solid #fff;
  outline-offset: 5px;
}

.dsk-reactflow-example .dsk-flow-ellipse {
  border-color: #c4a6ff;
  border-radius: 50%;
  background: #453267;
}

.dsk-reactflow-example .dsk-flow-brand {
  font: 800 30px/1.1 sans-serif;
}

.dsk-reactflow-example .dsk-flow-label {
  font: 12px/1.4 sans-serif;
}

.dsk-reactflow-example .react-flow__handle {
  width: 8px;
  height: 8px;
}

.dsk-reactflow-example .react-flow__attribution {
  background: #18181c;
}

.dsk-reactflow-example .react-flow__attribution a {
  color: #c8c8d0;
  font-size: 10px;
  text-decoration: none;
}

Запустите из src/main.ts:

ts
import { mountExample } from './reactflow/mount-example.ts';

const root = document.querySelector<HTMLElement>('#example');
if (!root) throw new Error('Missing example root');
const dispose = mountExample(root);

// Also call dispose() when your application removes this page.
if (import.meta.hot) import.meta.hot.dispose(dispose);

Размонтирование React освобождает движок и его эффекты. Уничтожение оболочки удаляет слушатели DSK, цели линеек и дополнительные слои. В SSR-приложении импортируйте и запускайте модуль из клиентского lifecycle после появления контейнера:

ts
// Inside a client mount hook, never during server rendering.
const { mountExample } = await import('./reactflow/mount-example.ts');
const dispose = mountExample(root);
// Register dispose with your framework's unmount hook.

Документация следует тому же правилу: импорт запускается автоматически при открытии страницы, а во время инициализации движка показывается индикатор загрузки.

7. Изучите и скачайте проект ​

Ниже можно просмотреть исходники работающего примера и его настройки. ZIP содержит общую оболочку и собранный DSK в vendor.

Распакуйте архив, откройте терминал в dsk-reactflow-example и выполните npm install, затем npm run dev. Нужны Node.js 22.12+ и интернет для установки зависимостей. npm run build проверяет типы и создаёт самостоятельное приложение. Монорепозиторий и дополнительный сервер не нужны.

ПОЛНЫЙ ПРОЕКТdsk-reactflow-example
Скачать ZIP
main.ts
srcmain.ts

Автоматическая загрузка и очистка при навигации или перезагрузке.

import './page.css';

const root = document.querySelector<HTMLElement>('#example');
const loading = document.querySelector<HTMLElement>('#loading');
if (!root || !loading) throw new Error('Missing example elements');
const host = root;
const status = loading;
let active = true;
let dispose: (() => void) | undefined;
const observer = new MutationObserver(() => {
  if (host.dataset.ready === 'true') {
    status.hidden = true;
    observer.disconnect();
  }
});
observer.observe(host, { attributes: true, attributeFilter: ['data-ready'] });

// Load this engine only after the browser has created its host.
async function start() {
  try {
    const { mountExample } = await import('./reactflow/mount-example.ts');
    if (!active) return;
    const cleanup = await mountExample(host, document.documentElement.lang === 'ru');
    if (!active) { cleanup(); return; }
    dispose = cleanup;
    if (host.dataset.ready === 'true') status.hidden = true;
  } catch (error) {
    if (!active) return;
    status.textContent = 'The example could not load. Check the console and reload the page.';
    console.error(error);
  }
}
void start();

// In a routed application, call cleanup when leaving this page.
function cleanup() {
  active = false;
  observer.disconnect();
  dispose?.();
}
if (import.meta.hot) import.meta.hot.dispose(cleanup);
Только чтениеTS · UTF-8
Распакуйте ZIP и выполните в папке проекта:npm installnpm run dev