Skip to content

Excalidraw + DSK ​

Keep Excalidraw's hand-drawn canvas and use native DSK components around it. Excalidraw owns the scene, selection, dragging, rotation and navigation. DSK adds an external transform inspector, two rulers with guides and a comment that follows the rectangle.

The editor loads automatically with this page. Drag or rotate either shape, then edit X, Y or Rotation in the DSK fields. Select rectangle restores the first selection, Fit brings the scene into view and Reset restores the two original shapes. Pull guides from the rulers and press Delete on a focused guide to remove it. Use Excalidraw's normal canvas navigation to pan and zoom.

The scene stays in memory. Leaving the page disposes the editor; coming back creates a fresh example. The comment and guides belong to the DSK overlay. They do not become Excalidraw elements or change its snapping behavior.

1. Install the dependencies ​

Start with a Vite TypeScript application:

sh
npm i @flowscape-ui/dsk @excalidraw/excalidraw@0.18.1 react@18.3.1 react-dom@18.3.1
npm i -D @types/react@18 @types/react-dom@18

React is required by Excalidraw. DSK itself remains a collection of native custom elements. The example uses createElement, so the files below are ordinary .ts files and do not require JSX.

text
index.html
src/
  main.ts
  shared/
    engine-error-boundary.ts
    example-shell.ts
    example-shell.css
  excalidraw/
    mount-example.ts
    scene.ts
    bind-dsk.ts
    style.css

Excalidraw is available under the MIT license. See its official package documentation. Keep the license notices when redistributing dependencies.

2. Reserve a host and add the DSK controls ​

Add a host to index.html:

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

The shared shell creates the inputs, rulers and comment. Its surface is reserved for the rendering engine. The rulers and comment use the same inner viewport origin, and the comment overlay does not intercept canvas gestures.

Save these two files in src/shared. They are reused unchanged by the other engine examples:

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;
  }
}

The important public APIs are ruler.target = viewport, .camera on each ruler and marker, and input.value / input events on the fields. The shell skips writing the active field during a drag, so a render update does not interrupt editing.

3. Create an Excalidraw scene ​

Save this as src/excalidraw/scene.ts. convertToExcalidrawElements fills in the engine's required element metadata. Stable IDs let the comment stay attached to the rectangle while selection changes.

ts
import { convertToExcalidrawElements } from '@excalidraw/excalidraw';

export const rectangleId = 'dsk-rectangle';

/** Fresh elements also give Reset a clean, independent scene. */
export function createElements() {
  return convertToExcalidrawElements(
    [
      {
        id: rectangleId,
        type: 'rectangle',
        x: 80,
        y: 80,
        width: 180,
        height: 130,
        strokeColor: '#ff7bb7',
        backgroundColor: '#a61e68',
        fillStyle: 'solid',
        roughness: 1,
        strokeWidth: 2,
      },
      {
        id: 'dsk-ellipse',
        type: 'ellipse',
        x: 350,
        y: 170,
        width: 110,
        height: 110,
        strokeColor: '#d0bfff',
        backgroundColor: '#6741a5',
        fillStyle: 'hachure',
        roughness: 1,
        strokeWidth: 2,
      },
    ],
    { regenerateIds: false },
  );
}

The helper creates fresh elements for Reset. Excalidraw owns their geometry and rendering; DSK does not draw substitute shapes.

4. Mount the editor ​

Save this as src/excalidraw/mount-example.ts:

ts
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { Excalidraw } from '@excalidraw/excalidraw';
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types';
import '@excalidraw/excalidraw/index.css';
import { createExampleShell } from '../shared/example-shell.ts';
import { EngineErrorBoundary } from '../shared/engine-error-boundary.ts';
import { bindDsk } from './bind-dsk.ts';
import { createElements, rectangleId } from './scene.ts';
import './style.css';

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

  const editor = createElement(Excalidraw, {
    theme: 'dark',
    langCode: ru ? 'ru-RU' : 'en',
    autoFocus: false,
    handleKeyboardGlobally: false,
    zenModeEnabled: true,
    initialData: {
      elements: createElements(),
      appState: {
        // Excalidraw applies its dark-mode filter to this scene color.
        viewBackgroundColor: '#ffffff',
        selectedElementIds: { [rectangleId]: true },
      },
      scrollToContent: true,
    },
    UIOptions: {
      canvasActions: {
        changeViewBackgroundColor: false,
        clearCanvas: false,
        export: false,
        loadScene: false,
        saveAsImage: false,
        saveToActiveFile: false,
        toggleTheme: false,
      },
    },
    excalidrawAPI(api: ExcalidrawImperativeAPI) {
      if (disposed) return;
      disposeBinding?.();
      disposeBinding = bindDsk(api, shell, () => {
        if (!disposed) root.dataset.ready = 'true';
      });
    },
  });
  reactRoot.render(createElement(EngineErrorBoundary, { host: root }, editor));

  return () => {
    disposed = true;
    disposeBinding?.();
    reactRoot.unmount();
    shell.destroy();
    root.classList.remove('dsk-excalidraw-example');
    delete root.dataset.ready;
    delete root.dataset.error;
  };
}

Save the small React error boundary as src/shared/engine-error-boundary.ts. It reports rendering failures to the host so the documentation can replace the loading indicator with a retry button:

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;
  }
}

excalidrawAPI supplies the public editor API. initialData creates the two shapes and selects the rectangle. autoFocus: false prevents page loading from taking keyboard focus, and handleKeyboardGlobally: false keeps Excalidraw shortcuts inside its editor instead of consuming input in the external DSK controls.

The example uses zen mode to keep the scene clear while preserving Excalidraw's editing tools. The host receives data-ready="true" after the scene is available, allowing the documentation wrapper to remove its loading indicator.

5. Bind selection, transforms and camera ​

Save this as src/excalidraw/bind-dsk.ts:

ts
import {
  CaptureUpdateAction,
  getCommonBounds,
  newElementWith,
} from '@excalidraw/excalidraw';
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types';
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types';
import type { createExampleShell } from '../shared/example-shell.ts';
import { createElements, rectangleId } from './scene.ts';

type Shell = ReturnType<typeof createExampleShell>;

export function bindDsk(
  api: ExcalidrawImperativeAPI,
  shell: Shell,
  onReady: () => void,
): () => void {
  const selected = () => {
    const state = api.getAppState();
    const elements = api
      .getSceneElements()
      .filter((element) => state.selectedElementIds[element.id]);
    const element = elements.length === 1 ? elements[0] : undefined;
    // This inspector deliberately edits independent primitive shapes.
    return element &&
      !element.locked &&
      !element.frameId &&
      element.groupIds.length === 0 &&
      (element.type === 'rectangle' || element.type === 'ellipse')
      ? element
      : undefined;
  };

  const sync = () => {
    const state = api.getAppState();
    const zoom = state.zoom.value;
    shell.setCamera({
      panX: state.scrollX * zoom,
      panY: state.scrollY * zoom,
      zoom,
    });
    const element = selected();
    shell.setSelection(
      element
        ? {
            x: element.x,
            y: element.y,
            rotation: (element.angle * 180) / Math.PI,
            name: `${element.type} · ${element.id}`,
          }
        : null,
    );
    const rectangle = api
      .getSceneElements()
      .find((item) => item.id === rectangleId);
    if (rectangle) {
      const [, , maxX, maxY] = getCommonBounds([rectangle]);
      shell.setMarker(maxX, maxY);
      if (!state.isLoading) onReady();
    } else {
      shell.setMarker(0, 0, false);
    }
  };

  shell.onTransform((field, value) => {
    const element = selected();
    if (!element) return;
    const changes =
      field === 'rotation'
        ? { angle: ((value * Math.PI) / 180) as ExcalidrawElement['angle'] }
        : { [field]: value };
    // The public helper makes a versioned copy and invalidates cached geometry.
    api.updateScene({
      elements: api
        .getSceneElements()
        .map((item) =>
          item.id === element.id ? newElementWith(item, changes) : item,
        ),
      captureUpdate: CaptureUpdateAction.IMMEDIATELY,
    });
  });
  shell.onAction((action) => {
    if (action === 'fit') {
      api.scrollToContent(undefined, {
        fitToViewport: true,
        viewportZoomFactor: 0.7,
        animate: false,
      });
      return;
    }
    api.updateScene({
      ...(action === 'reset' ? { elements: createElements() } : {}),
      appState: { selectedElementIds: { [rectangleId]: true } },
      captureUpdate: CaptureUpdateAction.IMMEDIATELY,
    });
    api.setActiveTool({ type: 'selection' });
  });

  const unsubscribe = api.onChange(sync);
  sync();
  return unsubscribe;
}

The adapter has two directions:

  1. api.onChange reads selection and geometry, then updates the DSK inspector and overlay.
  2. A DSK input event copies the selected element with newElementWith and passes the scene to api.updateScene. The public helper updates the element version and invalidates cached geometry.

Coordinate conversion ​

Excalidraw stores scene scroll before zoom. DSK expects pan in viewport pixels:

ts
const { scrollX, scrollY, zoom } = api.getAppState();
shell.setCamera({
  panX: scrollX * zoom.value,
  panY: scrollY * zoom.value,
  zoom: zoom.value,
});

Both then describe screen = world * zoom + pan. Do not add page scrolling or the host's position in the document. DSK already works relative to its target viewport. getCommonBounds gives the rectangle's rotated scene bounds, so the comment follows its lower-right corner.

Selection and rotation ​

This inspector accepts one unlocked rectangle or ellipse that is outside a group or frame. Empty selection, multiple selection and other element types disable the fields. X/Y describe the element's unrotated origin; Excalidraw applies its angle around the element center. DSK uses degrees and Excalidraw uses radians, so the adapter converts between them.

Native Excalidraw selection, resizing and rotation still work. DSK field changes use CaptureUpdateAction.IMMEDIATELY, so they also enter Excalidraw's history. This small adapter does not combine a whole input drag into one undo step. For a product integration, group commits according to your editing workflow.

See the official imperative API for scene updates, subscriptions and camera operations.

6. Style, start and clean up ​

Save these styles as src/excalidraw/style.css. The engine stylesheet is imported by the mounting module; these scoped rules only adjust its accent colors.

css
.dsk-excalidraw-example .excalidraw {
  --color-primary: #ff7bb7;
  --color-primary-darker: #ff0073;
  --color-primary-light: #502139;
  --island-bg-color: #1d1d21;
}

/* The engine fills the same viewport used by the DSK rulers and marker. */
.dsk-excalidraw-example .dsk-engine-surface {
  isolation: isolate;
}

Start the example from src/main.ts:

ts
import { mountExample } from './excalidraw/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);

Cleanup unsubscribes from scene changes, unmounts React and removes DSK listeners and guide overlays. In an SSR app, dynamically import the mounting module inside the framework's client mount hook, after the host exists, and call the returned disposer on unmount:

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

7. Explore and download the project ​

The project below includes every file needed to run this example, including the shared shell and these exact editor sources. Select a file to inspect it or copy its contents.

Download and extract the ZIP. Open a terminal in dsk-excalidraw-example, then run npm install and npm run dev. Requires Node.js 22.12+ and internet access for installing dependencies. The archive includes a built DSK snapshot in vendor, so it does not need this monorepo. Run npm run build for a type check and production build.

COMPLETE PROJECTdsk-excalidraw-example
Download ZIP
main.ts
srcmain.ts

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('./excalidraw/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);
Read onlyTS · UTF-8
Extract ZIP, then run inside the project folder:npm installnpm run dev