@tldraw/tlschema 5.2.0-next.ee0fa4d6244f → 5.2.0
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.
- package/DOCS.md +682 -0
- package/README.md +9 -1
- package/dist-cjs/index.d.ts +85 -11
- package/dist-cjs/index.js +3 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/misc/b64Vecs.js +175 -10
- package/dist-cjs/misc/b64Vecs.js.map +2 -2
- package/dist-cjs/records/TLInstance.js +3 -2
- package/dist-cjs/records/TLInstance.js.map +2 -2
- package/dist-cjs/records/TLPageState.js +5 -1
- package/dist-cjs/records/TLPageState.js.map +2 -2
- package/dist-cjs/records/TLPresence.js +3 -2
- package/dist-cjs/records/TLPresence.js.map +2 -2
- package/dist-cjs/shapes/TLDrawShape.js +16 -2
- package/dist-cjs/shapes/TLDrawShape.js.map +2 -2
- package/dist-cjs/shapes/TLHighlightShape.js +14 -1
- package/dist-cjs/shapes/TLHighlightShape.js.map +2 -2
- package/dist-cjs/shapes/TLNoteShape.js +14 -2
- package/dist-cjs/shapes/TLNoteShape.js.map +2 -2
- package/dist-esm/index.d.mts +85 -11
- package/dist-esm/index.mjs +4 -2
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/misc/b64Vecs.mjs +175 -10
- package/dist-esm/misc/b64Vecs.mjs.map +2 -2
- package/dist-esm/records/TLInstance.mjs +3 -2
- package/dist-esm/records/TLInstance.mjs.map +2 -2
- package/dist-esm/records/TLPageState.mjs +5 -1
- package/dist-esm/records/TLPageState.mjs.map +2 -2
- package/dist-esm/records/TLPresence.mjs +3 -2
- package/dist-esm/records/TLPresence.mjs.map +2 -2
- package/dist-esm/shapes/TLDrawShape.mjs +17 -3
- package/dist-esm/shapes/TLDrawShape.mjs.map +2 -2
- package/dist-esm/shapes/TLHighlightShape.mjs +15 -2
- package/dist-esm/shapes/TLHighlightShape.mjs.map +2 -2
- package/dist-esm/shapes/TLNoteShape.mjs +14 -2
- package/dist-esm/shapes/TLNoteShape.mjs.map +2 -2
- package/package.json +11 -7
- package/src/index.ts +1 -1
- package/src/migrations.test.ts +136 -1
- package/src/misc/b64Vecs.test.ts +112 -0
- package/src/misc/b64Vecs.ts +230 -15
- package/src/records/TLInstance.ts +5 -4
- package/src/records/TLPageState.ts +5 -1
- package/src/records/TLPresence.ts +5 -4
- package/src/shapes/TLDrawShape.ts +30 -1
- package/src/shapes/TLHighlightShape.ts +19 -1
- package/src/shapes/TLNoteShape.ts +15 -3
package/src/migrations.test.ts
CHANGED
|
@@ -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
|
package/src/misc/b64Vecs.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/misc/b64Vecs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
+
const len = uint8Array.length
|
|
141
|
+
const fullGroups = Math.floor(len / 3) * 3
|
|
97
142
|
let result = ''
|
|
98
143
|
|
|
99
|
-
//
|
|
100
|
-
|
|
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) &
|
|
108
|
-
BASE64_CHARS[(bitmap >> 12) &
|
|
109
|
-
BASE64_CHARS[(bitmap >> 6) &
|
|
110
|
-
BASE64_CHARS[bitmap &
|
|
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:
|
|
47
|
-
highlightedUserIds:
|
|
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:
|
|
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(
|
|
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:
|
|
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
|
|