@tldraw/editor 5.3.0-next.91d2a20b898c → 5.3.0-next.aefb8773a7eb

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 (106) hide show
  1. package/dist-cjs/index.d.ts +138 -3
  2. package/dist-cjs/index.js +13 -3
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +24 -19
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/EditorPortal.js +44 -0
  7. package/dist-cjs/lib/components/EditorPortal.js.map +7 -0
  8. package/dist-cjs/lib/components/LiveCollaborators.js +102 -0
  9. package/dist-cjs/lib/components/LiveCollaborators.js.map +7 -0
  10. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +4 -10
  11. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +3 -3
  12. package/dist-cjs/lib/components/default-components/DefaultCursor.js +59 -0
  13. package/dist-cjs/lib/components/default-components/DefaultCursor.js.map +7 -0
  14. package/dist-cjs/lib/editor/Editor.js +7 -2
  15. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  16. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +4 -4
  17. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  18. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  19. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  20. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  21. package/dist-cjs/lib/hooks/useCursor.js +1 -0
  22. package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
  23. package/dist-cjs/lib/hooks/useEditorComponents.js +2 -0
  24. package/dist-cjs/lib/hooks/useEditorComponents.js.map +2 -2
  25. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +25 -1
  26. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
  27. package/dist-cjs/lib/hooks/useTransform.js +45 -0
  28. package/dist-cjs/lib/hooks/useTransform.js.map +7 -0
  29. package/dist-cjs/lib/license/LicenseManager.js +51 -2
  30. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  31. package/dist-cjs/lib/license/useLicenseManagerState.js +7 -0
  32. package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
  33. package/dist-cjs/lib/utils/collaborators.js +27 -0
  34. package/dist-cjs/lib/utils/collaborators.js.map +7 -0
  35. package/dist-cjs/lib/utils/getHtmlLayerTransform.js +33 -0
  36. package/dist-cjs/lib/utils/getHtmlLayerTransform.js.map +7 -0
  37. package/dist-cjs/version.js +3 -3
  38. package/dist-cjs/version.js.map +1 -1
  39. package/dist-esm/index.d.mts +138 -3
  40. package/dist-esm/index.mjs +19 -4
  41. package/dist-esm/index.mjs.map +2 -2
  42. package/dist-esm/lib/TldrawEditor.mjs +23 -18
  43. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  44. package/dist-esm/lib/components/EditorPortal.mjs +24 -0
  45. package/dist-esm/lib/components/EditorPortal.mjs.map +7 -0
  46. package/dist-esm/lib/components/LiveCollaborators.mjs +82 -0
  47. package/dist-esm/lib/components/LiveCollaborators.mjs.map +7 -0
  48. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -11
  49. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  50. package/dist-esm/lib/components/default-components/DefaultCursor.mjs +29 -0
  51. package/dist-esm/lib/components/default-components/DefaultCursor.mjs.map +7 -0
  52. package/dist-esm/lib/editor/Editor.mjs +7 -2
  53. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  54. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +5 -5
  55. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  56. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  57. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  58. package/dist-esm/lib/hooks/useCursor.mjs +1 -0
  59. package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
  60. package/dist-esm/lib/hooks/useEditorComponents.mjs +2 -0
  61. package/dist-esm/lib/hooks/useEditorComponents.mjs.map +2 -2
  62. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +25 -1
  63. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
  64. package/dist-esm/lib/hooks/useTransform.mjs +25 -0
  65. package/dist-esm/lib/hooks/useTransform.mjs.map +7 -0
  66. package/dist-esm/lib/license/LicenseManager.mjs +52 -3
  67. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  68. package/dist-esm/lib/license/useLicenseManagerState.mjs +7 -0
  69. package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
  70. package/dist-esm/lib/utils/collaborators.mjs +7 -0
  71. package/dist-esm/lib/utils/collaborators.mjs.map +7 -0
  72. package/dist-esm/lib/utils/getHtmlLayerTransform.mjs +13 -0
  73. package/dist-esm/lib/utils/getHtmlLayerTransform.mjs.map +7 -0
  74. package/dist-esm/version.mjs +3 -3
  75. package/dist-esm/version.mjs.map +1 -1
  76. package/editor.css +43 -4
  77. package/package.json +7 -7
  78. package/src/index.ts +14 -2
  79. package/src/lib/TldrawEditor.tsx +21 -13
  80. package/src/lib/components/EditorPortal.tsx +77 -0
  81. package/src/lib/components/LiveCollaborators.tsx +133 -0
  82. package/src/lib/components/default-components/DefaultCanvas.tsx +5 -17
  83. package/src/lib/components/default-components/DefaultCursor.tsx +65 -0
  84. package/src/lib/editor/Editor.ts +14 -2
  85. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts +46 -1
  86. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +10 -5
  87. package/src/lib/editor/shapes/ShapeUtil.ts +24 -0
  88. package/src/lib/editor/types/external-content.ts +5 -0
  89. package/src/lib/hooks/EditorComponentsContext.tsx +2 -0
  90. package/src/lib/hooks/useCursor.ts +1 -0
  91. package/src/lib/hooks/useEditorComponents.tsx +2 -0
  92. package/src/lib/hooks/usePassThroughWheelEvents.ts +44 -2
  93. package/src/lib/hooks/useTransform.ts +38 -0
  94. package/src/lib/license/LicenseManager.test.ts +146 -0
  95. package/src/lib/license/LicenseManager.ts +98 -2
  96. package/src/lib/license/useLicenseManagerState.ts +17 -1
  97. package/src/lib/test/EditorPortal.test.tsx +48 -0
  98. package/src/lib/utils/collaborators.test.ts +34 -0
  99. package/src/lib/utils/collaborators.ts +25 -0
  100. package/src/lib/utils/getHtmlLayerTransform.ts +22 -0
  101. package/src/version.ts +3 -3
  102. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js +0 -51
  103. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js.map +0 -7
  104. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs +0 -31
  105. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs.map +0 -7
  106. package/src/lib/hooks/usePassThroughMouseOverEvents.ts +0 -32
@@ -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,62 @@ 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
+ })
511
568
  })
512
569
 
513
570
  describe('Expiry date timezone handling', () => {
@@ -793,6 +850,8 @@ function getDefaultLicenseResult(overrides: Partial<ValidLicenseKeyResult>): Val
793
850
  isLicensedWithWatermark: false,
794
851
  isEvaluationLicense: false,
795
852
  isEvaluationLicenseExpired: false,
853
+ isCollaborationEnabled: false,
854
+ isCommentingEnabled: false,
796
855
  daysSinceExpiry: 0,
797
856
  // WatermarkManager does not check these fields, it relies on the calculated values like isAnnualLicenseExpired
798
857
  license: {
@@ -1094,3 +1153,90 @@ describe('getLicenseState', () => {
1094
1153
  })
1095
1154
  })
1096
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('enables every feature for an evaluation license regardless of flags', () => {
1206
+ const result = getDefaultLicenseResult({
1207
+ isEvaluationLicense: true,
1208
+ isCollaborationEnabled: false,
1209
+ isCommentingEnabled: false,
1210
+ })
1211
+ expect(getEnabledFeatures(result, 'licensed', false)).toEqual({
1212
+ collaboration: true,
1213
+ commenting: true,
1214
+ })
1215
+ })
1216
+
1217
+ it('disables all features for an expired evaluation license', () => {
1218
+ const result = getDefaultLicenseResult({
1219
+ isEvaluationLicense: true,
1220
+ isEvaluationLicenseExpired: true,
1221
+ isCollaborationEnabled: false,
1222
+ isCommentingEnabled: false,
1223
+ })
1224
+ expect(getEnabledFeatures(result, 'expired', false)).toEqual({
1225
+ collaboration: false,
1226
+ commenting: false,
1227
+ })
1228
+ })
1229
+
1230
+ it('disables all features when the license is not parseable', () => {
1231
+ expect(
1232
+ getEnabledFeatures(
1233
+ { isLicenseParseable: false, reason: 'no-key-provided' },
1234
+ 'licensed',
1235
+ false
1236
+ )
1237
+ ).toEqual({
1238
+ collaboration: false,
1239
+ commenting: false,
1240
+ })
1241
+ })
1242
+ })
@@ -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)
@@ -483,6 +540,45 @@ export class LicenseManager {
483
540
  static className = 'tl-watermark_SEE-LICENSE'
484
541
  }
485
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
+ // Evaluation licenses get every feature so prospects can trial the whole product without us
570
+ // having to mint per-feature evaluation keys. Expired evaluation licenses are already excluded
571
+ // above: they resolve to the 'expired' state.
572
+ if (result.isEvaluationLicense) {
573
+ return { ...ALL_FEATURES }
574
+ }
575
+
576
+ return {
577
+ collaboration: result.isCollaborationEnabled,
578
+ commenting: result.isCommentingEnabled,
579
+ }
580
+ }
581
+
486
582
  export function getLicenseState(
487
583
  result: LicenseFromKeyResult,
488
584
  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
+ }
@@ -0,0 +1,48 @@
1
+ import { act, render, screen } from '@testing-library/react'
2
+ import { EditorPortal } from '../components/EditorPortal'
3
+ import { createTLStore } from '../config/createTLStore'
4
+ import { TL_CONTAINER_CLASS, TldrawEditor } from '../TldrawEditor'
5
+
6
+ /** Mounted in a canvas slot — the deep-in-the-tree case the portal exists for. */
7
+ function Portaled() {
8
+ return (
9
+ <EditorPortal>
10
+ <div data-testid="portaled" />
11
+ </EditorPortal>
12
+ )
13
+ }
14
+
15
+ describe('EditorPortal', () => {
16
+ async function renderEditor() {
17
+ const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
18
+ await act(async () => {
19
+ render(<TldrawEditor store={store} components={{ InFrontOfTheCanvas: Portaled }} />)
20
+ })
21
+ return document.querySelector(`.${TL_CONTAINER_CLASS}`)!
22
+ }
23
+
24
+ it('renders its children into the editor container', async () => {
25
+ const container = await renderEditor()
26
+ expect(container.contains(screen.getByTestId('portaled'))).toBe(true)
27
+ })
28
+
29
+ it('renders into a host that comes after everything the editor renders', async () => {
30
+ const container = await renderEditor()
31
+ const host = container.querySelector('.tl-portal-host')!
32
+ expect(host.contains(screen.getByTestId('portaled'))).toBe(true)
33
+ // Last of the container's rendered children. Managers append their own nodes after this one
34
+ // (the text measurement element, say) but those are `tabIndex = -1` and out of the tab order.
35
+ const rendered = [...container.children].filter((el) => !el.matches('.tl-text-measure'))
36
+ expect(rendered.at(-1)).toBe(host)
37
+ })
38
+
39
+ // The tab order guarantee: mounted in a canvas slot, the portaled content still lands after
40
+ // everything the editor renders itself — including (in tldraw) the UI's skip link, which only
41
+ // works while nothing precedes it. Portaling straight into the container lands it ahead of them.
42
+ it('places its children after the rest of the editor in document order', async () => {
43
+ await renderEditor()
44
+ const portaled = screen.getByTestId('portaled')
45
+ const canvas = screen.getByTestId('canvas')
46
+ expect(canvas.compareDocumentPosition(portaled) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
47
+ })
48
+ })
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { isCursorInViewport } from './collaborators'
3
+
4
+ // The viewport (page coords) all cases test against; zoom 1 so the 12/16px margins map 1:1.
5
+ const viewport = { minX: 0, minY: 0, maxX: 100, maxY: 100 }
6
+
7
+ describe('isCursorInViewport', () => {
8
+ it('is true for a cursor well inside the viewport', () => {
9
+ expect(isCursorInViewport({ x: 50, y: 50 }, viewport, 1)).toBe(true)
10
+ })
11
+
12
+ it('keeps a cursor just past the left/top edges (12/16px margin)', () => {
13
+ expect(isCursorInViewport({ x: -11, y: 50 }, viewport, 1)).toBe(true) // within 12 of left
14
+ expect(isCursorInViewport({ x: -13, y: 50 }, viewport, 1)).toBe(false) // past it
15
+ expect(isCursorInViewport({ x: 50, y: -15 }, viewport, 1)).toBe(true) // within 16 of top
16
+ expect(isCursorInViewport({ x: 50, y: -17 }, viewport, 1)).toBe(false) // past it
17
+ })
18
+
19
+ it('culls a cursor beyond the right/bottom edges', () => {
20
+ // Asymmetric with the left/top edges: the max checks subtract the margin rather than add
21
+ // it, so the cull starts *inside* the far edge (maxX - 12, maxY - 16) — a known quirk
22
+ // carried over from the pre-canvas implementation.
23
+ expect(isCursorInViewport({ x: 87, y: 50 }, viewport, 1)).toBe(true) // < maxX - 12 (88)
24
+ expect(isCursorInViewport({ x: 89, y: 50 }, viewport, 1)).toBe(false) // > 88, culled
25
+ expect(isCursorInViewport({ x: 50, y: 83 }, viewport, 1)).toBe(true) // < maxY - 16 (84)
26
+ expect(isCursorInViewport({ x: 50, y: 85 }, viewport, 1)).toBe(false) // > 84, culled
27
+ })
28
+
29
+ it('scales the margins by zoom', () => {
30
+ // At zoom 2 the left margin is 12/2 = 6px, so -7 is now culled where -11 survived at zoom 1.
31
+ expect(isCursorInViewport({ x: -5, y: 50 }, viewport, 2)).toBe(true)
32
+ expect(isCursorInViewport({ x: -7, y: 50 }, viewport, 2)).toBe(false)
33
+ })
34
+ })
@@ -0,0 +1,25 @@
1
+ import { VecLike } from '../primitives/Vec'
2
+
3
+ /**
4
+ * Whether a collaborator's cursor (page coords) is inside the viewport, with a small margin for the
5
+ * cursor glyph so it isn't culled the instant its hotspot crosses the edge.
6
+ *
7
+ * Single source of truth for the cursor/hint split: the DOM cursor layer renders a collaborator
8
+ * when this is true, and `CollaboratorHintOverlayUtil` draws the viewport-edge hint when it's
9
+ * false. Sharing the predicate keeps those two exhaustive and mutually exclusive — a margin change
10
+ * can't open a dead zone (neither renders) or a double-render (both).
11
+ *
12
+ * @public
13
+ */
14
+ export function isCursorInViewport(
15
+ cursor: VecLike,
16
+ viewport: { minX: number; minY: number; maxX: number; maxY: number },
17
+ zoom: number
18
+ ): boolean {
19
+ return !(
20
+ cursor.x < viewport.minX - 12 / zoom ||
21
+ cursor.y < viewport.minY - 16 / zoom ||
22
+ cursor.x > viewport.maxX - 12 / zoom ||
23
+ cursor.y > viewport.maxY - 16 / zoom
24
+ )
25
+ }
@@ -0,0 +1,22 @@
1
+ import { modulate } from '@tldraw/utils'
2
+ import type { Editor } from '../editor/Editor'
3
+ import { toDomPrecision } from '../primitives/utils'
4
+
5
+ /**
6
+ * The CSS `transform` that puts a 1px-sized html layer into page space under the current camera.
7
+ * Shared by every camera-tracked DOM layer (the canvas's shape layer, the collaborator cursor
8
+ * layer) so they line up exactly and can't drift apart.
9
+ *
10
+ * The offset compensates for the layer's 1px width/height when zoomed, so it registers precisely
11
+ * with the other layers.
12
+ *
13
+ * @internal
14
+ */
15
+ export function getHtmlLayerTransform(editor: Editor): string {
16
+ const { x, y, z } = editor.getCamera()
17
+ const offset =
18
+ z >= 1 ? modulate(z, [1, 8], [0.125, 0.5], true) : modulate(z, [0.1, 1], [-2, 0.125], true)
19
+ return `scale(${toDomPrecision(z)}) translate(${toDomPrecision(x + offset)}px,${toDomPrecision(
20
+ y + offset
21
+ )}px)`
22
+ }
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-next.91d2a20b898c'
4
+ export const version = '5.3.0-next.aefb8773a7eb'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-07-15T09:31:43.721Z',
8
- patch: '2026-07-15T09:31:43.721Z',
7
+ minor: '2026-08-05T09:54:21.039Z',
8
+ patch: '2026-08-05T09:54:21.039Z',
9
9
  }
@@ -1,51 +0,0 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
9
- };
10
- var __copyProps = (to, from, except, desc) => {
11
- if (from && typeof from === "object" || typeof from === "function") {
12
- for (let key of __getOwnPropNames(from))
13
- if (!__hasOwnProp.call(to, key) && key !== except)
14
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
- }
16
- return to;
17
- };
18
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
- var usePassThroughMouseOverEvents_exports = {};
20
- __export(usePassThroughMouseOverEvents_exports, {
21
- usePassThroughMouseOverEvents: () => usePassThroughMouseOverEvents
22
- });
23
- module.exports = __toCommonJS(usePassThroughMouseOverEvents_exports);
24
- var import_react = require("react");
25
- var import_dom = require("../utils/dom");
26
- var import_useContainer = require("./useContainer");
27
- var import_useEditor = require("./useEditor");
28
- function usePassThroughMouseOverEvents(ref) {
29
- if (!ref) throw Error("usePassThroughWheelEvents must be passed a ref");
30
- const container = (0, import_useContainer.useContainer)();
31
- const editor = (0, import_useEditor.useMaybeEditor)();
32
- (0, import_react.useEffect)(() => {
33
- function onMouseOver(e) {
34
- if (!editor?.getInstanceState().isFocused) return;
35
- if (e.isSpecialRedispatchedEvent) return;
36
- (0, import_dom.preventDefault)(e);
37
- const cvs = container.querySelector(".tl-canvas");
38
- if (!cvs) return;
39
- const newEvent = new PointerEvent(e.type, e);
40
- newEvent.isSpecialRedispatchedEvent = true;
41
- cvs.dispatchEvent(newEvent);
42
- }
43
- const elm = ref.current;
44
- if (!elm) return;
45
- elm.addEventListener("mouseover", onMouseOver, { passive: false });
46
- return () => {
47
- elm.removeEventListener("mouseover", onMouseOver);
48
- };
49
- }, [container, editor, ref]);
50
- }
51
- //# sourceMappingURL=usePassThroughMouseOverEvents.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../src/lib/hooks/usePassThroughMouseOverEvents.ts"],
4
- "sourcesContent": ["import { RefObject, useEffect } from 'react'\nimport { preventDefault } from '../utils/dom'\nimport { useContainer } from './useContainer'\nimport { useMaybeEditor } from './useEditor'\n\n/** @public */\nexport function usePassThroughMouseOverEvents(ref: RefObject<HTMLElement | null>) {\n\tif (!ref) throw Error('usePassThroughWheelEvents must be passed a ref')\n\tconst container = useContainer()\n\tconst editor = useMaybeEditor()\n\n\tuseEffect(() => {\n\t\tfunction onMouseOver(e: MouseEvent) {\n\t\t\tif (!editor?.getInstanceState().isFocused) return\n\t\t\tif ((e as any).isSpecialRedispatchedEvent) return\n\t\t\tpreventDefault(e)\n\t\t\tconst cvs = container.querySelector('.tl-canvas')\n\t\t\tif (!cvs) return\n\t\t\tconst newEvent = new PointerEvent(e.type, e as any)\n\t\t\t;(newEvent as any).isSpecialRedispatchedEvent = true\n\t\t\tcvs.dispatchEvent(newEvent)\n\t\t}\n\n\t\tconst elm = ref.current\n\t\tif (!elm) return\n\n\t\telm.addEventListener('mouseover', onMouseOver, { passive: false })\n\t\treturn () => {\n\t\t\telm.removeEventListener('mouseover', onMouseOver)\n\t\t}\n\t}, [container, editor, ref])\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAAqC;AACrC,iBAA+B;AAC/B,0BAA6B;AAC7B,uBAA+B;AAGxB,SAAS,8BAA8B,KAAoC;AACjF,MAAI,CAAC,IAAK,OAAM,MAAM,gDAAgD;AACtE,QAAM,gBAAY,kCAAa;AAC/B,QAAM,aAAS,iCAAe;AAE9B,8BAAU,MAAM;AACf,aAAS,YAAY,GAAe;AACnC,UAAI,CAAC,QAAQ,iBAAiB,EAAE,UAAW;AAC3C,UAAK,EAAU,2BAA4B;AAC3C,qCAAe,CAAC;AAChB,YAAM,MAAM,UAAU,cAAc,YAAY;AAChD,UAAI,CAAC,IAAK;AACV,YAAM,WAAW,IAAI,aAAa,EAAE,MAAM,CAAQ;AACjD,MAAC,SAAiB,6BAA6B;AAChD,UAAI,cAAc,QAAQ;AAAA,IAC3B;AAEA,UAAM,MAAM,IAAI;AAChB,QAAI,CAAC,IAAK;AAEV,QAAI,iBAAiB,aAAa,aAAa,EAAE,SAAS,MAAM,CAAC;AACjE,WAAO,MAAM;AACZ,UAAI,oBAAoB,aAAa,WAAW;AAAA,IACjD;AAAA,EACD,GAAG,CAAC,WAAW,QAAQ,GAAG,CAAC;AAC5B;",
6
- "names": []
7
- }
@@ -1,31 +0,0 @@
1
- import { useEffect } from "react";
2
- import { preventDefault } from "../utils/dom.mjs";
3
- import { useContainer } from "./useContainer.mjs";
4
- import { useMaybeEditor } from "./useEditor.mjs";
5
- function usePassThroughMouseOverEvents(ref) {
6
- if (!ref) throw Error("usePassThroughWheelEvents must be passed a ref");
7
- const container = useContainer();
8
- const editor = useMaybeEditor();
9
- useEffect(() => {
10
- function onMouseOver(e) {
11
- if (!editor?.getInstanceState().isFocused) return;
12
- if (e.isSpecialRedispatchedEvent) return;
13
- preventDefault(e);
14
- const cvs = container.querySelector(".tl-canvas");
15
- if (!cvs) return;
16
- const newEvent = new PointerEvent(e.type, e);
17
- newEvent.isSpecialRedispatchedEvent = true;
18
- cvs.dispatchEvent(newEvent);
19
- }
20
- const elm = ref.current;
21
- if (!elm) return;
22
- elm.addEventListener("mouseover", onMouseOver, { passive: false });
23
- return () => {
24
- elm.removeEventListener("mouseover", onMouseOver);
25
- };
26
- }, [container, editor, ref]);
27
- }
28
- export {
29
- usePassThroughMouseOverEvents
30
- };
31
- //# sourceMappingURL=usePassThroughMouseOverEvents.mjs.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../src/lib/hooks/usePassThroughMouseOverEvents.ts"],
4
- "sourcesContent": ["import { RefObject, useEffect } from 'react'\nimport { preventDefault } from '../utils/dom'\nimport { useContainer } from './useContainer'\nimport { useMaybeEditor } from './useEditor'\n\n/** @public */\nexport function usePassThroughMouseOverEvents(ref: RefObject<HTMLElement | null>) {\n\tif (!ref) throw Error('usePassThroughWheelEvents must be passed a ref')\n\tconst container = useContainer()\n\tconst editor = useMaybeEditor()\n\n\tuseEffect(() => {\n\t\tfunction onMouseOver(e: MouseEvent) {\n\t\t\tif (!editor?.getInstanceState().isFocused) return\n\t\t\tif ((e as any).isSpecialRedispatchedEvent) return\n\t\t\tpreventDefault(e)\n\t\t\tconst cvs = container.querySelector('.tl-canvas')\n\t\t\tif (!cvs) return\n\t\t\tconst newEvent = new PointerEvent(e.type, e as any)\n\t\t\t;(newEvent as any).isSpecialRedispatchedEvent = true\n\t\t\tcvs.dispatchEvent(newEvent)\n\t\t}\n\n\t\tconst elm = ref.current\n\t\tif (!elm) return\n\n\t\telm.addEventListener('mouseover', onMouseOver, { passive: false })\n\t\treturn () => {\n\t\t\telm.removeEventListener('mouseover', onMouseOver)\n\t\t}\n\t}, [container, editor, ref])\n}\n"],
5
- "mappings": "AAAA,SAAoB,iBAAiB;AACrC,SAAS,sBAAsB;AAC/B,SAAS,oBAAoB;AAC7B,SAAS,sBAAsB;AAGxB,SAAS,8BAA8B,KAAoC;AACjF,MAAI,CAAC,IAAK,OAAM,MAAM,gDAAgD;AACtE,QAAM,YAAY,aAAa;AAC/B,QAAM,SAAS,eAAe;AAE9B,YAAU,MAAM;AACf,aAAS,YAAY,GAAe;AACnC,UAAI,CAAC,QAAQ,iBAAiB,EAAE,UAAW;AAC3C,UAAK,EAAU,2BAA4B;AAC3C,qBAAe,CAAC;AAChB,YAAM,MAAM,UAAU,cAAc,YAAY;AAChD,UAAI,CAAC,IAAK;AACV,YAAM,WAAW,IAAI,aAAa,EAAE,MAAM,CAAQ;AACjD,MAAC,SAAiB,6BAA6B;AAChD,UAAI,cAAc,QAAQ;AAAA,IAC3B;AAEA,UAAM,MAAM,IAAI;AAChB,QAAI,CAAC,IAAK;AAEV,QAAI,iBAAiB,aAAa,aAAa,EAAE,SAAS,MAAM,CAAC;AACjE,WAAO,MAAM;AACZ,UAAI,oBAAoB,aAAa,WAAW;AAAA,IACjD;AAAA,EACD,GAAG,CAAC,WAAW,QAAQ,GAAG,CAAC;AAC5B;",
6
- "names": []
7
- }
@@ -1,32 +0,0 @@
1
- import { RefObject, useEffect } from 'react'
2
- import { preventDefault } from '../utils/dom'
3
- import { useContainer } from './useContainer'
4
- import { useMaybeEditor } from './useEditor'
5
-
6
- /** @public */
7
- export function usePassThroughMouseOverEvents(ref: RefObject<HTMLElement | null>) {
8
- if (!ref) throw Error('usePassThroughWheelEvents must be passed a ref')
9
- const container = useContainer()
10
- const editor = useMaybeEditor()
11
-
12
- useEffect(() => {
13
- function onMouseOver(e: MouseEvent) {
14
- if (!editor?.getInstanceState().isFocused) return
15
- if ((e as any).isSpecialRedispatchedEvent) return
16
- preventDefault(e)
17
- const cvs = container.querySelector('.tl-canvas')
18
- if (!cvs) return
19
- const newEvent = new PointerEvent(e.type, e as any)
20
- ;(newEvent as any).isSpecialRedispatchedEvent = true
21
- cvs.dispatchEvent(newEvent)
22
- }
23
-
24
- const elm = ref.current
25
- if (!elm) return
26
-
27
- elm.addEventListener('mouseover', onMouseOver, { passive: false })
28
- return () => {
29
- elm.removeEventListener('mouseover', onMouseOver)
30
- }
31
- }, [container, editor, ref])
32
- }