@tldraw/editor 5.5.4 → 5.6.0-canary.0091c91994d6

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 (101) hide show
  1. package/dist-cjs/index.d.ts +166 -1
  2. package/dist-cjs/index.js +7 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +18 -5
  5. package/dist-cjs/lib/TldrawEditor.js.map +3 -3
  6. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +57 -15
  7. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +2 -2
  8. package/dist-cjs/lib/config/TLI18n.js +64 -0
  9. package/dist-cjs/lib/config/TLI18n.js.map +7 -0
  10. package/dist-cjs/lib/editor/Editor.js +44 -16
  11. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  12. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +10 -3
  13. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
  14. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +1 -1
  15. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
  16. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +19 -2
  17. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  18. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +3 -5
  19. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  20. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +7 -1
  21. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
  22. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +28 -0
  23. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  24. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +3 -0
  25. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +2 -2
  26. package/dist-cjs/lib/editor/shapes/shared/resizeBox.js +21 -16
  27. package/dist-cjs/lib/editor/shapes/shared/resizeBox.js.map +2 -2
  28. package/dist-cjs/lib/hooks/useCursor.js +12 -8
  29. package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
  30. package/dist-cjs/lib/hooks/useDocumentEvents.js +28 -3
  31. package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
  32. package/dist-cjs/lib/license/LicenseManager.js +59 -0
  33. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  34. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +12 -5
  35. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  36. package/dist-cjs/version.js +3 -3
  37. package/dist-cjs/version.js.map +1 -1
  38. package/dist-esm/index.d.mts +166 -1
  39. package/dist-esm/index.mjs +13 -1
  40. package/dist-esm/index.mjs.map +2 -2
  41. package/dist-esm/lib/TldrawEditor.mjs +18 -5
  42. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  43. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +61 -16
  44. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +2 -2
  45. package/dist-esm/lib/config/TLI18n.mjs +44 -0
  46. package/dist-esm/lib/config/TLI18n.mjs.map +7 -0
  47. package/dist-esm/lib/editor/Editor.mjs +46 -17
  48. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  49. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +10 -3
  50. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
  51. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +1 -1
  52. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
  53. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +20 -3
  54. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  55. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +3 -5
  56. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  57. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +7 -1
  58. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
  59. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +28 -0
  60. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  61. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +3 -0
  62. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +2 -2
  63. package/dist-esm/lib/editor/shapes/shared/resizeBox.mjs +21 -16
  64. package/dist-esm/lib/editor/shapes/shared/resizeBox.mjs.map +2 -2
  65. package/dist-esm/lib/hooks/useCursor.mjs +12 -8
  66. package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
  67. package/dist-esm/lib/hooks/useDocumentEvents.mjs +28 -3
  68. package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
  69. package/dist-esm/lib/license/LicenseManager.mjs +59 -0
  70. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  71. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +13 -6
  72. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  73. package/dist-esm/version.mjs +3 -3
  74. package/dist-esm/version.mjs.map +1 -1
  75. package/package.json +17 -19
  76. package/src/index.ts +14 -2
  77. package/src/lib/TldrawEditor.tsx +44 -4
  78. package/src/lib/components/default-components/DefaultErrorFallback.tsx +66 -18
  79. package/src/lib/config/TLI18n.test.ts +84 -0
  80. package/src/lib/config/TLI18n.ts +172 -0
  81. package/src/lib/editor/Editor.ts +90 -33
  82. package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +19 -3
  83. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +17 -0
  84. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +2 -2
  85. package/src/lib/editor/managers/FontManager/FontManager.test.ts +1 -0
  86. package/src/lib/editor/managers/FontManager/FontManager.ts +26 -3
  87. package/src/lib/editor/managers/HistoryManager/HistoryManager.test.ts +32 -0
  88. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +4 -6
  89. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +16 -7
  90. package/src/lib/editor/shapes/ShapeUtil.ts +30 -0
  91. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +4 -0
  92. package/src/lib/editor/shapes/shared/resizeBox.ts +21 -16
  93. package/src/lib/hooks/useCursor.ts +14 -7
  94. package/src/lib/hooks/useDocumentEvents.ts +51 -3
  95. package/src/lib/license/LicenseManager.test.ts +156 -0
  96. package/src/lib/license/LicenseManager.ts +83 -0
  97. package/src/lib/test/useCursor.test.tsx +36 -0
  98. package/src/lib/test/useDocumentEvents.test.tsx +153 -22
  99. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +73 -0
  100. package/src/lib/utils/sync/TLLocalSyncClient.ts +24 -8
  101. package/src/version.ts +3 -3
@@ -1,5 +1,6 @@
1
1
  import { VecModel } from '@tldraw/tlschema'
2
2
  import { Box } from '../../../primitives/Box'
3
+ import { clamp } from '../../../primitives/utils'
3
4
  import { Vec } from '../../../primitives/Vec'
4
5
  import { TLResizeHandle } from '../../types/selection-types'
5
6
  import type { TLBaseBoxShape } from '../BaseBoxShapeUtil'
@@ -36,29 +37,31 @@ export function resizeBox<T extends TLBaseBoxShape>(
36
37
  const offset = new Vec(0, 0)
37
38
 
38
39
  if (w > 0) {
39
- if (w < minWidth) {
40
+ const clampedW = clamp(w, minWidth, maxWidth)
41
+ if (clampedW !== w) {
40
42
  switch (handle) {
41
43
  case 'top_left':
42
44
  case 'left':
43
45
  case 'bottom_left': {
44
- offset.x = w - minWidth
46
+ offset.x = w - clampedW
45
47
  break
46
48
  }
47
49
  case 'top':
48
50
  case 'bottom': {
49
- offset.x = (w - minWidth) / 2
51
+ offset.x = (w - clampedW) / 2
50
52
  break
51
53
  }
52
54
  default: {
53
55
  offset.x = 0
54
56
  }
55
57
  }
56
- w = minWidth
58
+ w = clampedW
57
59
  }
58
60
  } else {
59
61
  offset.x = w
60
62
  w = -w
61
- if (w < minWidth) {
63
+ const clampedW = clamp(w, minWidth, maxWidth)
64
+ if (clampedW !== w) {
62
65
  switch (handle) {
63
66
  case 'top_left':
64
67
  case 'left':
@@ -67,26 +70,27 @@ export function resizeBox<T extends TLBaseBoxShape>(
67
70
  break
68
71
  }
69
72
  default: {
70
- offset.x = -minWidth
73
+ offset.x = -clampedW
71
74
  }
72
75
  }
73
76
 
74
- w = minWidth
77
+ w = clampedW
75
78
  }
76
79
  }
77
80
 
78
81
  if (h > 0) {
79
- if (h < minHeight) {
82
+ const clampedH = clamp(h, minHeight, maxHeight)
83
+ if (clampedH !== h) {
80
84
  switch (handle) {
81
85
  case 'top_left':
82
86
  case 'top':
83
87
  case 'top_right': {
84
- offset.y = h - minHeight
88
+ offset.y = h - clampedH
85
89
  break
86
90
  }
87
91
  case 'right':
88
92
  case 'left': {
89
- offset.y = (h - minHeight) / 2
93
+ offset.y = (h - clampedH) / 2
90
94
  break
91
95
  }
92
96
  default: {
@@ -94,12 +98,13 @@ export function resizeBox<T extends TLBaseBoxShape>(
94
98
  }
95
99
  }
96
100
 
97
- h = minHeight
101
+ h = clampedH
98
102
  }
99
103
  } else {
100
104
  offset.y = h
101
105
  h = -h
102
- if (h < minHeight) {
106
+ const clampedH = clamp(h, minHeight, maxHeight)
107
+ if (clampedH !== h) {
103
108
  switch (handle) {
104
109
  case 'top_left':
105
110
  case 'top':
@@ -108,10 +113,10 @@ export function resizeBox<T extends TLBaseBoxShape>(
108
113
  break
109
114
  }
110
115
  default: {
111
- offset.y = -minHeight
116
+ offset.y = -clampedH
112
117
  }
113
118
  }
114
- h = minHeight
119
+ h = clampedH
115
120
  }
116
121
  }
117
122
 
@@ -122,8 +127,8 @@ export function resizeBox<T extends TLBaseBoxShape>(
122
127
  x,
123
128
  y,
124
129
  props: {
125
- w: Math.min(maxWidth, w),
126
- h: Math.min(maxHeight, h),
130
+ w,
131
+ h,
127
132
  },
128
133
  }
129
134
  }
@@ -23,8 +23,9 @@ function getCursorCss(
23
23
  const dx = 1 * c - 1 * s
24
24
  const dy = 1 * s + 1 * c
25
25
 
26
+ // A raw '#' in the colour (hex) would end the unencoded data url as a fragment.
26
27
  return (
27
- `url("data:image/svg+xml,<svg height='32' width='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' style='color: ${color};'><defs><filter id='shadow' y='-40%' x='-40%' width='180px' height='180%' color-interpolation-filters='sRGB'><feDropShadow dx='${dx}' dy='${dy}' stdDeviation='1.2' flood-opacity='.5'/></filter></defs><g fill='none' transform='rotate(${
28
+ `url("data:image/svg+xml,<svg height='32' width='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' style='color: ${encodeURIComponent(color)};'><defs><filter id='shadow' y='-40%' x='-40%' width='180px' height='180%' color-interpolation-filters='sRGB'><feDropShadow dx='${dx}' dy='${dy}' stdDeviation='1.2' flood-opacity='.5'/></filter></defs><g fill='none' transform='rotate(${
28
29
  r + tr
29
30
  } 16 16)${f ? ` scale(-1,-1) translate(0, -32)` : ''}' filter='url(%23shadow)'>` +
30
31
  svg.replaceAll(`"`, `'`) +
@@ -32,7 +33,8 @@ function getCursorCss(
32
33
  )
33
34
  }
34
35
 
35
- const STATIC_CURSORS = [
36
+ // Cursors that map straight to a prerendered `--tl-cursor-*` css variable rather than a runtime svg.
37
+ const STATIC_CURSORS = new Set<TLCursorType>([
36
38
  'default',
37
39
  'pointer',
38
40
  'cross',
@@ -43,11 +45,14 @@ const STATIC_CURSORS = [
43
45
  'text',
44
46
  'zoom-in',
45
47
  'zoom-out',
46
- ]
48
+ 'rotate',
49
+ 'resize-edge',
50
+ 'resize-corner',
51
+ ])
47
52
 
48
53
  type CursorFunction = (rotation: number, flip: boolean, color: string) => string
49
54
 
50
- const CURSORS: Record<TLCursorType, CursorFunction> = {
55
+ const DYNAMIC_CURSORS: Record<TLCursorType, CursorFunction> = {
51
56
  none: () => 'none',
52
57
  'ew-resize': (r, f, c) => getCursorCss(EDGE_SVG, r, 0, f, c),
53
58
  'ns-resize': (r, f, c) => getCursorCss(EDGE_SVG, r, 90, f, c),
@@ -61,7 +66,8 @@ const CURSORS: Record<TLCursorType, CursorFunction> = {
61
66
 
62
67
  /** @public */
63
68
  export function getCursor(cursor: TLCursorType, rotation = 0, color = 'black') {
64
- return CURSORS[cursor](radiansToDegrees(rotation), false, color)
69
+ if (STATIC_CURSORS.has(cursor)) return `var(--tl-cursor-${cursor})`
70
+ return DYNAMIC_CURSORS[cursor](radiansToDegrees(rotation), false, color)
65
71
  }
66
72
 
67
73
  export function useCursor() {
@@ -73,8 +79,9 @@ export function useCursor() {
73
79
  () => {
74
80
  const { type, rotation } = editor.getInstanceState().cursor
75
81
 
76
- if (STATIC_CURSORS.includes(type)) {
77
- container.style.setProperty('--tl-cursor', `var(--tl-cursor-${type})`)
82
+ // Static cursors don't need the theme colour, so skip reading it to avoid depending on it.
83
+ if (STATIC_CURSORS.has(type)) {
84
+ container.style.setProperty('--tl-cursor', getCursor(type))
78
85
  return
79
86
  }
80
87
 
@@ -18,6 +18,10 @@ export function useDocumentEvents() {
18
18
  // blur release below needs the key remembered per code.
19
19
  const heldKeysRef = useRef(new Map<string, string>())
20
20
 
21
+ // Non-modifier keys pressed while Meta was down. macOS never delivers their keyup, so
22
+ // `inputs.keys` would count them as held for good; see the Meta branch in `handleKeyUp`.
23
+ const keysPressedWithMetaRef = useRef(new Set<string>())
24
+
21
25
  // Prevent the browser's default drag and drop behavior on our container (UI, etc)
22
26
  useEffect(() => {
23
27
  if (!container) return
@@ -217,6 +221,12 @@ export function useDocumentEvents() {
217
221
  }
218
222
 
219
223
  heldKeysRef.current.set(e.code, e.key)
224
+ // Only a fresh press counts. A key held from before Meta went down keeps auto-
225
+ // repeating, and those repeats carry `metaKey` too, which would make it look like
226
+ // it was pressed during the Meta window.
227
+ if (!e.repeat && e.metaKey && !MODIFIER_CODES.has(e.code)) {
228
+ keysPressedWithMetaRef.current.add(e.code)
229
+ }
220
230
  editor.dispatch(info)
221
231
  }
222
232
 
@@ -232,6 +242,13 @@ export function useDocumentEvents() {
232
242
  return
233
243
  }
234
244
 
245
+ // The native `metaKey` is still true on Meta's own keyup. Reported as-is it matches
246
+ // neither branch of the editor's modifier handling - which ignores `metaKey` on a
247
+ // key_up, then only arms the 150ms release when it reads false - so a cmd tap left
248
+ // `getMetaKey()` true until some later event happened to say otherwise.
249
+ const isMetaRelease = e.code === 'MetaLeft' || e.code === 'MetaRight'
250
+ const metaKey = isMetaRelease ? false : e.metaKey
251
+
235
252
  const info: TLKeyboardEventInfo = {
236
253
  type: 'keyboard',
237
254
  name: 'key_up',
@@ -239,13 +256,32 @@ export function useDocumentEvents() {
239
256
  code: e.code,
240
257
  shiftKey: e.shiftKey,
241
258
  altKey: e.altKey,
242
- ctrlKey: e.metaKey || e.ctrlKey,
243
- metaKey: e.metaKey,
244
- accelKey: isAccelKey(e),
259
+ ctrlKey: metaKey || e.ctrlKey,
260
+ metaKey,
261
+ accelKey: isAccelKey({ ctrlKey: e.ctrlKey, metaKey }),
245
262
  }
246
263
 
247
264
  heldKeysRef.current.delete(e.code)
265
+ keysPressedWithMetaRef.current.delete(e.code)
248
266
  editor.dispatch(info)
267
+
268
+ // macOS swallows the keyup of a non-modifier key pressed while Meta is held, so a
269
+ // stuck ArrowUp from Cmd+ArrowUp made every later arrow nudge diagonally: the nudge
270
+ // sums all held arrows. Anything held from before the Meta window still gets its
271
+ // own keyup, so only drop what was pressed inside it. That is still one too many -
272
+ // the key you are *still* holding is not owed anything - and the key_repeat case
273
+ // in `Editor.dispatch` is what takes that one back.
274
+ //
275
+ // Drop them rather than replaying a key_up: a tool's onKeyUp is a real action at
276
+ // the wrong moment, and a synthetic Enter re-entered shape editing and stole the
277
+ // focus that cmd+Enter's a11y action had just put on the style toolbar.
278
+ if (isMetaRelease) {
279
+ for (const code of keysPressedWithMetaRef.current) {
280
+ editor.inputs.keys.delete(code)
281
+ heldKeysRef.current.delete(code)
282
+ }
283
+ keysPressedWithMetaRef.current.clear()
284
+ }
249
285
  }
250
286
 
251
287
  function handleTouchStart(e: TouchEvent) {
@@ -334,6 +370,7 @@ export function useDocumentEvents() {
334
370
  })
335
371
  }
336
372
  heldKeys.clear()
373
+ keysPressedWithMetaRef.current.clear()
337
374
  }
338
375
 
339
376
  win.addEventListener('blur', handleWindowBlur)
@@ -343,6 +380,17 @@ export function useDocumentEvents() {
343
380
  }, [editor])
344
381
  }
345
382
 
383
+ const MODIFIER_CODES = new Set([
384
+ 'ShiftLeft',
385
+ 'ShiftRight',
386
+ 'AltLeft',
387
+ 'AltRight',
388
+ 'ControlLeft',
389
+ 'ControlRight',
390
+ 'MetaLeft',
391
+ 'MetaRight',
392
+ ])
393
+
346
394
  function areShortcutsDisabled(editor: Editor) {
347
395
  return (
348
396
  editor.menus.hasOpenMenus() ||
@@ -678,6 +678,162 @@ describe('LicenseManager', () => {
678
678
  )
679
679
  })
680
680
 
681
+ describe('Canonical tldraw hosts', () => {
682
+ let fetchSpy: ReturnType<typeof vi.fn>
683
+
684
+ beforeEach(() => {
685
+ process.env.NODE_ENV = 'production'
686
+ fetchSpy = vi.fn(() => Promise.resolve(new Response()))
687
+ vi.stubGlobal('fetch', fetchSpy)
688
+ })
689
+
690
+ afterEach(() => {
691
+ vi.unstubAllGlobals()
692
+ process.env.NODE_ENV = 'test'
693
+ })
694
+
695
+ // The shared licenseManager was built against jsdom's localhost default, so it's in dev mode.
696
+ // Build one on the host itself so these run the constructor path a real page on our domains
697
+ // takes: production dev detection, license state, feature flags and the tracking ping.
698
+ async function createManagerOnHost(url: string, licenseKey = '') {
699
+ // @ts-ignore
700
+ delete window.location
701
+ // @ts-ignore
702
+ window.location = new URL(url)
703
+
704
+ const manager = new LicenseManager(licenseKey, keyPair.publicKey)
705
+ await vi.waitFor(() => expect(manager.state.get()).not.toBe('pending'))
706
+ return manager
707
+ }
708
+
709
+ it('Licenses tldraw-owned hosts with a watermark when no key is provided', async () => {
710
+ const hosts = [
711
+ 'https://tldraw.com',
712
+ 'https://www.tldraw.com',
713
+ 'https://makereal.tldraw.com',
714
+ 'https://tldraw.dev',
715
+ 'https://staging.tldraw.dev',
716
+ 'https://preview.tldraw.club',
717
+ 'https://fairies.tldraw.workers.dev',
718
+ 'https://teach.tldraw.xyz',
719
+ ]
720
+
721
+ for (const host of hosts) {
722
+ const manager = await createManagerOnHost(host)
723
+
724
+ expect(manager.isDevelopment).toBe(false)
725
+ expect(await manager.getLicenseFromKey('')).toMatchObject({
726
+ isLicenseParseable: true,
727
+ isDomainValid: true,
728
+ isLicensedWithWatermark: true,
729
+ isCollaborationEnabled: true,
730
+ isCommentingEnabled: true,
731
+ isAnnualLicenseExpired: false,
732
+ isPerpetualLicenseExpired: false,
733
+ daysSinceExpiry: 0,
734
+ })
735
+ expect(manager.state.get()).toBe('licensed-with-watermark')
736
+ expect(manager.featureFlags.get()).toEqual({ collaboration: true, commenting: true })
737
+ manager.dispose()
738
+ }
739
+ })
740
+
741
+ it('Does not license hosts that merely look like ours', async () => {
742
+ const hosts = [
743
+ 'https://nottldraw.com',
744
+ 'https://tldraw.com.example.com',
745
+ 'https://eviltldraw.dev',
746
+ 'https://tldraw.co',
747
+ 'https://tldraw.workers.dev.example.com',
748
+ ]
749
+
750
+ for (const host of hosts) {
751
+ const manager = await createManagerOnHost(host)
752
+
753
+ expect(await manager.getLicenseFromKey('')).toMatchObject({
754
+ isLicenseParseable: false,
755
+ reason: 'no-key-provided',
756
+ })
757
+ expect(manager.state.get()).toBe('unlicensed-production')
758
+ expect(manager.featureFlags.get()).toEqual({ collaboration: false, commenting: false })
759
+ manager.dispose()
760
+ }
761
+ })
762
+
763
+ it('Licenses our VS Code extension webview when no key is provided', async () => {
764
+ const manager = await createManagerOnHost(
765
+ 'vscode-webview://1ipd8pun8ud7nd7hv9d112g7evi7m10vak9vviuvia66ou6aibp3/index.html?id=6ec2dc7a-afe9-45d9-bd71-1749f9568d28&swVersion=4&extensionId=tldraw-org.tldraw-vscode&platform=electron'
766
+ )
767
+
768
+ expect(await manager.getLicenseFromKey('')).toMatchObject({
769
+ isLicenseParseable: true,
770
+ isDomainValid: true,
771
+ isLicensedWithWatermark: true,
772
+ })
773
+ expect(manager.state.get()).toBe('licensed-with-watermark')
774
+ manager.dispose()
775
+ })
776
+
777
+ it('Does not license other VS Code extension webviews', async () => {
778
+ const urls = [
779
+ 'vscode-webview://abc/index.html?extensionId=evil-org.evil-vscode&x=tldraw-org.tldraw-vscode',
780
+ 'vscode-webview://tldraw-org.tldraw-vscode/index.html?extensionId=evil-org.evil-vscode',
781
+ ]
782
+
783
+ for (const url of urls) {
784
+ const manager = await createManagerOnHost(url)
785
+
786
+ expect(await manager.getLicenseFromKey('')).toMatchObject({
787
+ isLicenseParseable: false,
788
+ reason: 'no-key-provided',
789
+ })
790
+ manager.dispose()
791
+ }
792
+ })
793
+
794
+ it('Never sends a watermark tracking ping for our own domains', async () => {
795
+ const manager = await createManagerOnHost('https://makereal.tldraw.com')
796
+
797
+ expect(manager.state.get()).toBe('licensed-with-watermark')
798
+ expect(fetchSpy).not.toHaveBeenCalled()
799
+ manager.dispose()
800
+ })
801
+
802
+ it('Still tracks a real watermarked key on one of our domains', async () => {
803
+ const licenseKey = await generateLicenseKey(
804
+ JSON.stringify([
805
+ 'a-real-key',
806
+ ['*.tldraw.com'],
807
+ FLAGS.ANNUAL_LICENSE | FLAGS.WITH_WATERMARK,
808
+ expiryDate,
809
+ ]),
810
+ keyPair
811
+ )
812
+ const manager = await createManagerOnHost('https://makereal.tldraw.com', licenseKey)
813
+
814
+ expect(manager.state.get()).toBe('licensed-with-watermark')
815
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
816
+ const trackUrl = new URL(fetchSpy.mock.calls[0][0] as string)
817
+ expect(trackUrl.searchParams.get('license_type')).toBe('with_watermark')
818
+ expect(trackUrl.searchParams.get('license_id')).toBe('a-real-key')
819
+ manager.dispose()
820
+ })
821
+
822
+ it('Lets an explicitly provided key win over the canonical-host grant', async () => {
823
+ const licenseKey = await generateLicenseKey(
824
+ JSON.stringify(['a-real-key', ['*.tldraw.com'], FLAGS.ANNUAL_LICENSE, expiryDate]),
825
+ keyPair
826
+ )
827
+ const manager = await createManagerOnHost('https://tldraw.com', licenseKey)
828
+
829
+ const result = (await manager.getLicenseFromKey(licenseKey)) as ValidLicenseKeyResult
830
+ expect(result.license.id).toBe('a-real-key')
831
+ expect(result.isLicensedWithWatermark).toBe(false)
832
+ expect(manager.state.get()).toBe('licensed')
833
+ manager.dispose()
834
+ })
835
+ })
836
+
681
837
  describe('Tracked licenses', () => {
682
838
  let fetchSpy: ReturnType<typeof vi.fn>
683
839
 
@@ -43,6 +43,31 @@ const NUMBER_OF_KNOWN_PROPERTIES = Object.keys(PROPERTIES).length
43
43
 
44
44
  const LICENSE_EMAIL = 'sales@tldraw.com'
45
45
 
46
+ /**
47
+ * tldraw's own public domains. A page served from one of these hosts — or any subdomain of one — is
48
+ * licensed without a key, because we own them: minting and rotating keys for our own properties was
49
+ * a recurring source of expired and wrong-domain deployments.
50
+ *
51
+ * This list ships in the bundle, so it holds only domains we already serve from publicly. tldraw's
52
+ * private domains are deliberately absent and keep using a real license key.
53
+ */
54
+ const CANONICAL_TLDRAW_HOSTS = [
55
+ 'tldraw.com',
56
+ 'tldraw.dev',
57
+ 'tldraw.club',
58
+ 'tldraw.workers.dev',
59
+ 'tldraw.xyz',
60
+ ]
61
+
62
+ /** Our VS Code extension, whose webviews are served from `vscode-webview:` with a random hostname. */
63
+ const CANONICAL_VSCODE_EXTENSION_ID = 'tldraw-org.tldraw-vscode'
64
+
65
+ /** Marks a license as the keyless canonical-host grant, which is never tracked. */
66
+ const CANONICAL_HOST_LICENSE_ID = 'tldraw-canonical-host'
67
+
68
+ /** The canonical-host grant has no key behind it, and so no expiry date to fall due. */
69
+ const CANONICAL_HOST_NEVER_EXPIRES = new Date(8640000000000000)
70
+
46
71
  const WATERMARK_TRACK_SRC = `${getDefaultCdnBaseUrl()}/watermarks/watermark-track.svg`
47
72
 
48
73
  /** @internal */
@@ -215,7 +240,59 @@ export class LicenseManager {
215
240
  return host === 'localhost' || host === '::1' || /^127(?:\.\d{1,3}){3}$/.test(host)
216
241
  }
217
242
 
243
+ /** Whether the page is served from one of tldraw's own domains, a subdomain of one, or our VS Code extension. */
244
+ private isCanonicalTldrawHost() {
245
+ if (window.location.protocol === 'vscode-webview:') {
246
+ const extensionId = new URL(window.location.href).searchParams.get('extensionId')
247
+ return extensionId === CANONICAL_VSCODE_EXTENSION_ID
248
+ }
249
+ const hostname = window.location.hostname.toLowerCase()
250
+ return CANONICAL_TLDRAW_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`))
251
+ }
252
+
253
+ /**
254
+ * The license tldraw's own domains get without a key. It keeps the watermark — our properties
255
+ * advertise the SDK the same way any other watermarked deployment does — and grants the
256
+ * collaboration features our apps are built on.
257
+ *
258
+ * Every expiry-derived field is a literal rather than something computed from a date, because
259
+ * there is no key here to expire: the grant lasts as long as the host is ours.
260
+ */
261
+ private getCanonicalHostLicense(): ValidLicenseKeyResult {
262
+ return {
263
+ isLicenseParseable: true,
264
+ license: {
265
+ id: CANONICAL_HOST_LICENSE_ID,
266
+ hosts: [window.location.hostname.toLowerCase()],
267
+ flags: FLAGS.WITH_WATERMARK | FLAGS.FEAT_COLLABORATION | FLAGS.FEAT_COMMENTING,
268
+ expiryDate: CANONICAL_HOST_NEVER_EXPIRES.toISOString(),
269
+ },
270
+ isDevelopment: this.isDevelopment,
271
+ isDomainValid: true,
272
+ expiryDate: CANONICAL_HOST_NEVER_EXPIRES,
273
+ isAnnualLicense: false,
274
+ isAnnualLicenseExpired: false,
275
+ isPerpetualLicense: false,
276
+ isPerpetualLicenseExpired: false,
277
+ isInternalLicense: false,
278
+ isNativeLicense: false,
279
+ isLicensedWithWatermark: true,
280
+ isEvaluationLicense: false,
281
+ isEvaluationLicenseExpired: false,
282
+ isCollaborationEnabled: true,
283
+ isCommentingEnabled: true,
284
+ daysSinceExpiry: 0,
285
+ }
286
+ }
287
+
218
288
  private getTrackType(result: LicenseFromKeyResult, licenseState: LicenseState): TrackType {
289
+ // Our own domains are licensed without a key, and the watermark ping exists to tell us about
290
+ // deployments we don't already know about. Reporting our own apps back to ourselves would only
291
+ // add noise to the numbers we read, so the canonical-host grant is never tracked.
292
+ if ('license' in result && result.license.id === CANONICAL_HOST_LICENSE_ID) {
293
+ return null
294
+ }
295
+
219
296
  // Track watermark for unlicensed production deployments
220
297
  if (licenseState === 'unlicensed-production') {
221
298
  return 'unlicensed'
@@ -328,6 +405,12 @@ export class LicenseManager {
328
405
  }
329
406
 
330
407
  async getLicenseFromKey(licenseKey?: string): Promise<LicenseFromKeyResult> {
408
+ // A key that is provided still wins, so an app on one of our hosts can opt into a different
409
+ // license — a native or feature-specific key — just by passing it.
410
+ if (!licenseKey && this.isCanonicalTldrawHost()) {
411
+ return this.getCanonicalHostLicense()
412
+ }
413
+
331
414
  if (!licenseKey) {
332
415
  if (!this.isDevelopment) {
333
416
  this.outputNoLicenseKeyProvided()
@@ -0,0 +1,36 @@
1
+ import { act, render } from '@testing-library/react'
2
+ import { createTLStore } from '../config/createTLStore'
3
+ import { Editor } from '../editor/Editor'
4
+ import { getCursor } from '../hooks/useCursor'
5
+ import { TL_CONTAINER_CLASS, TldrawEditor } from '../TldrawEditor'
6
+
7
+ describe('useCursor', () => {
8
+ it('maps the static schema cursor types to their css variables', async () => {
9
+ let editor: Editor
10
+ const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
11
+ await act(async () => {
12
+ render(<TldrawEditor store={store} onMount={(e) => void (editor = e)} />)
13
+ })
14
+ const container = document.querySelector(`.${TL_CONTAINER_CLASS}`) as HTMLElement
15
+
16
+ for (const type of ['rotate', 'resize-edge', 'resize-corner', 'default'] as const) {
17
+ act(() => editor.setCursor({ type, rotation: 0 }))
18
+ expect(container.style.getPropertyValue('--tl-cursor')).toBe(`var(--tl-cursor-${type})`)
19
+ }
20
+ })
21
+ })
22
+
23
+ describe('getCursor', () => {
24
+ it('returns the css variable for the static cursor types', () => {
25
+ for (const type of ['rotate', 'resize-edge', 'resize-corner', 'default'] as const) {
26
+ expect(getCursor(type)).toBe(`var(--tl-cursor-${type})`)
27
+ expect(getCursor(type, Math.PI / 2, '#000000')).toBe(`var(--tl-cursor-${type})`)
28
+ }
29
+ })
30
+
31
+ it('encodes the colour so a hex value does not end the data url as a fragment', () => {
32
+ const css = getCursor('ew-resize', 0, '#000000')
33
+ expect(css).toContain("style='color: %23000000;'")
34
+ expect(css).not.toContain('#')
35
+ })
36
+ })