[feature] add meta property to records (#1627)

This PR adds a `meta` property to shapes and other records.

It adds it to:
- asset
- camera
- document
- instance
- instancePageState
- instancePresence
- page
- pointer
- rootShape

## Setting meta

This data can generally be added wherever you would normally update the
corresponding record.

An exception exists for shapes, which can be updated using a partial of
the `meta` in the same way that we update shapes with a partial of
`props`.

```ts
this.updateShapes([{
    id: myShape.id,
    type: "geo",
    meta: { 
      nemesis: "steve",
      special: true
    }
])
```

## `Editor.getInitialMetaForShape`

The `Editor.getInitialMetaForShape` method is kind of a hack to set the
initial meta property for newly created shapes. You can set it
externally. Escape hatch!

### Change Type

- [x] `minor` — New feature

### Test Plan

todo

- [ ] Unit Tests (todo)

### Release Notes

- todo
This commit is contained in:
Steve Ruiz 2023-06-28 15:24:05 +01:00 committed by GitHub
parent 3e07f70440
commit fd29006538
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 594 additions and 95 deletions

View file

@ -179,7 +179,7 @@ test.describe('Export snapshots', () => {
.selectAll()
.deleteShapes()
.createShapes(shapes)
}, shapes)
}, shapes as any)
const downloadEvent = page.waitForEvent('download')
await page.click('[data-testid="main.menu"]')

View file

@ -0,0 +1,49 @@
import { TLShape, Tldraw, track, useEditor } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
export default function ShapeMetaExample() {
return (
<div className="tldraw__editor">
<Tldraw
persistenceKey="shape_meta_example"
autoFocus
onMount={(editor) => {
editor.getInitialMetaForShape = (shape) => {
return { label: `My ${shape.type} shape` }
}
}}
>
<ShapeLabelUiWithHelper />
</Tldraw>
</div>
)
}
// By default, the TLShape type's meta property is { [key: string]: any }, but we can type it
// by unioning the type with a new type that has a meta property of our choosing.
type ShapeWithMyMeta = TLShape & { meta: { label: string } }
export const ShapeLabelUiWithHelper = track(function ShapeLabelUiWithHelper() {
const editor = useEditor()
const onlySelectedShape = editor.onlySelectedShape as ShapeWithMyMeta | null
if (!onlySelectedShape) {
return null
}
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
if (onlySelectedShape) {
const { id, type, meta } = onlySelectedShape
editor.updateShapes<ShapeWithMyMeta>([
{ id, type, meta: { ...meta, label: e.currentTarget.value } },
])
}
}
return (
<div style={{ position: 'absolute', zIndex: 300, top: 64, left: 12 }}>
shape label: <input value={onlySelectedShape.meta.label} onChange={onChange} />
</div>
)
})

View file

@ -16,6 +16,7 @@ import StoreEventsExample from './13-store-events/StoreEventsExample'
import PersistenceExample from './14-persistence/PersistenceExample'
import ZonesExample from './15-custom-zones/ZonesExample'
import CustomStylesExample from './16-custom-styles/CustomStylesExample'
import ShapeMetaExample from './17-shape-meta/ShapeMetaExample'
import ExampleApi from './2-api/APIExample'
import CustomConfigExample from './3-custom-config/CustomConfigExample'
import CustomUiExample from './4-custom-ui/CustomUiExample'
@ -113,6 +114,10 @@ export const allExamples: Example[] = [
path: '/custom-styles',
element: <CustomStylesExample />,
},
{
path: '/shape-meta',
element: <ShapeMetaExample />,
},
]
const router = createBrowserRouter(allExamples)

View file

@ -17,6 +17,7 @@ export async function onCreateAssetFromUrl(editor: Editor, url: string): Promise
image: meta.image ?? '',
title: meta.title ?? truncateStringWithEllipsis(url, 32),
},
meta: {},
}
} catch (error) {
// Otherwise, fallback to fetching data from the url
@ -51,6 +52,7 @@ export async function onCreateAssetFromUrl(editor: Editor, url: string): Promise
title: meta.title,
description: meta.description,
},
meta: {},
}
}
}

View file

@ -224,6 +224,7 @@ editor.updateShapes([
])
```
### Delete shapes
```ts
@ -244,6 +245,46 @@ editor.getShapeById(myShapeId)
editor.setCamera(0, 0, 1)
```
## Meta
Shapes have a `meta` property that you can fill with your own data. This should feel like a bit of a hack, however it's intended to be an escape hatch for applications where you want to use tldraw's shapes but also want to attach a bit of extra data to the shape.
Note that tldraw's regular shape definitions have an unknown object for the shape's `meta` property. To type your shape's meta, use a union like this:
```ts
type MyShapeWithMeta = TLGeoShape & { meta: { createdBy: string } }
const shape = editor.getShapeById<MyShapeWithMeta>(myGeoShape.id)
```
You can update a shape's `meta` property in the same way you would update its props, using `Editor.updateShapes`.
```ts
editor.updateShapes<MyShapeWithMeta>([{
id: myGeoShape.id,
type: "geo",
meta: {
createdBy: "Steve"
}
}])
```
Like `props`, the data in a shape's `meta` object must be JSON serializable.
In addition to setting meta properties this way, you can also set the default meta data for shapes using the Editor's `getShapeInitialMeta` method.
```tsx
editor.getShapeInitialMeta = (shape: TLShape) => {
if (shape.type === 'text') {
return { createdBy: currentUser.id, lastModified: Date.now() }
} else {
return { createdBy: currentUser.id }
}
}
```
Whenever new shapes are created using the `createShapes` method, the shape's meta property will be set using the `getShapeInitialMeta` method. By default this method returns an empty object.
---
See the [tldraw repository](https://github.com/tldraw/tldraw/tree/main/apps/examples) for an example of how to use tldraw's Editor API to control the editor.

View file

@ -20,6 +20,7 @@ import { EmbedDefinition } from '@tldraw/tlschema';
import { EventEmitter } from 'eventemitter3';
import { getHashForString } from '@tldraw/utils';
import { HistoryEntry } from '@tldraw/store';
import { JsonObject } from '@tldraw/utils';
import { MatLike } from '@tldraw/primitives';
import { Matrix2d } from '@tldraw/primitives';
import { Matrix2dModel } from '@tldraw/primitives';
@ -478,6 +479,7 @@ export class Editor extends EventEmitter<TLEventMap> {
getHandles<T extends TLShape>(shape: T): TLHandle[] | undefined;
getHandlesById<T extends TLShape>(id: T['id']): TLHandle[] | undefined;
getHighestIndexForParent(parentId: TLPageId | TLShapeId): string;
getInitialMetaForShape(_shape: TLShape): JsonObject;
getMaskedPageBounds(shape: TLShape): Box2d | undefined;
getMaskedPageBoundsById(id: TLShapeId): Box2d | undefined;
getOutermostSelectableShape(shape: TLShape, filter?: (shape: TLShape) => boolean): TLShape;
@ -886,6 +888,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
parentId: TLParentId;
isLocked: boolean;
opacity: number;
meta: JsonObject;
id: TLShapeId;
typeName: "shape";
} | undefined;
@ -915,6 +918,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
parentId: TLParentId;
isLocked: boolean;
opacity: number;
meta: JsonObject;
id: TLShapeId;
typeName: "shape";
} | undefined;
@ -931,6 +935,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
parentId: TLParentId;
isLocked: boolean;
opacity: number;
meta: JsonObject;
id: TLShapeId;
typeName: "shape";
} | {
@ -945,6 +950,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
parentId: TLParentId;
isLocked: boolean;
opacity: number;
meta: JsonObject;
id: TLShapeId;
typeName: "shape";
} | undefined;
@ -1711,6 +1717,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
parentId: TLParentId;
isLocked: boolean;
opacity: number;
meta: JsonObject;
id: TLShapeId;
typeName: "shape";
} | undefined;
@ -1734,6 +1741,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
parentId: TLParentId;
isLocked: boolean;
opacity: number;
meta: JsonObject;
id: TLShapeId;
typeName: "shape";
} | undefined;
@ -2101,6 +2109,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
scale: number;
autoSize: boolean;
};
meta: JsonObject;
id: TLShapeId;
typeName: "shape";
} | undefined;
@ -2124,6 +2133,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
parentId: TLParentId;
isLocked: boolean;
opacity: number;
meta: JsonObject;
id: TLShapeId;
typeName: "shape";
} | undefined;

View file

@ -148,7 +148,7 @@ const InnerShape = React.memo(
function InnerShape<T extends TLShape>({ shape, util }: { shape: T; util: ShapeUtil<T> }) {
return useStateTracking('InnerShape:' + util.type, () => util.component(shape))
},
(prev, next) => prev.shape.props === next.shape.props
(prev, next) => prev.shape.props === next.shape.props || prev.shape.meta === next.shape.meta
)
const InnerShapeBackground = React.memo(
@ -161,7 +161,7 @@ const InnerShapeBackground = React.memo(
}) {
return useStateTracking('InnerShape:' + util.type, () => util.backgroundComponent?.(shape))
},
(prev, next) => prev.shape.props === next.shape.props
(prev, next) => prev.shape.props === next.shape.props || prev.shape.meta === next.shape.meta
)
const CulledShape = React.memo(

View file

@ -11,7 +11,7 @@ import { OptionalErrorBoundary } from './ErrorBoundary'
class ShapeWithPropsEquality {
constructor(public shape: TLShape | undefined) {}
equals(other: ShapeWithPropsEquality) {
return this.shape?.props === other?.shape?.props
return this.shape?.props === other?.shape?.props || this.shape?.meta === other?.shape?.meta
}
}

View file

@ -65,6 +65,7 @@ import {
isShapeId,
} from '@tldraw/tlschema'
import {
JsonObject,
annotateError,
assert,
compact,
@ -3971,6 +3972,7 @@ export class Editor extends EventEmitter<TLEventMap> {
bottomIndex && topIndex !== bottomIndex
? getIndexBetween(topIndex, bottomIndex)
: getIndexAbove(topIndex),
meta: {},
})
const newCamera = CameraRecordType.create({
@ -7094,6 +7096,26 @@ export class Editor extends EventEmitter<TLEventMap> {
return destination
}
/**
* Get the initial meta value for a shape.
*
* @example
* ```ts
* editor.getInitialMetaForShape = (shape) => {
* if (shape.type === 'note') {
* return { createdBy: myCurrentUser.id }
* }
* }
* ```
*
* @param shape - The shape to get the initial meta for.
*
* @public
*/
getInitialMetaForShape(_shape: TLShape): JsonObject {
return {}
}
/**
* Create shapes.
*
@ -7253,6 +7275,11 @@ export class Editor extends EventEmitter<TLEventMap> {
shapeRecordsToCreate.push(shapeRecordToCreate)
}
// Add meta properties, if any, to the shapes
shapeRecordsToCreate.forEach((shape) => {
shape.meta = this.getInitialMetaForShape(shape)
})
this.store.put(shapeRecordsToCreate)
// If we're also selecting the newly created shapes, attempt to select all of them;
@ -7564,9 +7591,7 @@ export class Editor extends EventEmitter<TLEventMap> {
switch (k) {
case 'id':
case 'type':
case 'typeName': {
continue
}
default: {
if (v !== (prev as any)[k]) {
if (!newRecord) {
@ -7574,13 +7599,25 @@ export class Editor extends EventEmitter<TLEventMap> {
}
if (k === 'props') {
const nextProps = { ...prev.props } as Record<string, unknown>
// props property
const nextProps = { ...prev.props } as JsonObject
for (const [propKey, propValue] of Object.entries(v as object)) {
if (propValue === undefined) continue
if (propValue !== undefined) {
nextProps[propKey] = propValue
}
}
newRecord!.props = nextProps
} else if (k === 'meta') {
// meta property
const nextMeta = { ...prev.meta } as JsonObject
for (const [metaKey, metaValue] of Object.entries(v as object)) {
if (metaValue !== undefined) {
nextMeta[metaKey] = metaValue
}
}
newRecord!.meta = nextMeta
} else {
// base property
;(newRecord as any)[k] = v
}
}
@ -8802,6 +8839,7 @@ export class Editor extends EventEmitter<TLEventMap> {
info.type === 'pointer' && info.pointerId === INTERNAL_POINTER_IDS.CAMERA_MOVE
? this.store.get(TLPOINTER_ID)?.lastActivityTimestamp ?? Date.now()
: Date.now(),
meta: {},
},
])
}

View file

@ -500,6 +500,7 @@ export class ExternalContentManager {
mimeType: file.type,
isAnimated: metadata.isAnimated,
},
meta: {},
}
resolve(asset)
@ -546,6 +547,7 @@ export class ExternalContentManager {
image: meta.image,
title: meta.title,
},
meta: {},
}
}
}

View file

@ -5,6 +5,7 @@ Object {
"id": "shape:line1",
"index": "a1",
"isLocked": false,
"meta": Object {},
"opacity": 1,
"parentId": "page:id50",
"props": Object {

View file

@ -6,6 +6,7 @@ Array [
"id": "shape:boxA",
"index": "a1",
"isLocked": false,
"meta": Object {},
"opacity": 1,
"parentId": "wahtever",
"props": Object {
@ -34,6 +35,7 @@ Array [
"id": "shape:boxB",
"index": "a2",
"isLocked": false,
"meta": Object {},
"opacity": 1,
"parentId": "wahtever",
"props": Object {
@ -62,6 +64,7 @@ Array [
"id": "shape:boxC",
"index": "a3",
"isLocked": false,
"meta": Object {},
"opacity": 1,
"parentId": "wahtever",
"props": Object {
@ -95,6 +98,7 @@ Array [
"id": "shape:boxA",
"index": "a1",
"isLocked": false,
"meta": Object {},
"opacity": 1,
"parentId": "wahtever",
"props": Object {
@ -123,6 +127,7 @@ Array [
"id": "shape:boxB",
"index": "a2",
"isLocked": false,
"meta": Object {},
"opacity": 1,
"parentId": "wahtever",
"props": Object {
@ -151,6 +156,7 @@ Array [
"id": "shape:boxC",
"index": "a3",
"isLocked": false,
"meta": Object {},
"opacity": 1,
"parentId": "wahtever",
"props": Object {

View file

@ -3,6 +3,7 @@
exports[`converts correctly: Zoom to Fit Camera 1`] = `
Object {
"id": "static",
"meta": Object {},
"typeName": "camera",
"x": 330.435496777593,
"y": 22.261531457640388,

View file

@ -0,0 +1,19 @@
import { createShapeId } from '@tldraw/tlschema'
import { TestEditor } from '../TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
it('Sets shape meta by default to an empty object', () => {
editor.createShapes([{ id: createShapeId(), type: 'geo' }], true)
expect(editor.onlySelectedShape!.meta).toStrictEqual({})
})
it('Sets shape meta', () => {
editor.getInitialMetaForShape = (shape) => ({ firstThreeCharactersOfId: shape.id.slice(0, 3) })
editor.createShapes([{ id: createShapeId(), type: 'geo' }], true)
expect(editor.onlySelectedShape!.meta).toStrictEqual({ firstThreeCharactersOfId: 'sha' })
})

View file

@ -5,6 +5,7 @@ Object {
"id": "shape:lineA",
"index": "a3",
"isLocked": false,
"meta": Object {},
"opacity": 1,
"parentId": "shape:boxA",
"props": Object {

View file

@ -170,6 +170,7 @@ export async function getMediaAssetFromFile(file: File): Promise<TLAsset> {
mimeType: file.type,
isAnimated: metadata.isAnimated,
},
meta: {},
}
resolve(asset)

View file

@ -70,6 +70,7 @@ export function buildFromV1Document(editor: Editor, document: LegacyTldrawDocume
mimeType: null,
src: v1Asset.src,
},
meta: {},
}
editor.createAssets([placeholderAsset])
tryMigrateAsset(editor, placeholderAsset)
@ -92,6 +93,7 @@ export function buildFromV1Document(editor: Editor, document: LegacyTldrawDocume
mimeType: null,
src: v1Asset.src,
},
meta: {},
},
])
}

View file

@ -6,6 +6,7 @@
import { BaseRecord } from '@tldraw/store';
import { Expand } from '@tldraw/utils';
import { JsonObject } from '@tldraw/utils';
import { Migrations } from '@tldraw/store';
import { RecordId } from '@tldraw/store';
import { RecordType } from '@tldraw/store';
@ -113,11 +114,12 @@ export const canvasUiColorTypeValidator: T.Validator<"accent" | "black" | "laser
export function CLIENT_FIXUP_SCRIPT(persistedStore: SerializedStore<TLRecord>): SerializedStore<TLRecord>;
// @public
export function createAssetValidator<Type extends string, Props extends object>(type: Type, props: T.Validator<Props>): T.ObjectValidator<{
export function createAssetValidator<Type extends string, Props extends JsonObject>(type: Type, props: T.Validator<Props>): T.ObjectValidator<{
id: TLAssetId;
typeName: 'asset';
type: Type;
props: Props;
meta: JsonObject;
}>;
// @public (undocumented)
@ -131,21 +133,11 @@ export const createPresenceStateDerivation: ($user: Signal<{
export function createShapeId(id?: string): TLShapeId;
// @public (undocumented)
export function createShapeValidator<Type extends string, Props extends object>(type: Type, props?: {
export function createShapeValidator<Type extends string, Props extends JsonObject, Meta extends JsonObject>(type: Type, props?: {
[K in keyof Props]: T.Validatable<Props[K]>;
}): T.ObjectValidator<{
id: TLShapeId;
typeName: "shape";
x: number;
y: number;
rotation: number;
index: string;
parentId: TLParentId;
type: Type;
isLocked: boolean;
opacity: number;
props: Props | Record<string, unknown>;
}>;
}, meta?: {
[K in keyof Meta]: T.Validatable<Meta[K]>;
}): T.ObjectValidator<TLBaseShape<Type, Props>>;
// @public
export function createTLSchema({ shapes }: {
@ -710,6 +702,9 @@ export type SchemaShapeInfo = {
props?: Record<string, {
validate: (prop: any) => any;
}>;
meta?: Record<string, {
validate: (prop: any) => any;
}>;
};
// @internal (undocumented)
@ -786,7 +781,8 @@ export type TLAssetPartial<T extends TLAsset = TLAsset> = T extends T ? {
id: TLAssetId;
type: T['type'];
props?: Partial<T['props']>;
} & Partial<Omit<T, 'id' | 'props' | 'type'>> : never;
meta?: Partial<T['meta']>;
} & Partial<Omit<T, 'id' | 'meta' | 'props' | 'type'>> : never;
// @public (undocumented)
export type TLAssetShape = Extract<TLShape, {
@ -797,6 +793,8 @@ export type TLAssetShape = Extract<TLShape, {
// @public (undocumented)
export interface TLBaseAsset<Type extends string, Props> extends BaseRecord<'asset', TLAssetId> {
// (undocumented)
meta: JsonObject;
// (undocumented)
props: Props;
// (undocumented)
@ -810,6 +808,8 @@ export interface TLBaseShape<Type extends string, Props extends object> extends
// (undocumented)
isLocked: boolean;
// (undocumented)
meta: JsonObject;
// (undocumented)
opacity: TLOpacityType;
// (undocumented)
parentId: TLParentId;
@ -838,6 +838,8 @@ export type TLBookmarkShape = TLBaseShape<'bookmark', TLBookmarkShapeProps>;
// @public
export interface TLCamera extends BaseRecord<'camera', TLCameraId> {
// (undocumented)
meta: JsonObject;
// (undocumented)
x: number;
// (undocumented)
@ -912,6 +914,8 @@ export interface TLDocument extends BaseRecord<'document', RecordId<TLDocument>>
// (undocumented)
gridSize: number;
// (undocumented)
meta: JsonObject;
// (undocumented)
name: string;
}
@ -1010,6 +1014,8 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
// (undocumented)
isToolLocked: boolean;
// (undocumented)
meta: JsonObject;
// (undocumented)
opacityForNextShape: TLOpacityType;
// (undocumented)
screenBounds: Box2dModel;
@ -1042,6 +1048,8 @@ export interface TLInstancePageState extends BaseRecord<'instance_page_state', T
// (undocumented)
hoveredId: null | TLShapeId;
// (undocumented)
meta: JsonObject;
// (undocumented)
pageId: RecordId<TLPage>;
// (undocumented)
selectedIds: TLShapeId[];
@ -1075,6 +1083,8 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn
// (undocumented)
lastActivityTimestamp: number;
// (undocumented)
meta: JsonObject;
// (undocumented)
screenBounds: Box2dModel;
// (undocumented)
scribble: null | TLScribble;
@ -1103,6 +1113,8 @@ export interface TLPage extends BaseRecord<'page', TLPageId> {
// (undocumented)
index: string;
// (undocumented)
meta: JsonObject;
// (undocumented)
name: string;
}
@ -1145,7 +1157,8 @@ export type TLShapePartial<T extends TLShape = TLShape> = T extends T ? {
id: TLShapeId;
type: T['type'];
props?: Partial<T['props']>;
} & Partial<Omit<T, 'id' | 'props' | 'type'>> : never;
meta?: Partial<T['meta']>;
} & Partial<Omit<T, 'id' | 'meta' | 'props' | 'type'>> : never;
// @public (undocumented)
export type TLShapeProp = keyof TLShapeProps;

View file

@ -74,7 +74,7 @@ export const onValidationFailure: StoreSchemaOptions<
}
function getDefaultPages() {
return [PageRecordType.create({ name: 'Page 1', index: 'a1' })]
return [PageRecordType.create({ name: 'Page 1', index: 'a1', meta: {} })]
}
/** @internal */

View file

@ -1,4 +1,5 @@
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'
@ -7,6 +8,7 @@ import { TLAssetId } from '../records/TLAsset'
export interface TLBaseAsset<Type extends string, Props> extends BaseRecord<'asset', TLAssetId> {
type: Type
props: Props
meta: JsonObject
}
/**
@ -22,7 +24,7 @@ export const assetIdValidator = idValidator<TLAssetId>('asset')
* @param props - The validator for the asset's props
*
* @public */
export function createAssetValidator<Type extends string, Props extends object>(
export function createAssetValidator<Type extends string, Props extends JsonObject>(
type: Type,
props: T.Validator<Props>
): T.ObjectValidator<{
@ -30,11 +32,13 @@ export function createAssetValidator<Type extends string, Props extends object>(
typeName: 'asset'
type: Type
props: Props
meta: JsonObject
}> {
return T.object({
id: assetIdValidator,
typeName: T.literal('asset'),
type: T.literal(type),
props,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
}

View file

@ -47,6 +47,7 @@ export const createPresenceStateDerivation =
lastActivityTimestamp: pointer.lastActivityTimestamp,
screenBounds: instance.screenBounds,
chatMessage: instance.chatMessage,
meta: {},
})
})
}

View file

@ -18,6 +18,7 @@ import { StyleProp } from './styles/StyleProp'
export type SchemaShapeInfo = {
migrations?: Migrations
props?: Record<string, { validate: (prop: any) => any }>
meta?: Record<string, { validate: (prop: any) => any }>
}
/** @public */

View file

@ -2,11 +2,15 @@ import { Migrations, Store, createRecordType } from '@tldraw/store'
import fs from 'fs'
import { imageAssetMigrations } from './assets/TLImageAsset'
import { videoAssetMigrations } from './assets/TLVideoAsset'
import { documentMigrations } from './records/TLDocument'
import { instanceMigrations, instanceTypeVersions } from './records/TLInstance'
import { assetMigrations, assetVersions } from './records/TLAsset'
import { cameraMigrations, cameraVersions } from './records/TLCamera'
import { documentMigrations, documentVersions } from './records/TLDocument'
import { instanceMigrations, instanceVersions } from './records/TLInstance'
import { pageMigrations, pageVersions } from './records/TLPage'
import { instancePageStateMigrations, instancePageStateVersions } from './records/TLPageState'
import { pointerMigrations, pointerVersions } from './records/TLPointer'
import { instancePresenceMigrations, instancePresenceVersions } from './records/TLPresence'
import { TLShape, rootShapeMigrations, Versions as rootShapeVersions } from './records/TLShape'
import { TLShape, rootShapeMigrations, rootShapeVersions } from './records/TLShape'
import { arrowShapeMigrations } from './shapes/TLArrowShape'
import { bookmarkShapeMigrations } from './shapes/TLBookmarkShape'
import { drawShapeMigrations } from './shapes/TLDrawShape'
@ -896,7 +900,7 @@ describe('user config refactor', () => {
})
test('removes userId from the instance state', () => {
const { up, down } = instanceMigrations.migrators[instanceTypeVersions.RemoveUserId]
const { up, down } = instanceMigrations.migrators[instanceVersions.RemoveUserId]
const prev = {
id: 'instance:123',
@ -924,8 +928,7 @@ describe('user config refactor', () => {
describe('making instance state independent', () => {
it('adds isPenMode and isGridMode to instance state', () => {
const { up, down } =
instanceMigrations.migrators[instanceTypeVersions.AddIsPenModeAndIsGridMode]
const { up, down } = instanceMigrations.migrators[instanceVersions.AddIsPenModeAndIsGridMode]
const prev = {
id: 'instance:123',
@ -1081,7 +1084,7 @@ describe('hoist opacity', () => {
})
test('hoists opacity from propsForNextShape', () => {
const { up, down } = instanceMigrations.migrators[instanceTypeVersions.HoistOpacity]
const { up, down } = instanceMigrations.migrators[instanceVersions.HoistOpacity]
const before = {
isToolLocked: true,
propsForNextShape: {
@ -1111,7 +1114,7 @@ describe('hoist opacity', () => {
})
describe('Adds highlightedUserIds to instance', () => {
const { up, down } = instanceMigrations.migrators[instanceTypeVersions.AddHighlightedUserIds]
const { up, down } = instanceMigrations.migrators[instanceVersions.AddHighlightedUserIds]
test('up works as expected', () => {
expect(up({})).toEqual({ highlightedUserIds: [] })
@ -1194,9 +1197,7 @@ describe('Removes overridePermissions from embed', () => {
describe('propsForNextShape -> stylesForNextShape', () => {
test('deletes propsForNextShape and adds stylesForNextShape without trying to bring across contents', () => {
const { up, down } =
instanceMigrations.migrators[
instanceTypeVersions.ReplacePropsForNextShapeWithStylesForNextShape
]
instanceMigrations.migrators[instanceVersions.ReplacePropsForNextShapeWithStylesForNextShape]
const beforeUp = {
isToolLocked: true,
propsForNextShape: {
@ -1232,6 +1233,30 @@ describe('propsForNextShape -> stylesForNextShape', () => {
})
})
describe('adds meta ', () => {
const metaMigrations = [
assetMigrations.migrators[assetVersions.AddMeta],
cameraMigrations.migrators[cameraVersions.AddMeta],
documentMigrations.migrators[documentVersions.AddMeta],
instanceMigrations.migrators[instanceVersions.AddMeta],
instancePageStateMigrations.migrators[instancePageStateVersions.AddMeta],
instancePresenceMigrations.migrators[instancePresenceVersions.AddMeta],
pageMigrations.migrators[pageVersions.AddMeta],
pointerMigrations.migrators[pointerVersions.AddMeta],
rootShapeMigrations.migrators[rootShapeVersions.AddMeta],
]
for (const { up, down } of metaMigrations) {
test('up works as expected', () => {
expect(up({})).toStrictEqual({ meta: {} })
})
test('down works as expected', () => {
expect(down({ meta: {} })).toStrictEqual({})
})
}
})
/* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
for (const migrator of allMigrators) {

View file

@ -23,6 +23,11 @@ export const assetValidator: T.Validator<TLAsset> = T.model(
})
)
/** @internal */
export const assetVersions = {
AddMeta: 1,
}
/** @internal */
export const assetMigrations = defineMigrations({
subTypeKey: 'type',
@ -31,6 +36,22 @@ export const assetMigrations = defineMigrations({
video: videoAssetMigrations,
bookmark: bookmarkAssetMigrations,
},
currentVersion: assetVersions.AddMeta,
migrators: {
[assetVersions.AddMeta]: {
up: (record) => {
return {
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
},
},
},
})
/** @public */
@ -39,7 +60,8 @@ export type TLAssetPartial<T extends TLAsset = TLAsset> = T extends T
id: TLAssetId
type: T['type']
props?: Partial<T['props']>
} & Partial<Omit<T, 'type' | 'id' | 'props'>>
meta?: Partial<T['meta']>
} & Partial<Omit<T, 'type' | 'id' | 'props' | 'meta'>>
: never
/** @public */
@ -47,7 +69,9 @@ export const AssetRecordType = createRecordType<TLAsset>('asset', {
migrations: assetMigrations,
validator: assetValidator,
scope: 'document',
})
}).withDefaultProperties(() => ({
meta: {},
}))
/** @public */
export type TLAssetId = RecordId<TLBaseAsset<any, any>>

View file

@ -1,4 +1,5 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
@ -11,6 +12,7 @@ export interface TLCamera extends BaseRecord<'camera', TLCameraId> {
x: number
y: number
z: number
meta: JsonObject
}
/**
@ -28,11 +30,34 @@ export const cameraValidator: T.Validator<TLCamera> = T.model(
x: T.number,
y: T.number,
z: T.number,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
/** @internal */
export const cameraMigrations = defineMigrations({})
export const cameraVersions = {
AddMeta: 1,
}
/** @internal */
export const cameraMigrations = defineMigrations({
currentVersion: cameraVersions.AddMeta,
migrators: {
[cameraVersions.AddMeta]: {
up: (record) => {
return {
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
},
},
},
})
/** @public */
export const CameraRecordType = createRecordType<TLCamera>('camera', {
@ -44,5 +69,6 @@ export const CameraRecordType = createRecordType<TLCamera>('camera', {
x: 0,
y: 0,
z: 1,
meta: {},
})
)

View file

@ -1,4 +1,5 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
/**
@ -9,6 +10,7 @@ import { T } from '@tldraw/validate'
export interface TLDocument extends BaseRecord<'document', RecordId<TLDocument>> {
gridSize: number
name: string
meta: JsonObject
}
/** @internal */
@ -19,18 +21,21 @@ export const documentValidator: T.Validator<TLDocument> = T.model(
id: T.literal('document:document' as RecordId<TLDocument>),
gridSize: T.number,
name: T.string,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
const Versions = {
/** @internal */
export const documentVersions = {
AddName: 1,
AddMeta: 2,
} as const
/** @internal */
export const documentMigrations = defineMigrations({
currentVersion: Versions.AddName,
currentVersion: documentVersions.AddMeta,
migrators: {
[Versions.AddName]: {
[documentVersions.AddName]: {
up: (document: TLDocument) => {
return { ...document, name: '' }
},
@ -38,6 +43,19 @@ export const documentMigrations = defineMigrations({
return document
},
},
[documentVersions.AddMeta]: {
up: (record) => {
return {
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
},
},
},
})
@ -50,6 +68,7 @@ export const DocumentRecordType = createRecordType<TLDocument>('document', {
(): Omit<TLDocument, 'id' | 'typeName'> => ({
gridSize: 10,
name: '',
meta: {},
})
)

View file

@ -1,4 +1,5 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { Box2dModel, box2dModelValidator } from '../misc/geometry-types'
import { idValidator } from '../misc/id-validator'
@ -34,6 +35,7 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
isChatting: boolean
isPenMode: boolean
isGridMode: boolean
meta: JsonObject
}
/** @public */
@ -71,6 +73,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
chatMessage: T.string,
isChatting: T.boolean,
highlightedUserIds: T.arrayOf(T.string),
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
@ -101,11 +104,13 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
chatMessage: '',
isChatting: false,
highlightedUserIds: [],
meta: {},
})
)
}
const Versions = {
/** @internal */
export const instanceVersions = {
AddTransparentExportBgs: 1,
RemoveDialog: 2,
AddToolLockMode: 3,
@ -122,15 +127,14 @@ const Versions = {
AddChat: 14,
AddHighlightedUserIds: 15,
ReplacePropsForNextShapeWithStylesForNextShape: 16,
AddMeta: 17,
} as const
export { Versions as instanceTypeVersions }
/** @public */
export const instanceMigrations = defineMigrations({
currentVersion: Versions.ReplacePropsForNextShapeWithStylesForNextShape,
currentVersion: instanceVersions.AddMeta,
migrators: {
[Versions.AddTransparentExportBgs]: {
[instanceVersions.AddTransparentExportBgs]: {
up: (instance: TLInstance) => {
return { ...instance, exportBackground: true }
},
@ -138,7 +142,7 @@ export const instanceMigrations = defineMigrations({
return instance
},
},
[Versions.RemoveDialog]: {
[instanceVersions.RemoveDialog]: {
up: ({ dialog: _, ...instance }: any) => {
return instance
},
@ -146,7 +150,7 @@ export const instanceMigrations = defineMigrations({
return { ...instance, dialog: null }
},
},
[Versions.AddToolLockMode]: {
[instanceVersions.AddToolLockMode]: {
up: (instance: TLInstance) => {
return { ...instance, isToolLocked: false }
},
@ -154,7 +158,7 @@ export const instanceMigrations = defineMigrations({
return instance
},
},
[Versions.RemoveExtraPropsForNextShape]: {
[instanceVersions.RemoveExtraPropsForNextShape]: {
up: ({ propsForNextShape, ...instance }: any) => {
return {
...instance,
@ -184,7 +188,7 @@ export const instanceMigrations = defineMigrations({
return instance
},
},
[Versions.AddLabelColor]: {
[instanceVersions.AddLabelColor]: {
up: ({ propsForNextShape, ...instance }: any) => {
return {
...instance,
@ -204,7 +208,7 @@ export const instanceMigrations = defineMigrations({
}
},
},
[Versions.AddFollowingUserId]: {
[instanceVersions.AddFollowingUserId]: {
up: (instance: TLInstance) => {
return { ...instance, followingUserId: null }
},
@ -212,7 +216,7 @@ export const instanceMigrations = defineMigrations({
return instance
},
},
[Versions.RemoveAlignJustify]: {
[instanceVersions.RemoveAlignJustify]: {
up: (instance: any) => {
let newAlign = instance.propsForNextShape.align
if (newAlign === 'justify') {
@ -231,7 +235,7 @@ export const instanceMigrations = defineMigrations({
return { ...instance }
},
},
[Versions.AddZoom]: {
[instanceVersions.AddZoom]: {
up: (instance: TLInstance) => {
return { ...instance, zoomBrush: null }
},
@ -239,7 +243,7 @@ export const instanceMigrations = defineMigrations({
return instance
},
},
[Versions.AddVerticalAlign]: {
[instanceVersions.AddVerticalAlign]: {
up: (instance) => {
return {
...instance,
@ -257,7 +261,7 @@ export const instanceMigrations = defineMigrations({
}
},
},
[Versions.AddScribbleDelay]: {
[instanceVersions.AddScribbleDelay]: {
up: (instance) => {
if (instance.scribble !== null) {
return { ...instance, scribble: { ...instance.scribble, delay: 0 } }
@ -272,7 +276,7 @@ export const instanceMigrations = defineMigrations({
return { ...instance }
},
},
[Versions.RemoveUserId]: {
[instanceVersions.RemoveUserId]: {
up: ({ userId: _, ...instance }: any) => {
return instance
},
@ -280,7 +284,7 @@ export const instanceMigrations = defineMigrations({
return { ...instance, userId: 'user:none' }
},
},
[Versions.AddIsPenModeAndIsGridMode]: {
[instanceVersions.AddIsPenModeAndIsGridMode]: {
up: (instance: TLInstance) => {
return { ...instance, isPenMode: false, isGridMode: false }
},
@ -288,7 +292,7 @@ export const instanceMigrations = defineMigrations({
return instance
},
},
[Versions.HoistOpacity]: {
[instanceVersions.HoistOpacity]: {
up: ({ propsForNextShape: { opacity, ...propsForNextShape }, ...instance }: any) => {
return { ...instance, opacityForNextShape: Number(opacity ?? '1'), propsForNextShape }
},
@ -311,7 +315,7 @@ export const instanceMigrations = defineMigrations({
}
},
},
[Versions.AddChat]: {
[instanceVersions.AddChat]: {
up: (instance: TLInstance) => {
return { ...instance, chatMessage: '', isChatting: false }
},
@ -319,7 +323,7 @@ export const instanceMigrations = defineMigrations({
return instance
},
},
[Versions.AddHighlightedUserIds]: {
[instanceVersions.AddHighlightedUserIds]: {
up: (instance: TLInstance) => {
return { ...instance, highlightedUserIds: [] }
},
@ -327,7 +331,7 @@ export const instanceMigrations = defineMigrations({
return instance
},
},
[Versions.ReplacePropsForNextShapeWithStylesForNextShape]: {
[instanceVersions.ReplacePropsForNextShapeWithStylesForNextShape]: {
up: ({ propsForNextShape: _, ...instance }) => {
return { ...instance, stylesForNextShape: {} }
},
@ -352,6 +356,19 @@ export const instanceMigrations = defineMigrations({
}
},
},
[instanceVersions.AddMeta]: {
up: (record) => {
return {
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
},
},
},
})

View file

@ -1,4 +1,5 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
@ -10,6 +11,7 @@ import { idValidator } from '../misc/id-validator'
export interface TLPage extends BaseRecord<'page', TLPageId> {
name: string
index: string
meta: JsonObject
}
/** @public */
@ -26,17 +28,43 @@ export const pageValidator: T.Validator<TLPage> = T.model(
id: pageIdValidator,
name: T.string,
index: T.string,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
/** @internal */
export const pageMigrations = defineMigrations({})
export const pageVersions = {
AddMeta: 1,
}
/** @internal */
export const pageMigrations = defineMigrations({
currentVersion: pageVersions.AddMeta,
migrators: {
[pageVersions.AddMeta]: {
up: (record) => {
return {
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
},
},
},
})
/** @public */
export const PageRecordType = createRecordType<TLPage>('page', {
validator: pageValidator,
migrations: pageMigrations,
scope: 'document',
})
}).withDefaultProperties(() => ({
meta: {},
}))
/** @public */
export function isPageId(id: string): id is TLPageId {

View file

@ -1,4 +1,5 @@
import { BaseRecord, createRecordType, defineMigrations, 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'
@ -24,6 +25,7 @@ export interface TLInstancePageState
editingId: TLShapeId | null
croppingId: TLShapeId | null
focusLayerId: TLShapeId | null
meta: JsonObject
}
/** @internal */
@ -40,22 +42,22 @@ export const instancePageStateValidator: T.Validator<TLInstancePageState> = T.mo
editingId: shapeIdValidator.nullable(),
croppingId: shapeIdValidator.nullable(),
focusLayerId: shapeIdValidator.nullable(),
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
const Versions = {
/** @internal */
export const instancePageStateVersions = {
AddCroppingId: 1,
RemoveInstanceIdAndCameraId: 2,
AddMeta: 3,
} as const
/** @internal */
export { Versions as instancePageStateVersions }
/** @public */
export const instancePageStateMigrations = defineMigrations({
currentVersion: Versions.RemoveInstanceIdAndCameraId,
currentVersion: instancePageStateVersions.AddMeta,
migrators: {
[Versions.AddCroppingId]: {
[instancePageStateVersions.AddCroppingId]: {
up(instance) {
return { ...instance, croppingId: null }
},
@ -63,7 +65,7 @@ export const instancePageStateMigrations = defineMigrations({
return instance
},
},
[Versions.RemoveInstanceIdAndCameraId]: {
[instancePageStateVersions.RemoveInstanceIdAndCameraId]: {
up({ instanceId: _, cameraId: __, ...instance }) {
return instance
},
@ -76,6 +78,19 @@ export const instancePageStateMigrations = defineMigrations({
}
},
},
[instancePageStateVersions.AddMeta]: {
up: (record) => {
return {
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
},
},
},
})
@ -96,6 +111,7 @@ export const InstancePageStateRecordType = createRecordType<TLInstancePageState>
erasingIds: [],
hintingIds: [],
focusLayerId: null,
meta: {},
})
)

View file

@ -1,4 +1,5 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
@ -11,6 +12,7 @@ export interface TLPointer extends BaseRecord<'pointer', TLPointerId> {
x: number
y: number
lastActivityTimestamp: number
meta: JsonObject
}
/** @public */
@ -25,11 +27,34 @@ export const pointerValidator: T.Validator<TLPointer> = T.model(
x: T.number,
y: T.number,
lastActivityTimestamp: T.number,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
/** @internal */
export const pointerMigrations = defineMigrations({})
export const pointerVersions = {
AddMeta: 1,
}
/** @internal */
export const pointerMigrations = defineMigrations({
currentVersion: pointerVersions.AddMeta,
migrators: {
[pointerVersions.AddMeta]: {
up: (record) => {
return {
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
},
},
},
})
/** @public */
export const PointerRecordType = createRecordType<TLPointer>('pointer', {
@ -41,6 +66,7 @@ export const PointerRecordType = createRecordType<TLPointer>('pointer', {
x: 0,
y: 0,
lastActivityTimestamp: 0,
meta: {},
})
)

View file

@ -1,4 +1,5 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store'
import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { Box2dModel, box2dModelValidator } from '../misc/geometry-types'
import { idValidator } from '../misc/id-validator'
@ -28,6 +29,7 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn
rotation: number
}
chatMessage: string
meta: JsonObject
}
/** @public */
@ -61,21 +63,22 @@ export const instancePresenceValidator: T.Validator<TLInstancePresence> = T.mode
brush: box2dModelValidator.nullable(),
scribble: scribbleValidator.nullable(),
chatMessage: T.string,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
const Versions = {
/** @internal */
export const instancePresenceVersions = {
AddScribbleDelay: 1,
RemoveInstanceId: 2,
AddChatMessage: 3,
AddMeta: 4,
} as const
export { Versions as instancePresenceVersions }
export const instancePresenceMigrations = defineMigrations({
currentVersion: Versions.AddChatMessage,
currentVersion: instancePresenceVersions.AddMeta,
migrators: {
[Versions.AddScribbleDelay]: {
[instancePresenceVersions.AddScribbleDelay]: {
up: (instance) => {
if (instance.scribble !== null) {
return { ...instance, scribble: { ...instance.scribble, delay: 0 } }
@ -90,7 +93,7 @@ export const instancePresenceMigrations = defineMigrations({
return { ...instance }
},
},
[Versions.RemoveInstanceId]: {
[instancePresenceVersions.RemoveInstanceId]: {
up: ({ instanceId: _, ...instance }) => {
return instance
},
@ -98,7 +101,7 @@ export const instancePresenceMigrations = defineMigrations({
return { ...instance, instanceId: TLINSTANCE_ID }
},
},
[Versions.AddChatMessage]: {
[instancePresenceVersions.AddChatMessage]: {
up: (instance) => {
return { ...instance, chatMessage: '' }
},
@ -106,6 +109,19 @@ export const instancePresenceMigrations = defineMigrations({
return instance
},
},
[instancePresenceVersions.AddMeta]: {
up: (record) => {
return {
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
},
},
},
})
@ -142,4 +158,5 @@ export const InstancePresenceRecordType = createRecordType<TLInstancePresence>(
brush: null,
scribble: null,
chatMessage: '',
meta: {},
}))

View file

@ -59,7 +59,8 @@ export type TLShapePartial<T extends TLShape = TLShape> = T extends T
id: TLShapeId
type: T['type']
props?: Partial<T['props']>
} & Partial<Omit<T, 'type' | 'id' | 'props'>>
meta?: Partial<T['meta']>
} & Partial<Omit<T, 'type' | 'id' | 'props' | 'meta'>>
: never
/** @public */
@ -81,16 +82,18 @@ export type TLShapeProp = keyof TLShapeProps
/** @public */
export type TLParentId = TLPageId | TLShapeId
export const Versions = {
/** @internal */
export const rootShapeVersions = {
AddIsLocked: 1,
HoistOpacity: 2,
AddMeta: 3,
} as const
/** @internal */
export const rootShapeMigrations = defineMigrations({
currentVersion: Versions.HoistOpacity,
currentVersion: rootShapeVersions.AddMeta,
migrators: {
[Versions.AddIsLocked]: {
[rootShapeVersions.AddIsLocked]: {
up: (record) => {
return {
...record,
@ -104,7 +107,7 @@ export const rootShapeMigrations = defineMigrations({
}
},
},
[Versions.HoistOpacity]: {
[rootShapeVersions.HoistOpacity]: {
up: ({ props: { opacity, ...props }, ...record }) => {
return {
...record,
@ -131,6 +134,19 @@ export const rootShapeMigrations = defineMigrations({
}
},
},
[rootShapeVersions.AddMeta]: {
up: (record) => {
return {
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
},
},
},
})
@ -182,7 +198,9 @@ export function createShapeRecordType(shapes: Record<string, SchemaShapeInfo>) {
'shape',
T.union(
'type',
mapObjectMapValues(shapes, (type, { props }) => createShapeValidator(type, props))
mapObjectMapValues(shapes, (type, { props, meta }) =>
createShapeValidator(type, props, meta)
)
)
),
}).withDefaultProperties(() => ({
@ -191,5 +209,6 @@ export function createShapeRecordType(shapes: Record<string, SchemaShapeInfo>) {
rotation: 0,
isLocked: false,
opacity: 1,
meta: {},
}))
}

View file

@ -1,5 +1,5 @@
import { BaseRecord } from '@tldraw/store'
import { Expand } from '@tldraw/utils'
import { Expand, JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { TLOpacityType, opacityValidator } from '../misc/TLOpacity'
import { idValidator } from '../misc/id-validator'
@ -17,6 +17,7 @@ export interface TLBaseShape<Type extends string, Props extends object>
isLocked: boolean
opacity: TLOpacityType
props: Props
meta: JsonObject
}
/** @public */
@ -31,11 +32,16 @@ export const parentIdValidator = T.string.refine((id) => {
export const shapeIdValidator = idValidator<TLShapeId>('shape')
/** @public */
export function createShapeValidator<Type extends string, Props extends object>(
export function createShapeValidator<
Type extends string,
Props extends JsonObject,
Meta extends JsonObject
>(
type: Type,
props?: { [K in keyof Props]: T.Validatable<Props[K]> }
props?: { [K in keyof Props]: T.Validatable<Props[K]> },
meta?: { [K in keyof Meta]: T.Validatable<Meta[K]> }
) {
return T.object({
return T.object<TLBaseShape<Type, Props>>({
id: shapeIdValidator,
typeName: T.literal('shape'),
x: T.number,
@ -46,7 +52,8 @@ export function createShapeValidator<Type extends string, Props extends object>(
type: T.literal(type),
isLocked: T.boolean,
opacity: opacityValidator,
props: props ? T.object(props) : T.unknownObject,
props: props ? T.object(props) : (T.jsonValue as T.ObjectValidator<Props>),
meta: meta ? T.object(meta) : (T.jsonValue as T.ObjectValidator<Meta>),
})
}

View file

@ -80,6 +80,7 @@ export async function pasteExcalidrawContent(editor: Editor, clipboard: any, poi
rotation: 0,
isLocked: element.locked,
opacity: getOpacity(element.opacity),
meta: {},
} as const
if (element.angle !== 0) {
@ -298,6 +299,7 @@ export async function pasteExcalidrawContent(editor: Editor, clipboard: any, poi
mimeType: file.mimeType,
src: file.dataURL,
},
meta: {},
})
tldrawContent.shapes.push({

View file

@ -79,6 +79,20 @@ export function isNonNull<T>(value: T): value is typeof value extends null ? nev
// @public
export function isNonNullish<T>(value: T): value is typeof value extends undefined ? never : typeof value extends null ? never : T;
// @public (undocumented)
export type JsonArray = JsonValue[];
// @public (undocumented)
export type JsonObject = {
[key: string]: JsonValue | undefined;
};
// @public (undocumented)
export type JsonPrimitive = boolean | null | number | string;
// @public (undocumented)
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
// @internal (undocumented)
export function last<T>(arr: readonly T[]): T | undefined;

View file

@ -13,6 +13,7 @@ export { annotateError, getErrorAnnotations } from './lib/error'
export { noop, omitFromStackTrace, throttle } from './lib/function'
export { getHashForObject, getHashForString, lns } from './lib/hash'
export { getFirstFromIterable } from './lib/iterable'
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value'
export { lerp, modulate, rng } from './lib/number'
export {
deepCopy,

View file

@ -0,0 +1,8 @@
/** @public */
export type JsonValue = JsonPrimitive | JsonArray | JsonObject
/** @public */
export type JsonPrimitive = boolean | null | string | number
/** @public */
export type JsonArray = JsonValue[]
/** @public */
export type JsonObject = { [key: string]: JsonValue | undefined }

View file

@ -4,6 +4,8 @@
```ts
import { JsonValue } from '@tldraw/utils';
// @public
const any: Validator<any>;
@ -45,6 +47,12 @@ class DictValidator<Key extends string, Value> extends Validator<Record<Key, Val
// @public
const integer: Validator<number>;
// @public
function jsonDict(): DictValidator<string, JsonValue>;
// @public
const jsonValue: Validator<JsonValue>;
// @public
function literal<T extends boolean | number | string>(expectedValue: T): Validator<T>;
@ -109,6 +117,7 @@ declare namespace T {
literal,
arrayOf,
object,
jsonDict,
dict,
union,
model,
@ -137,7 +146,8 @@ declare namespace T {
boolean,
bigint,
array,
unknownObject
unknownObject,
jsonValue
}
}
export { T }

View file

@ -1,4 +1,4 @@
import { exhaustiveSwitchError, getOwnProperty, hasOwnProperty } from '@tldraw/utils'
import { JsonValue, exhaustiveSwitchError, getOwnProperty, hasOwnProperty } from '@tldraw/utils'
/** @public */
export type ValidatorFn<T> = (value: unknown) => T
@ -466,6 +466,49 @@ export function object<Shape extends object>(config: {
return new ObjectValidator(config)
}
function isValidJson(value: any): value is JsonValue {
if (
value === null ||
typeof value === 'number' ||
typeof value === 'string' ||
typeof value === 'boolean'
) {
return true
}
if (Array.isArray(value)) {
return value.every(isValidJson)
}
if (typeof value === 'object') {
return Object.values(value).every(isValidJson)
}
return false
}
/**
* Validate that a value is valid JSON.
*
* @public
*/
export const jsonValue = new Validator<JsonValue>((value): JsonValue => {
if (isValidJson(value)) {
return value as JsonValue
}
throw new ValidationError(`Expected json serializable value, got ${typeof value}`)
})
/**
* Validate an object has a particular shape.
*
* @public
*/
export function jsonDict(): DictValidator<string, JsonValue> {
return dict(string, jsonValue)
}
/**
* Validation that an option is a dict with particular keys and values.
*