tldraw SDK + DSK
Use DSK inside an existing editor rather than replacing its engine. This example keeps tldraw responsible for shapes, selection, transforms, camera navigation and undo. DSK supplies two rulers, a comment marker and a small transform inspector.
The example starts automatically. Drag or rotate the rectangle with tldraw, then change X, Y or Rotation in the DSK inputs. Select the ellipse to inspect another object. Use the mouse wheel/trackpad to navigate, or Fit to bring the shapes back. Pull a guide from either ruler and use Delete on the focused guide to remove it. The comment follows the rectangle even when a different object is selected.
The SDK loads when this page opens, with a loading indicator until the editor is ready. Edits stay in memory and reset when you leave the page. The comment is a DSK annotation, not a tldraw comment thread. Guides are visual aids; they do not add snapping constraints to tldraw.
tldraw license
This example uses tldraw 5.5.2 and React 18.3.1. tldraw has its own license, separate from DSK's MIT license. Local development works without a key. Deploying this example requires a valid tldraw license key for your host; set VITE_TLDRAW_LICENSE_KEY before building. Keep the SDK's attribution. See the official license and key instructions.
1. Install the dependencies
In a Vite TypeScript application:
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 is required by tldraw, not by DSK. The example uses React's createElement so the adapter and entry files remain plain TypeScript. You can also render <Tldraw /> in your existing React app and pass its onMount editor to the same DSK adapter.
index.html
src/
main.ts
mount-example.ts
bind-dsk.ts
style.css2. Reserve the editor area
The viewport has a fixed height. A 24px row and column around it hold the rulers. Their origins align with the inner viewport, not with the page or the inspector above it.
<!-- index.html -->
<div id="example"></div>
<script type="module" src="/src/main.ts"></script>The mounting function below creates these three independent areas:
- DSK inputs outside tldraw's event surface.
- A React root containing the real
Tldrawcomponent. - An untransformed overlay containing
dsk-canvas-marker.
The overlay does not intercept canvas gestures. DSK rulers target the viewport element through .target; no global selector is needed.
3. Mount tldraw and create two shapes
Save this as src/mount-example.ts. It is the exact code used by the live example. onMount gives us the editor instance; no global editor variable is needed.
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;
};
}The normal tldraw selection tools still work with hideUi. This hides its toolbar so DSK controls are easy to see; it does not replace tldraw's editor or suppress its attribution. autoFocus: false prevents loading the demo from taking focus away from the documentation.
4. Translate camera coordinates
tldraw and DSK express pan differently:
// 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;For example, tldraw camera { x: 40, y: -20, z: 2 } becomes DSK { panX: 80, panY: -40, zoom: 2 }. A point at (100, 50) appears at (280, 60) CSS pixels inside the viewport.
Do not add document scroll or the viewport's page offset here. Both rulers and the marker already use the viewport origin. Do not apply another CSS camera transform to the overlay. For other engines, derive the equivalent mapping from their camera API. See tldraw's coordinate systems and camera API.
5. Connect selection, inputs and the comment
Save this as src/bind-dsk.ts. The react function here is tldraw's reactive subscription, not a React hook. It tracks the camera, selection and shape changes and returns a disposer.
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;
};
}The connection works in both directions:
- tldraw changes a shape or selection. The subscription updates DSK fields, rulers and the marker.
- A DSK
inputevent updates the selected tldraw shape. Programmatic DSK value assignments are silent, so they do not create an event loop. - Rotation converts degrees to radians and uses
rotateShapesByto rotate around the object's center. X/Y may also change during that rotation. - A history stopping point starts each field edit, keeping undo grouped rather than adding a separate undo step for every pointer movement.
The active field is not rewritten during an edit. DSK inputs receive focus outside the editor, and editor.blur() prevents editor shortcuts from consuming their keyboard input.
Selection boundaries
This inspector deliberately edits one unlocked shape whose parent is the current page. Empty selection, multiple selection and nested shapes disable the inputs. A nested shape's X/Y are relative to its parent; supporting them requires a deliberate page-to-parent transform, not treating those coordinates as page coordinates. See tldraw's shape transforms.
Comment attachment
The annotation stores a reference to the rectangle's stable shape ID. getShapePageBounds supplies its page-space bounding box; the marker follows its bottom-right corner after movement, resize or rotation. Deleting the rectangle hides the marker; undo restores it. Switching selection does not move the comment to another shape. In a real product, store the shape ID and comment content in your document model, and decide whether the anchor belongs to the bounding box or to a local point on the shape.
6. Add the styles
Save this as src/style.css. The important layout rule is the shared viewport origin. The remaining rules make the inspector match the documentation. tldraw's stylesheet is imported separately by the mounting file.
.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. Start and clean up
// 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);The disposer stops the reactive subscription, removes DSK listeners and unmounts React. Removing the rulers also removes their guide overlays. For SSR, load this module only on the client after the host element exists; neither tldraw nor DSK should be initialized on the server.
For a public deployment, create .env.local with your tldraw public license key and rebuild:
VITE_TLDRAW_LICENSE_KEY=your-public-tldraw-license-keyNo wrapper package, tldraw fork or DSK-specific rendering engine is involved. The same approach applies to your own editor: map its camera, read its selection and update its model through its public API.
8. Explore and download the project
Browse the complete file structure below. Each file includes its source and a short explanation. The editor files are the same ones used by the live demo, so the downloadable project stays in sync with this guide.
Download and extract the ZIP, open a terminal in dsk-tldraw-example, then run npm install and npm run dev. Requires Node.js 22.12+ and internet access for dependency installation. The archive includes a built DSK snapshot in vendor; npm installs it automatically. No monorepo setup is needed. Use npm run build to check types and build the app. The tldraw deployment key described above is still required for public hosting.
The entry point: mount the editor and register cleanup.
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