tldraw SDK + DSK
DSK можно добавить в готовый редактор, сохранив его движок. В этом примере tldraw отвечает за объекты, выделение, трансформации, камеру и историю. DSK добавляет две линейки, метку-комментарий и панель изменения позиции и поворота.
Пример запускается автоматически. Переместите или поверните прямоугольник средствами tldraw, затем измените X, Y или Поворот через инпуты DSK. Выберите эллипс, чтобы редактировать другой объект. Колесо/трекпад перемещают камеру, кнопка Уместить возвращает объекты в поле зрения. Потяните от линейки для создания направляющей; Delete удаляет линию в фокусе. Комментарий остаётся привязан к прямоугольнику независимо от выделения.
SDK загружается при открытии страницы. Пока редактор не готов, виден индикатор загрузки. Изменения хранятся в памяти и сбрасываются при уходе со страницы. Комментарий - аннотация DSK, а не ветка обсуждения tldraw. Направляющие помогают визуально, но не добавляют привязку объектов к линиям в движке tldraw.
Лицензия tldraw
Пример использует tldraw 5.5.2 и React 18.3.1. У tldraw собственная лицензия, отдельная от MIT-лицензии DSK. Локальная разработка доступна без ключа. Для публикации нужен действующий ключ tldraw для вашего хоста: задайте VITE_TLDRAW_LICENSE_KEY перед сборкой. Сохраняйте атрибуцию SDK. Подробнее в официальных условиях и инструкции по ключам.
1. Установите зависимости
В приложении Vite с TypeScript:
npm i @flowscape-ui/dsk tldraw@5.5.2 react@18.3.1 react-dom@18.3.1
npm i -D @types/react@18 @types/react-dom@18React нужен tldraw, а не DSK. В примере используется createElement, поэтому все файлы остаются обычными TypeScript-файлами. В существующем React-приложении можно вывести <Tldraw /> и передать editor из onMount в тот же адаптер DSK.
index.html
src/
main.ts
mount-example.ts
bind-dsk.ts
style.css2. Подготовьте область редактора
Viewport имеет заданную высоту. Строка и колонка по 24px отводятся линейкам. Начала шкал совпадают с внутренней областью редактора, а не со страницей или панелью инпутов над ней.
<!-- index.html -->
<div id="example"></div>
<script type="module" src="/src/main.ts"></script>Функция монтирования ниже создаёт три независимые области:
- Инпуты DSK за пределами поверхности событий tldraw.
- React root с настоящим компонентом Tldraw.
- Overlay без трансформации камеры, содержащий dsk-canvas-marker.
Overlay не перехватывает жесты холста. Линейки получают viewport через свойство .target; глобальный CSS-селектор не нужен.
3. Запустите tldraw и создайте два объекта
Сохраните код в src/mount-example.ts. Именно этот файл используется в живом примере. onMount передаёт экземпляр редактора, поэтому глобальная переменная editor не нужна.
import { Component, createElement, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { createShapeId, Tldraw } from 'tldraw';
import '@flowscape-ui/dsk';
import 'tldraw/tldraw.css';
import { bindDsk } from './bind-dsk.ts';
import './style.css';
/** Notify the page loader when React fails after the mounting function returns. */
class TldrawErrorBoundary extends Component<
{ host: HTMLElement; children?: ReactNode },
{ 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 tldraw example could not render', error);
}
override render(): ReactNode {
return this.state.failed ? null : this.props.children;
}
}
export function mountExample(root: HTMLElement, ru = false): () => void {
delete root.dataset.error;
const t = (en: string, russian: string) => (ru ? russian : en);
root.classList.add('dsk-tldraw-example');
root.dataset.emptySelection = t(
'Select one unlocked, top-level shape.',
'Выберите один незаблокированный объект верхнего уровня.',
);
root.innerHTML = `
<div class="dsk-tldraw-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="undo">${t('Undo', 'Отменить')}</button>
<button type="button" data-action="redo">${t('Redo', 'Повторить')}</button>
</div>
<div class="dsk-tldraw-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-tldraw-selection" aria-live="polite"></output>
<div class="dsk-tldraw-layout">
<span class="dsk-tldraw-corner"></span>
<dsk-ruler class="dsk-tldraw-x" aria-label="X ruler" snap="10"></dsk-ruler>
<dsk-ruler class="dsk-tldraw-y" orientation="vertical" aria-label="Y ruler" snap="10"></dsk-ruler>
<div class="dsk-tldraw-viewport">
<div class="dsk-tldraw-react"></div>
<div class="dsk-tldraw-overlay">
<dsk-canvas-marker label="${t('Comment: check spacing', 'Комментарий: проверить отступ')}" hidden></dsk-canvas-marker>
</div>
</div>
</div>`;
// React owns only the editor surface. DSK controls remain native DOM elements.
const reactRoot = createRoot(
root.querySelector<HTMLElement>('.dsk-tldraw-react')!,
);
let dispose: (() => void) | undefined;
reactRoot.render(
createElement(
TldrawErrorBoundary,
{ host: root },
createElement(Tldraw, {
hideUi: true,
autoFocus: false,
licenseKey: import.meta.env.VITE_TLDRAW_LICENSE_KEY,
onMount(editor) {
editor.user.updateUserPreferences({ colorScheme: 'dark' });
// Keep this stable ID so the comment follows the rectangle, not selection.
const id = createShapeId('dsk-example');
editor.createShapes([
{
id,
type: 'geo',
x: 80,
y: 80,
props: {
geo: 'rectangle',
w: 180,
h: 130,
color: 'violet',
fill: 'solid',
},
},
{
id: createShapeId('dsk-second'),
type: 'geo',
x: 330,
y: 180,
props: {
geo: 'ellipse',
w: 100,
h: 100,
color: 'orange',
fill: 'solid',
},
},
]);
editor.select(id);
editor.zoomToFit({ animation: { duration: 0 } });
// The adapter converts the camera and synchronizes all external controls.
dispose = bindDsk(editor, root, id);
root.dataset.ready = 'true';
return () => {
dispose?.();
dispose = undefined;
};
},
}),
),
);
return () => {
dispose?.();
dispose = undefined;
reactRoot.unmount();
root.replaceChildren();
delete root.dataset.ready;
};
}С hideUi инструменты выделения tldraw продолжают работать. Скрыта панель инструментов, чтобы сосредоточить пример на DSK; движок и штатная атрибуция остаются. autoFocus: false не позволяет загрузке демо забрать фокус у документации.
4. Преобразуйте координаты камеры
tldraw и DSK по-разному представляют pan:
// tldraw: viewportPoint = (pagePoint + cameraPosition) * zoom
// DSK: viewportPoint = worldPoint * zoom + pan
const { x, y, z } = editor.getCamera();
const camera = { panX: x * z, panY: y * z, zoom: z };
xRuler.camera = camera;
yRuler.camera = camera;
comment.camera = camera;Например, камера tldraw { x: 40, y: -20, z: 2 } преобразуется в DSK { panX: 80, panY: -40, zoom: 2 }. Мировая точка (100,50) окажется в точке (280,60) CSS-пикселей внутри viewport.
Не добавляйте сюда прокрутку документа и положение viewport на странице. Линейки и метка уже отсчитывают координаты от его начала. Не применяйте повторный CSS-transform камеры к overlay. Для другого движка выведите соответствующее преобразование из его API. Смотрите системы координат и камеру tldraw.
5. Свяжите выделение, инпуты и комментарий
Сохраните код в src/bind-dsk.ts. Функция react здесь - реактивная подписка tldraw, а не React hook. Она отслеживает камеру, выделение и изменения объектов и возвращает функцию очистки.
import { react, type Editor, type TLShapeId } from 'tldraw';
import type { DskInput } from '@flowscape-ui/dsk';
/** Connect native DSK elements to an existing editor, without a React wrapper. */
export function bindDsk(
editor: Editor,
root: HTMLElement,
commentShape: TLShapeId,
): () => void {
const viewport = root.querySelector<HTMLElement>('.dsk-tldraw-viewport')!;
const rulers = Array.from(root.querySelectorAll('dsk-ruler'));
const marker = root.querySelector('dsk-canvas-marker')!;
const fields = Array.from(root.querySelectorAll('dsk-input'));
const status = root.querySelector<HTMLOutputElement>(
'.dsk-tldraw-selection',
)!;
const listeners = new AbortController();
// tldraw tracks editor focus separately from DOM focus. Release its shortcuts
// when interacting with an external control, including ruler guide handles.
const releaseEditorFocus = (event: Event) => {
if (
event.target instanceof Element &&
!event.target.closest('.dsk-tldraw-react')
)
editor.blur();
};
root.addEventListener('pointerdown', releaseEditorFocus, {
capture: true,
signal: listeners.signal,
});
root.addEventListener('focusin', releaseEditorFocus, {
capture: true,
signal: listeners.signal,
});
let editing: DskInput | undefined;
let editingShape: TLShapeId | undefined;
for (const ruler of rulers) ruler.target = viewport;
const selected = () => {
const shapes = editor.getSelectedShapes();
const shape = shapes.length === 1 ? shapes[0] : undefined;
// These fields intentionally expose page-space transforms for root shapes.
return shape &&
!shape.isLocked &&
shape.parentId === editor.getCurrentPageId()
? shape
: undefined;
};
const sync = () => {
const { x, y, z } = editor.getCamera();
// tldraw: screen = (page + camera) * zoom. DSK: screen = world * zoom + pan.
const camera = { panX: x * z, panY: y * z, zoom: z };
for (const ruler of rulers) ruler.camera = camera;
marker.camera = camera;
const bounds = editor.getShapePageBounds(commentShape);
marker.hidden =
!bounds || !editor.getCurrentPageShapeIds().has(commentShape);
if (bounds) {
marker.x = bounds.maxX;
marker.y = bounds.maxY;
}
const shape = selected();
status.textContent = shape
? `${shape.type} · ${shape.id}`
: (root.dataset.emptySelection ??
'Select one unlocked, top-level shape.');
for (const field of fields) {
field.disabled = !shape;
if (!shape || (field === editing && shape.id === editingShape)) continue;
const value =
field.dataset.field === 'rotation'
? (shape.rotation * 180) / Math.PI
: field.dataset.field === 'x'
? shape.x
: shape.y;
field.value = Math.round(value * 100) / 100;
}
};
const stop = react('DSK tldraw inspector', sync);
for (const field of fields) {
field.addEventListener(
'input',
() => {
const shape = selected();
if (!shape || !Number.isFinite(field.valueAsNumber)) return;
if (editing !== field || editingShape !== shape.id) {
editor.markHistoryStoppingPoint('DSK transform');
editing = field;
editingShape = shape.id;
}
if (field.dataset.field === 'rotation') {
// Rotate around the shape's center, preserving tldraw's transform behavior.
editor.rotateShapesBy(
[shape.id],
(field.valueAsNumber * Math.PI) / 180 - shape.rotation,
);
} else {
const axis = field.dataset.field === 'x' ? 'x' : 'y';
editor.updateShape({
id: shape.id,
type: shape.type,
[axis]: field.valueAsNumber,
});
}
},
{ signal: listeners.signal },
);
const finish = () => {
editing = undefined;
editingShape = undefined;
sync();
};
field.addEventListener('change', finish, { signal: listeners.signal });
field.addEventListener('focusout', finish, { signal: listeners.signal });
}
const actions: Record<string, () => void> = {
undo: () => editor.undo(),
redo: () => editor.redo(),
fit: () => editor.zoomToFit({ animation: { duration: 0 } }),
select: () => editor.select(commentShape),
};
for (const button of root.querySelectorAll<HTMLButtonElement>(
'[data-action]',
)) {
button.addEventListener(
'click',
() => actions[button.dataset.action ?? '']?.(),
{ signal: listeners.signal },
);
}
return () => {
stop();
listeners.abort();
for (const ruler of rulers) ruler.target = null;
};
}Связь работает в обе стороны:
- tldraw изменяет объект или выделение. Подписка обновляет поля DSK, линейки и метку.
- Событие input от DSK изменяет выбранный объект tldraw. Программное присваивание значения DSK не отправляет input, поэтому цикл событий не возникает.
- Поворот переводит градусы в радианы и использует rotateShapesBy, чтобы сохранить поведение вращения вокруг центра. При этом X/Y также могут измениться.
- В начале правки поля создаётся точка истории. Undo отменяет правку целиком, а не каждый промежуточный шаг перетаскивания.
Значение активного поля не перезаписывается во время ввода. Инпуты расположены вне редактора, а editor.blur() не позволяет горячим клавишам холста перехватывать их ввод.
Границы примера
Панель редактирует один незаблокированный объект, родителем которого является текущая страница. При пустом или множественном выделении, а также для вложенного объекта поля отключаются. X/Y вложенного объекта заданы относительно его родителя. Для поддержки таких объектов нужно осознанное преобразование между координатами страницы и родителя. Подробнее: трансформации объектов tldraw.
Привязка комментария
Аннотация хранит стабильный ID прямоугольника. getShapePageBounds возвращает ограничивающую рамку в координатах страницы; метка следует за её правым нижним углом при перемещении, изменении размера и повороте. Удаление прямоугольника скрывает комментарий, Undo возвращает его. Смена выделения не переносит комментарий на другой объект. В продукте храните ID и текст в модели документа и выберите, привязывается ли метка к рамке или к локальной точке фигуры.
6. Добавьте стили
Сохраните это в src/style.css. Главное правило - общее начало координат viewport и виджетов. Остальные стили подстраивают панель под документацию. Таблица стилей самого tldraw импортируется отдельно в файле монтирования.
.dsk-tldraw-example {
border: 1px solid var(--vp-c-divider, #39393f);
--dsk-input-background: #171717;
--dsk-input-color: #ffffff;
--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-tldraw-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
}
.dsk-tldraw-toolbar button {
padding: 6px 10px;
border: 1px solid #39393f;
background: #171717;
color: white;
cursor: pointer;
font: inherit;
font-size: 13px;
}
.dsk-tldraw-toolbar button:focus-visible {
outline: 2px solid #ff0073;
outline-offset: 2px;
}
.dsk-tldraw-inspector {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
padding: 0 12px 12px;
}
.dsk-tldraw-inspector label {
display: grid;
gap: 6px;
font-size: 13px;
min-width: 0;
}
.dsk-tldraw-inspector dsk-input {
width: 100%;
min-width: 0;
}
.dsk-tldraw-selection {
display: block;
padding: 0 12px 12px;
font: 12px/1.5 monospace;
overflow-wrap: anywhere;
}
.dsk-tldraw-layout {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
grid-template-rows: 24px 420px;
}
.dsk-tldraw-x {
grid-column: 2;
grid-row: 1;
}
.dsk-tldraw-y {
grid-column: 1;
grid-row: 2;
}
.dsk-tldraw-corner {
background: #171717;
}
.dsk-tldraw-viewport {
grid-column: 2;
grid-row: 2;
position: relative;
min-width: 0;
overflow: hidden;
}
.dsk-tldraw-react,
.dsk-tldraw-overlay {
position: absolute;
inset: 0;
}
.dsk-tldraw-overlay {
pointer-events: none;
z-index: 300;
}
.dsk-tldraw-overlay dsk-canvas-marker {
transform: translate(-100%, 12px);
}
@media (max-width: 648px) {
.dsk-tldraw-inspector {
grid-template-columns: 1fr;
}
.dsk-tldraw-inspector label {
grid-template-columns: 72px minmax(0, 1fr);
align-items: center;
}
}7. Запуск и очистка
// src/main.ts
import { mountExample } from './mount-example.ts';
const root = document.querySelector<HTMLElement>('#example');
if (!root) throw new Error('Missing example root');
const dispose = mountExample(root);
// On route unmount: dispose();
if (import.meta.hot) import.meta.hot.dispose(dispose);Очистка останавливает реактивную подписку, удаляет слушатели DSK и размонтирует React. Отключение линеек также удаляет их overlay с направляющими. Для SSR загружайте модуль только на клиенте, когда контейнер уже существует; инициализировать tldraw и DSK на сервере не нужно.
Для публичного размещения создайте .env.local с публичным лицензионным ключом tldraw и пересоберите проект:
VITE_TLDRAW_LICENSE_KEY=your-public-tldraw-license-keyЗдесь нет специального пакета-обёртки, форка tldraw или отдельного движка DSK. В своём редакторе можно повторить ту же схему: преобразовать камеру, прочитать выделение и изменять модель через публичный API.
8. Изучите и скачайте проект
Ниже можно просмотреть всю структуру проекта. У каждого файла есть исходный код и короткое объяснение. Файлы редактора взяты из живого примера, поэтому скачиваемый проект соответствует этому руководству.
Скачайте и распакуйте ZIP, откройте терминал в папке dsk-tldraw-example и выполните npm install, затем npm run dev. Нужны Node.js 22.12+ и интернет для установки зависимостей. Архив содержит собранную копию DSK в папке vendor; npm установит её автоматически. Настраивать монорепозиторий не нужно. Команда npm run build проверит типы и соберёт приложение. Для публичного размещения по-прежнему нужен ключ tldraw, описанный выше.
Точка входа: подключение редактора и очистка при перезагрузке.
import { mountExample } from './mount-example.ts';
import './page.css';
// Mount only after the host exists. The HTML entry loads this as a module.
const root = document.querySelector<HTMLElement>('#example');
if (!root) throw new Error('Missing example root');
const dispose = mountExample(root, document.documentElement.lang === 'ru');
// Stop editor subscriptions and remove listeners when Vite replaces this module.
// In a routed application, also call dispose() when leaving the page.
if (import.meta.hot) import.meta.hot.dispose(dispose);npm installnpm run dev