@tldraw/editor 5.3.0-canary.fceaae5e9feb → 5.3.0-internal.fba91ed55f6c

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 (58) hide show
  1. package/dist-cjs/index.d.ts +37 -2
  2. package/dist-cjs/index.js +4 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +6 -1
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/editor/Editor.js +65 -13
  7. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  8. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +9 -0
  9. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  10. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +3 -0
  11. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  12. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js +3 -0
  13. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js.map +2 -2
  14. package/dist-cjs/lib/editor/types/emit-types.js.map +1 -1
  15. package/dist-cjs/lib/license/LicenseManager.js +62 -7
  16. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  17. package/dist-cjs/lib/license/useLicenseManagerState.js +7 -0
  18. package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
  19. package/dist-cjs/lib/options.js +2 -1
  20. package/dist-cjs/lib/options.js.map +2 -2
  21. package/dist-cjs/version.js +3 -3
  22. package/dist-cjs/version.js.map +1 -1
  23. package/dist-esm/index.d.mts +37 -2
  24. package/dist-esm/index.mjs +5 -2
  25. package/dist-esm/index.mjs.map +2 -2
  26. package/dist-esm/lib/TldrawEditor.mjs +6 -1
  27. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  28. package/dist-esm/lib/editor/Editor.mjs +65 -13
  29. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  30. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +9 -0
  31. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  32. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +3 -0
  33. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  34. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs +3 -0
  35. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs.map +2 -2
  36. package/dist-esm/lib/license/LicenseManager.mjs +63 -8
  37. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  38. package/dist-esm/lib/license/useLicenseManagerState.mjs +7 -0
  39. package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
  40. package/dist-esm/lib/options.mjs +2 -1
  41. package/dist-esm/lib/options.mjs.map +2 -2
  42. package/dist-esm/version.mjs +3 -3
  43. package/dist-esm/version.mjs.map +1 -1
  44. package/editor.css +10 -0
  45. package/package.json +7 -7
  46. package/src/index.ts +3 -1
  47. package/src/lib/TldrawEditor.tsx +10 -1
  48. package/src/lib/editor/Editor.ts +97 -18
  49. package/src/lib/editor/managers/FontManager/FontManager.test.ts +66 -0
  50. package/src/lib/editor/managers/FontManager/FontManager.ts +14 -0
  51. package/src/lib/editor/managers/TextManager/TextManager.ts +3 -0
  52. package/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx +6 -1
  53. package/src/lib/editor/types/emit-types.ts +1 -0
  54. package/src/lib/license/LicenseManager.test.ts +220 -29
  55. package/src/lib/license/LicenseManager.ts +107 -6
  56. package/src/lib/license/useLicenseManagerState.ts +17 -1
  57. package/src/lib/options.ts +3 -1
  58. package/src/version.ts +3 -3
@@ -4,6 +4,7 @@ import { publishDates } from '../../version'
4
4
  import { str2ab } from '../utils/licensing'
5
5
  import {
6
6
  FLAGS,
7
+ getEnabledFeatures,
7
8
  getLicenseState,
8
9
  LicenseManager,
9
10
  PROPERTIES,
@@ -508,6 +509,116 @@ describe('LicenseManager', () => {
508
509
  expect(result.isEvaluationLicense).toBe(true)
509
510
  expect(result.isEvaluationLicenseExpired).toBe(true)
510
511
  })
512
+
513
+ it('Checks for the commenting feature flag', async () => {
514
+ const commentingLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
515
+ commentingLicenseInfo[PROPERTIES.FLAGS] |= FLAGS.FEAT_COMMENTING
516
+ const commentingLicenseKey = await generateLicenseKey(
517
+ JSON.stringify(commentingLicenseInfo),
518
+ keyPair
519
+ )
520
+ const result = (await licenseManager.getLicenseFromKey(
521
+ commentingLicenseKey
522
+ )) as ValidLicenseKeyResult
523
+ expect(result.isCommentingEnabled).toBe(true)
524
+ expect(result.isCollaborationEnabled).toBe(false)
525
+ })
526
+
527
+ it('Checks for the collaboration feature flag', async () => {
528
+ const collaborationLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
529
+ collaborationLicenseInfo[PROPERTIES.FLAGS] |= FLAGS.FEAT_COLLABORATION
530
+ const collaborationLicenseKey = await generateLicenseKey(
531
+ JSON.stringify(collaborationLicenseInfo),
532
+ keyPair
533
+ )
534
+ const result = (await licenseManager.getLicenseFromKey(
535
+ collaborationLicenseKey
536
+ )) as ValidLicenseKeyResult
537
+ expect(result.isCollaborationEnabled).toBe(true)
538
+ // The collaboration umbrella grants the commenting sub-feature.
539
+ expect(result.isCommentingEnabled).toBe(true)
540
+ })
541
+
542
+ it('Leaves feature flags off when no feature bits are set', async () => {
543
+ const standardLicenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
544
+ const result = (await licenseManager.getLicenseFromKey(
545
+ standardLicenseKey
546
+ )) as ValidLicenseKeyResult
547
+ expect(result.isCollaborationEnabled).toBe(false)
548
+ expect(result.isCommentingEnabled).toBe(false)
549
+ })
550
+
551
+ it('Enables features synchronously in development, before validation resolves', () => {
552
+ process.env.NODE_ENV = 'development'
553
+ // @ts-ignore
554
+ delete window.location
555
+ // @ts-ignore
556
+ window.location = new URL('https://www.example.com')
557
+
558
+ try {
559
+ // No await: the feature flags should already reflect development (all enabled) rather
560
+ // than the fail-closed default, which only lifts once async validation resolves.
561
+ const devLicenseManager = new LicenseManager('', keyPair.publicKey)
562
+ expect(devLicenseManager.isFeatureEnabled('commenting')).toBe(true)
563
+ expect(devLicenseManager.isFeatureEnabled('collaboration')).toBe(true)
564
+ } finally {
565
+ process.env.NODE_ENV = 'test'
566
+ }
567
+ })
568
+ })
569
+
570
+ describe('Expiry date timezone handling', () => {
571
+ afterEach(() => {
572
+ vi.useRealTimers()
573
+ })
574
+
575
+ // The named expiry date is minted as a date-only string and should mean "the license
576
+ // is fully usable through the end of that day in UTC", giving every user the same
577
+ // cutoff regardless of their local timezone.
578
+ async function makeEvaluationLicenseWithExpiry(expiry: string) {
579
+ const licenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
580
+ licenseInfo[PROPERTIES.FLAGS] = FLAGS.EVALUATION_LICENSE
581
+ licenseInfo[PROPERTIES.EXPIRY_DATE] = expiry
582
+ return generateLicenseKey(JSON.stringify(licenseInfo), keyPair)
583
+ }
584
+
585
+ it('Keeps the license valid through the end of the named expiry day (UTC)', async () => {
586
+ const key = await makeEvaluationLicenseWithExpiry('2026-06-26')
587
+
588
+ // The final moment of the named day is still valid.
589
+ vi.useFakeTimers()
590
+ vi.setSystemTime(new Date('2026-06-26T23:59:59.000Z'))
591
+ expect(
592
+ ((await licenseManager.getLicenseFromKey(key)) as ValidLicenseKeyResult)
593
+ .isEvaluationLicenseExpired
594
+ ).toBe(false)
595
+ })
596
+
597
+ it('Expires the license at the start of the day after the named expiry day (UTC)', async () => {
598
+ const key = await makeEvaluationLicenseWithExpiry('2026-06-26')
599
+
600
+ vi.useFakeTimers()
601
+ vi.setSystemTime(new Date('2026-06-27T00:00:01.000Z'))
602
+ expect(
603
+ ((await licenseManager.getLicenseFromKey(key)) as ValidLicenseKeyResult)
604
+ .isEvaluationLicenseExpired
605
+ ).toBe(true)
606
+ })
607
+
608
+ it('Does not expire early for users west of UTC who are still on the named day locally', async () => {
609
+ const key = await makeEvaluationLicenseWithExpiry('2026-06-26')
610
+
611
+ // Under the old logic a user west of UTC parsed the date one calendar day early and
612
+ // expired at the *start* of that day, losing most of a day. With the uniform UTC
613
+ // cutoff the license stays valid all through June 26 for everyone, no matter their
614
+ // local offset. 1pm UTC on June 26 is well before the cutoff, so it is still valid.
615
+ vi.useFakeTimers()
616
+ vi.setSystemTime(new Date('2026-06-26T13:00:00.000Z'))
617
+ expect(
618
+ ((await licenseManager.getLicenseFromKey(key)) as ValidLicenseKeyResult)
619
+ .isEvaluationLicenseExpired
620
+ ).toBe(false)
621
+ })
511
622
  })
512
623
 
513
624
  describe('License expiry and grace period', () => {
@@ -540,38 +651,54 @@ describe('LicenseManager', () => {
540
651
  })
541
652
 
542
653
  it('Handles grace period correctly - 20 days expired should still be within grace period', async () => {
543
- const expiredLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
544
- const expiredDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 20) // 20 days ago
545
- expiredLicenseInfo[PROPERTIES.EXPIRY_DATE] = expiredDate.toISOString()
546
-
547
- const expiredLicenseKey = await generateLicenseKey(
548
- JSON.stringify(expiredLicenseInfo),
549
- keyPair
550
- )
551
-
552
- // Test the getLicenseFromKey method to verify grace period calculation
553
- const result = (await licenseManager.getLicenseFromKey(
554
- expiredLicenseKey
555
- )) as ValidLicenseKeyResult
556
- expect(result.isAnnualLicense).toBe(true)
557
- expect(result.isAnnualLicenseExpired).toBe(false) // Within 30-day grace period
558
- expect(result.daysSinceExpiry).toBe(20)
654
+ // Pin the clock and use a date-only UTC expiry so the calculation is timezone
655
+ // independent. The named day is fully usable, so an expiry 20 calendar days before
656
+ // "now" has been past its usable window for 19 full days.
657
+ vi.useFakeTimers()
658
+ vi.setSystemTime(new Date('2026-06-26T12:00:00.000Z'))
659
+ try {
660
+ const expiredLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
661
+ expiredLicenseInfo[PROPERTIES.EXPIRY_DATE] = '2026-06-06' // 20 days before now
662
+
663
+ const expiredLicenseKey = await generateLicenseKey(
664
+ JSON.stringify(expiredLicenseInfo),
665
+ keyPair
666
+ )
667
+
668
+ // Test the getLicenseFromKey method to verify grace period calculation
669
+ const result = (await licenseManager.getLicenseFromKey(
670
+ expiredLicenseKey
671
+ )) as ValidLicenseKeyResult
672
+ expect(result.isAnnualLicense).toBe(true)
673
+ expect(result.isAnnualLicenseExpired).toBe(false) // Within 30-day grace period
674
+ expect(result.daysSinceExpiry).toBe(19)
675
+ } finally {
676
+ vi.useRealTimers()
677
+ }
559
678
  })
560
679
 
561
680
  it('Calculates days since expiry correctly', async () => {
562
- const expiredLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
563
- const expiredDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 15) // 15 days ago
564
- expiredLicenseInfo[PROPERTIES.EXPIRY_DATE] = expiredDate.toISOString()
565
-
566
- const expiredLicenseKey = await generateLicenseKey(
567
- JSON.stringify(expiredLicenseInfo),
568
- keyPair
569
- )
570
-
571
- const result = (await licenseManager.getLicenseFromKey(
572
- expiredLicenseKey
573
- )) as ValidLicenseKeyResult
574
- expect(result.daysSinceExpiry).toBe(15)
681
+ // Pin the clock and use a date-only UTC expiry so the calculation is timezone
682
+ // independent. The named day is fully usable, so an expiry 15 calendar days before
683
+ // "now" has been past its usable window for 14 full days.
684
+ vi.useFakeTimers()
685
+ vi.setSystemTime(new Date('2026-06-26T12:00:00.000Z'))
686
+ try {
687
+ const expiredLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
688
+ expiredLicenseInfo[PROPERTIES.EXPIRY_DATE] = '2026-06-11' // 15 days before now
689
+
690
+ const expiredLicenseKey = await generateLicenseKey(
691
+ JSON.stringify(expiredLicenseInfo),
692
+ keyPair
693
+ )
694
+
695
+ const result = (await licenseManager.getLicenseFromKey(
696
+ expiredLicenseKey
697
+ )) as ValidLicenseKeyResult
698
+ expect(result.daysSinceExpiry).toBe(14)
699
+ } finally {
700
+ vi.useRealTimers()
701
+ }
575
702
  })
576
703
  })
577
704
 
@@ -723,6 +850,8 @@ function getDefaultLicenseResult(overrides: Partial<ValidLicenseKeyResult>): Val
723
850
  isLicensedWithWatermark: false,
724
851
  isEvaluationLicense: false,
725
852
  isEvaluationLicenseExpired: false,
853
+ isCollaborationEnabled: false,
854
+ isCommentingEnabled: false,
726
855
  daysSinceExpiry: 0,
727
856
  // WatermarkManager does not check these fields, it relies on the calculated values like isAnnualLicenseExpired
728
857
  license: {
@@ -1024,3 +1153,65 @@ describe('getLicenseState', () => {
1024
1153
  })
1025
1154
  })
1026
1155
  })
1156
+
1157
+ describe('getEnabledFeatures', () => {
1158
+ it('enables every feature in development regardless of flags', () => {
1159
+ const result = getDefaultLicenseResult({
1160
+ isCollaborationEnabled: false,
1161
+ isCommentingEnabled: false,
1162
+ })
1163
+ expect(getEnabledFeatures(result, 'unlicensed', true)).toEqual({
1164
+ collaboration: true,
1165
+ commenting: true,
1166
+ })
1167
+ })
1168
+
1169
+ it('reflects the license flags for a valid licensed state', () => {
1170
+ const result = getDefaultLicenseResult({
1171
+ isCollaborationEnabled: false,
1172
+ isCommentingEnabled: true,
1173
+ })
1174
+ expect(getEnabledFeatures(result, 'licensed', false)).toEqual({
1175
+ collaboration: false,
1176
+ commenting: true,
1177
+ })
1178
+ })
1179
+
1180
+ it('still grants features while showing a watermark', () => {
1181
+ const result = getDefaultLicenseResult({
1182
+ isCollaborationEnabled: true,
1183
+ isCommentingEnabled: true,
1184
+ })
1185
+ expect(getEnabledFeatures(result, 'licensed-with-watermark', false)).toEqual({
1186
+ collaboration: true,
1187
+ commenting: true,
1188
+ })
1189
+ })
1190
+
1191
+ it.each(['unlicensed', 'unlicensed-production', 'expired', 'pending'] as const)(
1192
+ 'disables all features for the %s state in production',
1193
+ (state) => {
1194
+ const result = getDefaultLicenseResult({
1195
+ isCollaborationEnabled: true,
1196
+ isCommentingEnabled: true,
1197
+ })
1198
+ expect(getEnabledFeatures(result, state, false)).toEqual({
1199
+ collaboration: false,
1200
+ commenting: false,
1201
+ })
1202
+ }
1203
+ )
1204
+
1205
+ it('disables all features when the license is not parseable', () => {
1206
+ expect(
1207
+ getEnabledFeatures(
1208
+ { isLicenseParseable: false, reason: 'no-key-provided' },
1209
+ 'licensed',
1210
+ false
1211
+ )
1212
+ ).toEqual({
1213
+ collaboration: false,
1214
+ commenting: false,
1215
+ })
1216
+ })
1217
+ })
@@ -1,4 +1,4 @@
1
- import { atom } from '@tldraw/state'
1
+ import { atom, transact } from '@tldraw/state'
2
2
  import { publishDates, version } from '../../version'
3
3
  import { getDefaultCdnBaseUrl } from '../utils/assets'
4
4
  import { importPublicKey, str2ab } from '../utils/licensing'
@@ -22,6 +22,12 @@ export const FLAGS = {
22
22
  // Native means the license is for native apps which switches
23
23
  // on special-case logic.
24
24
  NATIVE_LICENSE: 1 << 5,
25
+
26
+ // -- FEATURE FLAGS --
27
+ // Collaboration is the umbrella flag for collaboration features; it grants all sub-features.
28
+ FEAT_COLLABORATION: 1 << 6,
29
+ // Commenting is the first sub-feature of collaboration.
30
+ FEAT_COMMENTING: 1 << 7,
25
31
  }
26
32
  const HIGHEST_FLAG = Math.max(...Object.values(FLAGS))
27
33
 
@@ -45,6 +51,23 @@ export interface LicenseInfo {
45
51
  expiryDate: string
46
52
  }
47
53
 
54
+ /**
55
+ * Names of the licensable product features gated by the license. `collaboration` is an umbrella
56
+ * that also grants all of its sub-features (currently just `commenting`).
57
+ *
58
+ * @internal
59
+ */
60
+ export type LicenseFeatureName = 'collaboration' | 'commenting'
61
+
62
+ const NO_FEATURES: Readonly<Record<LicenseFeatureName, boolean>> = {
63
+ collaboration: false,
64
+ commenting: false,
65
+ }
66
+ const ALL_FEATURES: Readonly<Record<LicenseFeatureName, boolean>> = {
67
+ collaboration: true,
68
+ commenting: true,
69
+ }
70
+
48
71
  /** @internal */
49
72
  export type LicenseState =
50
73
  | 'pending' // License validation is in progress
@@ -84,6 +107,8 @@ export interface ValidLicenseKeyResult {
84
107
  isLicensedWithWatermark: boolean
85
108
  isEvaluationLicense: boolean
86
109
  isEvaluationLicenseExpired: boolean
110
+ isCollaborationEnabled: boolean
111
+ isCommentingEnabled: boolean
87
112
  daysSinceExpiry: number
88
113
  }
89
114
 
@@ -98,6 +123,9 @@ export class LicenseManager {
98
123
  public isTest: boolean
99
124
  public isCryptoAvailable: boolean
100
125
  state = atom<LicenseState>('license state', 'pending')
126
+ featureFlags = atom<Record<LicenseFeatureName, boolean>>('license feature flags', {
127
+ ...NO_FEATURES,
128
+ })
101
129
  public verbose = true
102
130
 
103
131
  constructor(licenseKey: string | undefined, testPublicKey?: string) {
@@ -106,6 +134,14 @@ export class LicenseManager {
106
134
  this.publicKey = testPublicKey || this.publicKey
107
135
  this.isCryptoAvailable = !!crypto.subtle
108
136
 
137
+ // In development every feature is enabled (see `getEnabledFeatures`), and that doesn't depend
138
+ // on the async validation result. Reflect it eagerly so features aren't reported as disabled
139
+ // during the validation window — or left disabled if validation rejects (the `.catch` below
140
+ // never sets `featureFlags`). In production the fail-closed default stands until validation.
141
+ if (this.isDevelopment) {
142
+ this.featureFlags.set({ ...ALL_FEATURES })
143
+ }
144
+
109
145
  this.getLicenseFromKey(licenseKey)
110
146
  .then((result) => {
111
147
  const licenseState = getLicenseState(
@@ -116,7 +152,12 @@ export class LicenseManager {
116
152
 
117
153
  this.maybeTrack(result, licenseState)
118
154
 
119
- this.state.set(licenseState)
155
+ // Update both atoms atomically so dependents never observe the license state and the
156
+ // feature flags out of sync mid-update.
157
+ transact(() => {
158
+ this.state.set(licenseState)
159
+ this.featureFlags.set(getEnabledFeatures(result, licenseState, this.isDevelopment))
160
+ })
120
161
  })
121
162
  .catch((error) => {
122
163
  console.error('License validation failed:', error)
@@ -124,6 +165,14 @@ export class LicenseManager {
124
165
  })
125
166
  }
126
167
 
168
+ /**
169
+ * Returns whether a given licensable feature is enabled. Reactive: reading this inside a signal
170
+ * recomputes when license validation resolves.
171
+ */
172
+ isFeatureEnabled(feature: LicenseFeatureName): boolean {
173
+ return this.featureFlags.get()[feature]
174
+ }
175
+
127
176
  private getIsDevelopment() {
128
177
  // If we are using https on a non-loopback domain we assume it's a production env and a development one otherwise
129
178
  return (
@@ -286,6 +335,12 @@ export class LicenseManager {
286
335
  const isPerpetualLicenseExpired =
287
336
  isPerpetualLicense && this.isPerpetualLicenseExpired(expiryDate)
288
337
 
338
+ // The collaboration umbrella grants all of its sub-features, so commenting is enabled
339
+ // by either the commenting flag or the collaboration flag.
340
+ const isCollaborationEnabled = this.isFlagEnabled(licenseInfo.flags, FLAGS.FEAT_COLLABORATION)
341
+ const isCommentingEnabled =
342
+ isCollaborationEnabled || this.isFlagEnabled(licenseInfo.flags, FLAGS.FEAT_COMMENTING)
343
+
289
344
  // For perpetual licenses, the calendar expiry date only gates access to future
290
345
  // major/minor releases; it does not "expire" the license itself. While the user
291
346
  // is still on a covered version we report `daysSinceExpiry` as 0 so consumers
@@ -309,6 +364,8 @@ export class LicenseManager {
309
364
  isEvaluationLicense,
310
365
  isEvaluationLicenseExpired:
311
366
  isEvaluationLicense && this.isEvaluationLicenseExpired(expiryDate),
367
+ isCollaborationEnabled,
368
+ isCommentingEnabled,
312
369
  daysSinceExpiry,
313
370
  }
314
371
  this.outputLicenseInfoIfNeeded(result)
@@ -372,14 +429,26 @@ export class LicenseManager {
372
429
  }
373
430
 
374
431
  private getExpirationDateWithoutGracePeriod(expiryDate: Date) {
375
- return new Date(expiryDate.getFullYear(), expiryDate.getMonth(), expiryDate.getDate())
432
+ // The named expiry date is the last day the license is fully usable, so the license
433
+ // expires at the end of that day, i.e. the start of the following day. We work in UTC
434
+ // (the expiry date is minted and parsed as a UTC date-only string) so the cutoff is a
435
+ // single predictable instant for every user regardless of their local timezone.
436
+ return new Date(
437
+ Date.UTC(
438
+ expiryDate.getUTCFullYear(),
439
+ expiryDate.getUTCMonth(),
440
+ expiryDate.getUTCDate() + 1 // Add 1 day so the named date is fully usable
441
+ )
442
+ )
376
443
  }
377
444
 
378
445
  private getExpirationDateWithGracePeriod(expiryDate: Date) {
379
446
  return new Date(
380
- expiryDate.getFullYear(),
381
- expiryDate.getMonth(),
382
- expiryDate.getDate() + GRACE_PERIOD_DAYS + 1 // Add 1 day to include the expiration day
447
+ Date.UTC(
448
+ expiryDate.getUTCFullYear(),
449
+ expiryDate.getUTCMonth(),
450
+ expiryDate.getUTCDate() + GRACE_PERIOD_DAYS + 1 // Add 1 day to include the expiration day
451
+ )
383
452
  )
384
453
  }
385
454
 
@@ -471,6 +540,38 @@ export class LicenseManager {
471
540
  static className = 'tl-watermark_SEE-LICENSE'
472
541
  }
473
542
 
543
+ /**
544
+ * Derives which licensable features are enabled from the parse result and the derived license
545
+ * state. In development every feature is enabled so SDK developers can build against them; in
546
+ * production a feature requires both an active, valid license and the corresponding flag.
547
+ *
548
+ * @internal
549
+ */
550
+ export function getEnabledFeatures(
551
+ result: LicenseFromKeyResult,
552
+ licenseState: LicenseState,
553
+ isDevelopment: boolean
554
+ ): Record<LicenseFeatureName, boolean> {
555
+ // Development gets all features so SDK developers can build against them.
556
+ if (isDevelopment) {
557
+ return { ...ALL_FEATURES }
558
+ }
559
+
560
+ // Features require an active, valid license. Both the 30-day grace-period 'licensed' state and
561
+ // the watermark state count as valid; unlicensed/expired/pending do not.
562
+ if (
563
+ !result.isLicenseParseable ||
564
+ (licenseState !== 'licensed' && licenseState !== 'licensed-with-watermark')
565
+ ) {
566
+ return { ...NO_FEATURES }
567
+ }
568
+
569
+ return {
570
+ collaboration: result.isCollaborationEnabled,
571
+ commenting: result.isCommentingEnabled,
572
+ }
573
+ }
574
+
474
575
  export function getLicenseState(
475
576
  result: LicenseFromKeyResult,
476
577
  outputMessages: (messages: string[]) => void,
@@ -1,7 +1,23 @@
1
1
  import { useValue } from '@tldraw/state-react'
2
- import { LicenseManager, LicenseState } from './LicenseManager'
2
+ import { LicenseFeatureName, LicenseManager, LicenseState } from './LicenseManager'
3
3
 
4
4
  /** @internal */
5
5
  export function useLicenseManagerState(licenseManager: LicenseManager): LicenseState {
6
6
  return useValue('watermarkState', () => licenseManager.state.get(), [licenseManager])
7
7
  }
8
+
9
+ /**
10
+ * Reactively reads whether a licensable feature is enabled for the current license. Re-renders when
11
+ * license validation resolves.
12
+ *
13
+ * @internal
14
+ */
15
+ export function useLicenseFeatureFlag(
16
+ licenseManager: LicenseManager,
17
+ feature: LicenseFeatureName
18
+ ): boolean {
19
+ return useValue('licenseFeature', () => licenseManager.isFeatureEnabled(feature), [
20
+ licenseManager,
21
+ feature,
22
+ ])
23
+ }
@@ -86,6 +86,7 @@ export interface TldrawOptions {
86
86
  readonly collaboratorCheckIntervalMs: number
87
87
  readonly cameraMovingTimeoutMs: number
88
88
  readonly hitTestMargin: number
89
+ readonly coarseHitTestMargin: number
89
90
  readonly edgeScrollDelay: number
90
91
  readonly edgeScrollEaseDuration: number
91
92
  readonly edgeScrollSpeed: number
@@ -312,7 +313,8 @@ export const defaultTldrawOptions = {
312
313
  collaboratorIdleTimeoutMs: 3000,
313
314
  collaboratorCheckIntervalMs: 1200,
314
315
  cameraMovingTimeoutMs: 64,
315
- hitTestMargin: 8,
316
+ hitTestMargin: 3,
317
+ coarseHitTestMargin: 4,
316
318
  edgeScrollDelay: 200,
317
319
  edgeScrollEaseDuration: 200,
318
320
  edgeScrollSpeed: 25,
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.0-canary.fceaae5e9feb'
4
+ export const version = '5.3.0-internal.fba91ed55f6c'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-07-01T09:33:16.670Z',
8
- patch: '2026-07-01T09:33:16.670Z',
7
+ minor: '2026-07-17T10:57:42.512Z',
8
+ patch: '2026-07-17T10:57:42.512Z',
9
9
  }