@tldraw/editor 5.3.1 → 5.4.0-canary.042da660fa66

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 (76) hide show
  1. package/dist-cjs/index.d.ts +97 -52
  2. package/dist-cjs/index.js +4 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +3 -0
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/editor/Editor.js +94 -26
  7. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  8. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +7 -3
  9. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
  10. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +6 -3
  11. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  12. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +6 -3
  13. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
  14. package/dist-cjs/lib/globals/editors.js +57 -0
  15. package/dist-cjs/lib/globals/editors.js.map +7 -0
  16. package/dist-cjs/lib/hooks/useViewportHeight.js.map +2 -2
  17. package/dist-cjs/lib/license/LicenseManager.js +9 -2
  18. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  19. package/dist-cjs/lib/license/LicenseProvider.js +16 -3
  20. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  21. package/dist-cjs/lib/license/useLicenseManagerState.js +5 -4
  22. package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
  23. package/dist-cjs/lib/primitives/geometry/Group2d.js +63 -21
  24. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  25. package/dist-cjs/lib/utils/normalizeWheel.js +2 -0
  26. package/dist-cjs/lib/utils/normalizeWheel.js.map +2 -2
  27. package/dist-cjs/version.js +3 -3
  28. package/dist-cjs/version.js.map +1 -1
  29. package/dist-esm/index.d.mts +97 -52
  30. package/dist-esm/index.mjs +9 -2
  31. package/dist-esm/index.mjs.map +2 -2
  32. package/dist-esm/lib/TldrawEditor.mjs +4 -1
  33. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  34. package/dist-esm/lib/editor/Editor.mjs +94 -26
  35. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  36. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +7 -3
  37. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
  38. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +7 -4
  39. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  40. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +6 -3
  41. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
  42. package/dist-esm/lib/globals/editors.mjs +37 -0
  43. package/dist-esm/lib/globals/editors.mjs.map +7 -0
  44. package/dist-esm/lib/hooks/useViewportHeight.mjs.map +2 -2
  45. package/dist-esm/lib/license/LicenseManager.mjs +9 -2
  46. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  47. package/dist-esm/lib/license/LicenseProvider.mjs +16 -3
  48. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  49. package/dist-esm/lib/license/useLicenseManagerState.mjs +5 -4
  50. package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
  51. package/dist-esm/lib/primitives/geometry/Group2d.mjs +63 -21
  52. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  53. package/dist-esm/lib/utils/normalizeWheel.mjs +2 -0
  54. package/dist-esm/lib/utils/normalizeWheel.mjs.map +2 -2
  55. package/dist-esm/version.mjs +3 -3
  56. package/dist-esm/version.mjs.map +1 -1
  57. package/package.json +13 -7
  58. package/src/index.ts +7 -1
  59. package/src/lib/TldrawEditor.tsx +4 -1
  60. package/src/lib/editor/Editor.ts +136 -37
  61. package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +7 -3
  62. package/src/lib/editor/managers/TextManager/TextManager.test.ts +42 -0
  63. package/src/lib/editor/managers/TextManager/TextManager.ts +10 -8
  64. package/src/lib/editor/managers/TickManager/TickManager.test.ts +13 -0
  65. package/src/lib/editor/managers/TickManager/TickManager.ts +9 -3
  66. package/src/lib/globals/editors.test.ts +76 -0
  67. package/src/lib/globals/editors.ts +53 -0
  68. package/src/lib/hooks/useViewportHeight.ts +9 -1
  69. package/src/lib/license/LicenseManager.test.ts +160 -0
  70. package/src/lib/license/LicenseManager.ts +13 -4
  71. package/src/lib/license/LicenseProvider.tsx +24 -3
  72. package/src/lib/license/useLicenseManagerState.ts +8 -6
  73. package/src/lib/primitives/geometry/Group2d.ts +63 -27
  74. package/src/lib/utils/normalizeWheel.test.ts +39 -0
  75. package/src/lib/utils/normalizeWheel.ts +6 -0
  76. package/src/version.ts +3 -3
@@ -316,6 +316,42 @@ describe('LicenseManager', () => {
316
316
  expect(result.isDomainValid).toBe(true)
317
317
  })
318
318
 
319
+ it('Fails if the wildcard domain is a prefix of the current hostname', async () => {
320
+ // @ts-ignore
321
+ delete window.location
322
+ // @ts-ignore
323
+ window.location = new URL('https://sub.example.com.other.io')
324
+
325
+ const permissiveHostsInfo = JSON.parse(STANDARD_LICENSE_INFO)
326
+ permissiveHostsInfo[PROPERTIES.HOSTS] = ['*.example.com']
327
+ const permissiveLicenseKey = await generateLicenseKey(
328
+ JSON.stringify(permissiveHostsInfo),
329
+ keyPair
330
+ )
331
+ const result = (await licenseManager.getLicenseFromKey(
332
+ permissiveLicenseKey
333
+ )) as ValidLicenseKeyResult
334
+ expect(result.isDomainValid).toBe(false)
335
+ })
336
+
337
+ it('Fails if the current hostname only ends with the wildcard domain', async () => {
338
+ // @ts-ignore
339
+ delete window.location
340
+ // @ts-ignore
341
+ window.location = new URL('https://notexample.com')
342
+
343
+ const permissiveHostsInfo = JSON.parse(STANDARD_LICENSE_INFO)
344
+ permissiveHostsInfo[PROPERTIES.HOSTS] = ['*.example.com']
345
+ const permissiveLicenseKey = await generateLicenseKey(
346
+ JSON.stringify(permissiveHostsInfo),
347
+ keyPair
348
+ )
349
+ const result = (await licenseManager.getLicenseFromKey(
350
+ permissiveLicenseKey
351
+ )) as ValidLicenseKeyResult
352
+ expect(result.isDomainValid).toBe(false)
353
+ })
354
+
319
355
  it('Fails if has a subdomain wildcard isnt for the same base domain', async () => {
320
356
  // @ts-ignore
321
357
  delete window.location
@@ -422,6 +458,107 @@ describe('LicenseManager', () => {
422
458
  expect(result.isDomainValid).toBe(true)
423
459
  })
424
460
 
461
+ it('Is not development mode on a custom protocol', async () => {
462
+ process.env.NODE_ENV = 'production'
463
+ try {
464
+ // @ts-ignore
465
+ delete window.location
466
+ // @ts-ignore
467
+ window.location = new URL('app-bundle://app/index.html')
468
+
469
+ const nativeLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
470
+ nativeLicenseInfo[PROPERTIES.FLAGS] = FLAGS.NATIVE_LICENSE
471
+ nativeLicenseInfo[PROPERTIES.HOSTS] = ['app-bundle:']
472
+ const nativeLicenseKey = await generateLicenseKey(
473
+ JSON.stringify(nativeLicenseInfo),
474
+ keyPair
475
+ )
476
+ const nativeLicenseManager = new LicenseManager('', keyPair.publicKey)
477
+ const result = (await nativeLicenseManager.getLicenseFromKey(
478
+ nativeLicenseKey
479
+ )) as ValidLicenseKeyResult
480
+ expect(result).toMatchObject({
481
+ isLicenseParseable: true,
482
+ isNativeLicense: true,
483
+ isDomainValid: true,
484
+ isDevelopment: false,
485
+ })
486
+ expect(getLicenseState(result, () => {}, result.isDevelopment)).toBe('licensed')
487
+ } finally {
488
+ process.env.NODE_ENV = 'test'
489
+ }
490
+ })
491
+
492
+ it('Validates the domain on a custom protocol', async () => {
493
+ process.env.NODE_ENV = 'production'
494
+ try {
495
+ // @ts-ignore
496
+ delete window.location
497
+ // @ts-ignore
498
+ window.location = new URL('app-bundle://app/index.html')
499
+
500
+ const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
501
+ const customProtocolLicenseManager = new LicenseManager('', keyPair.publicKey)
502
+ const result = (await customProtocolLicenseManager.getLicenseFromKey(
503
+ licenseKey
504
+ )) as ValidLicenseKeyResult
505
+ expect(result).toMatchObject({ isDomainValid: false, isDevelopment: false })
506
+ expect(getLicenseState(result, () => {}, result.isDevelopment)).toBe(
507
+ 'unlicensed-production'
508
+ )
509
+ } finally {
510
+ process.env.NODE_ENV = 'test'
511
+ }
512
+ })
513
+
514
+ it('Is development mode over http on a non-loopback host', async () => {
515
+ process.env.NODE_ENV = 'production'
516
+ try {
517
+ // @ts-ignore
518
+ delete window.location
519
+ // @ts-ignore
520
+ window.location = new URL('http://www.example.com')
521
+
522
+ const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
523
+ const httpLicenseManager = new LicenseManager('', keyPair.publicKey)
524
+ const result = (await httpLicenseManager.getLicenseFromKey(
525
+ licenseKey
526
+ )) as ValidLicenseKeyResult
527
+ expect(result.isDevelopment).toBe(true)
528
+ } finally {
529
+ process.env.NODE_ENV = 'test'
530
+ }
531
+ })
532
+
533
+ it.each([
534
+ ['http://localhost:5173/', true],
535
+ ['http://127.0.0.1:3000/', true],
536
+ ['https://localhost:8443/', true],
537
+ ['https://[::1]:8443/', true],
538
+ ['http://192.168.1.5:3000/', true],
539
+ ['http://staging-box/', true],
540
+ ['https://www.example.com/', false],
541
+ ['tauri://localhost/index.html', false],
542
+ ['http://tauri.localhost/index.html', false],
543
+ ['app://tauri.localhost/com.example.app', false],
544
+ ['app-bundle://localhost/index.html', false],
545
+ ])('Development mode at %s is %s', async (href, expected) => {
546
+ process.env.NODE_ENV = 'production'
547
+ try {
548
+ // @ts-ignore
549
+ delete window.location
550
+ // @ts-ignore
551
+ window.location = new URL(href)
552
+
553
+ const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
554
+ const manager = new LicenseManager('', keyPair.publicKey)
555
+ const result = (await manager.getLicenseFromKey(licenseKey)) as ValidLicenseKeyResult
556
+ expect(result.isDevelopment).toBe(expected)
557
+ } finally {
558
+ process.env.NODE_ENV = 'test'
559
+ }
560
+ })
561
+
425
562
  it('Fails if it is a native app with the wrong protocol', async () => {
426
563
  // @ts-ignore
427
564
  delete window.location
@@ -437,6 +574,29 @@ describe('LicenseManager', () => {
437
574
  )) as ValidLicenseKeyResult
438
575
  expect(result.isDomainValid).toBe(false)
439
576
  })
577
+
578
+ it.each(['tauri://localhost/index.html', 'http://tauri.localhost/index.html'])(
579
+ 'Does not license an unrelated key at %s',
580
+ async (href) => {
581
+ process.env.NODE_ENV = 'production'
582
+ try {
583
+ // @ts-ignore
584
+ delete window.location
585
+ // @ts-ignore
586
+ window.location = new URL(href)
587
+
588
+ const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
589
+ const manager = new LicenseManager('', keyPair.publicKey)
590
+ const result = (await manager.getLicenseFromKey(licenseKey)) as ValidLicenseKeyResult
591
+ expect(result).toMatchObject({ isDevelopment: false, isDomainValid: false })
592
+ expect(getLicenseState(result, () => {}, result.isDevelopment)).toBe(
593
+ 'unlicensed-production'
594
+ )
595
+ } finally {
596
+ process.env.NODE_ENV = 'test'
597
+ }
598
+ }
599
+ )
440
600
  })
441
601
 
442
602
  describe('License types and flags', () => {
@@ -174,10 +174,17 @@ export class LicenseManager {
174
174
  }
175
175
 
176
176
  private getIsDevelopment() {
177
- // If we are using https on a non-loopback domain we assume it's a production env and a development one otherwise
177
+ const protocol = window.location.protocol
178
+ const hostname = window.location.hostname
179
+
180
+ // Tauri uses `tauri://localhost` on macOS and Linux and `http://tauri.localhost` on Windows.
181
+ if (hostname.toLowerCase().endsWith('.localhost')) {
182
+ return process.env.NODE_ENV !== 'production'
183
+ }
184
+
178
185
  return (
179
- !['https:', 'vscode-webview:'].includes(window.location.protocol) ||
180
- this.isLoopbackHost(window.location.hostname) ||
186
+ protocol === 'http:' ||
187
+ (protocol === 'https:' && this.isLoopbackHost(hostname)) ||
181
188
  process.env.NODE_ENV !== 'production'
182
189
  )
183
190
  }
@@ -407,7 +414,9 @@ export class LicenseManager {
407
414
 
408
415
  // Glob testing, we only support '*.somedomain.com' right now.
409
416
  if (host.includes('*')) {
410
- const globToRegex = new RegExp(host.replace(/\*/g, '.*?'))
417
+ const globToRegex = new RegExp(
418
+ normalizedHostOrUrlRegex.replace(/\./g, '\\.').replace(/\*/g, '.*?') + '$'
419
+ )
411
420
  return globToRegex.test(currentHostname) || globToRegex.test(`www.${currentHostname}`)
412
421
  }
413
422
 
@@ -1,13 +1,34 @@
1
1
  import { useValue } from '@tldraw/state-react'
2
2
  import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
3
+ import { useMaybeEditor } from '../hooks/useEditor'
3
4
  import { LicenseManager } from './LicenseManager'
4
5
 
5
6
  /** @internal */
6
- export const LicenseContext = createContext({} as LicenseManager)
7
+ export const LicenseContext = createContext<LicenseManager | null>(null)
7
8
 
8
9
  /** @internal */
9
- export function useLicenseContext() {
10
- return useContext(LicenseContext)
10
+ export function useLicenseContext(): LicenseManager {
11
+ const licenseManager = useMaybeLicenseManager()
12
+ if (!licenseManager) {
13
+ throw new Error(
14
+ 'useLicenseContext must be used inside of the <Tldraw /> or <TldrawEditor /> components, or inside an <EditorProvider /> wrapping an editor created by them'
15
+ )
16
+ }
17
+ return licenseManager
18
+ }
19
+
20
+ /**
21
+ * Returns the license manager for the current editor, or `null` if there is none. Reads the
22
+ * license context when inside `<TldrawEditor />`, and otherwise falls back to the license manager
23
+ * of the nearest editor, so UI mounted outside the editor tree (via `EditorProvider`) resolves the
24
+ * same license as the editor it belongs to.
25
+ *
26
+ * @internal
27
+ */
28
+ export function useMaybeLicenseManager(): LicenseManager | null {
29
+ const licenseManager = useContext(LicenseContext)
30
+ const editor = useMaybeEditor()
31
+ return licenseManager ?? editor?.licenseManager ?? null
11
32
  }
12
33
 
13
34
  function shouldHideEditorAfterDelay(licenseState: string): boolean {
@@ -8,16 +8,18 @@ export function useLicenseManagerState(licenseManager: LicenseManager): LicenseS
8
8
 
9
9
  /**
10
10
  * Reactively reads whether a licensable feature is enabled for the current license. Re-renders when
11
- * license validation resolves.
11
+ * license validation resolves. Returns `false` when there is no license manager, so gated UI stays
12
+ * hidden when mounted without an editor or license context.
12
13
  *
13
14
  * @internal
14
15
  */
15
16
  export function useLicenseFeatureFlag(
16
- licenseManager: LicenseManager,
17
+ licenseManager: LicenseManager | null,
17
18
  feature: LicenseFeatureName
18
19
  ): boolean {
19
- return useValue('licenseFeature', () => licenseManager.isFeatureEnabled(feature), [
20
- licenseManager,
21
- feature,
22
- ])
20
+ return useValue(
21
+ 'licenseFeature',
22
+ () => (licenseManager ? licenseManager.isFeatureEnabled(feature) : false),
23
+ [licenseManager, feature]
24
+ )
23
25
  }
@@ -1,4 +1,3 @@
1
- import { EMPTY_ARRAY } from '@tldraw/state'
2
1
  import { assert, invLerp, lerp } from '@tldraw/utils'
3
2
  import { Box } from '../Box'
4
3
  import { Mat } from '../Mat'
@@ -36,9 +35,15 @@ export class Group2d extends Geometry2d {
36
35
 
37
36
  override getVertices(filters: Geometry2dFilters): Vec[] {
38
37
  if (this.isExcludedByFilter(filters)) return []
39
- return this.children
40
- .filter((c) => !c.isExcludedByFilter(filters))
41
- .flatMap((c) => c.getVertices(filters))
38
+ const vertices: Vec[] = []
39
+ for (const child of this.children) {
40
+ if (child.isExcludedByFilter(filters)) continue
41
+ const childVertices = child.getVertices(filters)
42
+ for (let i = 0, n = childVertices.length; i < n; i++) {
43
+ vertices.push(childVertices[i])
44
+ }
45
+ }
46
+ return vertices
42
47
  }
43
48
 
44
49
  override nearestPoint(point: VecLike, filters?: Geometry2dFilters): Vec {
@@ -84,9 +89,11 @@ export class Group2d extends Geometry2d {
84
89
  hitInside: boolean,
85
90
  filters = Geometry2dFilters.EXCLUDE_LABELS
86
91
  ): boolean {
87
- return !!this.children
88
- .filter((c) => !c.isExcludedByFilter(filters))
89
- .find((c) => c.hitTestPoint(point, margin, hitInside))
92
+ for (const child of this.children) {
93
+ if (child.isExcludedByFilter(filters)) continue
94
+ if (child.hitTestPoint(point, margin, hitInside)) return true
95
+ }
96
+ return false
90
97
  }
91
98
 
92
99
  override hitTestLineSegment(
@@ -95,42 +102,71 @@ export class Group2d extends Geometry2d {
95
102
  zoom: number,
96
103
  filters = Geometry2dFilters.EXCLUDE_LABELS
97
104
  ): boolean {
98
- return !!this.children
99
- .filter((c) => !c.isExcludedByFilter(filters))
100
- .find((c) => c.hitTestLineSegment(A, B, zoom))
105
+ for (const child of this.children) {
106
+ if (child.isExcludedByFilter(filters)) continue
107
+ if (child.hitTestLineSegment(A, B, zoom)) return true
108
+ }
109
+ return false
101
110
  }
102
111
 
103
112
  override intersectLineSegment(A: VecLike, B: VecLike, filters?: Geometry2dFilters) {
104
- return this.children.flatMap((child) => {
105
- if (child.isExcludedByFilter(filters)) return EMPTY_ARRAY
106
- return child.intersectLineSegment(A, B, filters)
107
- })
113
+ const result: VecLike[] = []
114
+ for (const child of this.children) {
115
+ if (child.isExcludedByFilter(filters)) continue
116
+ const hits = child.intersectLineSegment(A, B, filters)
117
+ for (let i = 0, n = hits.length; i < n; i++) {
118
+ result.push(hits[i])
119
+ }
120
+ }
121
+ return result
108
122
  }
109
123
 
110
124
  override intersectCircle(center: VecLike, radius: number, filters?: Geometry2dFilters) {
111
- return this.children.flatMap((child) => {
112
- if (child.isExcludedByFilter(filters)) return EMPTY_ARRAY
113
- return child.intersectCircle(center, radius, filters)
114
- })
125
+ const result: VecLike[] = []
126
+ for (const child of this.children) {
127
+ if (child.isExcludedByFilter(filters)) continue
128
+ const hits = child.intersectCircle(center, radius, filters)
129
+ for (let i = 0, n = hits.length; i < n; i++) {
130
+ result.push(hits[i])
131
+ }
132
+ }
133
+ return result
115
134
  }
116
135
 
117
136
  override getBoundsVertices(): Vec[] {
118
137
  if (this.excludeFromShapeBounds) return []
119
- return this.children.flatMap((child) => child.getBoundsVertices())
138
+ const vertices: Vec[] = []
139
+ for (const child of this.children) {
140
+ const childVertices = child.getBoundsVertices()
141
+ for (let i = 0, n = childVertices.length; i < n; i++) {
142
+ vertices.push(childVertices[i])
143
+ }
144
+ }
145
+ return vertices
120
146
  }
121
147
 
122
148
  override intersectPolygon(polygon: VecLike[], filters?: Geometry2dFilters) {
123
- return this.children.flatMap((child) => {
124
- if (child.isExcludedByFilter(filters)) return EMPTY_ARRAY
125
- return child.intersectPolygon(polygon, filters)
126
- })
149
+ const result: VecLike[] = []
150
+ for (const child of this.children) {
151
+ if (child.isExcludedByFilter(filters)) continue
152
+ const hits = child.intersectPolygon(polygon, filters)
153
+ for (let i = 0, n = hits.length; i < n; i++) {
154
+ result.push(hits[i])
155
+ }
156
+ }
157
+ return result
127
158
  }
128
159
 
129
160
  override intersectPolyline(polyline: VecLike[], filters?: Geometry2dFilters) {
130
- return this.children.flatMap((child) => {
131
- if (child.isExcludedByFilter(filters)) return EMPTY_ARRAY
132
- return child.intersectPolyline(polyline, filters)
133
- })
161
+ const result: VecLike[] = []
162
+ for (const child of this.children) {
163
+ if (child.isExcludedByFilter(filters)) continue
164
+ const hits = child.intersectPolyline(polyline, filters)
165
+ for (let i = 0, n = hits.length; i < n; i++) {
166
+ result.push(hits[i])
167
+ }
168
+ }
169
+ return result
134
170
  }
135
171
 
136
172
  override interpolateAlongEdge(t: number, filters?: Geometry2dFilters): Vec {
@@ -0,0 +1,39 @@
1
+ import { normalizeWheel } from './normalizeWheel'
2
+
3
+ function wheelEvent(props: Partial<WheelEvent>): WheelEvent {
4
+ return {
5
+ deltaX: 0,
6
+ deltaY: 0,
7
+ ctrlKey: false,
8
+ altKey: false,
9
+ metaKey: false,
10
+ shiftKey: false,
11
+ ...props,
12
+ } as WheelEvent
13
+ }
14
+
15
+ describe('normalizeWheel', () => {
16
+ it('negates finite deltas for panning', () => {
17
+ const { x, y } = normalizeWheel(wheelEvent({ deltaX: 5, deltaY: 10 }))
18
+ expect(x).toBe(-5)
19
+ expect(y).toBe(-10)
20
+ })
21
+
22
+ it('coerces a non-finite deltaX to zero movement', () => {
23
+ const { x, y } = normalizeWheel(wheelEvent({ deltaX: NaN, deltaY: 10 }))
24
+ expect(x === 0).toBe(true)
25
+ expect(y).toBe(-10)
26
+ })
27
+
28
+ it('coerces a non-finite deltaY to zero movement', () => {
29
+ const { x, y } = normalizeWheel(wheelEvent({ deltaX: 5, deltaY: Infinity }))
30
+ expect(x).toBe(-5)
31
+ expect(y === 0).toBe(true)
32
+ })
33
+
34
+ it('does not produce a non-finite zoom delta when wheeling with a bad deltaY', () => {
35
+ const { z } = normalizeWheel(wheelEvent({ deltaY: NaN, ctrlKey: true }))
36
+ expect(Number.isFinite(z)).toBe(true)
37
+ expect(z === 0).toBe(true)
38
+ })
39
+ })
@@ -9,6 +9,12 @@ const MAX_ZOOM_STEP = 10
9
9
  * @internal */
10
10
  export function normalizeWheel(event: WheelEvent | React.WheelEvent<HTMLElement>) {
11
11
  let { deltaY, deltaX } = event
12
+
13
+ // Coerce non-finite deltas to no movement so they can't propagate into the camera and
14
+ // crash validation. Also covers deltaZ below, which is derived from deltaY.
15
+ if (!Number.isFinite(deltaX)) deltaX = 0
16
+ if (!Number.isFinite(deltaY)) deltaY = 0
17
+
12
18
  let deltaZ = 0
13
19
 
14
20
  // wheeling
package/src/version.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '5.3.1'
4
+ export const version = '5.4.0-canary.042da660fa66'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-08-05T11:39:54.978Z',
8
- patch: '2026-08-14T10:46:20.010Z',
7
+ minor: '2026-08-19T12:47:12.111Z',
8
+ patch: '2026-08-19T12:47:12.111Z',
9
9
  }