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

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:

sh
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@18

React нужен tldraw, а не DSK. В примере используется createElement, поэтому все файлы остаются обычными TypeScript-файлами. В существующем React-приложении можно вывести <Tldraw /> и передать editor из onMount в тот же адаптер DSK.

text
index.html
src/
  main.ts
  mount-example.ts
  bind-dsk.ts
  style.css

2. Подготовьте область редактора ​

Viewport имеет заданную высоту. Строка и колонка по 24px отводятся линейкам. Начала шкал совпадают с внутренней областью редактора, а не со страницей или панелью инпутов над ней.

html
<!-- 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 не нужна.

ts
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:

ts
// 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. Она отслеживает камеру, выделение и изменения объектов и возвращает функцию очистки.

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

Связь работает в обе стороны:

  1. tldraw изменяет объект или выделение. Подписка обновляет поля DSK, линейки и метку.
  2. Событие input от DSK изменяет выбранный объект tldraw. Программное присваивание значения DSK не отправляет input, поэтому цикл событий не возникает.
  3. Поворот переводит градусы в радианы и использует rotateShapesBy, чтобы сохранить поведение вращения вокруг центра. При этом X/Y также могут измениться.
  4. В начале правки поля создаётся точка истории. Undo отменяет правку целиком, а не каждый промежуточный шаг перетаскивания.

Значение активного поля не перезаписывается во время ввода. Инпуты расположены вне редактора, а editor.blur() не позволяет горячим клавишам холста перехватывать их ввод.

Границы примера ​

Панель редактирует один незаблокированный объект, родителем которого является текущая страница. При пустом или множественном выделении, а также для вложенного объекта поля отключаются. X/Y вложенного объекта заданы относительно его родителя. Для поддержки таких объектов нужно осознанное преобразование между координатами страницы и родителя. Подробнее: трансформации объектов tldraw.

Привязка комментария ​

Аннотация хранит стабильный ID прямоугольника. getShapePageBounds возвращает ограничивающую рамку в координатах страницы; метка следует за её правым нижним углом при перемещении, изменении размера и повороте. Удаление прямоугольника скрывает комментарий, Undo возвращает его. Смена выделения не переносит комментарий на другой объект. В продукте храните ID и текст в модели документа и выберите, привязывается ли метка к рамке или к локальной точке фигуры.

6. Добавьте стили ​

Сохраните это в src/style.css. Главное правило - общее начало координат viewport и виджетов. Остальные стили подстраивают панель под документацию. Таблица стилей самого tldraw импортируется отдельно в файле монтирования.

css
.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. Запуск и очистка ​

ts
// 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 и пересоберите проект:

dotenv
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, описанный выше.

ПОЛНЫЙ ПРОЕКТdsk-tldraw-example
Скачать ZIP
main.ts
srcmain.ts

Точка входа: подключение редактора и очистка при перезагрузке.

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);
Только чтениеTS · UTF-8
Распакуйте ZIP и выполните в папке проекта:npm installnpm run dev