React Flow + DSK
Use the same DSK inspector, rulers and comment with a node editor. React Flow handles node selection, dragging, edges and camera navigation. Native DSK fields update the selected node's position and the rotation of its custom visual body.
The editor loads automatically. Drag either node, select it and change X, Y or Rotation. Drag empty space to pan, use the wheel or trackpad to zoom, and choose Fit to see both nodes. Select rectangle restores the first selection; Reset restores the initial layout. Pull a guide from either ruler. The comment stays attached to the rectangle, including after rotation.
The edge is a real React Flow edge. This focused example keeps its two endpoints fixed and disables node deletion. Changes stay in memory and reset when you leave the page. DSK guides are visual references, not snapping constraints on React Flow nodes.
1. Install the dependencies
In a Vite TypeScript application:
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@18React Flow uses React; the DSK controls remain native custom elements outside its React root. createElement lets us use plain TypeScript without adding JSX configuration.
index.html
src/
main.ts
shared/
engine-error-boundary.ts
example-shell.ts
example-shell.css
reactflow/
mount-example.ts
shape-node.ts
style.cssReact Flow is MIT-licensed. This example keeps its built-in attribution. See the official quick start.
2. Create a host and the shared inspector
Add a host to index.html:
<div id="example"></div>
<script type="module" src="/src/main.ts"></script>Save the shared shell in src/shared. It creates three DSK fields, two rulers and an untransformed comment overlay. Only shell.surface belongs to React Flow. All overlays share the same viewport origin.
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;
},
};
}.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;
}
}The shell handles native input events and calls the engine adapter. Programmatic DSK value changes do not emit another input event, and the active field is not overwritten during a drag.
3. Define a custom node
Save this as src/reactflow/shape-node.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 };The node model stores position for React Flow and data.rotation for our visual body. nodeTypes stays outside the React component, keeping its identity stable during edits. Each node provides actual React Flow Handle elements for the connecting edge.
What rotation means here
React Flow's standard node position and selection box remain axis-aligned. In this example, rotation affects the node's inner visual body through CSS. Connection handles stay fixed on the outer node, so the edge does not orbit the shape when you change the angle. This is a deliberate custom-node design, not a built-in React Flow rotation property.
To rotate handles too, place them inside the rotating body and update their internals with React Flow's useUpdateNodeInternals after each rotation. See custom nodes and the rotatable node example.
4. Mount React Flow and connect DSK
Save this as src/reactflow/mount-example.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;
};
}Save the shared React error boundary as src/shared/engine-error-boundary.ts. It reports rendering failures to the host instead of leaving its loading indicator running:
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 owns the node array. Canvas edits arrive through onNodesChange; native DSK edits call setNodes with a new node and a new position or data object. A ref exposes the latest nodes to native event listeners, preventing a stale React closure from editing an old selection.
Only one selected node can be edited. Empty or multiple selection disables the inspector. React effects update the displayed field values after node changes. The comment uses the rectangle's stable ID, so switching selection never moves it to the ellipse.
5. Keep the camera and comment aligned
React Flow and DSK use the same camera formula:
// React Flow viewport: screen = world * zoom + { x, y }
onMove(_event, { x, y, zoom }) {
shell.setCamera({ panX: x, panY: y, zoom });
}Unlike the Excalidraw and tldraw examples, no extra multiplication is needed. React Flow's viewport X/Y are already measured in screen pixels. The adapter also reads getViewport() inside onInit, so the rulers have a camera before the first gesture.
The rectangle body is 180 × 120 pixels. Its comment anchor is the lower-right corner of the rotated body's axis-aligned bounds:
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);The marker receives world coordinates. Its own .camera performs the screen projection, so do not apply another camera transform to the overlay.
6. Add styles, start and clean up
Save this as src/reactflow/style.css. All rules are scoped to the example. React Flow's required base stylesheet is imported separately in the mounting module.
.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;
}Start from src/main.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);Unmounting React releases the engine and its effects. Destroying the shell removes DSK event listeners, ruler targets and overlays. In an SSR app, import and mount this module from the client lifecycle after the host is available:
// 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.The documentation follows that same rule: it starts the import automatically when the page mounts and shows a loading indicator while the engine initializes.
7. Explore and download the project
Browse the actual demo sources and configuration below. The ZIP includes the shared shell and a built DSK snapshot in vendor.
Extract it, open a terminal in dsk-reactflow-example, then run npm install and npm run dev. Requires Node.js 22.12+ and internet access for dependency installation. Run npm run build to check types and produce a standalone app. No monorepo or additional backend is required.
Automatic loading and cleanup on navigation or hot reload.
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);npm installnpm run dev