snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1,657 @@
import { computed } from '@tldraw/state'
import { Store } from '@tldraw/store'
import { annotateError, IndexKey, structuredClone } from '@tldraw/utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createTLSchema } from './createTLSchema'
import { CameraRecordType } from './records/TLCamera'
import { TLDOCUMENT_ID } from './records/TLDocument'
import { createInstanceRecordType, TLINSTANCE_ID } from './records/TLInstance'
import { PageRecordType, TLPageId } from './records/TLPage'
import { InstancePageStateRecordType } from './records/TLPageState'
import { TLPOINTER_ID } from './records/TLPointer'
import { TLRecord } from './records/TLRecord'
import { TLShapeId } from './records/TLShape'
import {
createIntegrityChecker,
onValidationFailure,
redactRecordForErrorReporting,
TLAssetStore,
TLStoreProps,
} from './TLStore'
// Mock dependencies
vi.mock('@tldraw/utils', async () => {
const actual = await vi.importActual('@tldraw/utils')
return {
...actual,
annotateError: vi.fn(),
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
}
})
describe('TLStore utility functions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('redactRecordForErrorReporting', () => {
it('should redact src field from asset record', () => {
const assetRecord = {
id: 'asset:test',
typeName: 'asset',
type: 'image',
src: 'https://secret.com/image.png',
props: {
src: 'https://secret.com/props-image.png',
width: 100,
height: 100,
},
}
redactRecordForErrorReporting(assetRecord)
expect(assetRecord.src).toBe('<redacted>')
expect(assetRecord.props.src).toBe('<redacted>')
expect(assetRecord.props.width).toBe(100) // Other props should remain unchanged
expect(assetRecord.props.height).toBe(100)
})
it('should redact only props.src if top-level src does not exist', () => {
const assetRecord = {
id: 'asset:test',
typeName: 'asset',
type: 'video',
props: {
src: 'https://secret.com/video.mp4',
width: 200,
height: 150,
},
}
redactRecordForErrorReporting(assetRecord)
expect(assetRecord.props.src).toBe('<redacted>')
expect(assetRecord.props.width).toBe(200)
expect(assetRecord.props.height).toBe(150)
})
it('should not modify non-asset records', () => {
const shapeRecord = {
id: 'shape:test',
typeName: 'shape',
type: 'geo',
x: 100,
y: 200,
props: {
color: 'red',
size: 'medium',
},
}
const originalRecord = JSON.parse(JSON.stringify(shapeRecord))
redactRecordForErrorReporting(shapeRecord)
expect(shapeRecord).toEqual(originalRecord)
})
it('should handle asset records without src fields gracefully', () => {
const assetRecord = {
id: 'asset:test',
typeName: 'asset',
type: 'bookmark',
props: {
title: 'Test Bookmark',
description: 'A test bookmark',
},
}
const originalRecord = JSON.parse(JSON.stringify(assetRecord))
redactRecordForErrorReporting(assetRecord)
expect(assetRecord).toEqual(originalRecord)
})
it('should handle asset records with only top-level src', () => {
const assetRecord = {
id: 'asset:test',
typeName: 'asset',
type: 'image',
src: 'https://secret.com/image.png',
props: {
width: 100,
height: 100,
},
}
redactRecordForErrorReporting(assetRecord)
expect(assetRecord.src).toBe('<redacted>')
expect(assetRecord.props.width).toBe(100)
expect(assetRecord.props.height).toBe(100)
})
})
})
describe('onValidationFailure', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should annotate error with correct tags and extras', () => {
const mockError = new Error('Test validation error')
const record = {
id: 'shape:test',
typeName: 'shape',
type: 'geo',
x: 100,
y: 200,
} as any
const recordBefore = {
id: 'shape:test',
typeName: 'shape',
type: 'geo',
x: 50,
y: 150,
} as any
const validationFailure = {
error: mockError,
phase: 'updateRecord' as const,
record,
recordBefore,
store: {} as any, // Required by StoreValidationFailure interface
}
expect(() => onValidationFailure(validationFailure)).toThrow(mockError)
expect(annotateError).toHaveBeenCalledWith(
mockError,
expect.objectContaining({
tags: expect.objectContaining({
origin: 'store.validateRecord',
storePhase: 'updateRecord',
isExistingValidationIssue: false,
}),
})
)
})
it('should mark initialize phase as existing validation issue', () => {
const mockError = new Error('Initialize error')
const record = { id: 'test:1', typeName: 'test' } as any
const validationFailure = {
error: mockError,
phase: 'initialize' as const,
record,
recordBefore: null,
store: {} as any,
}
expect(() => onValidationFailure(validationFailure)).toThrow(mockError)
expect(annotateError).toHaveBeenCalledWith(
mockError,
expect.objectContaining({
tags: expect.objectContaining({
isExistingValidationIssue: true,
}),
})
)
})
it('should handle missing recordBefore', () => {
const mockError = new Error('No record before')
const record = { id: 'test:new', typeName: 'test' } as any
const validationFailure = {
error: mockError,
phase: 'createRecord' as const,
record,
recordBefore: null,
store: {} as any,
}
expect(() => onValidationFailure(validationFailure)).toThrow(mockError)
expect(annotateError).toHaveBeenCalledWith(
mockError,
expect.objectContaining({
tags: expect.objectContaining({
origin: 'store.validateRecord',
storePhase: 'createRecord',
isExistingValidationIssue: false,
}),
})
)
})
it('should redact asset records in error reporting', () => {
const mockError = new Error('Asset validation error')
const assetRecord = {
id: 'asset:image',
typeName: 'asset',
type: 'image',
src: 'https://secret.com/image.png',
props: {
src: 'https://secret.com/props-image.png',
width: 100,
height: 100,
},
} as any
const validationFailure = {
error: mockError,
phase: 'createRecord' as const,
record: assetRecord,
recordBefore: null,
store: {} as any,
}
expect(() => onValidationFailure(validationFailure)).toThrow(mockError)
// The function should call annotateError, and redaction happens internally
expect(annotateError).toHaveBeenCalledWith(
mockError,
expect.objectContaining({
tags: expect.objectContaining({
origin: 'store.validateRecord',
storePhase: 'createRecord',
isExistingValidationIssue: false,
}),
})
)
})
it('should handle different validation phases correctly', () => {
const phases = ['initialize', 'createRecord', 'updateRecord', 'tests'] as const
const mockError = new Error('Phase test')
const record = { id: 'test:phase', typeName: 'test' } as any
phases.forEach((phase) => {
const validationFailure = {
error: mockError,
phase,
record,
recordBefore: null,
store: {} as any,
}
expect(() => onValidationFailure(validationFailure)).toThrow(mockError)
expect(annotateError).toHaveBeenCalledWith(
mockError,
expect.objectContaining({
tags: expect.objectContaining({
storePhase: phase,
isExistingValidationIssue: phase === 'initialize',
}),
})
)
vi.clearAllMocks()
})
})
it('should use structuredClone for records', () => {
const mockError = new Error('Clone test')
const record = { id: 'test:clone', typeName: 'test', nested: { prop: 'value' } } as any
const recordBefore = { id: 'test:clone', typeName: 'test', nested: { prop: 'old' } } as any
const validationFailure = {
error: mockError,
phase: 'updateRecord' as const,
record,
recordBefore,
store: {} as any,
}
expect(() => onValidationFailure(validationFailure)).toThrow(mockError)
expect(structuredClone).toHaveBeenCalledWith(record)
expect(structuredClone).toHaveBeenCalledWith(recordBefore)
})
})
describe('createIntegrityChecker', () => {
let store: Store<TLRecord, TLStoreProps>
let mockAssetStore: Required<TLAssetStore>
beforeEach(() => {
mockAssetStore = {
upload: vi.fn().mockResolvedValue({ src: 'uploaded-url' }),
resolve: vi.fn().mockResolvedValue('resolved-url'),
remove: vi.fn().mockResolvedValue(undefined),
} as Required<TLAssetStore>
const schema = createTLSchema()
store = new Store({
schema,
props: {
defaultName: 'Test Store',
assets: mockAssetStore,
users: {
currentUser: computed('currentUser', () => null),
resolve: () => computed('resolve', () => null),
},
onMount: vi.fn(),
},
})
})
afterEach(() => {
vi.clearAllMocks()
})
describe('document and pointer records', () => {
it('should create missing document record', () => {
// Remove document record if it exists
if (store.has(TLDOCUMENT_ID)) {
store.remove([TLDOCUMENT_ID])
}
const checker = createIntegrityChecker(store)
checker()
expect(store.has(TLDOCUMENT_ID)).toBe(true)
const document = store.get(TLDOCUMENT_ID)
expect(document).toBeDefined()
expect(document!.name).toBe('Test Store')
})
it('should create missing pointer record', () => {
// Remove pointer record if it exists
if (store.has(TLPOINTER_ID)) {
store.remove([TLPOINTER_ID])
}
const checker = createIntegrityChecker(store)
checker()
expect(store.has(TLPOINTER_ID)).toBe(true)
const pointer = store.get(TLPOINTER_ID)
expect(pointer).toBeDefined()
expect(pointer!.typeName).toBe('pointer')
})
})
describe('page management', () => {
it('should create default page when none exist', () => {
// Clear all pages
const pageIds = store.query.ids('page').get()
store.remove([...pageIds])
const checker = createIntegrityChecker(store)
checker()
const newPageIds = store.query.ids('page').get()
expect(newPageIds.size).toBe(1)
const page = store.get([...newPageIds][0]) as any
expect(page).toBeDefined()
expect(page!.name).toBe('Page 1')
expect(page!.index).toBe('a1')
})
it('should preserve existing pages', () => {
// First ensure we have at least one page
const checker = createIntegrityChecker(store)
checker()
const existingPageIds = store.query.ids('page').get()
expect(existingPageIds.size).toBeGreaterThan(0) // Should have at least one page
// Run checker again - should not change pages
checker()
const newPageIds = store.query.ids('page').get()
expect(newPageIds.size).toBe(existingPageIds.size)
})
})
describe('instance state management', () => {
it('should create missing instance state', () => {
// Remove instance if it exists
if (store.has(TLINSTANCE_ID)) {
store.remove([TLINSTANCE_ID])
}
// Ensure we have at least one page
const pageIds = store.query.ids('page').get()
if (pageIds.size === 0) {
store.put([
PageRecordType.create({
id: 'page:test' as TLPageId,
name: 'Test Page',
index: 'a1' as IndexKey,
meta: {},
}),
])
}
const checker = createIntegrityChecker(store)
checker()
expect(store.has(TLINSTANCE_ID)).toBe(true)
const instance = store.get(TLINSTANCE_ID)
expect(instance).toBeDefined()
expect(instance!.currentPageId).toBeDefined()
expect(instance!.exportBackground).toBe(true)
})
it('should default exportBackground to true when the instance omits it', () => {
const instance = createInstanceRecordType(new Map()).create({
id: TLINSTANCE_ID,
currentPageId: 'page:whatever' as TLPageId,
})
expect(instance.exportBackground).toBe(true)
})
it('should update instance to reference valid page when current page is invalid', () => {
// Create a valid page
const validPageId = 'page:valid' as TLPageId
store.put([
PageRecordType.create({
id: validPageId,
name: 'Valid Page',
index: 'a1' as IndexKey,
meta: {},
}),
])
// Create instance with invalid page reference
const invalidPageId = 'page:invalid' as TLPageId
store.put([
store.schema.types.instance.create({
id: TLINSTANCE_ID,
currentPageId: invalidPageId,
exportBackground: true,
}),
])
const checker = createIntegrityChecker(store)
checker()
const instance = store.get(TLINSTANCE_ID)
expect(instance!.currentPageId).toBe(validPageId)
})
})
describe('page state and camera management', () => {
it('should create missing page states for existing pages', () => {
// Create a page
const pageId = 'page:test' as TLPageId
store.put([
PageRecordType.create({
id: pageId,
name: 'Test Page',
index: 'a1' as IndexKey,
meta: {},
}),
])
// Remove any existing page state
const pageStateId = InstancePageStateRecordType.createId(pageId)
if (store.has(pageStateId)) {
store.remove([pageStateId])
}
const checker = createIntegrityChecker(store)
checker()
expect(store.has(pageStateId)).toBe(true)
const pageState = store.get(pageStateId)
expect(pageState).toBeDefined()
expect(pageState!.pageId).toBe(pageId)
})
it('should create missing cameras for existing pages', () => {
// Create a page
const pageId = 'page:test' as TLPageId
store.put([
PageRecordType.create({
id: pageId,
name: 'Test Page',
index: 'a1' as IndexKey,
meta: {},
}),
])
// Remove any existing camera
const cameraId = CameraRecordType.createId(pageId)
if (store.has(cameraId)) {
store.remove([cameraId])
}
const checker = createIntegrityChecker(store)
checker()
expect(store.has(cameraId)).toBe(true)
const camera = store.get(cameraId)
expect(camera).toBeDefined()
expect(camera!.id).toBe(cameraId)
})
})
describe('page state cleanup and validation', () => {
it('should remove page states for non-existent pages', () => {
// Create page state for non-existent page
const nonExistentPageId = 'page:nonexistent' as TLPageId
const orphanPageStateId = InstancePageStateRecordType.createId(nonExistentPageId)
store.put([
InstancePageStateRecordType.create({
id: orphanPageStateId,
pageId: nonExistentPageId,
}),
])
const checker = createIntegrityChecker(store)
checker()
expect(store.has(orphanPageStateId)).toBe(false)
})
it.each([
['croppingShapeId', 'shape:nonexistent' as TLShapeId, null],
['focusedGroupId', 'shape:nonexistent' as TLShapeId, null],
['hoveredShapeId', 'shape:nonexistent' as TLShapeId, null],
])('should clear invalid %s from page states', (fieldName, invalidValue, expectedValue) => {
const pageId = 'page:test' as TLPageId
const pageStateId = InstancePageStateRecordType.createId(pageId)
store.put([
PageRecordType.create({
id: pageId,
name: 'Test Page',
index: 'a1' as IndexKey,
meta: {},
}),
InstancePageStateRecordType.create({
id: pageStateId,
pageId: pageId,
[fieldName]: invalidValue,
}),
])
const checker = createIntegrityChecker(store)
checker()
const pageState = store.get(pageStateId)
expect(pageState![fieldName as keyof typeof pageState]).toBe(expectedValue)
})
it.each([
['selectedShapeIds', ['shape:nonexistent1', 'shape:nonexistent2'] as TLShapeId[]],
['hintingShapeIds', ['shape:hint1', 'shape:hint2'] as TLShapeId[]],
['erasingShapeIds', ['shape:erase1', 'shape:erase2'] as TLShapeId[]],
])('should filter invalid %s from page states', (fieldName, invalidShapeIds) => {
const pageId = 'page:test' as TLPageId
const pageStateId = InstancePageStateRecordType.createId(pageId)
store.put([
PageRecordType.create({
id: pageId,
name: 'Test Page',
index: 'a1' as IndexKey,
meta: {},
}),
InstancePageStateRecordType.create({
id: pageStateId,
pageId: pageId,
[fieldName]: invalidShapeIds,
}),
])
const checker = createIntegrityChecker(store)
checker()
const pageState = store.get(pageStateId)
expect(pageState![fieldName as keyof typeof pageState]).toEqual([])
})
})
describe('recursive integrity checking', () => {
it('should recursively call itself when making corrections', () => {
// Start with empty store - this will trigger multiple corrections
store.clear()
const checker = createIntegrityChecker(store)
// This should not throw or hang - it should complete successfully
expect(() => checker()).not.toThrow()
// Verify final state is valid
expect(store.has(TLDOCUMENT_ID)).toBe(true)
expect(store.has(TLPOINTER_ID)).toBe(true)
expect(store.query.ids('page').get().size).toBe(1)
expect(store.has(TLINSTANCE_ID)).toBe(true)
})
it('should handle complex integrity violations in sequence', () => {
// Create a scenario with multiple integrity issues
store.clear()
// Add a page without required instance/document/pointer records
const pageId = 'page:orphan' as TLPageId
store.put([
PageRecordType.create({
id: pageId,
name: 'Orphan Page',
index: 'a1' as IndexKey,
meta: {},
}),
])
const checker = createIntegrityChecker(store)
checker()
// All required records should now exist
expect(store.has(TLDOCUMENT_ID)).toBe(true)
expect(store.has(TLPOINTER_ID)).toBe(true)
expect(store.has(TLINSTANCE_ID)).toBe(true)
expect(store.has(InstancePageStateRecordType.createId(pageId))).toBe(true)
expect(store.has(CameraRecordType.createId(pageId))).toBe(true)
})
})
})

View File

@@ -0,0 +1,570 @@
import { Signal, computed } from '@tldraw/state'
import {
SerializedStore,
Store,
StoreSchema,
StoreSnapshot,
StoreValidationFailure,
} from '@tldraw/store'
import { IndexKey, JsonObject, annotateError, sortByIndex, structuredClone } from '@tldraw/utils'
import { TLAsset, TLAssetId } from './records/TLAsset'
import { CameraRecordType, TLCameraId } from './records/TLCamera'
import { DocumentRecordType, TLDOCUMENT_ID } from './records/TLDocument'
import { TLINSTANCE_ID } from './records/TLInstance'
import { PageRecordType, TLPageId } from './records/TLPage'
import { InstancePageStateRecordType, TLInstancePageStateId } from './records/TLPageState'
import { PointerRecordType, TLPOINTER_ID } from './records/TLPointer'
import { TLRecord } from './records/TLRecord'
import { TLUser } from './records/TLUser'
/**
* Redacts the source of an asset record for error reporting.
*
* @param record - The asset record to redact
* @returns The redacted record
* @internal
*/
export function redactRecordForErrorReporting(record: any) {
if (record.typeName === 'asset') {
if ('src' in record) {
record.src = '<redacted>'
}
if ('src' in record.props) {
record.props.src = '<redacted>'
}
}
}
/**
* The complete schema type for a tldraw store, defining the structure and validation rules
* for all tldraw records and store properties.
*
* @public
* @example
* ```ts
* import { createTLSchema } from '@tldraw/tlschema'
*
* const schema = createTLSchema()
* const storeSchema: TLStoreSchema = schema
* ```
*/
export type TLStoreSchema = StoreSchema<TLRecord, TLStoreProps>
/**
* A serialized representation of a tldraw store that can be persisted or transmitted.
* Contains all store records in a JSON-serializable format.
*
* @public
* @example
* ```ts
* // Serialize a store
* const serializedStore: TLSerializedStore = store.serialize()
*
* // Save to localStorage
* localStorage.setItem('drawing', JSON.stringify(serializedStore))
* ```
*/
export type TLSerializedStore = SerializedStore<TLRecord>
/**
* A snapshot of a tldraw store at a specific point in time, containing all records
* and metadata. Used for persistence, synchronization, and creating store backups.
*
* @public
* @example
* ```ts
* // Create a snapshot
* const snapshot: TLStoreSnapshot = store.getSnapshot()
*
* // Restore from snapshot
* store.loadSnapshot(snapshot)
* ```
*/
export type TLStoreSnapshot = StoreSnapshot<TLRecord>
/**
* Context information provided when resolving asset URLs, containing details about
* the current rendering environment and user's connection to optimize asset delivery.
*
* @public
* @example
* ```ts
* const assetStore: TLAssetStore = {
* async resolve(asset, context: TLAssetContext) {
* // Use low resolution for slow connections
* if (context.networkEffectiveType === 'slow-2g') {
* return `${asset.props.src}?quality=low`
* }
* // Use high DPI version for retina displays
* if (context.dpr > 1) {
* return `${asset.props.src}@2x`
* }
* return asset.props.src
* }
* }
* ```
*/
export interface TLAssetContext {
/**
* The scale at which the asset is being rendered on-screen relative to its native dimensions.
* If the asset is 1000px wide, but it's been resized/zoom so it takes 500px on-screen, this
* will be 0.5.
*
* The scale measures CSS pixels, not device pixels.
*/
screenScale: number
/** The {@link TLAssetContext.screenScale}, stepped to the nearest power-of-2 multiple. */
steppedScreenScale: number
/** The device pixel ratio - how many CSS pixels are in one device pixel? */
dpr: number
/**
* An alias for
* {@link https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/effectiveType | `navigator.connection.effectiveType` }
* if it's available in the current browser. Use this to e.g. serve lower-resolution images to
* users on slow connections.
*/
networkEffectiveType: string | null
/**
* In some circumstances, we need to resolve a URL that points to the original version of a
* particular asset. This is used when the asset will leave the current tldraw instance - e.g.
* for copy/paste, or exports.
*/
shouldResolveToOriginal: boolean
}
/**
* Interface for storing and managing assets (images, videos, etc.) in tldraw.
* Provides methods for uploading, resolving, and removing assets from storage.
*
* A `TLAssetStore` sits alongside the main {@link TLStore} and is responsible for storing and
* retrieving large assets such as images. Generally, this should be part of a wider sync system:
*
* - By default, the store is in-memory only, so `TLAssetStore` converts images to data URLs
* - When using
* {@link @tldraw/editor#TldrawEditorWithoutStoreProps.persistenceKey | `persistenceKey`}, the
* store is synced to the browser's local IndexedDB, so `TLAssetStore` stores images there too
* - When using a multiplayer sync server, you would implement `TLAssetStore` to upload images to
* e.g. an S3 bucket.
*
* @public
* @example
* ```ts
* // Simple in-memory asset store
* const assetStore: TLAssetStore = {
* async upload(asset, file) {
* const dataUrl = await fileToDataUrl(file)
* return { src: dataUrl }
* },
*
* async resolve(asset, context) {
* return asset.props.src
* },
*
* async remove(assetIds) {
* // Clean up if needed
* }
* }
* ```
*/
export interface TLAssetStore {
/**
* Upload an asset to your storage, returning a URL that can be used to refer to the asset
* long-term.
*
* @param asset - Information & metadata about the asset being uploaded
* @param file - The `File` to be uploaded
* @returns A promise that resolves to the URL of the uploaded asset
*/
upload(
asset: TLAsset,
file: File,
abortSignal?: AbortSignal
): Promise<{ src: string; meta?: JsonObject }>
/**
* Resolve an asset to a URL. This is used when rendering the asset in the editor. By default,
* this will just use `asset.props.src`, the URL returned by `upload()`. This can be used to
* rewrite that URL to add access credentials, or optimized the asset for how it's currently
* being displayed using the {@link TLAssetContext | information provided}.
*
* @param asset - the asset being resolved
* @param ctx - information about the current environment and where the asset is being used
* @returns The URL of the resolved asset, or `null` if the asset is not available
*/
resolve?(asset: TLAsset, ctx: TLAssetContext): Promise<string | null> | string | null
/**
* Remove an asset from storage. This is called when the asset is no longer needed, e.g. when
* the user deletes it from the editor.
* @param asset - the asset being removed
* @returns A promise that resolves when the asset has been removed
*/
remove?(assetIds: TLAssetId[]): Promise<void>
}
/**
* Interface for resolving user information in tldraw.
*
* A `TLUserStore` sits alongside the main {@link TLStore} and provides user
* resolution for attribution labels and display names. Implement this interface
* to connect tldraw to your auth/user system.
*
* `currentUser` and `resolve` are reactive {@link @tldraw/state#Signal | Signals}
* so that the editor can automatically track changes to user data and
* re-render when a user's name, color, or avatar updates.
*
* Implementations should cache signals returned by `resolve` — e.g. return the
* same `Signal` for repeated calls with the same `userId` — to avoid
* unnecessary re-computation.
*
* @public
* @example
* ```ts
* const currentUser = computed('currentUser', () =>
* UserRecordType.create({
* id: createUserId(myAuth.userId),
* name: myAuth.displayName,
* color: myAuth.color,
* })
* )
*
* const userStore: TLUserStore = {
* currentUser,
* resolve(userId) {
* return computed('resolve-' + userId, () =>
* myUserCache.get(userId) ?? null
* )
* },
* }
* ```
*/
export interface TLUserStore {
/**
* A signal resolving to the currently authenticated user,
* or `null` for anonymous / unknown.
* Read when stamping attribution on shape create/update.
*/
currentUser: Signal<TLUser | null>
/**
* Return a signal resolving an arbitrary user ID to display info.
* Called when rendering attribution labels for shapes that may have been
* created or edited by someone else.
* The signal's value should be `null` if the user cannot be resolved.
*/
resolve?(userId: string): Signal<TLUser | null>
}
/**
* Create a cached {@link TLUserStore.resolve} implementation.
*
* Wraps a reactive lookup function so that each `userId` gets a single
* stable {@link @tldraw/state#Signal | Signal} that is reused across calls.
* The `resolveFn` is evaluated inside a `computed`, so any `.get()` calls
* it makes are automatically tracked.
*
* @param resolveFn - A function that resolves a raw user-ID string to a
* {@link TLUser} or `null`. Called reactively inside a `computed`.
* @returns A function suitable for use as `TLUserStore.resolve`.
*
* @example
* ```ts
* const users: TLUserStore = {
* currentUser: currentUserSignal,
* resolve: createCachedUserResolve(
* (userId) => usersAtom.get()[createUserId(userId)] ?? null
* ),
* }
* ```
*
* @public
*/
export function createCachedUserResolve(
resolveFn: (userId: string) => TLUser | null
): (userId: string) => Signal<TLUser | null> {
const cache = new Map<string, Signal<TLUser | null>>()
return (userId: string) => {
let signal = cache.get(userId)
if (!signal) {
signal = computed('resolve-user-' + userId, () => resolveFn(userId))
cache.set(userId, signal)
}
return signal
}
}
/**
* Configuration properties for a tldraw store, defining its behavior and integrations.
* These props are passed when creating a new store instance.
*
* @public
* @example
* ```ts
* const storeProps: TLStoreProps = {
* defaultName: 'My Drawing',
* assets: myAssetStore,
* onMount: (editor) => {
* console.log('Editor mounted')
* return () => console.log('Editor unmounted')
* },
* collaboration: {
* status: statusSignal,
* mode: modeSignal
* }
* }
*
* const store = new Store({ schema, props: storeProps })
* ```
*/
export interface TLStoreProps {
/** Default name for new documents created in this store */
defaultName: string
/** Asset store implementation for handling file uploads and storage */
assets: Required<TLAssetStore>
/** User store implementation for user resolution and attribution */
users: Required<TLUserStore>
/**
* Called when an {@link @tldraw/editor#Editor} connected to this store is mounted.
* Can optionally return a cleanup function that will be called when unmounted.
*
* @param editor - The editor instance that was mounted
* @returns Optional cleanup function
*/
onMount(editor: unknown): void | (() => void)
/** Optional collaboration configuration for multiplayer features */
collaboration?: {
/** Signal indicating online/offline collaboration status */
status: Signal<'online' | 'offline'> | null
/** Signal indicating collaboration mode permissions */
mode?: Signal<'readonly' | 'readwrite'> | null
}
}
/**
* The main tldraw store type, representing a reactive database of tldraw records
* with associated store properties. This is the central data structure that holds
* all shapes, assets, pages, and user state.
*
* @public
* @example
* ```ts
* import { Store } from '@tldraw/store'
* import { createTLSchema } from '@tldraw/tlschema'
*
* const schema = createTLSchema()
* const store: TLStore = new Store({
* schema,
* props: {
* defaultName: 'Untitled',
* assets: myAssetStore,
* onMount: () => console.log('Store mounted')
* }
* })
* ```
*/
export type TLStore = Store<TLRecord, TLStoreProps>
/**
* Default validation failure handler for tldraw stores. This function is called
* when a record fails validation during store operations. It annotates errors
* with debugging information and determines whether to allow invalid records
* during store initialization.
*
* @param options - The validation failure details
* - error - The validation error that occurred
* - phase - The store operation phase when validation failed
* - record - The invalid record that caused the failure
* - recordBefore - The previous state of the record (if applicable)
* @returns The record to use (typically throws the annotated error)
* @throws The original validation error with additional debugging context
*
* @public
* @example
* ```ts
* const store = new Store({
* schema,
* props: storeProps,
* onValidationFailure // Use this as the validation failure handler
* })
*
* // The handler will be called automatically when validation fails
* try {
* store.put([invalidRecord])
* } catch (error) {
* // Error will contain debugging information added by onValidationFailure
* }
* ```
*/
export function onValidationFailure({
error,
phase,
record,
recordBefore,
}: StoreValidationFailure<TLRecord>): TLRecord {
const isExistingValidationIssue =
// if we're initializing the store for the first time, we should
// allow invalid records so people can load old buggy data:
phase === 'initialize'
annotateError(error, {
tags: {
origin: 'store.validateRecord',
storePhase: phase,
isExistingValidationIssue,
},
extras: {
recordBefore: recordBefore
? redactRecordForErrorReporting(structuredClone(recordBefore))
: undefined,
recordAfter: redactRecordForErrorReporting(structuredClone(record)),
},
})
throw error
}
function getDefaultPages() {
return [
PageRecordType.create({
id: 'page:page' as TLPageId,
name: 'Page 1',
index: 'a1' as IndexKey,
meta: {},
}),
]
}
/**
* Creates an integrity checker function that ensures the tldraw store maintains
* a consistent and usable state. The checker validates that required records exist
* and relationships between records are maintained.
*
* The integrity checker ensures:
* - Document and pointer records exist
* - At least one page exists
* - Instance state references valid pages
* - Page states and cameras exist for all pages
* - Shape references in page states are valid
*
* @param store - The tldraw store to check for integrity
* @returns A function that when called, validates and fixes store integrity
*
* @internal
* @example
* ```ts
* const checker = createIntegrityChecker(store)
*
* // Run integrity check (typically called automatically)
* checker()
*
* // The checker will create missing records and fix invalid references
* ```
*/
export function createIntegrityChecker(store: Store<TLRecord, TLStoreProps>): () => void {
const $pageIds = store.query.ids('page')
const $pageStates = store.query.records('instance_page_state')
const ensureStoreIsUsable = (): void => {
// make sure we have exactly one document
if (!store.has(TLDOCUMENT_ID)) {
store.put([DocumentRecordType.create({ id: TLDOCUMENT_ID, name: store.props.defaultName })])
return ensureStoreIsUsable()
}
if (!store.has(TLPOINTER_ID)) {
store.put([PointerRecordType.create({ id: TLPOINTER_ID })])
return ensureStoreIsUsable()
}
// make sure there is at least one page
const pageIds = $pageIds.get()
if (pageIds.size === 0) {
store.put(getDefaultPages())
return ensureStoreIsUsable()
}
const getFirstPageId = () => [...pageIds].map((id) => store.get(id)!).sort(sortByIndex)[0].id!
// make sure we have state for the current user's current tab
const instanceState = store.get(TLINSTANCE_ID)
if (!instanceState) {
store.put([
store.schema.types.instance.create({
id: TLINSTANCE_ID,
currentPageId: getFirstPageId(),
}),
])
return ensureStoreIsUsable()
} else if (!pageIds.has(instanceState.currentPageId)) {
store.put([{ ...instanceState, currentPageId: getFirstPageId() }])
return ensureStoreIsUsable()
}
// make sure we have page states and cameras for all the pages
const missingPageStateIds = new Set<TLInstancePageStateId>()
const missingCameraIds = new Set<TLCameraId>()
for (const id of pageIds) {
const pageStateId = InstancePageStateRecordType.createId(id)
const pageState = store.get(pageStateId)
if (!pageState) {
missingPageStateIds.add(pageStateId)
}
const cameraId = CameraRecordType.createId(id)
if (!store.has(cameraId)) {
missingCameraIds.add(cameraId)
}
}
if (missingPageStateIds.size > 0) {
store.put(
[...missingPageStateIds].map((id) =>
InstancePageStateRecordType.create({
id,
pageId: InstancePageStateRecordType.parseId(id) as TLPageId,
})
)
)
}
if (missingCameraIds.size > 0) {
store.put([...missingCameraIds].map((id) => CameraRecordType.create({ id })))
}
const pageStates = $pageStates.get()
for (const pageState of pageStates) {
if (!pageIds.has(pageState.pageId)) {
store.remove([pageState.id])
continue
}
if (pageState.croppingShapeId && !store.has(pageState.croppingShapeId)) {
store.put([{ ...pageState, croppingShapeId: null }])
return ensureStoreIsUsable()
}
if (pageState.focusedGroupId && !store.has(pageState.focusedGroupId)) {
store.put([{ ...pageState, focusedGroupId: null }])
return ensureStoreIsUsable()
}
if (pageState.hoveredShapeId && !store.has(pageState.hoveredShapeId)) {
store.put([{ ...pageState, hoveredShapeId: null }])
return ensureStoreIsUsable()
}
const filteredSelectedIds = pageState.selectedShapeIds.filter((id) => store.has(id))
if (filteredSelectedIds.length !== pageState.selectedShapeIds.length) {
store.put([{ ...pageState, selectedShapeIds: filteredSelectedIds }])
return ensureStoreIsUsable()
}
const filteredHintingIds = pageState.hintingShapeIds.filter((id) => store.has(id))
if (filteredHintingIds.length !== pageState.hintingShapeIds.length) {
store.put([{ ...pageState, hintingShapeIds: filteredHintingIds }])
return ensureStoreIsUsable()
}
const filteredErasingIds = pageState.erasingShapeIds.filter((id) => store.has(id))
if (filteredErasingIds.length !== pageState.erasingShapeIds.length) {
store.put([{ ...pageState, erasingShapeIds: filteredErasingIds }])
return ensureStoreIsUsable()
}
}
}
return ensureStoreIsUsable
}

View File

@@ -0,0 +1,58 @@
import { devFreeze, Migration, MigrationId } from '@tldraw/store'
import { mockUniqueId, structuredClone } from '@tldraw/utils'
import { createTLSchema } from '../createTLSchema'
let nextNanoId = 0
mockUniqueId(() => `nanoid_${++nextNanoId}`)
export const testSchema = createTLSchema()
// Records which migrator fns ran, so migrations.test.ts can assert every one is covered by a
// test. This deliberately isn't a vi.fn: vitest clears mock call history before each test, which
// would wipe the record before the coverage check runs.
const calledMigrators = new Set<string>()
/** Whether any test in this run exercised the given migrator. */
export function wasMigratorCalled(id: MigrationId, direction: 'up' | 'down') {
return calledMigrators.has(`${id}/${direction}`)
}
for (const migration of testSchema.sortedMigrations) {
for (const direction of ['up', 'down'] as const) {
const fn = migration[direction]
if (typeof fn !== 'function') continue
;(migration as any)[direction] = (...args: any[]) => {
calledMigrators.add(`${migration.id}/${direction}`)
return (fn as any)(...args)
}
}
}
export function getTestMigration(migrationId: MigrationId) {
const migration = testSchema.sortedMigrations.find((m) => m.id === migrationId) as Migration
if (!migration) {
throw new Error(`Migration ${migrationId} not found`)
}
return {
id: migrationId,
up: (stuff: any) => {
nextNanoId = 0
if (migration.scope === 'record' || migration.scope === 'store') {
const result = structuredClone(stuff)
return migration.up(result) ?? result
}
const storage =
typeof stuff.entries === 'function' ? stuff : new Map(Object.entries(stuff).map(devFreeze))
migration.up(storage)
return typeof stuff.entries === 'function' ? storage : Object.fromEntries(storage.entries())
},
down: (stuff: any) => {
nextNanoId = 0
if (typeof migration.down !== 'function') {
throw new Error(`Migration ${migrationId} does not have a down function`)
}
const result = structuredClone(stuff)
return migration.down(result) ?? result
},
}
}

View File

@@ -0,0 +1,124 @@
import { BaseRecord } from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
import { TLAssetId } from '../records/TLAsset'
/**
* Base interface for all asset records in tldraw. Assets represent external resources
* like images, videos, or bookmarks that shapes can reference. This interface extends
* the base record system with asset-specific typing.
*
* @param Type - The specific asset type identifier (e.g., 'image', 'video', 'bookmark')
* @param Props - The properties object specific to this asset type
*
* @example
* ```ts
* // Define a custom asset type
* interface MyCustomAsset extends TLBaseAsset<'custom', { url: string; title: string }> {}
*
* const customAsset: MyCustomAsset = {
* id: 'asset:custom123',
* typeName: 'asset',
* type: 'custom',
* props: {
* url: 'https://example.com',
* title: 'My Custom Asset'
* },
* meta: {}
* }
* ```
*
* @public
*/
export interface TLBaseAsset<Type extends string, Props> extends BaseRecord<'asset', TLAssetId> {
/** The specific type of this asset (e.g., 'image', 'video', 'bookmark') */
type: Type
/** Type-specific properties for this asset */
props: Props
/** User-defined metadata that can be attached to this asset */
meta: JsonObject
}
/**
* A validator for asset record type IDs. This validator ensures that asset IDs
* follow the correct format and type structure required by tldraw's asset system.
* Asset IDs are prefixed with 'asset:' followed by a unique identifier.
*
* @example
* ```ts
* import { assetIdValidator } from '@tldraw/tlschema'
*
* // Valid asset ID
* const validId = 'asset:abc123'
* console.log(assetIdValidator.isValid(validId)) // true
*
* // Invalid asset ID
* const invalidId = 'shape:abc123'
* console.log(assetIdValidator.isValid(invalidId)) // false
* ```
*
* @public
*/
export const assetIdValidator = idValidator<TLAssetId>('asset')
/**
* Creates a validator for a specific asset record type. This factory function generates
* a complete validator that validates the entire asset record structure including the
* base properties (id, typeName, type, meta) and the type-specific props.
*
* @param type - The asset type identifier (e.g., 'image', 'video', 'bookmark')
* @param props - A validator or per-key validator record for the asset's type-specific properties
* @param meta - An optional per-key validator record for the asset's meta properties
* @returns A complete validator for the asset record type
*
* @example
* ```ts
* import { createAssetValidator, TLBaseAsset } from '@tldraw/tlschema'
* import { T } from '@tldraw/validate'
*
* // Define a custom asset type
* type TLCustomAsset = TLBaseAsset<'custom', {
* url: string
* title: string
* description?: string
* }>
*
* // Create validator using a per-key record (recommended)
* const customAssetValidator = createAssetValidator('custom', {
* url: T.string,
* title: T.string,
* description: T.string.optional()
* })
*
* // Or using a T.object validator
* const customAssetValidator2 = createAssetValidator('custom', T.object({
* url: T.string,
* title: T.string,
* description: T.string.optional()
* }))
* ```
*
* @public
*/
export function createAssetValidator<
Type extends string,
Props extends JsonObject,
Meta extends JsonObject = JsonObject,
>(
type: Type,
props?: T.Validator<Props> | { [K in keyof Props]: T.Validatable<Props[K]> },
meta?: { [K in keyof Meta]: T.Validatable<Meta[K]> }
) {
// Determine if props is a Validator instance or a per-key record
const propsValidator =
props instanceof T.Validator ? props : props ? T.object(props) : (T.jsonValue as any)
return T.object<TLBaseAsset<Type, Props>>({
id: assetIdValidator,
typeName: T.literal('asset'),
type: T.literal(type),
props: propsValidator,
meta: meta ? T.object(meta) : (T.jsonValue as T.ObjectValidator<JsonObject>),
})
}

View File

@@ -0,0 +1,99 @@
import { createMigrationIds, createRecordMigrationSequence } from '@tldraw/store'
import { T } from '@tldraw/validate'
import { RecordProps } from '../recordsWithProps'
import { TLBaseAsset, createAssetValidator } from './TLBaseAsset'
/**
* An asset used for URL bookmarks, used by the TLBookmarkShape.
*
* @public */
export type TLBookmarkAsset = TLBaseAsset<
'bookmark',
{
title: string
description: string
image: string
favicon: string
src: string | null
}
>
/** @public */
export const bookmarkAssetProps = {
title: T.string,
description: T.string,
image: T.string,
favicon: T.string,
src: T.srcUrl.nullable(),
} satisfies RecordProps<TLBookmarkAsset>
/** Validator for bookmark assets. @public */
export const bookmarkAssetValidator: T.Validator<TLBookmarkAsset> = createAssetValidator(
'bookmark',
T.object(bookmarkAssetProps)
)
const Versions = createMigrationIds('com.tldraw.asset.bookmark', {
MakeUrlsValid: 1,
AddFavicon: 2,
} as const)
/**
* Migration version identifiers for bookmark assets. These versions track
* the evolution of the bookmark asset schema over time.
*
* Available versions:
* - `MakeUrlsValid` (v1): Ensures src URLs are valid or empty
* - `AddFavicon` (v2): Adds favicon property to bookmark assets
*
* @example
* ```ts
* import { bookmarkAssetVersions } from '@tldraw/tlschema'
*
* // Check if a migration exists
* console.log(bookmarkAssetVersions.AddFavicon) // 2
* ```
*
* @public
*/
export { Versions as bookmarkAssetVersions }
/**
* Migration sequence for bookmark assets. Handles the evolution of bookmark asset
* data structure over time, ensuring backward and forward compatibility.
*
* The migration sequence includes:
* 1. **MakeUrlsValid** (v1): Validates and cleans up src URLs, setting invalid URLs to empty string
* 2. **AddFavicon** (v2): Adds the favicon property and validates it, setting invalid favicons to empty string
*
* @public
*/
export const bookmarkAssetMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.asset.bookmark',
recordType: 'asset',
filter: (asset) => (asset as TLBookmarkAsset).type === 'bookmark',
sequence: [
{
id: Versions.MakeUrlsValid,
up: (asset: any) => {
if (!T.srcUrl.isValid(asset.props.src)) {
asset.props.src = ''
}
},
down: (_asset) => {
// noop
},
},
{
id: Versions.AddFavicon,
up: (asset: any) => {
if (!T.srcUrl.isValid(asset.props.favicon)) {
asset.props.favicon = ''
}
},
down: (asset: any) => {
delete asset.props.favicon
},
},
],
})

View File

@@ -0,0 +1,155 @@
import { createMigrationIds, createRecordMigrationSequence } from '@tldraw/store'
import { T } from '@tldraw/validate'
import { RecordProps } from '../recordsWithProps'
import { TLBaseAsset, createAssetValidator } from './TLBaseAsset'
/**
* An asset for images such as PNGs and JPEGs, used by the TLImageShape.
*
* @public */
export type TLImageAsset = TLBaseAsset<
'image',
{
w: number
h: number
name: string
isAnimated: boolean
mimeType: string | null
src: string | null
fileSize?: number
pixelRatio?: number
}
>
/** @public */
export const imageAssetProps = {
w: T.number,
h: T.number,
name: T.string,
isAnimated: T.boolean,
mimeType: T.string.nullable(),
src: T.srcUrl.nullable(),
fileSize: T.nonZeroNumber.optional(),
pixelRatio: T.positiveNumber.optional(),
} satisfies RecordProps<TLImageAsset>
/** Validator for image assets. @public */
export const imageAssetValidator: T.Validator<TLImageAsset> = createAssetValidator(
'image',
T.object(imageAssetProps)
)
const Versions = createMigrationIds('com.tldraw.asset.image', {
AddIsAnimated: 1,
RenameWidthHeight: 2,
MakeUrlsValid: 3,
AddFileSize: 4,
MakeFileSizeOptional: 5,
AddPixelRatio: 6,
} as const)
/**
* Migration version identifiers for image assets. These define the different schema versions
* that image assets have gone through during the evolution of the tldraw data model.
*
* @example
* ```ts
* import { imageAssetVersions } from '@tldraw/tlschema'
*
* // Access specific version IDs
* console.log(imageAssetVersions.AddIsAnimated) // Version when isAnimated was added
* console.log(imageAssetVersions.RenameWidthHeight) // Version when width/height became w/h
* ```
*
* @public
*/
export { Versions as imageAssetVersions }
/**
* Migration sequence for image assets. Handles the evolution of the image asset schema
* over time, providing both forward (up) and backward (down) migration functions to
* maintain compatibility across different versions of the tldraw data model.
*
* The sequence includes migrations for:
* - Adding the `isAnimated` property to track animated images
* - Renaming `width`/`height` properties to shorter `w`/`h` names
* - Ensuring URLs are valid format
* - Adding file size tracking
* - Making file size optional
*
*
* @public
*/
export const imageAssetMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.asset.image',
recordType: 'asset',
filter: (asset) => (asset as TLImageAsset).type === 'image',
sequence: [
{
id: Versions.AddIsAnimated,
up: (asset: any) => {
asset.props.isAnimated = false
},
down: (asset: any) => {
delete asset.props.isAnimated
},
},
{
id: Versions.RenameWidthHeight,
up: (asset: any) => {
asset.props.w = asset.props.width
asset.props.h = asset.props.height
delete asset.props.width
delete asset.props.height
},
down: (asset: any) => {
asset.props.width = asset.props.w
asset.props.height = asset.props.h
delete asset.props.w
delete asset.props.h
},
},
{
id: Versions.MakeUrlsValid,
up: (asset: any) => {
if (!T.srcUrl.isValid(asset.props.src)) {
asset.props.src = ''
}
},
down: (_asset) => {
// noop
},
},
{
id: Versions.AddFileSize,
up: (asset: any) => {
asset.props.fileSize = -1
},
down: (asset: any) => {
delete asset.props.fileSize
},
},
{
id: Versions.MakeFileSizeOptional,
up: (asset: any) => {
if (asset.props.fileSize === -1) {
asset.props.fileSize = undefined
}
},
down: (asset: any) => {
if (asset.props.fileSize === undefined) {
asset.props.fileSize = -1
}
},
},
{
id: Versions.AddPixelRatio,
up: (_asset: any) => {
// noop — pixelRatio is optional and undefined by default
},
down: (asset: any) => {
delete asset.props.pixelRatio
},
},
],
})

View File

@@ -0,0 +1,175 @@
import { createMigrationIds, createRecordMigrationSequence } from '@tldraw/store'
import { T } from '@tldraw/validate'
import { RecordProps } from '../recordsWithProps'
import { TLBaseAsset, createAssetValidator } from './TLBaseAsset'
/**
* An asset record representing video files that can be displayed in video shapes.
* Video assets store metadata about video files including dimensions, MIME type,
* animation status, and file source information. They are referenced by TLVideoShape
* instances to display video content on the canvas.
*
* @example
* ```ts
* import { TLVideoAsset } from '@tldraw/tlschema'
*
* const videoAsset: TLVideoAsset = {
* id: 'asset:video123',
* typeName: 'asset',
* type: 'video',
* props: {
* w: 1920,
* h: 1080,
* name: 'my-video.mp4',
* isAnimated: true,
* mimeType: 'video/mp4',
* src: 'https://example.com/video.mp4',
* fileSize: 5242880
* },
* meta: {}
* }
* ```
*
* @public
*/
export type TLVideoAsset = TLBaseAsset<
'video',
{
/** The width of the video in pixels */
w: number
/** The height of the video in pixels */
h: number
/** The original filename or display name of the video */
name: string
/** Whether the video contains animation/motion (true for most videos) */
isAnimated: boolean
/** The MIME type of the video file (e.g., 'video/mp4', 'video/webm'), null if unknown */
mimeType: string | null
/** The source URL or data URI for the video file, null if not yet available */
src: string | null
/** The file size in bytes, optional for backward compatibility */
fileSize?: number
}
>
/** @public */
export const videoAssetProps = {
w: T.number,
h: T.number,
name: T.string,
isAnimated: T.boolean,
mimeType: T.string.nullable(),
src: T.srcUrl.nullable(),
fileSize: T.number.optional(),
} satisfies RecordProps<TLVideoAsset>
/** Validator for video assets. @public */
export const videoAssetValidator: T.Validator<TLVideoAsset> = createAssetValidator(
'video',
T.object(videoAssetProps)
)
const Versions = createMigrationIds('com.tldraw.asset.video', {
AddIsAnimated: 1,
RenameWidthHeight: 2,
MakeUrlsValid: 3,
AddFileSize: 4,
MakeFileSizeOptional: 5,
} as const)
/**
* Version identifiers for video asset migration sequences. These versions track
* the evolution of the video asset schema over time, enabling proper data migration
* when the asset structure changes.
*
* @example
* ```ts
* import { videoAssetVersions } from '@tldraw/tlschema'
*
* // Check the current version of a specific migration
* console.log(videoAssetVersions.AddFileSize) // 4
* ```
*
* @public
*/
export { Versions as videoAssetVersions }
/**
* Migration sequence for video assets that handles schema evolution over time.
* This sequence defines how video asset data should be transformed when upgrading
* or downgrading between different schema versions. Each migration step handles
* specific changes like adding properties, renaming fields, or changing data formats.
*
* The migrations handle:
* - Adding animation detection (isAnimated property)
* - Renaming width/height properties to w/h for consistency
* - Ensuring URL validity for src properties
* - Adding file size tracking
* - Making file size optional for backward compatibility
*
* @public
*/
export const videoAssetMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.asset.video',
recordType: 'asset',
filter: (asset) => (asset as TLVideoAsset).type === 'video',
sequence: [
{
id: Versions.AddIsAnimated,
up: (asset: any) => {
asset.props.isAnimated = false
},
down: (asset: any) => {
delete asset.props.isAnimated
},
},
{
id: Versions.RenameWidthHeight,
up: (asset: any) => {
asset.props.w = asset.props.width
asset.props.h = asset.props.height
delete asset.props.width
delete asset.props.height
},
down: (asset: any) => {
asset.props.width = asset.props.w
asset.props.height = asset.props.h
delete asset.props.w
delete asset.props.h
},
},
{
id: Versions.MakeUrlsValid,
up: (asset: any) => {
if (!T.srcUrl.isValid(asset.props.src)) {
asset.props.src = ''
}
},
down: (_asset) => {
// noop
},
},
{
id: Versions.AddFileSize,
up: (asset: any) => {
asset.props.fileSize = -1
},
down: (asset: any) => {
delete asset.props.fileSize
},
},
{
id: Versions.MakeFileSizeOptional,
up: (asset: any) => {
if (asset.props.fileSize === -1) {
asset.props.fileSize = undefined
}
},
down: (asset: any) => {
if (asset.props.fileSize === undefined) {
asset.props.fileSize = -1
}
},
},
],
})

View File

@@ -0,0 +1,185 @@
import { T } from '@tldraw/validate'
import { VecModel, vecModelValidator } from '../misc/geometry-types'
import {
createBindingPropsMigrationIds,
createBindingPropsMigrationSequence,
} from '../records/TLBinding'
import { RecordProps } from '../recordsWithProps'
import { arrowShapeVersions } from '../shapes/TLArrowShape'
import { TLBaseBinding } from './TLBaseBinding'
/**
* Defines the snapping behavior for elbow-style arrows when binding to shapes.
* Controls how the arrow segment aligns with the target shape's geometry.
*
* @example
* ```ts
* const binding: TLArrowBindingProps = {
* terminal: 'end',
* normalizedAnchor: { x: 0.5, y: 0.5 },
* isExact: false,
* isPrecise: true,
* snap: 'edge' // Snap to shape edge
* }
* ```
*
* @public
*/
export const ElbowArrowSnap = T.literalEnum('center', 'edge-point', 'edge', 'none')
/**
* Type representing the possible elbow arrow snap modes.
*
* - `'center'` - Snap to the center of the target shape
* - `'edge-point'` - Snap to a specific point on the shape's edge
* - `'edge'` - Snap to the nearest edge of the shape
* - `'none'` - No snapping behavior
*
* @public
*/
export type ElbowArrowSnap = T.TypeOf<typeof ElbowArrowSnap>
/**
* Properties that define how an arrow binds to a target shape.
* These properties control the visual and behavioral aspects of the arrow-to-shape connection.
*
* @example
* ```ts
* const arrowBindingProps: TLArrowBindingProps = {
* terminal: 'end', // Bind the arrow's end point
* normalizedAnchor: { x: 0.5, y: 0.0 }, // Bind to top center of shape
* isExact: true, // Arrow head enters the shape
* isPrecise: true, // Use exact anchor position
* snap: 'edge' // Snap to shape edge
* }
* ```
*
* @public
*/
export interface TLArrowBindingProps {
/** Which end of the arrow is bound - either 'start' or 'end' */
terminal: 'start' | 'end'
/**
* Normalized anchor point on the target shape (0,0 = top-left, 1,1 = bottom-right).
* Coordinates are relative to the shape's bounding box.
*/
normalizedAnchor: VecModel
/**
* Whether the arrow head 'enters' the bound shape to point directly at the binding
* anchor point. When true, the arrow head will be positioned inside the target shape.
*/
isExact: boolean
/**
* Whether to bind to the exact normalizedAnchor position, or to the center of the shape.
* When false, the arrow will connect to the shape's center regardless of anchor position.
*/
isPrecise: boolean
/** Snapping behavior for elbow-style arrows */
snap: ElbowArrowSnap
}
/**
* Validation schema for arrow binding properties.
* Defines the runtime validation rules for each property in TLArrowBindingProps.
*
* @example
* ```ts
* import { arrowBindingProps } from '@tldraw/tlschema'
*
* // Use in custom shape schema
* const customSchema = createTLSchema({
* bindings: {
* arrow: {
* props: arrowBindingProps,
* migrations: arrowBindingMigrations
* }
* }
* })
* ```
*
* @public
*/
export const arrowBindingProps: RecordProps<TLArrowBinding> = {
terminal: T.literalEnum('start', 'end'),
normalizedAnchor: vecModelValidator,
isExact: T.boolean,
isPrecise: T.boolean,
snap: ElbowArrowSnap,
}
/**
* Represents a binding relationship between an arrow shape and another shape.
* Arrow bindings allow arrows to connect to and follow other shapes, maintaining
* the connection even when shapes are moved or transformed.
*
* @example
* ```ts
* const arrowBinding: TLArrowBinding = {
* id: 'binding:abc123',
* typeName: 'binding',
* type: 'arrow',
* fromId: 'shape:arrow1', // The arrow shape
* toId: 'shape:rectangle1', // The target shape
* props: {
* terminal: 'end',
* normalizedAnchor: { x: 0.5, y: 0.5 },
* isExact: false,
* isPrecise: true,
* snap: 'edge'
* },
* meta: {}
* }
* ```
*
* @public
*/
export type TLArrowBinding = TLBaseBinding<'arrow', TLArrowBindingProps>
/**
* Version identifiers for arrow binding property migrations.
* Each version represents a schema change that requires data migration.
*
* @example
* ```ts
* // Check if migration is needed
* if (bindingVersion < arrowBindingVersions.AddSnap) {
* // Apply AddSnap migration
* }
* ```
*
* @public
*/
export const arrowBindingVersions = createBindingPropsMigrationIds('arrow', {
AddSnap: 1,
})
/**
* Migration sequence for arrow binding properties.
* Handles schema evolution over time by defining how to migrate data between versions.
*
* The sequence includes:
* - **AddSnap (v1)**: Adds the `snap` property with default value 'none'
*
* @example
* ```ts
* import { arrowBindingMigrations } from '@tldraw/tlschema'
*
* // Apply migrations when loading older data
* const migratedBinding = arrowBindingMigrations.migrate(oldBinding)
* ```
*
* @public
*/
export const arrowBindingMigrations = createBindingPropsMigrationSequence({
sequence: [
{ dependsOn: [arrowShapeVersions.ExtractBindings] },
{
id: arrowBindingVersions.AddSnap,
up: (props) => {
props.snap = 'none'
},
down: (props) => {
delete props.snap
},
},
],
})

View File

@@ -0,0 +1,189 @@
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
import { TLBindingId } from '../records/TLBinding'
import { TLShapeId } from '../records/TLShape'
import { shapeIdValidator } from '../shapes/TLBaseShape'
/**
* Base interface for all binding types in tldraw. Bindings represent relationships
* between shapes, such as arrows connecting to other shapes or organizational connections.
*
* All default bindings extend this base interface with specific type and property definitions.
* The binding system enables shapes to maintain relationships that persist through
* transformations, movements, and other operations.
*
* Custom bindings should be defined by augmenting the TLGlobalBindingPropsMap type and getting the binding type from the TLBinding type.
*
* @param Type - String literal type identifying the specific binding type (e.g., 'arrow')
* @param Props - Object containing binding-specific properties and configuration
*
* @example
* ```ts
* // Define a default binding type
* interface TLArrowBinding extends TLBaseBinding<'arrow', TLArrowBindingProps> {}
*
* interface TLArrowBindingProps {
* terminal: 'start' | 'end'
* normalizedAnchor: VecModel
* isExact: boolean
* isPrecise: boolean
* snap: ElbowArrowSnap
* }
*
* // Create a binding instance
* const arrowBinding: TLArrowBinding = {
* id: 'binding:abc123',
* typeName: 'binding',
* type: 'arrow',
* fromId: 'shape:source1',
* toId: 'shape:target1',
* props: {
* terminal: 'end',
* normalizedAnchor: { x: 0.5, y: 0.5 },
* isExact: false,
* isPrecise: true,
* snap: 'edge'
* },
* meta: {}
* }
* ```
*
* @public
*/
export interface TLBaseBinding<Type extends string, Props extends object> {
// using real `extends BaseRecord<'binding', TLBindingId>` introduces a circularity in the types
// and for that reason those "base members" have to be declared manually here
readonly id: TLBindingId
readonly typeName: 'binding'
/** The specific type of this binding (e.g., 'arrow', 'custom') */
type: Type
/** ID of the source shape in this binding relationship */
fromId: TLShapeId
/** ID of the target shape in this binding relationship */
toId: TLShapeId
/** Binding-specific properties that define behavior and appearance */
props: Props
/** User-defined metadata for extending binding functionality */
meta: JsonObject
}
/**
* Validator for binding IDs. Ensures that binding identifiers follow the correct
* format and type constraints required by the tldraw schema system.
*
* Used internally by the schema validation system to verify binding IDs when
* records are created or modified. All binding IDs must be prefixed with 'binding:'.
*
* @example
* ```ts
* import { bindingIdValidator } from '@tldraw/tlschema'
*
* // Validate a binding ID
* const isValid = bindingIdValidator.isValid('binding:abc123') // true
* const isInvalid = bindingIdValidator.isValid('shape:abc123') // false
*
* // Use in custom validation schema
* const customBindingValidator = T.object({
* id: bindingIdValidator,
* // ... other properties
* })
* ```
*
* @public
*/
export const bindingIdValidator = idValidator<TLBindingId>('binding')
/**
* Creates a runtime validator for a specific binding type. This factory function
* generates a complete validation schema for custom bindings that extends TLBaseBinding.
*
* The validator ensures all binding records conform to the expected structure with
* proper type safety and runtime validation. It validates the base binding properties
* (id, type, fromId, toId) along with custom props and meta fields.
*
* @param type - The string literal type identifier for this binding (e.g., 'arrow', 'custom')
* @param props - Optional validation schema for binding-specific properties
* @param meta - Optional validation schema for metadata fields
*
* @returns A validator object that can validate complete binding records
*
* @example
* ```ts
* import { createBindingValidator } from '@tldraw/tlschema'
* import { T } from '@tldraw/validate'
*
* // Create validator for a custom binding type
* const myBindingValidator = createBindingValidator(
* 'myBinding',
* {
* strength: T.number,
* color: T.string,
* enabled: T.boolean
* },
* {
* createdAt: T.number,
* author: T.string
* }
* )
*
* // Validate a binding instance
* const bindingData = {
* id: 'binding:123',
* typeName: 'binding',
* type: 'myBinding',
* fromId: 'shape:abc',
* toId: 'shape:def',
* props: {
* strength: 0.8,
* color: 'red',
* enabled: true
* },
* meta: {
* createdAt: Date.now(),
* author: 'user123'
* }
* }
*
* const isValid = myBindingValidator.isValid(bindingData) // true
* ```
*
* @example
* ```ts
* // Simple binding without custom props or meta
* const simpleBindingValidator = createBindingValidator('simple')
*
* // This will use JsonValue validation for props and meta
* const binding = {
* id: 'binding:456',
* typeName: 'binding',
* type: 'simple',
* fromId: 'shape:start',
* toId: 'shape:end',
* props: {}, // Any JSON value allowed
* meta: {} // Any JSON value allowed
* }
* ```
*
* @public
*/
export function createBindingValidator<
Type extends string,
Props extends JsonObject,
Meta extends JsonObject,
>(
type: Type,
props?: { [K in keyof Props]: T.Validatable<Props[K]> },
meta?: { [K in keyof Meta]: T.Validatable<Meta[K]> }
) {
return T.object<TLBaseBinding<Type, Props>>({
id: bindingIdValidator,
typeName: T.literal('binding'),
type: T.literal(type),
fromId: shapeIdValidator,
toId: shapeIdValidator,
props: props ? T.object(props) : (T.jsonValue as any),
meta: meta ? T.object(meta) : (T.jsonValue as any),
})
}

View File

@@ -0,0 +1,171 @@
import { atom, computed } from '@tldraw/state'
import { Store } from '@tldraw/store'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createPresenceStateDerivation,
getDefaultUserPresence,
} from './createPresenceStateDerivation'
import { createTLSchema } from './createTLSchema'
import { TLINSTANCE_ID } from './records/TLInstance'
import { TLPageId } from './records/TLPage'
import { InstancePageStateRecordType } from './records/TLPageState'
import { TLRecord } from './records/TLRecord'
import { createUserId, TLUser, UserRecordType } from './records/TLUser'
import { createIntegrityChecker, TLStoreProps } from './TLStore'
describe('createPresenceStateDerivation', () => {
let store: Store<TLRecord, TLStoreProps>
let userSignal: ReturnType<typeof atom<TLUser>>
beforeEach(() => {
const schema = createTLSchema()
store = new Store({
schema,
props: {
defaultName: 'Test Store',
assets: {
upload: vi.fn().mockResolvedValue({ src: 'uploaded-url' }),
resolve: vi.fn().mockResolvedValue('resolved-url'),
remove: vi.fn().mockResolvedValue(undefined),
},
users: {
currentUser: computed('currentUser', () => null),
resolve: () => computed('resolve', () => null),
},
onMount: vi.fn(),
},
})
// Initialize store with required records
const checker = createIntegrityChecker(store)
checker()
userSignal = atom(
'user',
UserRecordType.create({
id: createUserId('user123'),
name: 'Test User',
color: '#007AFF',
})
)
})
afterEach(() => {
vi.clearAllMocks()
})
describe('basic functionality', () => {
it('should create presence state and be reactive to changes', () => {
const derivation = createPresenceStateDerivation(userSignal)
const presenceSignal = derivation(store)
const presence = presenceSignal.get()
expect(presence!.userId).toBe('user:user123')
expect(presence!.userName).toBe('Test User')
expect(presence!.color).toBe('#007AFF')
// Update user signal
userSignal.set(
UserRecordType.create({
id: createUserId('user456'),
name: 'Updated User',
color: '#FF6B6B',
})
)
const updatedPresence = presenceSignal.get()
expect(updatedPresence!.userName).toBe('Updated User')
expect(updatedPresence!.color).toBe('#FF6B6B')
})
it('should return null when required records are missing', () => {
store.remove([TLINSTANCE_ID])
const derivation = createPresenceStateDerivation(userSignal)
expect(derivation(store).get()).toBe(null)
})
})
describe('store state integration', () => {
it('should be reactive to store changes', () => {
const derivation = createPresenceStateDerivation(userSignal)
const presenceSignal = derivation(store)
const pageId = [...store.query.ids('page').get()][0] as TLPageId
const pageState = store.get(InstancePageStateRecordType.createId(pageId))!
store.put([
{
...pageState,
selectedShapeIds: ['shape:test' as any],
},
])
const presence = presenceSignal.get()
expect(presence!.selectedShapeIds).toEqual(['shape:test'])
})
})
})
describe('getDefaultUserPresence', () => {
let store: Store<TLRecord, TLStoreProps>
beforeEach(() => {
const schema = createTLSchema()
store = new Store({
schema,
props: {
defaultName: 'Test Store',
assets: {
upload: vi.fn().mockResolvedValue({ src: 'uploaded-url' }),
resolve: vi.fn().mockResolvedValue('resolved-url'),
remove: vi.fn().mockResolvedValue(undefined),
},
users: {
currentUser: computed('currentUser', () => null),
resolve: () => computed('resolve', () => null),
},
onMount: vi.fn(),
},
})
// Initialize store with required records
const checker = createIntegrityChecker(store)
checker()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('basic functionality', () => {
it('should return presence state with user info and default values', () => {
const user = UserRecordType.create({
id: createUserId('test-user'),
name: 'Test User',
color: '#00FF00',
})
const presence = getDefaultUserPresence(store, user)
expect(presence!.userId).toBe('user:test-user')
expect(presence!.userName).toBe('Test User')
expect(presence!.color).toBe('#00FF00')
})
it('should use defaults for missing user fields', () => {
const minimalUser = UserRecordType.create({ id: createUserId('minimal') })
const presence = getDefaultUserPresence(store, minimalUser)
expect(presence!.userName).toBe('')
expect(presence!.color).toBe('#FF0000')
})
})
describe('error conditions', () => {
it('should return null when required records are missing', () => {
store.remove([TLINSTANCE_ID])
const user = UserRecordType.create({ id: createUserId('test') })
expect(getDefaultUserPresence(store, user)).toBe(null)
})
})
})

View File

@@ -0,0 +1,154 @@
import { Signal, computed } from '@tldraw/state'
import { CameraRecordType } from './records/TLCamera'
import { TLINSTANCE_ID } from './records/TLInstance'
import { InstancePageStateRecordType } from './records/TLPageState'
import { TLPOINTER_ID } from './records/TLPointer'
import { InstancePresenceRecordType, TLInstancePresence } from './records/TLPresence'
import { TLUser } from './records/TLUser'
import { TLStore } from './TLStore'
/** @public */
export interface CreatePresenceStateDerivationOpts {
/** Custom instance ID. If not provided, one is generated from the store ID. */
instanceId?: TLInstancePresence['id']
/**
* Override how presence state is built from the store and current user.
* Defaults to {@link getDefaultUserPresence}.
*/
getUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null
}
/**
* Creates a derivation that represents the current presence state of the current user.
*
* This function returns a derivation factory that, when given a store, creates a computed signal
* containing the user's current presence state. The presence state includes information like cursor
* position, selected shapes, camera position, and user metadata that gets synchronized in
* multiplayer scenarios.
*
* @param $user - A reactive signal containing the user information, or `null` when anonymous
* @param opts - Optional configuration for instance ID and presence derivation
* @returns A function that takes a store and returns a computed signal of the user's presence state
*
* @example
* ```ts
* import { createPresenceStateDerivation } from '@tldraw/tlschema'
* import { atom } from '@tldraw/state'
*
* const userSignal = atom('user', { id: 'user-123', name: 'Alice', color: '#ff0000', meta: {} })
* const presenceDerivation = createPresenceStateDerivation(userSignal)
*
* // Use with a store to get reactive presence state
* const presenceState = presenceDerivation(store)
* console.log(presenceState.get()) // Current user presence or null
* ```
*
* @public
*/
export function createPresenceStateDerivation(
$user: Signal<TLUser | null>,
opts?: CreatePresenceStateDerivationOpts
) {
const { instanceId, getUserPresence: _getUserPresence } = opts ?? {}
const getUserPresence = _getUserPresence ?? getDefaultUserPresence
return (store: TLStore): Signal<TLInstancePresence | null> => {
return computed('instancePresence', () => {
const user = $user.get()
if (!user) return null
const state = getUserPresence(store, user)
if (!state) return null
return InstancePresenceRecordType.create({
...state,
id: instanceId ?? InstancePresenceRecordType.createId(store.id),
})
})
}
}
/**
* The shape of data used to create a presence record.
*
* This type represents all the properties needed to construct a TLInstancePresence record.
* It includes user information, cursor state, camera position, selected shapes, and other
* presence-related data that gets synchronized across multiplayer clients.
*
* @public
*/
export type TLPresenceStateInfo = Parameters<(typeof InstancePresenceRecordType)['create']>[0]
/**
* Creates default presence state information for a user based on the current store state.
*
* This function extracts the current state from various store records (instance, page state,
* camera, pointer) and combines them with user information to create a complete presence
* state object. This is commonly used as a starting point for custom presence implementations.
*
* @param store - The tldraw store containing the current editor state
* @param user - The user information to include in the presence state
* @returns The default presence state info, or null if required store records are missing
*
* @example
* ```ts
* import { getDefaultUserPresence } from '@tldraw/tlschema'
*
* const user = { id: 'user-123', name: 'Alice', color: '#ff0000', meta: {} }
* const presenceInfo = getDefaultUserPresence(store, user)
*
* if (presenceInfo) {
* console.log('Current cursor:', presenceInfo.cursor)
* console.log('Selected shapes:', presenceInfo.selectedShapeIds)
* console.log('Camera position:', presenceInfo.camera)
* }
* ```
*
* @example
* ```ts
* // Common pattern: customize default presence
* const customPresence = {
* ...getDefaultUserPresence(store, user),
* // Remove camera for privacy
* camera: undefined,
* // Add custom metadata
* customField: 'my-data'
* }
* ```
*
* @public
*/
export function getDefaultUserPresence(store: TLStore, user: TLUser) {
const instance = store.get(TLINSTANCE_ID)
const pageState = store.get(InstancePageStateRecordType.createId(instance?.currentPageId))
const camera = store.get(CameraRecordType.createId(instance?.currentPageId))
const pointer = store.get(TLPOINTER_ID)
if (!pageState || !instance || !camera || !pointer) {
return null
}
return {
selectedShapeIds: pageState.selectedShapeIds,
brush: instance.brush,
scribbles: instance.scribbles,
userId: user.id,
userName: user.name,
followingUserId: instance.followingUserId,
camera: {
x: camera.x,
y: camera.y,
z: camera.z,
},
color: user.color || '#FF0000',
currentPageId: instance.currentPageId,
cursor: {
x: pointer.x,
y: pointer.y,
rotation: instance.cursor.rotation,
type: instance.cursor.type,
},
lastActivityTimestamp: pointer.lastActivityTimestamp,
screenBounds: instance.screenBounds,
chatMessage: instance.chatMessage,
meta: {},
} satisfies TLPresenceStateInfo
}

View File

@@ -0,0 +1,181 @@
import { createMigrationSequence, MigrationSequence, StoreSchema } from '@tldraw/store'
import { T } from '@tldraw/validate'
import { describe, expect, it } from 'vitest'
import { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } from './createTLSchema'
import { TLDefaultBinding } from './records/TLBinding'
import { TLDefaultShape } from './records/TLShape'
import { StyleProp } from './styles/StyleProp'
import { DefaultColorStyle } from './styles/TLColorStyle'
describe('defaultShapeSchemas', () => {
const expectedShapeTypes: Array<TLDefaultShape['type']> = [
'arrow',
'bookmark',
'draw',
'embed',
'frame',
'geo',
'group',
'highlight',
'image',
'line',
'note',
'text',
'video',
]
it('should contain all default shape types', () => {
const actualShapeTypes = Object.keys(defaultShapeSchemas)
expect(actualShapeTypes).toHaveLength(expectedShapeTypes.length)
for (const shapeType of expectedShapeTypes) {
expect(actualShapeTypes).toContain(shapeType)
}
})
it('should contain style properties in shape props', () => {
// Test a few known shapes that should have style properties
const geoProps = defaultShapeSchemas.geo.props
const arrowProps = defaultShapeSchemas.arrow.props
// Check that some props are StyleProp instances
let hasStyleProp = false
for (const prop of Object.values(geoProps || {})) {
if (prop instanceof StyleProp) {
hasStyleProp = true
break
}
}
expect(hasStyleProp).toBe(true)
// Check arrow props too
hasStyleProp = false
for (const prop of Object.values(arrowProps || {})) {
if (prop instanceof StyleProp) {
hasStyleProp = true
break
}
}
expect(hasStyleProp).toBe(true)
})
})
describe('defaultBindingSchemas', () => {
const expectedBindingTypes: Array<TLDefaultBinding['type']> = ['arrow']
it('should contain all default binding types', () => {
const actualBindingTypes = Object.keys(defaultBindingSchemas)
expect(actualBindingTypes).toHaveLength(expectedBindingTypes.length)
for (const bindingType of expectedBindingTypes) {
expect(actualBindingTypes).toContain(bindingType)
}
})
})
describe('createTLSchema', () => {
it('should create a valid schema with default parameters', () => {
const schema = createTLSchema()
expect(schema).toBeInstanceOf(StoreSchema)
expect(schema.types).toBeDefined()
expect(schema.migrations).toBeDefined()
})
it('should create schema with custom shapes', () => {
const customShapeProps = {
customProp: T.string,
color: DefaultColorStyle,
}
const customShapes = {
...defaultShapeSchemas,
custom: {
props: customShapeProps,
migrations: createMigrationSequence({
sequenceId: 'com.tldraw.shape.custom', // Use the expected format
sequence: [],
}),
},
}
const schema = createTLSchema({ shapes: customShapes })
expect(schema).toBeInstanceOf(StoreSchema)
expect(schema.types.shape).toBeDefined()
})
it('should create schema with custom bindings', () => {
const customBindingProps = {
customProp: T.number,
}
const customBindings = {
...defaultBindingSchemas,
custom: {
props: customBindingProps,
migrations: createMigrationSequence({
sequenceId: 'com.tldraw.binding.custom', // Use the expected format
sequence: [],
}),
},
}
const schema = createTLSchema({ bindings: customBindings })
expect(schema).toBeInstanceOf(StoreSchema)
expect(schema.types.binding).toBeDefined()
})
it('should create schema with additional migrations', () => {
const additionalMigrations: MigrationSequence[] = [
createMigrationSequence({
sequenceId: 'com.test.additional',
sequence: [],
}),
]
const schema = createTLSchema({ migrations: additionalMigrations })
expect(schema).toBeInstanceOf(StoreSchema)
expect(schema.migrations).toBeDefined()
})
it('should create schema with only specific shapes', () => {
const minimalShapes = {
geo: defaultShapeSchemas.geo,
text: defaultShapeSchemas.text,
arrow: defaultShapeSchemas.arrow, // Include arrow to satisfy binding dependencies
}
const schema = createTLSchema({ shapes: minimalShapes })
expect(schema).toBeInstanceOf(StoreSchema)
expect(schema.types.shape).toBeDefined()
})
describe('style property collection and validation', () => {
it('should throw error for duplicate style property IDs', () => {
// Create a duplicate style property with same ID as DefaultColorStyle
const duplicateColorStyle = StyleProp.defineEnum('tldraw:color', {
defaultValue: 'black',
values: ['black', 'white'],
})
const conflictingShapes = {
shape1: {
props: { color: DefaultColorStyle },
},
shape2: {
props: { color: duplicateColorStyle },
},
}
expect(() => createTLSchema({ shapes: conflictingShapes })).toThrow(
'Multiple StyleProp instances with the same id: tldraw:color'
)
})
})
})

View File

@@ -0,0 +1,437 @@
import { LegacyMigrations, MigrationSequence, StoreSchema, StoreValidator } from '@tldraw/store'
import { objectMapValues } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { TLBaseAsset } from './assets/TLBaseAsset'
import { bookmarkAssetMigrations, bookmarkAssetProps } from './assets/TLBookmarkAsset'
import { imageAssetMigrations, imageAssetProps } from './assets/TLImageAsset'
import { videoAssetMigrations, videoAssetProps } from './assets/TLVideoAsset'
import { arrowBindingMigrations, arrowBindingProps } from './bindings/TLArrowBinding'
import {
TLDefaultAsset,
TLUnknownAsset,
assetMigrations,
createAssetRecordType,
} from './records/TLAsset'
import { TLBinding, TLDefaultBinding, createBindingRecordType } from './records/TLBinding'
import { CameraRecordType, cameraMigrations } from './records/TLCamera'
import {
CustomRecordInfo,
createCustomRecordType,
processCustomRecordMigrations,
} from './records/TLCustomRecord'
import { DocumentRecordType, documentMigrations } from './records/TLDocument'
import { createInstanceRecordType, instanceMigrations } from './records/TLInstance'
import { PageRecordType, pageMigrations } from './records/TLPage'
import { InstancePageStateRecordType, instancePageStateMigrations } from './records/TLPageState'
import { PointerRecordType, pointerMigrations } from './records/TLPointer'
import { InstancePresenceRecordType, instancePresenceMigrations } from './records/TLPresence'
import { TLRecord } from './records/TLRecord'
import {
TLDefaultShape,
TLShape,
createShapeRecordType,
getShapePropKeysByStyle,
rootShapeMigrations,
} from './records/TLShape'
import { UserRecordType, createUserRecordType, userMigrations } from './records/TLUser'
import { RecordProps, TLPropsMigrations, processPropsMigrations } from './recordsWithProps'
import { arrowShapeMigrations, arrowShapeProps } from './shapes/TLArrowShape'
import { TLBaseShape } from './shapes/TLBaseShape'
import { bookmarkShapeMigrations, bookmarkShapeProps } from './shapes/TLBookmarkShape'
import { drawShapeMigrations, drawShapeProps } from './shapes/TLDrawShape'
import { embedShapeMigrations, embedShapeProps } from './shapes/TLEmbedShape'
import { frameShapeMigrations, frameShapeProps } from './shapes/TLFrameShape'
import { geoShapeMigrations, geoShapeProps } from './shapes/TLGeoShape'
import { groupShapeMigrations, groupShapeProps } from './shapes/TLGroupShape'
import { highlightShapeMigrations, highlightShapeProps } from './shapes/TLHighlightShape'
import { imageShapeMigrations, imageShapeProps } from './shapes/TLImageShape'
import { lineShapeMigrations, lineShapeProps } from './shapes/TLLineShape'
import { noteShapeMigrations, noteShapeProps } from './shapes/TLNoteShape'
import { textShapeMigrations, textShapeProps } from './shapes/TLTextShape'
import { videoShapeMigrations, videoShapeProps } from './shapes/TLVideoShape'
import { storeMigrations } from './store-migrations'
import { StyleProp } from './styles/StyleProp'
import { TLStoreProps, createIntegrityChecker, onValidationFailure } from './TLStore'
/**
* Configuration information for a schema type (shape, binding, or asset), including its properties,
* metadata, and migration sequences for data evolution over time.
*
* @public
* @example
* ```ts
* import { arrowShapeMigrations, arrowShapeProps } from './shapes/TLArrowShape'
*
* const myShapeSchema: SchemaPropsInfo = {
* migrations: arrowShapeMigrations,
* props: arrowShapeProps,
* meta: {
* customField: T.string,
* },
* }
* ```
*/
export interface SchemaPropsInfo {
/**
* Migration sequences for handling data evolution over time. Can be legacy migrations,
* props-specific migrations, or general migration sequences.
*/
migrations?: LegacyMigrations | TLPropsMigrations | MigrationSequence
/**
* Validation schema for the shape, binding, or asset properties.
*/
props?: Record<string, StoreValidator<any>>
/**
* Validation schema for metadata fields.
*/
meta?: Record<string, StoreValidator<any>>
}
/**
* The complete schema definition for a tldraw store, encompassing all record types,
* validation rules, and migration sequences. This schema defines the structure of
* the persistent data model used by tldraw.
*
* @public
* @example
* ```ts
* import { createTLSchema, defaultShapeSchemas } from '@tldraw/tlschema'
* import { Store } from '@tldraw/store'
*
* const schema: TLSchema = createTLSchema({
* shapes: defaultShapeSchemas,
* })
*
* const store = new Store({ schema })
* ```
*/
export type TLSchema = StoreSchema<TLRecord, TLStoreProps>
/**
* Default shape schema configurations for all built-in tldraw shape types.
* Each shape type includes its validation props and migration sequences.
*
* This object contains schema information for:
* - arrow: Directional lines that can bind to other shapes
* - bookmark: Website bookmark cards with preview information
* - draw: Freehand drawing paths created with drawing tools
* - embed: Embedded content from external services (YouTube, Figma, etc.)
* - frame: Container shapes for organizing content
* - geo: Geometric shapes (rectangles, ellipses, triangles, etc.)
* - group: Logical groupings of multiple shapes
* - highlight: Highlighting strokes from the highlighter tool
* - image: Raster image shapes referencing image assets
* - line: Multi-point lines and splines
* - note: Sticky note shapes with text content
* - text: Rich text shapes with formatting support
* - video: Video shapes referencing video assets
*
* @public
* @example
* ```ts
* import { createTLSchema, defaultShapeSchemas } from '@tldraw/tlschema'
*
* // Use all default shapes
* const schema = createTLSchema({
* shapes: defaultShapeSchemas,
* })
*
* // Use only specific default shapes
* const minimalSchema = createTLSchema({
* shapes: {
* geo: defaultShapeSchemas.geo,
* text: defaultShapeSchemas.text,
* },
* })
* ```
*/
export const defaultShapeSchemas = {
arrow: { migrations: arrowShapeMigrations, props: arrowShapeProps },
bookmark: { migrations: bookmarkShapeMigrations, props: bookmarkShapeProps },
draw: { migrations: drawShapeMigrations, props: drawShapeProps },
embed: { migrations: embedShapeMigrations, props: embedShapeProps },
frame: { migrations: frameShapeMigrations, props: frameShapeProps },
geo: { migrations: geoShapeMigrations, props: geoShapeProps },
group: { migrations: groupShapeMigrations, props: groupShapeProps },
highlight: { migrations: highlightShapeMigrations, props: highlightShapeProps },
image: { migrations: imageShapeMigrations, props: imageShapeProps },
line: { migrations: lineShapeMigrations, props: lineShapeProps },
note: { migrations: noteShapeMigrations, props: noteShapeProps },
text: { migrations: textShapeMigrations, props: textShapeProps },
video: { migrations: videoShapeMigrations, props: videoShapeProps },
} satisfies {
[T in TLDefaultShape['type']]: {
migrations: SchemaPropsInfo['migrations']
props: RecordProps<TLBaseShape<T, Extract<TLDefaultShape, { type: T }>['props']>>
}
}
/**
* Default binding schema configurations for all built-in tldraw binding types.
* Bindings represent relationships between shapes, such as arrows connected to shapes.
*
* Currently includes:
* - arrow: Bindings that connect arrow shapes to other shapes at specific anchor points
*
* @public
* @example
* ```ts
* import { createTLSchema, defaultBindingSchemas } from '@tldraw/tlschema'
*
* // Use default bindings
* const schema = createTLSchema({
* bindings: defaultBindingSchemas,
* })
*
* // Add custom binding alongside defaults
* const customSchema = createTLSchema({
* bindings: {
* ...defaultBindingSchemas,
* myCustomBinding: {
* props: myCustomBindingProps,
* migrations: myCustomBindingMigrations,
* },
* },
* })
* ```
*/
export const defaultBindingSchemas = {
arrow: { migrations: arrowBindingMigrations, props: arrowBindingProps },
} satisfies { [T in TLDefaultBinding['type']]: SchemaPropsInfo }
/**
* Default asset schema configurations for all built-in tldraw asset types.
*
* @public
* @example
* ```ts
* import { createTLSchema, defaultAssetSchemas } from '@tldraw/tlschema'
*
* const schema = createTLSchema({
* assets: defaultAssetSchemas,
* })
* ```
*/
export const defaultAssetSchemas = {
image: { migrations: imageAssetMigrations, props: imageAssetProps },
video: { migrations: videoAssetMigrations, props: videoAssetProps },
bookmark: { migrations: bookmarkAssetMigrations, props: bookmarkAssetProps },
} satisfies {
[T in TLDefaultAsset['type']]: {
migrations: SchemaPropsInfo['migrations']
props: RecordProps<TLBaseAsset<T, Extract<TLDefaultAsset, { type: T }>['props']>>
}
}
/**
* Configuration for extending the user record type with custom metadata
* validators and migration sequences.
*
* @example
* ```ts
* import { T } from '@tldraw/validate'
*
* const userSchema: UserSchemaInfo = {
* meta: {
* isAdmin: T.boolean,
* department: T.string,
* },
* }
* ```
*
* @public
*/
export interface UserSchemaInfo {
/**
* Validators for custom metadata fields on user records. Each field is
* treated as optional — user records without these fields remain valid,
* but when present, values are validated against the provided validators.
*/
meta?: Record<string, T.Validatable<any>>
/**
* Additional migration sequences for evolving custom user data over time.
*/
migrations?: readonly MigrationSequence[]
}
/**
* Creates a complete TLSchema for use with tldraw stores. This schema defines the structure,
* validation, and migration sequences for all record types in a tldraw application.
*
* The schema includes all core record types (pages, cameras, instances, etc.) plus the
* shape, binding, asset, and custom record types you specify. Style properties are
* automatically collected from all shapes to ensure consistency across the application.
*
* @param options - Configuration options for the schema
* - shapes - Shape schema configurations. Defaults to defaultShapeSchemas if not provided
* - bindings - Binding schema configurations. Defaults to defaultBindingSchemas if not provided
* - assets - Asset schema configurations. Defaults to defaultAssetSchemas if not provided
* - user - Custom user record configuration with meta validators and migrations
* - records - Custom record type configurations. These are additional record types beyond
* the built-in shapes, bindings, assets, etc.
* - migrations - Additional migration sequences to include in the schema
* @returns A complete TLSchema ready for use with Store creation
*
* @public
* @example
* ```ts
* import {
* createTLSchema,
* defaultShapeSchemas,
* defaultBindingSchemas,
* defaultAssetSchemas,
* } from '@tldraw/tlschema'
* import { Store } from '@tldraw/store'
*
* // Create schema with all default shapes, bindings, and assets
* const schema = createTLSchema()
*
* // Create schema with custom shapes added
* const customSchema = createTLSchema({
* shapes: {
* ...defaultShapeSchemas,
* myCustomShape: {
* props: myCustomShapeProps,
* migrations: myCustomShapeMigrations,
* },
* },
* bindings: defaultBindingSchemas,
* assets: defaultAssetSchemas,
* })
*
* // Create schema with custom user metadata
* const schemaWithCustomUser = createTLSchema({
* user: {
* meta: {
* isAdmin: T.boolean,
* department: T.string,
* },
* },
* })
*
* // Create schema with custom record types
* const schemaWithCustomRecords = createTLSchema({
* records: {
* comment: {
* scope: 'document',
* validator: T.object({
* id: T.string,
* typeName: T.literal('comment'),
* text: T.string,
* shapeId: T.string,
* }),
* },
* },
* })
*
* // Use the schema with a store
* const store = new Store({
* schema: customSchema,
* props: {
* defaultName: 'My Drawing',
* },
* })
* ```
*/
export function createTLSchema({
shapes = defaultShapeSchemas,
bindings = defaultBindingSchemas,
assets = defaultAssetSchemas,
user,
records = {},
migrations,
}: {
shapes?: Record<string, SchemaPropsInfo>
bindings?: Record<string, SchemaPropsInfo>
assets?: Record<string, SchemaPropsInfo>
user?: UserSchemaInfo
records?: Record<string, CustomRecordInfo>
migrations?: readonly MigrationSequence[]
} = {}): TLSchema {
const stylesById = new Map<string, StyleProp<unknown>>()
for (const shape of objectMapValues(shapes)) {
for (const style of getShapePropKeysByStyle(shape.props ?? {}).keys()) {
if (stylesById.has(style.id) && stylesById.get(style.id) !== style) {
throw new Error(`Multiple StyleProp instances with the same id: ${style.id}`)
}
stylesById.set(style.id, style)
}
}
const ShapeRecordType = createShapeRecordType(shapes)
const BindingRecordType = createBindingRecordType(bindings)
const _AssetRecordType = createAssetRecordType(assets)
const InstanceRecordType = createInstanceRecordType(stylesById)
const CustomUserRecordType = user ? createUserRecordType(user) : UserRecordType
// Create RecordTypes for custom records
const builtInTypeNames = new Set([
'asset',
'binding',
'camera',
'document',
'instance',
'instance_page_state',
'page',
'instance_presence',
'pointer',
'shape',
'store',
'user',
])
const customRecordTypes: Record<string, { createId: any }> = {}
for (const [typeName, config] of Object.entries(records)) {
if (builtInTypeNames.has(typeName)) {
throw new Error(
`Custom record type name '${typeName}' conflicts with tldraw's built-in record type of that name. Choose a different name instead.`
)
}
customRecordTypes[typeName] = createCustomRecordType(typeName, config)
}
return StoreSchema.create(
{
asset: _AssetRecordType,
binding: BindingRecordType,
camera: CameraRecordType,
document: DocumentRecordType,
instance: InstanceRecordType,
instance_page_state: InstancePageStateRecordType,
page: PageRecordType,
instance_presence: InstancePresenceRecordType,
pointer: PointerRecordType,
shape: ShapeRecordType,
user: CustomUserRecordType,
...customRecordTypes,
},
{
migrations: [
storeMigrations,
assetMigrations,
cameraMigrations,
documentMigrations,
instanceMigrations,
instancePageStateMigrations,
pageMigrations,
instancePresenceMigrations,
pointerMigrations,
rootShapeMigrations,
userMigrations,
...processPropsMigrations<TLUnknownAsset>('asset', assets),
...processPropsMigrations<TLShape>('shape', shapes),
...processPropsMigrations<TLBinding>('binding', bindings),
...processCustomRecordMigrations(records),
...(user?.migrations ?? []),
...(migrations ?? []),
],
onValidationFailure,
createIntegrityChecker,
}
)
}

View File

@@ -0,0 +1,387 @@
/**
* @fileoverview
* Main entry point for the tldraw schema package. Exports the complete type system,
* data structures, validation, and migrations for tldraw's persisted data.
*
* This package provides:
* - Schema creation utilities (createTLSchema, defaultShapeSchemas, defaultBindingSchemas)
* - All built-in shape types (TLGeoShape, TLTextShape, TLArrowShape, etc.)
* - Asset management types and validators (TLImageAsset, TLVideoAsset, TLBookmarkAsset)
* - Binding system for shape relationships (TLArrowBinding)
* - Store integration types (TLStore, TLStoreProps, TLStoreSnapshot)
* - Style properties for consistent styling (DefaultColorStyle, DefaultSizeStyle, etc.)
* - Validation utilities and type guards
* - Migration systems for schema evolution
* - Geometry and utility types
*
* @example
* ```ts
* import { createTLSchema, defaultShapeSchemas, TLStore } from '@tldraw/tlschema'
*
* // Create a schema with default shapes
* const schema = createTLSchema({
* shapes: defaultShapeSchemas
* })
*
* // Use with a store
* const store = new Store({ schema })
* ```
*
* @public
*/
import { registerTldrawLibraryVersion } from '@tldraw/utils'
export { assetIdValidator, createAssetValidator, type TLBaseAsset } from './assets/TLBaseAsset'
export {
bookmarkAssetMigrations,
bookmarkAssetProps,
type TLBookmarkAsset,
} from './assets/TLBookmarkAsset'
export { imageAssetMigrations, imageAssetProps, type TLImageAsset } from './assets/TLImageAsset'
export { videoAssetMigrations, videoAssetProps, type TLVideoAsset } from './assets/TLVideoAsset'
export {
arrowBindingMigrations,
arrowBindingProps,
arrowBindingVersions,
ElbowArrowSnap,
type TLArrowBinding,
type TLArrowBindingProps,
} from './bindings/TLArrowBinding'
export {
bindingIdValidator,
createBindingValidator,
type TLBaseBinding,
} from './bindings/TLBaseBinding'
export {
createPresenceStateDerivation,
getDefaultUserPresence,
type CreatePresenceStateDerivationOpts,
type TLPresenceStateInfo,
} from './createPresenceStateDerivation'
export {
createTLSchema,
defaultAssetSchemas,
defaultBindingSchemas,
defaultShapeSchemas,
type SchemaPropsInfo,
type TLSchema,
type UserSchemaInfo,
} from './createTLSchema'
export {
boxModelValidator,
vecModelValidator,
type BoxModel,
type VecModel,
} from './misc/geometry-types'
export { idValidator } from './misc/id-validator'
export {
canvasUiColorTypeValidator,
TL_CANVAS_UI_COLOR_TYPES,
type TLCanvasUiColor,
} from './misc/TLColor'
export { TL_CURSOR_TYPES, type TLCursor, type TLCursorType } from './misc/TLCursor'
export { TL_HANDLE_TYPES, type TLHandle, type TLHandleType } from './misc/TLHandle'
export { opacityValidator, type TLOpacityType } from './misc/TLOpacity'
export { richTextValidator, toRichText, type TLRichText } from './misc/TLRichText'
export { scribbleValidator, TL_SCRIBBLE_STATES, type TLScribble } from './misc/TLScribble'
export {
assetMigrations,
AssetRecordType,
createAssetPropsMigrationIds,
createAssetPropsMigrationSequence,
createAssetRecordType,
type TLAsset,
type TLAssetId,
type TLAssetPartial,
type TLAssetShape,
type TLDefaultAsset,
type TLGlobalAssetPropsMap,
type TLIndexedAssets,
type TLUnknownAsset,
} from './records/TLAsset'
export {
createBindingId,
createBindingPropsMigrationIds,
createBindingPropsMigrationSequence,
isBinding,
isBindingId,
rootBindingMigrations,
type TLBinding,
type TLBindingCreate,
type TLBindingId,
type TLBindingUpdate,
type TLDefaultBinding,
type TLGlobalBindingPropsMap,
type TLIndexedBindings,
type TLUnknownBinding,
} from './records/TLBinding'
export { CameraRecordType, type TLCamera, type TLCameraId } from './records/TLCamera'
export {
commentReactionRecordConfig,
commentRecordConfig,
commentSchemaRecords,
commentThreadRecordConfig,
createComment,
createCommentId,
createCommentReaction,
createCommentReactionId,
createCommentThread,
createCommentThreadId,
isCommentId,
isCommentReactionId,
isCommentThreadId,
type TLComment,
type TLCommentAnchor,
type TLCommentId,
type TLCommentReaction,
type TLCommentReactionId,
type TLCommentThread,
type TLCommentThreadId,
} from './records/TLComment'
export {
createCustomRecordId,
createCustomRecordMigrationIds,
createCustomRecordMigrationSequence,
isCustomRecord,
isCustomRecordId,
type CustomRecordInfo,
} from './records/TLCustomRecord'
export {
DocumentRecordType,
isDocument,
TLDOCUMENT_ID,
type TLDocument,
} from './records/TLDocument'
export {
pluckPreservingValues,
TLINSTANCE_ID,
type TLInstance,
type TLInstanceId,
} from './records/TLInstance'
export {
isPage,
isPageId,
pageIdValidator,
PageRecordType,
type TLPage,
type TLPageId,
} from './records/TLPage'
export {
InstancePageStateRecordType,
type TLInstancePageState,
type TLInstancePageStateId,
} from './records/TLPageState'
export {
PointerRecordType,
TLPOINTER_ID,
type TLPointer,
type TLPointerId,
} from './records/TLPointer'
export {
InstancePresenceRecordType,
type TLInstancePresence,
type TLInstancePresenceID,
} from './records/TLPresence'
export {
type TLCustomRecord,
type TLDefaultRecord,
type TLGlobalRecordPropsMap,
type TLIndexedRecords,
type TLRecord,
} from './records/TLRecord'
export {
createShapeId,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
getShapePropKeysByStyle,
isShape,
isShapeId,
rootShapeMigrations,
type ExtractShapeByProps,
type TLCreateShapePartial,
type TLDefaultShape,
type TLGlobalShapePropsMap,
type TLIndexedShapes,
type TLParentId,
type TLShape,
type TLShapeId,
type TLShapePartial,
type TLUnknownShape,
} from './records/TLShape'
export {
createUserId,
createUserRecordType,
isUserId,
userIdValidator,
UserRecordType,
type TLUser,
type TLUserId,
} from './records/TLUser'
export {
type RecordProps,
type RecordPropsType,
type TLPropsMigration,
type TLPropsMigrations,
} from './recordsWithProps'
export { type ShapeWithCrop, type TLShapeCrop } from './shapes/ShapeWithCrop'
export {
ArrowShapeArrowheadEndStyle,
ArrowShapeArrowheadStartStyle,
ArrowShapeKindStyle,
arrowShapeMigrations,
arrowShapeProps,
arrowShapeVersions,
type TLArrowShape,
type TLArrowShapeArrowheadStyle,
type TLArrowShapeKind,
type TLArrowShapeProps,
} from './shapes/TLArrowShape'
export {
createShapeValidator,
parentIdValidator,
shapeIdValidator,
type TLBaseShape,
} from './shapes/TLBaseShape'
export {
bookmarkShapeMigrations,
bookmarkShapeProps,
type TLBookmarkShape,
type TLBookmarkShapeProps,
} from './shapes/TLBookmarkShape'
export {
compressLegacySegments,
drawShapeMigrations,
drawShapeProps,
type TLDrawShape,
type TLDrawShapeProps,
type TLDrawShapeSegment,
} from './shapes/TLDrawShape'
export {
embedShapeMigrations,
embedShapeProps,
type TLEmbedShape,
type TLEmbedShapeProps,
} from './shapes/TLEmbedShape'
export {
frameShapeMigrations,
frameShapeProps,
type TLFrameShape,
type TLFrameShapeProps,
} from './shapes/TLFrameShape'
export {
GeoShapeGeoStyle,
geoShapeMigrations,
geoShapeProps,
type TLGeoShape,
type TLGeoShapeGeoStyle,
type TLGeoShapeProps,
} from './shapes/TLGeoShape'
export {
groupShapeMigrations,
groupShapeProps,
type TLGroupShape,
type TLGroupShapeProps,
} from './shapes/TLGroupShape'
export {
highlightShapeMigrations,
highlightShapeProps,
type TLHighlightShape,
type TLHighlightShapeProps,
} from './shapes/TLHighlightShape'
export {
ImageShapeCrop,
imageShapeMigrations,
imageShapeProps,
type TLImageShape,
type TLImageShapeProps,
} from './shapes/TLImageShape'
export {
lineShapeMigrations,
lineShapeProps,
LineShapeSplineStyle,
type TLLineShape,
type TLLineShapePoint,
type TLLineShapeProps,
type TLLineShapeSplineStyle,
} from './shapes/TLLineShape'
export {
noteShapeMigrations,
noteShapeProps,
type TLNoteShape,
type TLNoteShapeProps,
} from './shapes/TLNoteShape'
export {
textShapeMigrations,
textShapeProps,
type TLTextShape,
type TLTextShapeProps,
} from './shapes/TLTextShape'
export {
videoShapeMigrations,
videoShapeProps,
type TLVideoShape,
type TLVideoShapeProps,
} from './shapes/TLVideoShape'
export { EnumStyleProp, StyleProp, type StylePropValue } from './styles/StyleProp'
export {
DefaultColorStyle,
registerColorsFromThemes,
type TLDefaultColorStyle,
} from './styles/TLColorStyle'
export { DefaultDashStyle, type TLDefaultDashStyle } from './styles/TLDashStyle'
export { DefaultFillStyle, type TLDefaultFillStyle } from './styles/TLFillStyle'
export { type TLFontFace, type TLFontFaceSource } from './styles/TLFontFace'
export {
DefaultFontFamilies,
DefaultFontStyle,
isFontEntry,
registerFontsFromThemes,
type TLDefaultFontStyle,
} from './styles/TLFontStyle'
export {
DefaultHorizontalAlignStyle,
type TLDefaultHorizontalAlignStyle,
} from './styles/TLHorizontalAlignStyle'
export { DefaultSizeStyle, type TLDefaultSizeStyle } from './styles/TLSizeStyle'
export { DefaultTextAlignStyle, type TLDefaultTextAlignStyle } from './styles/TLTextAlignStyle'
export {
type TLDefaultColor,
type TLRemovedDefaultThemeColors,
type TLTheme,
type TLThemeColors,
type TLThemeDefaultColors,
type TLThemeFont,
type TLThemeFonts,
type TLThemeId,
type TLThemes,
type TLThemeUiColorKeys,
} from './styles/TLTheme'
export {
DefaultVerticalAlignStyle,
type TLDefaultVerticalAlignStyle,
} from './styles/TLVerticalAlignStyle'
export {
createCachedUserResolve,
type TLAssetContext,
type TLAssetStore,
type TLSerializedStore,
type TLStore,
type TLStoreProps,
type TLStoreSchema,
type TLStoreSnapshot,
type TLUserStore,
} from './TLStore'
export {
getDefaultTranslationLocale,
LANGUAGES,
type TLLanguage,
} from './translations/translations'
export { type SetValue } from './util-types'
registerTldrawLibraryVersion(
(globalThis as any).TLDRAW_LIBRARY_NAME,
(globalThis as any).TLDRAW_LIBRARY_VERSION,
(globalThis as any).TLDRAW_LIBRARY_MODULES
)
export { DIM_2D, DIM_3D, b64Vecs } from './misc/b64Vecs'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
import { T } from '@tldraw/validate'
import { SetValue } from '../util-types'
/**
* The colors used by tldraw's canvas UI system.
*
* These are special color types used for canvas UI elements like selections,
* accents, and other interface components that overlay the drawing canvas.
* Unlike shape colors, these are semantic color types that adapt to the
* current theme.
*
* @example
* ```ts
* // Check if a color is a canvas UI color
* if (TL_CANVAS_UI_COLOR_TYPES.has('selection-stroke')) {
* console.log('This is a valid canvas UI color')
* }
* ```
*
* @public
*/
export const TL_CANVAS_UI_COLOR_TYPES = new Set([
'accent',
'white',
'black',
'selection-stroke',
'selection-fill',
'laser',
'muted-1',
] as const)
/**
* A union type representing the available canvas UI color types.
*
* Canvas UI colors are semantic color types used for interface elements
* that overlay the drawing canvas, such as selection indicators, accents,
* and other UI components.
*
* @example
* ```ts
* const selectionColor: TLCanvasUiColor = 'selection-stroke'
* const accentColor: TLCanvasUiColor = 'accent'
* const backgroundColor: TLCanvasUiColor = 'white'
* ```
*
* @public
*/
export type TLCanvasUiColor = SetValue<typeof TL_CANVAS_UI_COLOR_TYPES>
/**
* A validator for canvas UI color types.
*
* This validator ensures that color values are one of the valid canvas UI
* color types defined in {@link TL_CANVAS_UI_COLOR_TYPES}. It provides
* runtime type checking for canvas UI color properties.
*
* @example
* ```ts
* import { canvasUiColorTypeValidator } from '@tldraw/tlschema'
*
* // Validate a color value
* try {
* const validColor = canvasUiColorTypeValidator.validate('accent')
* console.log('Valid color:', validColor)
* } catch (error) {
* console.error('Invalid color:', error.message)
* }
* ```
*
* @public
*/
export const canvasUiColorTypeValidator = T.setEnum(TL_CANVAS_UI_COLOR_TYPES)

View File

@@ -0,0 +1,157 @@
import { T } from '@tldraw/validate'
import { SetValue } from '../util-types'
/**
* All available cursor types used throughout the tldraw editor.
*
* These cursor types correspond to CSS cursor values and are used to indicate
* different interaction modes and states within the editor. The cursor types
* cover selection, resizing, rotation, text editing, and various other editor
* interactions.
*
* @example
* ```ts
* // Check if a cursor type is valid
* if (TL_CURSOR_TYPES.has('resize-corner')) {
* console.log('Valid cursor type')
* }
*
* // Get all available cursor types
* const allCursors = Array.from(TL_CURSOR_TYPES)
* ```
*
* @public
*/
export const TL_CURSOR_TYPES = new Set([
'none',
'default',
'pointer',
'cross',
'comment',
'grab',
'rotate',
'grabbing',
'resize-edge',
'resize-corner',
'text',
'move',
'ew-resize',
'ns-resize',
'nesw-resize',
'nwse-resize',
'nesw-rotate',
'nwse-rotate',
'swne-rotate',
'senw-rotate',
'zoom-in',
'zoom-out',
])
/**
* A union type representing all available cursor types in the tldraw editor.
*
* Each cursor type corresponds to a different interaction mode or state,
* helping users understand what action they can perform at any given moment.
*
* @example
* ```ts
* const defaultCursor: TLCursorType = 'default'
* const textCursor: TLCursorType = 'text'
* const resizeCursor: TLCursorType = 'resize-corner'
* const rotateCursor: TLCursorType = 'nesw-rotate'
* ```
*
* @public
*/
export type TLCursorType = SetValue<typeof TL_CURSOR_TYPES>
/**
* A validator for cursor types.
*
* This validator ensures that cursor type values are one of the valid types
* defined in {@link TL_CURSOR_TYPES}. It provides runtime type checking for
* cursor properties throughout the editor.
*
* @example
* ```ts
* import { cursorTypeValidator } from '@tldraw/tlschema'
*
* // Validate a cursor type
* try {
* const validCursor = cursorTypeValidator.validate('pointer')
* console.log('Valid cursor:', validCursor)
* } catch (error) {
* console.error('Invalid cursor:', error.message)
* }
* ```
*
* @public
*/
export const cursorTypeValidator = T.setEnum(TL_CURSOR_TYPES)
/**
* A cursor object used throughout the tldraw editor.
*
* Represents both the cursor type (which determines the visual appearance)
* and its rotation angle. The rotation is particularly useful for resize
* and rotation cursors that need to align with the current interaction angle.
*
* @example
* ```ts
* // Default cursor
* const defaultCursor: TLCursor = {
* type: 'default',
* rotation: 0
* }
*
* // Rotated resize cursor
* const rotatedResizeCursor: TLCursor = {
* type: 'resize-corner',
* rotation: Math.PI / 4 // 45 degrees
* }
*
* // Text editing cursor
* const textCursor: TLCursor = {
* type: 'text',
* rotation: 0
* }
* ```
*
* @public
*/
export interface TLCursor {
/** The cursor type, determining the visual appearance and interaction mode */
type: TLCursorType
/** The rotation angle in radians, used for rotated cursors like resize handles */
rotation: number
}
/**
* A validator for TLCursor objects.
*
* This validator ensures that cursor objects have valid cursor types and
* numeric rotation values. It provides runtime validation for cursor
* properties used throughout the editor.
*
* @example
* ```ts
* import { cursorValidator } from '@tldraw/tlschema'
*
* // Validate a cursor object
* try {
* const validCursor = cursorValidator.validate({
* type: 'pointer',
* rotation: 0.5
* })
* console.log('Valid cursor:', validCursor)
* } catch (error) {
* console.error('Invalid cursor:', error.message)
* }
* ```
*
* @public
*/
export const cursorValidator: T.ObjectValidator<TLCursor> = T.object<TLCursor>({
type: cursorTypeValidator,
rotation: T.number,
})

View File

@@ -0,0 +1,113 @@
import { IndexKey } from '@tldraw/utils'
import { SetValue } from '../util-types'
/**
* All available handle types used by shapes in the tldraw editor.
*
* Handles are interactive control points on shapes that allow users to
* modify the shape's geometry. Different handle types serve different purposes:
*
* - `vertex`: A control point that defines a vertex of the shape
* - `virtual`: A handle that exists between vertices for adding new points
* - `create`: A handle for creating new geometry (like extending a line)
* - `clone`: A handle for duplicating or cloning shape elements
*
* @example
* ```ts
* // Check if a handle type is valid
* if (TL_HANDLE_TYPES.has('vertex')) {
* console.log('Valid handle type')
* }
*
* // Get all available handle types
* const allHandleTypes = Array.from(TL_HANDLE_TYPES)
* ```
*
* @public
*/
export const TL_HANDLE_TYPES = new Set(['vertex', 'virtual', 'create', 'clone'] as const)
/**
* A union type representing all available handle types.
*
* Handle types determine how a handle behaves when interacted with and
* what kind of shape modification it enables.
*
* @example
* ```ts
* const vertexHandle: TLHandleType = 'vertex'
* const virtualHandle: TLHandleType = 'virtual'
* const createHandle: TLHandleType = 'create'
* const cloneHandle: TLHandleType = 'clone'
* ```
*
* @public
*/
export type TLHandleType = SetValue<typeof TL_HANDLE_TYPES>
/**
* A handle object representing an interactive control point on a shape.
*
* Handles allow users to manipulate shape geometry by dragging control points.
* Each handle has a position, type, and various properties that control its
* behavior during interactions.
*
* @example
* ```ts
* // A vertex handle for a line endpoint
* const lineEndHandle: TLHandle = {
* id: 'end',
* label: 'End point',
* type: 'vertex',
* canSnap: true,
* index: 'a1',
* x: 100,
* y: 50
* }
*
* // A virtual handle for adding new points
* const virtualHandle: TLHandle = {
* id: 'virtual-1',
* type: 'virtual',
* canSnap: false,
* index: 'a1V',
* x: 75,
* y: 25
* }
*
* // A create handle for extending geometry
* const createHandle: TLHandle = {
* id: 'create',
* type: 'create',
* canSnap: true,
* index: 'a2',
* x: 200,
* y: 100
* }
* ```
*
* @public
*/
export interface TLHandle {
/** A unique identifier for the handle within the shape */
id: string
/** Optional human-readable label for the handle */
// TODO(mime): this needs to be required.
label?: string
/** The type of handle, determining its behavior and interaction mode */
type: TLHandleType
/**
* @deprecated Use `snapType` instead. Whether this handle should snap to other geometry during interactions.
*/
canSnap?: boolean
/** The type of snap to use for this handle */
snapType?: 'point' | 'align'
/** The ID of the handle to use as reference point for shift-modifier angle snapping */
snapReferenceHandleId?: string
/** The fractional index used for ordering handles */
index: IndexKey
/** The x-coordinate of the handle in the shape's local coordinate system */
x: number
/** The y-coordinate of the handle in the shape's local coordinate system */
y: number
}

View File

@@ -0,0 +1,56 @@
import { T } from '@tldraw/validate'
/**
* A type representing opacity values in tldraw.
*
* Opacity values are numbers between 0 and 1, where 0 is fully transparent
* and 1 is fully opaque. This type is used throughout the editor to control
* the transparency of shapes, UI elements, and other visual components.
*
* @example
* ```ts
* const fullyOpaque: TLOpacityType = 1.0
* const halfTransparent: TLOpacityType = 0.5
* const fullyTransparent: TLOpacityType = 0.0
* const quarterOpaque: TLOpacityType = 0.25
* ```
*
* @public
*/
export type TLOpacityType = number
/**
* A validator for opacity values.
*
* This validator ensures that opacity values are numbers between 0 and 1 (inclusive).
* Values outside this range will cause a validation error. The validator provides
* runtime type checking for opacity properties throughout the editor.
*
* @param n - The number to validate as an opacity value
* @throws T.ValidationError When the value is not between 0 and 1
*
* @example
* ```ts
* import { opacityValidator } from '@tldraw/tlschema'
*
* // Valid opacity values
* try {
* const validOpacity1 = opacityValidator.validate(0.5) // ✓
* const validOpacity2 = opacityValidator.validate(1.0) // ✓
* const validOpacity3 = opacityValidator.validate(0.0) // ✓
* } catch (error) {
* console.error('Validation failed:', error.message)
* }
*
* // Invalid opacity values
* try {
* opacityValidator.validate(-0.1) // ✗ Throws error
* opacityValidator.validate(1.5) // ✗ Throws error
* } catch (error) {
* console.error('Invalid opacity:', error.message)
* }
* ```
*
* @public
*/
export const opacityValidator = T.unitInterval

View File

@@ -0,0 +1,56 @@
import { toRichText } from './TLRichText'
describe('toRichText', () => {
it('should convert a single line of text to a TipTap document', () => {
const input = 'Hello, world!'
const expectedOutput = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello, world!' }],
},
],
}
expect(toRichText(input)).toEqual(expectedOutput)
})
it('should convert multiple lines of text to a TipTap document', () => {
const input = 'Hello, world!\nThis is a test.'
const expectedOutput = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello, world!' }],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'This is a test.' }],
},
],
}
expect(toRichText(input)).toEqual(expectedOutput)
})
it('should handle empty lines correctly', () => {
const input = 'Hello, world!\n\nThis is a test.'
const expectedOutput = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello, world!' }],
},
{
type: 'paragraph',
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'This is a test.' }],
},
],
}
expect(toRichText(input)).toEqual(expectedOutput)
})
})

View File

@@ -0,0 +1,87 @@
import { T } from '@tldraw/validate'
/**
* Validator for TLRichText objects that ensures they have the correct structure
* for document-based rich text content. Validates a document with a type field
* and an array of content blocks.
*
* @public
* @example
* ```ts
* const richText = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }] }
* const isValid = richTextValidator.check(richText) // true
* ```
*/
export const richTextValidator = T.object({
type: T.string,
content: T.arrayOf(T.unknown),
attrs: T.any.optional(),
})
/**
* Type representing rich text content in tldraw. Rich text follows a document-based
* structure with a root document containing an array of content blocks (paragraphs,
* text nodes, etc.). This enables formatted text with support for multiple paragraphs,
* styling, and other rich content.
*
* @public
* @example
* ```ts
* const richText: TLRichText = {
* type: 'doc',
* content: [
* {
* type: 'paragraph',
* content: [{ type: 'text', text: 'Hello world!' }]
* }
* ]
* }
* ```
*/
export type TLRichText = T.TypeOf<typeof richTextValidator>
/**
* Converts a plain text string into a TLRichText object. Each line of the input
* text becomes a separate paragraph in the rich text document. Empty lines are
* preserved as empty paragraphs to maintain the original text structure.
*
* @param text - The plain text string to convert to rich text
* @returns A TLRichText object with the text content structured as paragraphs
* @public
* @example
* ```ts
* const richText = toRichText('Hello\nWorld')
* // Returns:
* // {
* // type: 'doc',
* // content: [
* // { type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] },
* // { type: 'paragraph', content: [{ type: 'text', text: 'World' }] }
* // ]
* // }
*
* const emptyLine = toRichText('Line 1\n\nLine 3')
* // Creates three paragraphs, with the middle one being empty
* ```
*/
export function toRichText(text: string): TLRichText {
const lines = text.split('\n')
const content = lines.map((text) => {
if (!text) {
return {
type: 'paragraph',
}
}
return {
type: 'paragraph',
content: [{ type: 'text', text }],
}
})
return {
type: 'doc',
content,
}
}

View File

@@ -0,0 +1,146 @@
import { T } from '@tldraw/validate'
import { SetValue } from '../util-types'
import { VecModel, vecModelValidator } from './geometry-types'
import { TLCanvasUiColor, canvasUiColorTypeValidator } from './TLColor'
/**
* All available scribble states used by the tldraw drawing system.
*
* Scribble states represent the different phases of a drawing stroke:
*
* - `starting`: The scribble is being initiated
* - `paused`: The scribble is temporarily paused
* - `active`: The scribble is actively being drawn
* - `complete`: The scribble is done being drawn but not yet fading
* - `stopping`: The scribble is being finished
*
* These states help manage the drawing lifecycle and apply appropriate
* visual effects during different phases of the stroke.
*
* @example
* ```ts
* // Check if a scribble state is valid
* if (TL_SCRIBBLE_STATES.has('active')) {
* console.log('Valid scribble state')
* }
*
* // Get all available scribble states
* const allStates = Array.from(TL_SCRIBBLE_STATES)
* ```
*
* @public
*/
export const TL_SCRIBBLE_STATES = new Set([
'starting',
'paused',
'active',
'complete',
'stopping',
] as const)
/**
* A scribble object representing a drawing stroke in tldraw.
*
* Scribbles are temporary drawing strokes that appear during freehand drawing
* operations. They provide visual feedback as the user draws and can be styled
* with various properties like size, color, and effects.
*
* @example
* ```ts
* // A basic scribble stroke
* const scribble: TLScribble = {
* id: 'scribble-123',
* points: [
* { x: 0, y: 0, z: 0.5 },
* { x: 10, y: 5, z: 0.7 },
* { x: 20, y: 10, z: 0.6 }
* ],
* size: 4,
* color: 'black',
* opacity: 0.8,
* state: 'active',
* delay: 0,
* shrink: 0.1,
* taper: true
* }
*
* // A laser pointer scribble
* const laserScribble: TLScribble = {
* id: 'laser-pointer',
* points: [{ x: 50, y: 50, z: 1.0 }],
* size: 8,
* color: 'laser',
* opacity: 1.0,
* state: 'active',
* delay: 100,
* shrink: 0,
* taper: false
* }
* ```
*
* @public
*/
export interface TLScribble {
/** Unique identifier for the scribble */
id: string
/** Array of points that make up the scribble path */
points: VecModel[]
/** The brush size/width of the scribble stroke */
size: number
/** The color of the scribble using canvas UI color types */
color: TLCanvasUiColor
/** The opacity of the scribble (0-1) */
opacity: number
/** The current state of the scribble drawing */
state: SetValue<typeof TL_SCRIBBLE_STATES>
/** Time delay in milliseconds for animation effects */
delay: number
/** Amount the stroke should shrink over time (0-1) */
shrink: number
/** Whether the stroke should taper at the ends */
taper: boolean
}
/**
* A validator for TLScribble objects.
*
* This validator ensures that scribble objects have all required properties
* with valid types and values. It validates the points array, size constraints,
* color types, and state values according to the scribble system requirements.
*
* @example
* ```ts
* import { scribbleValidator } from '@tldraw/tlschema'
*
* // Validate a scribble object
* try {
* const validScribble = scribbleValidator.validate({
* id: 'scribble-1',
* points: [{ x: 0, y: 0, z: 1 }, { x: 10, y: 10, z: 1 }],
* size: 3,
* color: 'black',
* opacity: 0.8,
* state: 'active',
* delay: 0,
* shrink: 0.05,
* taper: true
* })
* console.log('Valid scribble:', validScribble)
* } catch (error) {
* console.error('Invalid scribble:', error.message)
* }
* ```
*
* @public
*/
export const scribbleValidator: T.ObjectValidator<TLScribble> = T.object({
id: T.string,
points: T.arrayOf(vecModelValidator),
size: T.positiveNumber,
color: canvasUiColorTypeValidator,
opacity: T.number,
state: T.setEnum(TL_SCRIBBLE_STATES),
delay: T.number,
shrink: T.number,
taper: T.boolean,
})

View File

@@ -0,0 +1,592 @@
import { describe, expect, it } from 'vitest'
import {
b64Vecs,
fallbackBase64ToUint8Array,
fallbackUint8ArrayToBase64,
float16BitsToNumber,
numberToFloat16Bits,
} from './b64Vecs'
import { VecModel } from './geometry-types'
const hasNativeFloat16 = typeof (DataView.prototype as any).getFloat16 === 'function'
describe('b64Vecs delta encoding', () => {
describe('precision advantage over legacy encoding', () => {
/**
* Float16 has 10 mantissa bits, so at value ~10000 the step size is 8.
* This means Float16 can only represent: ..., 9984, 9992, 10000, 10008, 10016, ...
* Values like 10001, 10002, 10003 all round to 10000.
*
* Legacy encoding stores absolute coordinates as Float16, losing small deltas.
* Delta encoding stores the first point as Float32 (exact) and subsequent
* points as Float16 deltas. Since deltas like 1, 2, 3 are perfectly
* representable in Float16, precision is preserved.
*/
it('preserves small deltas that legacy encoding loses', () => {
const points: VecModel[] = [
{ x: 10000, y: 10000, z: 0.5 },
{ x: 10001, y: 10001, z: 0.5 },
{ x: 10002, y: 10002, z: 0.5 },
]
// Legacy encoding: all points collapse to ~10000 due to Float16 precision limits
const legacyEncoded = b64Vecs._legacyEncodePoints(points)
const legacyDecoded = b64Vecs._legacyDecodePoints(legacyEncoded)
// Legacy encoding loses the deltas - all three x values become the same
// (or very close, depending on rounding)
const legacyDelta1 = Math.abs(legacyDecoded[1].x - legacyDecoded[0].x)
// Delta encoding: preserves the small deltas
const deltaEncoded = b64Vecs.encodePoints(points)
const deltaDecoded = b64Vecs.decodePoints(deltaEncoded)
const deltaDelta1 = Math.abs(deltaDecoded[1].x - deltaDecoded[0].x)
const deltaDelta2 = Math.abs(deltaDecoded[2].x - deltaDecoded[1].x)
// Legacy encoding loses the 1-unit deltas (they become 0 or 8)
expect(legacyDelta1).not.toBeCloseTo(1, 0) // Legacy fails to preserve delta of 1
// Delta encoding preserves the 1-unit deltas exactly
expect(deltaDelta1).toBeCloseTo(1, 5) // Delta encoding preserves it
expect(deltaDelta2).toBeCloseTo(1, 5)
// Verify absolute positions are correct with delta encoding
expect(deltaDecoded[0].x).toBe(10000)
expect(deltaDecoded[1].x).toBe(10001)
expect(deltaDecoded[2].x).toBe(10002)
})
it('maintains relative distances that legacy encoding destroys', () => {
// A realistic draw stroke: user draws near coordinate 50000
// with small movements of 0.5-2 pixels between points
const points: VecModel[] = [
{ x: 50000, y: 50000, z: 0.5 },
{ x: 50000.5, y: 50001, z: 0.5 },
{ x: 50001.5, y: 50002.5, z: 0.5 },
{ x: 50003, y: 50004, z: 0.5 },
]
// At 50000, Float16 step size is 32 (2^15 range, so 2^(15-10) = 32)
// All these points collapse to 49984 or 50016 in legacy encoding
const legacyDecoded = b64Vecs._legacyDecodePoints(b64Vecs._legacyEncodePoints(points))
const deltaDecoded = b64Vecs.decodePoints(b64Vecs.encodePoints(points))
// Calculate total path length for each encoding
let legacyLength = 0
let deltaLength = 0
for (let i = 1; i < points.length; i++) {
legacyLength += Math.hypot(
legacyDecoded[i].x - legacyDecoded[i - 1].x,
legacyDecoded[i].y - legacyDecoded[i - 1].y
)
deltaLength += Math.hypot(
deltaDecoded[i].x - deltaDecoded[i - 1].x,
deltaDecoded[i].y - deltaDecoded[i - 1].y
)
}
// Original path length
let originalLength = 0
for (let i = 1; i < points.length; i++) {
originalLength += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y)
}
// Legacy encoding destroys the path - length is way off
expect(Math.abs(legacyLength - originalLength)).toBeGreaterThan(1)
// Delta encoding preserves the path length accurately
expect(deltaLength).toBeCloseTo(originalLength, 1)
})
it('Float32 anchor provides exact precision for first point', () => {
// Test that Float32 is actually being used for the first point
// Float32 has 23 mantissa bits, so it can represent these exactly
const preciseValue = 123456.789
const points: VecModel[] = [{ x: preciseValue, y: preciseValue, z: 0.5 }]
const encoded = b64Vecs.encodePoints(points)
const decoded = b64Vecs.decodePoints(encoded)
// Float32 should preserve ~7 significant digits
expect(decoded[0].x).toBeCloseTo(preciseValue, 2)
expect(decoded[0].y).toBeCloseTo(preciseValue, 2)
// Legacy Float16 would mangle this value significantly
const legacyDecoded = b64Vecs._legacyDecodePoints(b64Vecs._legacyEncodePoints(points))
const legacyError = Math.abs(legacyDecoded[0].x - preciseValue)
const deltaError = Math.abs(decoded[0].x - preciseValue)
// Delta encoding should have much smaller error than legacy
expect(deltaError).toBeLessThan(legacyError)
})
})
describe('delta encoding format', () => {
it('uses correct byte sizes: 12 bytes for first point, 6 bytes per delta', () => {
// Single point: 3 Float32s = 12 bytes = 16 base64 chars
const onePoint = b64Vecs.encodePoints([{ x: 0, y: 0, z: 0.5 }])
expect(onePoint.length).toBe(16)
// Two points: 12 bytes + 6 bytes = 18 bytes = 24 base64 chars
const twoPoints = b64Vecs.encodePoints([
{ x: 0, y: 0, z: 0.5 },
{ x: 1, y: 1, z: 0.5 },
])
expect(twoPoints.length).toBe(24)
// Three points: 12 bytes + 6 bytes + 6 bytes = 24 bytes = 32 base64 chars
const threePoints = b64Vecs.encodePoints([
{ x: 0, y: 0, z: 0.5 },
{ x: 1, y: 1, z: 0.5 },
{ x: 2, y: 2, z: 0.5 },
])
expect(threePoints.length).toBe(32)
})
it('empty array produces empty string', () => {
expect(b64Vecs.encodePoints([])).toBe('')
expect(b64Vecs.decodePoints('')).toEqual([])
})
it('defaults z to 0.5 when undefined', () => {
const points: VecModel[] = [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
]
const decoded = b64Vecs.decodePoints(b64Vecs.encodePoints(points))
expect(decoded[0].z).toBe(0.5)
expect(decoded[1].z).toBe(0.5)
})
})
describe('decodeFirstPoint', () => {
it('extracts first point without full decode', () => {
const points: VecModel[] = [
{ x: 100, y: 200, z: 0.75 },
{ x: 101, y: 201, z: 0.8 },
]
const encoded = b64Vecs.encodePoints(points)
const first = b64Vecs.decodeFirstPoint(encoded)
expect(first).toEqual({ x: 100, y: 200, z: 0.75 })
})
it('returns null for insufficient data', () => {
expect(b64Vecs.decodeFirstPoint('')).toBeNull()
expect(b64Vecs.decodeFirstPoint('AAAA')).toBeNull() // Only 4 chars, need 16
})
})
describe('decodeLastPoint', () => {
it('accumulates deltas to get last point', () => {
const points: VecModel[] = [
{ x: 0, y: 0, z: 0.5 },
{ x: 10, y: 20, z: 0.6 },
{ x: 30, y: 50, z: 0.7 },
]
const encoded = b64Vecs.encodePoints(points)
const last = b64Vecs.decodeLastPoint(encoded)
expect(last!.x).toBeCloseTo(30, 2)
expect(last!.y).toBeCloseTo(50, 2)
expect(last!.z).toBeCloseTo(0.7, 2)
})
it('returns first point when only one point exists', () => {
const points: VecModel[] = [{ x: 42, y: 84, z: 0.5 }]
const encoded = b64Vecs.encodePoints(points)
const last = b64Vecs.decodeLastPoint(encoded)
expect(last).toEqual({ x: 42, y: 84, z: 0.5 })
})
it('returns null for insufficient data', () => {
expect(b64Vecs.decodeLastPoint('')).toBeNull()
expect(b64Vecs.decodeLastPoint('AAAA')).toBeNull()
})
})
describe('round-trip correctness', () => {
it('preserves typical draw stroke data', () => {
// Simulate a real freehand stroke with small movements
const points: VecModel[] = [
{ x: 100, y: 100, z: 0.5 },
{ x: 102, y: 101, z: 0.52 },
{ x: 105, y: 103, z: 0.55 },
{ x: 109, y: 106, z: 0.58 },
{ x: 114, y: 110, z: 0.6 },
]
const decoded = b64Vecs.decodePoints(b64Vecs.encodePoints(points))
expect(decoded).toHaveLength(points.length)
for (let i = 0; i < points.length; i++) {
expect(decoded[i].x).toBeCloseTo(points[i].x, 2)
expect(decoded[i].y).toBeCloseTo(points[i].y, 2)
expect(decoded[i].z).toBeCloseTo(points[i].z!, 2)
}
})
it('handles negative coordinates', () => {
const points: VecModel[] = [
{ x: -100, y: -200, z: 0.5 },
{ x: -99, y: -198, z: 0.5 },
]
const decoded = b64Vecs.decodePoints(b64Vecs.encodePoints(points))
expect(decoded[0].x).toBe(-100)
expect(decoded[0].y).toBe(-200)
expect(decoded[1].x).toBeCloseTo(-99, 2)
expect(decoded[1].y).toBeCloseTo(-198, 2)
})
it('handles zero deltas', () => {
const points: VecModel[] = [
{ x: 100, y: 100, z: 0.5 },
{ x: 100, y: 100, z: 0.5 }, // Same point
{ x: 100, y: 100, z: 0.5 }, // Same point again
]
const decoded = b64Vecs.decodePoints(b64Vecs.encodePoints(points))
expect(decoded[0]).toEqual({ x: 100, y: 100, z: 0.5 })
expect(decoded[1]).toEqual({ x: 100, y: 100, z: 0.5 })
expect(decoded[2]).toEqual({ x: 100, y: 100, z: 0.5 })
})
})
})
describe('native/fallback interoperability', () => {
describe('base64 encoding/decoding', () => {
it('fallback encode produces valid base64 that fallback decode can read', () => {
const bytes = new Uint8Array([0, 127, 255, 1, 128, 254])
const encoded = fallbackUint8ArrayToBase64(bytes)
const decoded = fallbackBase64ToUint8Array(encoded)
expect(decoded).toEqual(bytes)
})
it('fallback produces identical output to standard base64', () => {
// Test with various byte patterns
const testCases = [
new Uint8Array([0, 0, 0]),
new Uint8Array([255, 255, 255]),
new Uint8Array([0, 127, 255]),
new Uint8Array([1, 2, 3, 4, 5, 6]), // 6 bytes = 8 chars
new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), // 12 bytes = 16 chars (one delta point)
]
for (const bytes of testCases) {
const fallbackEncoded = fallbackUint8ArrayToBase64(bytes)
// Verify it's valid base64 by decoding and re-encoding
const decoded = fallbackBase64ToUint8Array(fallbackEncoded)
expect(decoded).toEqual(bytes)
// Verify it only uses valid base64 characters
expect(fallbackEncoded).toMatch(/^[A-Za-z0-9+/]*$/)
}
})
it('fallback decode handles all valid base64 characters', () => {
// Base64 alphabet: A-Z, a-z, 0-9, +, /
// "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" encodes specific bytes
const allChars = 'AAAA' // Simple test - decodes to [0, 0, 0]
const decoded = fallbackBase64ToUint8Array(allChars)
expect(decoded).toEqual(new Uint8Array([0, 0, 0]))
// Test with different character ranges
const mixedChars = 'QUJD' // "ABC" in base64
const mixedDecoded = fallbackBase64ToUint8Array(mixedChars)
expect(mixedDecoded.length).toBe(3)
})
it('cross-decodes: fallback encode → production decode works', () => {
const points: VecModel[] = [
{ x: 100, y: 200, z: 0.5 },
{ x: 105, y: 210, z: 0.6 },
]
// Encode with production (may use native)
const productionEncoded = b64Vecs.encodePoints(points)
// Decode with fallback
const bytes = fallbackBase64ToUint8Array(productionEncoded)
expect(bytes.length).toBe(18) // 12 bytes (first point) + 6 bytes (delta)
// Verify the bytes decode to correct values
const dataView = new DataView(bytes.buffer)
expect(dataView.getFloat32(0, true)).toBe(100)
expect(dataView.getFloat32(4, true)).toBe(200)
})
it('cross-encodes: fallback encode → production decode works', () => {
// Create bytes manually
const bytes = new Uint8Array(18) // One Float32 point + one Float16 delta
const dataView = new DataView(bytes.buffer)
dataView.setFloat32(0, 50, true)
dataView.setFloat32(4, 75, true)
dataView.setFloat32(8, 0.5, true)
// Delta point (small values work well with Float16)
dataView.setUint16(12, numberToFloat16Bits(5), true)
dataView.setUint16(14, numberToFloat16Bits(10), true)
dataView.setUint16(16, numberToFloat16Bits(0.1), true)
// Encode with fallback
const fallbackEncoded = fallbackUint8ArrayToBase64(bytes)
// Decode with production
const decoded = b64Vecs.decodePoints(fallbackEncoded)
expect(decoded).toHaveLength(2)
expect(decoded[0].x).toBe(50)
expect(decoded[0].y).toBe(75)
expect(decoded[1].x).toBeCloseTo(55, 2)
expect(decoded[1].y).toBeCloseTo(85, 2)
})
})
describe('Float16 encoding/decoding', () => {
it('fallback Float16 round-trips correctly for normal values', () => {
const testValues = [0, 1, -1, 0.5, -0.5, 100, -100, 1000, 0.001, 65504, -65504]
for (const value of testValues) {
const bits = numberToFloat16Bits(value)
const decoded = float16BitsToNumber(bits)
expect(decoded).toBeCloseTo(value, 2)
}
})
it('fallback Float16 handles special values', () => {
// Zero
expect(numberToFloat16Bits(0)).toBe(0)
expect(float16BitsToNumber(0)).toBe(0)
// Negative zero
expect(numberToFloat16Bits(-0)).toBe(0x8000)
// Infinity
expect(float16BitsToNumber(0x7c00)).toBe(Infinity)
expect(float16BitsToNumber(0xfc00)).toBe(-Infinity)
// NaN
expect(Number.isNaN(float16BitsToNumber(0x7e00))).toBe(true)
})
it('fallback Float16 handles overflow to infinity', () => {
// Values > 65504 should overflow to infinity
const bits = numberToFloat16Bits(100000)
expect(float16BitsToNumber(bits)).toBe(Infinity)
})
it('fallback Float16 matches native when available', () => {
if (!hasNativeFloat16) {
// Skip if native not available - can't compare
return
}
const testValues = [0, 1, -1, 0.5, 100, 1000, 0.001]
for (const value of testValues) {
// Create a DataView to use native Float16
const buffer = new ArrayBuffer(2)
const view = new DataView(buffer)
;(view as any).setFloat16(0, value, true)
const nativeBits = view.getUint16(0, true)
const fallbackBits = numberToFloat16Bits(value)
// Bits should match exactly
expect(fallbackBits).toBe(nativeBits)
}
})
})
describe('full encode/decode interop', () => {
it('points encoded with production can be decoded after re-encoding through fallback', () => {
const original: VecModel[] = [
{ x: 100, y: 200, z: 0.5 },
{ x: 102, y: 203, z: 0.55 },
{ x: 105, y: 208, z: 0.6 },
]
// Encode with production
const encoded = b64Vecs.encodePoints(original)
// Convert through fallback (decode then encode)
const bytes = fallbackBase64ToUint8Array(encoded)
const reEncoded = fallbackUint8ArrayToBase64(bytes)
// Should produce identical string
expect(reEncoded).toBe(encoded)
// And decode back to same points
const decoded = b64Vecs.decodePoints(reEncoded)
expect(decoded).toHaveLength(original.length)
for (let i = 0; i < original.length; i++) {
expect(decoded[i].x).toBeCloseTo(original[i].x, 2)
expect(decoded[i].y).toBeCloseTo(original[i].y, 2)
}
})
})
})
describe('b64Vecs legacy encoding', () => {
it('uses 8 base64 chars per point (6 bytes = 3 Float16s)', () => {
const onePoint = b64Vecs._legacyEncodePoints([{ x: 0, y: 0, z: 0.5 }])
expect(onePoint.length).toBe(8)
const twoPoints = b64Vecs._legacyEncodePoints([
{ x: 0, y: 0, z: 0.5 },
{ x: 1, y: 1, z: 0.5 },
])
expect(twoPoints.length).toBe(16)
})
it('round-trips correctly for small values', () => {
const points: VecModel[] = [
{ x: 0, y: 0, z: 0.5 },
{ x: 10, y: 20, z: 0.6 },
]
const decoded = b64Vecs._legacyDecodePoints(b64Vecs._legacyEncodePoints(points))
expect(decoded[0].x).toBeCloseTo(0, 2)
expect(decoded[1].x).toBeCloseTo(10, 2)
expect(decoded[1].y).toBeCloseTo(20, 2)
})
it('loses precision at large values due to Float16 limitations', () => {
// At 10000, Float16 step size is 8
// Values 10000, 10001, 10002, 10003, 10004 all encode to the same Float16 value
const points: VecModel[] = [
{ x: 10000, y: 0, z: 0.5 },
{ x: 10001, y: 0, z: 0.5 },
{ x: 10002, y: 0, z: 0.5 },
{ x: 10003, y: 0, z: 0.5 },
]
const decoded = b64Vecs._legacyDecodePoints(b64Vecs._legacyEncodePoints(points))
// All four distinct x values collapse to the same value (or very close)
const uniqueXValues = new Set(decoded.map((p) => Math.round(p.x)))
expect(uniqueXValues.size).toBeLessThanOrEqual(2) // Should collapse to 1-2 values, not 4
})
})
describe('2D encoding (skip-pressure)', () => {
it('round-trips x and y, supplies z = 0.5', () => {
const points: VecModel[] = [
{ x: 1000, y: 2000, z: 0.5 },
{ x: 1013, y: 2008, z: 0.5 },
{ x: 1027, y: 2011, z: 0.5 },
]
const decoded = b64Vecs.decodePoints2D(b64Vecs.encodePoints2D(points))
expect(decoded.length).toBe(3)
for (let i = 0; i < points.length; i++) {
expect(decoded[i].x).toBeCloseTo(points[i].x, 1)
expect(decoded[i].y).toBeCloseTo(points[i].y, 1)
expect(decoded[i].z).toBe(0.5)
}
})
it('encodePoints/decodePoints route through 2D when dim = 2', () => {
const points: VecModel[] = [
{ x: 10, y: 20, z: 0.5 },
{ x: 15, y: 28, z: 0.5 },
]
expect(b64Vecs.encodePoints(points, 2)).toBe(b64Vecs.encodePoints2D(points))
const decoded = b64Vecs.decodePoints(b64Vecs.encodePoints(points, 2), 2)
expect(decoded[1].z).toBe(0.5)
expect(decoded[1].x).toBeCloseTo(15, 1)
})
it('is ~33% smaller than 3D for the same points', () => {
const points: VecModel[] = Array.from({ length: 100 }, (_, i) => ({
x: i * 2.5,
y: 50 + i,
z: 0.5,
}))
const bytes3D = fallbackBase64ToUint8Array(b64Vecs.encodePoints(points)).length
const bytes2D = fallbackBase64ToUint8Array(b64Vecs.encodePoints2D(points)).length
const saving = 1 - bytes2D / bytes3D
expect(saving).toBeGreaterThan(0.3)
expect(saving).toBeLessThan(0.36)
})
it('decodeFirstPoint2D / decodeLastPoint2D match decodePoints2D (incl. via dim routing)', () => {
const points: VecModel[] = [
{ x: 5, y: 6, z: 0.5 },
{ x: 12, y: 9, z: 0.5 },
{ x: 20, y: 4, z: 0.5 },
]
const path = b64Vecs.encodePoints(points, 2)
const all = b64Vecs.decodePoints2D(path)
expect(b64Vecs.decodeFirstPoint(path, 2)).toEqual(all[0])
expect(b64Vecs.decodeLastPoint(path, 2)).toEqual(all[all.length - 1])
})
it('empty input returns empty', () => {
expect(b64Vecs.encodePoints2D([])).toBe('')
expect(b64Vecs.decodePoints2D('')).toEqual([])
})
})
describe('padding-aware fallback base64', () => {
it('matches Node Buffer for every trailing-byte remainder', () => {
for (let len = 0; len < 32; len++) {
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) bytes[i] = (i * 37 + 11) & 0xff
expect(fallbackUint8ArrayToBase64(bytes)).toBe(Buffer.from(bytes).toString('base64'))
expect(fallbackBase64ToUint8Array(fallbackUint8ArrayToBase64(bytes))).toEqual(bytes)
}
})
it('round-trips 2D buffer lengths (8 + 4(n-1) bytes)', () => {
for (const n of [1, 2, 3, 4, 5]) {
const len = 8 + (n - 1) * 4
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) bytes[i] = (i * 53 + 7) & 0xff
expect(fallbackBase64ToUint8Array(fallbackUint8ArrayToBase64(bytes))).toEqual(bytes)
}
})
})
describe('isSinglePoint', () => {
const mk = (n: number, dim?: 2 | 3) =>
b64Vecs.encodePoints(
Array.from({ length: n }, (_, i) => ({ x: i, y: i, z: 0.5 })),
dim
)
it('is true only for a single point, across 2D and 3D', () => {
expect(b64Vecs.isSinglePoint(mk(1, 3), 3)).toBe(true)
expect(b64Vecs.isSinglePoint(mk(2, 3), 3)).toBe(false)
expect(b64Vecs.isSinglePoint(mk(1, 2), 2)).toBe(true)
expect(b64Vecs.isSinglePoint(mk(2, 2), 2)).toBe(false)
expect(b64Vecs.isSinglePoint(mk(3, 2), 2)).toBe(false)
})
it('regression: a 2-point 2D stroke is not a dot (the old `< 24` check failed here)', () => {
const twoPoint2D = b64Vecs.encodePoints(
[
{ x: 0, y: 0, z: 0.5 },
{ x: 4, y: 1, z: 0.5 },
],
2
)
expect(twoPoint2D.length).toBe(16) // same length as a 3D dot
expect(twoPoint2D.length < 24).toBe(true) // the old buggy heuristic would call this a dot
expect(b64Vecs.isSinglePoint(twoPoint2D, 2)).toBe(false) // the fix
})
it('defaults to 3D when dim is omitted', () => {
expect(b64Vecs.isSinglePoint(mk(1), undefined)).toBe(true)
expect(b64Vecs.isSinglePoint(mk(2), undefined)).toBe(false)
})
})

View File

@@ -0,0 +1,636 @@
import { VecModel } from './geometry-types'
// Each point = 3 Float16s = 6 bytes = 8 base64 chars (legacy format)
const _POINT_B64_LENGTH = 8
// First point in delta encoding = 3 Float32s = 12 bytes = 16 base64 chars
const FIRST_POINT_B64_LENGTH = 16
// First point in 2D delta encoding = 2 Float32s = 8 bytes = 12 base64 chars (incl. padding)
const FIRST_POINT_2D_B64_LENGTH = 12
// Pressure value supplied when decoding non-pressure (2D) paths
const DEFAULT_PRESSURE = 0.5
/** Draw segment path encoded with 2 dimensions, XY — the constant pressure Z is dropped. @public */
export const DIM_2D = 2
/** Draw segment path encoded with 3 dimensions, XYZ. @public */
export const DIM_3D = 3
// O(1) lookup table for base64 decoding (maps char code -> 6-bit value)
const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
const B64_LOOKUP = new Uint8Array(128)
for (let i = 0; i < 64; i++) {
B64_LOOKUP[BASE64_CHARS.charCodeAt(i)] = i
}
// Mask for one base64 sextet: the low 6 bits select an index into BASE64_CHARS (0–63).
const SIX_BIT_MASK = 0x3f
// '=' padding character, appended on encode so the output length is a multiple of 4.
const PADDING_CHAR_CODE = '='.charCodeAt(0)
// Precomputed powers of 2 for Float16 exponents (exp - 15, so indices 0-30 map to 2^-15 to 2^15)
const POW2 = new Float64Array(31)
for (let i = 0; i < 31; i++) {
POW2[i] = Math.pow(2, i - 15)
}
const POW2_SUBNORMAL = Math.pow(2, -14) / 1024 // For subnormal numbers
// Precomputed mantissa values: 1 + frac/1024 for all 1024 possible frac values
// Avoids division in hot path
const MANTISSA = new Float64Array(1024)
for (let i = 0; i < 1024; i++) {
MANTISSA[i] = 1 + i / 1024
}
function nativeGetFloat16(dataView: DataView, offset: number): number {
return (dataView as any).getFloat16(offset, true)
}
function fallbackGetFloat16(dataView: DataView, offset: number): number {
return float16BitsToNumber(dataView.getUint16(offset, true))
}
const getFloat16 =
typeof (DataView.prototype as any).getFloat16 === 'function'
? nativeGetFloat16
: fallbackGetFloat16
function nativeSetFloat16(dataView: DataView, offset: number, value: number): void {
;(dataView as any).setFloat16(offset, value, true)
}
function fallbackSetFloat16(dataView: DataView, offset: number, value: number): void {
dataView.setUint16(offset, numberToFloat16Bits(value), true)
}
const setFloat16 =
typeof (DataView.prototype as any).setFloat16 === 'function'
? nativeSetFloat16
: fallbackSetFloat16
function nativeBase64ToUint8Array(base64: string): Uint8Array {
return Uint8Array.fromBase64(base64)
}
/** @internal */
export function fallbackBase64ToUint8Array(base64: string): Uint8Array {
// Strip up to 2 '=' padding characters to determine the real byte count.
// The 2D point layout (8 + 4(n-1) bytes) is not a multiple of 3, so encoded
// paths can carry padding the original multiple-of-3-only decoder couldn't read.
const paddedLength = base64.length
let padding = 0
if (paddedLength > 0 && base64.charCodeAt(paddedLength - 1) === PADDING_CHAR_CODE) {
padding++
if (paddedLength > 1 && base64.charCodeAt(paddedLength - 2) === PADDING_CHAR_CODE) {
padding++
}
}
const numBytes = Math.floor((paddedLength * 3) / 4) - padding
const bytes = new Uint8Array(numBytes)
let byteIndex = 0
// The reverse of encoding: each 4 chars are 4 six-bit values that pack back into
// one 24-bit number, which we then read out as 3 bytes (& 255 keeps one byte).
const fullGroups = Math.floor((paddedLength - padding) / 4) * 4
for (let i = 0; i < fullGroups; i += 4) {
const c0 = B64_LOOKUP[base64.charCodeAt(i)]
const c1 = B64_LOOKUP[base64.charCodeAt(i + 1)]
const c2 = B64_LOOKUP[base64.charCodeAt(i + 2)]
const c3 = B64_LOOKUP[base64.charCodeAt(i + 3)]
const bitmap = (c0 << 18) | (c1 << 12) | (c2 << 6) | c3
bytes[byteIndex++] = (bitmap >> 16) & 255 // top byte (bits 23–16)
bytes[byteIndex++] = (bitmap >> 8) & 255 // middle byte (bits 15–8)
bytes[byteIndex++] = bitmap & 255 // bottom byte (bits 7–0)
}
// Final group when padded: 3 valid chars -> 2 bytes, 2 valid chars -> 1 byte.
if (padding === 1) {
const c0 = B64_LOOKUP[base64.charCodeAt(fullGroups)]
const c1 = B64_LOOKUP[base64.charCodeAt(fullGroups + 1)]
const c2 = B64_LOOKUP[base64.charCodeAt(fullGroups + 2)]
const bitmap = (c0 << 18) | (c1 << 12) | (c2 << 6)
bytes[byteIndex++] = (bitmap >> 16) & 255
bytes[byteIndex++] = (bitmap >> 8) & 255
} else if (padding === 2) {
const c0 = B64_LOOKUP[base64.charCodeAt(fullGroups)]
const c1 = B64_LOOKUP[base64.charCodeAt(fullGroups + 1)]
const bitmap = (c0 << 18) | (c1 << 12)
bytes[byteIndex++] = (bitmap >> 16) & 255
}
return bytes
}
function nativeUint8ArrayToBase64(uint8Array: Uint8Array): string {
return uint8Array.toBase64()
}
/** @internal */
export function fallbackUint8ArrayToBase64(uint8Array: Uint8Array): string {
const len = uint8Array.length
const fullGroups = Math.floor(len / 3) * 3
let result = ''
// base64 represents 3 bytes (24 bits) as 4 characters of 6 bits each. For each
// group of 3 bytes we pack them into one 24-bit number, then read it back out as
// four 6-bit slices and use each slice (a value 0–63) to index into the 64-char
// alphabet. `>> n` shifts the wanted slice down to the bottom; SIX_BIT_MASK then
// discards everything above those 6 bits.
for (let i = 0; i < fullGroups; i += 3) {
const byte1 = uint8Array[i]
const byte2 = uint8Array[i + 1]
const byte3 = uint8Array[i + 2]
const bitmap = (byte1 << 16) | (byte2 << 8) | byte3
result +=
BASE64_CHARS[(bitmap >> 18) & SIX_BIT_MASK] + // bits 23–18 (top sextet)
BASE64_CHARS[(bitmap >> 12) & SIX_BIT_MASK] + // bits 17–12
BASE64_CHARS[(bitmap >> 6) & SIX_BIT_MASK] + // bits 11–6
BASE64_CHARS[bitmap & SIX_BIT_MASK] // bits 5–0 (bottom sextet)
}
// A trailing 1 or 2 bytes can't fill a whole 4-char group, so we emit only the
// chars their bits cover and pad the rest with '=' to keep the length a multiple
// of 4. Standard base64 — matches the native API and Node's Buffer, so a path
// encoded by the fallback round-trips on a runtime that decodes with the native one.
const remaining = len - fullGroups
if (remaining === 1) {
// 8 bits → 2 sextets (the 2nd only partly filled), then "=="
const bitmap = uint8Array[fullGroups] << 16
result +=
BASE64_CHARS[(bitmap >> 18) & SIX_BIT_MASK] +
BASE64_CHARS[(bitmap >> 12) & SIX_BIT_MASK] +
'=='
} else if (remaining === 2) {
// 16 bits → 3 sextets (the 3rd only partly filled), then "="
const bitmap = (uint8Array[fullGroups] << 16) | (uint8Array[fullGroups + 1] << 8)
result +=
BASE64_CHARS[(bitmap >> 18) & SIX_BIT_MASK] +
BASE64_CHARS[(bitmap >> 12) & SIX_BIT_MASK] +
BASE64_CHARS[(bitmap >> 6) & SIX_BIT_MASK] +
'='
}
return result
}
/**
* Convert a Uint8Array to base64.
* Processes bytes in groups of 3 to produce 4 base64 characters.
*
* @internal
*/
const uint8ArrayToBase64 =
typeof Uint8Array.prototype.toBase64 === 'function'
? nativeUint8ArrayToBase64
: fallbackUint8ArrayToBase64
/**
* Convert a base64 string to Uint8Array.
*
* @internal
*/
const base64ToUint8Array =
typeof Uint8Array.fromBase64 === 'function'
? nativeBase64ToUint8Array
: fallbackBase64ToUint8Array
/**
* Convert Float16 bits to a number using optimized lookup tables.
* Handles normal numbers, subnormal numbers, zero, infinity, and NaN.
*
* @param bits - The 16-bit Float16 value to decode
* @returns The decoded number value
* @internal
*/
export function float16BitsToNumber(bits: number): number {
const sign = bits >> 15
const exp = (bits >> 10) & 0x1f
const frac = bits & 0x3ff
if (exp === 0) {
// Subnormal or zero - rare case
return sign ? -frac * POW2_SUBNORMAL : frac * POW2_SUBNORMAL
}
if (exp === 31) {
// Infinity or NaN - very rare
return frac ? NaN : sign ? -Infinity : Infinity
}
// Normal case - two table lookups, one multiply, no division
const magnitude = POW2[exp] * MANTISSA[frac]
return sign ? -magnitude : magnitude
}
/**
* Convert a number to Float16 bits.
* Handles normal numbers, subnormal numbers, zero, infinity, and NaN.
*
* @param value - The number to encode as Float16
* @returns The 16-bit Float16 representation of the number
* @internal
*/
export function numberToFloat16Bits(value: number): number {
if (value === 0) return Object.is(value, -0) ? 0x8000 : 0
if (!Number.isFinite(value)) {
if (Number.isNaN(value)) return 0x7e00
return value > 0 ? 0x7c00 : 0xfc00
}
const sign = value < 0 ? 1 : 0
value = Math.abs(value)
// Find exponent and mantissa
const exp = Math.floor(Math.log2(value))
let expBiased = exp + 15
if (expBiased >= 31) {
// Overflow to infinity
return (sign << 15) | 0x7c00
}
if (expBiased <= 0) {
// Subnormal or underflow
const frac = Math.round(value * Math.pow(2, 14) * 1024)
return (sign << 15) | (frac & 0x3ff)
}
// Normal number
const mantissa = value / Math.pow(2, exp) - 1
let frac = Math.round(mantissa * 1024)
// Handle rounding overflow: if frac rounds to 1024, increment exponent
if (frac >= 1024) {
frac = 0
expBiased++
if (expBiased >= 31) {
// Overflow to infinity
return (sign << 15) | 0x7c00
}
}
return (sign << 15) | (expBiased << 10) | frac
}
/**
* Utilities for encoding and decoding points using base64 and Float16 encoding.
* Provides functions for converting between VecModel arrays and compact base64 strings,
* as well as individual point encoding/decoding operations.
*
* @public
*/
export class b64Vecs {
/**
* Encode a single point (x, y, z) to 8 base64 characters using legacy Float16 encoding.
* Each coordinate is encoded as a Float16 value, resulting in 6 bytes total.
*
* @param x - The x coordinate
* @param y - The y coordinate
* @param z - The z coordinate
* @returns An 8-character base64 string representing the point
* @internal
*/
static _legacyEncodePoint(x: number, y: number, z: number): string {
const buffer = new Uint8Array(6)
const dataView = new DataView(buffer.buffer)
setFloat16(dataView, 0, x)
setFloat16(dataView, 2, y)
setFloat16(dataView, 4, z)
return uint8ArrayToBase64(buffer)
}
/**
* Convert an array of VecModels to a base64 string using legacy Float16 encoding.
* Uses Float16 encoding for each coordinate (x, y, z). If a point's z value is
* undefined, it defaults to 0.5.
*
* @param points - An array of VecModel objects to encode
* @returns A base64-encoded string containing all points
* @internal Used only for migrations from legacy format
*/
static _legacyEncodePoints(points: VecModel[]): string {
if (points.length === 0) return ''
// 3 Float16s per point = 6 bytes per point
const buffer = new Uint8Array(points.length * 6)
const dataView = new DataView(buffer.buffer)
for (let i = 0; i < points.length; i++) {
const p = points[i]
const offset = i * 6
setFloat16(dataView, offset, p.x)
setFloat16(dataView, offset + 2, p.y)
setFloat16(dataView, offset + 4, p.z ?? 0.5)
}
return uint8ArrayToBase64(buffer)
}
/**
* Convert a legacy base64 string back to an array of VecModels.
* Decodes Float16-encoded coordinates (x, y, z) from the base64 string.
*
* @param base64 - The base64-encoded string containing point data
* @returns An array of VecModel objects decoded from the string
* @internal Used only for migrations from legacy format
*/
static _legacyDecodePoints(base64: string): VecModel[] {
const bytes = base64ToUint8Array(base64)
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
const result: VecModel[] = []
for (let offset = 0; offset < bytes.length; offset += 6) {
result.push({
x: getFloat16(dataView, offset),
y: getFloat16(dataView, offset + 2),
z: getFloat16(dataView, offset + 4),
})
}
return result
}
/**
* Encode an array of VecModels using delta encoding for improved precision.
* The first point is stored as Float32 (high precision for absolute position),
* subsequent points are stored as Float16 deltas from the previous point.
* This provides full precision for the starting position and excellent precision
* for deltas between consecutive points (which are typically small values).
*
* Format:
* - First point: 3 Float32 values = 12 bytes = 16 base64 chars
* - Delta points: 3 Float16 values each = 6 bytes = 8 base64 chars each
*
* @param points - An array of VecModel objects to encode
* @param dim - Encoding dimension; `2` routes through the 2D variant (drops z), `3` (default) keeps x, y, z
* @returns A base64-encoded string containing delta-encoded points
* @public
*/
static encodePoints(points: VecModel[], dim?: 2 | 3): string {
if (dim === DIM_2D) return b64Vecs.encodePoints2D(points)
if (points.length === 0) return ''
// First point: 3 Float32s = 12 bytes
// Remaining points: 3 Float16s each = 6 bytes each
const firstPointBytes = 12
const deltaBytes = (points.length - 1) * 6
const totalBytes = firstPointBytes + deltaBytes
const buffer = new Uint8Array(totalBytes)
const dataView = new DataView(buffer.buffer)
// First point is stored as Float32 for full precision
const first = points[0]
dataView.setFloat32(0, first.x, true) // little-endian
dataView.setFloat32(4, first.y, true)
dataView.setFloat32(8, first.z ?? 0.5, true)
// Subsequent points are Float16 deltas from the previous point
let prevX = first.x
let prevY = first.y
let prevZ = first.z ?? 0.5
for (let i = 1; i < points.length; i++) {
const p = points[i]
const z = p.z ?? 0.5
const offset = firstPointBytes + (i - 1) * 6
setFloat16(dataView, offset, p.x - prevX)
setFloat16(dataView, offset + 2, p.y - prevY)
setFloat16(dataView, offset + 4, z - prevZ)
prevX = p.x
prevY = p.y
prevZ = z
}
return uint8ArrayToBase64(buffer)
}
/**
* Decode a delta-encoded base64 string back to an array of absolute VecModels.
* The first point is stored as Float32 (high precision), subsequent points are
* Float16 deltas that are accumulated to reconstruct absolute positions.
*
* @param base64 - The base64-encoded string containing delta-encoded point data
* @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z
* @returns An array of VecModel objects with absolute coordinates
* @public
*/
static decodePoints(base64: string, dim?: 2 | 3): VecModel[] {
if (dim === DIM_2D) return b64Vecs.decodePoints2D(base64)
if (base64.length === 0) return []
const bytes = base64ToUint8Array(base64)
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
const result: VecModel[] = []
// First point is Float32 (12 bytes)
let x = dataView.getFloat32(0, true)
let y = dataView.getFloat32(4, true)
let z = dataView.getFloat32(8, true)
result.push({ x, y, z })
// Subsequent points are Float16 deltas - accumulate to get absolute positions
const firstPointBytes = 12
for (let offset = firstPointBytes; offset < bytes.length; offset += 6) {
x += getFloat16(dataView, offset)
y += getFloat16(dataView, offset + 2)
z += getFloat16(dataView, offset + 4)
result.push({ x, y, z })
}
return result
}
/**
* Get the first point from a delta-encoded base64 string.
* The first point is stored as Float32 for full precision.
*
* @param b64Points - The delta-encoded base64 string
* @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z
* @returns The first point as a VecModel, or null if the string is too short
* @public
*/
static decodeFirstPoint(b64Points: string, dim?: 2 | 3): VecModel | null {
if (dim === DIM_2D) return b64Vecs.decodeFirstPoint2D(b64Points)
// First point needs 16 base64 chars (12 bytes as Float32)
if (b64Points.length < FIRST_POINT_B64_LENGTH) return null
const bytes = base64ToUint8Array(b64Points.slice(0, FIRST_POINT_B64_LENGTH))
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
return {
x: dataView.getFloat32(0, true),
y: dataView.getFloat32(4, true),
z: dataView.getFloat32(8, true),
}
}
/**
* Get the last point from a delta-encoded base64 string.
* Requires decoding all points to accumulate deltas.
*
* @param b64Points - The delta-encoded base64 string
* @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z
* @returns The last point as a VecModel, or null if the string is too short
* @public
*/
static decodeLastPoint(b64Points: string, dim?: 2 | 3): VecModel | null {
if (dim === DIM_2D) return b64Vecs.decodeLastPoint2D(b64Points)
if (b64Points.length < FIRST_POINT_B64_LENGTH) return null
const bytes = base64ToUint8Array(b64Points)
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
// Start with first point (Float32)
let x = dataView.getFloat32(0, true)
let y = dataView.getFloat32(4, true)
let z = dataView.getFloat32(8, true)
// Accumulate all Float16 deltas to get the last point
const firstPointBytes = 12
for (let offset = firstPointBytes; offset < bytes.length; offset += 6) {
x += getFloat16(dataView, offset)
y += getFloat16(dataView, offset + 2)
z += getFloat16(dataView, offset + 4)
}
return { x, y, z }
}
/**
* Encode an array of VecModels as 2D delta-encoded points, dropping z entirely.
* Use for draw shapes from devices that don't report pressure, where z is a
* constant 0.5 and storing it wastes ~33% of per-point bytes.
*
* Format:
* - First point: 2 Float32 values (x, y) = 8 bytes
* - Delta points: 2 Float16 values (dx, dy) = 4 bytes each
*
* @param points - An array of VecModel objects to encode (z is discarded)
* @returns A base64-encoded string containing 2D delta-encoded points
* @public
*/
static encodePoints2D(points: VecModel[]): string {
if (points.length === 0) return ''
const firstPointBytes = 8
const deltaBytes = (points.length - 1) * 4
const buffer = new Uint8Array(firstPointBytes + deltaBytes)
const dataView = new DataView(buffer.buffer)
const first = points[0]
dataView.setFloat32(0, first.x, true)
dataView.setFloat32(4, first.y, true)
let prevX = first.x
let prevY = first.y
for (let i = 1; i < points.length; i++) {
const p = points[i]
const offset = firstPointBytes + (i - 1) * 4
setFloat16(dataView, offset, p.x - prevX)
setFloat16(dataView, offset + 2, p.y - prevY)
prevX = p.x
prevY = p.y
}
return uint8ArrayToBase64(buffer)
}
/**
* Decode a 2D delta-encoded base64 string back to an array of absolute VecModels.
* The z coordinate is always set to 0.5 (the default pressure value) so downstream
* consumers don't need a separate code path.
*
* @param base64 - The base64-encoded string containing 2D delta-encoded point data
* @returns An array of VecModel objects with absolute (x, y) and z = 0.5
* @public
*/
static decodePoints2D(base64: string): VecModel[] {
if (base64.length === 0) return []
const bytes = base64ToUint8Array(base64)
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
const result: VecModel[] = []
let x = dataView.getFloat32(0, true)
let y = dataView.getFloat32(4, true)
result.push({ x, y, z: DEFAULT_PRESSURE })
const firstPointBytes = 8
for (let offset = firstPointBytes; offset < bytes.length; offset += 4) {
x += getFloat16(dataView, offset)
y += getFloat16(dataView, offset + 2)
result.push({ x, y, z: DEFAULT_PRESSURE })
}
return result
}
/**
* Get the first point from a 2D delta-encoded base64 string.
*
* @param b64Points - The 2D delta-encoded base64 string
* @returns The first point with z = 0.5, or null if the string is too short
* @public
*/
static decodeFirstPoint2D(b64Points: string): VecModel | null {
if (b64Points.length < FIRST_POINT_2D_B64_LENGTH) return null
const bytes = base64ToUint8Array(b64Points.slice(0, FIRST_POINT_2D_B64_LENGTH))
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
return {
x: dataView.getFloat32(0, true),
y: dataView.getFloat32(4, true),
z: DEFAULT_PRESSURE,
}
}
/**
* Get the last point from a 2D delta-encoded base64 string.
* Requires decoding all points to accumulate deltas.
*
* @param b64Points - The 2D delta-encoded base64 string
* @returns The last point with z = 0.5, or null if the string is too short
* @public
*/
static decodeLastPoint2D(b64Points: string): VecModel | null {
if (b64Points.length < FIRST_POINT_2D_B64_LENGTH) return null
const bytes = base64ToUint8Array(b64Points)
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
let x = dataView.getFloat32(0, true)
let y = dataView.getFloat32(4, true)
const firstPointBytes = 8
for (let offset = firstPointBytes; offset < bytes.length; offset += 4) {
x += getFloat16(dataView, offset)
y += getFloat16(dataView, offset + 2)
}
return { x, y, z: DEFAULT_PRESSURE }
}
/**
* Whether an encoded path contains only a single point (a "dot"), inferred from
* the encoded length without decoding — cheap enough for the render path.
*
* The single-point length depends on the encoding dimension, so this takes the
* segment's `dim`: a one-point path is `FIRST_POINT_B64_LENGTH` chars (3D) or
* `FIRST_POINT_2D_B64_LENGTH` chars (2D). Keeping this beside the layout constants
* is deliberate — it is the single source of truth for "how long is one point", so
* callers never hard-code a length threshold (which silently breaks when a new
* encoding is added).
*
* @param b64Points - The encoded path string
* @param dim - Encoding dimension; `2` for (x, y), `3` (default) for (x, y, z)
* @returns true if the path encodes exactly one point
* @public
*/
static isSinglePoint(b64Points: string, dim?: 2 | 3): boolean {
return b64Points.length <= (dim === DIM_2D ? FIRST_POINT_2D_B64_LENGTH : FIRST_POINT_B64_LENGTH)
}
}

View File

@@ -0,0 +1,65 @@
import { T } from '@tldraw/validate'
/**
* A serializable model for 2D boxes.
*
* @public */
export interface BoxModel {
x: number
y: number
w: number
h: number
}
/**
* A serializable model for 2D vectors.
*
* @public */
export interface VecModel {
x: number
y: number
z?: number
}
/**
* Validator for VecModel objects that ensures they have numeric x and y coordinates,
* with an optional z coordinate for 3D vectors. Used throughout the schema to
* validate point and vector data structures.
*
* @public
* @example
* ```ts
* const vector2D = { x: 10, y: 20 }
* const isValid = vecModelValidator.check(vector2D) // true
*
* const vector3D = { x: 10, y: 20, z: 30 }
* const isValid3D = vecModelValidator.check(vector3D) // true
* ```
*/
export const vecModelValidator: T.ObjectValidator<VecModel> = T.object({
x: T.number,
y: T.number,
z: T.number.optional(),
})
/**
* Validator for BoxModel objects that ensures they have numeric x, y coordinates
* for position and w, h values for width and height. Used throughout the schema
* to validate bounding box and rectangular area data structures.
*
* @public
* @example
* ```ts
* const box = { x: 10, y: 20, w: 100, h: 50 }
* const isValid = boxModelValidator.check(box) // true
*
* const invalidBox = { x: 10, y: 20, w: -5, h: 50 }
* const isValidNegative = boxModelValidator.check(invalidBox) // true (validator allows negative values)
* ```
*/
export const boxModelValidator: T.ObjectValidator<BoxModel> = T.object({
x: T.number,
y: T.number,
w: T.number,
h: T.number,
})

View File

@@ -0,0 +1,33 @@
import type { RecordId, UnknownRecord } from '@tldraw/store'
import { T } from '@tldraw/validate'
/**
* Creates a validator for typed record IDs that ensures they follow the correct
* format with the specified prefix. Record IDs in tldraw follow the pattern
* "prefix:identifier" where the prefix indicates the record type.
*
* @param prefix - The required prefix for the ID (e.g., 'shape', 'page', 'asset')
* @returns A validator that checks the ID format and returns the typed ID
* @public
* @example
* ```ts
* const shapeIdValidator = idValidator<TLShapeId>('shape')
* const validId = shapeIdValidator.validate('shape:abc123') // Returns 'shape:abc123' as TLShapeId
*
* const pageIdValidator = idValidator<TLPageId>('page')
* const pageId = pageIdValidator.validate('page:main') // Returns 'page:main' as TLPageId
*
* // This would throw an error:
* // shapeIdValidator.validate('page:abc123') // Error: shape ID must start with "shape:"
* ```
*/
export function idValidator<Id extends RecordId<UnknownRecord>>(
prefix: Id['__type__']['typeName']
): T.Validator<Id> {
return T.string.refine((id) => {
if (!id.startsWith(`${prefix}:`)) {
throw new Error(`${prefix} ID must start with "${prefix}:"`)
}
return id as Id
})
}

View File

@@ -0,0 +1,290 @@
import {
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { mapObjectMapValues } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { createAssetValidator, TLBaseAsset } from '../assets/TLBaseAsset'
import { TLBookmarkAsset } from '../assets/TLBookmarkAsset'
import { TLImageAsset } from '../assets/TLImageAsset'
import { TLVideoAsset } from '../assets/TLVideoAsset'
import { SchemaPropsInfo } from '../createTLSchema'
import { TLPropsMigrations } from '../recordsWithProps'
import { ExtractShapeByProps } from './TLShape'
/**
* The default set of asset types that are available in the editor.
*
* @example
* ```ts
* const imageAsset: TLDefaultAsset = {
* id: 'asset:image123',
* typeName: 'asset',
* type: 'image',
* props: {
* src: 'https://example.com/image.jpg',
* w: 800,
* h: 600,
* mimeType: 'image/jpeg',
* isAnimated: false,
* name: 'image.jpg',
* },
* meta: {},
* }
* ```
*
* @public
*/
export type TLDefaultAsset = TLImageAsset | TLVideoAsset | TLBookmarkAsset
/**
* A type for an asset that is available in the editor but whose type is
* unknown—either one of the editor's default assets or else a custom asset.
*
* @public
*/
export type TLUnknownAsset = TLBaseAsset<string, object>
/** @public */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface TLGlobalAssetPropsMap {}
/** @public */
// prettier-ignore
export type TLIndexedAssets = {
[K in keyof TLGlobalAssetPropsMap | TLDefaultAsset['type'] as K extends TLDefaultAsset['type']
? K extends keyof TLGlobalAssetPropsMap
? TLGlobalAssetPropsMap[K] extends null | undefined
? never
: K
: K
: K]: K extends TLDefaultAsset['type']
? K extends keyof TLGlobalAssetPropsMap
? TLBaseAsset<K, TLGlobalAssetPropsMap[K]>
: Extract<TLDefaultAsset, { type: K }>
: TLBaseAsset<K, TLGlobalAssetPropsMap[K & keyof TLGlobalAssetPropsMap]>
}
/**
* The set of all assets that are available in the editor.
*
* This is the primary asset type used throughout tldraw. It includes both the
* built-in default assets and any custom assets registered via
* {@link TLGlobalAssetPropsMap} augmentation.
*
* You can use this type without a type argument to work with any asset, or pass
* a specific asset type string (e.g., `'image'`, `'video'`, `'bookmark'`) to
* narrow down to that specific asset type.
*
* @example
* ```ts
* // Register a custom asset type
* declare module '@tldraw/tlschema' {
* interface TLGlobalAssetPropsMap {
* file: { name: string; size: number; mimeType: string; src: string | null }
* }
* }
* ```
*
* @public
*/
export type TLAsset<K extends keyof TLIndexedAssets = keyof TLIndexedAssets> = TLIndexedAssets[K]
/**
* Migration version identifiers for asset record schema evolution.
* Each version represents a breaking change that requires data migration.
*
* @example
* ```ts
* // Check if a migration is needed
* const needsMigration = currentVersion < assetVersions.AddMeta
* ```
*
* @public
*/
export const assetVersions = createMigrationIds('com.tldraw.asset', {
AddMeta: 1,
} as const)
/**
* Migration sequence for evolving asset record structure over time.
* Handles converting asset records from older schema versions to current format.
*
* @example
* ```ts
* // Migration is applied automatically when loading old documents
* const migratedStore = migrator.migrateStoreSnapshot({
* schema: oldSchema,
* store: oldStoreSnapshot,
* })
* ```
*
* @public
*/
export const assetMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.asset',
recordType: 'asset',
sequence: [
{
id: assetVersions.AddMeta,
up: (record) => {
;(record as any).meta = {}
},
},
],
})
/**
* Partial type for TLAsset allowing optional properties except id and type.
* Useful for creating or updating assets where not all properties need to be specified.
*
* @example
* ```ts
* // Create a partial asset for updating
* const partialAsset: TLAssetPartial<TLImageAsset> = {
* id: 'asset:image123',
* type: 'image',
* props: {
* w: 800, // Only updating width
* },
* }
*
* // Use in asset updates
* editor.updateAssets([partialAsset])
* ```
*
* @public
*/
export type TLAssetPartial<T extends TLAsset = TLAsset> = T extends T
? {
id: TLAssetId
type: T['type']
props?: Partial<T['props']>
meta?: Partial<T['meta']>
} & Partial<Omit<T, 'type' | 'id' | 'props' | 'meta'>>
: never
/**
* Creates the record type definition for assets based on registered asset schemas.
* This function follows the same pattern as `createShapeRecordType` and `createBindingRecordType`.
*
* @param assets - Record of asset type names to their schema configuration
* @returns A configured record type for assets with validation
*
* @example
* ```ts
* const AssetRecordType = createAssetRecordType({
* image: { migrations: imageAssetMigrations, props: imageAssetProps },
* video: { migrations: videoAssetMigrations, props: videoAssetProps },
* bookmark: { migrations: bookmarkAssetMigrations, props: bookmarkAssetProps },
* })
* ```
*
* @internal
*/
export function createAssetRecordType(assets: Record<string, SchemaPropsInfo>) {
return createRecordType('asset', {
scope: 'document',
validator: T.model(
'asset',
T.union(
'type',
mapObjectMapValues(assets, (type, { props, meta }) =>
createAssetValidator(type, props, meta)
)
)
),
}).withDefaultProperties(() => ({
meta: {},
}))
}
/**
* Record type definition for default TLAsset records with document scope and default metadata.
*
* @public
*/
export const AssetRecordType = createRecordType<TLAsset>('asset', {
scope: 'document',
}).withDefaultProperties(() => ({
meta: {},
}))
/**
* Branded string type for asset record identifiers.
* Prevents mixing asset IDs with other record IDs at compile time.
*
* @example
* ```ts
* const imageShape = {
* type: 'image',
* props: {
* assetId: 'asset:image123' as TLAssetId,
* },
* }
* ```
*
* @public
*/
export type TLAssetId = RecordId<TLBaseAsset<any, any>>
/**
* Union type of all shapes that reference assets through an assetId property.
* Includes image shapes, video shapes, and any other shapes that depend on external assets.
*
* @example
* ```ts
* function handleAssetShape(shape: TLAssetShape) {
* const assetId = shape.props.assetId
* if (!assetId) return
* const asset = editor.getAsset(assetId)
* // Handle the asset...
* }
* ```
*
* @public
*/
export type TLAssetShape = ExtractShapeByProps<{ assetId: TLAssetId }>
/**
* Creates a migration sequence for asset properties.
*
* @example
* ```ts
* const migrations = createAssetPropsMigrationSequence({
* sequence: [
* { id: 'com.myapp.asset.custom/1', up: (props) => { props.newField = '' } },
* ],
* })
* ```
*
* @public
*/
export function createAssetPropsMigrationSequence(
migrations: TLPropsMigrations
): TLPropsMigrations {
return migrations
}
/**
* Creates properly formatted migration IDs for asset properties.
*
* @example
* ```ts
* const assetPropsVersions = createAssetPropsMigrationIds('file', {
* AddFoo: 1,
* RenameBar: 2,
* })
* // => { AddFoo: 'com.tldraw.asset.file/1', RenameBar: 'com.tldraw.asset.file/2' }
* ```
*
* @public
*/
export function createAssetPropsMigrationIds<S extends string, T extends Record<string, number>>(
assetType: S,
ids: T
): { [k in keyof T]: `com.tldraw.asset.${S}/${T[k]}` } {
return mapObjectMapValues(ids, (_k, v) => `com.tldraw.asset.${assetType}/${v}`) as any
}

View File

@@ -0,0 +1,434 @@
import {
RecordId,
UnknownRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
} from '@tldraw/store'
import { mapObjectMapValues, uniqueId } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { TLArrowBinding } from '../bindings/TLArrowBinding'
import { TLBaseBinding, createBindingValidator } from '../bindings/TLBaseBinding'
import { SchemaPropsInfo } from '../createTLSchema'
import { TLPropsMigrations } from '../recordsWithProps'
/**
* The default set of bindings that are available in the editor.
* Currently includes only arrow bindings, but can be extended with custom bindings.
*
* @example
* ```ts
* // Arrow binding connects an arrow to shapes
* const arrowBinding: TLDefaultBinding = {
* id: 'binding:arrow1',
* typeName: 'binding',
* type: 'arrow',
* fromId: 'shape:arrow1',
* toId: 'shape:rectangle1',
* props: {
* terminal: 'end',
* normalizedAnchor: { x: 0.5, y: 0.5 },
* isExact: false,
* isPrecise: true
* }
* }
* ```
*
* @public
*/
export type TLDefaultBinding = TLArrowBinding
/**
* A type for a binding that is available in the editor but whose type is
* unknown—either one of the editor's default bindings or else a custom binding.
* Used internally for type-safe handling of bindings with unknown structure.
*
* @example
* ```ts
* // Function that works with any binding type
* function processBinding(binding: TLUnknownBinding) {
* console.log(`Processing ${binding.type} binding from ${binding.fromId} to ${binding.toId}`)
* // Handle binding properties generically
* }
* ```
*
* @public
*/
export type TLUnknownBinding = TLBaseBinding<string, object>
/** @public */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface TLGlobalBindingPropsMap {}
/** @public */
// prettier-ignore
export type TLIndexedBindings = {
// We iterate over a union of augmented keys and default binding types.
// This allows us to include (or conditionally exclude or override) the default bindings in one go.
//
// In the `as` clause we are filtering out disabled bindings.
[K in keyof TLGlobalBindingPropsMap | TLDefaultBinding['type'] as K extends TLDefaultBinding['type']
? K extends keyof TLGlobalBindingPropsMap
? // if it extends a nullish value the user has disabled this binding type so we filter it out with never
TLGlobalBindingPropsMap[K] extends null | undefined
? never
: K
: K
: K]: K extends TLDefaultBinding['type']
? // if it's a default binding type we need to check if it's been overridden
K extends keyof TLGlobalBindingPropsMap
? // if it has been overriden then use the custom binding definition
TLBaseBinding<K, TLGlobalBindingPropsMap[K]>
: // if it has not been overriden then reuse existing type aliases for better type display
Extract<TLDefaultBinding, { type: K }>
: // use the custom binding definition
TLBaseBinding<K, TLGlobalBindingPropsMap[K & keyof TLGlobalBindingPropsMap]>
}
/**
* The set of all bindings that are available in the editor.
* Bindings represent relationships between shapes, such as arrows connecting to other shapes.
*
* You can use this type without a type argument to work with any binding, or pass
* a specific binding type string (e.g., `'arrow'`) to narrow down to that specific binding type.
*
* @example
* ```ts
* // Check binding type and handle accordingly
* function handleBinding(binding: TLBinding) {
* switch (binding.type) {
* case 'arrow':
* // Handle arrow binding
* break
* default:
* // Handle unknown custom binding
* break
* }
* }
*
* // Narrow to a specific binding type by passing the type as a generic argument
* function getArrowSourceId(binding: TLBinding<'arrow'>) {
* return binding.fromId // TypeScript knows this is a TLArrowBinding
* }
* ```
*
* @public
*/
export type TLBinding<K extends keyof TLIndexedBindings = keyof TLIndexedBindings> =
TLIndexedBindings[K]
/**
* Type for updating existing bindings with partial properties.
* Only the id and type are required, all other properties are optional.
*
* @example
* ```ts
* // Update arrow binding properties
* const bindingUpdate: TLBindingUpdate<TLArrowBinding> = {
* id: 'binding:arrow1',
* type: 'arrow',
* props: {
* normalizedAnchor: { x: 0.7, y: 0.3 } // Only update anchor position
* }
* }
*
* editor.updateBindings([bindingUpdate])
* ```
*
* @public
*/
export type TLBindingUpdate<T extends TLBinding = TLBinding> = T extends T
? {
id: TLBindingId
type: T['type']
typeName?: T['typeName']
fromId?: T['fromId']
toId?: T['toId']
props?: Partial<T['props']>
meta?: Partial<T['meta']>
}
: never
/**
* Type for creating new bindings with required fromId and toId.
* The id is optional and will be generated if not provided.
*
* @example
* ```ts
* // Create a new arrow binding
* const newBinding: TLBindingCreate<TLArrowBinding> = {
* type: 'arrow',
* fromId: 'shape:arrow1',
* toId: 'shape:rectangle1',
* props: {
* terminal: 'end',
* normalizedAnchor: { x: 0.5, y: 0.5 },
* isExact: false,
* isPrecise: true
* }
* }
*
* editor.createBindings([newBinding])
* ```
*
* @public
*/
export type TLBindingCreate<T extends TLBinding = TLBinding> = T extends T
? {
id?: TLBindingId
type: T['type']
typeName?: T['typeName']
fromId: T['fromId']
toId: T['toId']
props?: Partial<T['props']>
meta?: Partial<T['meta']>
}
: never
/**
* Branded string type for binding record identifiers.
* Prevents mixing binding IDs with other types of record IDs at compile time.
*
* @example
* ```ts
* import { createBindingId } from '@tldraw/tlschema'
*
* // Create a new binding ID
* const bindingId: TLBindingId = createBindingId()
*
* // Use in binding records
* const binding: TLBinding = {
* id: bindingId,
* type: 'arrow',
* fromId: 'shape:arrow1',
* toId: 'shape:rectangle1',
* // ... other properties
* }
* ```
*
* @public
*/
export type TLBindingId = RecordId<TLBinding>
/**
* Migration version identifiers for the root binding record schema.
* Currently empty as no migrations have been applied to the base binding structure.
*
* @example
* ```ts
* // Future migrations would be defined here
* const rootBindingVersions = createMigrationIds('com.tldraw.binding', {
* AddNewProperty: 1,
* } as const)
* ```
*
* @public
*/
export const rootBindingVersions = createMigrationIds('com.tldraw.binding', {} as const)
/**
* Migration sequence for the root binding record structure.
* Currently empty as the binding schema has not required any migrations yet.
*
* @example
* ```ts
* // Migrations would be automatically applied when loading old documents
* const migratedStore = migrator.migrateStoreSnapshot({
* schema: oldSchema,
* store: oldStoreSnapshot
* })
* ```
*
* @public
*/
export const rootBindingMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.binding',
recordType: 'binding',
sequence: [],
})
/**
* Type guard to check if a record is a TLBinding.
* Useful for filtering or type narrowing when working with mixed record types.
*
* @param record - The record to check
* @returns True if the record is a binding, false otherwise
*
* @example
* ```ts
* // Filter bindings from mixed records
* const allRecords = store.allRecords()
* const bindings = allRecords.filter(isBinding)
*
* // Type guard usage
* function processRecord(record: UnknownRecord) {
* if (isBinding(record)) {
* // record is now typed as TLBinding
* console.log(`Binding from ${record.fromId} to ${record.toId}`)
* }
* }
* ```
*
* @public
*/
export function isBinding(record?: UnknownRecord): record is TLBinding {
if (!record) return false
return record.typeName === 'binding'
}
/**
* Type guard to check if a string is a valid TLBindingId.
* Validates that the ID follows the correct format for binding identifiers.
*
* @param id - The string to check
* @returns True if the string is a valid binding ID, false otherwise
*
* @example
* ```ts
* // Validate binding IDs
* const maybeBindingId = 'binding:abc123'
* if (isBindingId(maybeBindingId)) {
* // maybeBindingId is now typed as TLBindingId
* const binding = store.get(maybeBindingId)
* }
*
* // Filter binding IDs from mixed ID array
* const mixedIds = ['shape:1', 'binding:2', 'page:3']
* const bindingIds = mixedIds.filter(isBindingId)
* ```
*
* @public
*/
export function isBindingId(id?: string): id is TLBindingId {
if (!id) return false
return id.startsWith('binding:')
}
/**
* Creates a new TLBindingId with proper formatting.
* Generates a unique ID if none is provided, or formats a provided ID correctly.
*
* @param id - Optional custom ID suffix. If not provided, a unique ID is generated
* @returns A properly formatted binding ID
*
* @example
* ```ts
* // Create with auto-generated ID
* const bindingId1 = createBindingId() // 'binding:abc123'
*
* // Create with custom ID
* const bindingId2 = createBindingId('myCustomBinding') // 'binding:myCustomBinding'
*
* // Use in binding creation
* const binding: TLBinding = {
* id: createBindingId(),
* type: 'arrow',
* fromId: 'shape:arrow1',
* toId: 'shape:rectangle1',
* // ... other properties
* }
* ```
*
* @public
*/
export function createBindingId(id?: string): TLBindingId {
return `binding:${id ?? uniqueId()}` as TLBindingId
}
/**
* Creates a migration sequence for binding properties.
* This is a pass-through function that validates and returns the provided migrations.
*
* @param migrations - The migration sequence for binding properties
* @returns The validated migration sequence
*
* @example
* ```ts
* // Define migrations for custom binding properties
* const myBindingMigrations = createBindingPropsMigrationSequence({
* sequence: [
* {
* id: 'com.myapp.binding.custom/1.0.0',
* up: (props) => ({ ...props, newProperty: 'default' }),
* down: ({ newProperty, ...props }) => props
* }
* ]
* })
* ```
*
* @public
*/
export function createBindingPropsMigrationSequence(
migrations: TLPropsMigrations
): TLPropsMigrations {
return migrations
}
/**
* Creates properly formatted migration IDs for binding property migrations.
* Follows the convention: 'com.tldraw.binding.\{bindingType\}/\{version\}'
*
* @param bindingType - The type of binding these migrations apply to
* @param ids - Object mapping migration names to version numbers
* @returns Object with formatted migration IDs
*
* @example
* ```ts
* // Create migration IDs for custom binding
* const myBindingVersions = createBindingPropsMigrationIds('myCustomBinding', {
* AddNewProperty: 1,
* UpdateProperty: 2
* })
*
* // Result:
* // {
* // AddNewProperty: 'com.tldraw.binding.myCustomBinding/1',
* // UpdateProperty: 'com.tldraw.binding.myCustomBinding/2'
* // }
* ```
*
* @public
*/
export function createBindingPropsMigrationIds<S extends string, T extends Record<string, number>>(
bindingType: S,
ids: T
): { [k in keyof T]: `com.tldraw.binding.${S}/${T[k]}` } {
return mapObjectMapValues(ids, (_k, v) => `com.tldraw.binding.${bindingType}/${v}`) as any
}
/**
* Creates a record type for TLBinding with validation based on the provided binding schemas.
* This function is used internally to configure the binding record type in the schema.
*
* @param bindings - Record mapping binding type names to their schema information
* @returns A configured record type for bindings with validation
*
* @example
* ```ts
* // Used internally when creating schemas
* const bindingRecordType = createBindingRecordType({
* arrow: {
* props: arrowBindingProps,
* meta: arrowBindingMeta
* }
* })
* ```
*
* @internal
*/
export function createBindingRecordType(bindings: Record<string, SchemaPropsInfo>) {
return createRecordType('binding', {
scope: 'document',
validator: T.model(
'binding',
T.union(
'type',
mapObjectMapValues(bindings, (type, { props, meta }) =>
createBindingValidator(type, props, meta)
)
)
),
}).withDefaultProperties(() => ({
meta: {},
}))
}

View File

@@ -0,0 +1,184 @@
import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
/**
* A camera record representing the viewport's position and zoom level.
* The camera defines what portion of the infinite canvas is visible to the user.
*
* @example
* ```ts
* const camera: TLCamera = {
* id: 'camera:user1',
* typeName: 'camera',
* x: 100, // Camera x position (negative values pan right)
* y: 50, // Camera y position (negative values pan down)
* z: 0.5, // Zoom level (1 = 100%, 0.5 = 50%, 2 = 200%)
* meta: {
* userId: 'user123',
* lastUpdated: Date.now()
* }
* }
*
* // Set camera position and zoom
* editor.setCamera({ x: -200, y: -100, z: 1.5 })
* ```
*
* @public
*/
export interface TLCamera extends BaseRecord<'camera', TLCameraId> {
/** Camera x position. Negative values move the viewport right */
x: number
/** Camera y position. Negative values move the viewport down */
y: number
/** Zoom level. 1 = 100%, 0.5 = 50% zoom, 2 = 200% zoom */
z: number
/** User-defined metadata for the camera */
meta: JsonObject
}
/**
* Branded string type for camera record identifiers.
* Prevents mixing camera IDs with other types of record IDs at compile time.
*
* @example
* ```ts
* import { CameraRecordType } from '@tldraw/tlschema'
*
* // Create a camera ID (typically one per user/session)
* const cameraId: TLCameraId = CameraRecordType.createId()
*
* // Use in camera records
* const camera: TLCamera = {
* id: cameraId,
* typeName: 'camera',
* x: 0, y: 0, z: 1,
* meta: {}
* }
*
* // Get camera from store
* const currentCamera = store.get(cameraId)
* ```
*
* @public
*/
export type TLCameraId = RecordId<TLCamera>
/**
* Validator for TLCamera records that ensures runtime type safety.
* Validates camera position coordinates and zoom level.
*
* @example
* ```ts
* // Validation happens automatically when cameras are stored
* try {
* const validatedCamera = cameraValidator.validate(cameraData)
* store.put([validatedCamera])
* } catch (error) {
* console.error('Camera validation failed:', error.message)
* }
* ```
*
* @public
*/
export const cameraValidator: T.Validator<TLCamera> = T.model(
'camera',
T.object({
typeName: T.literal('camera'),
id: idValidator<TLCameraId>('camera'),
x: T.number,
y: T.number,
z: T.number,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
/**
* Migration version identifiers for camera record schema evolution.
* Each version represents a breaking change that requires data migration.
*
* @example
* ```ts
* // Check if a camera needs migration
* const needsMigration = currentVersion < cameraVersions.AddMeta
* ```
*
* @public
*/
export const cameraVersions = createMigrationIds('com.tldraw.camera', {
AddMeta: 1,
})
/**
* Migration sequence for evolving camera record structure over time.
* Handles converting camera records from older schema versions to current format.
*
* @example
* ```ts
* // Migration is applied automatically when loading old documents
* const migratedStore = migrator.migrateStoreSnapshot({
* schema: oldSchema,
* store: oldStoreSnapshot
* })
* ```
*
* @public
*/
export const cameraMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.camera',
recordType: 'camera',
sequence: [
{
id: cameraVersions.AddMeta,
up: (record) => {
;(record as any).meta = {}
},
},
],
})
/**
* Record type definition for TLCamera with validation and default properties.
* Configures cameras as session-scoped records that don't persist across sessions.
*
* @example
* ```ts
* // Create a new camera record with defaults
* const cameraRecord = CameraRecordType.create({
* id: 'camera:main'
* // x: 0, y: 0, z: 1, meta: {} are applied as defaults
* })
*
* // Create with custom position and zoom
* const customCamera = CameraRecordType.create({
* id: 'camera:user1',
* x: -100,
* y: -50,
* z: 1.5,
* meta: { userId: 'user123' }
* })
*
* // Store the camera
* store.put([cameraRecord])
* ```
*
* @public
*/
export const CameraRecordType = createRecordType<TLCamera>('camera', {
validator: cameraValidator,
scope: 'session',
}).withDefaultProperties(
(): Omit<TLCamera, 'id' | 'typeName'> => ({
x: 0,
y: 0,
z: 1,
meta: {},
})
)

View File

@@ -0,0 +1,255 @@
import { describe, expect, it } from 'vitest'
import { createTLSchema } from '../createTLSchema'
import { toRichText } from '../misc/TLRichText'
import {
commentReactionRecordConfig,
commentRecordConfig,
commentSchemaRecords,
commentThreadRecordConfig,
createComment,
createCommentId,
createCommentReaction,
createCommentReactionId,
createCommentThread,
createCommentThreadId,
isCommentId,
isCommentReactionId,
isCommentThreadId,
TLCommentAnchor,
} from './TLComment'
import { TLPageId } from './TLPage'
import { TLShapeId } from './TLShape'
const pageId = 'page:page1' as TLPageId
const shapeId = 'shape:box1' as TLShapeId
const anchors: TLCommentAnchor[] = [
{ type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
{ type: 'point', x: 100, y: 200 },
{ type: 'region', x: 0, y: 0, w: 300, h: 150 },
{ type: 'page' },
]
describe('TLCommentThread', () => {
it.each(anchors)('creates a valid thread anchored to %j', (anchor) => {
const thread = createCommentThread({ pageId, anchor, createdBy: 'user1', now: 1000 })
expect(commentThreadRecordConfig.validator.validate(thread)).toEqual(thread)
expect(thread).toMatchObject({
typeName: 'comment-thread',
pageId,
anchor,
createdAt: 1000,
resolved: null,
meta: {},
})
})
it('validates resolution state', () => {
const thread = createCommentThread({
pageId,
anchor: { type: 'page' },
createdBy: 'user1',
now: 1000,
})
const resolved = { ...thread, resolved: { at: 2000, by: 'user2' } }
expect(commentThreadRecordConfig.validator.validate(resolved)).toEqual(resolved)
expect(() =>
commentThreadRecordConfig.validator.validate({ ...thread, resolved: { at: 2000 } })
).toThrow()
})
it('rejects unknown anchor kinds and malformed anchors', () => {
const thread = createCommentThread({
pageId,
anchor: { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
createdBy: 'user1',
now: 1000,
})
expect(() =>
commentThreadRecordConfig.validator.validate({
...thread,
anchor: { type: 'volume', level: 11 },
})
).toThrow()
expect(() =>
commentThreadRecordConfig.validator.validate({
...thread,
// shape anchors must reference a shape id
anchor: { type: 'shape', shapeId: 'page:nope', x: 1, y: 0, isPrecise: false },
})
).toThrow()
})
})
describe('TLComment', () => {
const threadId = createCommentThreadId('thread1')
it('creates a valid comment with a rich text body', () => {
const comment = createComment({
threadId,
pageId,
authorId: 'user1',
body: toRichText('hello'),
now: 1000,
})
expect(commentRecordConfig.validator.validate(comment)).toEqual(comment)
expect(comment).toMatchObject({
typeName: 'comment',
threadId,
pageId,
createdAt: 1000,
editedAt: null,
isDeleted: false,
})
})
it('validates soft-deletion state', () => {
const comment = createComment({
threadId,
pageId,
authorId: 'user1',
body: toRichText('hello'),
now: 1000,
})
const deleted = { ...comment, isDeleted: true }
expect(commentRecordConfig.validator.validate(deleted)).toEqual(deleted)
expect(() => commentRecordConfig.validator.validate({ ...comment, isDeleted: 'yes' })).toThrow()
})
it('accepts an editedAt timestamp after edits', () => {
const comment = createComment({
threadId,
pageId,
authorId: 'user1',
body: toRichText('hello'),
now: 1000,
})
const edited = { ...comment, editedAt: 2000, body: toRichText('hello, edited') }
expect(commentRecordConfig.validator.validate(edited)).toEqual(edited)
})
it('rejects a threadId that is not a comment-thread id', () => {
const comment = createComment({
threadId,
pageId,
authorId: 'user1',
body: toRichText('hello'),
now: 1000,
})
expect(() =>
commentRecordConfig.validator.validate({ ...comment, threadId: 'comment:not-a-thread' })
).toThrow()
})
})
describe('schema registration', () => {
it('registers both record types via commentSchemaRecords', () => {
const schema = createTLSchema({ records: commentSchemaRecords })
// custom record types aren't part of the default TLRecord union, so index untyped
const types = schema.types as Record<string, { scope: string } | undefined>
expect(types['comment-thread']?.scope).toBe('document')
expect(types['comment']?.scope).toBe('document')
})
})
describe('isCommentThreadId / isCommentId', () => {
it('isCommentThreadId accepts comment-thread ids and rejects everything else', () => {
expect(isCommentThreadId(createCommentThreadId())).toBe(true)
expect(isCommentThreadId(createCommentId())).toBe(false)
expect(isCommentThreadId('shape:box1')).toBe(false)
expect(isCommentThreadId('not-an-id')).toBe(false)
})
it('isCommentId accepts comment ids and rejects everything else, including comment-thread ids', () => {
expect(isCommentId(createCommentId())).toBe(true)
expect(isCommentId(createCommentThreadId())).toBe(false)
expect(isCommentId('shape:box1')).toBe(false)
expect(isCommentId('not-an-id')).toBe(false)
})
})
describe('comment-thread migrations', () => {
const migration = (commentThreadRecordConfig.migrations as any).sequence.find(
(m: any) => m.id === 'com.tldraw.comment-thread/2'
) as { up(r: any): any; down(r: any): any }
it('stamps existing shape anchors with the top-right position (x:1, y:0)', () => {
expect(migration.up({ anchor: { type: 'shape', shapeId } })).toEqual({
anchor: { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
})
})
it('leaves shape anchors that already have x/y unchanged', () => {
const anchor = { type: 'shape', shapeId, x: 0.25, y: 0.75, isPrecise: true }
expect(migration.up({ anchor })).toEqual({ anchor })
})
it('leaves non-shape anchors untouched', () => {
const anchor = { type: 'point', x: 5, y: 6 }
expect(migration.up({ anchor })).toEqual({ anchor })
})
it('down-migrates by dropping x/y', () => {
expect(
migration.down({ anchor: { type: 'shape', shapeId, x: 0.3, y: 0.4, isPrecise: true } })
).toEqual({
anchor: { type: 'shape', shapeId },
})
})
})
describe('comment-reaction record', () => {
it('createCommentReaction derives its id from the comment, user, and emoji', () => {
const commentId = createCommentId('c1')
const make = (emoji: string) =>
createCommentReaction({
commentId,
threadId: createCommentThreadId('t1'),
pageId,
userId: 'user1',
emoji,
})
// same emoji → same record (re-picking toggles it); different emoji → its own record (multi)
expect(make('👍').id).toBe(make('👍').id)
expect(make('👍').id).not.toBe(make('🎉').id)
expect(isCommentReactionId(make('👍').id)).toBe(true)
})
it('validates a well-formed reaction record', () => {
const reaction = createCommentReaction({
commentId: createCommentId('c1'),
threadId: createCommentThreadId('t1'),
pageId,
userId: 'user1',
emoji: '👍',
})
expect(commentReactionRecordConfig.validator.validate(reaction)).toEqual(reaction)
})
it('rejects an empty or oversized emoji token', () => {
const reaction = createCommentReaction({
commentId: createCommentId('c1'),
threadId: createCommentThreadId('t1'),
pageId,
userId: 'user1',
emoji: '👍',
})
// the reaction id embeds the emoji verbatim, so an unbounded token means an unbounded id
expect(() =>
commentReactionRecordConfig.validator.validate({ ...reaction, emoji: '' })
).toThrow()
expect(() =>
commentReactionRecordConfig.validator.validate({ ...reaction, emoji: 'x'.repeat(65) })
).toThrow()
})
it('createCommentReactionId is injective when a part contains a colon', () => {
// two different triples that a naive slice+join would collapse onto one id:
// comment "foo" + user "bar:baz" + 👍 vs comment "foo:bar" + user "baz" + 👍
const a = createCommentReactionId(createCommentId('foo'), 'bar:baz', '👍')
const b = createCommentReactionId(createCommentId('foo:bar'), 'baz', '👍')
expect(a).not.toBe(b)
expect(isCommentReactionId(a)).toBe(true)
expect(isCommentReactionId(b)).toBe(true)
})
})

View File

@@ -0,0 +1,446 @@
import { BaseRecord, RecordId, createRecordMigrationSequence } from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
import { richTextValidator, TLRichText } from '../misc/TLRichText'
import { CustomRecordInfo, createCustomRecordId, isCustomRecordId } from './TLCustomRecord'
import { TLPageId } from './TLPage'
import { TLShapeId } from './TLShape'
/**
* Where a comment thread is anchored on the canvas. Modeled as a discriminated union so new
* anchor kinds can be added without breaking existing threads:
*
* - `shape` — pinned to a shape. `x`/`y` are normalized (0–1) within the shape's bounds, so the pin
* keeps its spot as the shape moves, resizes, and rotates. `isPrecise` mirrors arrow bindings:
* when false (the default) the pin sits at a consumer-defined spot instead, top-right out of the
* box, and `x`/`y` are the remembered precise position
* - `point` — pinned to a fixed point on the page, in page coordinates
* - `region` — pinned to a rectangular area of the page, in page coordinates
* - `page` — a page-level thread with no spatial anchor
*
* @public
*/
export type TLCommentAnchor =
| { type: 'shape'; shapeId: TLShapeId; x: number; y: number; isPrecise: boolean }
| { type: 'point'; x: number; y: number }
| {
type: 'region'
x: number
y: number
w: number
h: number
/** The normalized (0–1) corner the pin sits on — where the creating drag was released.
* Absent on older records; consumers fall back to a corner of their own choosing. */
pinX?: number
pinY?: number
}
| { type: 'page' }
/**
* A comment thread. The thread owns the anchor (where the conversation lives on the canvas) and
* the resolution state; the messages themselves are `TLComment` records pointing at the thread
* via `threadId`, ordered by `createdAt`. v1 threads are flat — no nested replies.
*
* Threads and comments are document records, but they're intended to be served through the sync
* server's object-store lane: gated by the session's `objectAccess` rather than `isReadonly`
* (so "can comment but not edit" is expressible), excluded from document snapshots and `.tldr`
* exports server-side, and persisted in a separate lane from the main document.
*
* Opt-in: register with `createTLSchema({ records: commentSchemaRecords })` on the server and
* the matching `records` option on the client — neither type is part of the default schema, and
* both sides must register them identically.
*
* @public
*/
export interface TLCommentThread extends BaseRecord<'comment-thread', TLCommentThreadId> {
/** The page the thread lives on. */
pageId: TLPageId
/** Where the thread is anchored on that page. */
anchor: TLCommentAnchor
/** Who started the thread. Client-supplied; sync servers are expected to stamp/verify it. */
createdBy: string
createdAt: number
/** Resolution state: when and by whom the thread was resolved, or null while open. */
resolved: { at: number; by: string } | null
/**
* Whether the thread is soft-deleted. Clients set the flag and leave the record in place. Sync
* servers are expected to enforce it as write-once and creator-only, reject client hard
* deletes, and drop flagged threads from future room loads.
*/
isDeleted: boolean
meta: JsonObject
}
/** @public */
export type TLCommentThreadId = RecordId<TLCommentThread>
/**
* A single comment message within a thread. See `TLCommentThread` for the overall model and
* sync/registration notes.
*
* Comment mutations are deliberately not undoable — create/edit them with
* `{ history: 'ignore' }` to avoid multiplayer surprises like a comment reappearing after
* someone else deleted it.
*
* @public
*/
export interface TLComment extends BaseRecord<'comment', TLCommentId> {
/** The thread this comment belongs to. */
threadId: TLCommentThreadId
/** Denormalized from the thread so per-page queries don't need a join. */
pageId: TLPageId
/** See `TLCommentThread.createdBy` — same server-stamping caveat applies. */
authorId: string
createdAt: number
/** Null until the comment is first edited. */
editedAt: number | null
/** Rich text body. Use `toRichText(...)` for plaintext input. */
body: TLRichText
/** Whether the comment is soft-deleted. Same model as `TLCommentThread.isDeleted`. */
isDeleted: boolean
meta: JsonObject
}
/** @public */
export type TLCommentId = RecordId<TLComment>
/**
* One person's emoji reaction to one comment.
*
* A reaction is its own record rather than a field on the comment: comment records are owner-only
* for updates, and a shared field would make concurrent reactions race to overwrite each other.
*
* A user holds at most one record per (comment, emoji) pair, enforced by the derived id — see
* `createCommentReactionId`. Limiting a user to one reaction overall is client-side policy (the
* commenting package's `reactionMode`), not a property of this record.
*
* @public
*/
export interface TLCommentReaction extends BaseRecord<'comment-reaction', TLCommentReactionId> {
/** The comment being reacted to. */
commentId: TLCommentId
/** Denormalized from the comment, so a thread's reactions can be found without a join. */
threadId: TLCommentThreadId
/** Denormalized from the comment — see `TLComment.pageId`. */
pageId: TLPageId
/** Who reacted. See `TLCommentThread.createdBy` — same server-stamping caveat applies. */
userId: string
/** The emoji itself, as a string (e.g. `'👍'`), not a shortcode. */
emoji: string
createdAt: number
meta: JsonObject
}
/** @public */
export type TLCommentReactionId = RecordId<TLCommentReaction>
/**
* An emoji shortcode or literal. Bounded because reaction ids embed the emoji verbatim
* (see {@link createCommentReactionId}), so an unbounded value means an unbounded record id.
*/
const emojiValidator = T.string.check((value) => {
if (value.length === 0 || value.length > 64) {
throw new T.ValidationError(`Expected an emoji of 1-64 characters, got ${value.length}`)
}
})
const commentAnchorValidator: T.Validator<TLCommentAnchor> = T.union('type', {
shape: T.object({
type: T.literal('shape'),
shapeId: idValidator<TLShapeId>('shape'),
x: T.number,
y: T.number,
isPrecise: T.boolean,
}),
point: T.object({
type: T.literal('point'),
x: T.number,
y: T.number,
}),
region: T.object({
type: T.literal('region'),
x: T.number,
y: T.number,
w: T.number,
h: T.number,
pinX: T.number.optional(),
pinY: T.number.optional(),
}),
page: T.object({
type: T.literal('page'),
}),
})
/**
* Guard migrations for the comment record types. Each sequence starts with an identity migration
* that has no `down`, so a session whose schema predates the comment types is rejected with
* CLIENT_TOO_OLD instead of being sent records its store can't represent.
*/
function createCommentGuardMigrations(
typeName: 'comment' | 'comment-thread' | 'comment-reaction',
extra: Parameters<typeof createRecordMigrationSequence>[0]['sequence'] = []
) {
return createRecordMigrationSequence({
sequenceId: `com.tldraw.${typeName}`,
recordType: typeName,
retroactive: true,
sequence: [
{
id: `com.tldraw.${typeName}/1`,
up: (record) => record,
},
...extra,
],
})
}
/**
* Config for registering the `comment-thread` record type in a tldraw schema. Pass via
* `commentSchemaRecords`; see `TLCommentThread`.
*
* @public
*/
export const commentThreadRecordConfig: CustomRecordInfo = {
scope: 'document',
migrations: createCommentGuardMigrations('comment-thread', [
{
// Shape anchors gained normalized x/y + isPrecise; existing ones were imprecise (top-right).
id: 'com.tldraw.comment-thread/2',
up: (record) => {
const anchor = (record as any).anchor
if (anchor?.type === 'shape' && anchor.x === undefined) {
;(record as any).anchor = { ...anchor, x: 1, y: 0, isPrecise: false }
}
return record
},
down: (record) => {
const anchor = (record as any).anchor
if (anchor?.type === 'shape') {
;(record as any).anchor = { type: 'shape', shapeId: anchor.shapeId }
}
return record
},
},
{
// Region anchors gained an optional pin corner (where the creating drag released).
id: 'com.tldraw.comment-thread/3',
up: (record) => record,
down: (record) => {
const anchor = (record as any).anchor
if (anchor?.type === 'region') {
const { pinX: _pinX, pinY: _pinY, ...rest } = anchor
;(record as any).anchor = rest
}
return record
},
},
]),
validator: T.object({
id: idValidator<TLCommentThreadId>('comment-thread'),
typeName: T.literal('comment-thread'),
pageId: idValidator<TLPageId>('page'),
anchor: commentAnchorValidator,
createdBy: T.string,
createdAt: T.number,
resolved: T.object({ at: T.number, by: T.string }).nullable(),
isDeleted: T.boolean,
meta: T.jsonValue,
}),
}
/**
* Config for registering the `comment` record type in a tldraw schema. Pass via
* `commentSchemaRecords`; see `TLComment`.
*
* @public
*/
export const commentRecordConfig: CustomRecordInfo = {
scope: 'document',
migrations: createCommentGuardMigrations('comment'),
validator: T.object({
id: idValidator<TLCommentId>('comment'),
typeName: T.literal('comment'),
threadId: idValidator<TLCommentThreadId>('comment-thread'),
pageId: idValidator<TLPageId>('page'),
authorId: T.string,
createdAt: T.number,
editedAt: T.number.nullable(),
body: richTextValidator,
isDeleted: T.boolean,
meta: T.jsonValue,
}),
}
/**
* Config for registering the `comment-reaction` record type in a tldraw schema. Pass via
* `commentSchemaRecords`; see `TLCommentReaction`.
*
* @public
*/
export const commentReactionRecordConfig: CustomRecordInfo = {
scope: 'document',
migrations: createCommentGuardMigrations('comment-reaction'),
validator: T.object({
id: idValidator<TLCommentReactionId>('comment-reaction'),
typeName: T.literal('comment-reaction'),
commentId: idValidator<TLCommentId>('comment'),
threadId: idValidator<TLCommentThreadId>('comment-thread'),
pageId: idValidator<TLPageId>('page'),
userId: T.string,
emoji: emojiValidator,
createdAt: T.number,
meta: T.jsonValue,
}),
}
/**
* The `records` map to pass to `createTLSchema` / the client `records` option so comment
* threads, comments, and reactions sync. Register the types together — one without the others
* will fail schema validation on one side of the connection.
*
* @public
*/
export const commentSchemaRecords = {
'comment-thread': commentThreadRecordConfig,
comment: commentRecordConfig,
'comment-reaction': commentReactionRecordConfig,
}
/** @public */
export function createCommentThreadId(id?: string): TLCommentThreadId {
return createCustomRecordId('comment-thread', id) as TLCommentThreadId
}
/** @public */
export function createCommentId(id?: string): TLCommentId {
return createCustomRecordId('comment', id) as TLCommentId
}
/**
* The id of one user's reaction to one comment, derived from the (comment, user, emoji) triple
* rather than random, so the same triple always addresses the same record and two tabs converge
* instead of racing to create duplicates. The sync authorizer leans on this, so it must be
* injective: all three parts are URI-encoded before joining, so a `:` in any of them can't shift
* the boundary and collapse two triples onto one id.
*
* @public
*/
export function createCommentReactionId(
commentId: TLCommentId,
userId: string,
emoji: string
): TLCommentReactionId {
return createCustomRecordId(
'comment-reaction',
`${encodeURIComponent(commentId)}:${encodeURIComponent(userId)}:${encodeURIComponent(emoji)}`
) as TLCommentReactionId
}
/**
* Type guard for `TLCommentThreadId`. `isCommentId` rejects these — `comment-thread:` and
* `comment:` differ in the character after `comment`, so the prefixes never overlap.
*
* @public
*/
export function isCommentThreadId(id: string): id is TLCommentThreadId {
return isCustomRecordId('comment-thread', id)
}
/**
* Type guard for `TLCommentId`. See `isCommentThreadId`.
*
* @public
*/
export function isCommentId(id: string): id is TLCommentId {
return isCustomRecordId('comment', id)
}
/**
* Type guard for `TLCommentReactionId`. See `isCommentThreadId`.
*
* @public
*/
export function isCommentReactionId(id: string): id is TLCommentReactionId {
return isCustomRecordId('comment-reaction', id)
}
/**
* Create a new comment thread record. Pair with `createComment` for the thread's first message.
*
* @public
*/
export function createCommentThread(props: {
pageId: TLPageId
anchor: TLCommentAnchor
createdBy: string
now?: number
meta?: JsonObject
}): TLCommentThread {
return {
id: createCommentThreadId(),
typeName: 'comment-thread',
pageId: props.pageId,
anchor: props.anchor,
createdBy: props.createdBy,
createdAt: props.now ?? Date.now(),
resolved: null,
isDeleted: false,
meta: props.meta ?? {},
}
}
/**
* Create a new comment record within a thread.
*
* @public
*/
export function createComment(props: {
threadId: TLCommentThreadId
pageId: TLPageId
authorId: string
body: TLRichText
now?: number
meta?: JsonObject
}): TLComment {
return {
id: createCommentId(),
typeName: 'comment',
threadId: props.threadId,
pageId: props.pageId,
authorId: props.authorId,
createdAt: props.now ?? Date.now(),
editedAt: null,
body: props.body,
isDeleted: false,
meta: props.meta ?? {},
}
}
/**
* Create a reaction record for one user's reaction to one comment. The id is derived from the
* comment and user (see `createCommentReactionId`), so re-reacting overwrites rather than adding.
*
* @public
*/
export function createCommentReaction(props: {
commentId: TLCommentId
threadId: TLCommentThreadId
pageId: TLPageId
userId: string
emoji: string
now?: number
meta?: JsonObject
}): TLCommentReaction {
return {
id: createCommentReactionId(props.commentId, props.userId, props.emoji),
typeName: 'comment-reaction',
commentId: props.commentId,
threadId: props.threadId,
pageId: props.pageId,
userId: props.userId,
emoji: props.emoji,
createdAt: props.now ?? Date.now(),
meta: props.meta ?? {},
}
}

View File

@@ -0,0 +1,297 @@
import {
MigrationSequence,
RecordId,
RecordScope,
UnknownRecord,
createMigrationSequence,
createRecordType,
} from '@tldraw/store'
import { assert, mapObjectMapValues, uniqueId } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { TLPropsMigrations } from '../recordsWithProps'
/**
* Configuration for a custom record type in the schema.
*
* Custom record types allow you to add entirely new data types to the tldraw store
* that don't fit into the existing shape, binding, or asset categories. This is useful
* for storing domain-specific data like comments, annotations, or application state
* that needs to participate in persistence and synchronization.
*
* @example
* ```ts
* const commentRecordConfig: CustomRecordInfo = {
* scope: 'document',
* validator: T.object({
* id: T.string,
* typeName: T.literal('comment'),
* text: T.string,
* shapeId: T.string,
* authorId: T.string,
* createdAt: T.number,
* }),
* migrations: createRecordMigrationSequence({
* sequenceId: 'com.myapp.comment',
* recordType: 'comment',
* sequence: [],
* }),
* }
* ```
*
* @public
*/
export interface CustomRecordInfo {
/**
* The scope determines how records of this type are persisted and synchronized:
* - **document**: Persisted and synced across all clients
* - **session**: Local to current session, not synced
* - **presence**: Ephemeral presence data, may be synced but not persisted
*/
scope: RecordScope
/**
* Validator for the complete record structure.
*
* Should validate the entire record including `id` and `typeName` fields.
* Use validators like T.object, T.string, etc.
*/
validator: T.Validatable<any>
/**
* Optional migration sequence for handling schema evolution over time.
*
* Can be a full MigrationSequence or a simplified TLPropsMigrations format.
* If not provided, an empty migration sequence will be created automatically.
*/
migrations?: MigrationSequence | TLPropsMigrations
/**
* Optional factory function that returns default property values for new records.
*
* Called when creating new records to provide initial values for any properties
* not explicitly provided during creation.
*/
// eslint-disable-next-line tldraw/method-signature-style
createDefaultProperties?: () => Record<string, unknown>
}
/**
* Creates a RecordType for a custom record based on its configuration.
*
* @param typeName - The unique type name for this record type
* @param config - Configuration for the custom record type
* @returns A RecordType instance that can be used to create and manage records
*
* @internal
*/
export function createCustomRecordType(typeName: string, config: CustomRecordInfo) {
return createRecordType<UnknownRecord>(typeName, {
scope: config.scope,
validator: config.validator,
}).withDefaultProperties(config.createDefaultProperties ?? (() => ({})))
}
/**
* Processes migrations for custom record types.
*
* Converts the migration configuration from CustomRecordInfo into proper
* MigrationSequence objects that can be used by the store system.
*
* @param records - Record of type names to their configuration
* @returns Array of migration sequences for the custom record types
*
* @internal
*/
export function processCustomRecordMigrations(
records: Record<string, CustomRecordInfo>
): MigrationSequence[] {
const result: MigrationSequence[] = []
for (const [typeName, config] of Object.entries(records)) {
const sequenceId = `com.tldraw.${typeName}`
const { migrations } = config
if (!migrations) {
// Provide empty migration sequence to allow for future migrations
result.push(
createMigrationSequence({
sequenceId,
retroactive: true,
sequence: [],
})
)
} else if ('sequenceId' in migrations) {
// Full MigrationSequence provided
assert(
sequenceId === migrations.sequenceId,
`sequenceId mismatch for ${typeName} custom record migrations. Expected '${sequenceId}', got '${migrations.sequenceId}'`
)
result.push(migrations)
} else if ('sequence' in migrations) {
// TLPropsMigrations format - convert to full MigrationSequence
result.push(
createMigrationSequence({
sequenceId,
retroactive: true,
sequence: migrations.sequence.map((m) => {
if (!('id' in m)) return m
return {
id: m.id,
dependsOn: m.dependsOn,
scope: 'record' as const,
filter: (r: UnknownRecord) => r.typeName === typeName,
up: (record: any) => {
const result = m.up(record)
if (result) return result
},
down:
typeof m.down === 'function'
? (record: any) => {
const result = (m.down as (r: any) => any)(record)
if (result) return result
}
: undefined,
}
}),
})
)
}
}
return result
}
/**
* Creates properly formatted migration IDs for custom record migrations.
*
* Generates standardized migration IDs following the convention:
* `com.tldraw.{recordType}/{version}`
*
* @param recordType - The type name of the custom record
* @param ids - Record mapping migration names to version numbers
* @returns Record with the same keys but formatted migration ID values
*
* @example
* ```ts
* const commentVersions = createCustomRecordMigrationIds('comment', {
* AddAuthorId: 1,
* AddCreatedAt: 2,
* RefactorReactions: 3
* })
* // Result: {
* // AddAuthorId: 'com.tldraw.comment/1',
* // AddCreatedAt: 'com.tldraw.comment/2',
* // RefactorReactions: 'com.tldraw.comment/3'
* // }
* ```
*
* @public
*/
export function createCustomRecordMigrationIds<
const S extends string,
const T extends Record<string, number>,
>(recordType: S, ids: T): { [k in keyof T]: `com.tldraw.${S}/${T[k]}` } {
return mapObjectMapValues(ids, (_k, v) => `com.tldraw.${recordType}/${v}`) as any
}
/**
* Creates a migration sequence for custom record types.
*
* This is a pass-through function that maintains the same structure as the input.
* It's used for consistency and to provide a clear API for defining custom record migrations.
*
* @param migrations - The migration sequence to create
* @returns The same migration sequence (pass-through)
*
* @example
* ```ts
* const commentMigrations = createCustomRecordMigrationSequence({
* sequence: [
* {
* id: 'com.myapp.comment/1',
* up: (record) => ({ ...record, authorId: record.authorId ?? 'unknown' }),
* down: ({ authorId, ...record }) => record
* }
* ]
* })
* ```
*
* @public
*/
export function createCustomRecordMigrationSequence(
migrations: TLPropsMigrations
): TLPropsMigrations {
return migrations
}
/**
* Creates a unique ID for a custom record type.
*
* @param typeName - The type name of the custom record
* @param id - Optional custom ID suffix. If not provided, a unique ID will be generated
* @returns A properly formatted record ID
*
* @example
* ```ts
* // Create with auto-generated ID
* const commentId = createCustomRecordId('comment') // 'comment:abc123'
*
* // Create with custom ID
* const customId = createCustomRecordId('comment', 'my-comment') // 'comment:my-comment'
* ```
*
* @public
*/
export function createCustomRecordId<T extends string>(
typeName: T,
id?: string
): RecordId<UnknownRecord> & `${T}:${string}` {
return `${typeName}:${id ?? uniqueId()}` as RecordId<UnknownRecord> & `${T}:${string}`
}
/**
* Type guard to check if a string is a valid ID for a specific custom record type.
*
* @param typeName - The type name to check against
* @param id - The string to check
* @returns True if the string is a valid ID for the specified record type
*
* @example
* ```ts
* const id = 'comment:abc123'
* if (isCustomRecordId('comment', id)) {
* // id is now typed as a comment record ID
* const comment = store.get(id)
* }
* ```
*
* @public
*/
export function isCustomRecordId(typeName: string, id?: string): boolean {
if (!id) return false
return id.startsWith(`${typeName}:`)
}
/**
* Type guard to check if a record is of a specific custom type.
*
* @param typeName - The type name to check against
* @param record - The record to check
* @returns True if the record is of the specified type
*
* @example
* ```ts
* function handleRecord(record: TLRecord) {
* if (isCustomRecord('comment', record)) {
* // Handle comment record
* console.log(`Comment: ${record.text}`)
* }
* }
* ```
*
* @public
*/
export function isCustomRecord(typeName: string, record?: UnknownRecord): boolean {
if (!record) return false
return record.typeName === typeName
}

View File

@@ -0,0 +1,224 @@
import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
UnknownRecord,
} from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
/**
* Document record containing global settings and metadata for a tldraw document.
* There is exactly one document record per tldraw instance with a fixed ID.
*
* @example
* ```ts
* const document: TLDocument = {
* id: 'document:document',
* typeName: 'document',
* gridSize: 20, // Grid snap size in pixels
* name: 'My Drawing', // Document name
* meta: {
* createdAt: Date.now(),
* author: 'user123',
* version: '1.0.0'
* }
* }
*
* // Update document settings
* editor.updateDocumentSettings({
* name: 'Updated Drawing',
* gridSize: 25
* })
* ```
*
* @public
*/
export interface TLDocument extends BaseRecord<'document', RecordId<TLDocument>> {
/** Grid snap size in pixels. Used for shape positioning and alignment */
gridSize: number
/** Human-readable name of the document */
name: string
/** User-defined metadata for the document */
meta: JsonObject
}
/**
* Validator for TLDocument records that ensures runtime type safety.
* Enforces the fixed document ID and validates all document properties.
*
* @example
* ```ts
* // Validation happens automatically when document is stored
* try {
* const validatedDocument = documentValidator.validate(documentData)
* store.put([validatedDocument])
* } catch (error) {
* console.error('Document validation failed:', error.message)
* }
* ```
*
* @public
*/
export const documentValidator: T.Validator<TLDocument> = T.model(
'document',
T.object({
typeName: T.literal('document'),
id: T.literal('document:document' as RecordId<TLDocument>),
gridSize: T.number,
name: T.string,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
/**
* Type guard to check if a record is a TLDocument.
* Useful for filtering or type narrowing when working with mixed record types.
*
* @param record - The record to check
* @returns True if the record is a document, false otherwise
*
* @example
* ```ts
* // Type guard usage
* function processRecord(record: UnknownRecord) {
* if (isDocument(record)) {
* // record is now typed as TLDocument
* console.log(`Document: ${record.name}, Grid: ${record.gridSize}px`)
* }
* }
*
* // Filter documents from mixed records
* const allRecords = store.allRecords()
* const documents = allRecords.filter(isDocument) // Should be exactly one
* ```
*
* @public
*/
export function isDocument(record?: UnknownRecord): record is TLDocument {
if (!record) return false
return record.typeName === 'document'
}
/**
* Migration version identifiers for document record schema evolution.
* Each version represents a breaking change that requires data migration.
*
* @example
* ```ts
* // Check if document needs migration
* const needsNameMigration = currentVersion < documentVersions.AddName
* const needsMetaMigration = currentVersion < documentVersions.AddMeta
* ```
*
* @public
*/
export const documentVersions = createMigrationIds('com.tldraw.document', {
AddName: 1,
AddMeta: 2,
} as const)
/**
* Migration sequence for evolving document record structure over time.
* Handles converting document records from older schema versions to current format.
*
* @example
* ```ts
* // Migration is applied automatically when loading old documents
* const migratedStore = migrator.migrateStoreSnapshot({
* schema: oldSchema,
* store: oldStoreSnapshot
* })
*
* // The migrations:
* // v1: Added 'name' property with empty string default
* // v2: Added 'meta' property with empty object default
* ```
*
* @public
*/
export const documentMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.document',
recordType: 'document',
sequence: [
{
id: documentVersions.AddName,
up: (document) => {
;(document as any).name = ''
},
down: (document) => {
delete (document as any).name
},
},
{
id: documentVersions.AddMeta,
up: (record) => {
;(record as any).meta = {}
},
},
],
})
/**
* Record type definition for TLDocument with validation and default properties.
* Configures the document as a document-scoped record that persists across sessions.
*
* @example
* ```ts
* // Create a document record (usually done automatically)
* const documentRecord = DocumentRecordType.create({
* id: TLDOCUMENT_ID,
* name: 'My Drawing',
* gridSize: 20,
* meta: { createdAt: Date.now() }
* })
*
* // Create with defaults
* const defaultDocument = DocumentRecordType.create({
* id: TLDOCUMENT_ID
* // gridSize: 10, name: '', meta: {} are applied as defaults
* })
*
* // Store the document
* store.put([documentRecord])
* ```
*
* @public
*/
export const DocumentRecordType = createRecordType<TLDocument>('document', {
validator: documentValidator,
scope: 'document',
}).withDefaultProperties(
(): Omit<TLDocument, 'id' | 'typeName'> => ({
gridSize: 10,
name: '',
meta: {},
})
)
/**
* The fixed ID for the singleton document record in every tldraw store.
* All document records use this same ID: 'document:document'
*
* @example
* ```ts
* // Get the document from store
* const document = store.get(TLDOCUMENT_ID)
*
* // Update document settings
* store.put([{
* ...document,
* name: 'Updated Name',
* gridSize: 25
* }])
*
* // Access via editor
* const documentSettings = editor.getDocumentSettings()
* editor.updateDocumentSettings({ name: 'New Name' })
* ```
*
* @public
*/
export const TLDOCUMENT_ID: RecordId<TLDocument> = DocumentRecordType.createId('document')

View File

@@ -0,0 +1,656 @@
import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { filterEntries, JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { BoxModel, boxModelValidator } from '../misc/geometry-types'
import { idValidator } from '../misc/id-validator'
import { cursorValidator, TLCursor } from '../misc/TLCursor'
import { opacityValidator, TLOpacityType } from '../misc/TLOpacity'
import { scribbleValidator, TLScribble } from '../misc/TLScribble'
import { StyleProp } from '../styles/StyleProp'
import { pageIdValidator, TLPageId } from './TLPage'
import { TLShapeId } from './TLShape'
import { TLUserId, userIdValidator } from './TLUser'
/**
* State that is particular to a single browser tab. The TLInstance record stores
* all session-specific state including cursor position, selected tools, UI preferences,
* and temporary interaction state.
*
* Each browser tab has exactly one TLInstance record that persists for the duration
* of the session and tracks the user's current interaction state.
*
* @example
* ```ts
* const instance: TLInstance = {
* id: 'instance:instance',
* typeName: 'instance',
* currentPageId: 'page:page1',
* cursor: { type: 'default', rotation: 0 },
* screenBounds: { x: 0, y: 0, w: 1920, h: 1080 },
* isFocusMode: false,
* isGridMode: true
* }
* ```
*
* @public
*/
export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
currentPageId: TLPageId
opacityForNextShape: TLOpacityType
stylesForNextShape: Record<string, unknown>
followingUserId: TLUserId | null
highlightedUserIds: TLUserId[]
brush: BoxModel | null
cursor: TLCursor
scribbles: TLScribble[]
isFocusMode: boolean
isDebugMode: boolean
isToolLocked: boolean
exportBackground: boolean
screenBounds: BoxModel
insets: boolean[]
zoomBrush: BoxModel | null
chatMessage: string
isChatting: boolean
isPenMode: boolean
isGridMode: boolean
isFocused: boolean
devicePixelRatio: number
/**
* This is whether the primary input mechanism includes a pointing device of limited accuracy,
* such as a finger on a touchscreen.
*/
isCoarsePointer: boolean
/**
* Will be null if the pointer doesn't support hovering (e.g. touch), but true or false
* otherwise
*/
isHoveringCanvas: boolean | null
openMenus: string[]
isChangingStyle: boolean
isReadonly: boolean
meta: JsonObject
duplicateProps: {
shapeIds: TLShapeId[]
offset: {
x: number
y: number
}
} | null
/**
* Whether the camera is currently moving or idle. Used to optimize rendering
* and hit-testing during panning/zooming.
*/
cameraState: 'idle' | 'moving'
}
/**
* Configuration object defining which TLInstance properties should be preserved
* when loading snapshots across browser sessions. Properties marked as `true`
* represent user preferences that should persist, while `false` indicates
* temporary state that should reset.
*
* @internal
*/
export const shouldKeyBePreservedBetweenSessions = {
// This object defines keys that should be preserved across calls to loadSnapshot()
id: false, // meta
typeName: false, // meta
currentPageId: false, // does not preserve because who knows if the page still exists
opacityForNextShape: false, // does not preserve because it's a temporary state
stylesForNextShape: false, // does not preserve because it's a temporary state
followingUserId: false, // does not preserve because it's a temporary state
highlightedUserIds: false, // does not preserve because it's a temporary state
brush: false, // does not preserve because it's a temporary state
cursor: false, // does not preserve because it's a temporary state
scribbles: false, // does not preserve because it's a temporary state
isFocusMode: true, // preserves because it's a user preference
isDebugMode: true, // preserves because it's a user preference
isToolLocked: true, // preserves because it's a user preference
exportBackground: true, // preserves because it's a user preference
screenBounds: true, // preserves because it's capturing the user's screen state
insets: true, // preserves because it's capturing the user's screen state
zoomBrush: false, // does not preserve because it's a temporary state
chatMessage: false, // does not preserve because it's a temporary state
isChatting: false, // does not preserve because it's a temporary state
isPenMode: false, // does not preserve because it's a temporary state
isGridMode: true, // preserves because it's a user preference
isFocused: true, // preserves because obviously
devicePixelRatio: true, // preserves because it captures the user's screen state
isCoarsePointer: true, // preserves because it captures the user's screen state
isHoveringCanvas: false, // does not preserve because it's a temporary state
openMenus: false, // does not preserve because it's a temporary state
isChangingStyle: false, // does not preserve because it's a temporary state
isReadonly: true, // preserves because it's a config option
meta: false, // does not preserve because who knows what's in there, leave it up to sdk users to save and reinstate
duplicateProps: false, //
cameraState: false, // does not preserve because it's a temporary state
} as const satisfies { [K in keyof TLInstance]: boolean }
/**
* Extracts only the properties from a TLInstance that should be preserved
* between browser sessions, filtering out temporary state.
*
* @param val - The TLInstance to filter, or null/undefined
* @returns A partial TLInstance containing only preservable properties, or null
*
* @internal
*/
export function pluckPreservingValues(val?: TLInstance | null): null | Partial<TLInstance> {
return val
? (filterEntries(val, (key) => {
return shouldKeyBePreservedBetweenSessions[key as keyof TLInstance]
}) as Partial<TLInstance>)
: null
}
/**
* A unique identifier for TLInstance records.
*
* TLInstance IDs are always the constant 'instance:instance' since there
* is exactly one instance record per browser tab.
*
* @public
*/
export type TLInstanceId = RecordId<TLInstance>
/**
* Validator for TLInstanceId values. Ensures the ID follows the correct
* format for instance records.
*
* @example
* ```ts
* const isValid = instanceIdValidator.isValid('instance:instance') // true
* const isValid2 = instanceIdValidator.isValid('invalid') // false
* ```
*
* @public
*/
export const instanceIdValidator = idValidator<TLInstanceId>('instance')
/**
* Creates the record type definition for TLInstance records, including validation
* and default properties. The function takes a map of available style properties
* to configure validation for the stylesForNextShape field.
*
* @param stylesById - Map of style property IDs to their corresponding StyleProp definitions
* @returns A configured RecordType for TLInstance records
*
* @example
* ```ts
* const stylesMap = new Map([['color', DefaultColorStyle]])
* const InstanceRecordType = createInstanceRecordType(stylesMap)
*
* const instance = InstanceRecordType.create({
* id: 'instance:instance',
* currentPageId: 'page:page1'
* })
* ```
*
* @public
*/
export function createInstanceRecordType(stylesById: Map<string, StyleProp<unknown>>) {
const stylesForNextShapeValidators = {} as Record<string, T.Validator<unknown>>
for (const [id, style] of stylesById) {
stylesForNextShapeValidators[id] = T.optional(style)
}
const instanceTypeValidator: T.Validator<TLInstance> = T.model(
'instance',
T.object({
typeName: T.literal('instance'),
id: idValidator<TLInstanceId>('instance'),
currentPageId: pageIdValidator,
followingUserId: userIdValidator.nullable(),
brush: boxModelValidator.nullable(),
opacityForNextShape: opacityValidator,
stylesForNextShape: T.object(stylesForNextShapeValidators),
cursor: cursorValidator,
scribbles: T.arrayOf(scribbleValidator),
isFocusMode: T.boolean,
isDebugMode: T.boolean,
isToolLocked: T.boolean,
exportBackground: T.boolean,
screenBounds: boxModelValidator,
insets: T.arrayOf(T.boolean),
zoomBrush: boxModelValidator.nullable(),
isPenMode: T.boolean,
isGridMode: T.boolean,
chatMessage: T.string,
isChatting: T.boolean,
highlightedUserIds: T.arrayOf(userIdValidator),
isFocused: T.boolean,
devicePixelRatio: T.number,
isCoarsePointer: T.boolean,
isHoveringCanvas: T.boolean.nullable(),
openMenus: T.arrayOf(T.string),
isChangingStyle: T.boolean,
isReadonly: T.boolean,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
duplicateProps: T.object({
shapeIds: T.arrayOf(idValidator<TLShapeId>('shape')),
offset: T.object({
x: T.number,
y: T.number,
}),
}).nullable(),
cameraState: T.literalEnum('idle', 'moving'),
})
)
return createRecordType<TLInstance>('instance', {
validator: instanceTypeValidator,
scope: 'session',
ephemeralKeys: {
currentPageId: false,
meta: false,
followingUserId: true,
opacityForNextShape: true,
stylesForNextShape: true,
brush: true,
cursor: true,
scribbles: true,
isFocusMode: true,
isDebugMode: true,
isToolLocked: true,
exportBackground: true,
screenBounds: true,
insets: true,
zoomBrush: true,
isPenMode: true,
isGridMode: true,
chatMessage: true,
isChatting: true,
highlightedUserIds: true,
isFocused: true,
devicePixelRatio: true,
isCoarsePointer: true,
isHoveringCanvas: true,
openMenus: true,
isChangingStyle: true,
isReadonly: true,
duplicateProps: true,
cameraState: true,
},
}).withDefaultProperties(
(): Omit<TLInstance, 'typeName' | 'id' | 'currentPageId'> => ({
followingUserId: null,
opacityForNextShape: 1,
stylesForNextShape: {},
brush: null,
scribbles: [],
cursor: {
type: 'default',
rotation: 0,
},
isFocusMode: false,
exportBackground: true,
isDebugMode: false,
isToolLocked: false,
screenBounds: { x: 0, y: 0, w: 1080, h: 720 },
insets: [false, false, false, false],
zoomBrush: null,
isGridMode: false,
isPenMode: false,
chatMessage: '',
isChatting: false,
highlightedUserIds: [],
isFocused: false,
devicePixelRatio: typeof window === 'undefined' ? 1 : window.devicePixelRatio,
isCoarsePointer: false,
isHoveringCanvas: null,
openMenus: [] as string[],
isChangingStyle: false,
isReadonly: false,
meta: {},
duplicateProps: null,
cameraState: 'idle',
})
)
}
/**
* Migration version identifiers for TLInstance records. Each version represents
* a schema change that requires data transformation when loading older documents.
*
* The versions track the evolution of the instance record structure over time,
* enabling backward and forward compatibility.
*
* @public
*/
export const instanceVersions = createMigrationIds('com.tldraw.instance', {
AddTransparentExportBgs: 1,
RemoveDialog: 2,
AddToolLockMode: 3,
RemoveExtraPropsForNextShape: 4,
AddLabelColor: 5,
AddFollowingUserId: 6,
RemoveAlignJustify: 7,
AddZoom: 8,
AddVerticalAlign: 9,
AddScribbleDelay: 10,
RemoveUserId: 11,
AddIsPenModeAndIsGridMode: 12,
HoistOpacity: 13,
AddChat: 14,
AddHighlightedUserIds: 15,
ReplacePropsForNextShapeWithStylesForNextShape: 16,
AddMeta: 17,
RemoveCursorColor: 18,
AddLonelyProperties: 19,
ReadOnlyReadonly: 20,
AddHoveringCanvas: 21,
AddScribbles: 22,
AddInset: 23,
AddDuplicateProps: 24,
RemoveCanMoveCamera: 25,
AddCameraState: 26,
} as const)
// TODO: rewrite these to use mutation
/**
* Migration sequence for TLInstance records. Defines how to transform instance
* records between different schema versions, ensuring data compatibility when
* loading documents created with different versions of tldraw.
*
* Each migration includes an 'up' function to migrate forward and optionally
* a 'down' function for reverse migration.
*
* @example
* ```ts
* // Migrations are applied automatically when loading documents
* const migratedInstance = instanceMigrations.migrate(oldInstance, targetVersion)
* ```
*
* @public
*/
export const instanceMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.instance',
recordType: 'instance',
sequence: [
{
id: instanceVersions.AddTransparentExportBgs,
up: (instance) => {
return { ...instance, exportBackground: true }
},
},
{
id: instanceVersions.RemoveDialog,
up: ({ dialog: _, ...instance }: any) => {
return instance
},
},
{
id: instanceVersions.AddToolLockMode,
up: (instance) => {
return { ...instance, isToolLocked: false }
},
},
{
id: instanceVersions.RemoveExtraPropsForNextShape,
up: ({ propsForNextShape, ...instance }: any) => {
return {
...instance,
propsForNextShape: Object.fromEntries(
Object.entries(propsForNextShape).filter(([key]) =>
[
'color',
'labelColor',
'dash',
'fill',
'size',
'font',
'align',
'verticalAlign',
'icon',
'geo',
'arrowheadStart',
'arrowheadEnd',
'spline',
].includes(key)
)
),
}
},
},
{
id: instanceVersions.AddLabelColor,
up: ({ propsForNextShape, ...instance }: any) => {
return {
...instance,
propsForNextShape: {
...propsForNextShape,
labelColor: 'black',
},
}
},
},
{
id: instanceVersions.AddFollowingUserId,
up: (instance) => {
return { ...instance, followingUserId: null }
},
},
{
id: instanceVersions.RemoveAlignJustify,
up: (instance: any) => {
let newAlign = instance.propsForNextShape.align
if (newAlign === 'justify') {
newAlign = 'start'
}
return {
...instance,
propsForNextShape: {
...instance.propsForNextShape,
align: newAlign,
},
}
},
},
{
id: instanceVersions.AddZoom,
up: (instance) => {
return { ...instance, zoomBrush: null }
},
},
{
id: instanceVersions.AddVerticalAlign,
up: (instance: any) => {
return {
...instance,
propsForNextShape: {
...instance.propsForNextShape,
verticalAlign: 'middle',
},
}
},
},
{
id: instanceVersions.AddScribbleDelay,
up: (instance: any) => {
if (instance.scribble !== null) {
return { ...instance, scribble: { ...instance.scribble, delay: 0 } }
}
return { ...instance }
},
},
{
id: instanceVersions.RemoveUserId,
up: ({ userId: _, ...instance }: any) => {
return instance
},
},
{
id: instanceVersions.AddIsPenModeAndIsGridMode,
up: (instance) => {
return { ...instance, isPenMode: false, isGridMode: false }
},
},
{
id: instanceVersions.HoistOpacity,
up: ({ propsForNextShape: { opacity, ...propsForNextShape }, ...instance }: any) => {
return { ...instance, opacityForNextShape: Number(opacity ?? '1'), propsForNextShape }
},
},
{
id: instanceVersions.AddChat,
up: (instance) => {
return { ...instance, chatMessage: '', isChatting: false }
},
},
{
id: instanceVersions.AddHighlightedUserIds,
up: (instance) => {
return { ...instance, highlightedUserIds: [] }
},
},
{
id: instanceVersions.ReplacePropsForNextShapeWithStylesForNextShape,
up: ({ propsForNextShape: _, ...instance }: any) => {
return { ...instance, stylesForNextShape: {} }
},
},
{
id: instanceVersions.AddMeta,
up: (record) => {
return {
...record,
meta: {},
}
},
},
{
id: instanceVersions.RemoveCursorColor,
up: (record: any) => {
const { color: _, ...cursor } = record.cursor
return {
...record,
cursor,
}
},
},
{
id: instanceVersions.AddLonelyProperties,
up: (record) => {
return {
...record,
canMoveCamera: true,
isFocused: false,
devicePixelRatio: 1,
isCoarsePointer: false,
openMenus: [],
isChangingStyle: false,
isReadOnly: false,
}
},
},
{
id: instanceVersions.ReadOnlyReadonly,
up: ({ isReadOnly: _isReadOnly, ...record }: any) => {
return {
...record,
isReadonly: _isReadOnly,
}
},
},
{
id: instanceVersions.AddHoveringCanvas,
up: (record) => {
return {
...record,
isHoveringCanvas: null,
}
},
},
{
id: instanceVersions.AddScribbles,
up: ({ scribble: _, ...record }: any) => {
return {
...record,
scribbles: [],
}
},
},
{
id: instanceVersions.AddInset,
up: (record) => {
return {
...record,
insets: [false, false, false, false],
}
},
down: ({ insets: _, ...record }: any) => {
return {
...record,
}
},
},
{
id: instanceVersions.AddDuplicateProps,
up: (record) => {
return {
...record,
duplicateProps: null,
}
},
down: ({ duplicateProps: _, ...record }: any) => {
return {
...record,
}
},
},
{
id: instanceVersions.RemoveCanMoveCamera,
up: ({ canMoveCamera: _, ...record }: any) => {
return {
...record,
}
},
down: (instance) => {
return { ...instance, canMoveCamera: true }
},
},
{
id: instanceVersions.AddCameraState,
up: (record) => {
return { ...record, cameraState: 'idle' }
},
down: ({ cameraState: _, ...record }: any) => {
return record
},
},
],
})
/**
* The constant ID used for the singleton TLInstance record.
*
* Since each browser tab has exactly one instance, this constant ID
* is used universally across the application.
*
* @example
* ```ts
* const instance = store.get(TLINSTANCE_ID)
* if (instance) {
* console.log('Current page:', instance.currentPageId)
* }
* ```
*
* @public
*/
export const TLINSTANCE_ID = 'instance:instance' as TLInstanceId

View File

@@ -0,0 +1,192 @@
import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
UnknownRecord,
} from '@tldraw/store'
import { IndexKey, JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
/**
* A page within a tldraw document. Pages are containers for shapes and provide
* a way to organize content into separate canvases. Each document can have multiple
* pages, and users can navigate between them.
*
* Pages have a name for identification, an index for ordering, and can store
* custom metadata.
*
* @example
* ```ts
* const page: TLPage = {
* id: 'page:page1',
* typeName: 'page',
* name: 'Page 1',
* index: 'a1',
* meta: { description: 'Main design page' }
* }
* ```
*
* @public
*/
export interface TLPage extends BaseRecord<'page', TLPageId> {
name: string
index: IndexKey
meta: JsonObject
}
/**
* A unique identifier for TLPage records.
*
* Page IDs follow the format 'page:' followed by a unique string identifier.
*
* @example
* ```ts
* const pageId: TLPageId = 'page:main'
* const pageId2: TLPageId = createShapeId() // generates 'page:abc123'
* ```
*
* @public
*/
export type TLPageId = RecordId<TLPage>
/**
* Validator for TLPageId values. Ensures the ID follows the correct
* format for page records ('page:' prefix).
*
* @example
* ```ts
* const isValid = pageIdValidator.isValid('page:main') // true
* const isValid2 = pageIdValidator.isValid('shape:abc') // false
* ```
*
* @public
*/
export const pageIdValidator = idValidator<TLPageId>('page')
/**
* Runtime validator for TLPage records. Validates the structure and types
* of all page properties to ensure data integrity.
*
* @example
* ```ts
* const page = { id: 'page:1', typeName: 'page', name: 'My Page', index: 'a1', meta: {} }
* const isValid = pageValidator.isValid(page) // true
* ```
*
* @public
*/
export const pageValidator: T.Validator<TLPage> = T.model(
'page',
T.object({
typeName: T.literal('page'),
id: pageIdValidator,
name: T.string,
index: T.indexKey,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
/**
* Migration version identifiers for TLPage records. Each version represents
* a schema change that requires data transformation when loading older documents.
*
* @public
*/
export const pageVersions = createMigrationIds('com.tldraw.page', {
AddMeta: 1,
})
/**
* Migration sequence for TLPage records. Defines how to transform page
* records between different schema versions, ensuring data compatibility.
*
* @example
* ```ts
* // Migrations are applied automatically when loading documents
* const migratedPage = pageMigrations.migrate(oldPage, targetVersion)
* ```
*
* @public
*/
export const pageMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.page',
recordType: 'page',
sequence: [
{
id: pageVersions.AddMeta,
up: (record: any) => {
record.meta = {}
},
},
],
})
/**
* The RecordType definition for TLPage records. Defines validation, scope,
* and default properties for page records in the tldraw store.
*
* Pages are scoped to the document level, meaning they persist across sessions
* and are shared in collaborative environments.
*
* @example
* ```ts
* const page = PageRecordType.create({
* id: 'page:main',
* name: 'Main Page',
* index: 'a1'
* })
* ```
*
* @public
*/
export const PageRecordType = createRecordType<TLPage>('page', {
validator: pageValidator,
scope: 'document',
}).withDefaultProperties(() => ({
meta: {},
}))
/**
* Type guard to check if a record is a page.
*
* @param record - The record to check
* @returns True if the record is a page, false otherwise
*
* @example
* ```ts
* const record = store.get('page:abc123')
* if (isPage(record)) {
* console.log(`Page name: ${record.name}`)
* }
* ```
*
* @public
*/
export function isPage(record?: UnknownRecord): record is TLPage {
if (!record) return false
return record.typeName === 'page'
}
/**
* Type guard to check if a string is a valid TLPageId.
*
* @param id - The string to check
* @returns True if the ID is a valid page ID, false otherwise
*
* @example
* ```ts
* if (isPageId('page:main')) {
* // TypeScript knows this is a TLPageId
* console.log('Valid page ID')
* }
* ```
*
* @public
*/
export function isPageId(id: string): id is TLPageId {
return PageRecordType.isId(id)
}

View File

@@ -0,0 +1,255 @@
import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
import { shapeIdValidator } from '../shapes/TLBaseShape'
import { pageIdValidator, TLPage } from './TLPage'
import { TLShapeId } from './TLShape'
/**
* State that is unique to a particular page within a particular browser tab.
* This record tracks all page-specific interaction state including selected shapes,
* editing state, hover state, and other transient UI state that is tied to
* both a specific page and a specific browser session.
*
* Each combination of page and browser tab has its own TLInstancePageState record.
*
* @example
* ```ts
* const pageState: TLInstancePageState = {
* id: 'instance_page_state:page1',
* typeName: 'instance_page_state',
* pageId: 'page:page1',
* selectedShapeIds: ['shape:rect1', 'shape:circle2'],
* hoveredShapeId: 'shape:text3',
* editingShapeId: null,
* focusedGroupId: null
* }
* ```
*
* @public
*/
export interface TLInstancePageState extends BaseRecord<
'instance_page_state',
TLInstancePageStateId
> {
pageId: RecordId<TLPage>
selectedShapeIds: TLShapeId[]
hintingShapeIds: TLShapeId[]
erasingShapeIds: TLShapeId[]
hoveredShapeId: TLShapeId | null
editingShapeId: TLShapeId | null
croppingShapeId: TLShapeId | null
focusedGroupId: TLShapeId | null
meta: JsonObject
}
/**
* Runtime validator for TLInstancePageState records. Validates the structure
* and types of all instance page state properties to ensure data integrity.
*
* @example
* ```ts
* const pageState = {
* id: 'instance_page_state:page1',
* typeName: 'instance_page_state',
* pageId: 'page:page1',
* selectedShapeIds: ['shape:rect1'],
* // ... other properties
* }
* const isValid = instancePageStateValidator.isValid(pageState) // true
* ```
*
* @public
*/
export const instancePageStateValidator: T.Validator<TLInstancePageState> = T.model(
'instance_page_state',
T.object({
typeName: T.literal('instance_page_state'),
id: idValidator<TLInstancePageStateId>('instance_page_state'),
pageId: pageIdValidator,
selectedShapeIds: T.arrayOf(shapeIdValidator),
hintingShapeIds: T.arrayOf(shapeIdValidator),
erasingShapeIds: T.arrayOf(shapeIdValidator),
hoveredShapeId: shapeIdValidator.nullable(),
editingShapeId: shapeIdValidator.nullable(),
croppingShapeId: shapeIdValidator.nullable(),
focusedGroupId: shapeIdValidator.nullable(),
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
/**
* Migration version identifiers for TLInstancePageState records. Each version
* represents a schema change that requires data transformation when loading
* older documents.
*
* @public
*/
export const instancePageStateVersions = createMigrationIds('com.tldraw.instance_page_state', {
AddCroppingId: 1,
RemoveInstanceIdAndCameraId: 2,
AddMeta: 3,
RenameProperties: 4,
RenamePropertiesAgain: 5,
} as const)
/**
* Migration sequence for TLInstancePageState records. Defines how to transform
* instance page state records between different schema versions, ensuring data
* compatibility when loading documents created with different versions.
*
* @example
* ```ts
* // Migrations are applied automatically when loading documents
* const migrated = instancePageStateMigrations.migrate(oldState, targetVersion)
* ```
*
* @public
*/
export const instancePageStateMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.instance_page_state',
recordType: 'instance_page_state',
sequence: [
{
id: instancePageStateVersions.AddCroppingId,
up(instance: any) {
instance.croppingShapeId = null
},
},
{
id: instancePageStateVersions.RemoveInstanceIdAndCameraId,
up(instance: any) {
delete instance.instanceId
delete instance.cameraId
},
},
{
id: instancePageStateVersions.AddMeta,
up: (record: any) => {
record.meta = {}
},
},
{
id: instancePageStateVersions.RenameProperties,
// this migration is cursed: it was written wrong and doesn't do anything.
// rather than replace it, I've added another migration below that fixes it.
up: (_record) => {
// noop
},
down: (_record) => {
// noop
},
},
{
id: instancePageStateVersions.RenamePropertiesAgain,
up: (record: any) => {
record.selectedShapeIds = record.selectedIds
delete record.selectedIds
record.hintingShapeIds = record.hintingIds
delete record.hintingIds
record.erasingShapeIds = record.erasingIds
delete record.erasingIds
record.hoveredShapeId = record.hoveredId
delete record.hoveredId
record.editingShapeId = record.editingId
delete record.editingId
record.croppingShapeId = record.croppingShapeId ?? record.croppingId ?? null
delete record.croppingId
record.focusedGroupId = record.focusLayerId
delete record.focusLayerId
},
down: (record: any) => {
record.selectedIds = record.selectedShapeIds
delete record.selectedShapeIds
record.hintingIds = record.hintingShapeIds
delete record.hintingShapeIds
record.erasingIds = record.erasingShapeIds
delete record.erasingShapeIds
record.hoveredId = record.hoveredShapeId
delete record.hoveredShapeId
record.editingId = record.editingShapeId
delete record.editingShapeId
record.croppingId = record.croppingShapeId
delete record.croppingShapeId
record.focusLayerId = record.focusedGroupId
delete record.focusedGroupId
},
},
],
})
/**
* The RecordType definition for TLInstancePageState records. Defines validation,
* scope, and default properties for instance page state records.
*
* Instance page states are scoped to the session level, meaning they are
* specific to a browser tab and don't persist across sessions or sync
* in collaborative environments.
*
* @example
* ```ts
* const pageState = InstancePageStateRecordType.create({
* id: 'instance_page_state:page1',
* pageId: 'page:page1',
* selectedShapeIds: ['shape:rect1']
* })
* ```
*
* @public
*/
export const InstancePageStateRecordType = createRecordType<TLInstancePageState>(
'instance_page_state',
{
validator: instancePageStateValidator,
scope: 'session',
ephemeralKeys: {
pageId: false,
selectedShapeIds: false,
// editingShapeId is set with `history: 'ignore'`, so entering the editing
// state is never undoable. Marking it ephemeral keeps undo/redo from
// reapplying a stale editingShapeId (e.g. after a shape it pointed at was
// deleted), which could leave the editor pointing at a missing shape.
editingShapeId: true,
croppingShapeId: false,
meta: false,
hintingShapeIds: true,
erasingShapeIds: true,
hoveredShapeId: true,
focusedGroupId: true,
},
}
).withDefaultProperties(
(): Omit<TLInstancePageState, 'id' | 'typeName' | 'pageId'> => ({
editingShapeId: null,
croppingShapeId: null,
selectedShapeIds: [],
hoveredShapeId: null,
erasingShapeIds: [],
hintingShapeIds: [],
focusedGroupId: null,
meta: {},
})
)
/**
* A unique identifier for TLInstancePageState records.
*
* Instance page state IDs follow the format 'instance_page_state:' followed
* by a unique identifier, typically related to the page ID.
*
* @example
* ```ts
* const stateId: TLInstancePageStateId = 'instance_page_state:page1'
* ```
*
* @public
*/
export type TLInstancePageStateId = RecordId<TLInstancePageState>

View File

@@ -0,0 +1,171 @@
import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
/**
* Represents the current pointer/cursor position and activity state.
* This record tracks the mouse or touch pointer coordinates and when
* the pointer was last active, useful for cursor synchronization in
* collaborative environments.
*
* There is typically one pointer record per browser tab that gets updated
* as the user moves their mouse or touches the screen.
*
* @example
* ```ts
* const pointer: TLPointer = {
* id: 'pointer:pointer',
* typeName: 'pointer',
* x: 150,
* y: 200,
* lastActivityTimestamp: Date.now(),
* meta: {}
* }
* ```
*
* @public
*/
export interface TLPointer extends BaseRecord<'pointer', TLPointerId> {
x: number
y: number
lastActivityTimestamp: number
meta: JsonObject
}
/**
* A unique identifier for TLPointer records.
*
* Pointer IDs follow the format 'pointer:' followed by a unique identifier.
* Typically there is one pointer record with a constant ID per session.
*
* @example
* ```ts
* const pointerId: TLPointerId = 'pointer:pointer'
* ```
*
* @public
*/
export type TLPointerId = RecordId<TLPointer>
/**
* Runtime validator for TLPointer records. Validates the structure
* and types of all pointer properties to ensure data integrity.
*
* @example
* ```ts
* const pointer = {
* id: 'pointer:pointer',
* typeName: 'pointer',
* x: 100,
* y: 200,
* lastActivityTimestamp: Date.now(),
* meta: {}
* }
* const isValid = pointerValidator.isValid(pointer) // true
* ```
*
* @public
*/
export const pointerValidator: T.Validator<TLPointer> = T.model(
'pointer',
T.object({
typeName: T.literal('pointer'),
id: idValidator<TLPointerId>('pointer'),
x: T.number,
y: T.number,
lastActivityTimestamp: T.number,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
/**
* Migration version identifiers for TLPointer records. Each version
* represents a schema change that requires data transformation when
* loading older documents.
*
* @public
*/
export const pointerVersions = createMigrationIds('com.tldraw.pointer', {
AddMeta: 1,
})
/**
* Migration sequence for TLPointer records. Defines how to transform
* pointer records between different schema versions, ensuring data
* compatibility when loading documents created with different versions.
*
* @example
* ```ts
* // Migrations are applied automatically when loading documents
* const migratedPointer = pointerMigrations.migrate(oldPointer, targetVersion)
* ```
*
* @public
*/
export const pointerMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.pointer',
recordType: 'pointer',
sequence: [
{
id: pointerVersions.AddMeta,
up: (record: any) => {
record.meta = {}
},
},
],
})
/**
* The RecordType definition for TLPointer records. Defines validation,
* scope, and default properties for pointer records in the tldraw store.
*
* Pointer records are scoped to the session level, meaning they are
* specific to a browser tab and don't persist across sessions.
*
* @example
* ```ts
* const pointer = PointerRecordType.create({
* id: 'pointer:pointer',
* x: 0,
* y: 0
* })
* ```
*
* @public
*/
export const PointerRecordType = createRecordType<TLPointer>('pointer', {
validator: pointerValidator,
scope: 'session',
}).withDefaultProperties(
(): Omit<TLPointer, 'id' | 'typeName'> => ({
x: 0,
y: 0,
lastActivityTimestamp: 0,
meta: {},
})
)
/**
* The constant ID used for the singleton TLPointer record.
*
* Since each browser tab typically has one pointer, this constant ID
* is used universally across the application.
*
* @example
* ```ts
* const pointer = store.get(TLPOINTER_ID)
* if (pointer) {
* console.log('Pointer at:', pointer.x, pointer.y)
* }
* ```
*
* @public
*/
export const TLPOINTER_ID = PointerRecordType.createId('pointer')

View File

@@ -0,0 +1,258 @@
import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { BoxModel, boxModelValidator } from '../misc/geometry-types'
import { idValidator } from '../misc/id-validator'
import { cursorTypeValidator, TLCursor } from '../misc/TLCursor'
import { scribbleValidator, TLScribble } from '../misc/TLScribble'
import { TLPageId } from './TLPage'
import { TLShapeId } from './TLShape'
import { TLUserId, userIdValidator } from './TLUser'
/**
* Represents the presence state of a user in a collaborative tldraw session.
* This record tracks what another user is doing: their cursor position, selected
* shapes, current page, and other real-time activity indicators.
*
* Instance presence records are used in multiplayer environments to show
* where other collaborators are working and what they're doing.
*
* @example
* ```ts
* const presence: TLInstancePresence = {
* id: 'instance_presence:user123',
* typeName: 'instance_presence',
* userId: 'user123',
* userName: 'Alice',
* color: '#FF6B6B',
* cursor: { x: 100, y: 150, type: 'default', rotation: 0 },
* currentPageId: 'page:main',
* selectedShapeIds: ['shape:rect1']
* }
* ```
*
* @public
*/
export interface TLInstancePresence extends BaseRecord<'instance_presence', TLInstancePresenceID> {
userId: TLUserId
userName: string
lastActivityTimestamp: number | null
color: string // can be any hex color
camera: { x: number; y: number; z: number } | null
selectedShapeIds: TLShapeId[]
currentPageId: TLPageId
brush: BoxModel | null
scribbles: TLScribble[]
screenBounds: BoxModel | null
followingUserId: TLUserId | null
cursor: {
x: number
y: number
type: TLCursor['type']
rotation: number
} | null
chatMessage: string
meta: JsonObject
}
/**
* A unique identifier for TLInstancePresence records.
*
* Instance presence IDs follow the format 'instance_presence:' followed
* by a unique identifier, typically the user ID.
*
* @example
* ```ts
* const presenceId: TLInstancePresenceID = 'instance_presence:user123'
* ```
*
* @public
*/
export type TLInstancePresenceID = RecordId<TLInstancePresence>
/**
* Runtime validator for TLInstancePresence records. Validates the structure
* and types of all instance presence properties to ensure data integrity.
*
* @example
* ```ts
* const presence = {
* id: 'instance_presence:user1',
* typeName: 'instance_presence',
* userId: 'user1',
* userName: 'John',
* color: '#007AFF',
* cursor: { x: 0, y: 0, type: 'default', rotation: 0 },
* currentPageId: 'page:main',
* selectedShapeIds: []
* }
* const isValid = instancePresenceValidator.isValid(presence) // true
* ```
*
* @public
*/
export const instancePresenceValidator: T.Validator<TLInstancePresence> = T.model(
'instance_presence',
T.object({
typeName: T.literal('instance_presence'),
id: idValidator<TLInstancePresenceID>('instance_presence'),
userId: userIdValidator,
userName: T.string,
lastActivityTimestamp: T.number.nullable(),
followingUserId: userIdValidator.nullable(),
cursor: T.object({
x: T.number,
y: T.number,
type: cursorTypeValidator,
rotation: T.number,
}).nullable(),
color: T.string,
camera: T.object({
x: T.number,
y: T.number,
z: T.number,
}).nullable(),
screenBounds: boxModelValidator.nullable(),
selectedShapeIds: T.arrayOf(idValidator<TLShapeId>('shape')),
currentPageId: idValidator<TLPageId>('page'),
brush: boxModelValidator.nullable(),
scribbles: T.arrayOf(scribbleValidator),
chatMessage: T.string,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
/**
* Migration version identifiers for TLInstancePresence records. Each version
* represents a schema change that requires data transformation when loading
* older documents.
*
* @public
*/
export const instancePresenceVersions = createMigrationIds('com.tldraw.instance_presence', {
AddScribbleDelay: 1,
RemoveInstanceId: 2,
AddChatMessage: 3,
AddMeta: 4,
RenameSelectedShapeIds: 5,
NullableCameraCursor: 6,
} as const)
/**
* Migration sequence for TLInstancePresence records. Defines how to transform
* instance presence records between different schema versions, ensuring data
* compatibility when loading documents created with different versions.
*
* @example
* ```ts
* // Migrations are applied automatically when loading documents
* const migrated = instancePresenceMigrations.migrate(oldPresence, targetVersion)
* ```
*
* @public
*/
export const instancePresenceMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.instance_presence',
recordType: 'instance_presence',
sequence: [
{
id: instancePresenceVersions.AddScribbleDelay,
up: (instance: any) => {
if (instance.scribble !== null) {
instance.scribble.delay = 0
}
},
},
{
id: instancePresenceVersions.RemoveInstanceId,
up: (instance: any) => {
delete instance.instanceId
},
},
{
id: instancePresenceVersions.AddChatMessage,
up: (instance: any) => {
instance.chatMessage = ''
},
},
{
id: instancePresenceVersions.AddMeta,
up: (record: any) => {
record.meta = {}
},
},
{
id: instancePresenceVersions.RenameSelectedShapeIds,
up: (_record) => {
// noop, whoopsie
},
},
{
id: instancePresenceVersions.NullableCameraCursor,
up: (_record: any) => {
// noop
},
down: (record: any) => {
if (record.camera === null) {
record.camera = { x: 0, y: 0, z: 1 }
}
if (record.lastActivityTimestamp === null) {
record.lastActivityTimestamp = 0
}
if (record.cursor === null) {
record.cursor = { type: 'default', x: 0, y: 0, rotation: 0 }
}
if (record.screenBounds === null) {
record.screenBounds = { x: 0, y: 0, w: 1, h: 1 }
}
},
},
],
})
/**
* The RecordType definition for TLInstancePresence records. Defines validation,
* scope, and default properties for instance presence records.
*
* Instance presence records are scoped to the presence level, meaning they
* represent real-time collaborative state that is ephemeral and tied to
* active user sessions.
*
* @example
* ```ts
* const presence = InstancePresenceRecordType.create({
* id: 'instance_presence:user1',
* userId: 'user1',
* userName: 'Alice',
* color: '#FF6B6B',
* currentPageId: 'page:main'
* })
* ```
*
* @public
*/
export const InstancePresenceRecordType = createRecordType<TLInstancePresence>(
'instance_presence',
{
validator: instancePresenceValidator,
scope: 'presence',
}
).withDefaultProperties(() => ({
lastActivityTimestamp: null,
followingUserId: null,
color: '#FF0000',
camera: null,
cursor: null,
screenBounds: null,
selectedShapeIds: [],
brush: null,
scribbles: [],
chatMessage: '',
meta: {},
}))

View File

@@ -0,0 +1,136 @@
import { TLAsset } from './TLAsset'
import { TLBinding } from './TLBinding'
import { TLCamera } from './TLCamera'
import { TLDocument } from './TLDocument'
import { TLInstance } from './TLInstance'
import { TLPage } from './TLPage'
import { TLInstancePageState } from './TLPageState'
import { TLPointer } from './TLPointer'
import { TLInstancePresence } from './TLPresence'
import { TLShape } from './TLShape'
import { TLUser } from './TLUser'
/**
* Interface for extending tldraw with custom record types via TypeScript module augmentation.
*
* Custom record types allow you to add entirely new data types to the tldraw store that
* don't fit into the existing shape, binding, or asset categories. Each key in this
* interface becomes a new record type name, and the value should be your full record type.
*
* @example
* ```ts
* import { BaseRecord, RecordId } from '@tldraw/store'
*
* // Define your custom record type
* interface TLComment extends BaseRecord<'comment', RecordId<TLComment>> {
* text: string
* shapeId: TLShapeId
* authorId: string
* createdAt: number
* }
*
* // Augment the global record props map
* declare module '@tldraw/tlschema' {
* interface TLGlobalRecordPropsMap {
* comment: TLComment
* }
* }
*
* // Now TLRecord includes your custom comment type
* // and you can use it with createTLSchema()
* ```
*
* @public
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface TLGlobalRecordPropsMap {}
/**
* Union type of all built-in tldraw record types.
*
* This includes persistent records (documents, pages, shapes, assets, bindings)
* and session/presence records (cameras, instances, pointers, page states).
*
* @public
*/
export type TLDefaultRecord =
| TLAsset
| TLBinding
| TLCamera
| TLDocument
| TLInstance
| TLInstancePageState
| TLPage
| TLShape
| TLInstancePresence
| TLPointer
| TLUser
/**
* Index type that maps custom record type names to their record types.
*
* Similar to TLIndexedShapes and TLIndexedBindings, this type creates a mapping
* from type name keys to their corresponding record types, filtering out any
* disabled types (those set to null or undefined in TLGlobalRecordPropsMap).
*
* @public
*/
// prettier-ignore
export type TLIndexedRecords = {
[K in keyof TLGlobalRecordPropsMap as TLGlobalRecordPropsMap[K] extends null | undefined
? never
: K]: TLGlobalRecordPropsMap[K]
}
/**
* Union type representing a custom record from the TLGlobalRecordPropsMap.
*
* @public
*/
export type TLCustomRecord = TLIndexedRecords[keyof TLIndexedRecords]
/**
* Union type representing all possible record types in a tldraw store.
* This includes both persistent records (documents, pages, shapes, assets, bindings)
* and session/presence records (cameras, instances, pointers, page states),
* as well as any custom record types added via TLGlobalRecordPropsMap augmentation.
*
* Records are organized by scope:
* - **document**: Persisted across sessions (shapes, pages, assets, bindings, documents)
* - **session**: Local to current session (cameras, instances, page states)
* - **presence**: Ephemeral user presence data (pointers, instance presence)
*
* @example
* ```ts
* // Function that works with any record type
* function processRecord(record: TLRecord) {
* switch (record.typeName) {
* case 'shape':
* console.log(`Shape: ${record.type} at (${record.x}, ${record.y})`)
* break
* case 'page':
* console.log(`Page: ${record.name}`)
* break
* case 'asset':
* console.log(`Asset: ${record.type}`)
* break
* case 'camera':
* console.log(`Camera at (${record.x}, ${record.y}) zoom: ${record.z}`)
* break
* // ... handle other record types
* }
* }
*
* // Get all records from store
* const allRecords: TLRecord[] = store.allRecords()
*
* // Filter by record type using type guards
* import { isShape, isPage, isAsset } from '@tldraw/tlschema'
* const shapes = allRecords.filter(isShape)
* const pages = allRecords.filter(isPage)
* const assets = allRecords.filter(isAsset)
* ```
*
* @public
*/
export type TLRecord = TLDefaultRecord | TLCustomRecord

View File

@@ -0,0 +1,586 @@
import {
RecordId,
UnknownRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
} from '@tldraw/store'
import { mapObjectMapValues, uniqueId } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { SchemaPropsInfo } from '../createTLSchema'
import { TLPropsMigrations } from '../recordsWithProps'
import { TLArrowShape } from '../shapes/TLArrowShape'
import { TLBaseShape, createShapeValidator } from '../shapes/TLBaseShape'
import { TLBookmarkShape } from '../shapes/TLBookmarkShape'
import { TLDrawShape } from '../shapes/TLDrawShape'
import { TLEmbedShape } from '../shapes/TLEmbedShape'
import { TLFrameShape } from '../shapes/TLFrameShape'
import { TLGeoShape } from '../shapes/TLGeoShape'
import { TLGroupShape } from '../shapes/TLGroupShape'
import { TLHighlightShape } from '../shapes/TLHighlightShape'
import { TLImageShape } from '../shapes/TLImageShape'
import { TLLineShape } from '../shapes/TLLineShape'
import { TLNoteShape } from '../shapes/TLNoteShape'
import { TLTextShape } from '../shapes/TLTextShape'
import { TLVideoShape } from '../shapes/TLVideoShape'
import { StyleProp } from '../styles/StyleProp'
import { TLPageId } from './TLPage'
/**
* The default set of shapes that are available in the editor.
*
* This union type represents all the built-in shape types supported by tldraw,
* including arrows, bookmarks, drawings, embeds, frames, geometry shapes,
* groups, images, lines, notes, text, videos, and highlights.
*
* @example
* ```ts
* // Check if a shape is a default shape type
* function isDefaultShape(shape: TLShape): shape is TLDefaultShape {
* const defaultTypes = ['arrow', 'bookmark', 'draw', 'embed', 'frame', 'geo', 'group', 'image', 'line', 'note', 'text', 'video', 'highlight']
* return defaultTypes.includes(shape.type)
* }
* ```
*
* @public
*/
export type TLDefaultShape =
| TLArrowShape
| TLBookmarkShape
| TLDrawShape
| TLEmbedShape
| TLFrameShape
| TLGeoShape
| TLGroupShape
| TLImageShape
| TLLineShape
| TLNoteShape
| TLTextShape
| TLVideoShape
| TLHighlightShape
/**
* A type for a shape that is available in the editor but whose type is
* unknown—either one of the editor's default shapes or else a custom shape.
*
* This is useful when working with shapes generically without knowing their specific type.
* The shape type is a string and props are a generic object.
*
* @example
* ```ts
* // Handle any shape regardless of its specific type
* function processUnknownShape(shape: TLUnknownShape) {
* console.log(`Processing shape of type: ${shape.type}`)
* console.log(`Position: (${shape.x}, ${shape.y})`)
* }
* ```
*
* @public
*/
export type TLUnknownShape = TLBaseShape<string, object>
/** @public */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface TLGlobalShapePropsMap {}
/** @public */
// prettier-ignore
export type TLIndexedShapes = {
// We iterate over a union of augmented keys and default shape types.
// This allows us to include (or conditionally exclude or override) the default shapes in one go.
//
// In the `as` clause we are filtering out disabled shapes.
[K in keyof TLGlobalShapePropsMap | TLDefaultShape['type'] as K extends TLDefaultShape['type']
? // core shapes are always available and cannot be overridden so we just include them
K extends 'group'
? K
: K extends keyof TLGlobalShapePropsMap
? // if it extends a nullish value the user has disabled this shape type so we filter it out with never
TLGlobalShapePropsMap[K] extends null | undefined
? never
: K
: K
: K]: K extends 'group'
? // core shapes are always available and cannot be overridden so we just include them
Extract<TLDefaultShape, { type: K }>
: K extends TLDefaultShape['type']
? // if it's a default shape type we need to check if it's been overridden
K extends keyof TLGlobalShapePropsMap
? // if it has been overriden then use the custom shape definition
TLBaseShape<K, TLGlobalShapePropsMap[K]>
: // if it has not been overriden then reuse existing type aliases for better type display
Extract<TLDefaultShape, { type: K }>
: // use the custom shape definition
TLBaseShape<K, TLGlobalShapePropsMap[K & keyof TLGlobalShapePropsMap]>
}
/**
* The set of all shapes that are available in the editor.
*
* This is the primary shape type used throughout tldraw. It includes both the
* built-in default shapes and any custom shapes that might be added.
*
* You can use this type without a type argument to work with any shape, or pass
* a specific shape type string (e.g., `'geo'`, `'arrow'`, `'text'`) to narrow
* down to that specific shape type.
*
* @example
* ```ts
* // Work with any shape in the editor
* function moveShape(shape: TLShape, deltaX: number, deltaY: number): TLShape {
* return {
* ...shape,
* x: shape.x + deltaX,
* y: shape.y + deltaY
* }
* }
*
* // Narrow to a specific shape type by passing the type as a generic argument
* function getArrowLabel(shape: TLShape<'arrow'>): string {
* return shape.props.text // TypeScript knows this is a TLArrowShape
* }
* ```
*
* @public
*/
export type TLShape<K extends keyof TLIndexedShapes = keyof TLIndexedShapes> = TLIndexedShapes[K]
/**
* A partial version of a shape, useful for updates and patches.
*
* This type represents a shape where all properties except `id` and `type` are optional.
* It's commonly used when updating existing shapes or creating shape patches.
*
* @example
* ```ts
* // Update a shape's position
* const shapeUpdate: TLShapePartial = {
* id: 'shape:123',
* type: 'geo',
* x: 100,
* y: 200
* }
*
* // Update shape properties
* const propsUpdate: TLShapePartial<TLGeoShape> = {
* id: 'shape:123',
* type: 'geo',
* props: {
* w: 150,
* h: 100
* }
* }
* ```
*
* @public
*/
export type TLShapePartial<T extends TLShape = TLShape> = T extends T
? {
id: TLShapeId
type: T['type']
props?: Partial<T['props']>
meta?: Partial<T['meta']>
} & Partial<Omit<T, 'type' | 'id' | 'props' | 'meta'>>
: never
/**
* A partial version of a shape, useful for creating shapes.
*
* This type represents a shape where all properties except `type` are optional.
* It's commonly used when creating shapes.
*
* @example
* ```ts
* // Create a shape
* const shapeCreate: TLCreateShapePartial = {
* type: 'geo',
* x: 100,
* y: 200
* }
*
* // Create shape properties
* const propsCreate: TLCreateShapePartial<TLGeoShape> = {
* type: 'geo',
* props: {
* w: 150,
* h: 100
* }
* }
* ```
*
* @public
*/
export type TLCreateShapePartial<T extends TLShape = TLShape> = T extends T
? {
type: T['type']
props?: Partial<T['props']>
meta?: Partial<T['meta']>
} & Partial<Omit<T, 'type' | 'props' | 'meta'>>
: never
/**
* Extract a shape type by its props.
*
* This utility type takes a props object type and returns the corresponding shape type
* from the TLShape union whose props match the given type.
*
* @example
* ```ts
* type MyShape = ExtractShapeByProps<{ w: number; h: number }>
* // MyShape is now the type of shape(s) that have props with w and h as numbers
* ```
*
* @public
*/
export type ExtractShapeByProps<P> = Extract<TLShape, { props: P }>
/**
* A unique identifier for a shape record.
*
* Shape IDs are branded strings that start with "shape:" followed by a unique identifier.
* This type-safe approach prevents mixing up different types of record IDs.
*
* @example
* ```ts
* const shapeId: TLShapeId = createShapeId() // "shape:abc123"
* const customId: TLShapeId = createShapeId('my-custom-id') // "shape:my-custom-id"
* ```
*
* @public
*/
export type TLShapeId = RecordId<TLShape>
/**
* The ID of a shape's parent, which can be either a page or another shape.
*
* Shapes can be parented to pages (for top-level shapes) or to other shapes
* (for shapes inside frames or groups).
*
* @example
* ```ts
* // Shape parented to a page
* const pageParentId: TLParentId = 'page:main'
*
* // Shape parented to another shape (e.g., inside a frame)
* const shapeParentId: TLParentId = 'shape:frame123'
* ```
*
* @public
*/
export type TLParentId = TLPageId | TLShapeId
/**
* Migration version IDs for the root shape schema.
*
* These track the evolution of the base shape structure over time, ensuring
* that shapes created in older versions can be migrated to newer formats.
*
* @example
* ```ts
* // Check if a migration needs to be applied
* if (shapeVersion < rootShapeVersions.AddIsLocked) {
* // Apply isLocked migration
* }
* ```
*
* @public
*/
export const rootShapeVersions = createMigrationIds('com.tldraw.shape', {
AddIsLocked: 1,
HoistOpacity: 2,
AddMeta: 3,
AddWhite: 4,
})
/**
* Migration sequence for the root shape record type.
*
* This sequence defines how shape records should be transformed when migrating
* between different schema versions. Each migration handles a specific version
* upgrade, ensuring data compatibility across tldraw versions.
*
* @public
*/
export const rootShapeMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.shape',
recordType: 'shape',
sequence: [
{
id: rootShapeVersions.AddIsLocked,
up: (record: any) => {
record.isLocked = false
},
down: (record: any) => {
delete record.isLocked
},
},
{
id: rootShapeVersions.HoistOpacity,
up: (record: any) => {
record.opacity = Number(record.props.opacity ?? '1')
delete record.props.opacity
},
down: (record: any) => {
const opacity = record.opacity
delete record.opacity
record.props.opacity =
opacity < 0.175
? '0.1'
: opacity < 0.375
? '0.25'
: opacity < 0.625
? '0.5'
: opacity < 0.875
? '0.75'
: '1'
},
},
{
id: rootShapeVersions.AddMeta,
up: (record: any) => {
record.meta = {}
},
},
{
id: rootShapeVersions.AddWhite,
up: (_record) => {
// noop
},
down: (record: any) => {
if (record.props.color === 'white') {
record.props.color = 'black'
}
},
},
],
})
/**
* Type guard to check if a record is a shape.
*
* @param record - The record to check
* @returns True if the record is a shape, false otherwise
*
* @example
* ```ts
* const record = store.get('shape:abc123')
* if (isShape(record)) {
* console.log(`Shape type: ${record.type}`)
* console.log(`Position: (${record.x}, ${record.y})`)
* }
* ```
*
* @public
*/
export function isShape(record?: UnknownRecord): record is TLShape {
if (!record) return false
return record.typeName === 'shape'
}
/**
* Type guard to check if a string is a valid shape ID.
*
* @param id - The string to check
* @returns True if the string is a valid shape ID, false otherwise
*
* @example
* ```ts
* const id = 'shape:abc123'
* if (isShapeId(id)) {
* const shape = store.get(id) // TypeScript knows id is TLShapeId
* }
*
* // Check user input
* function selectShape(id: string) {
* if (isShapeId(id)) {
* editor.selectShape(id)
* } else {
* console.error('Invalid shape ID format')
* }
* }
* ```
*
* @public
*/
export function isShapeId(id?: string): id is TLShapeId {
if (!id) return false
return id.startsWith('shape:')
}
/**
* Creates a new shape ID.
*
* @param id - Optional custom ID suffix. If not provided, a unique ID will be generated
* @returns A new shape ID with the "shape:" prefix
*
* @example
* ```ts
* // Create a shape with auto-generated ID
* const shapeId = createShapeId() // "shape:abc123"
*
* // Create a shape with custom ID
* const customShapeId = createShapeId('my-rectangle') // "shape:my-rectangle"
*
* // Use in shape creation
* const newShape: TLGeoShape = {
* id: createShapeId(),
* type: 'geo',
* x: 100,
* y: 200,
* // ... other properties
* }
* ```
*
* @public
*/
export function createShapeId(id?: string): TLShapeId {
return `shape:${id ?? uniqueId()}` as TLShapeId
}
/**
* Extracts style properties from a shape's props definition and maps them to their property keys.
*
* This function analyzes shape property validators to identify which ones are style properties
* and creates a mapping from StyleProp instances to their corresponding property keys.
* It also validates that each style property is only used once per shape.
*
* @param props - Record of property validators for a shape type
* @returns Map from StyleProp instances to their property keys
* @throws Error if a style property is used more than once in the same shape
*
* @example
* ```ts
* const geoShapeProps = {
* color: DefaultColorStyle,
* fill: DefaultFillStyle,
* width: T.number,
* height: T.number
* }
*
* const styleMap = getShapePropKeysByStyle(geoShapeProps)
* // styleMap.get(DefaultColorStyle) === 'color'
* // styleMap.get(DefaultFillStyle) === 'fill'
* ```
*
* @internal
*/
export function getShapePropKeysByStyle(props: Record<string, T.Validatable<any>>) {
const propKeysByStyle = new Map<StyleProp<unknown>, string>()
for (const [key, prop] of Object.entries(props)) {
if (prop instanceof StyleProp) {
if (propKeysByStyle.has(prop)) {
throw new Error(
`Duplicate style prop ${prop.id}. Each style prop can only be used once within a shape.`
)
}
propKeysByStyle.set(prop, key)
}
}
return propKeysByStyle
}
/**
* Creates a migration sequence for shape properties.
*
* This is a pass-through function that maintains the same structure as the input.
* It's used for consistency and to provide a clear API for defining shape property migrations.
*
* @param migrations - The migration sequence to create
* @returns The same migration sequence (pass-through)
*
* @example
* ```ts
* const myShapeMigrations = createShapePropsMigrationSequence({
* sequence: [
* {
* id: 'com.myapp.shape.custom/1.0.0',
* up: (props) => ({ ...props, newProperty: 'default' }),
* down: ({ newProperty, ...props }) => props
* }
* ]
* })
* ```
*
* @public
*/
export function createShapePropsMigrationSequence(
migrations: TLPropsMigrations
): TLPropsMigrations {
return migrations
}
/**
* Creates properly formatted migration IDs for shape properties.
*
* Generates standardized migration IDs following the convention:
* `com.tldraw.shape.{shapeType}/{version}`
*
* @param shapeType - The type of shape these migrations apply to
* @param ids - Record mapping migration names to version numbers
* @returns Record with the same keys but formatted migration ID values
*
* @example
* ```ts
* const myShapeVersions = createShapePropsMigrationIds('custom', {
* AddColor: 1,
* AddSize: 2,
* RefactorProps: 3
* })
* // Result: {
* // AddColor: 'com.tldraw.shape.custom/1',
* // AddSize: 'com.tldraw.shape.custom/2',
* // RefactorProps: 'com.tldraw.shape.custom/3'
* // }
* ```
*
* @public
*/
export function createShapePropsMigrationIds<
const S extends string,
const T extends Record<string, number>,
>(shapeType: S, ids: T): { [k in keyof T]: `com.tldraw.shape.${S}/${T[k]}` } {
return mapObjectMapValues(ids, (_k, v) => `com.tldraw.shape.${shapeType}/${v}`) as any
}
/**
* Creates the record type definition for shapes.
*
* This function generates a complete record type for shapes that includes validation
* for all registered shape types. It combines the base shape properties with
* type-specific properties and creates a union validator that can handle any
* registered shape type.
*
* @param shapes - Record of shape type names to their schema configuration
* @returns A complete RecordType for shapes with proper validation and default properties
*
* @example
* ```ts
* const shapeRecordType = createShapeRecordType({
* geo: { props: geoShapeProps, migrations: geoMigrations },
* arrow: { props: arrowShapeProps, migrations: arrowMigrations }
* })
* ```
*
* @internal
*/
export function createShapeRecordType(shapes: Record<string, SchemaPropsInfo>) {
return createRecordType('shape', {
scope: 'document',
validator: T.model(
'shape',
T.union(
'type',
mapObjectMapValues(shapes, (type, { props, meta }) =>
createShapeValidator(type, props, meta)
)
)
),
}).withDefaultProperties(() => ({
x: 0,
y: 0,
rotation: 0,
isLocked: false,
opacity: 1,
meta: {},
}))
}

View File

@@ -0,0 +1,148 @@
import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
/**
* A user record in a tldraw store. User records are document-scoped and
* persist alongside shapes, assets, and pages. They are automatically
* included in snapshots, clipboard content, and `.tldr` files so that
* attribution display names survive across boards and sessions.
*
* User records are populated from the {@link @tldraw/tlschema#TLUserStore}
* when the editor stamps attribution metadata onto shapes.
*
* Extend user records with custom metadata by passing validators to
* {@link @tldraw/tlschema#createTLSchema} or {@link createUserRecordType}.
*
* @public
*/
export interface TLUser extends BaseRecord<'user', TLUserId> {
name: string
color: string
imageUrl: string
meta: JsonObject
}
/** @public */
export type TLUserId = RecordId<TLUser>
/** @public */
export const userIdValidator = idValidator<TLUserId>('user')
/** @public */
export const userVersions = createMigrationIds('com.tldraw.user', {
Initial: 1,
})
/** @public */
export const userMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.user',
recordType: 'user',
sequence: [
{
id: userVersions.Initial,
up: (_record: any) => {
// initial version — nothing to migrate
},
},
],
})
/**
* Creates a user record type with optional custom meta validation.
*
* When `meta` validators are provided, the user record's `meta` field will
* validate those specific fields (when present) while still allowing
* arbitrary additional JSON properties. Custom meta fields are treated as
* optional so that user records created without them remain valid.
*
* @param config - Optional configuration for custom meta validators
* @returns A configured user record type
*
* @example
* ```ts
* import { createUserRecordType } from '@tldraw/tlschema'
* import { T } from '@tldraw/validate'
*
* const CustomUserRecordType = createUserRecordType({
* meta: {
* isAdmin: T.boolean,
* department: T.string,
* },
* })
* ```
*
* @public
*/
export function createUserRecordType(config?: { meta?: Record<string, T.Validatable<any>> }) {
const metaConfig = config?.meta
const metaValidator = metaConfig
? T.object(
Object.fromEntries(
Object.entries(metaConfig).map(([key, v]) => [
key,
new T.Validator((value) => {
if (value === undefined) return undefined
return (v as T.Validator<unknown>).validate(value)
}),
])
)
).allowUnknownProperties()
: (T.jsonValue as T.ObjectValidator<JsonObject>)
const validator: T.Validator<TLUser> = T.model(
'user',
T.object({
typeName: T.literal('user'),
id: userIdValidator,
name: T.string,
color: T.string,
imageUrl: T.string,
meta: metaValidator as T.ObjectValidator<JsonObject>,
})
)
return createRecordType<TLUser>('user', {
validator,
scope: 'document',
}).withDefaultProperties(() => ({
name: '',
color: '',
imageUrl: '',
meta: {},
}))
}
/** @public */
export const userValidator: T.Validator<TLUser> = T.model(
'user',
T.object({
typeName: T.literal('user'),
id: userIdValidator,
name: T.string,
color: T.string,
imageUrl: T.string,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
/** @public */
export const UserRecordType = createUserRecordType()
/** @public */
export function isUserId(id: string): id is TLUserId {
return UserRecordType.isId(id)
}
/** @public */
export function createUserId(id: string): TLUserId {
return UserRecordType.createId(id)
}

View File

@@ -0,0 +1,188 @@
import { Migration, createMigrationSequence } from '@tldraw/store'
import { T } from '@tldraw/validate'
import { describe, expect, it, vi } from 'vitest'
import { SchemaPropsInfo } from './createTLSchema'
import {
TLPropsMigration,
TLPropsMigrations,
createPropsMigration,
processPropsMigrations,
} from './recordsWithProps'
describe('createPropsMigration', () => {
it('should create a store migration from props migration', () => {
const propsMigration: TLPropsMigration = {
id: 'com.test.shape.custom/1' as const,
up: (props: any) => ({ ...props, newProp: 'added' }),
down: (props: any) => {
const { newProp: _newProp, ...rest } = props
return rest
},
}
const storeMigration = createPropsMigration('shape', 'custom', propsMigration)
expect(storeMigration.id).toBe('com.test.shape.custom/1')
expect(storeMigration.scope).toBe('record')
expect(typeof storeMigration.up).toBe('function')
expect(typeof storeMigration.down).toBe('function')
expect(typeof (storeMigration as any).filter).toBe('function')
})
it('should apply up migration to record props', () => {
const propsMigration: TLPropsMigration = {
id: 'com.test.shape.custom/1' as const,
up: (props: any) => ({ ...props, newProp: 'added' }),
}
const storeMigration = createPropsMigration('shape', 'custom', propsMigration)
const record = {
id: 'shape:test' as const,
typeName: 'shape' as const,
type: 'custom' as const,
props: { oldProp: 'value' },
}
storeMigration.up(record as any)
expect(record.props).toEqual({
oldProp: 'value',
newProp: 'added',
})
})
it('should apply down migration to record props', () => {
const propsMigration: TLPropsMigration = {
id: 'com.test.shape.custom/1' as const,
up: (props: any) => ({ ...props, newProp: 'added' }),
down: (props: any) => {
const { newProp: _newProp, ...rest } = props
return rest
},
}
const storeMigration = createPropsMigration('shape', 'custom', propsMigration)
const record = {
id: 'shape:test' as const,
typeName: 'shape' as const,
type: 'custom' as const,
props: { oldProp: 'value', newProp: 'added' },
}
if (storeMigration.down) {
storeMigration.down(record as any)
}
expect(record.props).toEqual({ oldProp: 'value' })
})
})
describe('processPropsMigrations', () => {
it('should throw error for mismatched sequenceId', () => {
const wrongSequenceId = createMigrationSequence({
sequenceId: 'com.wrong.shape.custom', // Wrong sequence ID
retroactive: true,
sequence: [],
})
const records: Record<string, SchemaPropsInfo> = {
custom: {
props: { width: T.number },
migrations: wrongSequenceId,
},
}
expect(() => processPropsMigrations('shape', records)).toThrow(`sequenceId mismatch for custom`)
})
it('should process TLPropsMigrations format', () => {
const propsMigrations: TLPropsMigrations = {
sequence: [
{
id: 'com.tldraw.shape.custom/1',
up: (props: any) => ({ ...props, v: 1 }),
down: (props: any) => {
const { v: _v, ...rest } = props
return rest
},
},
{
id: 'com.tldraw.shape.custom/2',
dependsOn: ['com.tldraw.shape.custom/1'],
up: (props: any) => ({ ...props, v: 2 }),
down: (props: any) => ({ ...props, v: 1 }),
},
],
}
const records: Record<string, SchemaPropsInfo> = {
custom: {
props: { width: T.number },
migrations: propsMigrations,
},
}
const result = processPropsMigrations('shape', records)
expect(result).toHaveLength(1)
expect(result[0].sequenceId).toBe('com.tldraw.shape.custom')
expect(result[0].retroactive).toBe(true)
expect(result[0].sequence).toHaveLength(2)
// Check that props migrations were converted to store migrations
const storeMigrations = result[0].sequence as Migration[]
expect(storeMigrations[0].id).toBe('com.tldraw.shape.custom/1')
expect(storeMigrations[0].scope).toBe('record')
expect(storeMigrations[1].id).toBe('com.tldraw.shape.custom/2')
expect(storeMigrations[1].dependsOn).toEqual(['com.tldraw.shape.custom/1'])
})
it('should process legacy migrations format', () => {
const legacyMigrations = {
migrators: {
1: {
up: vi.fn((record: any) => ({ ...record, version: 1 })),
down: vi.fn((record: any) => {
const { version: _version, ...rest } = record
return rest
}),
},
2: {
up: vi.fn((record: any) => ({ ...record, version: 2 })),
down: vi.fn((record: any) => ({ ...record, version: 1 })),
},
},
}
const records: Record<string, SchemaPropsInfo> = {
legacy: {
props: { width: T.number },
migrations: legacyMigrations as any,
},
}
const result = processPropsMigrations('shape', records)
expect(result).toHaveLength(1)
expect(result[0].sequenceId).toBe('com.tldraw.shape.legacy')
expect(result[0].retroactive).toBe(true)
expect(result[0].sequence).toHaveLength(2)
// Check legacy migration conversion
const migrations = result[0].sequence as Migration[]
expect(migrations[0].id).toBe('com.tldraw.shape.legacy/1')
expect(migrations[0].scope).toBe('record')
expect(migrations[1].id).toBe('com.tldraw.shape.legacy/2')
expect(migrations[1].scope).toBe('record')
// Test filter function - cast to the specific migration type that has filter
const recordMigration = migrations[0] as any
const matchingRecord = { typeName: 'shape', type: 'legacy' }
const nonMatchingRecord = { typeName: 'shape', type: 'other' }
expect(recordMigration.filter(matchingRecord)).toBe(true)
expect(recordMigration.filter(nonMatchingRecord)).toBe(false)
})
})

View File

@@ -0,0 +1,276 @@
import {
Migration,
MigrationId,
MigrationSequence,
RecordType,
StandaloneDependsOn,
UnknownRecord,
createMigrationSequence,
} from '@tldraw/store'
import { MakeUndefinedOptional, assert } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { SchemaPropsInfo } from './createTLSchema'
/**
* Maps a record's property types to their corresponding validators.
*
* This utility type takes a record type with a `props` object and creates
* a mapping where each property key maps to a validator for that property's type.
* This is used to define validation schemas for record properties.
*
* @example
* ```ts
* interface MyShape extends TLBaseShape<'custom', { width: number; color: string }> {}
*
* // Define validators for the shape properties
* const myShapeProps: RecordProps<MyShape> = {
* width: T.number,
* color: T.string
* }
* ```
*
* @public
*/
export type RecordProps<R extends UnknownRecord & { props: object }> = {
[K in keyof R['props']]: T.Validatable<R['props'][K]>
}
/**
* Extracts the TypeScript types from a record properties configuration.
*
* Takes a configuration object where values are validators and returns the
* corresponding TypeScript types, with undefined values made optional.
*
* @example
* ```ts
* const shapePropsConfig = {
* width: T.number,
* height: T.number,
* color: T.optional(T.string)
* }
*
* type ShapeProps = RecordPropsType<typeof shapePropsConfig>
* // Result: { width: number; height: number; color?: string }
* ```
*
* @public
*/
export type RecordPropsType<Config extends Record<string, T.Validatable<any>>> =
MakeUndefinedOptional<{
[K in keyof Config]: T.TypeOf<Config[K]>
}>
/**
* A migration definition for shape or record properties.
*
* Defines how to transform record properties when migrating between schema versions.
* Each migration has an `up` function to upgrade data and an optional `down` function
* to downgrade data if needed.
*
* @example
* ```ts
* const addColorMigration: TLPropsMigration = {
* id: 'com.myapp.shape.custom/1.0.0',
* up: (props) => {
* // Add a default color property
* return { ...props, color: 'black' }
* },
* down: (props) => {
* // Remove the color property
* const { color, ...rest } = props
* return rest
* }
* }
* ```
*
* @public
*/
export interface TLPropsMigration {
readonly id: MigrationId
readonly dependsOn?: MigrationId[]
// eslint-disable-next-line tldraw/method-signature-style
readonly up: (props: any) => any
/**
* If a down migration was deployed more than a couple of months ago it should be safe to retire it.
* We only really need them to smooth over the transition between versions, and some folks do keep
* browser tabs open for months without refreshing, but at a certain point that kind of behavior is
* on them. Plus anyway recently chrome has started to actually kill tabs that are open for too long
* rather than just suspending them, so if other browsers follow suit maybe it's less of a concern.
*
* @public
*/
readonly down?: 'none' | 'retired' | ((props: any) => any)
}
/**
* A sequence of property migrations for a record type.
*
* Contains an ordered array of migrations that should be applied to transform
* record properties from one version to another. Migrations can include both
* property-specific migrations and standalone dependency declarations.
*
* @example
* ```ts
* const myShapeMigrations: TLPropsMigrations = {
* sequence: [
* {
* id: 'com.myapp.shape.custom/1.0.0',
* up: (props) => ({ ...props, version: 1 })
* },
* {
* id: 'com.myapp.shape.custom/2.0.0',
* up: (props) => ({ ...props, newFeature: true })
* }
* ]
* }
* ```
*
* @public
*/
export interface TLPropsMigrations {
readonly sequence: Array<StandaloneDependsOn | TLPropsMigration>
}
/**
* Processes property migrations for all record types in a schema.
*
* Takes a collection of record configurations and converts their migrations
* into proper migration sequences that can be used by the store system.
* Handles different migration formats including legacy migrations.
*
* @param typeName - The base type name for the records (e.g., 'shape', 'binding')
* @param records - Record of type names to their schema configuration
* @returns Array of processed migration sequences
*
* @example
* ```ts
* const shapeRecords = {
* geo: { props: geoProps, migrations: geoMigrations },
* arrow: { props: arrowProps, migrations: arrowMigrations }
* }
*
* const sequences = processPropsMigrations('shape', shapeRecords)
* ```
*
* @internal
*/
export function processPropsMigrations<R extends UnknownRecord & { type: string; props: object }>(
typeName: R['typeName'],
records: Record<string, SchemaPropsInfo>
) {
const result: MigrationSequence[] = []
for (const [subType, { migrations }] of Object.entries(records)) {
const sequenceId = `com.tldraw.${typeName}.${subType}`
if (!migrations) {
// provide empty migrations sequence to allow for future migrations
result.push(
createMigrationSequence({
sequenceId,
retroactive: true,
sequence: [],
})
)
} else if ('sequenceId' in migrations) {
assert(
sequenceId === migrations.sequenceId,
`sequenceId mismatch for ${subType} ${RecordType} migrations. Expected '${sequenceId}', got '${migrations.sequenceId}'`
)
result.push(migrations)
} else if ('sequence' in migrations) {
result.push(
createMigrationSequence({
sequenceId,
retroactive: true,
sequence: migrations.sequence.map((m) =>
'id' in m ? createPropsMigration(typeName, subType, m) : m
),
})
)
} else {
// legacy migrations, will be removed in the future
result.push(
createMigrationSequence({
sequenceId,
retroactive: true,
sequence: Object.keys(migrations.migrators)
.map((k) => Number(k))
.sort((a: number, b: number) => a - b)
.map(
(version): Migration => ({
id: `${sequenceId}/${version}`,
scope: 'record',
filter: (r) => r.typeName === typeName && (r as R).type === subType,
up: (record: any) => {
const result = migrations.migrators[version].up(record)
if (result) {
return result
}
},
down: (record: any) => {
const result = migrations.migrators[version].down(record)
if (result) {
return result
}
},
})
),
})
)
}
}
return result
}
/**
* Creates a store migration from a props migration definition.
*
* Converts a high-level property migration into a low-level store migration
* that can be applied to records. The resulting migration will only affect
* records of the specified type and subtype.
*
* @param typeName - The base type name (e.g., 'shape', 'binding')
* @param subType - The specific subtype (e.g., 'geo', 'arrow')
* @param m - The property migration definition
* @returns A store migration that applies the property transformation
*
* @example
* ```ts
* const propsMigration: TLPropsMigration = {
* id: 'com.myapp.shape.custom/1.0.0',
* up: (props) => ({ ...props, color: 'blue' })
* }
*
* const storeMigration = createPropsMigration('shape', 'custom', propsMigration)
* ```
*
* @internal
*/
export function createPropsMigration<R extends UnknownRecord & { type: string; props: object }>(
typeName: R['typeName'],
subType: R['type'],
m: TLPropsMigration
): Migration {
return {
id: m.id,
dependsOn: m.dependsOn,
scope: 'record',
filter: (r) => r.typeName === typeName && (r as R).type === subType,
up: (record: any) => {
const result = m.up(record.props)
if (result) {
record.props = result
}
},
down:
typeof m.down === 'function'
? (record: any) => {
const result = (m.down as (props: any) => any)(record.props)
if (result) {
record.props = result
}
}
: undefined,
}
}

View File

@@ -0,0 +1,74 @@
import { VecModel } from '../misc/geometry-types'
import { ExtractShapeByProps } from '../records/TLShape'
/**
* Defines cropping parameters for shapes that support cropping.
*
* Specifies the visible area of an asset (like an image or video) within a shape.
* The crop is defined by top-left and bottom-right coordinates in normalized space (0-1),
* where (0,0) is the top-left of the original asset and (1,1) is the bottom-right.
*
* @example
* ```ts
* // Crop the center 50% of an image
* const centerCrop: TLShapeCrop = {
* topLeft: { x: 0.25, y: 0.25 },
* bottomRight: { x: 0.75, y: 0.75 }
* }
*
* // Crop for a circular image shape
* const circleCrop: TLShapeCrop = {
* topLeft: { x: 0, y: 0 },
* bottomRight: { x: 1, y: 1 },
* isCircle: true
* }
* ```
*
* @public
*/
export interface TLShapeCrop {
topLeft: VecModel
bottomRight: VecModel
isCircle?: boolean
}
/**
* A shape type that supports cropping functionality.
*
* This type represents any shape that can display cropped content, typically media shapes
* like images and videos. The shape has width, height, and optional crop parameters.
* When crop is null, the entire asset is displayed.
*
* @example
* ```ts
* // An image shape with cropping
* const croppedImageShape: ShapeWithCrop = {
* id: 'shape:image1',
* type: 'image',
* x: 100,
* y: 200,
* // ... other base shape properties
* props: {
* w: 300,
* h: 200,
* crop: {
* topLeft: { x: 0.1, y: 0.1 },
* bottomRight: { x: 0.9, y: 0.9 }
* }
* }
* }
*
* // A shape without cropping (shows full asset)
* const fullImageShape: ShapeWithCrop = {
* // ... shape properties
* props: {
* w: 400,
* h: 300,
* crop: null // Shows entire asset
* }
* }
* ```
*
* @public
*/
export type ShapeWithCrop = ExtractShapeByProps<{ w: number; h: number; crop: TLShapeCrop | null }>

View File

@@ -0,0 +1,472 @@
import { createMigrationSequence } from '@tldraw/store'
import { structuredClone } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { VecModel, vecModelValidator } from '../misc/geometry-types'
import { TLRichText, richTextValidator, toRichText } from '../misc/TLRichText'
import { createBindingId } from '../records/TLBinding'
import { TLShape, TLShapeId, createShapePropsMigrationIds } from '../records/TLShape'
import { RecordProps, TLPropsMigration, createPropsMigration } from '../recordsWithProps'
import { StyleProp } from '../styles/StyleProp'
import {
DefaultColorStyle,
DefaultLabelColorStyle,
TLDefaultColorStyle,
} from '../styles/TLColorStyle'
import { DefaultDashStyle, TLDefaultDashStyle } from '../styles/TLDashStyle'
import { DefaultFillStyle, TLDefaultFillStyle } from '../styles/TLFillStyle'
import { DefaultFontStyle, TLDefaultFontStyle } from '../styles/TLFontStyle'
import { DefaultSizeStyle, TLDefaultSizeStyle } from '../styles/TLSizeStyle'
import { TLBaseShape } from './TLBaseShape'
const arrowKinds = ['arc', 'elbow'] as const
/**
* Style property for arrow shape kind, determining how the arrow is drawn.
*
* Arrows can be drawn as arcs (curved) or elbows (angled with straight segments).
* This affects the visual appearance and behavior of arrow shapes.
*
* @example
* ```ts
* // Create an arrow with arc style (curved)
* const arcArrow: TLArrowShape = {
* // ... other properties
* props: {
* kind: 'arc',
* // ... other props
* }
* }
*
* // Create an arrow with elbow style (angled)
* const elbowArrow: TLArrowShape = {
* // ... other properties
* props: {
* kind: 'elbow',
* // ... other props
* }
* }
* ```
*
* @public
*/
export const ArrowShapeKindStyle = StyleProp.defineEnum('tldraw:arrowKind', {
defaultValue: 'arc',
values: arrowKinds,
})
/**
* The type representing arrow shape kinds.
*
* @public
*/
export type TLArrowShapeKind = T.TypeOf<typeof ArrowShapeKindStyle>
const arrowheadTypes = [
'arrow',
'triangle',
'square',
'dot',
'pipe',
'diamond',
'inverted',
'bar',
'none',
] as const
/**
* Style property for the arrowhead at the start of an arrow.
*
* Defines the visual style of the arrowhead at the beginning of the arrow path.
* Can be one of several predefined styles or none for no arrowhead.
*
* @example
* ```ts
* // Arrow with no start arrowhead but triangle end arrowhead
* const arrow: TLArrowShape = {
* // ... other properties
* props: {
* arrowheadStart: 'none',
* arrowheadEnd: 'triangle',
* // ... other props
* }
* }
* ```
*
* @public
*/
export const ArrowShapeArrowheadStartStyle = StyleProp.defineEnum('tldraw:arrowheadStart', {
defaultValue: 'none',
values: arrowheadTypes,
})
/**
* Style property for the arrowhead at the end of an arrow.
*
* Defines the visual style of the arrowhead at the end of the arrow path.
* Defaults to 'arrow' style, giving arrows their characteristic pointed appearance.
*
* @example
* ```ts
* // Arrow with different start and end arrowheads
* const doubleArrow: TLArrowShape = {
* // ... other properties
* props: {
* arrowheadStart: 'triangle',
* arrowheadEnd: 'diamond',
* // ... other props
* }
* }
* ```
*
* @public
*/
export const ArrowShapeArrowheadEndStyle = StyleProp.defineEnum('tldraw:arrowheadEnd', {
defaultValue: 'arrow',
values: arrowheadTypes,
})
/**
* The type representing arrowhead styles for both start and end of arrows.
*
* @public
*/
export type TLArrowShapeArrowheadStyle = T.TypeOf<typeof ArrowShapeArrowheadStartStyle>
/**
* Properties specific to arrow shapes.
*
* Defines all the configurable aspects of an arrow shape, including visual styling,
* geometry, text labeling, and positioning. Arrows can connect two points and
* optionally display text labels.
*
* @example
* ```ts
* const arrowProps: TLArrowShapeProps = {
* kind: 'arc',
* labelColor: 'black',
* color: 'blue',
* fill: 'none',
* dash: 'solid',
* size: 'm',
* arrowheadStart: 'none',
* arrowheadEnd: 'arrow',
* font: 'draw',
* start: { x: 0, y: 0 },
* end: { x: 100, y: 100 },
* bend: 0.2,
* richText: toRichText('Label'),
* labelPosition: 0.5,
* scale: 1,
* elbowMidPoint: 0.5
* }
* ```
*
* @public
*/
export interface TLArrowShapeProps {
kind: TLArrowShapeKind
labelColor: TLDefaultColorStyle
color: TLDefaultColorStyle
fill: TLDefaultFillStyle
dash: TLDefaultDashStyle
size: TLDefaultSizeStyle
arrowheadStart: TLArrowShapeArrowheadStyle
arrowheadEnd: TLArrowShapeArrowheadStyle
font: TLDefaultFontStyle
start: VecModel
end: VecModel
bend: number
richText: TLRichText
labelPosition: number
scale: number
elbowMidPoint: number
}
/**
* A complete arrow shape record.
*
* Combines the base shape interface with arrow-specific properties to create
* a full arrow shape that can be stored and manipulated in the editor.
*
* @example
* ```ts
* const arrowShape: TLArrowShape = {
* id: 'shape:arrow123',
* typeName: 'shape',
* type: 'arrow',
* x: 100,
* y: 200,
* rotation: 0,
* index: 'a1',
* parentId: 'page:main',
* isLocked: false,
* opacity: 1,
* props: {
* kind: 'arc',
* start: { x: 0, y: 0 },
* end: { x: 150, y: 100 },
* // ... other props
* },
* meta: {}
* }
* ```
*
* @public
*/
export type TLArrowShape = TLBaseShape<'arrow', TLArrowShapeProps>
/**
* Validation configuration for arrow shape properties.
*
* Defines the validators for each property of an arrow shape, ensuring that
* arrow shape data is valid and conforms to the expected types and constraints.
*
* @example
* ```ts
* // The validators ensure proper typing and validation
* const validator = T.object(arrowShapeProps)
* const validArrowProps = validator.validate({
* kind: 'arc',
* start: { x: 0, y: 0 },
* end: { x: 100, y: 50 },
* // ... other required properties
* })
* ```
*
* @public
*/
export const arrowShapeProps: RecordProps<TLArrowShape> = {
kind: ArrowShapeKindStyle,
labelColor: DefaultLabelColorStyle,
color: DefaultColorStyle,
fill: DefaultFillStyle,
dash: DefaultDashStyle,
size: DefaultSizeStyle,
arrowheadStart: ArrowShapeArrowheadStartStyle,
arrowheadEnd: ArrowShapeArrowheadEndStyle,
font: DefaultFontStyle,
start: vecModelValidator,
end: vecModelValidator,
bend: T.number,
richText: richTextValidator,
labelPosition: T.number,
scale: T.nonZeroNumber,
elbowMidPoint: T.number,
}
/**
* Migration version identifiers for arrow shape properties.
*
* These track the evolution of the arrow shape schema over time, with each
* version representing a specific change to the arrow shape structure or properties.
*
* @example
* ```ts
* // Used internally for migration system
* if (version < arrowShapeVersions.AddLabelColor) {
* // Apply label color migration
* }
* ```
*
* @public
*/
export const arrowShapeVersions = createShapePropsMigrationIds('arrow', {
AddLabelColor: 1,
AddIsPrecise: 2,
AddLabelPosition: 3,
ExtractBindings: 4,
AddScale: 5,
AddElbow: 6,
AddRichText: 7,
AddRichTextAttrs: 8,
})
function propsMigration(migration: TLPropsMigration) {
return createPropsMigration<TLArrowShape>('shape', 'arrow', migration)
}
/**
* Complete migration sequence for arrow shapes.
*
* Defines all the migrations needed to transform arrow shape data from older
* versions to the current version. Each migration handles a specific schema change,
* ensuring backward compatibility and smooth data evolution.
*
* @public
*/
export const arrowShapeMigrations = createMigrationSequence({
sequenceId: 'com.tldraw.shape.arrow',
retroactive: false,
sequence: [
propsMigration({
id: arrowShapeVersions.AddLabelColor,
up: (props) => {
props.labelColor = 'black'
},
down: 'retired',
}),
propsMigration({
id: arrowShapeVersions.AddIsPrecise,
up: ({ start, end }) => {
if (start.type === 'binding') {
start.isPrecise = !(start.normalizedAnchor.x === 0.5 && start.normalizedAnchor.y === 0.5)
}
if (end.type === 'binding') {
end.isPrecise = !(end.normalizedAnchor.x === 0.5 && end.normalizedAnchor.y === 0.5)
}
},
down: ({ start, end }) => {
if (start.type === 'binding') {
if (!start.isPrecise) {
start.normalizedAnchor = { x: 0.5, y: 0.5 }
}
delete start.isPrecise
}
if (end.type === 'binding') {
if (!end.isPrecise) {
end.normalizedAnchor = { x: 0.5, y: 0.5 }
}
delete end.isPrecise
}
},
}),
propsMigration({
id: arrowShapeVersions.AddLabelPosition,
up: (props) => {
props.labelPosition = 0.5
},
down: (props) => {
delete props.labelPosition
},
}),
{
id: arrowShapeVersions.ExtractBindings,
scope: 'storage',
up: (storage) => {
type OldArrowTerminal =
| {
type: 'point'
x: number
y: number
}
| {
type: 'binding'
boundShapeId: TLShapeId
normalizedAnchor: VecModel
isExact: boolean
isPrecise: boolean
}
// new type:
| { type?: undefined; x: number; y: number }
type OldArrow = TLBaseShape<'arrow', { start: OldArrowTerminal; end: OldArrowTerminal }>
// Collect all updates during iteration, then apply them after.
// This avoids issues with live iterators (e.g., SQLite) where updating
// records during iteration can cause them to be visited multiple times.
const updates: [string, unknown][] = []
for (const record of storage.values()) {
if (record.typeName !== 'shape' || (record as TLShape).type !== 'arrow') continue
const arrow = record as OldArrow
const newArrow = structuredClone(arrow)
const { start, end } = arrow.props
if (start.type === 'binding') {
const id = createBindingId()
const binding = {
typeName: 'binding',
id,
type: 'arrow',
fromId: arrow.id,
toId: start.boundShapeId,
meta: {},
props: {
terminal: 'start',
normalizedAnchor: start.normalizedAnchor,
isExact: start.isExact,
isPrecise: start.isPrecise,
},
}
updates.push([id, binding])
newArrow.props.start = { x: 0, y: 0 }
} else {
delete newArrow.props.start.type
}
if (end.type === 'binding') {
const id = createBindingId()
const binding = {
typeName: 'binding',
id,
type: 'arrow',
fromId: arrow.id,
toId: end.boundShapeId,
meta: {},
props: {
terminal: 'end',
normalizedAnchor: end.normalizedAnchor,
isExact: end.isExact,
isPrecise: end.isPrecise,
},
}
updates.push([id, binding])
newArrow.props.end = { x: 0, y: 0 }
} else {
delete newArrow.props.end.type
}
updates.push([arrow.id, newArrow])
}
for (const [id, record] of updates) {
storage.set(id, record as any)
}
},
},
propsMigration({
id: arrowShapeVersions.AddScale,
up: (props) => {
props.scale = 1
},
down: (props) => {
delete props.scale
},
}),
propsMigration({
id: arrowShapeVersions.AddElbow,
up: (props) => {
props.kind = 'arc'
props.elbowMidPoint = 0.5
},
down: (props) => {
delete props.kind
delete props.elbowMidPoint
},
}),
propsMigration({
id: arrowShapeVersions.AddRichText,
up: (props) => {
props.richText = toRichText(props.text)
delete props.text
},
// N.B. Explicitly no down state so that we force clients to update.
// down: (props) => {
// delete props.richText
// },
}),
propsMigration({
id: arrowShapeVersions.AddRichTextAttrs,
up: (_props) => {
// noop - attrs is optional so old records are valid
},
down: (props) => {
// Remove attrs from richText when migrating down
if (props.richText && 'attrs' in props.richText) {
delete props.richText.attrs
}
},
}),
],
})

View File

@@ -0,0 +1,181 @@
import { IndexKey, JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
import { TLOpacityType, opacityValidator } from '../misc/TLOpacity'
import { TLParentId, TLShapeId } from '../records/TLShape'
/**
* Base interface for all shapes in tldraw.
*
* This interface defines the common properties that all shapes share, regardless of their
* specific type. Every default shape extends this base with additional type-specific properties.
*
* Custom shapes should be defined by augmenting the TLGlobalShapePropsMap type and getting the shape type from the TLShape type.
*
* @example
* ```ts
* // Define a default shape type
* interface TLArrowShape extends TLBaseShape<'arrow', {
* kind: TLArrowShapeKind
* labelColor: TLDefaultColorStyle
* color: TLDefaultColorStyle
* fill: TLDefaultFillStyle
* dash: TLDefaultDashStyle
* size: TLDefaultSizeStyle
* arrowheadStart: TLArrowShapeArrowheadStyle
* arrowheadEnd: TLArrowShapeArrowheadStyle
* font: TLDefaultFontStyle
* start: VecModel
* end: VecModel
* bend: number
* richText: TLRichText
* labelPosition: number
* scale: number
* elbowMidPoint: number
* }> {}
*
* // Create a shape instance
* const arrowShape: TLArrowShape = {
* id: 'shape:abc123',
* typeName: 'shape',
* type: 'arrow',
* x: 100,
* y: 200,
* rotation: 0,
* index: 'a1',
* parentId: 'page:main',
* isLocked: false,
* opacity: 1,
* props: {
* kind: 'arc',
* start: { x: 0, y: 0 },
* end: { x: 100, y: 100 },
* // ... other props
* },
* meta: {}
* }
* ```
*
* @public
*/
export interface TLBaseShape<Type extends string, Props extends object> {
// using real `extends BaseRecord<'shape', TLShapeId>` introduces a circularity in the types
// and for that reason those "base members" have to be declared manually here
readonly id: TLShapeId
readonly typeName: 'shape'
type: Type
x: number
y: number
rotation: number
index: IndexKey
parentId: TLParentId
isLocked: boolean
opacity: TLOpacityType
props: Props
meta: JsonObject
}
/**
* Validator for parent IDs, ensuring they follow the correct format.
*
* Parent IDs must start with either "page:" (for shapes directly on a page)
* or "shape:" (for shapes inside other shapes like frames or groups).
*
* @example
* ```ts
* // Valid parent IDs
* const pageParent = parentIdValidator.validate('page:main') // ✓
* const shapeParent = parentIdValidator.validate('shape:frame1') // ✓
*
* // Invalid parent ID (throws error)
* const invalid = parentIdValidator.validate('invalid:123') // ✗
* ```
*
* @public
*/
export const parentIdValidator = T.string.refine((id) => {
if (!id.startsWith('page:') && !id.startsWith('shape:')) {
throw new Error('Parent ID must start with "page:" or "shape:"')
}
return id as TLParentId
})
/**
* Validator for shape IDs, ensuring they follow the "shape:" format.
*
* @example
* ```ts
* const validId = shapeIdValidator.validate('shape:abc123') // ✓
* const invalidId = shapeIdValidator.validate('page:abc123') // ✗ throws error
* ```
*
* @public
*/
export const shapeIdValidator = idValidator<TLShapeId>('shape')
/**
* Creates a validator for a specific shape type.
*
* This function generates a complete validator that can validate shape records
* of the specified type, including both the base shape properties and any
* custom properties and metadata specific to that shape type.
*
* @param type - The string literal type for this shape (e.g., 'geo', 'arrow')
* @param props - Optional validator configuration for shape-specific properties
* @param meta - Optional validator configuration for shape-specific metadata
* @returns A validator that can validate complete shape records of the specified type
*
* @example
* ```ts
* // Create a validator for a custom shape type
* const customShapeValidator = createShapeValidator('custom', {
* width: T.number,
* height: T.number,
* color: T.string
* })
*
* // Use the validator to validate shape data
* const shapeData = {
* id: 'shape:abc123',
* typeName: 'shape',
* type: 'custom',
* x: 100,
* y: 200,
* // ... other base properties
* props: {
* width: 150,
* height: 100,
* color: 'red'
* }
* }
*
* const validatedShape = customShapeValidator.validate(shapeData)
* ```
*
* @public
*/
export function createShapeValidator<
Type extends string,
Props extends JsonObject,
Meta extends JsonObject,
>(
type: Type,
props?: { [K in keyof Props]: T.Validatable<Props[K]> },
meta?: { [K in keyof Meta]: T.Validatable<Meta[K]> }
) {
return T.object<TLBaseShape<Type, Props>>({
id: shapeIdValidator,
typeName: T.literal('shape'),
x: T.number,
y: T.number,
rotation: T.number,
index: T.indexKey,
parentId: parentIdValidator,
type: T.literal(type),
isLocked: T.boolean,
opacity: opacityValidator,
props: props ? T.object(props) : (T.jsonValue as any),
meta: meta ? T.object(meta) : (T.jsonValue as any),
})
}

View File

@@ -0,0 +1,112 @@
import { T } from '@tldraw/validate'
import { assetIdValidator } from '../assets/TLBaseAsset'
import { TLAssetId } from '../records/TLAsset'
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'
import { RecordProps } from '../recordsWithProps'
import { TLBaseShape } from './TLBaseShape'
/**
* Properties for the bookmark shape, which displays website bookmarks as interactive cards.
*
* @public
*/
export interface TLBookmarkShapeProps {
/** Width of the bookmark shape in pixels */
w: number
/** Height of the bookmark shape in pixels */
h: number
/** Asset ID for the bookmark's preview image, or null if no image is available */
assetId: TLAssetId | null
/** The URL that this bookmark points to */
url: string
}
/**
* A bookmark shape represents a website link with optional preview content.
* Bookmark shapes display as cards showing the page title, description, and preview image.
*
* @public
* @example
* ```ts
* const bookmarkShape: TLBookmarkShape = {
* id: createShapeId(),
* typeName: 'shape',
* type: 'bookmark',
* x: 100,
* y: 100,
* rotation: 0,
* index: 'a1',
* parentId: 'page:page1',
* isLocked: false,
* opacity: 1,
* props: {
* w: 300,
* h: 320,
* assetId: 'asset:bookmark123',
* url: 'https://www.example.com'
* },
* meta: {}
* }
* ```
*/
export type TLBookmarkShape = TLBaseShape<'bookmark', TLBookmarkShapeProps>
/**
* Validation schema for bookmark shape properties.
*
* @public
* @example
* ```ts
* // Validates bookmark shape properties
* const isValid = bookmarkShapeProps.url.isValid('https://example.com')
* ```
*/
export const bookmarkShapeProps: RecordProps<TLBookmarkShape> = {
w: T.nonZeroNumber,
h: T.nonZeroNumber,
assetId: assetIdValidator.nullable(),
url: T.linkUrl,
}
const Versions = createShapePropsMigrationIds('bookmark', {
NullAssetId: 1,
MakeUrlsValid: 2,
})
/**
* Version identifiers for bookmark shape migrations.
*
* @public
*/
export { Versions as bookmarkShapeVersions }
/**
* Migration sequence for bookmark shape properties across different schema versions.
* Handles backwards compatibility when bookmark shape structure changes.
*
* @public
*/
export const bookmarkShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.NullAssetId,
up: (props) => {
if (props.assetId === undefined) {
props.assetId = null
}
},
down: 'retired',
},
{
id: Versions.MakeUrlsValid,
up: (props) => {
if (!T.linkUrl.isValid(props.url)) {
props.url = ''
}
},
down: (_props) => {
// noop
},
},
],
})

View File

@@ -0,0 +1,291 @@
import { T } from '@tldraw/validate'
import { DIM_2D, DIM_3D, b64Vecs } from '../misc/b64Vecs'
import { VecModel } from '../misc/geometry-types'
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'
import { RecordProps } from '../recordsWithProps'
import { DefaultColorStyle, TLDefaultColorStyle } from '../styles/TLColorStyle'
import { DefaultDashStyle, TLDefaultDashStyle } from '../styles/TLDashStyle'
import { DefaultFillStyle, TLDefaultFillStyle } from '../styles/TLFillStyle'
import { DefaultSizeStyle, TLDefaultSizeStyle } from '../styles/TLSizeStyle'
import { TLBaseShape } from './TLBaseShape'
/**
* A segment of a draw shape representing either freehand drawing or straight line segments.
*
* @public
*/
export interface TLDrawShapeSegment {
/** Type of drawing segment - 'free' for freehand curves, 'straight' for line segments */
type: 'free' | 'straight'
/**
* Delta-encoded base64 path data.
* First point stored as Float32 (12 bytes) for precision, subsequent points as Float16 deltas (6 bytes each).
*/
path: string
/**
* Encoding dimension of `path`. `2` means (x, y) only — the constant 0.5 pressure
* was omitted, for input from devices that don't report pressure. `3` or absent
* (the legacy default) means (x, y, z). Added in the `OmitNonPressureZ` migration.
*/
dim?: 2 | 3
}
/**
* Validator for draw shape segments ensuring proper structure and data types.
*
* @public
*/
export const DrawShapeSegment: T.ObjectValidator<TLDrawShapeSegment> = T.object({
type: T.literalEnum('free', 'straight'),
path: T.string,
dim: T.literalEnum(DIM_2D, DIM_3D).optional(),
})
/**
* Properties for the draw shape, which represents freehand drawing and sketching.
*
* @public
*/
export interface TLDrawShapeProps {
/** Color style for the drawing stroke */
color: TLDefaultColorStyle
/** Fill style for closed drawing shapes */
fill: TLDefaultFillStyle
/** Dash pattern style for the stroke */
dash: TLDefaultDashStyle
/** Size/thickness of the drawing stroke */
size: TLDefaultSizeStyle
/** Array of segments that make up the complete drawing path */
segments: TLDrawShapeSegment[]
/** Whether the drawing is complete (user finished drawing) */
isComplete: boolean
/** Whether the drawing path forms a closed shape */
isClosed: boolean
/** Whether this drawing was created with a pen/stylus device */
isPen: boolean
/** Scale factor applied to the drawing */
scale: number
/** Horizontal scale factor for lazy resize */
scaleX: number
/** Vertical scale factor for lazy resize */
scaleY: number
}
/**
* A draw shape represents freehand drawing, sketching, and pen input on the canvas.
* Draw shapes are composed of segments that can be either smooth curves or straight lines.
*
* @public
* @example
* ```ts
* const drawShape: TLDrawShape = {
* id: createShapeId(),
* typeName: 'shape',
* type: 'draw',
* x: 50,
* y: 50,
* rotation: 0,
* index: 'a1',
* parentId: 'page:page1',
* isLocked: false,
* opacity: 1,
* props: {
* color: 'black',
* fill: 'none',
* dash: 'solid',
* size: 'm',
* segments: [{
* type: 'free',
* points: [{ x: 0, y: 0, z: 0.5 }, { x: 20, y: 15, z: 0.6 }]
* }],
* isComplete: true,
* isClosed: false,
* isPen: false,
* scale: 1
* },
* meta: {}
* }
* ```
*/
export type TLDrawShape = TLBaseShape<'draw', TLDrawShapeProps>
/**
* Validation schema for draw shape properties.
*
* @public
* @example
* ```ts
* // Validate draw shape properties
* const props = {
* color: 'red',
* fill: 'solid',
* segments: [{ type: 'free', points: [] }],
* isComplete: true
* }
* const isValid = drawShapeProps.color.isValid(props.color)
* ```
*/
/** @public */
export const drawShapeProps: RecordProps<TLDrawShape> = {
color: DefaultColorStyle,
fill: DefaultFillStyle,
dash: DefaultDashStyle,
size: DefaultSizeStyle,
segments: T.arrayOf(DrawShapeSegment),
isComplete: T.boolean,
isClosed: T.boolean,
isPen: T.boolean,
scale: T.nonZeroNumber,
scaleX: T.nonZeroFiniteNumber,
scaleY: T.nonZeroFiniteNumber,
}
const Versions = createShapePropsMigrationIds('draw', {
AddInPen: 1,
AddScale: 2,
Base64: 3,
LegacyPointsConversion: 4,
OmitNonPressureZ: 5,
})
/**
* Version identifiers for draw shape migrations.
*
* @public
*/
export { Versions as drawShapeVersions }
/**
* Migration sequence for draw shape properties across different schema versions.
* Handles adding pen detection and scale properties to existing draw shapes.
*
* @public
*/
export const drawShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.AddInPen,
up: (props) => {
// Rather than checking to see whether the shape is a pen at runtime,
// from now on we're going to use the type of device reported to us
// as well as the pressure data received; but for existing shapes we
// need to check the pressure data to see if it's a pen or not.
const { points } = props.segments[0]
if (points.length === 0) {
props.isPen = false
return
}
let isPen = !(points[0].z === 0 || points[0].z === 0.5)
if (points[1]) {
// Double check if we have a second point (we probably should)
isPen = isPen && !(points[1].z === 0 || points[1].z === 0.5)
}
props.isPen = isPen
},
down: 'retired',
},
{
id: Versions.AddScale,
up: (props) => {
props.scale = 1
},
down: (props) => {
delete props.scale
},
},
{
id: Versions.Base64,
up: (props) => {
// Convert VecModel[] arrays directly to delta-encoded base64 in 'path'
props.segments = props.segments.map((segment: any) => {
if (segment.path !== undefined) return segment
const { points, ...rest } = segment
const vecModels = Array.isArray(points) ? points : b64Vecs._legacyDecodePoints(points)
return {
...rest,
path: b64Vecs.encodePoints(vecModels),
}
})
props.scaleX = props.scaleX ?? 1
props.scaleY = props.scaleY ?? 1
},
down: (props) => {
// Convert delta-encoded 'path' back to VecModel[] arrays in 'points'
props.segments = props.segments.map((segment: any) => {
const { path, ...rest } = segment
return {
...rest,
points: b64Vecs.decodePoints(path),
}
})
delete props.scaleX
delete props.scaleY
},
},
{
id: Versions.LegacyPointsConversion,
up: (props) => {
// Handle legacy data that was already migrated to v3 with absolute Float16 in 'points'
// Convert 'points' to delta-encoded 'path'
props.segments = props.segments.map((segment: any) => {
// If segment already has 'path', it's already in the new format
if (segment.path !== undefined) return segment
const { points, ...rest } = segment
const vecModels = Array.isArray(points) ? points : b64Vecs._legacyDecodePoints(points)
return {
...rest,
path: b64Vecs.encodePoints(vecModels),
}
})
},
down: (_props) => {
// handled by the previous down migration
},
},
{
id: Versions.OmitNonPressureZ,
up: (_props) => {
// No-op. Pre-existing segments have no `dim` field, which is exactly how
// the new schema reads them (3D). The validator now accepts the optional
// `dim`, so newer 2D segments validate as well — nothing to rewrite here.
},
down: (props) => {
// Older clients only understand 3D paths and reject the unknown `dim` field.
// Strip `dim` from every segment that carries it; a 2D segment is also
// re-encoded back to 3D (decode supplies z = 0.5), while a `dim: 3` segment
// already has a 3D path so we just drop the field.
props.segments = props.segments.map((segment: any) => {
if (segment.dim === undefined) return segment
const { dim, ...rest } = segment
return dim === DIM_2D
? { ...rest, path: b64Vecs.encodePoints(b64Vecs.decodePoints(segment.path, DIM_2D)) }
: rest
})
},
},
],
})
/**
* Compress legacy draw shape segments by converting VecModel[] points to delta-encoded base64 format.
* This function is useful for converting old draw shape data to the new compressed format.
* Uses delta encoding for improved Float16 precision.
*
* @public
*/
export function compressLegacySegments(
segments: {
type: 'free' | 'straight'
points: VecModel[]
}[]
): TLDrawShapeSegment[] {
return segments.map((segment) => ({
type: segment.type,
path: b64Vecs.encodePoints(segment.points),
}))
}

View File

@@ -0,0 +1,386 @@
import { safeParseUrl } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'
import { RecordProps } from '../recordsWithProps'
import { TLBaseShape } from './TLBaseShape'
// Only allow multiplayer embeds. If we add additional routes later for example '/help' this won't match
const TLDRAW_APP_RE = /(^\/r\/[^/]+\/?$)/
const EMBED_DEFINITIONS = [
{
hostnames: ['beta.tldraw.com', 'tldraw.com', 'localhost:3000'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(TLDRAW_APP_RE)) {
return url
}
return
},
},
{
hostnames: ['figma.com'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/^\/embed\/?$/)) {
const outUrl = urlObj.searchParams.get('url')
if (outUrl) {
return outUrl
}
}
return
},
},
{
hostnames: ['google.*'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (!urlObj) return
const matches = urlObj.pathname.match(/^\/maps\/embed\/v1\/view\/?$/)
if (matches && urlObj.searchParams.has('center') && urlObj.searchParams.get('zoom')) {
const zoom = urlObj.searchParams.get('zoom')
const [lat, lon] = urlObj.searchParams.get('center')!.split(',')
return `https://www.google.com/maps/@${lat},${lon},${zoom}z`
}
return
},
},
{
hostnames: ['val.town'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
// e.g. extract "steveruizok/mathFact" from https://www.val.town/v/steveruizok/mathFact
const matches = urlObj && urlObj.pathname.match(/\/embed\/(.+)\/?/)
if (matches) {
return `https://www.val.town/v/${matches[1]}`
}
return
},
},
{
hostnames: ['codesandbox.io'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
const matches = urlObj && urlObj.pathname.match(/\/embed\/([^/]+)\/?/)
if (matches) {
return `https://codesandbox.io/s/${matches[1]}`
}
return
},
},
{
hostnames: ['codepen.io'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const CODEPEN_EMBED_REGEXP = /https:\/\/codepen.io\/([^/]+)\/embed\/([^/]+)/
const matches = url.match(CODEPEN_EMBED_REGEXP)
if (matches) {
const [_, user, id] = matches
return `https://codepen.io/${user}/pen/${id}`
}
return
},
},
{
hostnames: ['scratch.mit.edu'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const SCRATCH_EMBED_REGEXP = /https:\/\/scratch.mit.edu\/projects\/embed\/([^/]+)/
const matches = url.match(SCRATCH_EMBED_REGEXP)
if (matches) {
const [_, id] = matches
return `https://scratch.mit.edu/projects/${id}`
}
return
},
},
{
hostnames: ['*.youtube.com', 'youtube.com', 'youtu.be'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (!urlObj) return
const hostname = urlObj.hostname.replace(/^www./, '')
if (hostname === 'youtube.com') {
const matches = urlObj.pathname.match(/^\/embed\/([^/]+)\/?/)
if (matches) {
return `https://www.youtube.com/watch?v=${matches[1]}`
}
}
return
},
},
{
hostnames: ['calendar.google.*'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
const srcQs = urlObj?.searchParams.get('src')
if (urlObj?.pathname.match(/\/calendar\/embed/) && srcQs) {
urlObj.pathname = '/calendar/u/0'
const keys = Array.from(urlObj.searchParams.keys())
for (const key of keys) {
urlObj.searchParams.delete(key)
}
urlObj.searchParams.set('cid', srcQs)
return urlObj.href
}
return
},
},
{
hostnames: ['docs.google.*'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (urlObj?.pathname.match(/^\/presentation/) && urlObj?.pathname.match(/\/embed\/?$/)) {
urlObj.pathname = urlObj.pathname.replace(/\/embed$/, '/pub')
const keys = Array.from(urlObj.searchParams.keys())
for (const key of keys) {
urlObj.searchParams.delete(key)
}
return urlObj.href
}
return
},
},
{
hostnames: ['gist.github.com'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/\/([^/]+)\/([^/]+)/)) {
if (!url.split('/').pop()) return
return url
}
return
},
},
{
hostnames: ['replit.com'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (
urlObj &&
urlObj.pathname.match(/\/@([^/]+)\/([^/]+)/) &&
urlObj.searchParams.has('embed')
) {
urlObj.searchParams.delete('embed')
return urlObj.href
}
return
},
},
{
hostnames: ['felt.com'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/^\/embed\/map\//)) {
urlObj.pathname = urlObj.pathname.replace(/^\/embed/, '')
return urlObj.href
}
return
},
},
{
hostnames: ['open.spotify.com'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/^\/embed\/(artist|album)\//)) {
return urlObj.origin + urlObj.pathname.replace(/^\/embed/, '')
}
return
},
},
{
hostnames: ['vimeo.com', 'player.vimeo.com'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.hostname === 'player.vimeo.com') {
const matches = urlObj.pathname.match(/^\/video\/([^/]+)\/?$/)
if (matches) {
return 'https://vimeo.com/' + matches[1]
}
}
return
},
},
{
hostnames: ['observablehq.com'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/^\/embed\/@([^/]+)\/([^/]+)\/?$/)) {
return `${urlObj.origin}${urlObj.pathname.replace('/embed', '')}#cell-*`
}
if (urlObj && urlObj.pathname.match(/^\/embed\/([^/]+)\/?$/)) {
return `${urlObj.origin}${urlObj.pathname.replace('/embed', '/d')}#cell-*`
}
return
},
},
{
hostnames: ['desmos.com'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (
urlObj &&
urlObj.hostname === 'www.desmos.com' &&
urlObj.pathname.match(/^\/calculator\/([^/]+)\/?$/) &&
urlObj.search === '?embed' &&
urlObj.hash === ''
) {
return url.replace('?embed', '')
}
return
},
},
]
/**
* Properties for the embed shape, which displays embedded content from external services.
*
* @public
*/
export interface TLEmbedShapeProps {
/** Width of the embed shape in pixels */
w: number
/** Height of the embed shape in pixels */
h: number
/** URL of the content to embed (supports YouTube, Figma, CodePen, etc.) */
url: string
}
/**
* An embed shape displays external content like YouTube videos, Figma designs, CodePen demos,
* and other embeddable content within the tldraw canvas.
*
* @public
* @example
* ```ts
* const embedShape: TLEmbedShape = {
* id: createShapeId(),
* typeName: 'shape',
* type: 'embed',
* x: 200,
* y: 200,
* rotation: 0,
* index: 'a1',
* parentId: 'page:page1',
* isLocked: false,
* opacity: 1,
* props: {
* w: 560,
* h: 315,
* url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
* },
* meta: {}
* }
* ```
*/
export type TLEmbedShape = TLBaseShape<'embed', TLEmbedShapeProps>
/**
* Validation schema for embed shape properties.
*
* @public
* @example
* ```ts
* // Validate embed shape properties
* const isValidUrl = embedShapeProps.url.isValid('https://youtube.com/watch?v=abc123')
* const isValidSize = embedShapeProps.w.isValid(560)
* ```
*/
export const embedShapeProps: RecordProps<TLEmbedShape> = {
w: T.nonZeroNumber,
h: T.nonZeroNumber,
url: T.string,
}
const Versions = createShapePropsMigrationIds('embed', {
GenOriginalUrlInEmbed: 1,
RemoveDoesResize: 2,
RemoveTmpOldUrl: 3,
RemovePermissionOverrides: 4,
})
/**
* Version identifiers for embed shape migrations.
*
* @public
*/
export { Versions as embedShapeVersions }
/**
* Migration sequence for embed shape properties across different schema versions.
* Handles URL transformations and removal of deprecated properties.
*
* @public
*/
export const embedShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.GenOriginalUrlInEmbed,
// add tmpOldUrl property
up: (props) => {
try {
const url = props.url
const host = new URL(url).host.replace('www.', '')
let originalUrl
for (const localEmbedDef of EMBED_DEFINITIONS) {
if (localEmbedDef.hostnames.includes(host)) {
try {
originalUrl = localEmbedDef.fromEmbedUrl(url)
} catch (err) {
console.warn(err)
}
}
}
props.tmpOldUrl = props.url
props.url = originalUrl ?? ''
} catch {
props.url = ''
props.tmpOldUrl = props.url
}
},
down: 'retired',
},
{
id: Versions.RemoveDoesResize,
up: (props) => {
delete props.doesResize
},
down: 'retired',
},
{
id: Versions.RemoveTmpOldUrl,
up: (props) => {
delete props.tmpOldUrl
},
down: 'retired',
},
{
id: Versions.RemovePermissionOverrides,
up: (props) => {
delete props.overridePermissions
},
down: 'retired',
},
],
})

View File

@@ -0,0 +1,106 @@
import { T } from '@tldraw/validate'
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'
import { RecordProps } from '../recordsWithProps'
import { DefaultColorStyle, TLDefaultColorStyle } from '../styles/TLColorStyle'
import { TLBaseShape } from './TLBaseShape'
/**
* Properties for the frame shape, which provides a container for organizing other shapes.
*
* @public
*/
export interface TLFrameShapeProps {
/** Width of the frame in pixels */
w: number
/** Height of the frame in pixels */
h: number
/** Display name for the frame (shown in UI) */
name: string
/** Color style for the frame border and label */
color: TLDefaultColorStyle
}
/**
* A frame shape provides a container for organizing and grouping other shapes.
* Frames can be used to create sections, organize content, or define specific areas.
*
* @public
* @example
* ```ts
* const frameShape: TLFrameShape = {
* id: createShapeId(),
* typeName: 'shape',
* type: 'frame',
* x: 0,
* y: 0,
* rotation: 0,
* index: 'a1',
* parentId: 'page:page1',
* isLocked: false,
* opacity: 1,
* props: {
* w: 400,
* h: 300,
* name: 'Header Section',
* color: 'blue'
* },
* meta: {}
* }
* ```
*/
export type TLFrameShape = TLBaseShape<'frame', TLFrameShapeProps>
/**
* Validation schema for frame shape properties.
*
* @public
* @example
* ```ts
* // Validate frame properties
* const isValidName = frameShapeProps.name.isValid('My Frame')
* const isValidColor = frameShapeProps.color.isValid('red')
* ```
*/
export const frameShapeProps: RecordProps<TLFrameShape> = {
w: T.nonZeroNumber,
h: T.nonZeroNumber,
name: T.string,
// because shape colors are an option, we don't want them to be picked up by the editor as a
// style prop by default, so instead of a proper style we just supply an equivalent validator.
// Check `FrameShapeUtil.configure` for how we replace this with the original
// `DefaultColorStyle` style when the option is turned on.
// We delegate to DefaultColorStyle.validate so custom colors from themes are
// picked up automatically.
color: { validate: (v: unknown) => DefaultColorStyle.validate(v) as TLDefaultColorStyle },
}
const Versions = createShapePropsMigrationIds('frame', {
AddColorProp: 1,
})
/**
* Version identifiers for frame shape migrations.
*
* @public
*/
export { Versions as frameShapeVersions }
/**
* Migration sequence for frame shape properties across different schema versions.
* Handles adding color properties to existing frame shapes.
*
* @public
*/
export const frameShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.AddColorProp,
up: (props) => {
props.color = 'black'
},
down: (props) => {
delete props.color
},
},
],
})

View File

@@ -0,0 +1,342 @@
import { T } from '@tldraw/validate'
import { TLRichText, richTextValidator, toRichText } from '../misc/TLRichText'
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'
import { RecordProps } from '../recordsWithProps'
import { StyleProp } from '../styles/StyleProp'
import {
DefaultColorStyle,
DefaultLabelColorStyle,
TLDefaultColorStyle,
} from '../styles/TLColorStyle'
import { DefaultDashStyle, TLDefaultDashStyle } from '../styles/TLDashStyle'
import { DefaultFillStyle, TLDefaultFillStyle } from '../styles/TLFillStyle'
import { DefaultFontStyle, TLDefaultFontStyle } from '../styles/TLFontStyle'
import {
DefaultHorizontalAlignStyle,
TLDefaultHorizontalAlignStyle,
} from '../styles/TLHorizontalAlignStyle'
import { DefaultSizeStyle, TLDefaultSizeStyle } from '../styles/TLSizeStyle'
import {
DefaultVerticalAlignStyle,
TLDefaultVerticalAlignStyle,
} from '../styles/TLVerticalAlignStyle'
import { TLBaseShape } from './TLBaseShape'
/**
* Style property defining the geometric shape type for geo shapes.
* Provides a variety of built-in geometric forms including basic shapes,
* polygons, arrows, and special shapes.
*
* @public
* @example
* ```ts
* // Use in shape props
* const props = {
* geo: 'rectangle', // or 'ellipse', 'triangle', etc.
* // other properties...
* }
* ```
*/
export const GeoShapeGeoStyle = StyleProp.defineEnum('tldraw:geo', {
defaultValue: 'rectangle',
values: [
'cloud',
'rectangle',
'ellipse',
'triangle',
'diamond',
'pentagon',
'hexagon',
'octagon',
'star',
'rhombus',
'rhombus-2',
'oval',
'trapezoid',
'arrow-right',
'arrow-left',
'arrow-up',
'arrow-down',
'x-box',
'check-box',
'heart',
],
})
/**
* Type representing valid geometric shape styles for geo shapes.
*
* @public
*/
export type TLGeoShapeGeoStyle = T.TypeOf<typeof GeoShapeGeoStyle>
/**
* Properties for the geo shape, which renders various geometric forms with styling and text.
*
* @public
*/
export interface TLGeoShapeProps {
/** Geometric shape type (rectangle, ellipse, triangle, etc.) */
geo: TLGeoShapeGeoStyle
/** Dash pattern style for the shape outline */
dash: TLDefaultDashStyle
/** URL link associated with the shape */
url: string
/** Width of the shape in pixels */
w: number
/** Height of the shape in pixels */
h: number
/** Additional vertical growth for text content */
growY: number
/** Scale factor applied to the shape */
scale: number
/** Whether to mirror the shape horizontally */
flipX: boolean
/** Whether to mirror the shape vertically */
flipY: boolean
/** Color style for text label */
labelColor: TLDefaultColorStyle
/** Color style for the shape outline */
color: TLDefaultColorStyle
/** Fill style for the shape interior */
fill: TLDefaultFillStyle
/** Size/thickness style for outline and text */
size: TLDefaultSizeStyle
/** Font style for text content */
font: TLDefaultFontStyle
/** Horizontal alignment for text content */
align: TLDefaultHorizontalAlignStyle
/** Vertical alignment for text content */
verticalAlign: TLDefaultVerticalAlignStyle
/** Rich text content displayed within the shape */
richText: TLRichText
}
/**
* A geo shape represents geometric forms like rectangles, ellipses, triangles, and other
* predefined shapes. Geo shapes support styling, text content, and can act as containers.
*
* @public
* @example
* ```ts
* const geoShape: TLGeoShape = {
* id: createShapeId(),
* typeName: 'shape',
* type: 'geo',
* x: 100,
* y: 100,
* rotation: 0,
* index: 'a1',
* parentId: 'page:page1',
* isLocked: false,
* opacity: 1,
* props: {
* geo: 'rectangle',
* w: 200,
* h: 100,
* color: 'black',
* fill: 'solid',
* dash: 'solid',
* size: 'm',
* font: 'draw',
* align: 'middle',
* verticalAlign: 'middle',
* richText: toRichText('Hello World'),
* labelColor: 'black',
* url: '',
* growY: 0,
* scale: 1,
* flipX: false,
* flipY: false
* },
* meta: {}
* }
* ```
*/
export type TLGeoShape = TLBaseShape<'geo', TLGeoShapeProps>
/**
* Validation schema for geo shape properties.
*
* @public
* @example
* ```ts
* // Validate geo shape properties
* const isValidGeo = geoShapeProps.geo.isValid('rectangle')
* const isValidSize = geoShapeProps.w.isValid(100)
* const isValidText = geoShapeProps.richText.isValid(toRichText('Hello'))
* ```
*/
export const geoShapeProps: RecordProps<TLGeoShape> = {
geo: GeoShapeGeoStyle,
dash: DefaultDashStyle,
url: T.linkUrl,
w: T.nonZeroNumber,
h: T.nonZeroNumber,
growY: T.positiveNumber,
scale: T.nonZeroNumber,
flipX: T.boolean,
flipY: T.boolean,
// Text properties
labelColor: DefaultLabelColorStyle,
color: DefaultColorStyle,
fill: DefaultFillStyle,
size: DefaultSizeStyle,
font: DefaultFontStyle,
align: DefaultHorizontalAlignStyle,
verticalAlign: DefaultVerticalAlignStyle,
richText: richTextValidator,
}
const geoShapeVersions = createShapePropsMigrationIds('geo', {
AddUrlProp: 1,
AddLabelColor: 2,
RemoveJustify: 3,
AddCheckBox: 4,
AddVerticalAlign: 5,
MigrateLegacyAlign: 6,
AddCloud: 7,
MakeUrlsValid: 8,
AddScale: 9,
AddRichText: 10,
AddRichTextAttrs: 11,
AddFlipProps: 12,
})
/**
* Version identifiers for geo shape migrations.
*
* @public
*/
export { geoShapeVersions }
/**
* Migration sequence for geo shape properties across different schema versions.
* Handles evolution of geo shapes including URL support, label colors, alignment changes,
* the transition from plain text to rich text, and support for attrs property on richText.
*
* @public
*/
export const geoShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: geoShapeVersions.AddUrlProp,
up: (props) => {
props.url = ''
},
down: 'retired',
},
{
id: geoShapeVersions.AddLabelColor,
up: (props) => {
props.labelColor = 'black'
},
down: 'retired',
},
{
id: geoShapeVersions.RemoveJustify,
up: (props) => {
if (props.align === 'justify') {
props.align = 'start'
}
},
down: 'retired',
},
{
id: geoShapeVersions.AddCheckBox,
up: (_props) => {
// noop
},
down: 'retired',
},
{
id: geoShapeVersions.AddVerticalAlign,
up: (props) => {
props.verticalAlign = 'middle'
},
down: 'retired',
},
{
id: geoShapeVersions.MigrateLegacyAlign,
up: (props) => {
let newAlign: TLDefaultHorizontalAlignStyle
switch (props.align) {
case 'start':
newAlign = 'start-legacy'
break
case 'end':
newAlign = 'end-legacy'
break
default:
newAlign = 'middle-legacy'
break
}
props.align = newAlign
},
down: 'retired',
},
{
id: geoShapeVersions.AddCloud,
up: (_props) => {
// noop
},
down: 'retired',
},
{
id: geoShapeVersions.MakeUrlsValid,
up: (props) => {
if (!T.linkUrl.isValid(props.url)) {
props.url = ''
}
},
down: (_props) => {
// noop
},
},
{
id: geoShapeVersions.AddScale,
up: (props) => {
props.scale = 1
},
down: (props) => {
delete props.scale
},
},
{
id: geoShapeVersions.AddRichText,
up: (props) => {
props.richText = toRichText(props.text)
delete props.text
},
// N.B. Explicitly no down state so that we force clients to update.
// down: (props) => {
// delete props.richText
// },
},
{
id: geoShapeVersions.AddRichTextAttrs,
up: (_props) => {
// noop - attrs is optional so old records are valid
},
down: (props) => {
// Remove attrs from richText when migrating down
if (props.richText && 'attrs' in props.richText) {
delete props.richText.attrs
}
},
},
{
id: geoShapeVersions.AddFlipProps,
up: (props) => {
props.flipX = false
props.flipY = false
},
down: (props) => {
delete props.flipX
delete props.flipY
},
},
],
})

View File

@@ -0,0 +1,64 @@
import { createShapePropsMigrationSequence } from '../records/TLShape'
import { RecordProps } from '../recordsWithProps'
import { TLBaseShape } from './TLBaseShape'
/**
* Properties for a group shape. Group shapes are used to organize and manage collections of shapes as a single unit.
* Group shapes themselves have no visual properties and serve only as containers.
*
* @public
* @example
* ```ts
* const groupProps: TLGroupShapeProps = {}
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface TLGroupShapeProps {}
/**
* A group shape that acts as a container for organizing multiple shapes into a single logical unit.
* Groups enable users to move, transform, and manage collections of shapes together while maintaining
* their relative positions and properties.
*
* @public
* @example
* ```ts
* const groupShape: TLGroupShape = {
* id: 'shape:group1',
* type: 'group',
* x: 100,
* y: 100,
* rotation: 0,
* index: 'a1',
* parentId: 'page:main',
* isLocked: false,
* opacity: 1,
* props: {},
* meta: {},
* typeName: 'shape'
* }
* ```
*/
export type TLGroupShape = TLBaseShape<'group', TLGroupShapeProps>
/**
* Validation schema for group shape properties. Since group shapes have no visual properties,
* this is an empty object that serves as a placeholder for the schema system.
*
* @public
* @example
* ```ts
* import { groupShapeProps } from '@tldraw/tlschema'
*
* // Used internally by the validation system
* const validator = T.object(groupShapeProps)
* ```
*/
export const groupShapeProps: RecordProps<TLGroupShape> = {}
/**
* Migration sequence for group shapes. Currently contains no migrations.
*
* @public
*/
export const groupShapeMigrations = createShapePropsMigrationSequence({ sequence: [] })

View File

@@ -0,0 +1,204 @@
import { T } from '@tldraw/validate'
import { DIM_2D, b64Vecs } from '../misc/b64Vecs'
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'
import { RecordProps } from '../recordsWithProps'
import { DefaultColorStyle, TLDefaultColorStyle } from '../styles/TLColorStyle'
import { DefaultSizeStyle, TLDefaultSizeStyle } from '../styles/TLSizeStyle'
import { TLBaseShape } from './TLBaseShape'
import { DrawShapeSegment, TLDrawShapeSegment } from './TLDrawShape'
/**
* Properties for a highlight shape. Highlight shapes represent highlighting strokes made with
* a highlighting tool, typically used to emphasize or mark up content.
*
* @public
* @example
* ```ts
* const highlightProps: TLHighlightShapeProps = {
* color: 'yellow',
* size: 'm',
* segments: [{ type: 'straight', points: [{ x: 0, y: 0, z: 0.5 }] }],
* isComplete: true,
* isPen: false,
* scale: 1
* }
* ```
*/
export interface TLHighlightShapeProps {
/** The color style of the highlight stroke */
color: TLDefaultColorStyle
/** The size style determining the thickness of the highlight stroke */
size: TLDefaultSizeStyle
/** Array of segments that make up the highlight stroke path */
segments: TLDrawShapeSegment[]
/** Whether the highlight stroke has been completed by the user */
isComplete: boolean
/** Whether the highlight was drawn with a pen/stylus (affects rendering style) */
isPen: boolean
/** Scale factor applied to the highlight shape for display */
scale: number
/** Horizontal scale factor for lazy resize */
scaleX: number
/** Vertical scale factor for lazy resize */
scaleY: number
}
/**
* A highlight shape representing a highlighting stroke drawn by the user. Highlight shapes
* are typically semi-transparent and used for marking up or emphasizing content on the canvas.
*
* @public
* @example
* ```ts
* const highlightShape: TLHighlightShape = {
* id: 'shape:highlight1',
* type: 'highlight',
* x: 100,
* y: 50,
* rotation: 0,
* index: 'a1',
* parentId: 'page:main',
* isLocked: false,
* opacity: 0.7,
* props: {
* color: 'yellow',
* size: 'l',
* segments: [],
* isComplete: false,
* isPen: false,
* scale: 1
* },
* meta: {},
* typeName: 'shape'
* }
* ```
*/
export type TLHighlightShape = TLBaseShape<'highlight', TLHighlightShapeProps>
/**
* Validation schema for highlight shape properties. Defines the runtime validation rules
* for all properties of highlight shapes.
*
* @public
* @example
* ```ts
* import { highlightShapeProps } from '@tldraw/tlschema'
*
* // Used internally by the validation system
* const validator = T.object(highlightShapeProps)
* const validatedProps = validator.validate(someHighlightProps)
* ```
*/
/** @public */
export const highlightShapeProps: RecordProps<TLHighlightShape> = {
color: DefaultColorStyle,
size: DefaultSizeStyle,
segments: T.arrayOf(DrawShapeSegment),
isComplete: T.boolean,
isPen: T.boolean,
scale: T.nonZeroNumber,
scaleX: T.nonZeroFiniteNumber,
scaleY: T.nonZeroFiniteNumber,
}
const Versions = createShapePropsMigrationIds('highlight', {
AddScale: 1,
Base64: 2,
LegacyPointsConversion: 3,
OmitNonPressureZ: 4,
})
/**
* Version identifiers for highlight shape migrations. These version numbers track
* schema changes over time to enable proper data migration.
*
* @public
*/
export { Versions as highlightShapeVersions }
/**
* Migration sequence for highlight shapes. Handles schema evolution over time by defining
* how to upgrade and downgrade highlight shape data between different versions.
*
* @public
*/
export const highlightShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.AddScale,
up: (props) => {
props.scale = 1
},
down: (props) => {
delete props.scale
},
},
{
id: Versions.Base64,
up: (props) => {
// Convert VecModel[] arrays directly to delta-encoded base64 in 'path'
props.segments = props.segments.map((segment: any) => {
if (segment.path !== undefined) return segment
const { points, ...rest } = segment
const vecModels = Array.isArray(points) ? points : b64Vecs._legacyDecodePoints(points)
return {
...rest,
path: b64Vecs.encodePoints(vecModels),
}
})
props.scaleX = props.scaleX ?? 1
props.scaleY = props.scaleY ?? 1
},
down: (props) => {
// Convert delta-encoded 'path' back to VecModel[] arrays in 'points'
props.segments = props.segments.map((segment: any) => {
const { path, ...rest } = segment
return {
...rest,
points: b64Vecs.decodePoints(path),
}
})
delete props.scaleX
delete props.scaleY
},
},
{
id: Versions.LegacyPointsConversion,
up: (props) => {
// Handle legacy data that was already migrated to v2 with absolute Float16 in 'points'
// Convert 'points' to delta-encoded 'path'
props.segments = props.segments.map((segment: any) => {
// If segment already has 'path', it's already in the new format
if (segment.path !== undefined) return segment
const { points, ...rest } = segment
const vecModels = Array.isArray(points) ? points : b64Vecs._legacyDecodePoints(points)
return {
...rest,
path: b64Vecs.encodePoints(vecModels),
}
})
},
down: (_props) => {
// handled by the previous down migration
},
},
{
id: Versions.OmitNonPressureZ,
up: (_props) => {
// No-op — see the matching draw-shape migration. Absent `dim` reads as 3D.
},
down: (props) => {
// Strip `dim` from every segment that carries it (older clients reject the
// unknown field); 2D segments are re-encoded to 3D, dim: 3 just loses the field.
props.segments = props.segments.map((segment: any) => {
if (segment.dim === undefined) return segment
const { dim, ...rest } = segment
return dim === DIM_2D
? { ...rest, path: b64Vecs.encodePoints(b64Vecs.decodePoints(segment.path, DIM_2D)) }
: rest
})
},
},
],
})

View File

@@ -0,0 +1,207 @@
import { T } from '@tldraw/validate'
import { assetIdValidator } from '../assets/TLBaseAsset'
import { vecModelValidator } from '../misc/geometry-types'
import { TLAssetId } from '../records/TLAsset'
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'
import { RecordProps } from '../recordsWithProps'
import { TLShapeCrop } from './ShapeWithCrop'
import { TLBaseShape } from './TLBaseShape'
/**
* Validator for image shape crop data. Defines the structure for cropping an image,
* specifying the visible region within the original image bounds.
*
* @public
* @example
* ```ts
* const cropData: TLShapeCrop = {
* topLeft: { x: 0.1, y: 0.1 },
* bottomRight: { x: 0.9, y: 0.9 },
* isCircle: false
* }
*
* const isValid = ImageShapeCrop.isValid(cropData)
* ```
*/
export const ImageShapeCrop: T.ObjectValidator<TLShapeCrop> = T.object({
topLeft: vecModelValidator,
bottomRight: vecModelValidator,
isCircle: T.boolean.optional(),
})
/**
* Properties for an image shape. Image shapes display raster images on the canvas,
* with support for cropping, flipping, and asset management.
*
* @public
* @example
* ```ts
* const imageProps: TLImageShapeProps = {
* w: 300,
* h: 200,
* playing: true,
* url: 'https://example.com/image.jpg',
* assetId: 'asset:image123',
* crop: null,
* flipX: false,
* flipY: false,
* altText: 'A sample image'
* }
* ```
*/
export interface TLImageShapeProps {
/** Width of the image shape in canvas units */
w: number
/** Height of the image shape in canvas units */
h: number
/** Whether animated images (like GIFs) should play */
playing: boolean
/** URL of the image resource */
url: string
/** ID of the associated asset record, null if no asset */
assetId: TLAssetId | null
/** Crop data defining visible region of the image, null for no cropping */
crop: TLShapeCrop | null
/** Whether to flip the image horizontally */
flipX: boolean
/** Whether to flip the image vertically */
flipY: boolean
/** Alternative text for accessibility and when image fails to load */
altText: string
}
/**
* An image shape representing a raster image on the canvas. Image shapes can display
* various image formats and support features like cropping, flipping, and asset management.
*
* @public
* @example
* ```ts
* const imageShape: TLImageShape = {
* id: 'shape:image1',
* type: 'image',
* x: 100,
* y: 100,
* rotation: 0,
* index: 'a1',
* parentId: 'page:main',
* isLocked: false,
* opacity: 1,
* props: {
* w: 400,
* h: 300,
* playing: true,
* url: '',
* assetId: 'asset:photo1',
* crop: null,
* flipX: false,
* flipY: false,
* altText: 'Sample photo'
* },
* meta: {},
* typeName: 'shape'
* }
* ```
*/
export type TLImageShape = TLBaseShape<'image', TLImageShapeProps>
/**
* Validation schema for image shape properties. Defines the runtime validation rules
* for all properties of image shapes, ensuring data integrity and type safety.
*
* @public
* @example
* ```ts
* import { imageShapeProps } from '@tldraw/tlschema'
*
* // Used internally by the validation system
* const validator = T.object(imageShapeProps)
* const validatedProps = validator.validate(someImageProps)
* ```
*/
export const imageShapeProps: RecordProps<TLImageShape> = {
w: T.nonZeroNumber,
h: T.nonZeroNumber,
playing: T.boolean,
url: T.linkUrl,
assetId: assetIdValidator.nullable(),
crop: ImageShapeCrop.nullable(),
flipX: T.boolean,
flipY: T.boolean,
altText: T.string,
}
const Versions = createShapePropsMigrationIds('image', {
AddUrlProp: 1,
AddCropProp: 2,
MakeUrlsValid: 3,
AddFlipProps: 4,
AddAltText: 5,
})
/**
* Version identifiers for image shape migrations. These version numbers track
* schema changes over time to enable proper data migration between versions.
*
* @public
*/
export { Versions as imageShapeVersions }
/**
* Migration sequence for image shapes. Handles schema evolution over time by defining
* how to upgrade and downgrade image shape data between different versions. Includes
* migrations for URL properties, crop functionality, flip properties, and accessibility features.
*
* @public
*/
export const imageShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.AddUrlProp,
up: (props) => {
props.url = ''
},
down: 'retired',
},
{
id: Versions.AddCropProp,
up: (props) => {
props.crop = null
},
down: (props) => {
delete props.crop
},
},
{
id: Versions.MakeUrlsValid,
up: (props) => {
if (!T.linkUrl.isValid(props.url)) {
props.url = ''
}
},
down: (_props) => {
// noop
},
},
{
id: Versions.AddFlipProps,
up: (props) => {
props.flipX = false
props.flipY = false
},
down: (props) => {
delete props.flipX
delete props.flipY
},
},
{
id: Versions.AddAltText,
up: (props) => {
props.altText = ''
},
down: (props) => {
delete props.altText
},
},
],
})

View File

@@ -0,0 +1,305 @@
import { IndexKey, getIndices, objectMapFromEntries, sortByIndex } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'
import { RecordProps } from '../recordsWithProps'
import { StyleProp } from '../styles/StyleProp'
import { DefaultColorStyle, TLDefaultColorStyle } from '../styles/TLColorStyle'
import { DefaultDashStyle, TLDefaultDashStyle } from '../styles/TLDashStyle'
import { DefaultSizeStyle, TLDefaultSizeStyle } from '../styles/TLSizeStyle'
import { TLBaseShape } from './TLBaseShape'
/**
* Style property for line shape spline interpolation. Determines how the line is rendered
* between points - either as straight line segments or smooth cubic curves.
*
* @public
* @example
* ```ts
* // Create a shape with cubic spline interpolation
* const lineProps = {
* spline: 'cubic' as TLLineShapeSplineStyle,
* // other props...
* }
* ```
*/
export const LineShapeSplineStyle = StyleProp.defineEnum('tldraw:spline', {
defaultValue: 'line',
values: ['cubic', 'line'],
})
/**
* Type representing the spline style options for line shapes.
* - 'line': Straight line segments between points
* - 'cubic': Smooth cubic bezier curves between points
*
* @public
*/
export type TLLineShapeSplineStyle = T.TypeOf<typeof LineShapeSplineStyle>
/**
* Represents a single point in a line shape. Line shapes are made up of multiple points
* that define the path of the line, with each point having coordinates and ordering information.
*
* @public
* @example
* ```ts
* const linePoint: TLLineShapePoint = {
* id: 'a1',
* index: 'a1' as IndexKey,
* x: 100,
* y: 50
* }
* ```
*/
export interface TLLineShapePoint {
/** Unique identifier for this point, used for tracking and ordering */
id: string
/** Fractional index key used for ordering points along the line */
index: IndexKey
/** X coordinate of the point relative to the line shape's origin */
x: number
/** Y coordinate of the point relative to the line shape's origin */
y: number
}
const lineShapePointValidator: T.ObjectValidator<TLLineShapePoint> = T.object({
id: T.string,
index: T.indexKey,
x: T.number,
y: T.number,
})
/**
* Properties for a line shape. Line shapes represent multi-point lines or splines
* that can be drawn by connecting multiple points with either straight segments or curves.
*
* @public
* @example
* ```ts
* const lineProps: TLLineShapeProps = {
* color: 'black',
* dash: 'solid',
* size: 'm',
* spline: 'line',
* points: {
* 'a1': { id: 'a1', index: 'a1', x: 0, y: 0 },
* 'a2': { id: 'a2', index: 'a2', x: 100, y: 50 }
* },
* scale: 1
* }
* ```
*/
export interface TLLineShapeProps {
/** Color style of the line stroke */
color: TLDefaultColorStyle
/** Dash pattern style for the line (solid, dashed, dotted) */
dash: TLDefaultDashStyle
/** Size/thickness style of the line stroke */
size: TLDefaultSizeStyle
/** Interpolation style between points (straight lines or curved splines) */
spline: TLLineShapeSplineStyle
/** Dictionary of points that make up the line, keyed by point ID */
points: Record<string, TLLineShapePoint>
/** Scale factor applied to the line shape for display */
scale: number
}
/**
* A line shape that represents a multi-point line or spline on the canvas. Line shapes
* allow users to draw connected paths with multiple points, supporting both straight
* line segments and smooth curved splines.
*
* @public
* @example
* ```ts
* const lineShape: TLLineShape = {
* id: 'shape:line1',
* type: 'line',
* x: 100,
* y: 100,
* rotation: 0,
* index: 'a1',
* parentId: 'page:main',
* isLocked: false,
* opacity: 1,
* props: {
* color: 'red',
* dash: 'dashed',
* size: 'l',
* spline: 'cubic',
* points: {
* 'start': { id: 'start', index: 'a1', x: 0, y: 0 },
* 'end': { id: 'end', index: 'a2', x: 200, y: 100 }
* },
* scale: 1
* },
* meta: {},
* typeName: 'shape'
* }
* ```
*/
export type TLLineShape = TLBaseShape<'line', TLLineShapeProps>
/**
* Validation schema for line shape properties. Defines the runtime validation rules
* for all properties of line shapes, ensuring data integrity and type safety.
*
* @public
* @example
* ```ts
* import { lineShapeProps } from '@tldraw/tlschema'
*
* // Used internally by the validation system
* const validator = T.object(lineShapeProps)
* const validatedProps = validator.validate(someLineProps)
* ```
*/
export const lineShapeProps: RecordProps<TLLineShape> = {
color: DefaultColorStyle,
dash: DefaultDashStyle,
size: DefaultSizeStyle,
spline: LineShapeSplineStyle,
points: T.dict(T.string, lineShapePointValidator),
scale: T.nonZeroNumber,
}
/**
* Version identifiers for line shape migrations. These version numbers track
* significant schema changes over time, enabling proper data migration between versions.
*
* @public
*/
export const lineShapeVersions = createShapePropsMigrationIds('line', {
AddSnapHandles: 1,
RemoveExtraHandleProps: 2,
HandlesToPoints: 3,
PointIndexIds: 4,
AddScale: 5,
})
/**
* Migration sequence for line shapes. Handles schema evolution over time by defining
* how to upgrade and downgrade line shape data between different versions. Includes
* major structural changes like the transition from handles to points and the addition
* of scaling support.
*
* @public
*/
export const lineShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: lineShapeVersions.AddSnapHandles,
up: (props) => {
for (const handle of Object.values(props.handles)) {
;(handle as any).canSnap = true
}
},
down: 'retired',
},
{
id: lineShapeVersions.RemoveExtraHandleProps,
up: (props) => {
props.handles = objectMapFromEntries(
Object.values(props.handles).map((handle: any) => [
handle.index,
{
x: handle.x,
y: handle.y,
},
])
)
},
down: (props) => {
const handles = Object.entries(props.handles)
.map(([index, handle]: any) => ({ index, ...handle }))
.sort(sortByIndex)
props.handles = Object.fromEntries(
handles.map((handle, i) => {
const id =
i === 0 ? 'start' : i === handles.length - 1 ? 'end' : `handle:${handle.index}`
return [
id,
{
id,
type: 'vertex',
canBind: false,
canSnap: true,
index: handle.index,
x: handle.x,
y: handle.y,
},
]
})
)
},
},
{
id: lineShapeVersions.HandlesToPoints,
up: (props) => {
const sortedHandles = (
Object.entries(props.handles) as [IndexKey, { x: number; y: number }][]
)
.map(([index, { x, y }]) => ({ x, y, index }))
.sort(sortByIndex)
props.points = sortedHandles.map(({ x, y }) => ({ x, y }))
delete props.handles
},
down: (props) => {
const indices = getIndices(props.points.length)
props.handles = Object.fromEntries(
props.points.map((handle: { x: number; y: number }, i: number) => {
const index = indices[i]
return [
index,
{
x: handle.x,
y: handle.y,
},
]
})
)
delete props.points
},
},
{
id: lineShapeVersions.PointIndexIds,
up: (props) => {
const indices = getIndices(props.points.length)
props.points = Object.fromEntries(
props.points.map((point: { x: number; y: number }, i: number) => {
const id = indices[i]
return [
id,
{
id: id,
index: id,
x: point.x,
y: point.y,
},
]
})
)
},
down: (props) => {
const sortedHandles = (
Object.values(props.points) as { x: number; y: number; index: IndexKey }[]
).sort(sortByIndex)
props.points = sortedHandles.map(({ x, y }) => ({ x, y }))
},
},
{
id: lineShapeVersions.AddScale,
up: (props) => {
props.scale = 1
},
down: (props) => {
delete props.scale
},
},
],
})

View File

@@ -0,0 +1,308 @@
import { T } from '@tldraw/validate'
import { TLRichText, richTextValidator, toRichText } from '../misc/TLRichText'
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'
import { RecordProps } from '../recordsWithProps'
import {
DefaultColorStyle,
DefaultLabelColorStyle,
TLDefaultColorStyle,
} from '../styles/TLColorStyle'
import { DefaultFontStyle, TLDefaultFontStyle } from '../styles/TLFontStyle'
import {
DefaultHorizontalAlignStyle,
TLDefaultHorizontalAlignStyle,
} from '../styles/TLHorizontalAlignStyle'
import { DefaultSizeStyle, TLDefaultSizeStyle } from '../styles/TLSizeStyle'
import {
DefaultVerticalAlignStyle,
TLDefaultVerticalAlignStyle,
} from '../styles/TLVerticalAlignStyle'
import { TLBaseShape } from './TLBaseShape'
/**
* Properties for a note shape. Note shapes represent sticky notes or text annotations
* with rich formatting capabilities and various styling options.
*
* @public
* @example
* ```ts
* const noteProps: TLNoteShapeProps = {
* color: 'yellow',
* labelColor: 'black',
* size: 'm',
* font: 'draw',
* fontSizeAdjustment: null,
* align: 'middle',
* verticalAlign: 'middle',
* growY: 0,
* url: '',
* richText: toRichText('Hello **world**!'),
* scale: 1
* }
* ```
*/
export interface TLNoteShapeProps {
/** Background color style of the note */
color: TLDefaultColorStyle
/** Text color style for the note content */
labelColor: TLDefaultColorStyle
/** Size style determining the font size and note dimensions */
size: TLDefaultSizeStyle
/** Font family style for the note text */
font: TLDefaultFontStyle
/** Ratio to scale the base font size when text needs to shrink to fit. Null means needs recomputation, 1 means no adjustment, and values less than 1 indicate shrinkage. */
fontSizeAdjustment: number | null
/** Horizontal alignment of text within the note */
align: TLDefaultHorizontalAlignStyle
/** Vertical alignment of text within the note */
verticalAlign: TLDefaultVerticalAlignStyle
/** Additional height growth for the note beyond its base size */
growY: number
/** Optional URL associated with the note for linking */
url: string
/** Rich text content with formatting like bold, italic, etc. */
richText: TLRichText
/** Scale factor applied to the note shape for display */
scale: number
/** User ID of the person who last edited the note text */
textLastEditedBy: string | null
}
/**
* A note shape representing a sticky note or text annotation on the canvas.
* Note shapes support rich text formatting, various styling options, and can
* be used for annotations, reminders, or general text content.
*
* @public
* @example
* ```ts
* const noteShape: TLNoteShape = {
* id: 'shape:note1',
* type: 'note',
* x: 100,
* y: 100,
* rotation: 0,
* index: 'a1',
* parentId: 'page:main',
* isLocked: false,
* opacity: 1,
* props: {
* color: 'light-blue',
* labelColor: 'black',
* size: 's',
* font: 'sans',
* fontSizeAdjustment: 0.85,
* align: 'start',
* verticalAlign: 'start',
* growY: 50,
* url: 'https://example.com',
* richText: toRichText('Important **note**!'),
* scale: 1
* },
* meta: {},
* typeName: 'shape'
* }
* ```
*/
export type TLNoteShape = TLBaseShape<'note', TLNoteShapeProps>
/**
* Validation schema for note shape properties. Defines the runtime validation rules
* for all properties of note shapes, ensuring data integrity and type safety.
*
* @public
* @example
* ```ts
* import { noteShapeProps } from '@tldraw/tlschema'
*
* // Used internally by the validation system
* const validator = T.object(noteShapeProps)
* const validatedProps = validator.validate(someNoteProps)
* ```
*/
export const noteShapeProps: RecordProps<TLNoteShape> = {
color: DefaultColorStyle,
labelColor: DefaultLabelColorStyle,
size: DefaultSizeStyle,
font: DefaultFontStyle,
fontSizeAdjustment: T.positiveNumber.nullable(),
align: DefaultHorizontalAlignStyle,
verticalAlign: DefaultVerticalAlignStyle,
growY: T.positiveNumber,
url: T.linkUrl,
richText: richTextValidator,
scale: T.nonZeroNumber,
textLastEditedBy: T.string.nullable(),
}
const Versions = createShapePropsMigrationIds('note', {
AddUrlProp: 1,
RemoveJustify: 2,
MigrateLegacyAlign: 3,
AddVerticalAlign: 4,
MakeUrlsValid: 5,
AddFontSizeAdjustment: 6,
AddScale: 7,
AddLabelColor: 8,
AddRichText: 9,
AddRichTextAttrs: 10,
AddFirstEditedBy: 11,
MakeFontSizeAdjustmentRatio: 12,
RenameFirstEditedByToLast: 13,
})
/**
* Version identifiers for note shape migrations. These version numbers track
* significant schema changes over time, enabling proper data migration between versions.
*
* @public
*/
export { Versions as noteShapeVersions }
/**
* Migration sequence for note shapes. Handles schema evolution over time by defining
* how to upgrade and downgrade note shape data between different versions. Includes
* migrations for URL properties, text alignment changes, vertical alignment addition,
* font size adjustments, scaling support, label color, the transition from plain text to rich text,
* and support for attrs property on richText.
*
* @public
*/
export const noteShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.AddUrlProp,
up: (props) => {
props.url = ''
},
down: 'retired',
},
{
id: Versions.RemoveJustify,
up: (props) => {
if (props.align === 'justify') {
props.align = 'start'
}
},
down: 'retired',
},
{
id: Versions.MigrateLegacyAlign,
up: (props) => {
switch (props.align) {
case 'start':
props.align = 'start-legacy'
return
case 'end':
props.align = 'end-legacy'
return
default:
props.align = 'middle-legacy'
return
}
},
down: 'retired',
},
{
id: Versions.AddVerticalAlign,
up: (props) => {
props.verticalAlign = 'middle'
},
down: 'retired',
},
{
id: Versions.MakeUrlsValid,
up: (props) => {
if (!T.linkUrl.isValid(props.url)) {
props.url = ''
}
},
down: (_props) => {
// noop
},
},
{
id: Versions.AddFontSizeAdjustment,
up: (props) => {
props.fontSizeAdjustment = 0
},
down: (props) => {
delete props.fontSizeAdjustment
},
},
{
id: Versions.AddScale,
up: (props) => {
props.scale = 1
},
down: (props) => {
delete props.scale
},
},
{
id: Versions.AddLabelColor,
up: (props) => {
props.labelColor = 'black'
},
down: (props) => {
delete props.labelColor
},
},
{
id: Versions.AddRichText,
up: (props) => {
props.richText = toRichText(props.text)
delete props.text
},
// N.B. Explicitly no down state so that we force clients to update.
// down: (props) => {
// delete props.richText
// },
},
{
id: Versions.AddRichTextAttrs,
up: (_props) => {
// noop - attrs is optional so old records are valid
},
down: (props) => {
// Remove attrs from richText when migrating down
if (props.richText && 'attrs' in props.richText) {
delete props.richText.attrs
}
},
},
{
id: Versions.AddFirstEditedBy,
up: (props) => {
props.textFirstEditedBy = null
},
down: (props) => {
delete props.textFirstEditedBy
},
},
{
id: Versions.MakeFontSizeAdjustmentRatio,
up: (props) => {
// Old system stored 0 for "no adjustment" or an absolute pixel font size.
// New system stores a ratio (1 = no adjustment, <1 = shrunk).
// We can convert 0 → 1 (no adjustment), but non-zero values need
// recomputation (null) since we don't know the base font size here.
props.fontSizeAdjustment = props.fontSizeAdjustment === 0 ? 1 : null
},
down: (props) => {
props.fontSizeAdjustment = 0
},
},
{
id: Versions.RenameFirstEditedByToLast,
up: (props) => {
props.textLastEditedBy = props.textFirstEditedBy ?? null
delete props.textFirstEditedBy
},
down: (props) => {
props.textFirstEditedBy = props.textLastEditedBy ?? null
delete props.textLastEditedBy
},
},
],
})

View File

@@ -0,0 +1,185 @@
import { T } from '@tldraw/validate'
import { TLRichText, richTextValidator, toRichText } from '../misc/TLRichText'
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'
import { RecordProps } from '../recordsWithProps'
import { DefaultColorStyle, TLDefaultColorStyle } from '../styles/TLColorStyle'
import { DefaultFontStyle, TLDefaultFontStyle } from '../styles/TLFontStyle'
import { DefaultSizeStyle, TLDefaultSizeStyle } from '../styles/TLSizeStyle'
import { DefaultTextAlignStyle, TLDefaultTextAlignStyle } from '../styles/TLTextAlignStyle'
import { TLBaseShape } from './TLBaseShape'
/**
* Configuration interface defining properties for text shapes in tldraw.
* Text shapes support rich formatting, styling, and automatic sizing.
*
* @public
* @example
* ```ts
* const textProps: TLTextShapeProps = {
* color: 'black',
* size: 'm',
* font: 'draw',
* textAlign: 'start',
* w: 200,
* richText: toRichText('Hello **bold** text'),
* scale: 1,
* autoSize: true
* }
* ```
*/
export interface TLTextShapeProps {
color: TLDefaultColorStyle
size: TLDefaultSizeStyle
font: TLDefaultFontStyle
textAlign: TLDefaultTextAlignStyle
w: number
richText: TLRichText
scale: number
autoSize: boolean
}
/**
* A text shape that can display formatted text content with various styling options.
* Text shapes support rich formatting, automatic sizing, and consistent styling through
* the tldraw style system.
*
* @public
* @example
* ```ts
* const textShape: TLTextShape = {
* id: 'shape:text123',
* typeName: 'shape',
* type: 'text',
* x: 100,
* y: 200,
* rotation: 0,
* index: 'a1',
* parentId: 'page:main',
* isLocked: false,
* opacity: 1,
* props: {
* color: 'black',
* size: 'm',
* font: 'draw',
* textAlign: 'start',
* w: 200,
* richText: toRichText('Sample text'),
* scale: 1,
* autoSize: false
* },
* meta: {}
* }
* ```
*/
export type TLTextShape = TLBaseShape<'text', TLTextShapeProps>
/**
* Validation schema for text shape properties. This defines the runtime validation
* rules that ensure text shape data integrity when records are stored or transmitted.
*
* @public
* @example
* ```ts
* import { textShapeProps } from '@tldraw/tlschema'
*
* // Validate text shape properties
* const isValid = textShapeProps.richText.isValid(myRichText)
* if (isValid) {
* // Properties are valid, safe to use
* }
* ```
*/
export const textShapeProps: RecordProps<TLTextShape> = {
color: DefaultColorStyle,
size: DefaultSizeStyle,
font: DefaultFontStyle,
textAlign: DefaultTextAlignStyle,
w: T.nonZeroNumber,
richText: richTextValidator,
scale: T.nonZeroNumber,
autoSize: T.boolean,
}
const Versions = createShapePropsMigrationIds('text', {
RemoveJustify: 1,
AddTextAlign: 2,
AddRichText: 3,
AddRichTextAttrs: 4,
})
/**
* Version identifiers for text shape migrations. These constants track
* the evolution of the text shape schema over time.
*
* @public
* @example
* ```ts
* import { textShapeVersions } from '@tldraw/tlschema'
*
* // Check if shape data needs migration
* if (shapeVersion < textShapeVersions.AddRichTextAttrs) {
* // Apply rich text attrs migration
* }
* ```
*/
export { Versions as textShapeVersions }
/**
* Migration sequence for text shape schema evolution. This handles transforming
* text shape data between different versions as the schema evolves over time.
*
* Key migrations include:
* - RemoveJustify: Replaced 'justify' alignment with 'start'
* - AddTextAlign: Migrated from 'align' to 'textAlign' property
* - AddRichText: Converted plain text to rich text format
* - AddRichTextAttrs: Added support for attrs property on richText
*
* @public
*/
export const textShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.RemoveJustify,
up: (props) => {
if (props.align === 'justify') {
props.align = 'start'
}
},
down: 'retired',
},
{
id: Versions.AddTextAlign,
up: (props) => {
props.textAlign = props.align
delete props.align
},
down: (props) => {
props.align = props.textAlign
delete props.textAlign
},
},
{
id: Versions.AddRichText,
up: (props) => {
props.richText = toRichText(props.text)
delete props.text
},
// N.B. Explicitly no down state so that we force clients to update.
// down: (props) => {
// delete props.richText
// },
},
{
id: Versions.AddRichTextAttrs,
up: (_props) => {
// noop - attrs is optional so old records are valid
},
down: (props) => {
// Remove attrs from richText when migrating down
if (props.richText && 'attrs' in props.richText) {
delete props.richText.attrs
}
},
},
],
})

View File

@@ -0,0 +1,179 @@
import { T } from '@tldraw/validate'
import { assetIdValidator } from '../assets/TLBaseAsset'
import { TLAssetId } from '../records/TLAsset'
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'
import { RecordProps } from '../recordsWithProps'
import { TLBaseShape } from './TLBaseShape'
/**
* Configuration interface defining properties for video shapes in tldraw.
* Video shapes can display video content from URLs or asset references,
* with controls for playback state, timing, and accessibility.
*
* @public
* @example
* ```ts
* const videoProps: TLVideoShapeProps = {
* w: 640,
* h: 480,
* time: 0,
* playing: false,
* autoplay: true,
* url: 'https://example.com/video.mp4',
* assetId: 'asset:video123',
* altText: 'Educational video about shapes'
* }
* ```
*/
export interface TLVideoShapeProps {
w: number
h: number
time: number
playing: boolean
autoplay: boolean
url: string
assetId: TLAssetId | null
altText: string
}
/**
* A video shape that can display video content with playback controls and timing.
* Video shapes support both direct URL references and asset-based video storage,
* with accessibility features and playback state management.
*
* @public
* @example
* ```ts
* const videoShape: TLVideoShape = {
* id: 'shape:video123',
* typeName: 'shape',
* type: 'video',
* x: 100,
* y: 100,
* rotation: 0,
* index: 'a1',
* parentId: 'page:main',
* isLocked: false,
* opacity: 1,
* props: {
* w: 640,
* h: 480,
* time: 15.5,
* playing: false,
* autoplay: false,
* url: 'https://example.com/video.mp4',
* assetId: 'asset:video123',
* altText: 'Product demo video'
* },
* meta: {}
* }
* ```
*/
export type TLVideoShape = TLBaseShape<'video', TLVideoShapeProps>
/**
* Validation schema for video shape properties. This defines the runtime validation
* rules that ensure video shape data integrity, including URL validation, numeric
* constraints, and proper asset ID formatting.
*
* @public
* @example
* ```ts
* import { videoShapeProps } from '@tldraw/tlschema'
*
* // Validate video URL
* const isValidUrl = videoShapeProps.url.isValid('https://example.com/video.mp4')
* const isValidTime = videoShapeProps.time.isValid(42.5)
*
* if (isValidUrl && isValidTime) {
* // Video properties are valid
* }
* ```
*/
export const videoShapeProps: RecordProps<TLVideoShape> = {
w: T.nonZeroNumber,
h: T.nonZeroNumber,
time: T.number,
playing: T.boolean,
autoplay: T.boolean,
url: T.linkUrl,
assetId: assetIdValidator.nullable(),
altText: T.string,
}
const Versions = createShapePropsMigrationIds('video', {
AddUrlProp: 1,
MakeUrlsValid: 2,
AddAltText: 3,
AddAutoplay: 4,
})
/**
* Version identifiers for video shape migrations. These constants track
* the evolution of the video shape schema over time.
*
* @public
* @example
* ```ts
* import { videoShapeVersions } from '@tldraw/tlschema'
*
* // Check if shape data needs migration
* if (shapeVersion < videoShapeVersions.AddAltText) {
* // Apply alt text migration for accessibility
* }
* ```
*/
export { Versions as videoShapeVersions }
/**
* Migration sequence for video shape schema evolution. This handles transforming
* video shape data between different versions as the schema evolves over time.
*
* Key migrations include:
* - AddUrlProp: Added URL property for direct video links
* - MakeUrlsValid: Ensured all URLs conform to link URL validation
* - AddAltText: Added accessibility support with alternative text
* - AddAutoplay: Added autoplay control for video playback
*
* @public
*/
export const videoShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.AddUrlProp,
up: (props) => {
props.url = ''
},
down: 'retired',
},
{
id: Versions.MakeUrlsValid,
up: (props) => {
if (!T.linkUrl.isValid(props.url)) {
props.url = ''
}
},
down: (_props) => {
// noop
},
},
{
id: Versions.AddAltText,
up: (props) => {
props.altText = ''
},
down: (props) => {
delete props.altText
},
},
{
id: Versions.AddAutoplay,
up: (props) => {
props.autoplay = true
},
down: (props) => {
delete props.autoplay
},
},
],
})

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import { getTestMigration } from './__tests__/migrationTestUtils'
import { storeMigrations, storeVersions } from './store-migrations'
describe('storeMigrations', () => {
it('has valid migration sequence structure', () => {
expect(storeMigrations.sequenceId).toBe('com.tldraw.store')
expect(storeMigrations.retroactive).toBe(false)
expect(storeMigrations.sequence.length).toBeGreaterThan(0)
// Verify all migrations have required fields
for (const migration of storeMigrations.sequence) {
expect(migration.id).toBeDefined()
expect(migration.scope).toBeDefined()
expect(migration.up).toBeInstanceOf(Function)
}
})
})
describe('RemoveCodeAndIconShapeTypes migration', () => {
const { up } = getTestMigration(storeVersions.RemoveCodeAndIconShapeTypes)
it('removes icon and code shapes from store', () => {
const store = {
'shape:icon1': { typeName: 'shape', type: 'icon' },
'shape:code1': { typeName: 'shape', type: 'code' },
'shape:geo1': { typeName: 'shape', type: 'geo' },
'page:test': { typeName: 'page' },
}
const result = up(store)
expect(result['shape:icon1']).toBeUndefined()
expect(result['shape:code1']).toBeUndefined()
expect(result['shape:geo1']).toBeDefined()
expect(result['page:test']).toBeDefined()
})
})
describe('AddInstancePresenceType migration', () => {
const { up } = getTestMigration(storeVersions.AddInstancePresenceType)
it('is a no-op migration', () => {
const store = { 'page:test': { typeName: 'page' } }
const result = up(store)
expect(result).toEqual(store)
})
})
describe('RemoveTLUserAndPresenceAndAddPointer migration', () => {
const { up } = getTestMigration(storeVersions.RemoveTLUserAndPresenceAndAddPointer)
it('removes user and user_presence records', () => {
const store = {
'user:123': { typeName: 'user' },
'user_presence:456': { typeName: 'user_presence' },
'instance:789': { typeName: 'instance' },
'user_something:abc': { typeName: 'user_something' }, // Should not match
}
const result = up(store)
expect(result['user:123']).toBeUndefined()
expect(result['user_presence:456']).toBeUndefined()
expect(result['instance:789']).toBeDefined()
expect(result['user_something:abc']).toBeDefined() // Uses exact regex match
})
})
describe('RemoveUserDocument migration', () => {
const { up } = getTestMigration(storeVersions.RemoveUserDocument)
it('removes user_document records', () => {
const store = {
'user_document:123': { typeName: 'user_document' },
'user_document_meta:456': { typeName: 'user_document_meta' }, // Also matches due to substring
'document:789': { typeName: 'document' },
'instance:abc': { typeName: 'instance' },
}
const result = up(store)
expect(result['user_document:123']).toBeUndefined()
expect(result['user_document_meta:456']).toBeUndefined() // Uses match() which finds substring
expect(result['document:789']).toBeDefined()
expect(result['instance:abc']).toBeDefined()
})
})

View File

@@ -0,0 +1,156 @@
import { createMigrationIds, createMigrationSequence } from '@tldraw/store'
import { IndexKey, objectMapEntries } from '@tldraw/utils'
import { TLPage } from './records/TLPage'
import { TLShape } from './records/TLShape'
import { TLLineShape } from './shapes/TLLineShape'
/**
* Migration version constants for store-level schema changes.
* Each version represents a breaking change that requires data transformation.
*
* @internal
*/
const Versions = createMigrationIds('com.tldraw.store', {
RemoveCodeAndIconShapeTypes: 1,
AddInstancePresenceType: 2,
RemoveTLUserAndPresenceAndAddPointer: 3,
RemoveUserDocument: 4,
FixIndexKeys: 5,
} as const)
/**
* Migration version identifiers for store-level migrations.
* These versions track changes to the overall store structure and data model.
*
* @example
* ```ts
* import { storeVersions } from '@tldraw/tlschema'
*
* // Check if a specific migration version exists
* const hasRemoveCodeShapes = storeVersions.RemoveCodeAndIconShapeTypes
* ```
*
* @public
*/
export { Versions as storeVersions }
/**
* Store-level migration sequence that handles evolution of the tldraw data model.
* These migrations run when the store schema version changes and ensure backward
* compatibility by transforming old data structures to new formats.
*
* The migrations handle:
* - Removal of deprecated shape types (code, icon)
* - Addition of new record types (instance presence)
* - Cleanup of obsolete user and presence data
* - Removal of deprecated user document records
*
* @example
* ```ts
* import { storeMigrations } from '@tldraw/tlschema'
* import { migrate } from '@tldraw/store'
*
* // Apply store migrations to old data
* const migratedStore = migrate({
* store: oldStoreData,
* migrations: storeMigrations,
* fromVersion: 0,
* toVersion: storeMigrations.currentVersion
* })
* ```
*
* @public
*/
export const storeMigrations = createMigrationSequence({
sequenceId: 'com.tldraw.store',
retroactive: false,
sequence: [
{
id: Versions.RemoveCodeAndIconShapeTypes,
scope: 'storage',
up: (storage) => {
for (const [id, record] of storage.entries()) {
if (
record.typeName === 'shape' &&
'type' in record &&
(record.type === 'icon' || record.type === 'code')
) {
storage.delete(id)
}
}
},
},
{
id: Versions.AddInstancePresenceType,
scope: 'storage',
up(_storage) {
// noop
// there used to be a down migration for this but we made down migrations optional
// and we don't use them on storage-level migrations so we can just remove it
},
},
{
// remove user and presence records and add pointer records
id: Versions.RemoveTLUserAndPresenceAndAddPointer,
scope: 'storage',
up: (storage) => {
for (const [id, record] of storage.entries()) {
if (record.typeName.match(/^(user|user_presence)$/)) {
storage.delete(id)
}
}
},
},
{
// remove user document records
id: Versions.RemoveUserDocument,
scope: 'storage',
up: (storage) => {
for (const [id, record] of storage.entries()) {
if (record.typeName.match('user_document')) {
storage.delete(id)
}
}
},
},
{
id: Versions.FixIndexKeys,
scope: 'record',
up: (record) => {
if (['shape', 'page'].includes(record.typeName) && 'index' in record) {
const recordWithIndex = record as TLShape | TLPage
// Our newer fractional indexed library (more correctly) validates that indices
// do not end with 0. ('a0' being an exception)
if (recordWithIndex.index.endsWith('0') && recordWithIndex.index !== 'a0') {
recordWithIndex.index = (recordWithIndex.index.slice(0, -1) +
getNRandomBase62Digits(3)) as IndexKey
}
// Line shapes have 'points' that have indices as well.
if (record.typeName === 'shape' && (recordWithIndex as TLShape).type === 'line') {
const lineShape = recordWithIndex as TLLineShape
for (const [_, point] of objectMapEntries(lineShape.props.points)) {
if (point.index.endsWith('0') && point.index !== 'a0') {
point.index = (point.index.slice(0, -1) + getNRandomBase62Digits(3)) as IndexKey
}
}
}
}
},
down: () => {
// noop
// Enables tlsync to support older clients so as to not force people to refresh immediately after deploying.
},
},
],
})
const BASE_62_DIGITS_WITHOUT_ZERO = '123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
const getRandomBase62Digit = () => {
return BASE_62_DIGITS_WITHOUT_ZERO.charAt(
Math.floor(Math.random() * BASE_62_DIGITS_WITHOUT_ZERO.length)
)
}
const getNRandomBase62Digits = (n: number) => {
return Array.from({ length: n }, getRandomBase62Digit).join('')
}

View File

@@ -0,0 +1,163 @@
import { T } from '@tldraw/validate'
/**
* A `StyleProp` is a property of a shape that follows some special rules.
*
* 1. The same value can be set on lots of shapes at the same time.
*
* 2. The last used value is automatically saved and applied to new shapes.
*
* For example, {@link DefaultColorStyle} is a style prop used by tldraw's default shapes to set
* their color. If you try selecting several shapes on tldraw.com and changing their color, you'll
* see that the color is applied to all of them. Then, if you draw a new shape, it'll have the same
* color as the one you just set.
*
* You can use styles in your own shapes by either defining your own (see {@link StyleProp.define}
* and {@link StyleProp.defineEnum}) or using tldraw's default ones, like {@link DefaultColorStyle}.
* When you define a shape, pass a `props` object describing all of your shape's properties, using
* `StyleProp`s for the ones you want to be styles. See the
* {@link https://github.com/tldraw/tldraw/tree/main/apps/examples | custom styles example}
* for more.
*
* @public
*/
export class StyleProp<Type> implements T.Validatable<Type> {
/**
* Define a new {@link StyleProp}.
*
* @param uniqueId - Each StyleProp must have a unique ID. We recommend you prefix this with
* your app/library name.
* @param options -
* - `defaultValue`: The default value for this style prop.
*
* - `type`: Optionally, describe what type of data you expect for this style prop.
*
* @example
* ```ts
* import {T} from '@tldraw/validate'
* import {StyleProp} from '@tldraw/tlschema'
*
* const MyLineWidthProp = StyleProp.define('myApp:lineWidth', {
* defaultValue: 1,
* type: T.number,
* })
* ```
* @public
*/
static define<Type>(
uniqueId: string,
options: { defaultValue: Type; type?: T.Validatable<Type> }
) {
const { defaultValue, type = T.any } = options
return new StyleProp<Type>(uniqueId, defaultValue, type)
}
/**
* Define a new {@link StyleProp} as a list of possible values.
*
* @param uniqueId - Each StyleProp must have a unique ID. We recommend you prefix this with
* your app/library name.
* @param options -
* - `defaultValue`: The default value for this style prop.
*
* - `values`: An array of possible values of this style prop.
*
* @example
* ```ts
* import {StyleProp} from '@tldraw/tlschema'
*
* const MySizeProp = StyleProp.defineEnum('myApp:size', {
* defaultValue: 'medium',
* values: ['small', 'medium', 'large'],
* })
* ```
*/
static defineEnum<const Values extends readonly unknown[]>(
uniqueId: string,
options: { defaultValue: Values[number]; values: Values }
) {
const { defaultValue, values } = options
return new EnumStyleProp<Values[number]>(uniqueId, defaultValue, values)
}
/** @internal */
protected constructor(
readonly id: string,
public defaultValue: Type,
readonly type: T.Validatable<Type>
) {}
setDefaultValue(value: Type) {
this.defaultValue = value
}
validate(value: unknown) {
return this.type.validate(value)
}
validateUsingKnownGoodVersion(prevValue: Type, newValue: unknown) {
if (this.type.validateUsingKnownGoodVersion) {
return this.type.validateUsingKnownGoodVersion(prevValue, newValue)
} else {
return this.validate(newValue)
}
}
}
/**
* See {@link StyleProp} & {@link StyleProp.defineEnum}
*
* @public
*/
export class EnumStyleProp<T> extends StyleProp<T> {
/** @internal */
constructor(id: string, defaultValue: T, values: readonly T[]) {
super(id, defaultValue, T.literalEnum(...values))
this.values = [...values]
}
readonly values: T[]
/**
* Add new values to this enum style prop at runtime. This is useful for extending
* the built-in styles with custom values (e.g. adding custom colors). Be sure to
* also modify the associated types.
*
* @param newValues - The new values to add.
*
* @public
*/
addValues(...newValues: T[]): void {
for (const v of newValues) {
if (!this.values.includes(v)) {
this.values.push(v)
}
}
// Rebuild the validator with the updated values
;(this as any).type = T.literalEnum(...this.values)
}
/**
* Remove values from this enum style prop at runtime. This is useful for narrowing
* the built-in styles with custom values (e.g. adding custom colors). Be sure to
* also modify the associated types.
*
* @param valuesToRemove - The values to remove.
*
* @public
*/
removeValues(...valuesToRemove: T[]): void {
for (const v of valuesToRemove) {
if (this.values.includes(v)) {
this.values.splice(this.values.indexOf(v), 1)
}
}
// Rebuild the validator with the updated values
;(this as any).type = T.literalEnum(...this.values)
}
}
/**
* @public
*/
export type StylePropValue<T extends StyleProp<any>> = T extends StyleProp<infer U> ? U : never

View File

@@ -0,0 +1,101 @@
import { StyleProp } from './StyleProp'
import { TLDefaultColor, TLThemeDefaultColors, TLThemes } from './TLTheme'
/**
* The names of all available shape colors, derived from {@link TLThemeDefaultColors}.
* Extend {@link TLThemeDefaultColors} to add custom color names.
*
* @public
*/
export type TLDefaultColorStyle = {
[K in keyof TLThemeDefaultColors]: TLThemeDefaultColors[K] extends TLDefaultColor ? K : never
}[keyof TLThemeDefaultColors] &
string
/**
* Used only for initial values of the color style; the source of truth has moved to TLTheme.
*
* @internal
*/
const defaultColorNames: TLDefaultColorStyle[] = [
'black',
'grey',
'light-violet',
'violet',
'blue',
'light-blue',
'yellow',
'orange',
'green',
'light-green',
'light-red',
'red',
'white',
] as const
/**
* @public
*/
export const DefaultColorStyle = StyleProp.defineEnum('tldraw:color', {
defaultValue: 'black',
values: defaultColorNames,
})
/**
* @public
*/
export const DefaultLabelColorStyle = StyleProp.defineEnum('tldraw:labelColor', {
defaultValue: 'black',
values: defaultColorNames,
})
/**
* Scan theme definitions and sync color registrations to match.
* A color entry is any key in `TLThemeColors` whose value is an object
* (i.e. a {@link TLDefaultColor}), as opposed to utility strings like
* `background` or `text`.
*
* Colors present in themes but not yet registered will be added.
* Colors currently registered but absent from all themes will be removed.
*
* @public
*/
export function registerColorsFromThemes(definitions: TLThemes): void {
const colorNames = new Set<TLDefaultColorStyle>()
for (const def of Object.values(definitions)) {
for (const colorPalette of [def.colors.light, def.colors.dark]) {
for (const [key, value] of Object.entries(colorPalette)) {
if (typeof value === 'object' && value !== null) {
colorNames.add(key as TLDefaultColorStyle)
}
}
}
}
if (colorNames.size > 0) {
DefaultColorStyle.addValues(...colorNames)
DefaultLabelColorStyle.addValues(...colorNames)
}
const toRemove = DefaultColorStyle.values.filter((v) => !colorNames.has(v as TLDefaultColorStyle))
if (toRemove.length > 0) {
DefaultColorStyle.removeValues(...toRemove)
DefaultLabelColorStyle.removeValues(...toRemove)
}
if (process.env.NODE_ENV !== 'production') {
for (const def of Object.values(definitions)) {
for (const color of colorNames) {
if (!(color in def.colors.light)) {
console.warn(
`Theme '${def.id}' light palette is missing color '${color}'. Shapes using this color won't render correctly.`
)
}
if (!(color in def.colors.dark)) {
console.warn(
`Theme '${def.id}' dark palette is missing color '${color}'. Shapes using this color won't render correctly.`
)
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
import { T } from '@tldraw/validate'
import { StyleProp } from './StyleProp'
/**
* Default dash style property used by tldraw shapes for line styling.
* Controls how shape outlines and lines are rendered with different dash patterns.
*
* Available values:
* - `draw` - Hand-drawn, sketchy line style
* - `solid` - Continuous solid line
* - `dashed` - Evenly spaced dashes
* - `dotted` - Evenly spaced dots
*
* @example
* ```ts
* import { DefaultDashStyle } from '@tldraw/tlschema'
*
* // Use in shape props definition
* interface MyShapeProps {
* dash: typeof DefaultDashStyle
* // other props...
* }
*
* // Create a shape with dashed outline
* const shape = {
* // ... other properties
* props: {
* dash: 'dashed' as const,
* // ... other props
* }
* }
* ```
*
* @public
*/
export const DefaultDashStyle = StyleProp.defineEnum('tldraw:dash', {
defaultValue: 'draw',
values: ['draw', 'solid', 'dashed', 'dotted', 'none'],
})
/**
* Type representing a default dash style value.
* This is a union type of all available dash style options.
*
* @example
* ```ts
* import { TLDefaultDashStyle } from '@tldraw/tlschema'
*
* // Valid dash style values
* const drawStyle: TLDefaultDashStyle = 'draw'
* const solidStyle: TLDefaultDashStyle = 'solid'
* const dashedStyle: TLDefaultDashStyle = 'dashed'
* const dottedStyle: TLDefaultDashStyle = 'dotted'
*
* // Use in a function parameter
* function setShapeDash(dash: TLDefaultDashStyle) {
* // Apply dash style to shape
* }
* ```
*
* @public
*/
export type TLDefaultDashStyle = T.TypeOf<typeof DefaultDashStyle>

View File

@@ -0,0 +1,63 @@
import { T } from '@tldraw/validate'
import { StyleProp } from './StyleProp'
/**
* Default fill style property used by tldraw shapes for interior styling.
* Controls how the inside of shapes are filled or left empty.
*
* Available values:
* - `none` - No fill, shape interior is transparent
* - `semi` - Semi-transparent fill using the shape's color
* - `solid` - Solid fill using the shape's color
* - `pattern` - Crosshatch pattern fill using the shape's color
* - `fill` - Alternative solid fill variant
*
* @example
* ```ts
* import { DefaultFillStyle } from '@tldraw/tlschema'
*
* // Use in shape props definition
* interface MyShapeProps {
* fill: typeof DefaultFillStyle
* // other props...
* }
*
* // Create a shape with solid fill
* const shape = {
* // ... other properties
* props: {
* fill: 'solid' as const,
* // ... other props
* }
* }
* ```
*
* @public
*/
export const DefaultFillStyle = StyleProp.defineEnum('tldraw:fill', {
defaultValue: 'none',
values: ['none', 'semi', 'solid', 'pattern', 'fill', 'lined-fill'],
})
/**
* Type representing a default fill style value.
* This is a union type of all available fill style options.
*
* @example
* ```ts
* import { TLDefaultFillStyle } from '@tldraw/tlschema'
*
* // Valid fill style values
* const noFill: TLDefaultFillStyle = 'none'
* const solidFill: TLDefaultFillStyle = 'solid'
* const patternFill: TLDefaultFillStyle = 'pattern'
*
* // Use in a function parameter
* function setShapeFill(fill: TLDefaultFillStyle) {
* // Apply fill style to shape
* }
* ```
*
* @public
*/
export type TLDefaultFillStyle = T.TypeOf<typeof DefaultFillStyle>

View File

@@ -0,0 +1,65 @@
/**
* Represents the `src` property of a {@link TLFontFace}.
* See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/src | `src`} for details of the properties here.
* @public
*/
export interface TLFontFaceSource {
/**
* A URL from which to load the font. If the value here is a key in
* {@link tldraw#TLEditorAssetUrls.fonts}, the value from there will be used instead.
*/
url: string
format?: string
tech?: string
}
/**
* A font face that can be used in the editor. The properties of this are largely the same as the
* ones in the
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face | css `@font-face` rule}.
* @public
*/
export interface TLFontFace {
/**
* How this font can be referred to in CSS.
* See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-family | `font-family`}.
*/
readonly family: string
/**
* The source of the font. This
* See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/src | `src`}.
*/
readonly src: TLFontFaceSource
/**
* See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/ascent-override | `ascent-override`}.
*/
readonly ascentOverride?: string
/**
* See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/descent-override | `descent-override`}.
*/
readonly descentOverride?: string
/**
* See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-stretch | `font-stretch`}.
*/
readonly stretch?: string
/**
* See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-style | `font-style`}.
*/
readonly style?: string
/**
* See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-weight | `font-weight`}.
*/
readonly weight?: string
/**
* See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-feature-settings | `font-feature-settings`}.
*/
readonly featureSettings?: string
/**
* See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/line-gap-override | `line-gap-override`}.
*/
readonly lineGapOverride?: string
/**
* See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range | `unicode-range`}.
*/
readonly unicodeRange?: string
}

View File

@@ -0,0 +1,137 @@
import { StyleProp } from './StyleProp'
import { TLThemeFont, TLThemeFonts, TLThemes } from './TLTheme'
/**
* Default font style property used by tldraw shapes for text styling.
* Controls which typeface is used for text content within shapes.
*
* Available values:
* - `draw` - Hand-drawn, sketchy font style
* - `sans` - Clean sans-serif font
* - `serif` - Traditional serif font
* - `mono` - Monospace font for code-like text
*
* @example
* ```ts
* import { DefaultFontStyle } from '@tldraw/tlschema'
*
* // Use in shape props definition
* interface MyTextShapeProps {
* font: typeof DefaultFontStyle
* // other props...
* }
*
* // Create a text shape with monospace font
* const textShape = {
* // ... other properties
* props: {
* font: 'mono' as const,
* // ... other props
* }
* }
* ```
*
* @public
*/
export const DefaultFontStyle = StyleProp.defineEnum('tldraw:font', {
defaultValue: 'draw',
values: ['draw', 'sans', 'serif', 'mono'],
})
/**
* The names of all available font styles, derived from {@link TLThemeFonts}.
* Extend {@link TLThemeFonts} to add custom font names.
*
* @example
* ```ts
* import { TLDefaultFontStyle } from '@tldraw/tlschema'
*
* // Valid font style values
* const drawFont: TLDefaultFontStyle = 'draw'
* const sansFont: TLDefaultFontStyle = 'sans'
* const serifFont: TLDefaultFontStyle = 'serif'
* const monoFont: TLDefaultFontStyle = 'mono'
*
* // Use in a function parameter
* function setTextFont(font: TLDefaultFontStyle) {
* // Apply font style to text
* }
* ```
*
* @public
*/
export type TLDefaultFontStyle = keyof TLThemeFonts & string
/**
* Mapping of font style names to their corresponding CSS font-family declarations.
* These are the actual CSS font families used when rendering text with each font style.
*
* @example
* ```ts
* import { DefaultFontFamilies, TLDefaultFontStyle } from '@tldraw/tlschema'
*
* // Get CSS font family for a font style
* const fontStyle: TLDefaultFontStyle = 'mono'
* const cssFamily = DefaultFontFamilies[fontStyle] // "'tldraw_mono', monospace"
*
* // Apply to DOM element
* element.style.fontFamily = DefaultFontFamilies.sans
* ```
*
* @public
*/
export const DefaultFontFamilies = {
draw: "'tldraw_draw', sans-serif",
sans: "'tldraw_sans', sans-serif",
serif: "'tldraw_serif', serif",
mono: "'tldraw_mono', monospace",
}
/** @internal */
export function isFontEntry(value: unknown): value is TLThemeFont {
return typeof value === 'object' && value !== null && 'fontFamily' in value
}
/**
* Scan theme definitions and sync font registrations to match.
* A font entry is any key in `TLThemeFonts` whose value is a {@link TLThemeFont}
* object (i.e. has a `fontFamily` property).
*
* Fonts present in themes but not yet registered will be added.
* Fonts currently registered but absent from all themes will be removed.
*
* @public
*/
export function registerFontsFromThemes(definitions: TLThemes): void {
const fontNames = new Set<string>()
for (const def of Object.values(definitions)) {
if (!def.fonts) continue
for (const [key, value] of Object.entries(def.fonts)) {
if (isFontEntry(value)) {
fontNames.add(key)
}
}
}
const toAdd = [...fontNames].filter((v) => !DefaultFontStyle.values.includes(v as any))
if (toAdd.length > 0) {
DefaultFontStyle.addValues(...(toAdd as any))
}
const toRemove = DefaultFontStyle.values.filter((v) => !fontNames.has(v as string))
if (toRemove.length > 0) {
DefaultFontStyle.removeValues(...toRemove)
}
if (process.env.NODE_ENV !== 'production') {
for (const def of Object.values(definitions)) {
for (const font of fontNames) {
if (!def.fonts || !(font in def.fonts)) {
console.warn(
`Theme '${def.id}' is missing font '${font}'. Shapes using this font won't render correctly in this theme.`
)
}
}
}
}
}

View File

@@ -0,0 +1,64 @@
import { T } from '@tldraw/validate'
import { StyleProp } from './StyleProp'
/**
* Default horizontal alignment style property used by tldraw shapes for text positioning.
* Controls how text content is horizontally aligned within shape boundaries.
*
* Available values:
* - `start` - Align text to the start (left in LTR, right in RTL)
* - `middle` - Center text horizontally
* - `end` - Align text to the end (right in LTR, left in RTL)
* - `start-legacy` - Legacy start alignment (deprecated)
* - `end-legacy` - Legacy end alignment (deprecated)
* - `middle-legacy` - Legacy middle alignment (deprecated)
*
* @example
* ```ts
* import { DefaultHorizontalAlignStyle } from '@tldraw/tlschema'
*
* // Use in shape props definition
* interface MyTextShapeProps {
* align: typeof DefaultHorizontalAlignStyle
* // other props...
* }
*
* // Create a shape with center-aligned text
* const textShape = {
* // ... other properties
* props: {
* align: 'middle' as const,
* // ... other props
* }
* }
* ```
*
* @public
*/
export const DefaultHorizontalAlignStyle = StyleProp.defineEnum('tldraw:horizontalAlign', {
defaultValue: 'middle',
values: ['start', 'middle', 'end', 'start-legacy', 'end-legacy', 'middle-legacy'],
})
/**
* Type representing a default horizontal alignment style value.
* This is a union type of all available horizontal alignment options.
*
* @example
* ```ts
* import { TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema'
*
* // Valid horizontal alignment values
* const leftAlign: TLDefaultHorizontalAlignStyle = 'start'
* const centerAlign: TLDefaultHorizontalAlignStyle = 'middle'
* const rightAlign: TLDefaultHorizontalAlignStyle = 'end'
*
* // Use in a function parameter
* function setTextAlignment(align: TLDefaultHorizontalAlignStyle) {
* // Apply horizontal alignment to text
* }
* ```
*
* @public
*/
export type TLDefaultHorizontalAlignStyle = T.TypeOf<typeof DefaultHorizontalAlignStyle>

View File

@@ -0,0 +1,63 @@
import { T } from '@tldraw/validate'
import { StyleProp } from './StyleProp'
/**
* Default size style property used by tldraw shapes for scaling visual elements.
* Controls the relative size of shape elements like stroke width, text size, and other proportional features.
*
* Available values:
* - `s` - Small size
* - `m` - Medium size (default)
* - `l` - Large size
* - `xl` - Extra large size
*
* @example
* ```ts
* import { DefaultSizeStyle } from '@tldraw/tlschema'
*
* // Use in shape props definition
* interface MyShapeProps {
* size: typeof DefaultSizeStyle
* // other props...
* }
*
* // Create a shape with large size
* const shape = {
* // ... other properties
* props: {
* size: 'l' as const,
* // ... other props
* }
* }
* ```
*
* @public
*/
export const DefaultSizeStyle = StyleProp.defineEnum('tldraw:size', {
defaultValue: 'm',
values: ['s', 'm', 'l', 'xl'],
})
/**
* Type representing a default size style value.
* This is a union type of all available size options.
*
* @example
* ```ts
* import { TLDefaultSizeStyle } from '@tldraw/tlschema'
*
* // Valid size values
* const smallSize: TLDefaultSizeStyle = 's'
* const mediumSize: TLDefaultSizeStyle = 'm'
* const largeSize: TLDefaultSizeStyle = 'l'
* const extraLargeSize: TLDefaultSizeStyle = 'xl'
*
* // Use in a function parameter
* function setShapeSize(size: TLDefaultSizeStyle) {
* // Apply size style to shape
* }
* ```
*
* @public
*/
export type TLDefaultSizeStyle = T.TypeOf<typeof DefaultSizeStyle>

View File

@@ -0,0 +1,61 @@
import { T } from '@tldraw/validate'
import { StyleProp } from './StyleProp'
/**
* Default text alignment style property used by tldraw text shapes.
* Controls how text content is aligned within text-based shapes like text boxes and notes.
*
* Available values:
* - `start` - Align text to the start (left in LTR, right in RTL)
* - `middle` - Center text horizontally
* - `end` - Align text to the end (right in LTR, left in RTL)
*
* @example
* ```ts
* import { DefaultTextAlignStyle } from '@tldraw/tlschema'
*
* // Use in text shape props definition
* interface MyTextShapeProps {
* textAlign: typeof DefaultTextAlignStyle
* // other props...
* }
*
* // Create a text shape with center alignment
* const textShape = {
* // ... other properties
* props: {
* textAlign: 'middle' as const,
* // ... other props
* }
* }
* ```
*
* @public
*/
export const DefaultTextAlignStyle = StyleProp.defineEnum('tldraw:textAlign', {
defaultValue: 'start',
values: ['start', 'middle', 'end'],
})
/**
* Type representing a default text alignment style value.
* This is a union type of all available text alignment options.
*
* @example
* ```ts
* import { TLDefaultTextAlignStyle } from '@tldraw/tlschema'
*
* // Valid text alignment values
* const leftAlign: TLDefaultTextAlignStyle = 'start'
* const centerAlign: TLDefaultTextAlignStyle = 'middle'
* const rightAlign: TLDefaultTextAlignStyle = 'end'
*
* // Use in a function parameter
* function setTextAlignment(align: TLDefaultTextAlignStyle) {
* // Apply text alignment to text shape
* }
* ```
*
* @public
*/
export type TLDefaultTextAlignStyle = T.TypeOf<typeof DefaultTextAlignStyle>

View File

@@ -0,0 +1,261 @@
import type { TLFontFace } from './TLFontFace'
/**
* Defines the color variants available for each color in the default theme.
* Each color has multiple variants for different use cases like fills, strokes,
* patterns, and UI elements like frames and notes.
*
* @example
* ```ts
* import { TLDefaultColor } from '@tldraw/tlschema'
*
* const blueColor: TLDefaultColor = {
* solid: '#4465e9',
* semi: '#dce1f8',
* pattern: '#6681ee',
* fill: '#4465e9',
* // ... other variants
* }
* ```
*
* @public
*/
export interface TLDefaultColor {
solid: string
semi: string
pattern: string
fill: string // usually same as solid
linedFill: string // usually slightly lighter than fill
frameHeadingStroke: string
frameHeadingFill: string
frameStroke: string
frameFill: string
frameText: string
noteFill: string
noteText: string
highlightSrgb: string
highlightP3: string
}
/**
* The color palette for a theme. Contains base UI colors (as strings) and
* named shape colors (as {@link TLDefaultColor} objects).
*
* Extend this interface via module augmentation to add custom colors:
*
* @example
* ```ts
* declare module '@tldraw/tlschema' {
* interface TLThemeColors {
* pink: TLDefaultColor
* }
* }
* ```
*
* @public
*/
export interface TLThemeDefaultColors {
text: string
background: string
negativeSpace: string
solid: string
cursor: string
noteBorder: string
/** Snap indicator line color */
snap: string
/** Selection outline stroke */
selectionStroke: string
/** Selection handle fill (typically the background color) */
selectionFill: string
/** Brush rectangle fill */
brushFill: string
/** Brush rectangle stroke */
brushStroke: string
/** Selected contrast color (handle fills, etc.) */
selectedContrast: string
/** Laser pointer color */
laser: string
black: TLDefaultColor
grey: TLDefaultColor
'light-violet': TLDefaultColor
violet: TLDefaultColor
blue: TLDefaultColor
'light-blue': TLDefaultColor
yellow: TLDefaultColor
orange: TLDefaultColor
green: TLDefaultColor
'light-green': TLDefaultColor
'light-red': TLDefaultColor
red: TLDefaultColor
white: TLDefaultColor
}
/**
* A font definition within a theme. Maps a font style name to a CSS font-family
* declaration and optional font face descriptors for loading.
*
* @example
* ```ts
* import { TLThemeFont } from '@tldraw/tlschema'
*
* const customSans: TLThemeFont = {
* fontFamily: "'Inter', sans-serif",
* faces: [
* { family: 'Inter', src: { url: 'https://example.com/Inter-Regular.woff2', format: 'woff2' }, weight: 'normal' },
* { family: 'Inter', src: { url: 'https://example.com/Inter-Bold.woff2', format: 'woff2' }, weight: 'bold' },
* ]
* }
* ```
*
* @public
*/
export interface TLThemeFont {
/** CSS font-family declaration, e.g. `"'Inter', sans-serif"` */
fontFamily: string
/** Font face definitions for loading. Omit for system fonts that don't need loading. */
faces?: TLFontFace[]
/**
* Icon for the style panel. Accepts a string icon ID (resolved via `assetUrls.icons`)
* or a React element (`TLUiIconJsx`). Defaults to the built-in icon for known fonts,
* or `'font-draw'` for custom fonts.
*/
icon?: unknown
}
/**
* The font palette for a theme. Contains named font definitions mapping font style
* names to their CSS font-family strings and font face descriptors.
*
* Extend this interface via module augmentation to add custom fonts:
*
* @example
* ```ts
* declare module '@tldraw/tlschema' {
* interface TLThemeFonts {
* cursive: TLThemeFont
* }
* }
* ```
*
* @public
*/
export interface TLThemeFonts {
draw: TLThemeFont
sans: TLThemeFont
serif: TLThemeFont
mono: TLThemeFont
}
/**
* Augment this interface to remove built-in palette colors from themes.
* Each key you add will be omitted from {@link TLThemeColors}.
*
* @example
* ```ts
* declare module '@tldraw/tlschema' {
* interface TLRemovedDefaultThemeColors {
* 'light-violet': true
* 'light-blue': true
* }
* }
* ```
*
* @public
*/
// oxlint-disable-next-line typescript/no-empty-object-type
export interface TLRemovedDefaultThemeColors {}
/**
* Keys in {@link TLThemeDefaultColors} that are required UI infrastructure colors
* and cannot be removed via {@link TLRemovedDefaultThemeColors}.
*
* @public
*/
export type TLThemeUiColorKeys =
| 'text'
| 'background'
| 'negativeSpace'
| 'solid'
| 'cursor'
| 'noteBorder'
| 'snap'
| 'selectionStroke'
| 'selectionFill'
| 'brushFill'
| 'brushStroke'
| 'selectedContrast'
| 'laser'
/**
* A color palette for one color mode. UI colors are always required.
* Palette colors removed via {@link TLRemovedDefaultThemeColors} are omitted.
*
* @public
*/
export type TLThemeColors = Pick<TLThemeDefaultColors, TLThemeUiColorKeys> &
Omit<TLThemeDefaultColors, TLThemeUiColorKeys | keyof TLRemovedDefaultThemeColors>
/**
* A theme definition containing shared properties and color/font palettes for
* both light and dark modes.
*
* To remove palette colors from themes, augment {@link TLRemovedDefaultThemeColors}.
* UI colors (`text`, `background`, `negativeSpace`, `solid`, `cursor`, `noteBorder`)
* are always required.
*
* @example
* ```ts
* const myTheme: TLTheme = {
* id: 'custom',
* fontSize: 16,
* lineHeight: 1.35,
* strokeWidth: 2,
* fonts: DEFAULT_THEME.fonts,
* colors: {
* light: { ... },
* dark: { ... },
* },
* }
* editor.updateTheme(myTheme)
* ```
*
* @public
*/
export interface TLTheme {
/** Unique identifier for this theme. Used as the key in the theme registry. */
id: TLThemeId
/** Base font size in pixels. Shape font sizes are derived by multiplying this value. */
fontSize: number
/** Base line height multiplier. */
lineHeight: number
/** Base stroke width in pixels. Shape stroke widths are derived by multiplying this value. */
strokeWidth: number
/** Font definitions. Individual fonts may be absent if removed by a custom theme. */
fonts: TLThemeFonts
colors: {
light: TLThemeColors
dark: TLThemeColors
}
}
/**
* A registry of available themes. Extend this interface via module
* augmentation to register custom themes for type-safe theme IDs.
*
* @example
* ```ts
* declare module '@tldraw/tlschema' {
* interface TLThemes {
* corporate: TLTheme
* }
* }
* ```
*
* @public
*/
export interface TLThemes {
default: TLTheme
}
/** @public */
export type TLThemeId = keyof TLThemes & string

View File

@@ -0,0 +1,61 @@
import { T } from '@tldraw/validate'
import { StyleProp } from './StyleProp'
/**
* Default vertical alignment style property used by tldraw shapes for text positioning.
* Controls how text content is vertically aligned within shape boundaries.
*
* Available values:
* - `start` - Align text to the top
* - `middle` - Center text vertically (default)
* - `end` - Align text to the bottom
*
* @example
* ```ts
* import { DefaultVerticalAlignStyle } from '@tldraw/tlschema'
*
* // Use in shape props definition
* interface MyShapeProps {
* verticalAlign: typeof DefaultVerticalAlignStyle
* // other props...
* }
*
* // Create a shape with top-aligned text
* const shape = {
* // ... other properties
* props: {
* verticalAlign: 'start' as const,
* // ... other props
* }
* }
* ```
*
* @public
*/
export const DefaultVerticalAlignStyle = StyleProp.defineEnum('tldraw:verticalAlign', {
defaultValue: 'middle',
values: ['start', 'middle', 'end'],
})
/**
* Type representing a default vertical alignment style value.
* This is a union type of all available vertical alignment options.
*
* @example
* ```ts
* import { TLDefaultVerticalAlignStyle } from '@tldraw/tlschema'
*
* // Valid vertical alignment values
* const topAlign: TLDefaultVerticalAlignStyle = 'start'
* const centerAlign: TLDefaultVerticalAlignStyle = 'middle'
* const bottomAlign: TLDefaultVerticalAlignStyle = 'end'
*
* // Use in a function parameter
* function setVerticalAlignment(align: TLDefaultVerticalAlignStyle) {
* // Apply vertical alignment to text
* }
* ```
*
* @public
*/
export type TLDefaultVerticalAlignStyle = T.TypeOf<typeof DefaultVerticalAlignStyle>

View File

@@ -0,0 +1,55 @@
// This file is automatically generated by internal/scripts/refresh-assets.ts.
// Do not edit manually. Or do, I'm a comment, not a cop.
/** @public */
export const LANGUAGES = [
{ locale: 'id', label: 'Bahasa Indonesia' },
{ locale: 'ms', label: 'Bahasa Melayu' },
{ locale: 'ca', label: 'Català' },
{ locale: 'cs', label: 'Čeština' },
{ locale: 'da', label: 'Danish' },
{ locale: 'de', label: 'Deutsch' },
{ locale: 'en', label: 'English' },
{ locale: 'es', label: 'Español' },
{ locale: 'tl', label: 'Filipino' },
{ locale: 'fr', label: 'Français' },
{ locale: 'gl', label: 'Galego' },
{ locale: 'hr', label: 'Hrvatski' },
{ locale: 'it', label: 'Italiano' },
{ locale: 'hu', label: 'Magyar' },
{ locale: 'nl', label: 'Nederlands' },
{ locale: 'no', label: 'Norwegian' },
{ locale: 'pl', label: 'Polski' },
{ locale: 'pt-br', label: 'Português - Brasil' },
{ locale: 'pt-pt', label: 'Português - Europeu' },
{ locale: 'ro', label: 'Română' },
{ locale: 'sl', label: 'Slovenščina' },
{ locale: 'so', label: 'Somali' },
{ locale: 'fi', label: 'Suomi' },
{ locale: 'sv', label: 'Svenska' },
{ locale: 'vi', label: 'Tiếng Việt' },
{ locale: 'tr', label: 'Türkçe' },
{ locale: 'el', label: 'Ελληνικά' },
{ locale: 'ru', label: 'Русский' },
{ locale: 'uk', label: 'Українська' },
{ locale: 'he', label: 'עברית' },
{ locale: 'ur', label: 'اردو' },
{ locale: 'ar', label: 'عربي' },
{ locale: 'fa', label: 'فارسی' },
{ locale: 'ne', label: 'नेपाली' },
{ locale: 'mr', label: 'मराठी' },
{ locale: 'hi-in', label: 'हिन्दी' },
{ locale: 'bn', label: 'বাংলা' },
{ locale: 'pa', label: 'ਪੰਜਾਬੀ' },
{ locale: 'gu-in', label: 'ગુજરાતી' },
{ locale: 'ta', label: 'தமிழ்' },
{ locale: 'te', label: 'తెలుగు' },
{ locale: 'kn', label: 'ಕನ್ನಡ' },
{ locale: 'ml', label: 'മലയാളം' },
{ locale: 'th', label: 'ภาษาไทย' },
{ locale: 'km-kh', label: 'ភាសាខ្មែរ' },
{ locale: 'ko-kr', label: '한국어' },
{ locale: 'ja', label: '日本語' },
{ locale: 'zh-cn', label: '简体中文' },
{ locale: 'zh-tw', label: '繁體中文 (台灣)' },
] as const

View File

@@ -0,0 +1,386 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
LANGUAGES,
TLLanguage,
_getDefaultTranslationLocale,
getDefaultTranslationLocale,
} from './translations'
interface DefaultLanguageTest {
name: string
input: string[]
output: string
}
const originalWindow = global.window
describe('translations.ts', () => {
describe('exports', () => {
it('should export LANGUAGES constant', () => {
expect(LANGUAGES).toBeDefined()
expect(Array.isArray(LANGUAGES)).toBe(true)
expect(LANGUAGES.length).toBeGreaterThan(0)
})
it('should export TLLanguage type', () => {
// Test that TLLanguage type works correctly
const testLanguage: TLLanguage = LANGUAGES[0]
expect(testLanguage).toHaveProperty('locale')
expect(testLanguage).toHaveProperty('label')
expect(typeof testLanguage.locale).toBe('string')
expect(typeof testLanguage.label).toBe('string')
})
it('should export getDefaultTranslationLocale function', () => {
expect(typeof getDefaultTranslationLocale).toBe('function')
})
it('should export _getDefaultTranslationLocale function', () => {
expect(typeof _getDefaultTranslationLocale).toBe('function')
})
})
describe('getDefaultTranslationLocale', () => {
afterEach(() => {
// Restore original window
global.window = originalWindow
vi.clearAllMocks()
})
it('should return default locale in browser environment', () => {
// Mock browser environment
global.window = {
navigator: {
languages: ['fr', 'en'],
},
} as any
const locale = getDefaultTranslationLocale()
expect(locale).toBe('fr')
})
it('should use navigator.languages when available', () => {
// Mock browser environment with navigator.languages
global.window = {
navigator: {
languages: ['de', 'es', 'en'],
},
} as any
const locale = getDefaultTranslationLocale()
expect(locale).toBe('de')
})
it('should fallback to English when navigator.languages is null/undefined', () => {
// Mock browser environment with null languages
global.window = {
navigator: {
languages: null,
},
} as any
const locale = getDefaultTranslationLocale()
expect(locale).toBe('en')
})
it('should return English in server environment (no window)', () => {
// Mock server environment
delete (global as any).window
const locale = getDefaultTranslationLocale()
expect(locale).toBe('en')
})
it('should handle undefined navigator gracefully', () => {
// Mock browser environment without navigator
global.window = {
navigator: undefined,
} as any
const locale = getDefaultTranslationLocale()
expect(locale).toBe('en')
})
it('should handle empty languages array', () => {
// Mock browser environment with empty languages array
global.window = {
navigator: {
languages: [],
},
} as any
const locale = getDefaultTranslationLocale()
expect(locale).toBe('en')
})
it('should work with complex browser language preferences', () => {
// Test realistic browser scenario
global.window = {
navigator: {
languages: ['zh-TW', 'zh', 'en-US', 'en'],
},
} as any
const locale = getDefaultTranslationLocale()
expect(locale).toBe('zh-tw') // Should match zh-TW exactly
})
})
describe('_getDefaultTranslationLocale (internal logic)', () => {
const tests: DefaultLanguageTest[] = [
{
name: 'finds a matching language locale',
input: ['fr'],
output: 'fr',
},
{
name: 'finds a matching region locale',
input: ['pt-PT'],
output: 'pt-pt',
},
{
name: 'picks a region locale if no language locale available',
input: ['pt'],
output: 'pt-br',
},
{
name: 'picks a language locale if no region locale available',
input: ['fr-CA'],
output: 'fr',
},
{
name: 'picks the first language that loosely matches',
input: ['fr-CA', 'pt-PT'],
output: 'fr',
},
]
for (const test of tests) {
it(test.name, () => {
expect(_getDefaultTranslationLocale(test.input)).toEqual(test.output)
})
}
it('should return "en" when no locales provided', () => {
expect(_getDefaultTranslationLocale([])).toBe('en')
})
it('should return "en" when no supported locales found', () => {
expect(_getDefaultTranslationLocale(['xyz', 'abc', 'unknown'])).toBe('en')
})
it('should handle case insensitive matching', () => {
expect(_getDefaultTranslationLocale(['FR'])).toBe('fr')
expect(_getDefaultTranslationLocale(['PT-BR'])).toBe('pt-br')
expect(_getDefaultTranslationLocale(['ZH-CN'])).toBe('zh-cn')
})
it('should handle mixed case and separators', () => {
expect(_getDefaultTranslationLocale(['pt_BR'])).toBe('pt-br')
expect(_getDefaultTranslationLocale(['zh_CN'])).toBe('zh-cn')
expect(_getDefaultTranslationLocale(['KO_KR'])).toBe('ko-kr')
})
it('should prioritize exact matches over fallbacks', () => {
expect(_getDefaultTranslationLocale(['pt-pt', 'pt'])).toBe('pt-pt')
expect(_getDefaultTranslationLocale(['zh-tw', 'zh'])).toBe('zh-tw')
})
it('should handle default region assignments correctly', () => {
// Chinese defaults to zh-cn
expect(_getDefaultTranslationLocale(['zh'])).toBe('zh-cn')
// Portuguese defaults to pt-br
expect(_getDefaultTranslationLocale(['pt'])).toBe('pt-br')
// Korean defaults to ko-kr
expect(_getDefaultTranslationLocale(['ko'])).toBe('ko-kr')
// Hindi defaults to hi-in
expect(_getDefaultTranslationLocale(['hi'])).toBe('hi-in')
})
it('should handle region-to-language fallback', () => {
// fr-CA should fallback to fr (if we only have generic French)
expect(_getDefaultTranslationLocale(['fr-CA'])).toBe('fr')
// es-MX should fallback to es
expect(_getDefaultTranslationLocale(['es-MX'])).toBe('es')
})
it('should respect locale priority order', () => {
// First supported locale should win
expect(_getDefaultTranslationLocale(['unsupported', 'fr', 'de'])).toBe('fr')
expect(_getDefaultTranslationLocale(['xyz', 'de', 'fr'])).toBe('de')
})
it('should handle complex regional scenarios', () => {
// zh-HK not supported, should fallback to default zh-cn (doesn't continue to next locale)
expect(_getDefaultTranslationLocale(['zh-HK', 'zh-TW', 'en'])).toBe('zh-cn')
// zh-SG not supported, should fallback to default zh-cn
expect(_getDefaultTranslationLocale(['zh-SG', 'zh-CN', 'en'])).toBe('zh-cn')
// Test exact match takes priority
expect(_getDefaultTranslationLocale(['zh-TW', 'zh-CN'])).toBe('zh-tw')
// Test fallback behavior
expect(_getDefaultTranslationLocale(['zh-HK', 'zh'])).toBe('zh-cn')
})
it('should work with single locale array', () => {
expect(_getDefaultTranslationLocale(['fr'])).toBe('fr')
expect(_getDefaultTranslationLocale(['unsupported'])).toBe('en')
})
it('should handle whitespace and malformed locales gracefully', () => {
// These should not crash but return fallback
expect(_getDefaultTranslationLocale([' '])).toBe('en')
expect(_getDefaultTranslationLocale([''])).toBe('en')
// Malformed locales should fallback to English when they can't be parsed properly
expect(_getDefaultTranslationLocale(['fr-'])).toBe('en')
// But valid partial matches should work
expect(_getDefaultTranslationLocale(['fr', 'en'])).toBe('fr')
})
})
describe('getSupportedLocale (internal function behavior)', () => {
// Note: getSupportedLocale is not exported, but we can test its behavior through _getDefaultTranslationLocale
it('should find exact locale matches', () => {
// These test the internal getSupportedLocale logic through the public API
expect(_getDefaultTranslationLocale(['en'])).toBe('en')
expect(_getDefaultTranslationLocale(['fr'])).toBe('fr')
expect(_getDefaultTranslationLocale(['pt-br'])).toBe('pt-br')
})
it('should handle case normalization in exact matches', () => {
expect(_getDefaultTranslationLocale(['EN'])).toBe('en')
expect(_getDefaultTranslationLocale(['PT-BR'])).toBe('pt-br')
expect(_getDefaultTranslationLocale(['ZH-TW'])).toBe('zh-tw')
})
it('should fall back from region to language', () => {
// fr-CA -> fr (assuming we have generic French)
expect(_getDefaultTranslationLocale(['fr-CA'])).toBe('fr')
// es-AR -> es
expect(_getDefaultTranslationLocale(['es-AR'])).toBe('es')
})
it('should assign default regions for base languages', () => {
// zh -> zh-cn (default Chinese region)
expect(_getDefaultTranslationLocale(['zh'])).toBe('zh-cn')
// pt -> pt-br (default Portuguese region)
expect(_getDefaultTranslationLocale(['pt'])).toBe('pt-br')
})
it('should return null for unsupported languages (via fallback to en)', () => {
// Unsupported languages should result in English fallback
expect(_getDefaultTranslationLocale(['xyz'])).toBe('en')
expect(_getDefaultTranslationLocale(['unsupported-locale'])).toBe('en')
})
})
describe('DEFAULT_LOCALE_REGIONS behavior', () => {
it('should apply Chinese default region correctly', () => {
expect(_getDefaultTranslationLocale(['zh'])).toBe('zh-cn')
})
it('should apply Portuguese default region correctly', () => {
expect(_getDefaultTranslationLocale(['pt'])).toBe('pt-br')
})
it('should apply Korean default region correctly', () => {
expect(_getDefaultTranslationLocale(['ko'])).toBe('ko-kr')
})
it('should apply Hindi default region correctly', () => {
expect(_getDefaultTranslationLocale(['hi'])).toBe('hi-in')
})
it('should not affect languages without default regions', () => {
// Languages like French, Spanish, German don't have default regions
expect(_getDefaultTranslationLocale(['fr'])).toBe('fr')
expect(_getDefaultTranslationLocale(['es'])).toBe('es')
expect(_getDefaultTranslationLocale(['de'])).toBe('de')
})
})
describe('TLLanguage type integration', () => {
it('should work correctly with TypeScript type system', () => {
// Test that return values match the TLLanguage locale type
const result: TLLanguage['locale'] = _getDefaultTranslationLocale(['fr'])
expect(result).toBe('fr')
})
it('should be compatible with LANGUAGES array', () => {
const allLocales: TLLanguage['locale'][] = LANGUAGES.map((lang) => lang.locale)
// Every result should be a valid locale from LANGUAGES
const testLocales = ['fr', 'pt-br', 'zh-cn', 'unsupported']
for (const locale of testLocales) {
const result = _getDefaultTranslationLocale([locale])
expect(allLocales).toContain(result)
}
})
})
describe('edge cases and robustness', () => {
it('should handle null and undefined inputs gracefully', () => {
// These should not crash the function
expect(() => _getDefaultTranslationLocale([] as any)).not.toThrow()
expect(_getDefaultTranslationLocale([])).toBe('en')
})
it('should handle very large locale arrays', () => {
const manyLocales = Array.from({ length: 1000 }, (_, i) => `lang-${i}`).concat(['fr']) // Add a valid one at the end
expect(_getDefaultTranslationLocale(manyLocales)).toBe('fr')
})
it('should handle special characters in locale strings', () => {
// Should not crash on malformed input
expect(() => _getDefaultTranslationLocale(['@#$%', 'fr'])).not.toThrow()
expect(_getDefaultTranslationLocale(['@#$%', 'fr'])).toBe('fr')
})
it('should handle extremely long locale strings', () => {
const longLocale = 'a'.repeat(1000)
expect(() => _getDefaultTranslationLocale([longLocale, 'fr'])).not.toThrow()
expect(_getDefaultTranslationLocale([longLocale, 'fr'])).toBe('fr')
})
it('should be consistent across multiple calls', () => {
const input = ['de', 'fr', 'es']
const result1 = _getDefaultTranslationLocale(input)
const result2 = _getDefaultTranslationLocale(input)
const result3 = _getDefaultTranslationLocale([...input]) // Spread to ensure different array
expect(result1).toBe(result2)
expect(result2).toBe(result3)
expect(result1).toBe('de')
})
})
describe('real-world usage scenarios', () => {
it('should handle typical browser language lists', () => {
// Common browser scenarios
expect(_getDefaultTranslationLocale(['en-US', 'en'])).toBe('en')
expect(_getDefaultTranslationLocale(['es-ES', 'es', 'en'])).toBe('es')
expect(_getDefaultTranslationLocale(['fr-FR', 'fr', 'en-US', 'en'])).toBe('fr')
})
it('should work for mobile browser scenarios', () => {
// iOS Safari typical format
expect(_getDefaultTranslationLocale(['zh-Hans-CN', 'zh-Hans', 'zh'])).toBe('zh-cn')
// Android Chrome typical format
expect(_getDefaultTranslationLocale(['pt-BR', 'pt', 'en-US'])).toBe('pt-br')
})
it('should handle multilingual user preferences', () => {
// User who speaks multiple languages
expect(_getDefaultTranslationLocale(['de-CH', 'fr-CH', 'it-CH', 'en'])).toBe('de')
expect(_getDefaultTranslationLocale(['es-MX', 'en-US', 'fr'])).toBe('es')
})
it('should work in internationalization scenarios', () => {
// Various international format inputs
expect(_getDefaultTranslationLocale(['ja-JP'])).toBe('ja')
expect(_getDefaultTranslationLocale(['ko-KR'])).toBe('ko-kr')
expect(_getDefaultTranslationLocale(['ar-SA'])).toBe('ar')
expect(_getDefaultTranslationLocale(['hi-IN'])).toBe('hi-in')
})
})
})

View File

@@ -0,0 +1,220 @@
import { LANGUAGES } from './languages'
/** @public */
export { LANGUAGES }
/**
* A language definition object representing a supported localization in tldraw.
*
* Derived from the LANGUAGES array, this type represents a single language entry
* containing a locale identifier and human-readable label. The locale follows
* BCP 47 standards (e.g., 'en', 'fr', 'zh-CN') and the label is in the native language.
*
* @example
* ```ts
* import { TLLanguage } from '@tldraw/tlschema'
*
* // Using TLLanguage type
* const currentLanguage: TLLanguage = { locale: 'fr', label: 'Français' }
*
* // Access locale and label
* console.log(currentLanguage.locale) // "fr"
* console.log(currentLanguage.label) // "Français"
* ```
*
* @public
*/
export type TLLanguage = (typeof LANGUAGES)[number]
/**
* Gets the default translation locale based on the user's browser language preferences.
*
* This function determines the best matching locale from the user's browser language
* settings, falling back to English if no suitable match is found. It works in both
* browser and server-side environments, defaulting to English on the server.
*
* The function prioritizes exact matches first, then falls back to language-only
* matches, and finally uses predefined regional defaults for languages like Chinese,
* Portuguese, Korean, and Hindi.
*
* @returns The locale identifier (e.g., 'en', 'fr', 'zh-cn') that best matches the user's preferences
*
* @example
* ```ts
* import { getDefaultTranslationLocale } from '@tldraw/tlschema'
*
* // Get the user's preferred locale
* const locale = getDefaultTranslationLocale()
* console.log(locale) // e.g., "fr" or "en" or "zh-cn"
*
* // Use in localization setup
* const i18n = new I18n({
* locale,
* // ... other config
* })
* ```
*
* @example
* ```ts
* // Browser with languages: ['fr-CA', 'en-US']
* const locale = getDefaultTranslationLocale()
* console.log(locale) // "fr" (if French is supported)
*
* // Browser with languages: ['zh']
* const locale = getDefaultTranslationLocale()
* console.log(locale) // "zh-cn" (default region for Chinese)
* ```
*
* @public
*/
export function getDefaultTranslationLocale(): TLLanguage['locale'] {
const locales =
typeof window !== 'undefined' && window.navigator
? (window.navigator.languages ?? ['en'])
: ['en']
return _getDefaultTranslationLocale(locales)
}
/**
* Internal function that determines the default translation locale from a list of locale preferences.
*
* This function is the core logic for locale resolution, separated from browser-specific code
* for easier testing and reuse. It iterates through the provided locales in priority order
* and returns the first supported locale found, or 'en' as the ultimate fallback.
*
* @param locales - Array of locale identifiers in preference order (e.g., from navigator.languages)
* @returns The best matching supported locale identifier
*
* @example
* ```ts
*
* // Test locale resolution
* const locale = _getDefaultTranslationLocale(['fr-CA', 'en-US', 'es'])
* console.log(locale) // "fr" (if French is supported)
*
* // No supported locales
* const fallback = _getDefaultTranslationLocale(['xx-YY', 'zz-AA'])
* console.log(fallback) // "en"
* ```
*
* @internal
*/
export function _getDefaultTranslationLocale(locales: readonly string[]): TLLanguage['locale'] {
for (const locale of locales) {
const supportedLocale = getSupportedLocale(locale)
if (supportedLocale) {
return supportedLocale
}
}
return 'en'
}
/**
* Default regional variants for languages that have multiple regional versions.
*
* When a user's locale contains only a language code (e.g., 'zh', 'pt') but tldraw
* only supports region-specific variants, this mapping determines which regional
* variant to use as the default. This ensures users get the most appropriate
* localization even when their preference doesn't specify a region.
*
*
* @example
* ```ts
* // User has locale preference "zh" but we only support "zh-cn" and "zh-tw"
* const defaultRegion = DEFAULT_LOCALE_REGIONS['zh']
* console.log(defaultRegion) // "zh-cn"
*
* // User has locale preference "pt" but we support "pt-br" and "pt-pt"
* const defaultRegion = DEFAULT_LOCALE_REGIONS['pt']
* console.log(defaultRegion) // "pt-br"
* ```
*
* @public
*/
const DEFAULT_LOCALE_REGIONS: { [locale: string]: TLLanguage['locale'] } = {
zh: 'zh-cn',
pt: 'pt-br',
ko: 'ko-kr',
hi: 'hi-in',
}
/**
* Finds a supported locale that matches the given locale identifier.
*
* This function implements a flexible locale matching algorithm that tries multiple
* strategies to find the best available translation:
*
* 1. **Exact match**: Looks for an exact locale match (case-insensitive)
* 2. **Language-only match**: If the input has a region, tries matching just the language
* 3. **Default region**: If the input lacks a region, uses the default region for that language
* 4. **No match**: Returns null if no suitable locale is found
*
* @param locale - The locale identifier to match (e.g., 'fr-CA', 'pt', 'zh-TW')
* @returns The matching supported locale identifier, or null if no match is found
*
* @example
* ```ts
* // Exact matches
* getSupportedLocale('fr') // "fr" (if supported)
* getSupportedLocale('PT-BR') // "pt-br" (case insensitive)
*
* // Language-only fallback
* getSupportedLocale('fr-CA') // "fr" (if we only support generic French)
*
* // Default region assignment
* getSupportedLocale('zh') // "zh-cn" (default Chinese region)
*
* // No match
* getSupportedLocale('xyz') // null
* ```
*
* @example
* ```ts
* // Usage in locale resolution
* const userLocales = ['es-MX', 'en-US']
* for (const userLocale of userLocales) {
* const supported = getSupportedLocale(userLocale)
* if (supported) {
* console.log(`Using locale: ${supported}`)
* break
* }
* }
* ```
*
* @public
*/
function getSupportedLocale(locale: string): TLLanguage['locale'] | null {
// If we have an exact match, return it!
// (e.g. if the user has 'fr' and we have 'fr')
// (or if the user has 'pt-BR' and we have 'pt-br')
const exactMatch = LANGUAGES.find((t) => t.locale === locale.toLowerCase())
if (exactMatch) {
return exactMatch.locale
}
// Otherwise, we need to be more flexible...
const [language, region] = locale.split(/[-_]/).map((s) => s.toLowerCase())
// If the user's language has a region...
// let's try to find non-region-specific locale for them
// (e.g. if they have 'fr-CA' but we only have 'fr')
if (region) {
const languageMatch = LANGUAGES.find((t) => t.locale === language)
if (languageMatch) {
return languageMatch.locale
}
}
// If the user's language doesn't have a region...
// let's try to find a region-specific locale for them
// (e.g. if they have 'pt' but we only have 'pt-pt' or 'pt-br')
//
// In this case, we choose the hard-coded default region for that language
if (language in DEFAULT_LOCALE_REGIONS) {
return DEFAULT_LOCALE_REGIONS[language]
}
// Oh no! We don't have a translation for this language!
// Let's give up...
return null
}

View File

@@ -0,0 +1,52 @@
/**
* Utility type that extracts the value type from a Set type.
*
* This helper type uses conditional type inference to extract the element type
* from a Set. It's useful when working with sets in type definitions where you
* need to reference the type of elements contained within the set.
*
* The type uses TypeScript's `infer` keyword to capture the generic parameter
* of the Set type, making it reusable across different Set types.
*
* @example
* ```ts
* import { SetValue } from '@tldraw/tlschema'
*
* // Extract value type from a Set type
* type StringSet = Set<string>
* type StringValue = SetValue<StringSet> // string
*
* type NumberSet = Set<number>
* type NumberValue = SetValue<NumberSet> // number
* ```
*
* @example
* ```ts
* // Usage with const sets
* const COLORS = new Set(['red', 'green', 'blue'] as const)
* type ColorSet = typeof COLORS
* type Color = SetValue<ColorSet> // 'red' | 'green' | 'blue'
*
* // Function that accepts set values
* function processColor(color: SetValue<typeof COLORS>) {
* // color is typed as 'red' | 'green' | 'blue'
* console.log(`Processing color: ${color}`)
* }
* ```
*
* @example
* ```ts
* // Complex usage with union types
* const UI_COLORS = new Set(['selection-stroke', 'accent', 'muted'])
* type UIColorSet = typeof UI_COLORS
* type UIColor = SetValue<UIColorSet> // 'selection-stroke' | 'accent' | 'muted'
*
* // Validate color is in set
* function isValidUIColor(color: string): color is UIColor {
* return UI_COLORS.has(color)
* }
* ```
*
* @public
*/
export type SetValue<T extends Set<any>> = T extends Set<infer U> ? U : never