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
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:
657
packages/tlschema/src/TLStore.test.ts
Normal file
657
packages/tlschema/src/TLStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
570
packages/tlschema/src/TLStore.ts
Normal file
570
packages/tlschema/src/TLStore.ts
Normal 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
|
||||
}
|
||||
58
packages/tlschema/src/__tests__/migrationTestUtils.ts
Normal file
58
packages/tlschema/src/__tests__/migrationTestUtils.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
124
packages/tlschema/src/assets/TLBaseAsset.ts
Normal file
124
packages/tlschema/src/assets/TLBaseAsset.ts
Normal 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>),
|
||||
})
|
||||
}
|
||||
99
packages/tlschema/src/assets/TLBookmarkAsset.ts
Normal file
99
packages/tlschema/src/assets/TLBookmarkAsset.ts
Normal 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
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
155
packages/tlschema/src/assets/TLImageAsset.ts
Normal file
155
packages/tlschema/src/assets/TLImageAsset.ts
Normal 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
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
175
packages/tlschema/src/assets/TLVideoAsset.ts
Normal file
175
packages/tlschema/src/assets/TLVideoAsset.ts
Normal 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
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
185
packages/tlschema/src/bindings/TLArrowBinding.ts
Normal file
185
packages/tlschema/src/bindings/TLArrowBinding.ts
Normal 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
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
189
packages/tlschema/src/bindings/TLBaseBinding.ts
Normal file
189
packages/tlschema/src/bindings/TLBaseBinding.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
171
packages/tlschema/src/createPresenceStateDerivation.test.ts
Normal file
171
packages/tlschema/src/createPresenceStateDerivation.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
154
packages/tlschema/src/createPresenceStateDerivation.ts
Normal file
154
packages/tlschema/src/createPresenceStateDerivation.ts
Normal 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
|
||||
}
|
||||
181
packages/tlschema/src/createTLSchema.test.ts
Normal file
181
packages/tlschema/src/createTLSchema.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
437
packages/tlschema/src/createTLSchema.ts
Normal file
437
packages/tlschema/src/createTLSchema.ts
Normal 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,
|
||||
}
|
||||
)
|
||||
}
|
||||
387
packages/tlschema/src/index.ts
Normal file
387
packages/tlschema/src/index.ts
Normal 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'
|
||||
2809
packages/tlschema/src/migrations.test.ts
Normal file
2809
packages/tlschema/src/migrations.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
72
packages/tlschema/src/misc/TLColor.ts
Normal file
72
packages/tlschema/src/misc/TLColor.ts
Normal 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)
|
||||
157
packages/tlschema/src/misc/TLCursor.ts
Normal file
157
packages/tlschema/src/misc/TLCursor.ts
Normal 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,
|
||||
})
|
||||
113
packages/tlschema/src/misc/TLHandle.ts
Normal file
113
packages/tlschema/src/misc/TLHandle.ts
Normal 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
|
||||
}
|
||||
56
packages/tlschema/src/misc/TLOpacity.ts
Normal file
56
packages/tlschema/src/misc/TLOpacity.ts
Normal 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
|
||||
56
packages/tlschema/src/misc/TLRichText.test.ts
Normal file
56
packages/tlschema/src/misc/TLRichText.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
87
packages/tlschema/src/misc/TLRichText.ts
Normal file
87
packages/tlschema/src/misc/TLRichText.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
146
packages/tlschema/src/misc/TLScribble.ts
Normal file
146
packages/tlschema/src/misc/TLScribble.ts
Normal 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,
|
||||
})
|
||||
592
packages/tlschema/src/misc/b64Vecs.test.ts
Normal file
592
packages/tlschema/src/misc/b64Vecs.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
636
packages/tlschema/src/misc/b64Vecs.ts
Normal file
636
packages/tlschema/src/misc/b64Vecs.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
65
packages/tlschema/src/misc/geometry-types.ts
Normal file
65
packages/tlschema/src/misc/geometry-types.ts
Normal 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,
|
||||
})
|
||||
33
packages/tlschema/src/misc/id-validator.ts
Normal file
33
packages/tlschema/src/misc/id-validator.ts
Normal 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
|
||||
})
|
||||
}
|
||||
290
packages/tlschema/src/records/TLAsset.ts
Normal file
290
packages/tlschema/src/records/TLAsset.ts
Normal 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
|
||||
}
|
||||
434
packages/tlschema/src/records/TLBinding.ts
Normal file
434
packages/tlschema/src/records/TLBinding.ts
Normal 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: {},
|
||||
}))
|
||||
}
|
||||
184
packages/tlschema/src/records/TLCamera.ts
Normal file
184
packages/tlschema/src/records/TLCamera.ts
Normal 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: {},
|
||||
})
|
||||
)
|
||||
255
packages/tlschema/src/records/TLComment.test.ts
Normal file
255
packages/tlschema/src/records/TLComment.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
446
packages/tlschema/src/records/TLComment.ts
Normal file
446
packages/tlschema/src/records/TLComment.ts
Normal 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 ?? {},
|
||||
}
|
||||
}
|
||||
297
packages/tlschema/src/records/TLCustomRecord.ts
Normal file
297
packages/tlschema/src/records/TLCustomRecord.ts
Normal 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
|
||||
}
|
||||
224
packages/tlschema/src/records/TLDocument.ts
Normal file
224
packages/tlschema/src/records/TLDocument.ts
Normal 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')
|
||||
656
packages/tlschema/src/records/TLInstance.ts
Normal file
656
packages/tlschema/src/records/TLInstance.ts
Normal 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
|
||||
192
packages/tlschema/src/records/TLPage.ts
Normal file
192
packages/tlschema/src/records/TLPage.ts
Normal 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)
|
||||
}
|
||||
255
packages/tlschema/src/records/TLPageState.ts
Normal file
255
packages/tlschema/src/records/TLPageState.ts
Normal 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>
|
||||
171
packages/tlschema/src/records/TLPointer.ts
Normal file
171
packages/tlschema/src/records/TLPointer.ts
Normal 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')
|
||||
258
packages/tlschema/src/records/TLPresence.ts
Normal file
258
packages/tlschema/src/records/TLPresence.ts
Normal 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: {},
|
||||
}))
|
||||
136
packages/tlschema/src/records/TLRecord.ts
Normal file
136
packages/tlschema/src/records/TLRecord.ts
Normal 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
|
||||
586
packages/tlschema/src/records/TLShape.ts
Normal file
586
packages/tlschema/src/records/TLShape.ts
Normal 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: {},
|
||||
}))
|
||||
}
|
||||
148
packages/tlschema/src/records/TLUser.ts
Normal file
148
packages/tlschema/src/records/TLUser.ts
Normal 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)
|
||||
}
|
||||
188
packages/tlschema/src/recordsWithProps.test.ts
Normal file
188
packages/tlschema/src/recordsWithProps.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
276
packages/tlschema/src/recordsWithProps.ts
Normal file
276
packages/tlschema/src/recordsWithProps.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
74
packages/tlschema/src/shapes/ShapeWithCrop.ts
Normal file
74
packages/tlschema/src/shapes/ShapeWithCrop.ts
Normal 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 }>
|
||||
472
packages/tlschema/src/shapes/TLArrowShape.ts
Normal file
472
packages/tlschema/src/shapes/TLArrowShape.ts
Normal 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
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
181
packages/tlschema/src/shapes/TLBaseShape.ts
Normal file
181
packages/tlschema/src/shapes/TLBaseShape.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
112
packages/tlschema/src/shapes/TLBookmarkShape.ts
Normal file
112
packages/tlschema/src/shapes/TLBookmarkShape.ts
Normal 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
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
291
packages/tlschema/src/shapes/TLDrawShape.ts
Normal file
291
packages/tlschema/src/shapes/TLDrawShape.ts
Normal 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),
|
||||
}))
|
||||
}
|
||||
386
packages/tlschema/src/shapes/TLEmbedShape.ts
Normal file
386
packages/tlschema/src/shapes/TLEmbedShape.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
})
|
||||
106
packages/tlschema/src/shapes/TLFrameShape.ts
Normal file
106
packages/tlschema/src/shapes/TLFrameShape.ts
Normal 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
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
342
packages/tlschema/src/shapes/TLGeoShape.ts
Normal file
342
packages/tlschema/src/shapes/TLGeoShape.ts
Normal 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
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
64
packages/tlschema/src/shapes/TLGroupShape.ts
Normal file
64
packages/tlschema/src/shapes/TLGroupShape.ts
Normal 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: [] })
|
||||
204
packages/tlschema/src/shapes/TLHighlightShape.ts
Normal file
204
packages/tlschema/src/shapes/TLHighlightShape.ts
Normal 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
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
207
packages/tlschema/src/shapes/TLImageShape.ts
Normal file
207
packages/tlschema/src/shapes/TLImageShape.ts
Normal 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
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
305
packages/tlschema/src/shapes/TLLineShape.ts
Normal file
305
packages/tlschema/src/shapes/TLLineShape.ts
Normal 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
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
308
packages/tlschema/src/shapes/TLNoteShape.ts
Normal file
308
packages/tlschema/src/shapes/TLNoteShape.ts
Normal 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
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
185
packages/tlschema/src/shapes/TLTextShape.ts
Normal file
185
packages/tlschema/src/shapes/TLTextShape.ts
Normal 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
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
179
packages/tlschema/src/shapes/TLVideoShape.ts
Normal file
179
packages/tlschema/src/shapes/TLVideoShape.ts
Normal 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
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
88
packages/tlschema/src/store-migrations.test.ts
Normal file
88
packages/tlschema/src/store-migrations.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
156
packages/tlschema/src/store-migrations.ts
Normal file
156
packages/tlschema/src/store-migrations.ts
Normal 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('')
|
||||
}
|
||||
163
packages/tlschema/src/styles/StyleProp.ts
Normal file
163
packages/tlschema/src/styles/StyleProp.ts
Normal 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
|
||||
101
packages/tlschema/src/styles/TLColorStyle.ts
Normal file
101
packages/tlschema/src/styles/TLColorStyle.ts
Normal 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.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
packages/tlschema/src/styles/TLDashStyle.ts
Normal file
63
packages/tlschema/src/styles/TLDashStyle.ts
Normal 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>
|
||||
63
packages/tlschema/src/styles/TLFillStyle.ts
Normal file
63
packages/tlschema/src/styles/TLFillStyle.ts
Normal 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>
|
||||
65
packages/tlschema/src/styles/TLFontFace.ts
Normal file
65
packages/tlschema/src/styles/TLFontFace.ts
Normal 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
|
||||
}
|
||||
137
packages/tlschema/src/styles/TLFontStyle.ts
Normal file
137
packages/tlschema/src/styles/TLFontStyle.ts
Normal 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.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
packages/tlschema/src/styles/TLHorizontalAlignStyle.ts
Normal file
64
packages/tlschema/src/styles/TLHorizontalAlignStyle.ts
Normal 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>
|
||||
63
packages/tlschema/src/styles/TLSizeStyle.ts
Normal file
63
packages/tlschema/src/styles/TLSizeStyle.ts
Normal 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>
|
||||
61
packages/tlschema/src/styles/TLTextAlignStyle.ts
Normal file
61
packages/tlschema/src/styles/TLTextAlignStyle.ts
Normal 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>
|
||||
261
packages/tlschema/src/styles/TLTheme.ts
Normal file
261
packages/tlschema/src/styles/TLTheme.ts
Normal 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
|
||||
61
packages/tlschema/src/styles/TLVerticalAlignStyle.ts
Normal file
61
packages/tlschema/src/styles/TLVerticalAlignStyle.ts
Normal 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>
|
||||
55
packages/tlschema/src/translations/languages.ts
Normal file
55
packages/tlschema/src/translations/languages.ts
Normal 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
|
||||
386
packages/tlschema/src/translations/translations.test.ts
Normal file
386
packages/tlschema/src/translations/translations.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
220
packages/tlschema/src/translations/translations.ts
Normal file
220
packages/tlschema/src/translations/translations.ts
Normal 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
|
||||
}
|
||||
52
packages/tlschema/src/util-types.ts
Normal file
52
packages/tlschema/src/util-types.ts
Normal 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
|
||||
Reference in New Issue
Block a user