@tldraw/editor 3.16.0-canary.c1bcdabc9513 → 3.16.0-canary.c434355348c5

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 (112) hide show
  1. package/dist-cjs/index.d.ts +52 -101
  2. package/dist-cjs/index.js +3 -5
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +5 -5
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/Shape.js +7 -10
  7. package/dist-cjs/lib/components/Shape.js.map +2 -2
  8. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +4 -23
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  10. package/dist-cjs/lib/editor/Editor.js +31 -109
  11. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  12. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +13 -0
  13. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  14. package/dist-cjs/lib/editor/types/misc-types.js.map +1 -1
  15. package/dist-cjs/lib/exports/getSvgJsx.js +34 -14
  16. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  17. package/dist-cjs/lib/hooks/useCanvasEvents.js +7 -5
  18. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  19. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js +4 -1
  20. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js.map +2 -2
  21. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +4 -1
  22. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
  23. package/dist-cjs/lib/license/LicenseManager.js +120 -50
  24. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  25. package/dist-cjs/lib/license/LicenseProvider.js +22 -0
  26. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  27. package/dist-cjs/lib/license/Watermark.js +68 -6
  28. package/dist-cjs/lib/license/Watermark.js.map +3 -3
  29. package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
  30. package/dist-cjs/lib/primitives/Box.js +3 -0
  31. package/dist-cjs/lib/primitives/Box.js.map +2 -2
  32. package/dist-cjs/lib/primitives/Vec.js +0 -4
  33. package/dist-cjs/lib/primitives/Vec.js.map +2 -2
  34. package/dist-cjs/lib/primitives/geometry/Geometry2d.js +26 -18
  35. package/dist-cjs/lib/primitives/geometry/Geometry2d.js.map +2 -2
  36. package/dist-cjs/lib/primitives/geometry/Group2d.js +3 -0
  37. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  38. package/dist-cjs/lib/utils/reparenting.js +2 -35
  39. package/dist-cjs/lib/utils/reparenting.js.map +3 -3
  40. package/dist-cjs/version.js +3 -3
  41. package/dist-cjs/version.js.map +1 -1
  42. package/dist-esm/index.d.mts +52 -101
  43. package/dist-esm/index.mjs +3 -5
  44. package/dist-esm/index.mjs.map +2 -2
  45. package/dist-esm/lib/TldrawEditor.mjs +5 -5
  46. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  47. package/dist-esm/lib/components/Shape.mjs +7 -10
  48. package/dist-esm/lib/components/Shape.mjs.map +2 -2
  49. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +4 -23
  50. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  51. package/dist-esm/lib/editor/Editor.mjs +31 -109
  52. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  53. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +13 -0
  54. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  55. package/dist-esm/lib/exports/getSvgJsx.mjs +34 -14
  56. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  57. package/dist-esm/lib/hooks/useCanvasEvents.mjs +7 -5
  58. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  59. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs +4 -1
  60. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs.map +2 -2
  61. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +4 -1
  62. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
  63. package/dist-esm/lib/license/LicenseManager.mjs +121 -51
  64. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  65. package/dist-esm/lib/license/LicenseProvider.mjs +23 -1
  66. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  67. package/dist-esm/lib/license/Watermark.mjs +68 -6
  68. package/dist-esm/lib/license/Watermark.mjs.map +3 -3
  69. package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
  70. package/dist-esm/lib/primitives/Box.mjs +4 -1
  71. package/dist-esm/lib/primitives/Box.mjs.map +2 -2
  72. package/dist-esm/lib/primitives/Vec.mjs +0 -4
  73. package/dist-esm/lib/primitives/Vec.mjs.map +2 -2
  74. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs +29 -19
  75. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs.map +2 -2
  76. package/dist-esm/lib/primitives/geometry/Group2d.mjs +3 -0
  77. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  78. package/dist-esm/lib/utils/reparenting.mjs +3 -40
  79. package/dist-esm/lib/utils/reparenting.mjs.map +2 -2
  80. package/dist-esm/version.mjs +3 -3
  81. package/dist-esm/version.mjs.map +1 -1
  82. package/editor.css +8 -0
  83. package/package.json +7 -7
  84. package/src/index.ts +2 -9
  85. package/src/lib/TldrawEditor.tsx +6 -12
  86. package/src/lib/components/Shape.tsx +6 -12
  87. package/src/lib/components/default-components/DefaultCanvas.tsx +5 -22
  88. package/src/lib/editor/Editor.ts +38 -146
  89. package/src/lib/editor/shapes/ShapeUtil.ts +35 -0
  90. package/src/lib/editor/types/misc-types.ts +0 -6
  91. package/src/lib/exports/getSvgJsx.test.ts +868 -0
  92. package/src/lib/exports/getSvgJsx.tsx +76 -19
  93. package/src/lib/hooks/useCanvasEvents.ts +6 -6
  94. package/src/lib/hooks/usePassThroughMouseOverEvents.ts +4 -1
  95. package/src/lib/hooks/usePassThroughWheelEvents.ts +6 -1
  96. package/src/lib/license/LicenseManager.test.ts +645 -382
  97. package/src/lib/license/LicenseManager.ts +173 -53
  98. package/src/lib/license/LicenseProvider.tsx +34 -1
  99. package/src/lib/license/Watermark.tsx +73 -6
  100. package/src/lib/license/useLicenseManagerState.ts +2 -2
  101. package/src/lib/primitives/Box.test.ts +126 -0
  102. package/src/lib/primitives/Box.ts +10 -1
  103. package/src/lib/primitives/Vec.ts +0 -5
  104. package/src/lib/primitives/geometry/Geometry2d.ts +49 -19
  105. package/src/lib/primitives/geometry/Group2d.ts +4 -0
  106. package/src/lib/utils/reparenting.ts +3 -69
  107. package/src/version.ts +3 -3
  108. package/dist-cjs/lib/utils/nearestMultiple.js +0 -34
  109. package/dist-cjs/lib/utils/nearestMultiple.js.map +0 -7
  110. package/dist-esm/lib/utils/nearestMultiple.mjs +0 -14
  111. package/dist-esm/lib/utils/nearestMultiple.mjs.map +0 -7
  112. package/src/lib/utils/nearestMultiple.ts +0 -13
@@ -1,16 +1,16 @@
1
1
  import { atom } from '@tldraw/state'
2
- import { fetch } from '@tldraw/utils'
3
- import { publishDates } from '../../version'
2
+ import { publishDates, version } from '../../version'
4
3
  import { getDefaultCdnBaseUrl } from '../utils/assets'
5
4
  import { importPublicKey, str2ab } from '../utils/licensing'
6
5
 
7
- const GRACE_PERIOD_DAYS = 5
6
+ const GRACE_PERIOD_DAYS = 30
8
7
 
9
8
  export const FLAGS = {
10
- ANNUAL_LICENSE: 0x1,
11
- PERPETUAL_LICENSE: 0x2,
12
- INTERNAL_LICENSE: 0x4,
13
- WITH_WATERMARK: 0x8,
9
+ ANNUAL_LICENSE: 1,
10
+ PERPETUAL_LICENSE: 1 << 1,
11
+ INTERNAL_LICENSE: 1 << 2,
12
+ WITH_WATERMARK: 1 << 3,
13
+ EVALUATION_LICENSE: 1 << 4,
14
14
  }
15
15
  const HIGHEST_FLAG = Math.max(...Object.values(FLAGS))
16
16
 
@@ -33,6 +33,15 @@ export interface LicenseInfo {
33
33
  flags: number
34
34
  expiryDate: string
35
35
  }
36
+
37
+ /** @internal */
38
+ export type LicenseState =
39
+ | 'pending' // License validation is in progress
40
+ | 'licensed' // License is valid and active (no restrictions)
41
+ | 'licensed-with-watermark' // License is valid but shows watermark (evaluation licenses, WITH_WATERMARK licenses)
42
+ | 'unlicensed' // No valid license found or license is invalid (development)
43
+ | 'unlicensed-production' // No valid license in production deployment (missing, invalid, or wrong domain)
44
+ | 'expired' // License has been expired (30 days past expiration for regular licenses, immediately for evaluation licenses)
36
45
  /** @internal */
37
46
  export type InvalidLicenseReason =
38
47
  | 'invalid-license-key'
@@ -61,11 +70,17 @@ export interface ValidLicenseKeyResult {
61
70
  isPerpetualLicenseExpired: boolean
62
71
  isInternalLicense: boolean
63
72
  isLicensedWithWatermark: boolean
73
+ isEvaluationLicense: boolean
74
+ isEvaluationLicenseExpired: boolean
75
+ daysSinceExpiry: number
64
76
  }
65
77
 
66
78
  /** @internal */
67
79
  export type TestEnvironment = 'development' | 'production'
68
80
 
81
+ /** @internal */
82
+ export type TrackType = 'unlicensed' | 'with_watermark' | 'evaluation' | null
83
+
69
84
  /** @internal */
70
85
  export class LicenseManager {
71
86
  private publicKey =
@@ -73,10 +88,7 @@ export class LicenseManager {
73
88
  public isDevelopment: boolean
74
89
  public isTest: boolean
75
90
  public isCryptoAvailable: boolean
76
- state = atom<'pending' | 'licensed' | 'licensed-with-watermark' | 'unlicensed'>(
77
- 'license state',
78
- 'pending'
79
- )
91
+ state = atom<LicenseState>('license state', 'pending')
80
92
  public verbose = true
81
93
 
82
94
  constructor(
@@ -89,21 +101,22 @@ export class LicenseManager {
89
101
  this.publicKey = testPublicKey || this.publicKey
90
102
  this.isCryptoAvailable = !!crypto.subtle
91
103
 
92
- this.getLicenseFromKey(licenseKey).then((result) => {
93
- const isUnlicensed = isEditorUnlicensed(result)
104
+ this.getLicenseFromKey(licenseKey)
105
+ .then((result) => {
106
+ const licenseState = getLicenseState(
107
+ result,
108
+ (messages: string[]) => this.outputMessages(messages),
109
+ this.isDevelopment
110
+ )
94
111
 
95
- if (!this.isDevelopment && isUnlicensed) {
96
- fetch(WATERMARK_TRACK_SRC)
97
- }
112
+ this.maybeTrack(result, licenseState)
98
113
 
99
- if (isUnlicensed) {
114
+ this.state.set(licenseState)
115
+ })
116
+ .catch((error) => {
117
+ console.error('License validation failed:', error)
100
118
  this.state.set('unlicensed')
101
- } else if ((result as ValidLicenseKeyResult).isLicensedWithWatermark) {
102
- this.state.set('licensed-with-watermark')
103
- } else {
104
- this.state.set('licensed')
105
- }
106
- })
119
+ })
107
120
  }
108
121
 
109
122
  private getIsDevelopment(testEnvironment?: TestEnvironment) {
@@ -117,6 +130,47 @@ export class LicenseManager {
117
130
  )
118
131
  }
119
132
 
133
+ private getTrackType(result: LicenseFromKeyResult, licenseState: LicenseState): TrackType {
134
+ // Track watermark for unlicensed production deployments
135
+ if (licenseState === 'unlicensed-production') {
136
+ return 'unlicensed'
137
+ }
138
+
139
+ if (this.isDevelopment) {
140
+ return null
141
+ }
142
+
143
+ if (!result.isLicenseParseable) {
144
+ return null
145
+ }
146
+
147
+ // Track evaluation licenses (for analytics, even though no watermark is shown)
148
+ if (result.isEvaluationLicense) {
149
+ return 'evaluation'
150
+ }
151
+
152
+ // Track licenses that show watermarks
153
+ if (licenseState === 'licensed-with-watermark') {
154
+ return 'with_watermark'
155
+ }
156
+
157
+ return null
158
+ }
159
+
160
+ private maybeTrack(result: LicenseFromKeyResult, licenseState: LicenseState): void {
161
+ const trackType = this.getTrackType(result, licenseState)
162
+ if (!trackType) {
163
+ return
164
+ }
165
+
166
+ const url = new URL(WATERMARK_TRACK_SRC)
167
+ url.searchParams.set('version', version)
168
+ url.searchParams.set('license_type', trackType)
169
+
170
+ // eslint-disable-next-line no-restricted-globals
171
+ fetch(url.toString())
172
+ }
173
+
120
174
  private async extractLicenseKey(licenseKey: string): Promise<LicenseInfo> {
121
175
  const [data, signature] = licenseKey.split('.')
122
176
  const [prefix, encodedData] = data.split('/')
@@ -203,6 +257,9 @@ export class LicenseManager {
203
257
  const isAnnualLicense = this.isFlagEnabled(licenseInfo.flags, FLAGS.ANNUAL_LICENSE)
204
258
  const isPerpetualLicense = this.isFlagEnabled(licenseInfo.flags, FLAGS.PERPETUAL_LICENSE)
205
259
 
260
+ const isEvaluationLicense = this.isFlagEnabled(licenseInfo.flags, FLAGS.EVALUATION_LICENSE)
261
+ const daysSinceExpiry = this.getDaysSinceExpiry(expiryDate)
262
+
206
263
  const result: ValidLicenseKeyResult = {
207
264
  license: licenseInfo,
208
265
  isLicenseParseable: true,
@@ -215,6 +272,10 @@ export class LicenseManager {
215
272
  isPerpetualLicenseExpired: isPerpetualLicense && this.isPerpetualLicenseExpired(expiryDate),
216
273
  isInternalLicense: this.isFlagEnabled(licenseInfo.flags, FLAGS.INTERNAL_LICENSE),
217
274
  isLicensedWithWatermark: this.isFlagEnabled(licenseInfo.flags, FLAGS.WITH_WATERMARK),
275
+ isEvaluationLicense,
276
+ isEvaluationLicenseExpired:
277
+ isEvaluationLicense && this.isEvaluationLicenseExpired(expiryDate),
278
+ daysSinceExpiry,
218
279
  }
219
280
  this.outputLicenseInfoIfNeeded(result)
220
281
 
@@ -280,15 +341,7 @@ export class LicenseManager {
280
341
 
281
342
  private isAnnualLicenseExpired(expiryDate: Date) {
282
343
  const expiration = this.getExpirationDateWithGracePeriod(expiryDate)
283
- const isExpired = new Date() >= expiration
284
- // If it is not expired yet (including the grace period), but after the expiry date we warn the users
285
- if (!isExpired && new Date() >= this.getExpirationDateWithoutGracePeriod(expiryDate)) {
286
- this.outputMessages([
287
- 'tldraw license is about to expire, you are in a grace period.',
288
- `Please reach out to ${LICENSE_EMAIL} if you would like to renew your license.`,
289
- ])
290
- }
291
- return isExpired
344
+ return new Date() >= expiration
292
345
  }
293
346
 
294
347
  private isPerpetualLicenseExpired(expiryDate: Date) {
@@ -301,6 +354,21 @@ export class LicenseManager {
301
354
  return dates.major >= expiration || dates.minor >= expiration
302
355
  }
303
356
 
357
+ private getDaysSinceExpiry(expiryDate: Date): number {
358
+ const now = new Date()
359
+ const expiration = this.getExpirationDateWithoutGracePeriod(expiryDate)
360
+ const diffTime = now.getTime() - expiration.getTime()
361
+ const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
362
+ return Math.max(0, diffDays)
363
+ }
364
+
365
+ private isEvaluationLicenseExpired(expiryDate: Date): boolean {
366
+ // Evaluation licenses have no grace period - they expire immediately
367
+ const now = new Date()
368
+ const expiration = this.getExpirationDateWithoutGracePeriod(expiryDate)
369
+ return now >= expiration
370
+ }
371
+
304
372
  private isFlagEnabled(flags: number, flag: number) {
305
373
  return (flags & flag) === flag
306
374
  }
@@ -318,19 +386,6 @@ export class LicenseManager {
318
386
  }
319
387
 
320
388
  private outputLicenseInfoIfNeeded(result: ValidLicenseKeyResult) {
321
- if (result.isAnnualLicenseExpired) {
322
- this.outputMessages([
323
- 'Your tldraw license has expired!',
324
- `Please reach out to ${LICENSE_EMAIL} to renew.`,
325
- ])
326
- }
327
-
328
- if (!result.isDomainValid && !result.isDevelopment) {
329
- this.outputMessages([
330
- 'This tldraw license key is not valid for this domain!',
331
- `Please reach out to ${LICENSE_EMAIL} if you would like to use tldraw on other domains.`,
332
- ])
333
- }
334
389
  // If we added a new flag it will be twice the value of the currently highest flag.
335
390
  // And if all the current flags are on we would get the `HIGHEST_FLAG * 2 - 1`, so anything higher than that means there are new flags.
336
391
  if (result.license.flags >= HIGHEST_FLAG * 2) {
@@ -367,15 +422,80 @@ export class LicenseManager {
367
422
  static className = 'tl-watermark_SEE-LICENSE'
368
423
  }
369
424
 
370
- export function isEditorUnlicensed(result: LicenseFromKeyResult) {
371
- if (!result.isLicenseParseable) return true
372
- if (!result.isDomainValid && !result.isDevelopment) return true
373
- if (result.isPerpetualLicenseExpired || result.isAnnualLicenseExpired) {
374
- if (result.isInternalLicense) {
375
- throw new Error('License: Internal license expired.')
425
+ export function getLicenseState(
426
+ result: LicenseFromKeyResult,
427
+ outputMessages: (messages: string[]) => void,
428
+ isDevelopment: boolean
429
+ ): LicenseState {
430
+ if (!result.isLicenseParseable) {
431
+ if (isDevelopment) {
432
+ return 'unlicensed'
376
433
  }
377
- return true
434
+
435
+ // All unlicensed scenarios should not work in production
436
+ if (result.reason === 'no-key-provided') {
437
+ outputMessages([
438
+ 'No tldraw license key provided!',
439
+ 'A license is required for production deployments.',
440
+ `Please reach out to ${LICENSE_EMAIL} to purchase a license.`,
441
+ ])
442
+ } else {
443
+ outputMessages([
444
+ 'Invalid license key. tldraw requires a valid license for production use.',
445
+ `Please reach out to ${LICENSE_EMAIL} to purchase a license.`,
446
+ ])
447
+ }
448
+ return 'unlicensed-production'
449
+ }
450
+
451
+ if (!result.isDomainValid && !result.isDevelopment) {
452
+ outputMessages([
453
+ 'License key is not valid for this domain.',
454
+ 'A license is required for production deployments.',
455
+ `Please reach out to ${LICENSE_EMAIL} to purchase a license.`,
456
+ ])
457
+ return 'unlicensed-production'
458
+ }
459
+
460
+ // Handle evaluation licenses - they expire immediately with no grace period
461
+ if (result.isEvaluationLicense) {
462
+ if (result.isEvaluationLicenseExpired) {
463
+ outputMessages([
464
+ 'Your tldraw evaluation license has expired!',
465
+ `Please reach out to ${LICENSE_EMAIL} to purchase a full license.`,
466
+ ])
467
+ return 'expired'
468
+ } else {
469
+ // Valid evaluation license - tracked but no watermark shown
470
+ return 'licensed'
471
+ }
472
+ }
473
+
474
+ // Handle expired regular licenses (both annual and perpetual)
475
+ if (result.isPerpetualLicenseExpired || result.isAnnualLicenseExpired) {
476
+ outputMessages([
477
+ 'Your tldraw license has been expired for more than 30 days!',
478
+ `Please reach out to ${LICENSE_EMAIL} to renew your license.`,
479
+ ])
480
+ return 'expired'
481
+ }
482
+
483
+ // Check if license is past expiry date but within grace period
484
+ const daysSinceExpiry = result.daysSinceExpiry
485
+ if (daysSinceExpiry > 0 && !result.isEvaluationLicense) {
486
+ outputMessages([
487
+ 'Your tldraw license has expired.',
488
+ `License expired ${daysSinceExpiry} days ago.`,
489
+ `Please reach out to ${LICENSE_EMAIL} to renew your license.`,
490
+ ])
491
+ // Within 30-day grace period: still licensed (no watermark)
492
+ return 'licensed'
493
+ }
494
+
495
+ // License is valid, determine if it has watermark
496
+ if (result.isLicensedWithWatermark) {
497
+ return 'licensed-with-watermark'
378
498
  }
379
499
 
380
- return false
500
+ return 'licensed'
381
501
  }
@@ -1,4 +1,5 @@
1
- import { createContext, ReactNode, useContext, useState } from 'react'
1
+ import { useValue } from '@tldraw/state-react'
2
+ import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
2
3
  import { LicenseManager } from './LicenseManager'
3
4
 
4
5
  /** @internal */
@@ -7,6 +8,13 @@ export const LicenseContext = createContext({} as LicenseManager)
7
8
  /** @internal */
8
9
  export const useLicenseContext = () => useContext(LicenseContext)
9
10
 
11
+ function shouldHideEditorAfterDelay(licenseState: string): boolean {
12
+ return licenseState === 'expired' || licenseState === 'unlicensed-production'
13
+ }
14
+
15
+ /** @internal */
16
+ export const LICENSE_TIMEOUT = 5000
17
+
10
18
  /** @internal */
11
19
  export function LicenseProvider({
12
20
  licenseKey,
@@ -16,5 +24,30 @@ export function LicenseProvider({
16
24
  children: ReactNode
17
25
  }) {
18
26
  const [licenseManager] = useState(() => new LicenseManager(licenseKey))
27
+ const licenseState = useValue(licenseManager.state)
28
+ const [showEditor, setShowEditor] = useState(true)
29
+
30
+ // When license expires or no license in production, show for 5 seconds then hide
31
+ useEffect(() => {
32
+ if (shouldHideEditorAfterDelay(licenseState) && showEditor) {
33
+ // eslint-disable-next-line no-restricted-globals
34
+ const timer = setTimeout(() => {
35
+ setShowEditor(false)
36
+ }, LICENSE_TIMEOUT)
37
+
38
+ return () => clearTimeout(timer)
39
+ }
40
+ }, [licenseState, showEditor])
41
+
42
+ // If license is expired or no license in production and 5 seconds have passed, don't render anything (blank screen)
43
+ if (shouldHideEditorAfterDelay(licenseState) && !showEditor) {
44
+ return <LicenseGate />
45
+ }
46
+
19
47
  return <LicenseContext.Provider value={licenseManager}>{children}</LicenseContext.Provider>
20
48
  }
49
+
50
+ // Renders as a hidden div that can be detected by tests
51
+ function LicenseGate() {
52
+ return <div data-testid="tl-license-expired" style={{ display: 'none' }} />
53
+ }
@@ -28,12 +28,74 @@ export const Watermark = memo(function Watermark() {
28
28
  return (
29
29
  <>
30
30
  <LicenseStyles />
31
- <WatermarkInner src={isMobile ? WATERMARK_MOBILE_LOCAL_SRC : WATERMARK_DESKTOP_LOCAL_SRC} />
31
+ <WatermarkInner
32
+ src={isMobile ? WATERMARK_MOBILE_LOCAL_SRC : WATERMARK_DESKTOP_LOCAL_SRC}
33
+ isUnlicensed={licenseManagerState === 'unlicensed'}
34
+ />
32
35
  </>
33
36
  )
34
37
  })
35
38
 
36
- const WatermarkInner = memo(function WatermarkInner({ src }: { src: string }) {
39
+ const UnlicensedWatermark = memo(function UnlicensedWatermark({
40
+ isDebugMode,
41
+ isMobile,
42
+ }: {
43
+ isDebugMode: boolean
44
+ isMobile: boolean
45
+ }) {
46
+ const events = useCanvasEvents()
47
+ const ref = useRef<HTMLDivElement>(null)
48
+ usePassThroughWheelEvents(ref)
49
+
50
+ const url = 'https://tldraw.dev/?utm_source=dotcom&utm_medium=organic&utm_campaign=watermark'
51
+
52
+ return (
53
+ <div
54
+ ref={ref}
55
+ className={LicenseManager.className}
56
+ data-debug={isDebugMode}
57
+ data-mobile={isMobile}
58
+ data-unlicensed={true}
59
+ data-testid="tl-watermark-unlicensed"
60
+ draggable={false}
61
+ {...events}
62
+ >
63
+ <button
64
+ draggable={false}
65
+ role="button"
66
+ onPointerDown={(e) => {
67
+ stopEventPropagation(e)
68
+ preventDefault(e)
69
+ }}
70
+ title="Unlicensed - click to get a license"
71
+ onClick={() => runtime.openWindow(url, '_blank')}
72
+ style={{
73
+ position: 'absolute',
74
+ pointerEvents: 'all',
75
+ cursor: 'pointer',
76
+ color: 'var(--tl-color-text)',
77
+ opacity: 0.8,
78
+ border: 0,
79
+ padding: 0,
80
+ backgroundColor: 'transparent',
81
+ fontSize: '11px',
82
+ fontWeight: '600',
83
+ textAlign: 'center',
84
+ }}
85
+ >
86
+ Unlicensed
87
+ </button>
88
+ </div>
89
+ )
90
+ })
91
+
92
+ const WatermarkInner = memo(function WatermarkInner({
93
+ src,
94
+ isUnlicensed,
95
+ }: {
96
+ src: string
97
+ isUnlicensed: boolean
98
+ }) {
37
99
  const editor = useEditor()
38
100
  const isDebugMode = useValue('debug mode', () => editor.getInstanceState().isDebugMode, [editor])
39
101
  const isMobile = useValue('is mobile', () => editor.getViewportScreenBounds().width < 700, [
@@ -47,12 +109,17 @@ const WatermarkInner = memo(function WatermarkInner({ src }: { src: string }) {
47
109
  const maskCss = `url('${src}') center 100% / 100% no-repeat`
48
110
  const url = 'https://tldraw.dev/?utm_source=dotcom&utm_medium=organic&utm_campaign=watermark'
49
111
 
112
+ if (isUnlicensed) {
113
+ return <UnlicensedWatermark isDebugMode={isDebugMode} isMobile={isMobile} />
114
+ }
115
+
50
116
  return (
51
117
  <div
52
118
  ref={ref}
53
119
  className={LicenseManager.className}
54
120
  data-debug={isDebugMode}
55
121
  data-mobile={isMobile}
122
+ data-testid="tl-watermark-licensed"
56
123
  draggable={false}
57
124
  {...events}
58
125
  >
@@ -86,8 +153,8 @@ To remove the watermark, please purchase a license at tldraw.dev.
86
153
 
87
154
  .${className} {
88
155
  position: absolute;
89
- bottom: var(--tl-space-2);
90
- right: var(--tl-space-2);
156
+ bottom: max(var(--tl-space-2), env(safe-area-inset-bottom));
157
+ right: max(var(--tl-space-2), env(safe-area-inset-right));
91
158
  width: 96px;
92
159
  height: 32px;
93
160
  display: flex;
@@ -116,12 +183,12 @@ To remove the watermark, please purchase a license at tldraw.dev.
116
183
  }
117
184
 
118
185
  .${className}[data-debug='true'] {
119
- bottom: 46px;
186
+ bottom: max(46px, env(safe-area-inset-bottom));
120
187
  }
121
188
 
122
189
  .${className}[data-mobile='true'] {
123
190
  border-radius: 4px 0px 0px 4px;
124
- right: -2px;
191
+ right: max(-2px, calc(env(safe-area-inset-right) - 2px));
125
192
  width: 8px;
126
193
  height: 48px;
127
194
  }
@@ -1,7 +1,7 @@
1
1
  import { useValue } from '@tldraw/state-react'
2
- import { LicenseManager } from './LicenseManager'
2
+ import { LicenseManager, LicenseState } from './LicenseManager'
3
3
 
4
4
  /** @internal */
5
- export function useLicenseManagerState(licenseManager: LicenseManager) {
5
+ export function useLicenseManagerState(licenseManager: LicenseManager): LicenseState {
6
6
  return useValue('watermarkState', () => licenseManager.state.get(), [licenseManager])
7
7
  }
@@ -510,6 +510,132 @@ describe('Box', () => {
510
510
  })
511
511
  })
512
512
 
513
+ describe('Box.ContainsApproximately', () => {
514
+ it('returns true when first box exactly contains second', () => {
515
+ const boxA = new Box(0, 0, 100, 100)
516
+ const boxB = new Box(10, 10, 50, 50)
517
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(true)
518
+ })
519
+
520
+ it('returns false when first box clearly does not contain second', () => {
521
+ const boxA = new Box(0, 0, 50, 50)
522
+ const boxB = new Box(10, 10, 100, 100)
523
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(false)
524
+ })
525
+
526
+ it('returns true when containment is within default precision tolerance', () => {
527
+ // Box B extends very slightly outside A (within floating-point precision)
528
+ const boxA = new Box(0, 0, 100, 100)
529
+ const boxB = new Box(10, 10, 80, 80)
530
+ // Move B's max edges just slightly outside A's bounds
531
+ boxB.w = 90.000000000001 // maxX = 100.000000000001 (slightly beyond 100)
532
+ boxB.h = 90.000000000001 // maxY = 100.000000000001 (slightly beyond 100)
533
+
534
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(true)
535
+ expect(Box.Contains(boxA, boxB)).toBe(false) // strict contains would fail
536
+ })
537
+
538
+ it('returns false when containment exceeds default precision tolerance', () => {
539
+ const boxA = new Box(0, 0, 100, 100)
540
+ const boxB = new Box(10, 10, 80, 80)
541
+ // Move B's max edges clearly outside A's bounds
542
+ boxB.w = 95 // maxX = 105 (clearly beyond 100)
543
+ boxB.h = 95 // maxY = 105 (clearly beyond 100)
544
+
545
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(false)
546
+ })
547
+
548
+ it('respects custom precision parameter', () => {
549
+ const boxA = new Box(0, 0, 100, 100)
550
+ const boxB = new Box(10, 10, 85, 85) // maxX=95, maxY=95
551
+
552
+ // With loose precision (10), should contain (95 is within 100-10=90 tolerance)
553
+ expect(Box.ContainsApproximately(boxA, boxB, 10)).toBe(true)
554
+
555
+ // With tight precision (4), should still contain (95 is within 100-4=96)
556
+ expect(Box.ContainsApproximately(boxA, boxB, 4)).toBe(true)
557
+
558
+ // Since 95 < 100, the precision parameter doesn't affect containment here
559
+ expect(Box.ContainsApproximately(boxA, boxB, 4.9)).toBe(true)
560
+ })
561
+
562
+ it('handles negative coordinates correctly', () => {
563
+ const boxA = new Box(-50, -50, 100, 100) // bounds: (-50,-50) to (50,50)
564
+ const boxB = new Box(-40, -40, 79.999999999, 79.999999999) // bounds: (-40,-40) to (39.999999999, 39.999999999)
565
+
566
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(true)
567
+ })
568
+
569
+ it('handles edge case where boxes are identical', () => {
570
+ const boxA = new Box(10, 20, 100, 200)
571
+ const boxB = new Box(10, 20, 100, 200)
572
+
573
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(true)
574
+ })
575
+
576
+ it('handles edge case where inner box touches outer box edges', () => {
577
+ const boxA = new Box(0, 0, 100, 100)
578
+ const boxB = new Box(0, 0, 100, 100) // exactly the same
579
+
580
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(true)
581
+
582
+ // Slightly smaller inner box
583
+ const boxC = new Box(0.000001, 0.000001, 99.999998, 99.999998)
584
+ expect(Box.ContainsApproximately(boxA, boxC)).toBe(true)
585
+ })
586
+
587
+ it('handles floating-point precision issues in real-world scenarios', () => {
588
+ // Simulate common floating-point arithmetic issues
589
+ const containerBox = new Box(0, 0, 100, 100)
590
+
591
+ // Box that should be contained but has floating-point errors
592
+ const innerBox = new Box(10, 10, 80, 80)
593
+ // Simulate floating-point arithmetic that results in tiny overruns
594
+ innerBox.w = 90.00000000000001 // maxX = 100.00000000000001 (tiny overrun)
595
+ innerBox.h = 90.00000000000001 // maxY = 100.00000000000001 (tiny overrun)
596
+
597
+ expect(Box.ContainsApproximately(containerBox, innerBox)).toBe(true)
598
+ expect(Box.Contains(containerBox, innerBox)).toBe(false) // strict contains fails due to precision
599
+ })
600
+
601
+ it('fails when any edge exceeds tolerance', () => {
602
+ const boxA = new Box(10, 10, 100, 100) // bounds: (10,10) to (110,110)
603
+
604
+ // Test each edge exceeding tolerance
605
+ const testCases = [
606
+ { name: 'left edge', box: new Box(5, 20, 80, 80) }, // minX too small
607
+ { name: 'top edge', box: new Box(20, 5, 80, 80) }, // minY too small
608
+ { name: 'right edge', box: new Box(20, 20, 95, 80) }, // maxX too large (20+95=115 > 110)
609
+ { name: 'bottom edge', box: new Box(20, 20, 80, 95) }, // maxY too large (20+95=115 > 110)
610
+ ]
611
+
612
+ testCases.forEach(({ box }) => {
613
+ expect(Box.ContainsApproximately(boxA, box, 1)).toBe(false) // tight precision
614
+ })
615
+ })
616
+
617
+ it('works with zero-sized dimensions', () => {
618
+ const boxA = new Box(0, 0, 100, 100)
619
+ const boxB = new Box(50, 50, 0, 0) // zero-sized box (point)
620
+
621
+ expect(Box.ContainsApproximately(boxA, boxB)).toBe(true)
622
+ })
623
+
624
+ it('handles precision parameter edge cases', () => {
625
+ const boxA = new Box(0, 0, 100, 100)
626
+ const boxB = new Box(10, 10, 91, 91) // maxX=101, maxY=101 (clearly outside)
627
+
628
+ // Zero precision should work like strict Contains
629
+ expect(Box.ContainsApproximately(boxA, boxB, 0)).toBe(false)
630
+
631
+ // Small precision should still fail (101 > 100)
632
+ expect(Box.ContainsApproximately(boxA, boxB, 0.5)).toBe(false)
633
+
634
+ // Sufficient precision should succeed (101 <= 100 + 2)
635
+ expect(Box.ContainsApproximately(boxA, boxB, 2)).toBe(true)
636
+ })
637
+ })
638
+
513
639
  describe('Box.Includes', () => {
514
640
  it('returns true when boxes collide or contain', () => {
515
641
  const boxA = new Box(0, 0, 50, 50)
@@ -1,6 +1,6 @@
1
1
  import { BoxModel } from '@tldraw/tlschema'
2
2
  import { Vec, VecLike } from './Vec'
3
- import { PI, PI2, toPrecision } from './utils'
3
+ import { approximatelyLte, PI, PI2, toPrecision } from './utils'
4
4
 
5
5
  /** @public */
6
6
  export type BoxLike = BoxModel | Box
@@ -417,6 +417,15 @@ export class Box {
417
417
  return A.minX < B.minX && A.minY < B.minY && A.maxY > B.maxY && A.maxX > B.maxX
418
418
  }
419
419
 
420
+ static ContainsApproximately(A: Box, B: Box, precision?: number) {
421
+ return (
422
+ approximatelyLte(A.minX, B.minX, precision) &&
423
+ approximatelyLte(A.minY, B.minY, precision) &&
424
+ approximatelyLte(B.maxX, A.maxX, precision) &&
425
+ approximatelyLte(B.maxY, A.maxY, precision)
426
+ )
427
+ }
428
+
420
429
  static Includes(A: Box, B: Box) {
421
430
  return Box.Collides(A, B) || Box.Contains(A, B)
422
431
  }
@@ -240,11 +240,6 @@ export class Vec {
240
240
  return Vec.EqualsXY(this, x, y)
241
241
  }
242
242
 
243
- /** @deprecated use `uni` instead */
244
- norm() {
245
- return this.uni()
246
- }
247
-
248
243
  toFixed() {
249
244
  this.x = toFixed(this.x)
250
245
  this.y = toFixed(this.y)