@tldraw/tlschema
Version:
tldraw infinite canvas SDK (schema).
572 lines (543 loc) • 18.4 kB
text/typescript
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(),
exportBackground: true,
}),
])
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
}