@tldraw/tlschema 5.2.0-canary.cfef7b16ad41 → 5.2.0-canary.d28cf4347708

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/shapes/TLHighlightShape.ts"],
4
- "sourcesContent": ["import { T } from '@tldraw/validate'\nimport { b64Vecs } from '../misc/b64Vecs'\nimport { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'\nimport { RecordProps } from '../recordsWithProps'\nimport { DefaultColorStyle, TLDefaultColorStyle } from '../styles/TLColorStyle'\nimport { DefaultSizeStyle, TLDefaultSizeStyle } from '../styles/TLSizeStyle'\nimport { TLBaseShape } from './TLBaseShape'\nimport { DrawShapeSegment, TLDrawShapeSegment } from './TLDrawShape'\n\n/**\n * Properties for a highlight shape. Highlight shapes represent highlighting strokes made with\n * a highlighting tool, typically used to emphasize or mark up content.\n *\n * @public\n * @example\n * ```ts\n * const highlightProps: TLHighlightShapeProps = {\n * color: 'yellow',\n * size: 'm',\n * segments: [{ type: 'straight', points: [{ x: 0, y: 0, z: 0.5 }] }],\n * isComplete: true,\n * isPen: false,\n * scale: 1\n * }\n * ```\n */\nexport interface TLHighlightShapeProps {\n\t/** The color style of the highlight stroke */\n\tcolor: TLDefaultColorStyle\n\t/** The size style determining the thickness of the highlight stroke */\n\tsize: TLDefaultSizeStyle\n\t/** Array of segments that make up the highlight stroke path */\n\tsegments: TLDrawShapeSegment[]\n\t/** Whether the highlight stroke has been completed by the user */\n\tisComplete: boolean\n\t/** Whether the highlight was drawn with a pen/stylus (affects rendering style) */\n\tisPen: boolean\n\t/** Scale factor applied to the highlight shape for display */\n\tscale: number\n\t/** Horizontal scale factor for lazy resize */\n\tscaleX: number\n\t/** Vertical scale factor for lazy resize */\n\tscaleY: number\n}\n\n/**\n * A highlight shape representing a highlighting stroke drawn by the user. Highlight shapes\n * are typically semi-transparent and used for marking up or emphasizing content on the canvas.\n *\n * @public\n * @example\n * ```ts\n * const highlightShape: TLHighlightShape = {\n * id: 'shape:highlight1',\n * type: 'highlight',\n * x: 100,\n * y: 50,\n * rotation: 0,\n * index: 'a1',\n * parentId: 'page:main',\n * isLocked: false,\n * opacity: 0.7,\n * props: {\n * color: 'yellow',\n * size: 'l',\n * segments: [],\n * isComplete: false,\n * isPen: false,\n * scale: 1\n * },\n * meta: {},\n * typeName: 'shape'\n * }\n * ```\n */\nexport type TLHighlightShape = TLBaseShape<'highlight', TLHighlightShapeProps>\n\n/**\n * Validation schema for highlight shape properties. Defines the runtime validation rules\n * for all properties of highlight shapes.\n *\n * @public\n * @example\n * ```ts\n * import { highlightShapeProps } from '@tldraw/tlschema'\n *\n * // Used internally by the validation system\n * const validator = T.object(highlightShapeProps)\n * const validatedProps = validator.validate(someHighlightProps)\n * ```\n */\n/** @public */\nexport const highlightShapeProps: RecordProps<TLHighlightShape> = {\n\tcolor: DefaultColorStyle,\n\tsize: DefaultSizeStyle,\n\tsegments: T.arrayOf(DrawShapeSegment),\n\tisComplete: T.boolean,\n\tisPen: T.boolean,\n\tscale: T.nonZeroNumber,\n\tscaleX: T.nonZeroFiniteNumber,\n\tscaleY: T.nonZeroFiniteNumber,\n}\n\nconst Versions = createShapePropsMigrationIds('highlight', {\n\tAddScale: 1,\n\tBase64: 2,\n\tLegacyPointsConversion: 3,\n})\n\n/**\n * Version identifiers for highlight shape migrations. These version numbers track\n * schema changes over time to enable proper data migration.\n *\n * @public\n */\nexport { Versions as highlightShapeVersions }\n\n/**\n * Migration sequence for highlight shapes. Handles schema evolution over time by defining\n * how to upgrade and downgrade highlight shape data between different versions.\n *\n * @public\n */\nexport const highlightShapeMigrations = createShapePropsMigrationSequence({\n\tsequence: [\n\t\t{\n\t\t\tid: Versions.AddScale,\n\t\t\tup: (props) => {\n\t\t\t\tprops.scale = 1\n\t\t\t},\n\t\t\tdown: (props) => {\n\t\t\t\tdelete props.scale\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tid: Versions.Base64,\n\t\t\tup: (props) => {\n\t\t\t\t// Convert VecModel[] arrays directly to delta-encoded base64 in 'path'\n\t\t\t\tprops.segments = props.segments.map((segment: any) => {\n\t\t\t\t\tif (segment.path !== undefined) return segment\n\t\t\t\t\tconst { points, ...rest } = segment\n\t\t\t\t\tconst vecModels = Array.isArray(points) ? points : b64Vecs._legacyDecodePoints(points)\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...rest,\n\t\t\t\t\t\tpath: b64Vecs.encodePoints(vecModels),\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\tprops.scaleX = props.scaleX ?? 1\n\t\t\t\tprops.scaleY = props.scaleY ?? 1\n\t\t\t},\n\t\t\tdown: (props) => {\n\t\t\t\t// Convert delta-encoded 'path' back to VecModel[] arrays in 'points'\n\t\t\t\tprops.segments = props.segments.map((segment: any) => {\n\t\t\t\t\tconst { path, ...rest } = segment\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...rest,\n\t\t\t\t\t\tpoints: b64Vecs.decodePoints(path),\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\tdelete props.scaleX\n\t\t\t\tdelete props.scaleY\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tid: Versions.LegacyPointsConversion,\n\t\t\tup: (props) => {\n\t\t\t\t// Handle legacy data that was already migrated to v2 with absolute Float16 in 'points'\n\t\t\t\t// Convert 'points' to delta-encoded 'path'\n\t\t\t\tprops.segments = props.segments.map((segment: any) => {\n\t\t\t\t\t// If segment already has 'path', it's already in the new format\n\t\t\t\t\tif (segment.path !== undefined) return segment\n\n\t\t\t\t\tconst { points, ...rest } = segment\n\t\t\t\t\tconst vecModels = Array.isArray(points) ? points : b64Vecs._legacyDecodePoints(points)\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...rest,\n\t\t\t\t\t\tpath: b64Vecs.encodePoints(vecModels),\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t},\n\t\t\tdown: (_props) => {\n\t\t\t\t// handled by the previous down migration\n\t\t\t},\n\t\t},\n\t],\n})\n"],
5
- "mappings": "AAAA,SAAS,SAAS;AAClB,SAAS,eAAe;AACxB,SAAS,8BAA8B,yCAAyC;AAEhF,SAAS,yBAA8C;AACvD,SAAS,wBAA4C;AAErD,SAAS,wBAA4C;AAqF9C,MAAM,sBAAqD;AAAA,EACjE,OAAO;AAAA,EACP,MAAM;AAAA,EACN,UAAU,EAAE,QAAQ,gBAAgB;AAAA,EACpC,YAAY,EAAE;AAAA,EACd,OAAO,EAAE;AAAA,EACT,OAAO,EAAE;AAAA,EACT,QAAQ,EAAE;AAAA,EACV,QAAQ,EAAE;AACX;AAEA,MAAM,WAAW,6BAA6B,aAAa;AAAA,EAC1D,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,wBAAwB;AACzB,CAAC;AAgBM,MAAM,2BAA2B,kCAAkC;AAAA,EACzE,UAAU;AAAA,IACT;AAAA,MACC,IAAI,SAAS;AAAA,MACb,IAAI,CAAC,UAAU;AACd,cAAM,QAAQ;AAAA,MACf;AAAA,MACA,MAAM,CAAC,UAAU;AAChB,eAAO,MAAM;AAAA,MACd;AAAA,IACD;AAAA,IACA;AAAA,MACC,IAAI,SAAS;AAAA,MACb,IAAI,CAAC,UAAU;AAEd,cAAM,WAAW,MAAM,SAAS,IAAI,CAAC,YAAiB;AACrD,cAAI,QAAQ,SAAS,OAAW,QAAO;AACvC,gBAAM,EAAE,QAAQ,GAAG,KAAK,IAAI;AAC5B,gBAAM,YAAY,MAAM,QAAQ,MAAM,IAAI,SAAS,QAAQ,oBAAoB,MAAM;AACrF,iBAAO;AAAA,YACN,GAAG;AAAA,YACH,MAAM,QAAQ,aAAa,SAAS;AAAA,UACrC;AAAA,QACD,CAAC;AACD,cAAM,SAAS,MAAM,UAAU;AAC/B,cAAM,SAAS,MAAM,UAAU;AAAA,MAChC;AAAA,MACA,MAAM,CAAC,UAAU;AAEhB,cAAM,WAAW,MAAM,SAAS,IAAI,CAAC,YAAiB;AACrD,gBAAM,EAAE,MAAM,GAAG,KAAK,IAAI;AAC1B,iBAAO;AAAA,YACN,GAAG;AAAA,YACH,QAAQ,QAAQ,aAAa,IAAI;AAAA,UAClC;AAAA,QACD,CAAC;AACD,eAAO,MAAM;AACb,eAAO,MAAM;AAAA,MACd;AAAA,IACD;AAAA,IACA;AAAA,MACC,IAAI,SAAS;AAAA,MACb,IAAI,CAAC,UAAU;AAGd,cAAM,WAAW,MAAM,SAAS,IAAI,CAAC,YAAiB;AAErD,cAAI,QAAQ,SAAS,OAAW,QAAO;AAEvC,gBAAM,EAAE,QAAQ,GAAG,KAAK,IAAI;AAC5B,gBAAM,YAAY,MAAM,QAAQ,MAAM,IAAI,SAAS,QAAQ,oBAAoB,MAAM;AACrF,iBAAO;AAAA,YACN,GAAG;AAAA,YACH,MAAM,QAAQ,aAAa,SAAS;AAAA,UACrC;AAAA,QACD,CAAC;AAAA,MACF;AAAA,MACA,MAAM,CAAC,WAAW;AAAA,MAElB;AAAA,IACD;AAAA,EACD;AACD,CAAC;",
4
+ "sourcesContent": ["import { T } from '@tldraw/validate'\nimport { DIM_2D, b64Vecs } from '../misc/b64Vecs'\nimport { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape'\nimport { RecordProps } from '../recordsWithProps'\nimport { DefaultColorStyle, TLDefaultColorStyle } from '../styles/TLColorStyle'\nimport { DefaultSizeStyle, TLDefaultSizeStyle } from '../styles/TLSizeStyle'\nimport { TLBaseShape } from './TLBaseShape'\nimport { DrawShapeSegment, TLDrawShapeSegment } from './TLDrawShape'\n\n/**\n * Properties for a highlight shape. Highlight shapes represent highlighting strokes made with\n * a highlighting tool, typically used to emphasize or mark up content.\n *\n * @public\n * @example\n * ```ts\n * const highlightProps: TLHighlightShapeProps = {\n * color: 'yellow',\n * size: 'm',\n * segments: [{ type: 'straight', points: [{ x: 0, y: 0, z: 0.5 }] }],\n * isComplete: true,\n * isPen: false,\n * scale: 1\n * }\n * ```\n */\nexport interface TLHighlightShapeProps {\n\t/** The color style of the highlight stroke */\n\tcolor: TLDefaultColorStyle\n\t/** The size style determining the thickness of the highlight stroke */\n\tsize: TLDefaultSizeStyle\n\t/** Array of segments that make up the highlight stroke path */\n\tsegments: TLDrawShapeSegment[]\n\t/** Whether the highlight stroke has been completed by the user */\n\tisComplete: boolean\n\t/** Whether the highlight was drawn with a pen/stylus (affects rendering style) */\n\tisPen: boolean\n\t/** Scale factor applied to the highlight shape for display */\n\tscale: number\n\t/** Horizontal scale factor for lazy resize */\n\tscaleX: number\n\t/** Vertical scale factor for lazy resize */\n\tscaleY: number\n}\n\n/**\n * A highlight shape representing a highlighting stroke drawn by the user. Highlight shapes\n * are typically semi-transparent and used for marking up or emphasizing content on the canvas.\n *\n * @public\n * @example\n * ```ts\n * const highlightShape: TLHighlightShape = {\n * id: 'shape:highlight1',\n * type: 'highlight',\n * x: 100,\n * y: 50,\n * rotation: 0,\n * index: 'a1',\n * parentId: 'page:main',\n * isLocked: false,\n * opacity: 0.7,\n * props: {\n * color: 'yellow',\n * size: 'l',\n * segments: [],\n * isComplete: false,\n * isPen: false,\n * scale: 1\n * },\n * meta: {},\n * typeName: 'shape'\n * }\n * ```\n */\nexport type TLHighlightShape = TLBaseShape<'highlight', TLHighlightShapeProps>\n\n/**\n * Validation schema for highlight shape properties. Defines the runtime validation rules\n * for all properties of highlight shapes.\n *\n * @public\n * @example\n * ```ts\n * import { highlightShapeProps } from '@tldraw/tlschema'\n *\n * // Used internally by the validation system\n * const validator = T.object(highlightShapeProps)\n * const validatedProps = validator.validate(someHighlightProps)\n * ```\n */\n/** @public */\nexport const highlightShapeProps: RecordProps<TLHighlightShape> = {\n\tcolor: DefaultColorStyle,\n\tsize: DefaultSizeStyle,\n\tsegments: T.arrayOf(DrawShapeSegment),\n\tisComplete: T.boolean,\n\tisPen: T.boolean,\n\tscale: T.nonZeroNumber,\n\tscaleX: T.nonZeroFiniteNumber,\n\tscaleY: T.nonZeroFiniteNumber,\n}\n\nconst Versions = createShapePropsMigrationIds('highlight', {\n\tAddScale: 1,\n\tBase64: 2,\n\tLegacyPointsConversion: 3,\n\tOmitNonPressureZ: 4,\n})\n\n/**\n * Version identifiers for highlight shape migrations. These version numbers track\n * schema changes over time to enable proper data migration.\n *\n * @public\n */\nexport { Versions as highlightShapeVersions }\n\n/**\n * Migration sequence for highlight shapes. Handles schema evolution over time by defining\n * how to upgrade and downgrade highlight shape data between different versions.\n *\n * @public\n */\nexport const highlightShapeMigrations = createShapePropsMigrationSequence({\n\tsequence: [\n\t\t{\n\t\t\tid: Versions.AddScale,\n\t\t\tup: (props) => {\n\t\t\t\tprops.scale = 1\n\t\t\t},\n\t\t\tdown: (props) => {\n\t\t\t\tdelete props.scale\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tid: Versions.Base64,\n\t\t\tup: (props) => {\n\t\t\t\t// Convert VecModel[] arrays directly to delta-encoded base64 in 'path'\n\t\t\t\tprops.segments = props.segments.map((segment: any) => {\n\t\t\t\t\tif (segment.path !== undefined) return segment\n\t\t\t\t\tconst { points, ...rest } = segment\n\t\t\t\t\tconst vecModels = Array.isArray(points) ? points : b64Vecs._legacyDecodePoints(points)\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...rest,\n\t\t\t\t\t\tpath: b64Vecs.encodePoints(vecModels),\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\tprops.scaleX = props.scaleX ?? 1\n\t\t\t\tprops.scaleY = props.scaleY ?? 1\n\t\t\t},\n\t\t\tdown: (props) => {\n\t\t\t\t// Convert delta-encoded 'path' back to VecModel[] arrays in 'points'\n\t\t\t\tprops.segments = props.segments.map((segment: any) => {\n\t\t\t\t\tconst { path, ...rest } = segment\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...rest,\n\t\t\t\t\t\tpoints: b64Vecs.decodePoints(path),\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\tdelete props.scaleX\n\t\t\t\tdelete props.scaleY\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tid: Versions.LegacyPointsConversion,\n\t\t\tup: (props) => {\n\t\t\t\t// Handle legacy data that was already migrated to v2 with absolute Float16 in 'points'\n\t\t\t\t// Convert 'points' to delta-encoded 'path'\n\t\t\t\tprops.segments = props.segments.map((segment: any) => {\n\t\t\t\t\t// If segment already has 'path', it's already in the new format\n\t\t\t\t\tif (segment.path !== undefined) return segment\n\n\t\t\t\t\tconst { points, ...rest } = segment\n\t\t\t\t\tconst vecModels = Array.isArray(points) ? points : b64Vecs._legacyDecodePoints(points)\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...rest,\n\t\t\t\t\t\tpath: b64Vecs.encodePoints(vecModels),\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t},\n\t\t\tdown: (_props) => {\n\t\t\t\t// handled by the previous down migration\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tid: Versions.OmitNonPressureZ,\n\t\t\tup: (_props) => {\n\t\t\t\t// No-op \u2014 see the matching draw-shape migration. Absent `dim` reads as 3D.\n\t\t\t},\n\t\t\tdown: (props) => {\n\t\t\t\t// Strip `dim` from every segment that carries it (older clients reject the\n\t\t\t\t// unknown field); 2D segments are re-encoded to 3D, dim: 3 just loses the field.\n\t\t\t\tprops.segments = props.segments.map((segment: any) => {\n\t\t\t\t\tif (segment.dim === undefined) return segment\n\t\t\t\t\tconst { dim, ...rest } = segment\n\t\t\t\t\treturn dim === DIM_2D\n\t\t\t\t\t\t? { ...rest, path: b64Vecs.encodePoints(b64Vecs.decodePoints(segment.path, DIM_2D)) }\n\t\t\t\t\t\t: rest\n\t\t\t\t})\n\t\t\t},\n\t\t},\n\t],\n})\n"],
5
+ "mappings": "AAAA,SAAS,SAAS;AAClB,SAAS,QAAQ,eAAe;AAChC,SAAS,8BAA8B,yCAAyC;AAEhF,SAAS,yBAA8C;AACvD,SAAS,wBAA4C;AAErD,SAAS,wBAA4C;AAqF9C,MAAM,sBAAqD;AAAA,EACjE,OAAO;AAAA,EACP,MAAM;AAAA,EACN,UAAU,EAAE,QAAQ,gBAAgB;AAAA,EACpC,YAAY,EAAE;AAAA,EACd,OAAO,EAAE;AAAA,EACT,OAAO,EAAE;AAAA,EACT,QAAQ,EAAE;AAAA,EACV,QAAQ,EAAE;AACX;AAEA,MAAM,WAAW,6BAA6B,aAAa;AAAA,EAC1D,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,wBAAwB;AAAA,EACxB,kBAAkB;AACnB,CAAC;AAgBM,MAAM,2BAA2B,kCAAkC;AAAA,EACzE,UAAU;AAAA,IACT;AAAA,MACC,IAAI,SAAS;AAAA,MACb,IAAI,CAAC,UAAU;AACd,cAAM,QAAQ;AAAA,MACf;AAAA,MACA,MAAM,CAAC,UAAU;AAChB,eAAO,MAAM;AAAA,MACd;AAAA,IACD;AAAA,IACA;AAAA,MACC,IAAI,SAAS;AAAA,MACb,IAAI,CAAC,UAAU;AAEd,cAAM,WAAW,MAAM,SAAS,IAAI,CAAC,YAAiB;AACrD,cAAI,QAAQ,SAAS,OAAW,QAAO;AACvC,gBAAM,EAAE,QAAQ,GAAG,KAAK,IAAI;AAC5B,gBAAM,YAAY,MAAM,QAAQ,MAAM,IAAI,SAAS,QAAQ,oBAAoB,MAAM;AACrF,iBAAO;AAAA,YACN,GAAG;AAAA,YACH,MAAM,QAAQ,aAAa,SAAS;AAAA,UACrC;AAAA,QACD,CAAC;AACD,cAAM,SAAS,MAAM,UAAU;AAC/B,cAAM,SAAS,MAAM,UAAU;AAAA,MAChC;AAAA,MACA,MAAM,CAAC,UAAU;AAEhB,cAAM,WAAW,MAAM,SAAS,IAAI,CAAC,YAAiB;AACrD,gBAAM,EAAE,MAAM,GAAG,KAAK,IAAI;AAC1B,iBAAO;AAAA,YACN,GAAG;AAAA,YACH,QAAQ,QAAQ,aAAa,IAAI;AAAA,UAClC;AAAA,QACD,CAAC;AACD,eAAO,MAAM;AACb,eAAO,MAAM;AAAA,MACd;AAAA,IACD;AAAA,IACA;AAAA,MACC,IAAI,SAAS;AAAA,MACb,IAAI,CAAC,UAAU;AAGd,cAAM,WAAW,MAAM,SAAS,IAAI,CAAC,YAAiB;AAErD,cAAI,QAAQ,SAAS,OAAW,QAAO;AAEvC,gBAAM,EAAE,QAAQ,GAAG,KAAK,IAAI;AAC5B,gBAAM,YAAY,MAAM,QAAQ,MAAM,IAAI,SAAS,QAAQ,oBAAoB,MAAM;AACrF,iBAAO;AAAA,YACN,GAAG;AAAA,YACH,MAAM,QAAQ,aAAa,SAAS;AAAA,UACrC;AAAA,QACD,CAAC;AAAA,MACF;AAAA,MACA,MAAM,CAAC,WAAW;AAAA,MAElB;AAAA,IACD;AAAA,IACA;AAAA,MACC,IAAI,SAAS;AAAA,MACb,IAAI,CAAC,WAAW;AAAA,MAEhB;AAAA,MACA,MAAM,CAAC,UAAU;AAGhB,cAAM,WAAW,MAAM,SAAS,IAAI,CAAC,YAAiB;AACrD,cAAI,QAAQ,QAAQ,OAAW,QAAO;AACtC,gBAAM,EAAE,KAAK,GAAG,KAAK,IAAI;AACzB,iBAAO,QAAQ,SACZ,EAAE,GAAG,MAAM,MAAM,QAAQ,aAAa,QAAQ,aAAa,QAAQ,MAAM,MAAM,CAAC,EAAE,IAClF;AAAA,QACJ,CAAC;AAAA,MACF;AAAA,IACD;AAAA,EACD;AACD,CAAC;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tldraw/tlschema",
3
3
  "description": "tldraw infinite canvas SDK (schema).",
4
- "version": "5.2.0-canary.cfef7b16ad41",
4
+ "version": "5.2.0-canary.d28cf4347708",
5
5
  "author": {
6
6
  "name": "tldraw Inc.",
7
7
  "email": "hello@tldraw.com"
@@ -51,10 +51,10 @@
51
51
  "vitest": "^4.1.7"
52
52
  },
53
53
  "dependencies": {
54
- "@tldraw/state": "5.2.0-canary.cfef7b16ad41",
55
- "@tldraw/store": "5.2.0-canary.cfef7b16ad41",
56
- "@tldraw/utils": "5.2.0-canary.cfef7b16ad41",
57
- "@tldraw/validate": "5.2.0-canary.cfef7b16ad41"
54
+ "@tldraw/state": "5.2.0-canary.d28cf4347708",
55
+ "@tldraw/store": "5.2.0-canary.d28cf4347708",
56
+ "@tldraw/utils": "5.2.0-canary.d28cf4347708",
57
+ "@tldraw/validate": "5.2.0-canary.d28cf4347708"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "react": "^18.2.0 || ^19.2.1",
package/src/index.ts CHANGED
@@ -361,4 +361,4 @@ registerTldrawLibraryVersion(
361
361
  (globalThis as any).TLDRAW_LIBRARY_MODULES
362
362
  )
363
363
 
364
- export { b64Vecs } from './misc/b64Vecs'
364
+ export { DIM_2D, DIM_3D, b64Vecs } from './misc/b64Vecs'
@@ -18,7 +18,7 @@ import { TLShape, rootShapeVersions } from './records/TLShape'
18
18
  import { userVersions } from './records/TLUser'
19
19
  import { arrowShapeVersions } from './shapes/TLArrowShape'
20
20
  import { bookmarkShapeVersions } from './shapes/TLBookmarkShape'
21
- import { drawShapeVersions } from './shapes/TLDrawShape'
21
+ import { DrawShapeSegment, drawShapeVersions } from './shapes/TLDrawShape'
22
22
  import { embedShapeVersions } from './shapes/TLEmbedShape'
23
23
  import { frameShapeVersions } from './shapes/TLFrameShape'
24
24
  import { geoShapeVersions } from './shapes/TLGeoShape'
@@ -2647,6 +2647,119 @@ describe('TLUser initial migration', () => {
2647
2647
  })
2648
2648
  })
2649
2649
 
2650
+ describe('OmitNonPressureZ migration for draw shape', () => {
2651
+ const { up, down } = getTestMigration(drawShapeVersions.OmitNonPressureZ)
2652
+
2653
+ test('up is a no-op for 3D segments (absent dim reads as 3D)', () => {
2654
+ const path = b64Vecs.encodePoints([
2655
+ { x: 0, y: 0, z: 0.5 },
2656
+ { x: 10, y: 10, z: 0.5 },
2657
+ ])
2658
+ const result = up({ props: { segments: [{ type: 'free', path }] } })
2659
+ expect(result.props.segments[0]).toEqual({ type: 'free', path })
2660
+ })
2661
+
2662
+ test('up leaves a 2D segment untouched', () => {
2663
+ const path = b64Vecs.encodePoints2D([
2664
+ { x: 0, y: 0, z: 0.5 },
2665
+ { x: 10, y: 10, z: 0.5 },
2666
+ ])
2667
+ const result = up({ props: { segments: [{ type: 'free', path, dim: 2 }] } })
2668
+ expect(result.props.segments[0]).toEqual({ type: 'free', path, dim: 2 })
2669
+ })
2670
+
2671
+ test('down re-encodes a 2D segment to 3D and strips dim', () => {
2672
+ const points = [
2673
+ { x: 5, y: 6, z: 0.5 },
2674
+ { x: 12, y: 9, z: 0.5 },
2675
+ { x: 20, y: 4, z: 0.5 },
2676
+ ]
2677
+ const result = down({
2678
+ props: { segments: [{ type: 'free', path: b64Vecs.encodePoints2D(points), dim: 2 }] },
2679
+ })
2680
+
2681
+ expect(result.props.segments[0].dim).toBeUndefined()
2682
+ const decoded = b64Vecs.decodePoints(result.props.segments[0].path)
2683
+ expect(decoded.length).toBe(3)
2684
+ expect(decoded[2].x).toBeCloseTo(20, 0)
2685
+ expect(decoded[0].z).toBe(0.5)
2686
+ })
2687
+
2688
+ test('down leaves a 3D segment (no dim) unchanged', () => {
2689
+ const path = b64Vecs.encodePoints([
2690
+ { x: 0, y: 0, z: 0.5 },
2691
+ { x: 10, y: 10, z: 0.6 },
2692
+ ])
2693
+ const result = down({ props: { segments: [{ type: 'free', path }] } })
2694
+ expect(result.props.segments[0]).toEqual({ type: 'free', path })
2695
+ })
2696
+
2697
+ test('down strips an explicit dim: 3 without re-encoding the (already 3D) path', () => {
2698
+ const path = b64Vecs.encodePoints([
2699
+ { x: 1, y: 2, z: 0.6 },
2700
+ { x: 3, y: 4, z: 0.7 },
2701
+ ])
2702
+ const result = down({ props: { segments: [{ type: 'free', path, dim: 3 }] } })
2703
+ expect(result.props.segments[0].dim).toBeUndefined()
2704
+ expect(result.props.segments[0].path).toBe(path) // already 3D — field dropped, bytes untouched
2705
+ })
2706
+
2707
+ test('down handles mixed 2D and 3D segments', () => {
2708
+ const path3D = b64Vecs.encodePoints([
2709
+ { x: 1, y: 2, z: 0.6 },
2710
+ { x: 3, y: 4, z: 0.7 },
2711
+ ])
2712
+ const path2D = b64Vecs.encodePoints2D([
2713
+ { x: 5, y: 6, z: 0.5 },
2714
+ { x: 7, y: 8, z: 0.5 },
2715
+ ])
2716
+ const result = down({
2717
+ props: {
2718
+ segments: [
2719
+ { type: 'free', path: path3D },
2720
+ { type: 'straight', path: path2D, dim: 2 },
2721
+ ],
2722
+ },
2723
+ })
2724
+ expect(result.props.segments[0]).toEqual({ type: 'free', path: path3D })
2725
+ expect(result.props.segments[1].dim).toBeUndefined()
2726
+ expect(b64Vecs.decodePoints(result.props.segments[1].path).length).toBe(2)
2727
+ })
2728
+ })
2729
+
2730
+ describe('OmitNonPressureZ migration for highlight shape', () => {
2731
+ const { up, down } = getTestMigration(highlightShapeVersions.OmitNonPressureZ)
2732
+
2733
+ test('up is a no-op', () => {
2734
+ const path = b64Vecs.encodePoints2D([
2735
+ { x: 0, y: 0, z: 0.5 },
2736
+ { x: 10, y: 10, z: 0.5 },
2737
+ ])
2738
+ const result = up({ props: { segments: [{ type: 'free', path, dim: 2 }] } })
2739
+ expect(result.props.segments[0]).toEqual({ type: 'free', path, dim: 2 })
2740
+ })
2741
+
2742
+ test('down re-encodes a 2D segment to 3D and strips dim', () => {
2743
+ const path2D = b64Vecs.encodePoints2D([
2744
+ { x: 5, y: 6, z: 0.5 },
2745
+ { x: 12, y: 9, z: 0.5 },
2746
+ ])
2747
+ const result = down({ props: { segments: [{ type: 'free', path: path2D, dim: 2 }] } })
2748
+ expect(result.props.segments[0].dim).toBeUndefined()
2749
+ expect(b64Vecs.decodePoints(result.props.segments[0].path).length).toBe(2)
2750
+ })
2751
+ })
2752
+
2753
+ describe('DrawShapeSegment dim validation', () => {
2754
+ test('accepts dim 2, 3, or absent; rejects other values', () => {
2755
+ expect(() => DrawShapeSegment.validate({ type: 'free', path: 'AAAA', dim: 2 })).not.toThrow()
2756
+ expect(() => DrawShapeSegment.validate({ type: 'free', path: 'AAAA', dim: 3 })).not.toThrow()
2757
+ expect(() => DrawShapeSegment.validate({ type: 'free', path: 'AAAA' })).not.toThrow()
2758
+ expect(() => DrawShapeSegment.validate({ type: 'free', path: 'AAAA', dim: 0 })).toThrow()
2759
+ expect(() => DrawShapeSegment.validate({ type: 'free', path: 'AAAA', dim: 4 })).toThrow()
2760
+ })
2761
+ })
2762
+
2650
2763
  /* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
2651
2764
 
2652
2765
  // check that all migrator fns were called at least once
@@ -478,3 +478,115 @@ describe('b64Vecs legacy encoding', () => {
478
478
  expect(uniqueXValues.size).toBeLessThanOrEqual(2) // Should collapse to 1-2 values, not 4
479
479
  })
480
480
  })
481
+
482
+ describe('2D encoding (skip-pressure)', () => {
483
+ it('round-trips x and y, supplies z = 0.5', () => {
484
+ const points: VecModel[] = [
485
+ { x: 1000, y: 2000, z: 0.5 },
486
+ { x: 1013, y: 2008, z: 0.5 },
487
+ { x: 1027, y: 2011, z: 0.5 },
488
+ ]
489
+ const decoded = b64Vecs.decodePoints2D(b64Vecs.encodePoints2D(points))
490
+ expect(decoded.length).toBe(3)
491
+ for (let i = 0; i < points.length; i++) {
492
+ expect(decoded[i].x).toBeCloseTo(points[i].x, 1)
493
+ expect(decoded[i].y).toBeCloseTo(points[i].y, 1)
494
+ expect(decoded[i].z).toBe(0.5)
495
+ }
496
+ })
497
+
498
+ it('encodePoints/decodePoints route through 2D when dim = 2', () => {
499
+ const points: VecModel[] = [
500
+ { x: 10, y: 20, z: 0.5 },
501
+ { x: 15, y: 28, z: 0.5 },
502
+ ]
503
+ expect(b64Vecs.encodePoints(points, 2)).toBe(b64Vecs.encodePoints2D(points))
504
+ const decoded = b64Vecs.decodePoints(b64Vecs.encodePoints(points, 2), 2)
505
+ expect(decoded[1].z).toBe(0.5)
506
+ expect(decoded[1].x).toBeCloseTo(15, 1)
507
+ })
508
+
509
+ it('is ~33% smaller than 3D for the same points', () => {
510
+ const points: VecModel[] = Array.from({ length: 100 }, (_, i) => ({
511
+ x: i * 2.5,
512
+ y: 50 + i,
513
+ z: 0.5,
514
+ }))
515
+ const bytes3D = fallbackBase64ToUint8Array(b64Vecs.encodePoints(points)).length
516
+ const bytes2D = fallbackBase64ToUint8Array(b64Vecs.encodePoints2D(points)).length
517
+ const saving = 1 - bytes2D / bytes3D
518
+ expect(saving).toBeGreaterThan(0.3)
519
+ expect(saving).toBeLessThan(0.36)
520
+ })
521
+
522
+ it('decodeFirstPoint2D / decodeLastPoint2D match decodePoints2D (incl. via dim routing)', () => {
523
+ const points: VecModel[] = [
524
+ { x: 5, y: 6, z: 0.5 },
525
+ { x: 12, y: 9, z: 0.5 },
526
+ { x: 20, y: 4, z: 0.5 },
527
+ ]
528
+ const path = b64Vecs.encodePoints(points, 2)
529
+ const all = b64Vecs.decodePoints2D(path)
530
+ expect(b64Vecs.decodeFirstPoint(path, 2)).toEqual(all[0])
531
+ expect(b64Vecs.decodeLastPoint(path, 2)).toEqual(all[all.length - 1])
532
+ })
533
+
534
+ it('empty input returns empty', () => {
535
+ expect(b64Vecs.encodePoints2D([])).toBe('')
536
+ expect(b64Vecs.decodePoints2D('')).toEqual([])
537
+ })
538
+ })
539
+
540
+ describe('padding-aware fallback base64', () => {
541
+ it('matches Node Buffer for every trailing-byte remainder', () => {
542
+ for (let len = 0; len < 32; len++) {
543
+ const bytes = new Uint8Array(len)
544
+ for (let i = 0; i < len; i++) bytes[i] = (i * 37 + 11) & 0xff
545
+ expect(fallbackUint8ArrayToBase64(bytes)).toBe(Buffer.from(bytes).toString('base64'))
546
+ expect(fallbackBase64ToUint8Array(fallbackUint8ArrayToBase64(bytes))).toEqual(bytes)
547
+ }
548
+ })
549
+
550
+ it('round-trips 2D buffer lengths (8 + 4(n-1) bytes)', () => {
551
+ for (const n of [1, 2, 3, 4, 5]) {
552
+ const len = 8 + (n - 1) * 4
553
+ const bytes = new Uint8Array(len)
554
+ for (let i = 0; i < len; i++) bytes[i] = (i * 53 + 7) & 0xff
555
+ expect(fallbackBase64ToUint8Array(fallbackUint8ArrayToBase64(bytes))).toEqual(bytes)
556
+ }
557
+ })
558
+ })
559
+
560
+ describe('isSinglePoint', () => {
561
+ const mk = (n: number, dim?: 2 | 3) =>
562
+ b64Vecs.encodePoints(
563
+ Array.from({ length: n }, (_, i) => ({ x: i, y: i, z: 0.5 })),
564
+ dim
565
+ )
566
+
567
+ it('is true only for a single point, across 2D and 3D', () => {
568
+ expect(b64Vecs.isSinglePoint(mk(1, 3), 3)).toBe(true)
569
+ expect(b64Vecs.isSinglePoint(mk(2, 3), 3)).toBe(false)
570
+ expect(b64Vecs.isSinglePoint(mk(1, 2), 2)).toBe(true)
571
+ expect(b64Vecs.isSinglePoint(mk(2, 2), 2)).toBe(false)
572
+ expect(b64Vecs.isSinglePoint(mk(3, 2), 2)).toBe(false)
573
+ })
574
+
575
+ it('regression: a 2-point 2D stroke is not a dot (the old `< 24` check failed here)', () => {
576
+ const twoPoint2D = b64Vecs.encodePoints(
577
+ [
578
+ { x: 0, y: 0, z: 0.5 },
579
+ { x: 4, y: 1, z: 0.5 },
580
+ ],
581
+ 2
582
+ )
583
+ expect(twoPoint2D.length).toBe(16) // same length as a 3D dot
584
+ expect(twoPoint2D.length < 24).toBe(true) // the old buggy heuristic would call this a dot
585
+ expect(b64Vecs.isSinglePoint(twoPoint2D, 2)).toBe(false) // the fix
586
+ })
587
+
588
+ it('defaults to 3D when dim is omitted', () => {
589
+ expect(b64Vecs.isSinglePoint(mk(1), undefined)).toBe(true)
590
+ expect(b64Vecs.isSinglePoint(mk(2), undefined)).toBe(false)
591
+ })
592
+ })
@@ -1,4 +1,3 @@
1
- import { assert } from '@tldraw/utils'
2
1
  import { VecModel } from './geometry-types'
3
2
 
4
3
  // Each point = 3 Float16s = 6 bytes = 8 base64 chars (legacy format)
@@ -7,6 +6,17 @@ const _POINT_B64_LENGTH = 8
7
6
  // First point in delta encoding = 3 Float32s = 12 bytes = 16 base64 chars
8
7
  const FIRST_POINT_B64_LENGTH = 16
9
8
 
9
+ // First point in 2D delta encoding = 2 Float32s = 8 bytes = 12 base64 chars (incl. padding)
10
+ const FIRST_POINT_2D_B64_LENGTH = 12
11
+
12
+ // Pressure value supplied when decoding non-pressure (2D) paths
13
+ const DEFAULT_PRESSURE = 0.5
14
+
15
+ /** Draw segment path encoded with 2 dimensions, XY — the constant pressure Z is dropped. @public */
16
+ export const DIM_2D = 2
17
+ /** Draw segment path encoded with 3 dimensions, XYZ. @public */
18
+ export const DIM_3D = 3
19
+
10
20
  // O(1) lookup table for base64 decoding (maps char code -> 6-bit value)
11
21
  const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
12
22
  const B64_LOOKUP = new Uint8Array(128)
@@ -14,6 +24,11 @@ for (let i = 0; i < 64; i++) {
14
24
  B64_LOOKUP[BASE64_CHARS.charCodeAt(i)] = i
15
25
  }
16
26
 
27
+ // Mask for one base64 sextet: the low 6 bits select an index into BASE64_CHARS (0–63).
28
+ const SIX_BIT_MASK = 0x3f
29
+ // '=' padding character, appended on encode so the output length is a multiple of 4.
30
+ const PADDING_CHAR_CODE = '='.charCodeAt(0)
31
+
17
32
  // Precomputed powers of 2 for Float16 exponents (exp - 15, so indices 0-30 map to 2^-15 to 2^15)
18
33
  const POW2 = new Float64Array(31)
19
34
  for (let i = 0; i < 31; i++) {
@@ -67,11 +82,25 @@ function nativeBase64ToUint8Array(base64: string): Uint8Array {
67
82
 
68
83
  /** @internal */
69
84
  export function fallbackBase64ToUint8Array(base64: string): Uint8Array {
70
- const numBytes = Math.floor((base64.length * 3) / 4)
85
+ // Strip up to 2 '=' padding characters to determine the real byte count.
86
+ // The 2D point layout (8 + 4(n-1) bytes) is not a multiple of 3, so encoded
87
+ // paths can carry padding the original multiple-of-3-only decoder couldn't read.
88
+ const paddedLength = base64.length
89
+ let padding = 0
90
+ if (paddedLength > 0 && base64.charCodeAt(paddedLength - 1) === PADDING_CHAR_CODE) {
91
+ padding++
92
+ if (paddedLength > 1 && base64.charCodeAt(paddedLength - 2) === PADDING_CHAR_CODE) {
93
+ padding++
94
+ }
95
+ }
96
+ const numBytes = Math.floor((paddedLength * 3) / 4) - padding
71
97
  const bytes = new Uint8Array(numBytes)
72
98
  let byteIndex = 0
73
99
 
74
- for (let i = 0; i < base64.length; i += 4) {
100
+ // The reverse of encoding: each 4 chars are 4 six-bit values that pack back into
101
+ // one 24-bit number, which we then read out as 3 bytes (& 255 keeps one byte).
102
+ const fullGroups = Math.floor((paddedLength - padding) / 4) * 4
103
+ for (let i = 0; i < fullGroups; i += 4) {
75
104
  const c0 = B64_LOOKUP[base64.charCodeAt(i)]
76
105
  const c1 = B64_LOOKUP[base64.charCodeAt(i + 1)]
77
106
  const c2 = B64_LOOKUP[base64.charCodeAt(i + 2)]
@@ -79,9 +108,24 @@ export function fallbackBase64ToUint8Array(base64: string): Uint8Array {
79
108
 
80
109
  const bitmap = (c0 << 18) | (c1 << 12) | (c2 << 6) | c3
81
110
 
111
+ bytes[byteIndex++] = (bitmap >> 16) & 255 // top byte (bits 23–16)
112
+ bytes[byteIndex++] = (bitmap >> 8) & 255 // middle byte (bits 15–8)
113
+ bytes[byteIndex++] = bitmap & 255 // bottom byte (bits 7–0)
114
+ }
115
+
116
+ // Final group when padded: 3 valid chars -> 2 bytes, 2 valid chars -> 1 byte.
117
+ if (padding === 1) {
118
+ const c0 = B64_LOOKUP[base64.charCodeAt(fullGroups)]
119
+ const c1 = B64_LOOKUP[base64.charCodeAt(fullGroups + 1)]
120
+ const c2 = B64_LOOKUP[base64.charCodeAt(fullGroups + 2)]
121
+ const bitmap = (c0 << 18) | (c1 << 12) | (c2 << 6)
82
122
  bytes[byteIndex++] = (bitmap >> 16) & 255
83
123
  bytes[byteIndex++] = (bitmap >> 8) & 255
84
- bytes[byteIndex++] = bitmap & 255
124
+ } else if (padding === 2) {
125
+ const c0 = B64_LOOKUP[base64.charCodeAt(fullGroups)]
126
+ const c1 = B64_LOOKUP[base64.charCodeAt(fullGroups + 1)]
127
+ const bitmap = (c0 << 18) | (c1 << 12)
128
+ bytes[byteIndex++] = (bitmap >> 16) & 255
85
129
  }
86
130
 
87
131
  return bytes
@@ -93,21 +137,48 @@ function nativeUint8ArrayToBase64(uint8Array: Uint8Array): string {
93
137
 
94
138
  /** @internal */
95
139
  export function fallbackUint8ArrayToBase64(uint8Array: Uint8Array): string {
96
- assert(uint8Array.length % 3 === 0, 'Uint8Array length must be a multiple of 3')
140
+ const len = uint8Array.length
141
+ const fullGroups = Math.floor(len / 3) * 3
97
142
  let result = ''
98
143
 
99
- // Process bytes in groups of 3 -> 4 base64 chars
100
- for (let i = 0; i < uint8Array.length; i += 3) {
144
+ // base64 represents 3 bytes (24 bits) as 4 characters of 6 bits each. For each
145
+ // group of 3 bytes we pack them into one 24-bit number, then read it back out as
146
+ // four 6-bit slices and use each slice (a value 0–63) to index into the 64-char
147
+ // alphabet. `>> n` shifts the wanted slice down to the bottom; SIX_BIT_MASK then
148
+ // discards everything above those 6 bits.
149
+ for (let i = 0; i < fullGroups; i += 3) {
101
150
  const byte1 = uint8Array[i]
102
151
  const byte2 = uint8Array[i + 1]
103
152
  const byte3 = uint8Array[i + 2]
104
153
 
105
154
  const bitmap = (byte1 << 16) | (byte2 << 8) | byte3
106
155
  result +=
107
- BASE64_CHARS[(bitmap >> 18) & 63] +
108
- BASE64_CHARS[(bitmap >> 12) & 63] +
109
- BASE64_CHARS[(bitmap >> 6) & 63] +
110
- BASE64_CHARS[bitmap & 63]
156
+ BASE64_CHARS[(bitmap >> 18) & SIX_BIT_MASK] + // bits 23–18 (top sextet)
157
+ BASE64_CHARS[(bitmap >> 12) & SIX_BIT_MASK] + // bits 17–12
158
+ BASE64_CHARS[(bitmap >> 6) & SIX_BIT_MASK] + // bits 11–6
159
+ BASE64_CHARS[bitmap & SIX_BIT_MASK] // bits 5–0 (bottom sextet)
160
+ }
161
+
162
+ // A trailing 1 or 2 bytes can't fill a whole 4-char group, so we emit only the
163
+ // chars their bits cover and pad the rest with '=' to keep the length a multiple
164
+ // of 4. Standard base64 — matches the native API and Node's Buffer, so a path
165
+ // encoded by the fallback round-trips on a runtime that decodes with the native one.
166
+ const remaining = len - fullGroups
167
+ if (remaining === 1) {
168
+ // 8 bits → 2 sextets (the 2nd only partly filled), then "=="
169
+ const bitmap = uint8Array[fullGroups] << 16
170
+ result +=
171
+ BASE64_CHARS[(bitmap >> 18) & SIX_BIT_MASK] +
172
+ BASE64_CHARS[(bitmap >> 12) & SIX_BIT_MASK] +
173
+ '=='
174
+ } else if (remaining === 2) {
175
+ // 16 bits → 3 sextets (the 3rd only partly filled), then "="
176
+ const bitmap = (uint8Array[fullGroups] << 16) | (uint8Array[fullGroups + 1] << 8)
177
+ result +=
178
+ BASE64_CHARS[(bitmap >> 18) & SIX_BIT_MASK] +
179
+ BASE64_CHARS[(bitmap >> 12) & SIX_BIT_MASK] +
180
+ BASE64_CHARS[(bitmap >> 6) & SIX_BIT_MASK] +
181
+ '='
111
182
  }
112
183
 
113
184
  return result
@@ -299,10 +370,12 @@ export class b64Vecs {
299
370
  * - Delta points: 3 Float16 values each = 6 bytes = 8 base64 chars each
300
371
  *
301
372
  * @param points - An array of VecModel objects to encode
373
+ * @param dim - Encoding dimension; `2` routes through the 2D variant (drops z), `3` (default) keeps x, y, z
302
374
  * @returns A base64-encoded string containing delta-encoded points
303
375
  * @public
304
376
  */
305
- static encodePoints(points: VecModel[]): string {
377
+ static encodePoints(points: VecModel[], dim?: 2 | 3): string {
378
+ if (dim === DIM_2D) return b64Vecs.encodePoints2D(points)
306
379
  if (points.length === 0) return ''
307
380
 
308
381
  // First point: 3 Float32s = 12 bytes
@@ -348,10 +421,12 @@ export class b64Vecs {
348
421
  * Float16 deltas that are accumulated to reconstruct absolute positions.
349
422
  *
350
423
  * @param base64 - The base64-encoded string containing delta-encoded point data
424
+ * @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z
351
425
  * @returns An array of VecModel objects with absolute coordinates
352
426
  * @public
353
427
  */
354
- static decodePoints(base64: string): VecModel[] {
428
+ static decodePoints(base64: string, dim?: 2 | 3): VecModel[] {
429
+ if (dim === DIM_2D) return b64Vecs.decodePoints2D(base64)
355
430
  if (base64.length === 0) return []
356
431
 
357
432
  const bytes = base64ToUint8Array(base64)
@@ -381,10 +456,12 @@ export class b64Vecs {
381
456
  * The first point is stored as Float32 for full precision.
382
457
  *
383
458
  * @param b64Points - The delta-encoded base64 string
459
+ * @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z
384
460
  * @returns The first point as a VecModel, or null if the string is too short
385
461
  * @public
386
462
  */
387
- static decodeFirstPoint(b64Points: string): VecModel | null {
463
+ static decodeFirstPoint(b64Points: string, dim?: 2 | 3): VecModel | null {
464
+ if (dim === DIM_2D) return b64Vecs.decodeFirstPoint2D(b64Points)
388
465
  // First point needs 16 base64 chars (12 bytes as Float32)
389
466
  if (b64Points.length < FIRST_POINT_B64_LENGTH) return null
390
467
 
@@ -403,10 +480,12 @@ export class b64Vecs {
403
480
  * Requires decoding all points to accumulate deltas.
404
481
  *
405
482
  * @param b64Points - The delta-encoded base64 string
483
+ * @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z
406
484
  * @returns The last point as a VecModel, or null if the string is too short
407
485
  * @public
408
486
  */
409
- static decodeLastPoint(b64Points: string): VecModel | null {
487
+ static decodeLastPoint(b64Points: string, dim?: 2 | 3): VecModel | null {
488
+ if (dim === DIM_2D) return b64Vecs.decodeLastPoint2D(b64Points)
410
489
  if (b64Points.length < FIRST_POINT_B64_LENGTH) return null
411
490
 
412
491
  const bytes = base64ToUint8Array(b64Points)
@@ -427,4 +506,140 @@ export class b64Vecs {
427
506
 
428
507
  return { x, y, z }
429
508
  }
509
+
510
+ /**
511
+ * Encode an array of VecModels as 2D delta-encoded points, dropping z entirely.
512
+ * Use for draw shapes from devices that don't report pressure, where z is a
513
+ * constant 0.5 and storing it wastes ~33% of per-point bytes.
514
+ *
515
+ * Format:
516
+ * - First point: 2 Float32 values (x, y) = 8 bytes
517
+ * - Delta points: 2 Float16 values (dx, dy) = 4 bytes each
518
+ *
519
+ * @param points - An array of VecModel objects to encode (z is discarded)
520
+ * @returns A base64-encoded string containing 2D delta-encoded points
521
+ * @public
522
+ */
523
+ static encodePoints2D(points: VecModel[]): string {
524
+ if (points.length === 0) return ''
525
+
526
+ const firstPointBytes = 8
527
+ const deltaBytes = (points.length - 1) * 4
528
+ const buffer = new Uint8Array(firstPointBytes + deltaBytes)
529
+ const dataView = new DataView(buffer.buffer)
530
+
531
+ const first = points[0]
532
+ dataView.setFloat32(0, first.x, true)
533
+ dataView.setFloat32(4, first.y, true)
534
+
535
+ let prevX = first.x
536
+ let prevY = first.y
537
+
538
+ for (let i = 1; i < points.length; i++) {
539
+ const p = points[i]
540
+ const offset = firstPointBytes + (i - 1) * 4
541
+ setFloat16(dataView, offset, p.x - prevX)
542
+ setFloat16(dataView, offset + 2, p.y - prevY)
543
+ prevX = p.x
544
+ prevY = p.y
545
+ }
546
+
547
+ return uint8ArrayToBase64(buffer)
548
+ }
549
+
550
+ /**
551
+ * Decode a 2D delta-encoded base64 string back to an array of absolute VecModels.
552
+ * The z coordinate is always set to 0.5 (the default pressure value) so downstream
553
+ * consumers don't need a separate code path.
554
+ *
555
+ * @param base64 - The base64-encoded string containing 2D delta-encoded point data
556
+ * @returns An array of VecModel objects with absolute (x, y) and z = 0.5
557
+ * @public
558
+ */
559
+ static decodePoints2D(base64: string): VecModel[] {
560
+ if (base64.length === 0) return []
561
+
562
+ const bytes = base64ToUint8Array(base64)
563
+ const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
564
+ const result: VecModel[] = []
565
+
566
+ let x = dataView.getFloat32(0, true)
567
+ let y = dataView.getFloat32(4, true)
568
+ result.push({ x, y, z: DEFAULT_PRESSURE })
569
+
570
+ const firstPointBytes = 8
571
+ for (let offset = firstPointBytes; offset < bytes.length; offset += 4) {
572
+ x += getFloat16(dataView, offset)
573
+ y += getFloat16(dataView, offset + 2)
574
+ result.push({ x, y, z: DEFAULT_PRESSURE })
575
+ }
576
+
577
+ return result
578
+ }
579
+
580
+ /**
581
+ * Get the first point from a 2D delta-encoded base64 string.
582
+ *
583
+ * @param b64Points - The 2D delta-encoded base64 string
584
+ * @returns The first point with z = 0.5, or null if the string is too short
585
+ * @public
586
+ */
587
+ static decodeFirstPoint2D(b64Points: string): VecModel | null {
588
+ if (b64Points.length < FIRST_POINT_2D_B64_LENGTH) return null
589
+
590
+ const bytes = base64ToUint8Array(b64Points.slice(0, FIRST_POINT_2D_B64_LENGTH))
591
+ const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
592
+
593
+ return {
594
+ x: dataView.getFloat32(0, true),
595
+ y: dataView.getFloat32(4, true),
596
+ z: DEFAULT_PRESSURE,
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Get the last point from a 2D delta-encoded base64 string.
602
+ * Requires decoding all points to accumulate deltas.
603
+ *
604
+ * @param b64Points - The 2D delta-encoded base64 string
605
+ * @returns The last point with z = 0.5, or null if the string is too short
606
+ * @public
607
+ */
608
+ static decodeLastPoint2D(b64Points: string): VecModel | null {
609
+ if (b64Points.length < FIRST_POINT_2D_B64_LENGTH) return null
610
+
611
+ const bytes = base64ToUint8Array(b64Points)
612
+ const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
613
+
614
+ let x = dataView.getFloat32(0, true)
615
+ let y = dataView.getFloat32(4, true)
616
+
617
+ const firstPointBytes = 8
618
+ for (let offset = firstPointBytes; offset < bytes.length; offset += 4) {
619
+ x += getFloat16(dataView, offset)
620
+ y += getFloat16(dataView, offset + 2)
621
+ }
622
+
623
+ return { x, y, z: DEFAULT_PRESSURE }
624
+ }
625
+
626
+ /**
627
+ * Whether an encoded path contains only a single point (a "dot"), inferred from
628
+ * the encoded length without decoding — cheap enough for the render path.
629
+ *
630
+ * The single-point length depends on the encoding dimension, so this takes the
631
+ * segment's `dim`: a one-point path is `FIRST_POINT_B64_LENGTH` chars (3D) or
632
+ * `FIRST_POINT_2D_B64_LENGTH` chars (2D). Keeping this beside the layout constants
633
+ * is deliberate — it is the single source of truth for "how long is one point", so
634
+ * callers never hard-code a length threshold (which silently breaks when a new
635
+ * encoding is added).
636
+ *
637
+ * @param b64Points - The encoded path string
638
+ * @param dim - Encoding dimension; `2` for (x, y), `3` (default) for (x, y, z)
639
+ * @returns true if the path encodes exactly one point
640
+ * @public
641
+ */
642
+ static isSinglePoint(b64Points: string, dim?: 2 | 3): boolean {
643
+ return b64Points.length <= (dim === DIM_2D ? FIRST_POINT_2D_B64_LENGTH : FIRST_POINT_B64_LENGTH)
644
+ }
430
645
  }
@@ -212,7 +212,11 @@ export const InstancePageStateRecordType = createRecordType<TLInstancePageState>
212
212
  ephemeralKeys: {
213
213
  pageId: false,
214
214
  selectedShapeIds: false,
215
- editingShapeId: false,
215
+ // editingShapeId is set with `history: 'ignore'`, so entering the editing
216
+ // state is never undoable. Marking it ephemeral keeps undo/redo from
217
+ // reapplying a stale editingShapeId (e.g. after a shape it pointed at was
218
+ // deleted), which could leave the editor pointing at a missing shape.
219
+ editingShapeId: true,
216
220
  croppingShapeId: false,
217
221
  meta: false,
218
222