@tldraw/tlschema 5.2.0-next.ee0fa4d6244f → 5.2.1

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.
Files changed (47) hide show
  1. package/DOCS.md +682 -0
  2. package/README.md +9 -1
  3. package/dist-cjs/index.d.ts +85 -11
  4. package/dist-cjs/index.js +3 -1
  5. package/dist-cjs/index.js.map +2 -2
  6. package/dist-cjs/misc/b64Vecs.js +175 -10
  7. package/dist-cjs/misc/b64Vecs.js.map +2 -2
  8. package/dist-cjs/records/TLInstance.js +3 -2
  9. package/dist-cjs/records/TLInstance.js.map +2 -2
  10. package/dist-cjs/records/TLPageState.js +5 -1
  11. package/dist-cjs/records/TLPageState.js.map +2 -2
  12. package/dist-cjs/records/TLPresence.js +3 -2
  13. package/dist-cjs/records/TLPresence.js.map +2 -2
  14. package/dist-cjs/shapes/TLDrawShape.js +16 -2
  15. package/dist-cjs/shapes/TLDrawShape.js.map +2 -2
  16. package/dist-cjs/shapes/TLHighlightShape.js +14 -1
  17. package/dist-cjs/shapes/TLHighlightShape.js.map +2 -2
  18. package/dist-cjs/shapes/TLNoteShape.js +14 -2
  19. package/dist-cjs/shapes/TLNoteShape.js.map +2 -2
  20. package/dist-esm/index.d.mts +85 -11
  21. package/dist-esm/index.mjs +4 -2
  22. package/dist-esm/index.mjs.map +2 -2
  23. package/dist-esm/misc/b64Vecs.mjs +175 -10
  24. package/dist-esm/misc/b64Vecs.mjs.map +2 -2
  25. package/dist-esm/records/TLInstance.mjs +3 -2
  26. package/dist-esm/records/TLInstance.mjs.map +2 -2
  27. package/dist-esm/records/TLPageState.mjs +5 -1
  28. package/dist-esm/records/TLPageState.mjs.map +2 -2
  29. package/dist-esm/records/TLPresence.mjs +3 -2
  30. package/dist-esm/records/TLPresence.mjs.map +2 -2
  31. package/dist-esm/shapes/TLDrawShape.mjs +17 -3
  32. package/dist-esm/shapes/TLDrawShape.mjs.map +2 -2
  33. package/dist-esm/shapes/TLHighlightShape.mjs +15 -2
  34. package/dist-esm/shapes/TLHighlightShape.mjs.map +2 -2
  35. package/dist-esm/shapes/TLNoteShape.mjs +14 -2
  36. package/dist-esm/shapes/TLNoteShape.mjs.map +2 -2
  37. package/package.json +11 -7
  38. package/src/index.ts +1 -1
  39. package/src/migrations.test.ts +136 -1
  40. package/src/misc/b64Vecs.test.ts +112 -0
  41. package/src/misc/b64Vecs.ts +230 -15
  42. package/src/records/TLInstance.ts +5 -4
  43. package/src/records/TLPageState.ts +5 -1
  44. package/src/records/TLPresence.ts +5 -4
  45. package/src/shapes/TLDrawShape.ts +30 -1
  46. package/src/shapes/TLHighlightShape.ts +19 -1
  47. package/src/shapes/TLNoteShape.ts +15 -3
@@ -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'
@@ -2638,6 +2638,28 @@ describe('Adding textFirstEditedBy to note shape', () => {
2638
2638
  })
2639
2639
  })
2640
2640
 
2641
+ describe('Renaming textFirstEditedBy to textLastEditedBy on note shape', () => {
2642
+ const { up, down } = getTestMigration(noteShapeVersions.RenameFirstEditedByToLast)
2643
+
2644
+ test('up renames the prop, preserving its value', () => {
2645
+ expect(up({ props: { textFirstEditedBy: 'user:1' } })).toEqual({
2646
+ props: { textLastEditedBy: 'user:1' },
2647
+ })
2648
+ expect(up({ props: { textFirstEditedBy: null } })).toEqual({
2649
+ props: { textLastEditedBy: null },
2650
+ })
2651
+ })
2652
+
2653
+ test('down renames the prop back, preserving its value', () => {
2654
+ expect(down({ props: { textLastEditedBy: 'user:1' } })).toEqual({
2655
+ props: { textFirstEditedBy: 'user:1' },
2656
+ })
2657
+ expect(down({ props: { textLastEditedBy: null } })).toEqual({
2658
+ props: { textFirstEditedBy: null },
2659
+ })
2660
+ })
2661
+ })
2662
+
2641
2663
  describe('TLUser initial migration', () => {
2642
2664
  const { up } = getTestMigration(userVersions.Initial)
2643
2665
 
@@ -2647,6 +2669,119 @@ describe('TLUser initial migration', () => {
2647
2669
  })
2648
2670
  })
2649
2671
 
2672
+ describe('OmitNonPressureZ migration for draw shape', () => {
2673
+ const { up, down } = getTestMigration(drawShapeVersions.OmitNonPressureZ)
2674
+
2675
+ test('up is a no-op for 3D segments (absent dim reads as 3D)', () => {
2676
+ const path = b64Vecs.encodePoints([
2677
+ { x: 0, y: 0, z: 0.5 },
2678
+ { x: 10, y: 10, z: 0.5 },
2679
+ ])
2680
+ const result = up({ props: { segments: [{ type: 'free', path }] } })
2681
+ expect(result.props.segments[0]).toEqual({ type: 'free', path })
2682
+ })
2683
+
2684
+ test('up leaves a 2D segment untouched', () => {
2685
+ const path = b64Vecs.encodePoints2D([
2686
+ { x: 0, y: 0, z: 0.5 },
2687
+ { x: 10, y: 10, z: 0.5 },
2688
+ ])
2689
+ const result = up({ props: { segments: [{ type: 'free', path, dim: 2 }] } })
2690
+ expect(result.props.segments[0]).toEqual({ type: 'free', path, dim: 2 })
2691
+ })
2692
+
2693
+ test('down re-encodes a 2D segment to 3D and strips dim', () => {
2694
+ const points = [
2695
+ { x: 5, y: 6, z: 0.5 },
2696
+ { x: 12, y: 9, z: 0.5 },
2697
+ { x: 20, y: 4, z: 0.5 },
2698
+ ]
2699
+ const result = down({
2700
+ props: { segments: [{ type: 'free', path: b64Vecs.encodePoints2D(points), dim: 2 }] },
2701
+ })
2702
+
2703
+ expect(result.props.segments[0].dim).toBeUndefined()
2704
+ const decoded = b64Vecs.decodePoints(result.props.segments[0].path)
2705
+ expect(decoded.length).toBe(3)
2706
+ expect(decoded[2].x).toBeCloseTo(20, 0)
2707
+ expect(decoded[0].z).toBe(0.5)
2708
+ })
2709
+
2710
+ test('down leaves a 3D segment (no dim) unchanged', () => {
2711
+ const path = b64Vecs.encodePoints([
2712
+ { x: 0, y: 0, z: 0.5 },
2713
+ { x: 10, y: 10, z: 0.6 },
2714
+ ])
2715
+ const result = down({ props: { segments: [{ type: 'free', path }] } })
2716
+ expect(result.props.segments[0]).toEqual({ type: 'free', path })
2717
+ })
2718
+
2719
+ test('down strips an explicit dim: 3 without re-encoding the (already 3D) path', () => {
2720
+ const path = b64Vecs.encodePoints([
2721
+ { x: 1, y: 2, z: 0.6 },
2722
+ { x: 3, y: 4, z: 0.7 },
2723
+ ])
2724
+ const result = down({ props: { segments: [{ type: 'free', path, dim: 3 }] } })
2725
+ expect(result.props.segments[0].dim).toBeUndefined()
2726
+ expect(result.props.segments[0].path).toBe(path) // already 3D — field dropped, bytes untouched
2727
+ })
2728
+
2729
+ test('down handles mixed 2D and 3D segments', () => {
2730
+ const path3D = b64Vecs.encodePoints([
2731
+ { x: 1, y: 2, z: 0.6 },
2732
+ { x: 3, y: 4, z: 0.7 },
2733
+ ])
2734
+ const path2D = b64Vecs.encodePoints2D([
2735
+ { x: 5, y: 6, z: 0.5 },
2736
+ { x: 7, y: 8, z: 0.5 },
2737
+ ])
2738
+ const result = down({
2739
+ props: {
2740
+ segments: [
2741
+ { type: 'free', path: path3D },
2742
+ { type: 'straight', path: path2D, dim: 2 },
2743
+ ],
2744
+ },
2745
+ })
2746
+ expect(result.props.segments[0]).toEqual({ type: 'free', path: path3D })
2747
+ expect(result.props.segments[1].dim).toBeUndefined()
2748
+ expect(b64Vecs.decodePoints(result.props.segments[1].path).length).toBe(2)
2749
+ })
2750
+ })
2751
+
2752
+ describe('OmitNonPressureZ migration for highlight shape', () => {
2753
+ const { up, down } = getTestMigration(highlightShapeVersions.OmitNonPressureZ)
2754
+
2755
+ test('up is a no-op', () => {
2756
+ const path = b64Vecs.encodePoints2D([
2757
+ { x: 0, y: 0, z: 0.5 },
2758
+ { x: 10, y: 10, z: 0.5 },
2759
+ ])
2760
+ const result = up({ props: { segments: [{ type: 'free', path, dim: 2 }] } })
2761
+ expect(result.props.segments[0]).toEqual({ type: 'free', path, dim: 2 })
2762
+ })
2763
+
2764
+ test('down re-encodes a 2D segment to 3D and strips dim', () => {
2765
+ const path2D = b64Vecs.encodePoints2D([
2766
+ { x: 5, y: 6, z: 0.5 },
2767
+ { x: 12, y: 9, z: 0.5 },
2768
+ ])
2769
+ const result = down({ props: { segments: [{ type: 'free', path: path2D, dim: 2 }] } })
2770
+ expect(result.props.segments[0].dim).toBeUndefined()
2771
+ expect(b64Vecs.decodePoints(result.props.segments[0].path).length).toBe(2)
2772
+ })
2773
+ })
2774
+
2775
+ describe('DrawShapeSegment dim validation', () => {
2776
+ test('accepts dim 2, 3, or absent; rejects other values', () => {
2777
+ expect(() => DrawShapeSegment.validate({ type: 'free', path: 'AAAA', dim: 2 })).not.toThrow()
2778
+ expect(() => DrawShapeSegment.validate({ type: 'free', path: 'AAAA', dim: 3 })).not.toThrow()
2779
+ expect(() => DrawShapeSegment.validate({ type: 'free', path: 'AAAA' })).not.toThrow()
2780
+ expect(() => DrawShapeSegment.validate({ type: 'free', path: 'AAAA', dim: 0 })).toThrow()
2781
+ expect(() => DrawShapeSegment.validate({ type: 'free', path: 'AAAA', dim: 4 })).toThrow()
2782
+ })
2783
+ })
2784
+
2650
2785
  /* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
2651
2786
 
2652
2787
  // 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
  }
@@ -15,6 +15,7 @@ import { scribbleValidator, TLScribble } from '../misc/TLScribble'
15
15
  import { StyleProp } from '../styles/StyleProp'
16
16
  import { pageIdValidator, TLPageId } from './TLPage'
17
17
  import { TLShapeId } from './TLShape'
18
+ import { TLUserId, userIdValidator } from './TLUser'
18
19
 
19
20
  /**
20
21
  * State that is particular to a single browser tab. The TLInstance record stores
@@ -43,8 +44,8 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
43
44
  currentPageId: TLPageId
44
45
  opacityForNextShape: TLOpacityType
45
46
  stylesForNextShape: Record<string, unknown>
46
- followingUserId: string | null
47
- highlightedUserIds: string[]
47
+ followingUserId: TLUserId | null
48
+ highlightedUserIds: TLUserId[]
48
49
  brush: BoxModel | null
49
50
  cursor: TLCursor
50
51
  scribbles: TLScribble[]
@@ -211,7 +212,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
211
212
  typeName: T.literal('instance'),
212
213
  id: idValidator<TLInstanceId>('instance'),
213
214
  currentPageId: pageIdValidator,
214
- followingUserId: T.string.nullable(),
215
+ followingUserId: userIdValidator.nullable(),
215
216
  brush: boxModelValidator.nullable(),
216
217
  opacityForNextShape: opacityValidator,
217
218
  stylesForNextShape: T.object(stylesForNextShapeValidators),
@@ -228,7 +229,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
228
229
  isGridMode: T.boolean,
229
230
  chatMessage: T.string,
230
231
  isChatting: T.boolean,
231
- highlightedUserIds: T.arrayOf(T.string),
232
+ highlightedUserIds: T.arrayOf(userIdValidator),
232
233
  isFocused: T.boolean,
233
234
  devicePixelRatio: T.number,
234
235
  isCoarsePointer: T.boolean,
@@ -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