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

Excalidraw + DSK ​

Сохраняем рисованный холст Excalidraw и добавляем вокруг него нативные компоненты DSK. Excalidraw отвечает за сцену, выделение, перемещение, поворот и навигацию. DSK предоставляет внешнюю панель трансформаций, две линейки с направляющими и комментарий, который следует за прямоугольником.

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

Сцена хранится в памяти. При уходе со страницы редактор уничтожается, при возвращении создаётся новый пример. Комментарий и направляющие принадлежат слою DSK. Они не становятся элементами Excalidraw и не меняют его привязки.

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

Начните с приложения Vite на TypeScript:

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 нужен Excalidraw. Сам DSK остаётся набором нативных custom elements. В примере используется createElement, поэтому ниже обычные файлы .ts, для которых не нужна настройка 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 распространяется по лицензии MIT. См. официальную документацию пакета. Сохраняйте уведомления о лицензиях при распространении зависимостей.

2. Подготовьте контейнер и элементы DSK ​

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

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

Общая оболочка создаёт поля, линейки и комментарий. Её surface предназначена для движка. Линейки и комментарий используют начало координат внутреннего viewport, а слой комментария не перехватывает жесты на холсте.

Сохраните эти два файла в src/shared. В остальных примерах движков они используются без изменений:

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

Основные публичные API здесь: ruler.target = viewport, .camera у каждой линейки и маркера, а также input.value и события input у полей. Оболочка не перезаписывает активное поле во время перетаскивания, поэтому обновление сцены не прерывает редактирование.

3. Создайте сцену Excalidraw ​

Сохраните файл src/excalidraw/scene.ts. convertToExcalidrawElements добавляет необходимые движку метаданные элементов. Постоянные ID позволяют комментарию оставаться привязанным к прямоугольнику при смене выделения.

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

Функция создаёт новые элементы и для кнопки Сбросить. Геометрией и отрисовкой управляет Excalidraw; DSK не подменяет его фигуры собственной отрисовкой.

4. Подключите редактор ​

Сохраните файл 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;
  };
}

Сохраните небольшую 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;
  }
}

excalidrawAPI передаёт публичный API редактора. initialData создаёт две фигуры и выделяет прямоугольник. autoFocus: false не позволяет загрузке страницы перехватить фокус клавиатуры, а handleKeyboardGlobally: false ограничивает горячие клавиши Excalidraw его редактором, чтобы они не мешали внешним полям DSK.

Режим zen оставляет сцену свободной и сохраняет инструменты редактирования Excalidraw. Когда сцена готова, контейнер получает data-ready="true", и обёртка документации убирает индикатор загрузки.

5. Свяжите выделение, трансформации и камеру ​

Сохраните файл 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;
}

Адаптер работает в двух направлениях:

  1. api.onChange читает выделение и геометрию, затем обновляет панель и слой DSK.
  2. Событие input от DSK создаёт копию выбранного элемента через newElementWith и передаёт сцену в api.updateScene. Публичная функция обновляет версию элемента и сбрасывает кеш его геометрии.

Преобразование координат ​

Excalidraw хранит смещение сцены до применения масштаба. DSK ожидает смещение в пикселях viewport:

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

После преобразования обе системы описываются формулой screen = world * zoom + pan. Не добавляйте прокрутку страницы или положение контейнера в документе. DSK уже работает относительно целевого viewport. getCommonBounds возвращает границы повёрнутого прямоугольника в сцене, поэтому комментарий следует за его правым нижним углом.

Выделение и поворот ​

Эта панель редактирует один незаблокированный прямоугольник или эллипс вне группы и frame. При пустом или множественном выделении, а также для других типов элементов поля отключены. X/Y задают начало неповёрнутого элемента; Excalidraw применяет угол вокруг его центра. DSK использует градусы, а Excalidraw - радианы, поэтому адаптер преобразует единицы.

Обычное выделение, изменение размера и поворот Excalidraw продолжают работать. Изменения полей DSK используют CaptureUpdateAction.IMMEDIATELY и попадают в историю Excalidraw. Этот небольшой адаптер не объединяет перетаскивание инпута в один шаг отмены. При интеграции в продукт группируйте изменения в соответствии со своим сценарием редактирования.

Подробнее об обновлении сцены, подписках и камере - в официальном imperative API.

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

Сохраните стили в src/excalidraw/style.css. Основную таблицу стилей движка импортирует модуль подключения; эти локальные правила меняют только акцентные цвета.

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

Запустите пример из 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);

Очистка отменяет подписку на сцену, размонтирует React и удаляет слушатели DSK и слои направляющих. В SSR-приложении динамически импортируйте модуль из клиентского mount-хука фреймворка после появления контейнера, а при 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. Изучите и скачайте проект ​

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

Скачайте и распакуйте ZIP. Откройте терминал в dsk-excalidraw-example, выполните npm install и npm run dev. Нужны Node.js 22.12+ и интернет для установки зависимостей. Архив содержит собранный DSK в vendor, поэтому этот монорепозиторий не нужен. Команда npm run build проверяет типы и создаёт production-сборку.

ПОЛНЫЙ ПРОЕКТdsk-excalidraw-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('./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);
Только чтениеTS · UTF-8
Распакуйте ZIP и выполните в папке проекта:npm installnpm run dev