@tldraw/editor 5.5.0-next.f9308d324e1c → 5.5.0

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 (192) hide show
  1. package/dist-cjs/index.d.ts +49 -23
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +16 -12
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/MenuClickCapture.js +42 -3
  7. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  8. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +5 -1
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  10. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +2 -5
  11. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +3 -3
  12. package/dist-cjs/lib/components/default-components/DefaultSpinner.js.map +2 -2
  13. package/dist-cjs/lib/editor/Editor.js +168 -181
  14. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  15. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
  16. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  17. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +3 -2
  18. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/EditorManager.js +11 -2
  20. package/dist-cjs/lib/editor/managers/EditorManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js +49 -63
  22. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +1 -1
  24. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +24 -24
  26. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
  27. package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js +7 -1
  28. package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js.map +2 -2
  29. package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js +4 -3
  30. package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js.map +2 -2
  31. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +4 -4
  32. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  33. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
  34. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
  35. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  36. package/dist-cjs/lib/exports/ExportDelay.js +10 -2
  37. package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
  38. package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
  39. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  40. package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
  41. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  42. package/dist-cjs/lib/exports/fetchCache.js +3 -0
  43. package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
  44. package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
  45. package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
  46. package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
  47. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  48. package/dist-cjs/lib/exports/parseCss.js +1 -1
  49. package/dist-cjs/lib/exports/parseCss.js.map +1 -1
  50. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  51. package/dist-cjs/lib/hooks/useCanvasEvents.js +24 -0
  52. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  53. package/dist-cjs/lib/hooks/useIdentity.js +6 -2
  54. package/dist-cjs/lib/hooks/useIdentity.js.map +3 -3
  55. package/dist-cjs/lib/license/LicenseManager.js +9 -1
  56. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  57. package/dist-cjs/lib/license/LicenseProvider.js +12 -4
  58. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  59. package/dist-cjs/lib/utils/debug-flags.js +0 -6
  60. package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
  61. package/dist-cjs/lib/utils/deepLinks.js +2 -1
  62. package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
  63. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +32 -25
  64. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  65. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +81 -40
  66. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  67. package/dist-cjs/version.js +3 -3
  68. package/dist-cjs/version.js.map +1 -1
  69. package/dist-esm/index.d.mts +49 -23
  70. package/dist-esm/index.mjs +4 -2
  71. package/dist-esm/index.mjs.map +2 -2
  72. package/dist-esm/lib/TldrawEditor.mjs +17 -13
  73. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  74. package/dist-esm/lib/components/MenuClickCapture.mjs +42 -3
  75. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  76. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -1
  77. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  78. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +2 -5
  79. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
  80. package/dist-esm/lib/components/default-components/DefaultSpinner.mjs.map +2 -2
  81. package/dist-esm/lib/editor/Editor.mjs +168 -181
  82. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  83. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
  84. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  85. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +3 -2
  86. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
  87. package/dist-esm/lib/editor/managers/EditorManager.mjs +11 -2
  88. package/dist-esm/lib/editor/managers/EditorManager.mjs.map +2 -2
  89. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs +49 -63
  90. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs.map +2 -2
  91. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +1 -1
  92. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  93. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +24 -24
  94. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
  95. package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs +7 -1
  96. package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs.map +2 -2
  97. package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs +4 -3
  98. package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs.map +2 -2
  99. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +4 -4
  100. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  101. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  102. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  103. package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
  104. package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
  105. package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
  106. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  107. package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
  108. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  109. package/dist-esm/lib/exports/fetchCache.mjs +3 -0
  110. package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
  111. package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
  112. package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
  113. package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
  114. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  115. package/dist-esm/lib/exports/parseCss.mjs +1 -1
  116. package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
  117. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  118. package/dist-esm/lib/hooks/useCanvasEvents.mjs +24 -0
  119. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  120. package/dist-esm/lib/hooks/useIdentity.mjs +7 -3
  121. package/dist-esm/lib/hooks/useIdentity.mjs.map +3 -3
  122. package/dist-esm/lib/license/LicenseManager.mjs +9 -1
  123. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  124. package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
  125. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  126. package/dist-esm/lib/utils/debug-flags.mjs +0 -6
  127. package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
  128. package/dist-esm/lib/utils/deepLinks.mjs +2 -1
  129. package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
  130. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +32 -25
  131. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  132. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +81 -40
  133. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  134. package/dist-esm/version.mjs +3 -3
  135. package/dist-esm/version.mjs.map +1 -1
  136. package/editor.css +9 -1
  137. package/package.json +8 -11
  138. package/src/index.ts +5 -1
  139. package/src/lib/TldrawEditor.tsx +35 -21
  140. package/src/lib/components/MenuClickCapture.tsx +55 -4
  141. package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
  142. package/src/lib/components/default-components/DefaultErrorFallback.tsx +13 -5
  143. package/src/lib/components/default-components/DefaultSpinner.tsx +4 -1
  144. package/src/lib/editor/Editor.test.ts +65 -0
  145. package/src/lib/editor/Editor.ts +261 -294
  146. package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
  147. package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +1 -0
  148. package/src/lib/editor/managers/ClickManager/ClickManager.ts +3 -2
  149. package/src/lib/editor/managers/EditorManager.test.ts +32 -1
  150. package/src/lib/editor/managers/EditorManager.ts +15 -3
  151. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.test.ts +80 -0
  152. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.ts +54 -81
  153. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +1 -1
  154. package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +32 -36
  155. package/src/lib/editor/managers/SnapManager/HandleSnaps.ts +13 -2
  156. package/src/lib/editor/managers/SnapManager/SnapManager.test.ts +13 -0
  157. package/src/lib/editor/managers/SnapManager/SnapManager.ts +6 -4
  158. package/src/lib/editor/managers/TextManager/TextManager.test.ts +74 -0
  159. package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
  160. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  161. package/src/lib/editor/types/external-content.ts +5 -0
  162. package/src/lib/exports/ExportDelay.tsx +16 -4
  163. package/src/lib/exports/FontEmbedder.test.ts +99 -0
  164. package/src/lib/exports/FontEmbedder.ts +32 -11
  165. package/src/lib/exports/StyleEmbedder.ts +13 -4
  166. package/src/lib/exports/fetchCache.test.ts +22 -0
  167. package/src/lib/exports/fetchCache.ts +4 -0
  168. package/src/lib/exports/getSvgAsImage.ts +13 -5
  169. package/src/lib/exports/getSvgJsx.test.ts +43 -1
  170. package/src/lib/exports/getSvgJsx.tsx +28 -16
  171. package/src/lib/exports/parseCss.test.ts +4 -0
  172. package/src/lib/exports/parseCss.ts +2 -2
  173. package/src/lib/hooks/EditorComponentsContext.tsx +2 -1
  174. package/src/lib/hooks/useCanvasEvents.ts +42 -0
  175. package/src/lib/hooks/useIdentity.tsx +12 -1
  176. package/src/lib/license/LicenseManager.ts +13 -1
  177. package/src/lib/license/LicenseProvider.test.tsx +76 -0
  178. package/src/lib/license/LicenseProvider.tsx +19 -5
  179. package/src/lib/test/preserveFocus.test.tsx +81 -0
  180. package/src/lib/utils/debug-flags.ts +0 -6
  181. package/src/lib/utils/deepLinks.test.ts +15 -0
  182. package/src/lib/utils/deepLinks.ts +8 -3
  183. package/src/lib/utils/sync/LocalIndexedDb.test.ts +121 -1
  184. package/src/lib/utils/sync/LocalIndexedDb.ts +41 -28
  185. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +257 -1
  186. package/src/lib/utils/sync/TLLocalSyncClient.ts +112 -54
  187. package/src/version.ts +3 -3
  188. package/dist-cjs/lib/primitives/geometry/geometry.bench.js +0 -133
  189. package/dist-cjs/lib/primitives/geometry/geometry.bench.js.map +0 -7
  190. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs +0 -132
  191. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs.map +0 -7
  192. package/src/lib/primitives/geometry/geometry.bench.ts +0 -179
@@ -119,6 +119,10 @@ test('parseCss', () => {
119
119
  },
120
120
  ],
121
121
  "imports": [
122
+ {
123
+ "extras": "",
124
+ "url": "https://example.com/font.css",
125
+ },
122
126
  {
123
127
  "extras": " screen and (min-width: 900px)",
124
128
  "url": "thing.css",
@@ -2,7 +2,7 @@ import { safeParseUrl } from '@tldraw/utils'
2
2
 
3
3
  export interface ParsedFontFace {
4
4
  fontFace: string
5
- urls: { original: string; resolved: string | null; embedded?: Promise<string | null> }[]
5
+ urls: { original: string; resolved: string | null }[]
6
6
  fontFamilies: Set<string>
7
7
  }
8
8
 
@@ -18,7 +18,7 @@ export interface ParsedFontFace {
18
18
  // Parse @import declarations:
19
19
  // https://developer.mozilla.org/en-US/docs/Web/CSS/@import#formal_syntax
20
20
  const importsRegex =
21
- /@import\s+(?:"([^"]+)"|'([^']+)'|url\s*\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\))([^;]+);/gi
21
+ /@import\s+(?:"([^"]+)"|'([^']+)'|url\s*\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\))([^;]*);/gi
22
22
 
23
23
  // Locate @font-face declarations:
24
24
  const fontFaceRegex = /@font-face\s*{([^}]+)}/gi
@@ -5,6 +5,7 @@ import type { TLErrorFallbackComponent } from '../components/default-components/
5
5
  import type { TLGridProps } from '../components/default-components/DefaultGrid'
6
6
  import type { TLShapeErrorFallbackComponent } from '../components/default-components/DefaultShapeErrorFallback'
7
7
  import type { TLShapeWrapperProps } from '../components/default-components/DefaultShapeWrapper'
8
+ import type { TLSpinnerProps } from '../components/default-components/DefaultSpinner'
8
9
 
9
10
  /** @public */
10
11
  export interface TLEditorComponents {
@@ -16,7 +17,7 @@ export interface TLEditorComponents {
16
17
  LoadingScreen?: ComponentType | null
17
18
  OnTheCanvas?: ComponentType | null
18
19
  ShapeWrapper?: ComponentType<TLShapeWrapperProps & RefAttributes<HTMLDivElement>> | null
19
- Spinner?: ComponentType<React.SVGProps<SVGSVGElement>> | null
20
+ Spinner?: ComponentType<TLSpinnerProps> | null
20
21
  SvgDefs?: ComponentType | null
21
22
 
22
23
  // These will always have defaults
@@ -19,6 +19,9 @@ export function useCanvasEvents() {
19
19
  const events = useMemo(
20
20
  function canvasEvents() {
21
21
  let isSecondaryClickPointerDown = false
22
+ // The button each accepted press started with, by pointer id. pointercancel
23
+ // reports button -1, so the synthetic pointer_up needs the original.
24
+ const pointerDownButtons = new Map<number, number>()
22
25
 
23
26
  function onPointerDown(e: React.PointerEvent) {
24
27
  if (editor.wasEventAlreadyHandled(e)) return
@@ -45,6 +48,15 @@ export function useCanvasEvents() {
45
48
 
46
49
  setPointerCapture(e.currentTarget, e)
47
50
 
51
+ // Only remember presses the editor will act on. A rejected palm in pen mode
52
+ // must not overwrite the button of the pen press it lands on top of, or the
53
+ // pen's pointercancel would release the wrong button and skip the
54
+ // stylus-eraser tool restore.
55
+ const isPenMode = editor.getInstanceState().isPenMode
56
+ if (!editor.inputs.getIsPinching() && !(isPenMode && e.pointerType !== 'pen')) {
57
+ pointerDownButtons.set(e.pointerId, button)
58
+ }
59
+
48
60
  editor.dispatch({
49
61
  type: 'pointer',
50
62
  target: 'canvas',
@@ -56,6 +68,7 @@ export function useCanvasEvents() {
56
68
 
57
69
  function onPointerUp(e: React.PointerEvent) {
58
70
  if (editor.wasEventAlreadyHandled(e)) return
71
+ pointerDownButtons.delete(e.pointerId)
59
72
  const button = isSecondaryClickPointerDown ? 2 : getPointerEventButton(e)
60
73
  if (button !== 0 && button !== 1 && button !== 2 && button !== 5) return
61
74
 
@@ -91,6 +104,34 @@ export function useCanvasEvents() {
91
104
  isSecondaryClickPointerDown = false
92
105
  }
93
106
 
107
+ function onPointerCancel(e: React.PointerEvent) {
108
+ if (editor.wasEventAlreadyHandled(e)) return
109
+ const pointerDownButton = pointerDownButtons.get(e.pointerId)
110
+ pointerDownButtons.delete(e.pointerId)
111
+ // In pen mode a cancelled touch is usually a rejected palm, and the pen's own
112
+ // interaction must survive it.
113
+ if (editor.getInstanceState().isPenMode && e.pointerType !== 'pen') return
114
+
115
+ releasePointerCapture(e.currentTarget, e)
116
+
117
+ // Nothing to end if the press itself was filtered out (pen mode, unsupported
118
+ // button), or if this pointer never started an accepted press.
119
+ if (!editor.inputs.getIsPointing() || pointerDownButton === undefined) return
120
+
121
+ // The browser has taken the pointer (edge swipe, incoming call) and will never send
122
+ // pointerup, so end the gesture here or the tool stays stuck in its pointing or
123
+ // dragging state and the next touch continues it.
124
+ editor.interrupt()
125
+ editor.dispatch({
126
+ type: 'pointer',
127
+ target: 'canvas',
128
+ name: 'pointer_up',
129
+ ...getPointerInfo(editor, e),
130
+ button: pointerDownButton,
131
+ })
132
+ isSecondaryClickPointerDown = false
133
+ }
134
+
94
135
  function onPointerEnter(e: React.PointerEvent) {
95
136
  if (editor.wasEventAlreadyHandled(e)) return
96
137
  if (editor.getInstanceState().isPenMode && e.pointerType !== 'pen') return
@@ -201,6 +242,7 @@ export function useCanvasEvents() {
201
242
  return {
202
243
  onPointerDown,
203
244
  onPointerUp,
245
+ onPointerCancel,
204
246
  onPointerEnter,
205
247
  onPointerLeave,
206
248
  onDragOver,
@@ -1,4 +1,4 @@
1
- import { areArraysShallowEqual, areObjectsShallowEqual } from '@tldraw/utils'
1
+ import { areArraysShallowEqual, areObjectsShallowEqual, isEqual } from '@tldraw/utils'
2
2
  import { useRef } from 'react'
3
3
 
4
4
  function useIdentity<T>(value: T, isEqual: (a: T, b: T) => boolean): T {
@@ -49,3 +49,14 @@ const areNullableObjectsShallowEqual = (
49
49
  export function useShallowObjectIdentity<T extends object | null | undefined>(obj: T): T {
50
50
  return useIdentity(obj, areNullableObjectsShallowEqual)
51
51
  }
52
+
53
+ /**
54
+ * For small option objects that may nest (e.g. camera constraints), where a shallow comparison
55
+ * would still see a fresh identity on every render. Functions and class instances inside still
56
+ * compare by reference, so an inline callback or a fresh `Extension.configure()` counts as a change.
57
+ *
58
+ * @internal
59
+ */
60
+ export function useDeepObjectIdentity<T extends object | null | undefined>(obj: T): T {
61
+ return useIdentity(obj, isEqual)
62
+ }
@@ -1,4 +1,5 @@
1
1
  import { atom, transact } from '@tldraw/state'
2
+ import { noop } from '@tldraw/utils'
2
3
  import { publishDates, version } from '../../version'
3
4
  import { getDefaultCdnBaseUrl } from '../utils/assets'
4
5
  import { importPublicKey, str2ab } from '../utils/licensing'
@@ -127,6 +128,7 @@ export class LicenseManager {
127
128
  ...NO_FEATURES,
128
129
  })
129
130
  public verbose = true
131
+ private isDisposed = false
130
132
 
131
133
  constructor(licenseKey: string | undefined, testPublicKey?: string) {
132
134
  this.isTest = process.env.NODE_ENV === 'test'
@@ -144,6 +146,9 @@ export class LicenseManager {
144
146
 
145
147
  this.getLicenseFromKey(licenseKey)
146
148
  .then((result) => {
149
+ // replaced by a manager for a newer key: this one's messages, tracking ping and state
150
+ // would describe a key the app no longer uses
151
+ if (this.isDisposed) return
147
152
  const licenseState = getLicenseState(
148
153
  result,
149
154
  (messages: string[]) => this.outputMessages(messages),
@@ -160,11 +165,17 @@ export class LicenseManager {
160
165
  })
161
166
  })
162
167
  .catch((error) => {
168
+ if (this.isDisposed) return
163
169
  console.error('License validation failed:', error)
164
170
  this.state.set('unlicensed')
165
171
  })
166
172
  }
167
173
 
174
+ /** Ignore the pending validation: nothing is logged, tracked or set once the manager is replaced. */
175
+ dispose() {
176
+ this.isDisposed = true
177
+ }
178
+
168
179
  /**
169
180
  * Returns whether a given licensable feature is enabled. Reactive: reading this inside a signal
170
181
  * recomputes when license validation resolves.
@@ -247,8 +258,9 @@ export class LicenseManager {
247
258
  url.searchParams.set('environment', process.env.NODE_ENV)
248
259
  }
249
260
 
261
+ // best-effort: a blocked or offline request must not surface as an unhandled rejection
250
262
  // eslint-disable-next-line no-restricted-globals
251
- fetch(url.toString())
263
+ fetch(url.toString()).catch(noop)
252
264
  }
253
265
 
254
266
  private async extractLicenseKey(licenseKey: string): Promise<LicenseInfo> {
@@ -0,0 +1,76 @@
1
+ import { act, render } from '@testing-library/react'
2
+ import { vi } from 'vitest'
3
+ import { LicenseFromKeyResult, LicenseManager } from './LicenseManager'
4
+ import { LICENSE_TIMEOUT, LicenseProvider, useLicenseContext } from './LicenseProvider'
5
+
6
+ const NO_KEY: LicenseFromKeyResult = { isLicenseParseable: false, reason: 'no-key-provided' }
7
+
8
+ describe('LicenseProvider', () => {
9
+ const managers: LicenseManager[] = []
10
+ function Probe() {
11
+ managers.push(useLicenseContext())
12
+ return null
13
+ }
14
+ const renderWithKey = (licenseKey: string | undefined) => (
15
+ <LicenseProvider licenseKey={licenseKey}>
16
+ <Probe />
17
+ </LicenseProvider>
18
+ )
19
+
20
+ beforeEach(() => {
21
+ managers.length = 0
22
+ })
23
+ afterEach(() => {
24
+ vi.restoreAllMocks()
25
+ vi.useRealTimers()
26
+ })
27
+
28
+ it('creates a new license manager when the license key changes', async () => {
29
+ const rendered = await act(async () => render(renderWithKey(undefined)))
30
+ const initial = managers.at(-1)!
31
+
32
+ await act(async () => rendered.rerender(renderWithKey('not-a-real-key')))
33
+ const changed = managers.at(-1)!
34
+ expect(changed).not.toBe(initial)
35
+
36
+ // same key again: the manager is stable
37
+ await act(async () => rendered.rerender(renderWithKey('not-a-real-key')))
38
+ expect(managers.at(-1)).toBe(changed)
39
+ })
40
+
41
+ it('shows the editor again when a new key arrives after the gate closed', async () => {
42
+ // validation never settles so the test drives the state by hand
43
+ vi.spyOn(LicenseManager.prototype, 'getLicenseFromKey').mockReturnValue(new Promise(() => {}))
44
+ vi.useFakeTimers()
45
+
46
+ const rendered = await act(async () => render(renderWithKey('expired-key')))
47
+ const expired = managers.at(-1)!
48
+ act(() => expired.state.set('expired'))
49
+ act(() => vi.advanceTimersByTime(LICENSE_TIMEOUT))
50
+ expect(rendered.queryByTestId('tl-license-expired')).not.toBeNull()
51
+
52
+ await act(async () => rendered.rerender(renderWithKey('fresh-key')))
53
+ expect(rendered.queryByTestId('tl-license-expired')).toBeNull()
54
+ expect(managers.at(-1)).not.toBe(expired)
55
+ })
56
+
57
+ it('ignores the validation result of a manager replaced by a new key', async () => {
58
+ const resolvers: ((result: LicenseFromKeyResult) => void)[] = []
59
+ vi.spyOn(LicenseManager.prototype, 'getLicenseFromKey').mockImplementation(
60
+ () => new Promise((resolve) => resolvers.push(resolve))
61
+ )
62
+ const track = vi.spyOn(LicenseManager.prototype as any, 'maybeTrack')
63
+
64
+ const rendered = await act(async () => render(renderWithKey(undefined)))
65
+ const orphan = managers.at(-1)!
66
+ await act(async () => rendered.rerender(renderWithKey('real-key')))
67
+ expect(managers.at(-1)).not.toBe(orphan)
68
+
69
+ await act(async () => resolvers[0](NO_KEY))
70
+ expect(orphan.state.get()).toBe('pending')
71
+ expect(track).not.toHaveBeenCalled()
72
+
73
+ await act(async () => resolvers[1](NO_KEY))
74
+ expect(managers.at(-1)!.state.get()).toBe('unlicensed')
75
+ })
76
+ })
@@ -1,5 +1,5 @@
1
1
  import { useValue } from '@tldraw/state-react'
2
- import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
2
+ import { createContext, ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react'
3
3
  import { useMaybeEditor } from '../hooks/useEditor'
4
4
  import { LicenseManager } from './LicenseManager'
5
5
 
@@ -46,21 +46,35 @@ export function LicenseProvider({
46
46
  licenseKey?: string
47
47
  children: ReactNode
48
48
  }) {
49
- const [licenseManager] = useState(() => new LicenseManager(licenseKey))
49
+ // Keyed on the license key: the editor is recreated when the key changes, and must not be
50
+ // handed a manager that validated the old key.
51
+ const licenseManager = useMemo(() => new LicenseManager(licenseKey), [licenseKey])
50
52
  const licenseState = useValue(licenseManager.state)
51
- const [showEditor, setShowEditor] = useState(true)
53
+ // The manager whose LICENSE_TIMEOUT elapsed; compared by identity so a new key un-gates the editor.
54
+ const [gatedManager, setGatedManager] = useState<LicenseManager | null>(null)
55
+ const showEditor = gatedManager !== licenseManager
56
+
57
+ // Dispose only the replaced manager, never on cleanup: strict mode re-runs effects with the
58
+ // same manager, and disposing it there would silence the live one.
59
+ const previousManager = useRef<LicenseManager | null>(null)
60
+ useEffect(() => {
61
+ if (previousManager.current && previousManager.current !== licenseManager) {
62
+ previousManager.current.dispose()
63
+ }
64
+ previousManager.current = licenseManager
65
+ }, [licenseManager])
52
66
 
53
67
  // When license expires or no license in production, show for 5 seconds then hide
54
68
  useEffect(() => {
55
69
  if (shouldHideEditorAfterDelay(licenseState) && showEditor) {
56
70
  // eslint-disable-next-line no-restricted-globals
57
71
  const timer = setTimeout(() => {
58
- setShowEditor(false)
72
+ setGatedManager(licenseManager)
59
73
  }, LICENSE_TIMEOUT)
60
74
 
61
75
  return () => clearTimeout(timer)
62
76
  }
63
- }, [licenseState, showEditor])
77
+ }, [licenseManager, licenseState, showEditor])
64
78
 
65
79
  // If license is expired or no license in production and 5 seconds have passed, don't render anything (blank screen)
66
80
  if (shouldHideEditorAfterDelay(licenseState) && !showEditor) {
@@ -0,0 +1,81 @@
1
+ import { act, render } from '@testing-library/react'
2
+ import { createTLStore } from '../config/createTLStore'
3
+ import { Editor } from '../editor/Editor'
4
+ import { TldrawEditor } from '../TldrawEditor'
5
+
6
+ // The `tldraw_preserve_focus` search param switches the editor into preserve-focus mode, where
7
+ // a pointerdown inside the container focuses the editor and a pointerdown elsewhere blurs it.
8
+ describe('preserve-focus mode', () => {
9
+ const mounted: Element[] = []
10
+
11
+ beforeEach(() => {
12
+ window.history.replaceState(null, '', '?tldraw_preserve_focus')
13
+ })
14
+
15
+ afterEach(() => {
16
+ window.history.replaceState(null, '', '/')
17
+ for (const el of mounted.splice(0)) el.remove()
18
+ })
19
+
20
+ function pointerDown(target: EventTarget) {
21
+ act(() => {
22
+ target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }))
23
+ })
24
+ }
25
+
26
+ async function mount(root: Element | ShadowRoot) {
27
+ const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
28
+ const container = document.createElement('div')
29
+ root.appendChild(container)
30
+ mounted.push(container)
31
+ let editor: Editor | undefined
32
+ let unmount = () => {}
33
+ await act(async () => {
34
+ unmount = render(
35
+ <TldrawEditor
36
+ store={store}
37
+ onMount={(e) => {
38
+ editor = e
39
+ }}
40
+ />,
41
+ { container }
42
+ ).unmount
43
+ })
44
+ return { editor: editor!, unmount }
45
+ }
46
+
47
+ it('focuses on a canvas pointerdown and blurs on a pointerdown outside', async () => {
48
+ const { editor } = await mount(document.body)
49
+ expect(editor.getIsFocused()).toBe(false)
50
+
51
+ pointerDown(editor.getContainer().querySelector('.tl-canvas')!)
52
+ expect(editor.getIsFocused()).toBe(true)
53
+
54
+ pointerDown(document.body)
55
+ expect(editor.getIsFocused()).toBe(false)
56
+ })
57
+
58
+ it('keeps focus when the editor is inside an open shadow root', async () => {
59
+ // The body listener sees the event retargeted to the shadow host, so a check on
60
+ // `e.target` alone would blur the editor right after the container focused it.
61
+ const host = document.createElement('div')
62
+ document.body.appendChild(host)
63
+ mounted.push(host)
64
+ const { editor } = await mount(host.attachShadow({ mode: 'open' }))
65
+
66
+ pointerDown(editor.getContainer().querySelector('.tl-canvas')!)
67
+ expect(editor.getIsFocused()).toBe(true)
68
+
69
+ pointerDown(document.body)
70
+ expect(editor.getIsFocused()).toBe(false)
71
+ })
72
+
73
+ it('removes the body listener on unmount', async () => {
74
+ const { editor, unmount } = await mount(document.body)
75
+ const blur = vi.spyOn(editor, 'blur')
76
+
77
+ act(() => unmount())
78
+ pointerDown(document.body)
79
+ expect(blur).not.toHaveBeenCalled()
80
+ })
81
+ })
@@ -43,12 +43,6 @@ export const debugFlags = {
43
43
  throwToBlob: createDebugValue('throwToBlob', {
44
44
  defaults: { all: false },
45
45
  }),
46
- reconnectOnPing: createDebugValue('reconnectOnPing', {
47
- defaults: { all: false },
48
- }),
49
- debugCursors: createDebugValue('debugCursors', {
50
- defaults: { all: false },
51
- }),
52
46
  forceSrgb: createDebugValue('forceSrgbColors', { defaults: { all: false } }),
53
47
  debugGeometry: createDebugValue('debugGeometry', { defaults: { all: false } }),
54
48
  hideShapes: createDebugValue('hideShapes', { defaults: { all: false } }),
@@ -81,6 +81,21 @@ describe('parseDeepLinkString', () => {
81
81
  expect(() => parseDeepLinkString('v')).toThrow('Invalid deep link string')
82
82
  })
83
83
 
84
+ it('throws on a viewport link with no area', () => {
85
+ expect(() => parseDeepLinkString('v0.0.0.0')).toThrow('Invalid deep link string')
86
+ expect(() => parseDeepLinkString('v100.100.0.200')).toThrow('Invalid deep link string')
87
+ expect(() => parseDeepLinkString('v100.100.200.0')).toThrow('Invalid deep link string')
88
+ expect(() => parseDeepLinkString('v100.100.-200.-100')).toThrow('Invalid deep link string')
89
+ })
90
+
91
+ it('accepts a viewport link positioned at the origin', () => {
92
+ expect(parseDeepLinkString('v0.0.200.100')).toEqual({
93
+ type: 'viewport',
94
+ bounds: new Box(0, 0, 200, 100),
95
+ pageId: undefined,
96
+ })
97
+ })
98
+
84
99
  it('throws on an unknown link type', () => {
85
100
  expect(() => parseDeepLinkString('xabc')).toThrow('Invalid deep link string')
86
101
  })
@@ -74,9 +74,14 @@ export function parseDeepLinkString(deepLinkString: string): TLDeepLink {
74
74
  case 'v': {
75
75
  const [x, y, w, h, pageId] = deepLinkString.slice(1).split('.')
76
76
  const bounds = new Box(Number(x), Number(y), Number(w), Number(h))
77
- // Number('abc') is NaN rather than an error, so without this check a malformed
78
- // viewport link would bypass the caller's fallback and park the camera at the origin
79
- if (![bounds.x, bounds.y, bounds.w, bounds.h].every(Number.isFinite)) {
77
+ // Number('abc') is NaN rather than an error, and a box with no area parses cleanly but
78
+ // can only be zoomed to by clamping to max zoom at its corner. Both bypass the caller's
79
+ // fallback and park the camera at the origin, so both count as an invalid link here.
80
+ const isUsableViewport =
81
+ [bounds.x, bounds.y, bounds.w, bounds.h].every(Number.isFinite) &&
82
+ bounds.w > 0 &&
83
+ bounds.h > 0
84
+ if (!isUsableViewport) {
80
85
  throw Error('Invalid deep link string')
81
86
  }
82
87
  return {
@@ -1,5 +1,5 @@
1
1
  import { createTLSchema } from '@tldraw/tlschema'
2
- import { openDB } from 'idb'
2
+ import { deleteDB, openDB } from 'idb'
3
3
  import { vi } from 'vitest'
4
4
  import { hardReset } from './hardReset'
5
5
  import { getAllIndexDbNames, LocalIndexedDb } from './LocalIndexedDb'
@@ -10,6 +10,7 @@ describe('LocalIndexedDb', () => {
10
10
  vi.useRealTimers()
11
11
  })
12
12
  afterEach(async () => {
13
+ vi.restoreAllMocks()
13
14
  await hardReset({ shouldReload: false })
14
15
  })
15
16
  describe('#storeSnapshot', () => {
@@ -106,6 +107,34 @@ describe('LocalIndexedDb', () => {
106
107
  })
107
108
  })
108
109
 
110
+ it('commits a write that is still in flight when the db is closed', async () => {
111
+ const db = new LocalIndexedDb('test-0')
112
+ const write = db.storeSnapshot({ schema, snapshot: { 'shape:1': { id: 'shape:1' } } })
113
+ await db.close()
114
+ await write
115
+
116
+ const reopened = new LocalIndexedDb('test-0')
117
+ expect((await reopened.load())?.records).toEqual([{ id: 'shape:1' }])
118
+ await reopened.close()
119
+ })
120
+
121
+ it('rejects a transaction that aborts after its requests succeeded while closing', async () => {
122
+ const db = new LocalIndexedDb('test-0')
123
+ let closing: Promise<void> | undefined
124
+ // A commit can fail after every request succeeded, so the request promises cannot report it.
125
+ // @ts-expect-error Exercise the transaction boundary directly to inject a commit failure.
126
+ const write = db.tx('readwrite', ['records'], async (tx) => {
127
+ await tx.objectStore('records').put({ id: 'shape:1' }, 'shape:1')
128
+ closing = db.close()
129
+ tx.abort()
130
+ })
131
+ try {
132
+ await expect(write).rejects.toMatchObject({ name: 'AbortError' })
133
+ } finally {
134
+ await closing
135
+ }
136
+ })
137
+
109
138
  describe('#storeChanges', () => {
110
139
  it('allows merging changes into an existing store', async () => {
111
140
  const db = new LocalIndexedDb('test-0')
@@ -174,6 +203,72 @@ describe('LocalIndexedDb', () => {
174
203
  })
175
204
  })
176
205
 
206
+ describe('#pruneSessions', () => {
207
+ it('keeps the 10 most recent session rows and the current session', async () => {
208
+ const db = new LocalIndexedDb('test-0')
209
+ let now = 1000
210
+ vi.spyOn(Date, 'now').mockImplementation(() => now++)
211
+
212
+ // the current session is the oldest row of 14
213
+ const sessionIds = ['current', ...Array.from({ length: 13 }, (_, i) => `other-${i}`)]
214
+ for (const sessionId of sessionIds) {
215
+ await db.storeChanges({
216
+ schema,
217
+ changes: { added: {}, updated: {}, removed: {} },
218
+ sessionId,
219
+ sessionStateSnapshot: { sessionId } as any,
220
+ })
221
+ }
222
+
223
+ await db.pruneSessions({ keepSessionId: 'current' })
224
+
225
+ const remaining = await Promise.all(
226
+ sessionIds.map(async (sessionId) => [
227
+ sessionId,
228
+ ((await db.load({ sessionId })).sessionStateSnapshot as any)?.sessionId === sessionId,
229
+ ])
230
+ )
231
+ expect(Object.fromEntries(remaining)).toEqual({
232
+ current: true,
233
+ 'other-0': false,
234
+ 'other-1': false,
235
+ 'other-2': false,
236
+ 'other-3': true,
237
+ 'other-4': true,
238
+ 'other-5': true,
239
+ 'other-6': true,
240
+ 'other-7': true,
241
+ 'other-8': true,
242
+ 'other-9': true,
243
+ 'other-10': true,
244
+ 'other-11': true,
245
+ 'other-12': true,
246
+ })
247
+
248
+ await db.close()
249
+ })
250
+
251
+ it('is a no-op when there are at most 10 rows', async () => {
252
+ const db = new LocalIndexedDb('test-0')
253
+ for (let i = 0; i < 10; i++) {
254
+ await db.storeChanges({
255
+ schema,
256
+ changes: { added: {}, updated: {}, removed: {} },
257
+ sessionId: `session-${i}`,
258
+ sessionStateSnapshot: { i } as any,
259
+ })
260
+ }
261
+
262
+ await db.pruneSessions()
263
+
264
+ for (let i = 0; i < 10; i++) {
265
+ expect((await db.load({ sessionId: `session-${i}` })).sessionStateSnapshot).toEqual({ i })
266
+ }
267
+
268
+ await db.close()
269
+ })
270
+ })
271
+
177
272
  it('migrates legacy asset storage into the new format', async () => {
178
273
  const legacyAssetsDb = await openDB('TLDRAW_ASSET_STORE_v1test-0', 1, {
179
274
  upgrade(database) {
@@ -239,4 +334,29 @@ describe('LocalIndexedDb', () => {
239
334
  (await window.indexedDB.databases()).find((db) => db.name === 'TLDRAW_ASSET_STORE_v1test-0')
240
335
  ).toBeUndefined()
241
336
  })
337
+
338
+ it('closes its connection so another tab can delete the database', async () => {
339
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
340
+ const db = new LocalIndexedDb('test-0')
341
+ await db.storeSnapshot({ schema, snapshot: {} })
342
+
343
+ // Simulates a hard reset from another tab. Without the blocking handler this never
344
+ // resolves because our open connection blocks the delete.
345
+ let timeout: ReturnType<typeof setTimeout>
346
+ const result = await Promise.race([
347
+ deleteDB('TLDRAW_DOCUMENT_v2test-0').then(() => 'deleted'),
348
+ new Promise<string>((resolve) => {
349
+ timeout = setTimeout(() => resolve('blocked'), 1000)
350
+ }),
351
+ ])
352
+ clearTimeout(timeout!)
353
+ expect(result).toBe('deleted')
354
+ expect(warn).toHaveBeenCalledWith(
355
+ 'Closing TLDRAW_DOCUMENT_v2test-0 so another tab can delete or upgrade it'
356
+ )
357
+ warn.mockRestore()
358
+
359
+ // the next write on the closed connection surfaces as an error rather than hanging
360
+ await expect(db.storeSnapshot({ schema, snapshot: {} })).rejects.toThrow()
361
+ })
242
362
  })