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

682
packages/tlschema/DOCS.md Normal file
View File

@@ -0,0 +1,682 @@
# @tldraw/tlschema
The schema package defines the type system, data structures, validation, and migrations for tldraw's persisted data. It provides a complete, type-safe, and version-aware data model that powers the tldraw editor.
## 1. Introduction
**@tldraw/tlschema** is the foundational package that defines how tldraw stores and manages data. It contains:
- **Record types** for all persisted data (shapes, assets, pages, user state)
- **Validation schemas** that ensure data integrity at runtime
- **Migration sequences** that handle data evolution over time
- **Style properties** that enable consistent styling across shapes
You'll use this package when creating custom shapes, defining your own data schemas, or when you need to work with tldraw's data structures directly.
## 2. Core Concepts
### Schemas: The Foundation
A **schema** defines the structure of your tldraw store. It specifies what types of records can exist, how they're validated, and how they evolve over time.
```ts
import { createTLSchema, defaultShapeSchemas } from '@tldraw/tlschema'
// Create a schema with default shapes
const schema = createTLSchema({
shapes: defaultShapeSchemas,
})
```
### Records: The Data Units
**Records** are the individual pieces of data stored in tldraw. Every record has a type, an ID, and properties specific to that type:
```ts
import { TLShape, TLPage, TLAsset } from '@tldraw/tlschema'
// All records extend BaseRecord
const shape: TLShape = {
id: 'shape:abc123',
typeName: 'shape',
type: 'geo',
x: 100,
y: 200,
rotation: 0,
// ... other properties
}
```
### Style Properties: Shared Styling
**Style properties** are special properties that can be applied across multiple shapes and persist for new shape creation:
```ts
import { StyleProp } from '@tldraw/tlschema'
import { T } from '@tldraw/validate'
// Define a custom style property
const MyCustomStyle = StyleProp.define('myapp:custom', {
defaultValue: 'default',
type: T.string,
})
```
## 3. Basic Usage
### Creating a Custom Schema
You create schemas by combining shape configurations, bindings, and migrations:
```ts
import { createTLSchema, defaultShapeSchemas, defaultBindingSchemas } from '@tldraw/tlschema'
const schema = createTLSchema({
shapes: {
...defaultShapeSchemas,
// Add custom shapes here
},
bindings: defaultBindingSchemas,
})
```
### Working with Shape Records
Shape records are the most common type you'll work with. Every shape extends `TLBaseShape`:
```ts
import { createShapeValidator } from '@tldraw/tlschema'
import { T } from '@tldraw/validate'
const CUSTOM_TYPE = 'custom'
// Define a custom shape type
declare module '@tldraw/tlschema' {
export interface TLGlobalShapePropsMap {
[CUSTOM_TYPE]: MyCustomShapeProps
}
}
interface MyCustomShapeProps {
width: number
height: number
color: string
}
type MyCustomShape = TLShape<typeof CUSTOM_TYPE>
// Create validation for your shape
const customShapeValidator = createShapeValidator(CUSTOM_TYPE, {
width: T.number,
height: T.number,
color: T.string,
})
```
### Integrating with Store
Once you have a schema, you use it to create a store:
```ts
import { Store } from '@tldraw/store'
import { TLStoreProps } from '@tldraw/tlschema'
const store = new Store({
schema,
props: {
defaultName: 'My Drawing',
assets: myAssetStore, // Your asset storage implementation
},
})
```
## 4. Advanced Topics
### Creating Custom Shapes
When creating custom shapes, follow this pattern for complete integration:
```ts
import {
createShapeValidator,
createShapePropsMigrationSequence,
RecordProps,
} from '@tldraw/tlschema'
import { DefaultColorStyle } from '@tldraw/tlschema'
const MY_SHAPE_TYPE = 'myshape'
// 1. Define the shape
declare module '@tldraw/tlschema' {
export interface TLGlobalShapePropsMap {
[MY_SHAPE_TYPE]: MyShapeProps
}
}
interface MyShapeProps {
color: typeof DefaultColorStyle // Use existing style
width: number
height: number
customData: string
}
type MyShape = TLShape<typeof MY_SHAPE_TYPE>
// 2. Create props validation
const myShapeProps: RecordProps<MyShape> = {
color: DefaultColorStyle,
width: T.number,
height: T.number,
customData: T.string,
}
// 3. Define migrations for schema evolution
const myShapeMigrations = createShapePropsMigrationSequence({
sequenceId: 'com.myapp.shape.myshape',
sequence: [
{
id: 'com.myapp.shape.myshape/1.0.0',
up: (props) => props, // Initial version
down: (props) => props,
},
],
})
// 4. Add to schema
const schema = createTLSchema({
shapes: {
...defaultShapeSchemas,
myshape: {
props: myShapeProps,
migrations: myShapeMigrations,
},
},
})
```
### Custom Style Properties
Style properties enable consistent styling across shapes and remember the last used value:
```ts
import { StyleProp, EnumStyleProp } from '@tldraw/tlschema'
import { T } from '@tldraw/validate'
// Free-form style property
const MyWidthStyle = StyleProp.define('myapp:width', {
defaultValue: 2,
type: T.number,
})
// Enum-based style property
const MyPatternStyle = StyleProp.defineEnum('myapp:pattern', {
defaultValue: 'solid',
values: ['solid', 'dashed', 'dotted'],
})
// Use in shape props
interface MyShapeProps {
width: typeof MyWidthStyle
pattern: typeof MyPatternStyle
// other props...
}
```
### Asset Management
Assets represent external resources like images, videos, or bookmarks:
```ts
import { TLImageAsset, TLAssetStore } from '@tldraw/tlschema'
// Implement asset storage
const assetStore: TLAssetStore = {
async upload(asset, file) {
// Upload file to your storage service
const src = await uploadToStorage(file)
return { src }
},
async resolve(asset, context) {
// Resolve asset URL for rendering
return asset.props.src
},
async remove(assetIds) {
// Clean up removed assets
await deleteFromStorage(assetIds)
},
}
```
### Migration Strategies
Migrations handle schema evolution as your application develops:
```ts
import { createShapePropsMigrationSequence } from '@tldraw/tlschema'
const migrations = createShapePropsMigrationSequence({
sequenceId: 'com.myapp.shape.custom',
sequence: [
{
id: 'com.myapp.shape.custom/1.1.0',
up: (props) => {
// Add new property with default value
return { ...props, newProperty: 'default' }
},
down: ({ newProperty, ...props }) => {
// Remove property for backward compatibility
return props
},
},
{
id: 'com.myapp.shape.custom/1.2.0',
up: (props) => {
// Rename property
return {
...props,
renamedProperty: props.oldProperty,
oldProperty: undefined,
}
},
down: (props) => {
return {
...props,
oldProperty: props.renamedProperty,
renamedProperty: undefined,
}
},
},
],
})
```
## 5. Built-in Shape Types
### Default Shapes
tldraw includes several built-in shape types:
```ts
import {
TLGeoShape, // Rectangles, ellipses, triangles, etc.
TLTextShape, // Text with rich formatting
TLDrawShape, // Freehand drawing paths
TLArrowShape, // Arrows with optional binding to shapes
TLLineShape, // Multi-point lines and splines
TLImageShape, // Raster images
TLVideoShape, // Video files
TLNoteShape, // Sticky notes
TLBookmarkShape, // Website bookmarks
TLEmbedShape, // Embedded content (YouTube, Figma, etc.)
TLFrameShape, // Frames for grouping content
TLGroupShape, // Groups for organizing shapes
TLHighlightShape, // Highlighting tool strokes
} from '@tldraw/tlschema'
```
### Shape Properties
All shapes share common base properties from `TLBaseShape`:
```ts
interface TLBaseShape<Type, Props> {
id: TLShapeId
type: Type
x: number // Position X
y: number // Position Y
rotation: number // Rotation in radians
index: IndexKey // Fractional index for ordering
parentId: TLParentId // Parent page or shape
isLocked: boolean // Whether shape can be selected
opacity: TLOpacityType // Transparency (0-1)
props: Props // Shape-specific properties
meta: JsonObject // User-defined metadata
}
```
### Geo Shapes
Geometric shapes support various styles and configurations:
```ts
import { TLGeoShape, GeoShapeGeoStyle } from '@tldraw/tlschema'
// Geo shapes can be rectangles, ellipses, triangles, etc.
const geoShape: TLGeoShape = {
// ... base properties
props: {
geo: GeoShapeGeoStyle, // 'rectangle', 'ellipse', 'triangle', etc.
w: 100, // Width
h: 80, // Height
color: 'blue', // Color style
fill: 'solid', // Fill style
dash: 'solid', // Dash style
size: 'm', // Size style
richText: null, // Optional text content
},
}
```
## 6. Validation and Type Safety
### Runtime Validation
All records are validated at runtime to ensure data integrity:
```ts
import { T } from '@tldraw/validate'
import { createShapeValidator } from '@tldraw/tlschema'
// Validation happens automatically when records enter the store
const validator = createShapeValidator('myshape', {
width: T.number.check((n) => n > 0), // Custom validation
height: T.number.check((n) => n > 0),
color: T.string,
})
```
### Error Handling
When validation fails, you can handle errors gracefully:
```ts
try {
store.put([invalidRecord])
} catch (error) {
if (error instanceof ValidationError) {
console.log('Validation failed:', error.message)
// Handle validation error appropriately
}
}
```
### Type-Safe IDs
Record IDs are strongly typed to prevent mixing different record types:
```ts
import { TLShapeId, TLPageId, createShapeId } from '@tldraw/tlschema'
// IDs are branded types - compiler prevents mixing them up
const shapeId: TLShapeId = createShapeId()
const pageId: TLPageId = 'page:123' // TypeScript error if you use wrong format
```
## 7. Integration with @tldraw/store
### Store Configuration
The schema integrates with the store system to provide reactive data management:
```ts
import { Store } from '@tldraw/store'
import { TLStoreProps, createTLSchema } from '@tldraw/tlschema'
const schema = createTLSchema()
const store = new Store({
schema,
props: {
defaultName: 'Untitled',
assets: assetStore,
onMount: (editor) => {
// Initialize when editor mounts
console.log('Editor mounted with store')
},
},
})
```
### Reactive Queries
The store provides reactive access to records:
```ts
import { track } from '@tldraw/state'
// This function will re-run when shapes change
const ShapeCounter = track(() => {
const shapes = store.query.records('shape').get()
return `Total shapes: ${shapes.length}`
})
```
## 8. Performance Considerations
### Validation Optimization
- Use `validateUsingKnownGoodVersion()` when you know data is already valid
- Minimize validation in hot paths during user interactions
- Consider validation levels (development vs production)
### Memory Management
- Style properties are shared across shapes to reduce memory usage
- Records use immutable structures to prevent accidental mutations
- `devFreeze()` helps catch mutation bugs in development
### Migration Performance
- Group related changes into single migration steps
- Test migration performance with realistic data sizes
- Use migration sequence IDs to optimize dependency resolution
## 9. Debugging
### Understanding Schema Structure
You can inspect your schema to understand its configuration:
```ts
const schema = createTLSchema()
// Examine record types
console.log('Record types:', Object.keys(schema.types))
// Check validation for specific record type
const shapeValidator = schema.types.shape
console.log('Shape validator:', shapeValidator)
```
### Migration Debugging
When migrations fail, examine the migration sequence:
```ts
// Check migration history
const migrations = schema.sortedMigrations
console.log(
'Migration sequence:',
migrations.map((m) => m.id)
)
// Test individual migrations
try {
const migrated = migrator.migrateStoreSnapshot({
schema: oldSchema,
store: snapshot,
})
console.log('Migration successful')
} catch (error) {
console.error('Migration failed:', error)
}
```
### Validation Errors
When records fail validation, examine the validation path:
```ts
import { T } from '@tldraw/validate'
try {
shapeValidator.validate(invalidShape)
} catch (error) {
console.log('Validation path:', error.path)
console.log('Validation message:', error.message)
console.log('Invalid value:', error.value)
}
```
## 10. Common Patterns
### Shape with Asset References
Many shapes reference assets for their content:
```ts
interface MediaShapeProps {
assetId: TLAssetId | null
width: number
height: number
crop?: TLShapeCrop // Optional cropping info
}
// Usage pattern
const imageShape: TLImageShape = {
// ... base properties
props: {
assetId: 'asset:image123',
w: 200,
h: 150,
crop: {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 },
},
},
}
```
### Binding Relationships
Arrows can bind to other shapes:
```ts
import { TLArrowBinding } from '@tldraw/tlschema'
const arrowBinding: TLArrowBinding = {
id: 'binding:abc123',
typeName: 'binding',
type: 'arrow',
fromId: 'shape:arrow1', // Arrow shape ID
toId: 'shape:rectangle1', // Target shape ID
props: {
terminal: 'end', // 'start' or 'end'
normalizedAnchor: { x: 0.5, y: 0.5 }, // Position on target
isExact: false, // Whether position is exact
isPrecise: true, // Whether binding is precise
},
}
```
### Rich Text Content
Shapes can contain formatted text:
```ts
import { TLRichText, toRichText } from '@tldraw/tlschema'
const richText: TLRichText = toRichText('Hello **bold** text')
const textShape: TLTextShape = {
// ... base properties
props: {
color: 'black',
size: 'm',
font: 'draw',
textAlign: 'start',
richText: richText,
autoSize: true,
scale: 1,
},
}
```
## 11. Best Practices
### Schema Design
- **Start simple** - Begin with minimal shape properties and add complexity gradually
- **Use existing styles** - Leverage built-in style properties before creating custom ones
- **Plan for evolution** - Design your schema with migrations in mind
- **Validate thoroughly** - Include validation for edge cases and invalid states
### Migration Strategy
- **Version incrementally** - Use semantic versioning in migration IDs
- **Test both directions** - Ensure up and down migrations work correctly
- **Handle failures gracefully** - Provide fallbacks when migrations fail
- **Document breaking changes** - Clearly communicate migration requirements
### Performance Optimization
- **Minimize validation overhead** - Use efficient validators for frequently accessed properties
- **Batch related changes** - Group property updates to reduce reactive updates
- **Use appropriate record scopes** - Choose correct scope (document/session/presence) for data
- **Optimize asset handling** - Implement efficient asset storage and resolution
### Error Handling
- **Validate early** - Catch validation errors as close to the source as possible
- **Provide clear messages** - Use descriptive error messages for debugging
- **Handle partial failures** - Design systems that can recover from individual record failures
- **Log strategically** - Include enough context for debugging without overwhelming logs
## 12. Extension Points
### Custom Shapes
Extend the built-in shape system:
```ts
// Add custom shapes to default schemas
const customSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
myCustomShape: customShapeConfig,
},
})
```
### Custom Bindings
Create new types of shape relationships:
```ts
const CUSTOM_TYPE = 'custom'
// Define custom binding types
declare module '@tldraw/tlschema' {
export interface TLGlobalBindingPropsMap {
[CUSTOM_TYPE]: MyBindingProps
}
}
type MyCustomBinding = TLBinding<typeof CUSTOM_TYPE>
const customBindingConfig = {
props: myBindingProps,
migrations: myBindingMigrations,
}
```
### Asset Storage
Implement custom asset storage backends:
```ts
const customAssetStore: TLAssetStore = {
async upload(asset, file) {
return await myCloudStorage.upload(file)
},
async resolve(asset, context) {
return await myCloudStorage.getUrl(asset.props.src, context)
},
async remove(assetIds) {
await Promise.all(assetIds.map((id) => myCloudStorage.delete(id)))
},
}
```
The tlschema package provides a robust foundation for building applications with tldraw. By following these patterns and understanding the core concepts, you can create custom shapes, manage data effectively, and build experiences that scale with your users' needs.

View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2024 tldraw Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,99 @@
# @tldraw/tlschema
Type definitions, schema migrations, and other type metadata for the tldraw editor's default persisted data.
## Documentation
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
A `DOCS.md` file is included alongside this README in the published package, with detailed API documentation and usage examples.
## Records
There are three main kinds of types:
- Record types
These are root record types added to the `Store` class. They are defined in the `./src/records` directory.
- Shape types
These are subtypes of the root TLShape record type. They allow specifying a unique name and custom props for a particular kind of shape.
- Asset types
These are subtypes of the root TLAsset record type. They allow specifying a unique name and custom props for a particular kind of asset.
# Adding migrations
If you make any kind of change to any persisted data shape in this package, you must add migrations that are able to convert old versions to new versions, and vice-versa.
If you are making a change that affects the structure of a record, shape, or asset, update the migrations in the same file as the record, shape, or asset is defined.
If you are making a change that affects the structure of the store (e.g. renaming or deleting a type, consolidating two shape types into one, etc), add your changes in the migrations in `schema.ts`.
After making your changes, add a new version number, using a meaningful name. For example, if you add a new property
to the `TLShape` type called `ownerId` that points to a user, you might do this:
In `TLShape.ts`
```diff
const Versions = {
RemoveSomeProp: 1,
+ AddOwnerId: 2,
} as const
```
and then in the `TLShape` type
```diff
x: number
y: number
+ ownerId: ID<TLUser> | null
props: Props
parentId: ID<TLShape> | ID<TLPage>
```
and then adding a migration:
```diff
export const shapeTypeMigrations = defineMigrations({
currentVersion: Versions.Initial,
firstVersion: Versions.Initial,
migrators: {
+ [Versions.AddOwnerId]: {
+ // add ownerId property
+ up: (shape) => ({...shape, ownerId: null}),
+ // remove ownerId property
+ down: ({ownerId, ...shape}) => shape,
+ }
},
```
After you've added your migration, make sure to add a test for it in `src/migrations.test.ts`. It will complain if you do not!
## License
This project is licensed under the MIT License found [here](https://github.com/tldraw/tldraw/blob/main/packages/tlschema/LICENSE.md). The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Distributions
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "../../internal/config/api-extractor.json"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
{
"name": "@tldraw/tlschema",
"description": "tldraw infinite canvas SDK (schema).",
"version": "5.4.2",
"author": {
"name": "tldraw Inc.",
"email": "hello@tldraw.com"
},
"homepage": "https://tldraw.dev",
"license": "MIT",
"tldraw_product": {
"stableId": "tldraw:sdk-core",
"name": "SDK",
"type": "product",
"premium": false
},
"repository": {
"type": "git",
"url": "https://github.com/tldraw/tldraw"
},
"bugs": {
"url": "https://github.com/tldraw/tldraw/issues"
},
"keywords": [
"tldraw",
"sdk",
"drawing",
"app",
"development",
"whiteboard",
"canvas",
"infinite"
],
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
"main": "./src/index.ts",
"types": "./.tsbuild/index.d.ts",
"type": "module",
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
"files": [],
"scripts": {
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test": "yarn run -T vitest --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
"format": "yarn run -T oxfmt \"src/**/*.{ts,tsx,js,jsx,json,md}\"",
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
"postpack": "../../internal/scripts/postpack.sh",
"pack-tarball": "yarn pack",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
},
"devDependencies": {
"kleur": "^4.1.5",
"lazyrepo": "0.0.0-alpha.27",
"react": "^19.2.1",
"vitest": "^5.0.0"
},
"dependencies": {
"@tldraw/state": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/utils": "workspace:*",
"@tldraw/validate": "workspace:*"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.2.1",
"react-dom": "^18.2.0 || ^19.2.1"
},
"engines": {
"node": ">=22.12.0"
}
}

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

View File

@@ -0,0 +1,12 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"include": ["src", "scripts"],
"exclude": ["node_modules", "docs", "dist", ".tsbuild*"],
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "src" },
"references": [
{ "path": "../state" },
{ "path": "../store" },
{ "path": "../utils" },
{ "path": "../validate" }
]
}

View File

@@ -0,0 +1,6 @@
/// <reference types="vitest" />
import { mergeConfig } from 'vitest/config'
import baseConfig from '../../internal/config/vitest/node-preset'
// tlschema package uses the standard node preset with no overrides
export default mergeConfig(baseConfig, {})