Excalidraw + DSK
Сохраняем рисованный холст Excalidraw и добавляем вокруг него нативные компоненты DSK. Excalidraw отвечает за сцену, выделение, перемещение, поворот и навигацию. DSK предоставляет внешнюю панель трансформаций, две линейки с направляющими и комментарий, который следует за прямоугольником.
Редактор загружается автоматически вместе со страницей. Перетащите или поверните любую фигуру, затем измените X, Y или Поворот в полях DSK. Выбрать прямоугольник возвращает исходное выделение, Уместить показывает всю сцену, а Сбросить восстанавливает две начальные фигуры. Вытяните направляющую из линейки и нажмите Delete на направляющей в фокусе, чтобы удалить её. Для перемещения камеры и масштабирования используйте обычную навигацию Excalidraw.
Сцена хранится в памяти. При уходе со страницы редактор уничтожается, при возвращении создаётся новый пример. Комментарий и направляющие принадлежат слою DSK. Они не становятся элементами Excalidraw и не меняют его привязки.
1. Установите зависимости
Начните с приложения Vite на TypeScript:
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@18React нужен Excalidraw. Сам DSK остаётся набором нативных custom elements. В примере используется createElement, поэтому ниже обычные файлы .ts, для которых не нужна настройка JSX.
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.cssExcalidraw распространяется по лицензии MIT. См. официальную документацию пакета. Сохраняйте уведомления о лицензиях при распространении зависимостей.
2. Подготовьте контейнер и элементы DSK
Добавьте контейнер в index.html:
<div id="example"></div>
<script type="module" src="/src/main.ts"></script>Общая оболочка создаёт поля, линейки и комментарий. Её surface предназначена для движка. Линейки и комментарий используют начало координат внутреннего viewport, а слой комментария не перехватывает жесты на холсте.
Сохраните эти два файла в src/shared. В остальных примерах движков они используются без изменений:
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;
}
}Основные публичные API здесь: ruler.target = viewport, .camera у каждой линейки и маркера, а также input.value и события input у полей. Оболочка не перезаписывает активное поле во время перетаскивания, поэтому обновление сцены не прерывает редактирование.
3. Создайте сцену Excalidraw
Сохраните файл src/excalidraw/scene.ts. convertToExcalidrawElements добавляет необходимые движку метаданные элементов. Постоянные ID позволяют комментарию оставаться привязанным к прямоугольнику при смене выделения.
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:
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. Она сообщает контейнеру об ошибках отрисовки, чтобы документация могла заменить индикатор загрузки кнопкой повторной попытки:
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:
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;
}Адаптер работает в двух направлениях:
api.onChangeчитает выделение и геометрию, затем обновляет панель и слой DSK.- Событие
inputот DSK создаёт копию выбранного элемента черезnewElementWithи передаёт сцену вapi.updateScene. Публичная функция обновляет версию элемента и сбрасывает кеш его геометрии.
Преобразование координат
Excalidraw хранит смещение сцены до применения масштаба. DSK ожидает смещение в пикселях viewport:
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. Основную таблицу стилей движка импортирует модуль подключения; эти локальные правила меняют только акцентные цвета.
.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:
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 вызывайте возвращённую функцию очистки:
// 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-сборку.
Автоматическая загрузка и очистка при навигации или перезагрузке.
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);npm installnpm run dev