diff --git a/apps/examples/e2e/tests/export-snapshots.spec.ts b/apps/examples/e2e/tests/export-snapshots.spec.ts index 95314b9a0..8547a97fd 100644 --- a/apps/examples/e2e/tests/export-snapshots.spec.ts +++ b/apps/examples/e2e/tests/export-snapshots.spec.ts @@ -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"]') diff --git a/apps/examples/src/17-shape-meta/ShapeMetaExample.tsx b/apps/examples/src/17-shape-meta/ShapeMetaExample.tsx new file mode 100644 index 000000000..4e5282198 --- /dev/null +++ b/apps/examples/src/17-shape-meta/ShapeMetaExample.tsx @@ -0,0 +1,49 @@ +import { TLShape, Tldraw, track, useEditor } from '@tldraw/tldraw' +import '@tldraw/tldraw/tldraw.css' + +export default function ShapeMetaExample() { + return ( +
+ { + editor.getInitialMetaForShape = (shape) => { + return { label: `My ${shape.type} shape` } + } + }} + > + + +
+ ) +} + +// 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) { + if (onlySelectedShape) { + const { id, type, meta } = onlySelectedShape + + editor.updateShapes([ + { id, type, meta: { ...meta, label: e.currentTarget.value } }, + ]) + } + } + + return ( +
+ shape label: +
+ ) +}) diff --git a/apps/examples/src/index.tsx b/apps/examples/src/index.tsx index 85c618e7f..75ddffcb9 100644 --- a/apps/examples/src/index.tsx +++ b/apps/examples/src/index.tsx @@ -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: , }, + { + path: '/shape-meta', + element: , + }, ] const router = createBrowserRouter(allExamples) diff --git a/apps/vscode/editor/src/utils/bookmarks.ts b/apps/vscode/editor/src/utils/bookmarks.ts index c39c84682..df6098fc1 100644 --- a/apps/vscode/editor/src/utils/bookmarks.ts +++ b/apps/vscode/editor/src/utils/bookmarks.ts @@ -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: {}, } } } diff --git a/docs/docs/editor.mdx b/docs/docs/editor.mdx index 182385642..ea5c899d9 100644 --- a/docs/docs/editor.mdx +++ b/docs/docs/editor.mdx @@ -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(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([{ + 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. \ No newline at end of file diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 131a5af12..09e2e2d38 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -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 { getHandles(shape: T): TLHandle[] | undefined; getHandlesById(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 { parentId: TLParentId; isLocked: boolean; opacity: number; + meta: JsonObject; id: TLShapeId; typeName: "shape"; } | undefined; @@ -915,6 +918,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { parentId: TLParentId; isLocked: boolean; opacity: number; + meta: JsonObject; id: TLShapeId; typeName: "shape"; } | undefined; @@ -931,6 +935,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { parentId: TLParentId; isLocked: boolean; opacity: number; + meta: JsonObject; id: TLShapeId; typeName: "shape"; } | { @@ -945,6 +950,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { parentId: TLParentId; isLocked: boolean; opacity: number; + meta: JsonObject; id: TLShapeId; typeName: "shape"; } | undefined; @@ -1711,6 +1717,7 @@ export class NoteShapeUtil extends ShapeUtil { parentId: TLParentId; isLocked: boolean; opacity: number; + meta: JsonObject; id: TLShapeId; typeName: "shape"; } | undefined; @@ -1734,6 +1741,7 @@ export class NoteShapeUtil extends ShapeUtil { parentId: TLParentId; isLocked: boolean; opacity: number; + meta: JsonObject; id: TLShapeId; typeName: "shape"; } | undefined; @@ -2101,6 +2109,7 @@ export class TextShapeUtil extends ShapeUtil { scale: number; autoSize: boolean; }; + meta: JsonObject; id: TLShapeId; typeName: "shape"; } | undefined; @@ -2124,6 +2133,7 @@ export class TextShapeUtil extends ShapeUtil { parentId: TLParentId; isLocked: boolean; opacity: number; + meta: JsonObject; id: TLShapeId; typeName: "shape"; } | undefined; diff --git a/packages/editor/src/lib/components/Shape.tsx b/packages/editor/src/lib/components/Shape.tsx index 4fe691e82..832dab66a 100644 --- a/packages/editor/src/lib/components/Shape.tsx +++ b/packages/editor/src/lib/components/Shape.tsx @@ -148,7 +148,7 @@ const InnerShape = React.memo( function InnerShape({ shape, util }: { shape: T; util: ShapeUtil }) { 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( diff --git a/packages/editor/src/lib/components/ShapeIndicator.tsx b/packages/editor/src/lib/components/ShapeIndicator.tsx index 1c5b94c6f..7d5c9873e 100644 --- a/packages/editor/src/lib/components/ShapeIndicator.tsx +++ b/packages/editor/src/lib/components/ShapeIndicator.tsx @@ -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 } } diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index d196aaa16..c7520c675 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -65,6 +65,7 @@ import { isShapeId, } from '@tldraw/tlschema' import { + JsonObject, annotateError, assert, compact, @@ -3971,6 +3972,7 @@ export class Editor extends EventEmitter { bottomIndex && topIndex !== bottomIndex ? getIndexBetween(topIndex, bottomIndex) : getIndexAbove(topIndex), + meta: {}, }) const newCamera = CameraRecordType.create({ @@ -7094,6 +7096,26 @@ export class Editor extends EventEmitter { 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 { 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 { 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 { } if (k === 'props') { - const nextProps = { ...prev.props } as Record + // props property + const nextProps = { ...prev.props } as JsonObject for (const [propKey, propValue] of Object.entries(v as object)) { - if (propValue === undefined) continue - nextProps[propKey] = propValue + 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 { info.type === 'pointer' && info.pointerId === INTERNAL_POINTER_IDS.CAMERA_MOVE ? this.store.get(TLPOINTER_ID)?.lastActivityTimestamp ?? Date.now() : Date.now(), + meta: {}, }, ]) } diff --git a/packages/editor/src/lib/editor/managers/ExternalContentManager.ts b/packages/editor/src/lib/editor/managers/ExternalContentManager.ts index b8e3f3a90..6d4398439 100644 --- a/packages/editor/src/lib/editor/managers/ExternalContentManager.ts +++ b/packages/editor/src/lib/editor/managers/ExternalContentManager.ts @@ -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: {}, } } } diff --git a/packages/editor/src/lib/editor/shapes/line/__snapshots__/LineShapeUtil.test.ts.snap b/packages/editor/src/lib/editor/shapes/line/__snapshots__/LineShapeUtil.test.ts.snap index 7e4a81449..b2e94530f 100644 --- a/packages/editor/src/lib/editor/shapes/line/__snapshots__/LineShapeUtil.test.ts.snap +++ b/packages/editor/src/lib/editor/shapes/line/__snapshots__/LineShapeUtil.test.ts.snap @@ -5,6 +5,7 @@ Object { "id": "shape:line1", "index": "a1", "isLocked": false, + "meta": Object {}, "opacity": 1, "parentId": "page:id50", "props": Object { diff --git a/packages/editor/src/lib/test/commands/__snapshots__/packShapes.test.ts.snap b/packages/editor/src/lib/test/commands/__snapshots__/packShapes.test.ts.snap index 6e22955ae..439227edb 100644 --- a/packages/editor/src/lib/test/commands/__snapshots__/packShapes.test.ts.snap +++ b/packages/editor/src/lib/test/commands/__snapshots__/packShapes.test.ts.snap @@ -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 { diff --git a/packages/editor/src/lib/test/commands/__snapshots__/zoomToFit.test.ts.snap b/packages/editor/src/lib/test/commands/__snapshots__/zoomToFit.test.ts.snap index bb137928a..5903ed07c 100644 --- a/packages/editor/src/lib/test/commands/__snapshots__/zoomToFit.test.ts.snap +++ b/packages/editor/src/lib/test/commands/__snapshots__/zoomToFit.test.ts.snap @@ -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, diff --git a/packages/editor/src/lib/test/commands/getInitialMetaForShape.test.ts b/packages/editor/src/lib/test/commands/getInitialMetaForShape.test.ts new file mode 100644 index 000000000..b29d9bfb0 --- /dev/null +++ b/packages/editor/src/lib/test/commands/getInitialMetaForShape.test.ts @@ -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' }) +}) diff --git a/packages/editor/src/lib/test/tools/__snapshots__/resizing.test.ts.snap b/packages/editor/src/lib/test/tools/__snapshots__/resizing.test.ts.snap index b16aaadaf..aac67b5b6 100644 --- a/packages/editor/src/lib/test/tools/__snapshots__/resizing.test.ts.snap +++ b/packages/editor/src/lib/test/tools/__snapshots__/resizing.test.ts.snap @@ -5,6 +5,7 @@ Object { "id": "shape:lineA", "index": "a3", "isLocked": false, + "meta": Object {}, "opacity": 1, "parentId": "shape:boxA", "props": Object { diff --git a/packages/editor/src/lib/utils/assets.ts b/packages/editor/src/lib/utils/assets.ts index af4ae45e7..4425ab3b5 100644 --- a/packages/editor/src/lib/utils/assets.ts +++ b/packages/editor/src/lib/utils/assets.ts @@ -170,6 +170,7 @@ export async function getMediaAssetFromFile(file: File): Promise { mimeType: file.type, isAnimated: metadata.isAnimated, }, + meta: {}, } resolve(asset) diff --git a/packages/file-format/src/lib/buildFromV1Document.ts b/packages/file-format/src/lib/buildFromV1Document.ts index b4590bf21..b790883e1 100644 --- a/packages/file-format/src/lib/buildFromV1Document.ts +++ b/packages/file-format/src/lib/buildFromV1Document.ts @@ -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: {}, }, ]) } diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md index 495c6cf5b..da2871c06 100644 --- a/packages/tlschema/api-report.md +++ b/packages/tlschema/api-report.md @@ -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): SerializedStore; // @public -export function createAssetValidator(type: Type, props: T.Validator): T.ObjectValidator<{ +export function createAssetValidator(type: Type, props: T.Validator): 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: Type, props?: { +export function createShapeValidator(type: Type, props?: { [K in keyof Props]: T.Validatable; -}): 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; -}>; +}, meta?: { + [K in keyof Meta]: T.Validatable; +}): T.ObjectValidator>; // @public export function createTLSchema({ shapes }: { @@ -710,6 +702,9 @@ export type SchemaShapeInfo = { props?: Record any; }>; + meta?: Record any; + }>; }; // @internal (undocumented) @@ -786,7 +781,8 @@ export type TLAssetPartial = T extends T ? { id: TLAssetId; type: T['type']; props?: Partial; -} & Partial> : never; + meta?: Partial; +} & Partial> : never; // @public (undocumented) export type TLAssetShape = Extract extends BaseRecord<'asset', TLAssetId> { + // (undocumented) + meta: JsonObject; // (undocumented) props: Props; // (undocumented) @@ -810,6 +808,8 @@ export interface TLBaseShape 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> // (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; // (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 T ? { id: TLShapeId; type: T['type']; props?: Partial; -} & Partial> : never; + meta?: Partial; +} & Partial> : never; // @public (undocumented) export type TLShapeProp = keyof TLShapeProps; diff --git a/packages/tlschema/src/TLStore.ts b/packages/tlschema/src/TLStore.ts index f1ee38379..87c79c74e 100644 --- a/packages/tlschema/src/TLStore.ts +++ b/packages/tlschema/src/TLStore.ts @@ -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 */ diff --git a/packages/tlschema/src/assets/TLBaseAsset.ts b/packages/tlschema/src/assets/TLBaseAsset.ts index 2cdc46cf7..9360b7ce3 100644 --- a/packages/tlschema/src/assets/TLBaseAsset.ts +++ b/packages/tlschema/src/assets/TLBaseAsset.ts @@ -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 extends BaseRecord<'asset', TLAssetId> { type: Type props: Props + meta: JsonObject } /** @@ -22,7 +24,7 @@ export const assetIdValidator = idValidator('asset') * @param props - The validator for the asset's props * * @public */ -export function createAssetValidator( +export function createAssetValidator( type: Type, props: T.Validator ): T.ObjectValidator<{ @@ -30,11 +32,13 @@ export function createAssetValidator( 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, }) } diff --git a/packages/tlschema/src/createPresenceStateDerivation.ts b/packages/tlschema/src/createPresenceStateDerivation.ts index 6c174456a..c1e93a2bc 100644 --- a/packages/tlschema/src/createPresenceStateDerivation.ts +++ b/packages/tlschema/src/createPresenceStateDerivation.ts @@ -47,6 +47,7 @@ export const createPresenceStateDerivation = lastActivityTimestamp: pointer.lastActivityTimestamp, screenBounds: instance.screenBounds, chatMessage: instance.chatMessage, + meta: {}, }) }) } diff --git a/packages/tlschema/src/createTLSchema.ts b/packages/tlschema/src/createTLSchema.ts index 17e544b5d..7152ec706 100644 --- a/packages/tlschema/src/createTLSchema.ts +++ b/packages/tlschema/src/createTLSchema.ts @@ -18,6 +18,7 @@ import { StyleProp } from './styles/StyleProp' export type SchemaShapeInfo = { migrations?: Migrations props?: Record any }> + meta?: Record any }> } /** @public */ diff --git a/packages/tlschema/src/migrations.test.ts b/packages/tlschema/src/migrations.test.ts index 88839a10b..8c5ec752e 100644 --- a/packages/tlschema/src/migrations.test.ts +++ b/packages/tlschema/src/migrations.test.ts @@ -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) { diff --git a/packages/tlschema/src/records/TLAsset.ts b/packages/tlschema/src/records/TLAsset.ts index 2eddd8db5..8d952c9f9 100644 --- a/packages/tlschema/src/records/TLAsset.ts +++ b/packages/tlschema/src/records/TLAsset.ts @@ -23,6 +23,11 @@ export const assetValidator: T.Validator = 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 T id: TLAssetId type: T['type'] props?: Partial - } & Partial> + meta?: Partial + } & Partial> : never /** @public */ @@ -47,7 +69,9 @@ export const AssetRecordType = createRecordType('asset', { migrations: assetMigrations, validator: assetValidator, scope: 'document', -}) +}).withDefaultProperties(() => ({ + meta: {}, +})) /** @public */ export type TLAssetId = RecordId> diff --git a/packages/tlschema/src/records/TLCamera.ts b/packages/tlschema/src/records/TLCamera.ts index 15796312b..ae068ca2d 100644 --- a/packages/tlschema/src/records/TLCamera.ts +++ b/packages/tlschema/src/records/TLCamera.ts @@ -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 = T.model( x: T.number, y: T.number, z: T.number, + meta: T.jsonValue as T.ObjectValidator, }) ) /** @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('camera', { @@ -44,5 +69,6 @@ export const CameraRecordType = createRecordType('camera', { x: 0, y: 0, z: 1, + meta: {}, }) ) diff --git a/packages/tlschema/src/records/TLDocument.ts b/packages/tlschema/src/records/TLDocument.ts index 596fdb605..3ee1e3e5a 100644 --- a/packages/tlschema/src/records/TLDocument.ts +++ b/packages/tlschema/src/records/TLDocument.ts @@ -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> { gridSize: number name: string + meta: JsonObject } /** @internal */ @@ -19,18 +21,21 @@ export const documentValidator: T.Validator = T.model( id: T.literal('document:document' as RecordId), gridSize: T.number, name: T.string, + meta: T.jsonValue as T.ObjectValidator, }) ) -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('document', { (): Omit => ({ gridSize: 10, name: '', + meta: {}, }) ) diff --git a/packages/tlschema/src/records/TLInstance.ts b/packages/tlschema/src/records/TLInstance.ts index f1e194ad3..91b06f5c7 100644 --- a/packages/tlschema/src/records/TLInstance.ts +++ b/packages/tlschema/src/records/TLInstance.ts @@ -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, }) ) @@ -101,11 +104,13 @@ export function createInstanceRecordType(stylesById: Map { 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, + } + }, + }, }, }) diff --git a/packages/tlschema/src/records/TLPage.ts b/packages/tlschema/src/records/TLPage.ts index b04c70eb2..457495563 100644 --- a/packages/tlschema/src/records/TLPage.ts +++ b/packages/tlschema/src/records/TLPage.ts @@ -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 = T.model( id: pageIdValidator, name: T.string, index: T.string, + meta: T.jsonValue as T.ObjectValidator, }) ) + /** @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('page', { validator: pageValidator, migrations: pageMigrations, scope: 'document', -}) +}).withDefaultProperties(() => ({ + meta: {}, +})) /** @public */ export function isPageId(id: string): id is TLPageId { diff --git a/packages/tlschema/src/records/TLPageState.ts b/packages/tlschema/src/records/TLPageState.ts index 93062a1db..a633df5c9 100644 --- a/packages/tlschema/src/records/TLPageState.ts +++ b/packages/tlschema/src/records/TLPageState.ts @@ -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 = T.mo editingId: shapeIdValidator.nullable(), croppingId: shapeIdValidator.nullable(), focusLayerId: shapeIdValidator.nullable(), + meta: T.jsonValue as T.ObjectValidator, }) ) -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 erasingIds: [], hintingIds: [], focusLayerId: null, + meta: {}, }) ) diff --git a/packages/tlschema/src/records/TLPointer.ts b/packages/tlschema/src/records/TLPointer.ts index b76919d06..ddb740771 100644 --- a/packages/tlschema/src/records/TLPointer.ts +++ b/packages/tlschema/src/records/TLPointer.ts @@ -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 = T.model( x: T.number, y: T.number, lastActivityTimestamp: T.number, + meta: T.jsonValue as T.ObjectValidator, }) ) /** @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('pointer', { @@ -41,6 +66,7 @@ export const PointerRecordType = createRecordType('pointer', { x: 0, y: 0, lastActivityTimestamp: 0, + meta: {}, }) ) diff --git a/packages/tlschema/src/records/TLPresence.ts b/packages/tlschema/src/records/TLPresence.ts index 67f0246e2..297bd5f6a 100644 --- a/packages/tlschema/src/records/TLPresence.ts +++ b/packages/tlschema/src/records/TLPresence.ts @@ -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 = T.mode brush: box2dModelValidator.nullable(), scribble: scribbleValidator.nullable(), chatMessage: T.string, + meta: T.jsonValue as T.ObjectValidator, }) ) -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( brush: null, scribble: null, chatMessage: '', + meta: {}, })) diff --git a/packages/tlschema/src/records/TLShape.ts b/packages/tlschema/src/records/TLShape.ts index 0fd98e57b..1fba19efc 100644 --- a/packages/tlschema/src/records/TLShape.ts +++ b/packages/tlschema/src/records/TLShape.ts @@ -59,7 +59,8 @@ export type TLShapePartial = T extends T id: TLShapeId type: T['type'] props?: Partial - } & Partial> + meta?: Partial + } & Partial> : 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) { '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) { rotation: 0, isLocked: false, opacity: 1, + meta: {}, })) } diff --git a/packages/tlschema/src/shapes/TLBaseShape.ts b/packages/tlschema/src/shapes/TLBaseShape.ts index 22df36eb4..6ec7bfd81 100644 --- a/packages/tlschema/src/shapes/TLBaseShape.ts +++ b/packages/tlschema/src/shapes/TLBaseShape.ts @@ -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 isLocked: boolean opacity: TLOpacityType props: Props + meta: JsonObject } /** @public */ @@ -31,11 +32,16 @@ export const parentIdValidator = T.string.refine((id) => { export const shapeIdValidator = idValidator('shape') /** @public */ -export function createShapeValidator( +export function createShapeValidator< + Type extends string, + Props extends JsonObject, + Meta extends JsonObject +>( type: Type, - props?: { [K in keyof Props]: T.Validatable } + props?: { [K in keyof Props]: T.Validatable }, + meta?: { [K in keyof Meta]: T.Validatable } ) { - return T.object({ + return T.object>({ id: shapeIdValidator, typeName: T.literal('shape'), x: T.number, @@ -46,7 +52,8 @@ export function createShapeValidator( 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), + meta: meta ? T.object(meta) : (T.jsonValue as T.ObjectValidator), }) } diff --git a/packages/ui/src/lib/hooks/clipboard/pasteExcalidrawContent.ts b/packages/ui/src/lib/hooks/clipboard/pasteExcalidrawContent.ts index bc2645182..867559ab7 100644 --- a/packages/ui/src/lib/hooks/clipboard/pasteExcalidrawContent.ts +++ b/packages/ui/src/lib/hooks/clipboard/pasteExcalidrawContent.ts @@ -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({ diff --git a/packages/utils/api-report.md b/packages/utils/api-report.md index e63dc7824..f066408f1 100644 --- a/packages/utils/api-report.md +++ b/packages/utils/api-report.md @@ -79,6 +79,20 @@ export function isNonNull(value: T): value is typeof value extends null ? nev // @public export function isNonNullish(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(arr: readonly T[]): T | undefined; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c24928273..d905452e9 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -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, diff --git a/packages/utils/src/lib/json-value.ts b/packages/utils/src/lib/json-value.ts new file mode 100644 index 000000000..598c80cfe --- /dev/null +++ b/packages/utils/src/lib/json-value.ts @@ -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 } diff --git a/packages/validate/api-report.md b/packages/validate/api-report.md index 341f449db..9b06f4cc7 100644 --- a/packages/validate/api-report.md +++ b/packages/validate/api-report.md @@ -4,6 +4,8 @@ ```ts +import { JsonValue } from '@tldraw/utils'; + // @public const any: Validator; @@ -45,6 +47,12 @@ class DictValidator extends Validator; +// @public +function jsonDict(): DictValidator; + +// @public +const jsonValue: Validator; + // @public function literal(expectedValue: T): Validator; @@ -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 } diff --git a/packages/validate/src/lib/validation.ts b/packages/validate/src/lib/validation.ts index 82465fcda..9e9ea59eb 100644 --- a/packages/validate/src/lib/validation.ts +++ b/packages/validate/src/lib/validation.ts @@ -1,4 +1,4 @@ -import { exhaustiveSwitchError, getOwnProperty, hasOwnProperty } from '@tldraw/utils' +import { JsonValue, exhaustiveSwitchError, getOwnProperty, hasOwnProperty } from '@tldraw/utils' /** @public */ export type ValidatorFn = (value: unknown) => T @@ -466,6 +466,49 @@ export function 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((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 { + return dict(string, jsonValue) +} + /** * Validation that an option is a dict with particular keys and values. *