Skip to content

Commit 0666ccb

Browse files
steveruizokclaude
andauthored
feat(tlschema): allow adding custom record types to the store (tldraw#8213)
Closes tldraw#7749 In order to allow SDK users to persist and synchronize domain-specific data that doesn't fit into shapes, bindings, or assets, this PR adds support for custom record types in the tldraw store. Custom record types are registered via a new `records` option on `createTLSchema` and `createTLStore`. Each custom record type specifies a scope (document/session/presence), a validator, optional migrations, and optional default properties. Built-in type names are guarded against collision. The PR also adds TypeScript module augmentation support via `TLGlobalRecordPropsMap`, so custom record types can be included in the `TLRecord` union. Original branch: `ds300/custom-record-types` Almost entirely authored by @ds300 (David Sheldrick) https://github.com/user-attachments/assets/55d3e131-c7f1-4969-9932-1598461d7689 https://github.com/user-attachments/assets/dd531c84-6a4b-4a98-bc4d-90ea9a50711f ### Change type - [x] `feature` ### Test plan 1. Open the "Custom records" example at localhost:5420 2. Click "+ Add marker" to create map-pin markers on the canvas 3. Verify markers render at correct positions and follow camera movement 4. Right-click a marker to remove it - [ ] Unit tests - [ ] End to end tests ### API changes - Added `CustomRecordInfo` interface for configuring custom record types - Added `createCustomRecordId()` for creating custom record IDs - Added `createCustomRecordMigrationIds()` for creating versioned migration IDs - Added `createCustomRecordMigrationSequence()` for defining custom record migrations - Added `isCustomRecord()` type guard for checking record type - Added `isCustomRecordId()` type guard for checking record IDs - Added `TLGlobalRecordPropsMap` interface for module augmentation - Added `TLDefaultRecord`, `TLCustomRecord`, `TLIndexedRecords` types - Changed `TLRecord` to union of `TLDefaultRecord | TLCustomRecord` - Changed `createTLSchema()` to accept `records` option - Changed `TLStoreSchemaOptions` to accept `records` option ### Release notes - Add support for custom record types in the tldraw store, allowing SDK users to persist and sync domain-specific data alongside built-in records. ### Code changes | Section | LOC change | | --------------- | ---------- | | Core code | +444 / -21 | | Automated files | +46 / -2 | | Documentation | +244 / -0 | <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds new extensibility points to `createTLSchema`/`createTLStore` and broadens the `TLRecord` type, which can affect validation/migrations and any code assuming only built-in record types. > > **Overview** > Adds support for registering **custom record types** in the tldraw store via a new `records` option on `createTLSchema` and `createTLStore`, including validation, scope, default properties, and optional migrations (with a guard to prevent collisions with built-in type names). > > Introduces a new `TLCustomRecord` API surface (`CustomRecordInfo`, `createCustomRecordId`, migration helpers, and `isCustomRecord`/`isCustomRecordId`) and updates `TLRecord` typing to include custom records via `TLGlobalRecordPropsMap` module augmentation. > > Adds a new examples page (`Custom records`) demonstrating a persisted/synced `marker` custom record rendered as a canvas overlay, plus regenerated API reports. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1e40a2b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent efe342c commit 0666ccb

9 files changed

Lines changed: 735 additions & 23 deletions

File tree

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { useCallback, useMemo } from 'react'
2+
import {
3+
CustomRecordInfo,
4+
T,
5+
Tldraw,
6+
Vec,
7+
createCustomRecordId,
8+
createCustomRecordMigrationIds,
9+
createCustomRecordMigrationSequence,
10+
createTLStore,
11+
isCustomRecord,
12+
track,
13+
useEditor,
14+
} from 'tldraw'
15+
import 'tldraw/tldraw.css'
16+
17+
// There's a guide at the bottom of this file!
18+
19+
// [1]
20+
const MARKER_TYPE = 'marker'
21+
interface Marker {
22+
id: string
23+
typeName: typeof MARKER_TYPE
24+
x: number
25+
y: number
26+
label: string
27+
icon: string
28+
}
29+
30+
// [2]
31+
const markerVersions = createCustomRecordMigrationIds(MARKER_TYPE, {
32+
AddIcon: 1,
33+
})
34+
35+
// [3]
36+
const markerRecord: CustomRecordInfo = {
37+
scope: 'document',
38+
validator: T.object({
39+
id: T.string,
40+
typeName: T.literal(MARKER_TYPE),
41+
x: T.number,
42+
y: T.number,
43+
label: T.string,
44+
icon: T.string,
45+
}),
46+
migrations: createCustomRecordMigrationSequence({
47+
sequence: [
48+
{
49+
id: markerVersions.AddIcon,
50+
up: (record) => {
51+
record.icon = '📍'
52+
},
53+
down: (record) => {
54+
delete record.icon
55+
},
56+
},
57+
],
58+
}),
59+
createDefaultProperties: () => ({
60+
x: 0,
61+
y: 0,
62+
label: '',
63+
icon: '📍',
64+
}),
65+
}
66+
67+
// [4]
68+
function createMarkerId(id?: string) {
69+
return createCustomRecordId(MARKER_TYPE, id)
70+
}
71+
72+
const ICONS = ['📍', '⭐', '🏠', '🏢', '🎯', '⚠️']
73+
74+
// [5]
75+
const MarkerOverlay = track(function MarkerOverlay() {
76+
const editor = useEditor()
77+
78+
const markers = editor.store
79+
.allRecords()
80+
.filter((r) => isCustomRecord(MARKER_TYPE, r)) as any as Marker[]
81+
82+
const addMarker = useCallback(() => {
83+
const label = prompt('Marker label:')
84+
if (!label) return
85+
const center = editor.getViewportScreenCenter()
86+
const point = editor.screenToPage(center)
87+
editor.store.put([
88+
{
89+
id: createMarkerId(),
90+
typeName: MARKER_TYPE,
91+
x: point.x,
92+
y: point.y,
93+
label,
94+
icon: ICONS[Math.floor(Math.random() * ICONS.length)],
95+
} as any,
96+
])
97+
}, [editor])
98+
99+
return (
100+
<>
101+
{markers.map((marker) => {
102+
const screenPoint = editor.pageToViewport(new Vec(marker.x, marker.y))
103+
return (
104+
<div
105+
key={marker.id}
106+
style={{
107+
position: 'absolute',
108+
left: screenPoint.x,
109+
top: screenPoint.y,
110+
transform: 'translate(-50%, -100%)',
111+
display: 'flex',
112+
flexDirection: 'column',
113+
alignItems: 'center',
114+
pointerEvents: 'all',
115+
cursor: 'pointer',
116+
}}
117+
title={marker.label}
118+
onPointerDown={(e) => {
119+
e.stopPropagation()
120+
if (e.button === 2 || e.ctrlKey) {
121+
editor.store.remove([marker.id as any])
122+
}
123+
}}
124+
>
125+
<span style={{ fontSize: 28 }}>{marker.icon}</span>
126+
<span
127+
style={{
128+
fontSize: 11,
129+
background: 'white',
130+
border: '1px solid #ccc',
131+
borderRadius: 4,
132+
padding: '1px 4px',
133+
whiteSpace: 'nowrap',
134+
maxWidth: 120,
135+
overflow: 'hidden',
136+
textOverflow: 'ellipsis',
137+
}}
138+
>
139+
{marker.label}
140+
</span>
141+
</div>
142+
)
143+
})}
144+
<button
145+
onClick={addMarker}
146+
style={{
147+
position: 'absolute',
148+
top: 50,
149+
right: 10,
150+
zIndex: 1000,
151+
padding: '6px 12px',
152+
borderRadius: 6,
153+
border: '1px solid #ccc',
154+
background: 'white',
155+
cursor: 'pointer',
156+
fontSize: 14,
157+
}}
158+
>
159+
+ Add marker
160+
</button>
161+
</>
162+
)
163+
})
164+
165+
// [6]
166+
export default function CustomRecordsExample() {
167+
const store = useMemo(
168+
() =>
169+
createTLStore({
170+
records: { [MARKER_TYPE]: markerRecord },
171+
}),
172+
[]
173+
)
174+
175+
return (
176+
<div className="tldraw__editor">
177+
<Tldraw
178+
store={store}
179+
components={{
180+
InFrontOfTheCanvas: MarkerOverlay,
181+
}}
182+
/>
183+
</div>
184+
)
185+
}
186+
187+
/*
188+
Introduction:
189+
190+
You can add custom record types to the tldraw store to persist and synchronize
191+
domain-specific data that doesn't fit into shapes, bindings, or assets. This example
192+
adds a "marker" record type — like a map pin that marks a location on the canvas.
193+
194+
[1]
195+
Define your record's type name and TypeScript type. The record must have `id` and
196+
`typeName` fields — these are required by the store system.
197+
198+
[2]
199+
Use `createCustomRecordMigrationIds` to define versioned migration IDs for your record
200+
type. These follow the convention `com.tldraw.{typeName}/{version}`.
201+
202+
[3]
203+
Create a CustomRecordInfo configuration object. This tells the store how to handle
204+
your record type:
205+
- `scope`: 'document' records are persisted and synced. 'session' records are local only.
206+
- `validator`: Validates the record structure using tldraw's validation library.
207+
- `migrations`: Optional. Define how the record evolves over time using
208+
`createCustomRecordMigrationSequence`. Each migration has an `id` (from the version ids),
209+
an `up` function to add/transform fields, and an optional `down` function for backwards
210+
compatibility. If omitted, an empty migration sequence is created automatically.
211+
- `createDefaultProperties`: Factory for default property values.
212+
213+
[4]
214+
A helper to create properly formatted record IDs. Record IDs follow the pattern
215+
`typeName:uniqueId`.
216+
217+
[5]
218+
A React component that renders markers on the canvas and provides a button to add new
219+
ones. We use the `track` wrapper so the component re-renders when the store changes.
220+
We use `isCustomRecord` to filter records by type, and `pageToViewport` to position
221+
the markers correctly as the camera moves. Right-click (or ctrl-click) a marker to
222+
remove it.
223+
224+
[6]
225+
We create a store with our custom record type using `createTLStore` and pass it to
226+
Tldraw via the `store` prop. The `records` option registers our marker type alongside
227+
the built-in record types (shapes, assets, etc.).
228+
229+
*/
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
title: Custom records
3+
component: ./CustomRecordsExample.tsx
4+
category: data/assets
5+
priority: 1
6+
keywords: [record, store, custom, data]
7+
---
8+
9+
Add custom record types to the store.
10+
11+
---
12+
13+
You can add custom record types to the tldraw store to persist and synchronize
14+
domain-specific data alongside shapes, bindings, and other built-in records.
15+
This example adds a "marker" record type for pinning locations on the canvas.

packages/editor/api-report.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { AtomSet } from '@tldraw/store';
99
import { BoxModel } from '@tldraw/tlschema';
1010
import { ComponentType } from 'react';
1111
import { Computed } from '@tldraw/state';
12+
import { CustomRecordInfo } from '@tldraw/tlschema';
1213
import { Dispatch } from 'react';
1314
import { Editor as Editor_2 } from '@tiptap/core';
1415
import { EditorProviderProps as EditorProviderProps_2 } from '@tiptap/react';
@@ -4472,6 +4473,7 @@ export type TLStoreOptions = TLStoreBaseOptions & {
44724473
export type TLStoreSchemaOptions = {
44734474
bindingUtils?: readonly TLAnyBindingUtilConstructor[];
44744475
migrations?: readonly MigrationSequence[];
4476+
records?: Record<string, CustomRecordInfo>;
44754477
shapeUtils?: readonly TLAnyShapeUtilConstructor[];
44764478
} | {
44774479
schema?: StoreSchema<TLRecord, TLStoreProps>;

packages/editor/src/lib/config/createTLStore.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Signal } from '@tldraw/state'
22
import { HistoryEntry, MigrationSequence, SerializedStore, Store, StoreSchema } from '@tldraw/store'
33
import {
4+
CustomRecordInfo,
45
SchemaPropsInfo,
56
TLAssetStore,
67
TLRecord,
@@ -42,6 +43,7 @@ export type TLStoreSchemaOptions =
4243
shapeUtils?: readonly TLAnyShapeUtilConstructor[]
4344
migrations?: readonly MigrationSequence[]
4445
bindingUtils?: readonly TLAnyBindingUtilConstructor[]
46+
records?: Record<string, CustomRecordInfo>
4547
}
4648

4749
/** @public */
@@ -88,6 +90,7 @@ export function createTLSchemaFromUtils(
8890
'bindingUtils' in opts && opts.bindingUtils
8991
? utilsToMap(checkBindings(opts.bindingUtils))
9092
: undefined,
93+
records: 'records' in opts ? opts.records : undefined,
9194
migrations: 'migrations' in opts ? opts.migrations : undefined,
9295
})
9396
}

packages/tlschema/api-report.api.md

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { MakeUndefinedOptional } from '@tldraw/utils';
1313
import { MigrationId } from '@tldraw/store';
1414
import { MigrationSequence } from '@tldraw/store';
1515
import { RecordId } from '@tldraw/store';
16+
import { RecordScope } from '@tldraw/store';
1617
import { RecordType } from '@tldraw/store';
1718
import { SerializedStore } from '@tldraw/store';
1819
import { Signal } from '@tldraw/state';
@@ -157,6 +158,17 @@ export function createBindingValidator<Type extends string, Props extends JsonOb
157158
[K in keyof Meta]: T.Validatable<Meta[K]>;
158159
}): T.ObjectValidator<Expand< { [P in "fromId" | "id" | "meta" | "toId" | "typeName" | (undefined extends Props ? never : "props") | (undefined extends Type ? never : "type")]: TLBaseBinding<Type, Props>[P]; } & { [P in (undefined extends Props ? "props" : never) | (undefined extends Type ? "type" : never)]?: TLBaseBinding<Type, Props>[P] | undefined; }>>;
159160

161+
// @public
162+
export function createCustomRecordId<T extends string>(typeName: T, id?: string): RecordId<UnknownRecord> & `${T}:${string}`;
163+
164+
// @public
165+
export function createCustomRecordMigrationIds<const S extends string, const T extends Record<string, number>>(recordType: S, ids: T): {
166+
[k in keyof T]: `com.tldraw.${S}/${T[k]}`;
167+
};
168+
169+
// @public
170+
export function createCustomRecordMigrationSequence(migrations: TLPropsMigrations): TLPropsMigrations;
171+
160172
// @public
161173
export function createPresenceStateDerivation($user: Signal<TLPresenceUserInfo>, instanceId?: TLInstancePresence['id']): (store: TLStore) => Signal<null | TLInstancePresence, unknown>;
162174

@@ -179,12 +191,21 @@ export function createShapeValidator<Type extends string, Props extends JsonObje
179191
}): T.ObjectValidator<Expand< { [P in "id" | "index" | "isLocked" | "meta" | "opacity" | "parentId" | "rotation" | "typeName" | "x" | "y" | (undefined extends Props ? never : "props") | (undefined extends Type ? never : "type")]: TLBaseShape<Type, Props>[P]; } & { [P in (undefined extends Props ? "props" : never) | (undefined extends Type ? "type" : never)]?: TLBaseShape<Type, Props>[P] | undefined; }>>;
180192

181193
// @public
182-
export function createTLSchema({ shapes, bindings, migrations }?: {
194+
export function createTLSchema({ shapes, bindings, records, migrations }?: {
183195
bindings?: Record<string, SchemaPropsInfo>;
184196
migrations?: readonly MigrationSequence[];
197+
records?: Record<string, CustomRecordInfo>;
185198
shapes?: Record<string, SchemaPropsInfo>;
186199
}): TLSchema;
187200

201+
// @public
202+
export interface CustomRecordInfo {
203+
createDefaultProperties?: () => Record<string, unknown>;
204+
migrations?: MigrationSequence | TLPropsMigrations;
205+
scope: RecordScope;
206+
validator: T.Validatable<any>;
207+
}
208+
188209
// @public
189210
export const defaultBindingSchemas: {
190211
arrow: {
@@ -419,6 +440,12 @@ export function isBinding(record?: UnknownRecord): record is TLBinding;
419440
// @public
420441
export function isBindingId(id?: string): id is TLBindingId;
421442

443+
// @public
444+
export function isCustomRecord(typeName: string, record?: UnknownRecord): boolean;
445+
446+
// @public
447+
export function isCustomRecordId(typeName: string, id?: string): boolean;
448+
422449
// @public
423450
export function isDocument(record?: UnknownRecord): record is TLDocument;
424451

@@ -930,6 +957,9 @@ export interface TLCursor {
930957
// @public
931958
export type TLCursorType = SetValue<typeof TL_CURSOR_TYPES>;
932959

960+
// @public
961+
export type TLCustomRecord = TLIndexedRecords[keyof TLIndexedRecords];
962+
933963
// @public
934964
export type TLDefaultBinding = TLArrowBinding;
935965

@@ -988,6 +1018,9 @@ export type TLDefaultFontStyle = T.TypeOf<typeof DefaultFontStyle>;
9881018
// @public
9891019
export type TLDefaultHorizontalAlignStyle = T.TypeOf<typeof DefaultHorizontalAlignStyle>;
9901020

1021+
// @public
1022+
export type TLDefaultRecord = TLAsset | TLBinding | TLCamera | TLDocument | TLInstance | TLInstancePageState | TLInstancePresence | TLPage | TLPointer | TLShape;
1023+
9911024
// @public
9921025
export type TLDefaultShape = TLArrowShape | TLBookmarkShape | TLDrawShape | TLEmbedShape | TLFrameShape | TLGeoShape | TLGroupShape | TLHighlightShape | TLImageShape | TLLineShape | TLNoteShape | TLTextShape | TLVideoShape;
9931026

@@ -1084,6 +1117,10 @@ export interface TLGeoShapeProps {
10841117
export interface TLGlobalBindingPropsMap {
10851118
}
10861119

1120+
// @public
1121+
export interface TLGlobalRecordPropsMap {
1122+
}
1123+
10871124
// @public (undocumented)
10881125
export interface TLGlobalShapePropsMap {
10891126
}
@@ -1162,6 +1199,11 @@ export type TLIndexedBindings = {
11621199
}> : TLBaseBinding<K, TLGlobalBindingPropsMap[K & keyof TLGlobalBindingPropsMap]>;
11631200
};
11641201

1202+
// @public
1203+
export type TLIndexedRecords = {
1204+
[K in keyof TLGlobalRecordPropsMap as TLGlobalRecordPropsMap[K] extends null | undefined ? never : K]: TLGlobalRecordPropsMap[K];
1205+
};
1206+
11651207
// @public (undocumented)
11661208
export type TLIndexedShapes = {
11671209
[K in keyof TLGlobalShapePropsMap | TLDefaultShape['type'] as K extends TLDefaultShape['type'] ? K extends 'group' ? K : K extends keyof TLGlobalShapePropsMap ? TLGlobalShapePropsMap[K] extends null | undefined ? never : K : K : K]: K extends 'group' ? Extract<TLDefaultShape, {
@@ -1421,7 +1463,7 @@ export interface TLPropsMigrations {
14211463
}
14221464

14231465
// @public
1424-
export type TLRecord = TLAsset | TLBinding | TLCamera | TLDocument | TLInstance | TLInstancePageState | TLInstancePresence | TLPage | TLPointer | TLShape;
1466+
export type TLRecord = TLCustomRecord | TLDefaultRecord;
14251467

14261468
// @public
14271469
export type TLRichText = T.TypeOf<typeof richTextValidator>;

0 commit comments

Comments
 (0)