@tldraw/editor 5.4.0-next.ef99ab47e5ce → 5.4.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 (249) hide show
  1. package/dist-cjs/index.d.ts +114 -47
  2. package/dist-cjs/index.js +4 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +7 -2
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/MenuClickCapture.js +6 -8
  7. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  8. package/dist-cjs/lib/components/default-components/CanvasOverlays.js +1 -3
  9. package/dist-cjs/lib/components/default-components/CanvasOverlays.js.map +2 -2
  10. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +1 -1
  11. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +1 -1
  12. package/dist-cjs/lib/config/TLUserPreferences.js +6 -2
  13. package/dist-cjs/lib/config/TLUserPreferences.js.map +2 -2
  14. package/dist-cjs/lib/config/createTLStore.js +1 -1
  15. package/dist-cjs/lib/config/createTLStore.js.map +2 -2
  16. package/dist-cjs/lib/editor/Editor.js +181 -294
  17. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  18. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +7 -3
  19. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
  20. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +3 -3
  21. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
  22. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +8 -8
  23. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  24. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +2 -1
  25. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  26. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +79 -6
  27. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
  28. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +7 -0
  29. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  30. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +0 -2
  31. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
  32. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +1 -2
  33. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  34. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +6 -3
  35. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
  36. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +12 -1
  37. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  38. package/dist-cjs/lib/editor/overlays/OverlayManager.js +3 -7
  39. package/dist-cjs/lib/editor/overlays/OverlayManager.js.map +2 -2
  40. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js +0 -1
  41. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js.map +2 -2
  42. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js +3 -1
  43. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js.map +2 -2
  44. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js +1 -0
  45. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js.map +2 -2
  46. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +1 -1
  47. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
  48. package/dist-cjs/lib/editor/tools/StateNode.js +3 -2
  49. package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
  50. package/dist-cjs/lib/globals/editors.js +57 -0
  51. package/dist-cjs/lib/globals/editors.js.map +7 -0
  52. package/dist-cjs/lib/globals/menus.js +9 -4
  53. package/dist-cjs/lib/globals/menus.js.map +2 -2
  54. package/dist-cjs/lib/hooks/useCanvasEvents.js +2 -0
  55. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  56. package/dist-cjs/lib/hooks/useDocumentEvents.js +39 -9
  57. package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
  58. package/dist-cjs/lib/hooks/useLocalStore.js +10 -6
  59. package/dist-cjs/lib/hooks/useLocalStore.js.map +2 -2
  60. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +1 -0
  61. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
  62. package/dist-cjs/lib/hooks/usePeerIds.js +2 -1
  63. package/dist-cjs/lib/hooks/usePeerIds.js.map +2 -2
  64. package/dist-cjs/lib/license/LicenseManager.js +9 -2
  65. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  66. package/dist-cjs/lib/license/LicenseProvider.js +16 -3
  67. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  68. package/dist-cjs/lib/license/useLicenseManagerState.js +5 -4
  69. package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
  70. package/dist-cjs/lib/primitives/Box.js +2 -1
  71. package/dist-cjs/lib/primitives/Box.js.map +2 -2
  72. package/dist-cjs/lib/primitives/Vec.js +4 -3
  73. package/dist-cjs/lib/primitives/Vec.js.map +2 -2
  74. package/dist-cjs/lib/primitives/geometry/Group2d.js +63 -21
  75. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  76. package/dist-cjs/lib/primitives/utils.js +2 -1
  77. package/dist-cjs/lib/primitives/utils.js.map +2 -2
  78. package/dist-cjs/lib/utils/deepLinks.js +5 -1
  79. package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
  80. package/dist-cjs/lib/utils/normalizeWheel.js +2 -0
  81. package/dist-cjs/lib/utils/normalizeWheel.js.map +2 -2
  82. package/dist-cjs/lib/utils/rotation.js +1 -9
  83. package/dist-cjs/lib/utils/rotation.js.map +2 -2
  84. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +17 -0
  85. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  86. package/dist-cjs/lib/utils/sync/alerts.js +1 -1
  87. package/dist-cjs/lib/utils/sync/alerts.js.map +1 -1
  88. package/dist-cjs/version.js +3 -3
  89. package/dist-cjs/version.js.map +1 -1
  90. package/dist-esm/index.d.mts +114 -47
  91. package/dist-esm/index.mjs +9 -2
  92. package/dist-esm/index.mjs.map +2 -2
  93. package/dist-esm/lib/TldrawEditor.mjs +9 -4
  94. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  95. package/dist-esm/lib/components/MenuClickCapture.mjs +6 -8
  96. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  97. package/dist-esm/lib/components/default-components/CanvasOverlays.mjs +1 -3
  98. package/dist-esm/lib/components/default-components/CanvasOverlays.mjs.map +2 -2
  99. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +1 -1
  100. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +1 -1
  101. package/dist-esm/lib/config/TLUserPreferences.mjs +6 -2
  102. package/dist-esm/lib/config/TLUserPreferences.mjs.map +2 -2
  103. package/dist-esm/lib/config/createTLStore.mjs +1 -1
  104. package/dist-esm/lib/config/createTLStore.mjs.map +2 -2
  105. package/dist-esm/lib/editor/Editor.mjs +189 -295
  106. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  107. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +7 -3
  108. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
  109. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +3 -3
  110. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
  111. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +9 -9
  112. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  113. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +2 -1
  114. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  115. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +81 -8
  116. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
  117. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +7 -0
  118. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  119. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +0 -2
  120. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
  121. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +2 -3
  122. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  123. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +6 -3
  124. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
  125. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +12 -1
  126. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  127. package/dist-esm/lib/editor/overlays/OverlayManager.mjs +3 -7
  128. package/dist-esm/lib/editor/overlays/OverlayManager.mjs.map +2 -2
  129. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs +0 -1
  130. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs.map +2 -2
  131. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs +3 -1
  132. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs.map +2 -2
  133. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs +1 -0
  134. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs.map +2 -2
  135. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +1 -1
  136. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
  137. package/dist-esm/lib/editor/tools/StateNode.mjs +3 -2
  138. package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
  139. package/dist-esm/lib/globals/editors.mjs +37 -0
  140. package/dist-esm/lib/globals/editors.mjs.map +7 -0
  141. package/dist-esm/lib/globals/menus.mjs +9 -4
  142. package/dist-esm/lib/globals/menus.mjs.map +2 -2
  143. package/dist-esm/lib/hooks/useCanvasEvents.mjs +2 -0
  144. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  145. package/dist-esm/lib/hooks/useDocumentEvents.mjs +40 -10
  146. package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
  147. package/dist-esm/lib/hooks/useLocalStore.mjs +11 -7
  148. package/dist-esm/lib/hooks/useLocalStore.mjs.map +2 -2
  149. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +1 -0
  150. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
  151. package/dist-esm/lib/hooks/usePeerIds.mjs +2 -1
  152. package/dist-esm/lib/hooks/usePeerIds.mjs.map +2 -2
  153. package/dist-esm/lib/license/LicenseManager.mjs +9 -2
  154. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  155. package/dist-esm/lib/license/LicenseProvider.mjs +16 -3
  156. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  157. package/dist-esm/lib/license/useLicenseManagerState.mjs +5 -4
  158. package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
  159. package/dist-esm/lib/primitives/Box.mjs +2 -1
  160. package/dist-esm/lib/primitives/Box.mjs.map +2 -2
  161. package/dist-esm/lib/primitives/Vec.mjs +4 -3
  162. package/dist-esm/lib/primitives/Vec.mjs.map +2 -2
  163. package/dist-esm/lib/primitives/geometry/Group2d.mjs +63 -21
  164. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  165. package/dist-esm/lib/primitives/utils.mjs +2 -1
  166. package/dist-esm/lib/primitives/utils.mjs.map +2 -2
  167. package/dist-esm/lib/utils/deepLinks.mjs +5 -1
  168. package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
  169. package/dist-esm/lib/utils/normalizeWheel.mjs +2 -0
  170. package/dist-esm/lib/utils/normalizeWheel.mjs.map +2 -2
  171. package/dist-esm/lib/utils/rotation.mjs +1 -9
  172. package/dist-esm/lib/utils/rotation.mjs.map +2 -2
  173. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +17 -0
  174. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  175. package/dist-esm/lib/utils/sync/alerts.mjs +1 -1
  176. package/dist-esm/lib/utils/sync/alerts.mjs.map +1 -1
  177. package/dist-esm/version.mjs +3 -3
  178. package/dist-esm/version.mjs.map +1 -1
  179. package/package.json +13 -7
  180. package/src/index.ts +6 -1
  181. package/src/lib/TldrawEditor.tsx +16 -4
  182. package/src/lib/components/MenuClickCapture.tsx +19 -18
  183. package/src/lib/components/default-components/CanvasOverlays.tsx +1 -3
  184. package/src/lib/components/default-components/DefaultErrorFallback.tsx +1 -1
  185. package/src/lib/config/TLUserPreferences.test.ts +18 -1
  186. package/src/lib/config/TLUserPreferences.ts +8 -4
  187. package/src/lib/config/createTLStore.ts +1 -1
  188. package/src/lib/editor/Editor.ts +266 -383
  189. package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +7 -3
  190. package/src/lib/editor/managers/ClickManager/ClickManager.ts +5 -3
  191. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +9 -9
  192. package/src/lib/editor/managers/FontManager/FontManager.ts +2 -1
  193. package/src/lib/editor/managers/InputsManager/InputsManager.test.ts +199 -3
  194. package/src/lib/editor/managers/InputsManager/InputsManager.ts +128 -9
  195. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +12 -1
  196. package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +0 -2
  197. package/src/lib/editor/managers/TextManager/TextManager.ts +2 -6
  198. package/src/lib/editor/managers/TickManager/TickManager.test.ts +13 -0
  199. package/src/lib/editor/managers/TickManager/TickManager.ts +9 -3
  200. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +64 -3
  201. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +18 -1
  202. package/src/lib/editor/overlays/OverlayManager.ts +3 -7
  203. package/src/lib/editor/overlays/strokeShapeIndicators.ts +0 -1
  204. package/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx +5 -1
  205. package/src/lib/editor/shapes/shared/getPerfectDashProps.ts +3 -0
  206. package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +4 -1
  207. package/src/lib/editor/tools/StateNode.test.ts +64 -0
  208. package/src/lib/editor/tools/StateNode.ts +12 -2
  209. package/src/lib/globals/editors.test.ts +76 -0
  210. package/src/lib/globals/editors.ts +53 -0
  211. package/src/lib/globals/menus.test.ts +33 -0
  212. package/src/lib/globals/menus.ts +11 -4
  213. package/src/lib/hooks/useCanvasEvents.ts +2 -0
  214. package/src/lib/hooks/useDocumentEvents.ts +60 -16
  215. package/src/lib/hooks/useLocalStore.ts +16 -8
  216. package/src/lib/hooks/usePassThroughWheelEvents.ts +3 -0
  217. package/src/lib/hooks/usePeerIds.ts +2 -1
  218. package/src/lib/license/LicenseManager.test.ts +160 -0
  219. package/src/lib/license/LicenseManager.ts +13 -4
  220. package/src/lib/license/LicenseProvider.tsx +24 -3
  221. package/src/lib/license/useLicenseManagerState.ts +8 -6
  222. package/src/lib/primitives/Box.test.ts +9 -1
  223. package/src/lib/primitives/Box.ts +3 -1
  224. package/src/lib/primitives/Vec.test.ts +10 -0
  225. package/src/lib/primitives/Vec.ts +4 -4
  226. package/src/lib/primitives/geometry/Arc2d.test.ts +226 -3
  227. package/src/lib/primitives/geometry/CubicBezier2d.test.ts +173 -3
  228. package/src/lib/primitives/geometry/CubicSpline2d.test.ts +160 -3
  229. package/src/lib/primitives/geometry/Ellipse2d.test.ts +243 -3
  230. package/src/lib/primitives/geometry/Geometry2d.test.ts +553 -1
  231. package/src/lib/primitives/geometry/Group2d.test.ts +398 -3
  232. package/src/lib/primitives/geometry/Group2d.ts +63 -27
  233. package/src/lib/primitives/geometry/Polyline2d.test.ts +207 -0
  234. package/src/lib/primitives/geometry/Stadium2d.test.ts +191 -3
  235. package/src/lib/primitives/utils.test.ts +10 -1
  236. package/src/lib/primitives/utils.ts +4 -1
  237. package/src/lib/test/useCanvasEvents.test.tsx +74 -0
  238. package/src/lib/test/useDocumentEvents.test.tsx +107 -0
  239. package/src/lib/test/usePassThroughWheelEvents.test.tsx +25 -2
  240. package/src/lib/utils/deepLinks.test.ts +15 -0
  241. package/src/lib/utils/deepLinks.ts +7 -1
  242. package/src/lib/utils/normalizeWheel.test.ts +39 -0
  243. package/src/lib/utils/normalizeWheel.ts +6 -0
  244. package/src/lib/utils/rotation.ts +4 -12
  245. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +56 -0
  246. package/src/lib/utils/sync/TLLocalSyncClient.ts +24 -0
  247. package/src/lib/utils/sync/alerts.ts +1 -1
  248. package/src/version.ts +3 -3
  249. package/src/lib/primitives/geometry/Polyline.test.ts +0 -5
@@ -113,16 +113,16 @@ describe('UserPreferencesManager', () => {
113
113
  const mockAddEventListener = vi.fn()
114
114
  let changeHandler: (e: MediaQueryListEvent) => void
115
115
 
116
- mockMatchMedia.mockReturnValue({
116
+ mockMatchMedia.mockImplementation((query: string) => ({
117
117
  matches: false,
118
118
  addEventListener: (event: string, handler: any) => {
119
- if (event === 'change') {
119
+ if (event === 'change' && query === '(prefers-color-scheme: dark)') {
120
120
  changeHandler = handler
121
121
  }
122
122
  mockAddEventListener(event, handler)
123
123
  },
124
124
  removeEventListener: vi.fn(),
125
- })
125
+ }))
126
126
 
127
127
  userPreferencesManager = new UserPreferencesManager(mockUser, 'system')
128
128
 
@@ -367,6 +367,67 @@ describe('UserPreferencesManager', () => {
367
367
  defaultUserPreferences.animationSpeed
368
368
  )
369
369
  })
370
+
371
+ describe('following the OS reduce-motion setting', () => {
372
+ let reducedMotionChangeHandler: ((e: MediaQueryListEvent) => void) | undefined
373
+ let osPrefersReducedMotion: boolean
374
+
375
+ const mountWithOs = (prefersReducedMotion: boolean) => {
376
+ osPrefersReducedMotion = prefersReducedMotion
377
+ reducedMotionChangeHandler = undefined
378
+ mockMatchMedia.mockImplementation((query: string) => {
379
+ const isReducedMotion = query === '(prefers-reduced-motion: reduce)'
380
+ return {
381
+ matches: isReducedMotion ? osPrefersReducedMotion : false,
382
+ addEventListener: (event: string, handler: any) => {
383
+ if (isReducedMotion && event === 'change') reducedMotionChangeHandler = handler
384
+ },
385
+ removeEventListener: vi.fn(),
386
+ }
387
+ })
388
+ userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
389
+ }
390
+
391
+ it('resolves an unset speed to 0 when the OS prefers reduced motion', () => {
392
+ userPreferencesAtom.set({ ...mockUserPreferences, animationSpeed: null })
393
+ mountWithOs(true)
394
+ expect(userPreferencesManager.getAnimationSpeed()).toBe(0)
395
+ })
396
+
397
+ it('tracks a live OS change while the speed is unset', () => {
398
+ userPreferencesAtom.set({ ...mockUserPreferences, animationSpeed: null })
399
+ mountWithOs(false)
400
+ expect(userPreferencesManager.getAnimationSpeed()).toBe(1)
401
+
402
+ reducedMotionChangeHandler!({ matches: true } as MediaQueryListEvent)
403
+ expect(userPreferencesManager.getAnimationSpeed()).toBe(0)
404
+
405
+ reducedMotionChangeHandler!({ matches: false } as MediaQueryListEvent)
406
+ expect(userPreferencesManager.getAnimationSpeed()).toBe(1)
407
+ })
408
+
409
+ it('ignores the OS once the user has set a speed explicitly', () => {
410
+ userPreferencesAtom.set({ ...mockUserPreferences, animationSpeed: 1 })
411
+ mountWithOs(true)
412
+ expect(userPreferencesManager.getAnimationSpeed()).toBe(1)
413
+
414
+ userPreferencesAtom.set({ ...mockUserPreferences, animationSpeed: 0 })
415
+ reducedMotionChangeHandler!({ matches: false } as MediaQueryListEvent)
416
+ expect(userPreferencesManager.getAnimationSpeed()).toBe(0)
417
+ })
418
+
419
+ it('removes the reduce-motion listener on dispose', () => {
420
+ const removeEventListener = vi.fn()
421
+ mockMatchMedia.mockReturnValue({
422
+ matches: false,
423
+ addEventListener: vi.fn(),
424
+ removeEventListener,
425
+ })
426
+ userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
427
+ userPreferencesManager.dispose()
428
+ expect(removeEventListener).toHaveBeenCalledTimes(2)
429
+ })
430
+ })
370
431
  })
371
432
 
372
433
  describe('getAreKeyboardShortcutsEnabled', () => {
@@ -7,9 +7,11 @@ import { getGlobalWindow } from '../../../utils/dom'
7
7
  /** @public */
8
8
  export class UserPreferencesManager {
9
9
  systemColorScheme = atom<'dark' | 'light'>('systemColorScheme', 'light')
10
+ private readonly systemPrefersReducedMotion = atom('systemPrefersReducedMotion', false)
10
11
  disposables = new Set<() => void>()
11
12
  dispose() {
12
13
  this.disposables.forEach((d) => d())
14
+ this.disposables.clear()
13
15
  }
14
16
  constructor(
15
17
  private readonly user: TLCurrentUser,
@@ -30,6 +32,16 @@ export class UserPreferencesManager {
30
32
  }
31
33
  darkModeMediaQuery?.addEventListener('change', handleChange)
32
34
  this.disposables.add(() => darkModeMediaQuery?.removeEventListener('change', handleChange))
35
+
36
+ const reducedMotionMediaQuery = getGlobalWindow().matchMedia('(prefers-reduced-motion: reduce)')
37
+ this.systemPrefersReducedMotion.set(!!reducedMotionMediaQuery?.matches)
38
+ const handleReducedMotionChange = (e: MediaQueryListEvent) => {
39
+ this.systemPrefersReducedMotion.set(e.matches)
40
+ }
41
+ reducedMotionMediaQuery?.addEventListener('change', handleReducedMotionChange)
42
+ this.disposables.add(() =>
43
+ reducedMotionMediaQuery?.removeEventListener('change', handleReducedMotionChange)
44
+ )
33
45
  }
34
46
 
35
47
  updateUserPreferences(userPreferences: Partial<TLUserPreferences>) {
@@ -80,7 +92,12 @@ export class UserPreferencesManager {
80
92
  }
81
93
 
82
94
  @computed getAnimationSpeed() {
83
- return this.user.userPreferences.get().animationSpeed ?? defaultUserPreferences.animationSpeed
95
+ // Until the user explicitly sets a speed, follow the OS reduce-motion setting live rather
96
+ // than the value `defaultUserPreferences` captured at module load.
97
+ return (
98
+ this.user.userPreferences.get().animationSpeed ??
99
+ (this.systemPrefersReducedMotion.get() ? 0 : 1)
100
+ )
84
101
  }
85
102
 
86
103
  @computed getAreKeyboardShortcutsEnabled() {
@@ -1,4 +1,5 @@
1
1
  import { atom, computed } from '@tldraw/state'
2
+ import { WeakCache } from '@tldraw/utils'
2
3
  import { Geometry2d } from '../../primitives/geometry/Geometry2d'
3
4
  import { VecLike } from '../../primitives/Vec'
4
5
  import type { Editor } from '../Editor'
@@ -119,7 +120,7 @@ export class OverlayManager {
119
120
  // while getActiveOverlayEntries() keeps returning the same overlay
120
121
  // instances; when its reactive deps change, getOverlays() emits fresh
121
122
  // objects and stale entries fall out by GC.
122
- private _geometryCache = new WeakMap<TLOverlay, Geometry2d | null>()
123
+ private _geometryCache = new WeakCache<TLOverlay, Geometry2d | null>()
123
124
 
124
125
  /**
125
126
  * Get hit-test geometry for an overlay, cached by overlay identity. Lets
@@ -130,12 +131,7 @@ export class OverlayManager {
130
131
  * @public
131
132
  */
132
133
  getOverlayGeometry(overlay: TLOverlay): Geometry2d | null {
133
- const cached = this._geometryCache.get(overlay)
134
- if (cached !== undefined) return cached
135
- const util = this.getOverlayUtil(overlay)
136
- const geometry = util.getGeometry(overlay)
137
- this._geometryCache.set(overlay, geometry)
138
- return geometry
134
+ return this._geometryCache.get(overlay, (o) => this.getOverlayUtil(o).getGeometry(o))
139
135
  }
140
136
 
141
137
  /**
@@ -40,7 +40,6 @@ export function strokeShapeIndicators(
40
40
  if (!shape || shape.isLocked) continue
41
41
 
42
42
  const pageTransform = editor.getShapePageTransform(shape)
43
- if (!pageTransform) continue
44
43
 
45
44
  const indicatorPath = indicatorPathCache.get(editor, shape.id)
46
45
  if (!indicatorPath) continue
@@ -93,7 +93,11 @@ export abstract class BaseFrameLikeShapeUtil<
93
93
  const currentChildren = compact(
94
94
  editor.getSortedChildIdsForParent(shape).map((id) => editor.getShape(id))
95
95
  )
96
- if (previousChildren.every((s) => !currentChildren.find((c) => c.index === s.index))) {
96
+ if (
97
+ previousChildren.every(
98
+ (s) => !currentChildren.find((c) => c.index === initialIndices.get(s.id))
99
+ )
100
+ ) {
97
101
  canRestoreOriginalIndices = true
98
102
  }
99
103
  }
@@ -87,6 +87,9 @@ export function getPerfectDashProps(
87
87
 
88
88
  dashCount = Math.floor(totalLength / dashLength / (2 * ratio))
89
89
  dashCount -= dashCount % snap
90
+ // A path shorter than one dash period (or of zero length, where the count is NaN) would
91
+ // otherwise divide by zero below and produce an invalid dasharray; draw a single dash instead
92
+ if (!(dashCount >= 1)) dashCount = 1
90
93
 
91
94
  if (dashCount < 3 && style === 'dashed') {
92
95
  if (totalLength / strokeWidth < 4) {
@@ -75,7 +75,10 @@ export class Pointing extends StateNode {
75
75
  }
76
76
 
77
77
  override onComplete() {
78
- this.complete()
78
+ // `complete` arrives mid-press from undo, menus opening, page changes, and focus loss,
79
+ // none of which is a release; committing here would drop a default-size shape the user
80
+ // never asked for. Match the text and arrow tools and leave nothing behind.
81
+ this.cancel()
79
82
  }
80
83
 
81
84
  override onInterrupt() {
@@ -238,6 +238,70 @@ describe('StateNode.addChild', () => {
238
238
  })
239
239
  })
240
240
 
241
+ describe('StateNode.enter', () => {
242
+ let idleExits = 0
243
+
244
+ class Idle extends StateNode {
245
+ static override id = 'idle'
246
+ override onExit() {
247
+ idleExits++
248
+ }
249
+ }
250
+
251
+ class Pointing extends StateNode {
252
+ static override id = 'pointing'
253
+ }
254
+
255
+ class ToolWithEnterTransition extends StateNode {
256
+ static override id = 'enterTransition'
257
+ static override initial = 'idle'
258
+ static override children() {
259
+ return [Idle, Pointing]
260
+ }
261
+
262
+ override onEnter(info: { startPointing?: boolean }) {
263
+ if (info?.startPointing) this.transition('pointing', info)
264
+ }
265
+ }
266
+
267
+ class OtherTool extends StateNode {
268
+ static override id = 'other'
269
+ }
270
+
271
+ let editor: Editor
272
+
273
+ beforeEach(() => {
274
+ idleExits = 0
275
+ editor = new Editor({
276
+ initialState: 'other',
277
+ shapeUtils: [],
278
+ bindingUtils: [],
279
+ tools: [OtherTool, ToolWithEnterTransition],
280
+ store: createTLStore({ shapeUtils: [], bindingUtils: [] }),
281
+ getContainer: () => document.body,
282
+ })
283
+ })
284
+
285
+ it('enters the initial child when onEnter does not transition', () => {
286
+ editor.setCurrentTool('enterTransition')
287
+ expect(editor.root.getPath()).toBe('root.enterTransition.idle')
288
+ expect(editor.root.children!['enterTransition'].children!['idle'].getIsActive()).toBe(true)
289
+ })
290
+
291
+ it('does not enter the initial child on top of a child that onEnter transitioned to', () => {
292
+ editor.setCurrentTool('enterTransition', { startPointing: true })
293
+ const tool = editor.root.children!['enterTransition']
294
+ expect(editor.root.getPath()).toBe('root.enterTransition.pointing')
295
+ expect(tool.children!['pointing'].getIsActive()).toBe(true)
296
+ expect(tool.children!['idle'].getIsActive()).toBe(false)
297
+ // The stale (never re-entered) idle child must not get a spurious onExit
298
+ expect(idleExits).toBe(0)
299
+
300
+ editor.setCurrentTool('other')
301
+ expect(tool.children!['pointing'].getIsActive()).toBe(false)
302
+ })
303
+ })
304
+
241
305
  describe('current tool id mask', () => {
242
306
  // Tool mask test classes
243
307
  class ToolA extends StateNode {
@@ -152,7 +152,9 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
152
152
  }
153
153
 
154
154
  if (prevChildState?.id !== nextChildState.id) {
155
- prevChildState?.exit(info, id)
155
+ // The previous child is inactive when transitioning from a parent's onEnter (or from a
156
+ // node that was already exited); exiting it again would fire a spurious onExit
157
+ if (prevChildState?.getIsActive()) prevChildState.exit(info, id)
156
158
  currState._current.set(nextChildState)
157
159
  nextChildState.enter(info, prevChildState?.id || 'initial')
158
160
  if (!nextChildState.getIsActive()) break
@@ -188,10 +190,18 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
188
190
  this.editor.performance._notifyInteractionStart(this.id, this.getPath())
189
191
  }
190
192
 
193
+ const currentBeforeEnter = this.getCurrent()
191
194
  this._isActive.set(true)
192
195
  this.onEnter?.(info, from)
193
196
 
194
- if (this.children && this.initial && this.getIsActive()) {
197
+ // If onEnter already transitioned to a child, entering the initial child on top of it would
198
+ // leave that child active but orphaned
199
+ if (
200
+ this.children &&
201
+ this.initial &&
202
+ this.getIsActive() &&
203
+ this.getCurrent() === currentBeforeEnter
204
+ ) {
195
205
  const initial = this.children[this.initial]
196
206
  this._current.set(initial)
197
207
  initial.enter(info, from)
@@ -0,0 +1,76 @@
1
+ import { createTLStore } from '../..'
2
+ import { Editor } from '../editor/Editor'
3
+ import { tleditors } from './editors'
4
+
5
+ const editors: Editor[] = []
6
+
7
+ function createTestEditor() {
8
+ const editor = new Editor({
9
+ shapeUtils: [],
10
+ bindingUtils: [],
11
+ tools: [],
12
+ store: createTLStore({ shapeUtils: [], bindingUtils: [] }),
13
+ getContainer: () => document.body,
14
+ })
15
+ editors.push(editor)
16
+ return editor
17
+ }
18
+
19
+ afterEach(() => {
20
+ for (const editor of editors) {
21
+ editor.dispose()
22
+ }
23
+ editors.length = 0
24
+ expect(tleditors.getMounted()).toEqual([])
25
+ })
26
+
27
+ describe('tleditors', () => {
28
+ it('tracks editors as they mount and unmount', () => {
29
+ const editor = createTestEditor()
30
+ expect(tleditors.getMounted()).toEqual([])
31
+
32
+ editor.emit('mount')
33
+ expect(tleditors.getMounted()).toEqual([editor])
34
+
35
+ editor.emit('unmount')
36
+ expect(tleditors.getMounted()).toEqual([])
37
+ })
38
+
39
+ it('does not add the same editor twice', () => {
40
+ const editor = createTestEditor()
41
+ editor.emit('mount')
42
+ editor.emit('mount')
43
+ expect(tleditors.getMounted()).toEqual([editor])
44
+ })
45
+
46
+ it('removes an editor when it is disposed while mounted', () => {
47
+ const editor = createTestEditor()
48
+ editor.emit('mount')
49
+ expect(tleditors.getMounted()).toEqual([editor])
50
+
51
+ editor.dispose()
52
+ expect(tleditors.getMounted()).toEqual([])
53
+ expect(editor.getIsMounted()).toBe(false)
54
+ })
55
+
56
+ it('removes an editor on dispose even if the unmount listener was removed', () => {
57
+ const editor = createTestEditor()
58
+ editor.emit('mount')
59
+ editor.removeAllListeners('unmount')
60
+
61
+ editor.dispose()
62
+ expect(tleditors.getMounted()).toEqual([])
63
+ })
64
+
65
+ it('tracks multiple mounted editors', () => {
66
+ const editorA = createTestEditor()
67
+ const editorB = createTestEditor()
68
+
69
+ editorA.emit('mount')
70
+ editorB.emit('mount')
71
+ expect(tleditors.getMounted()).toEqual([editorA, editorB])
72
+
73
+ editorA.emit('unmount')
74
+ expect(tleditors.getMounted()).toEqual([editorB])
75
+ })
76
+ })
@@ -0,0 +1,53 @@
1
+ import { Signal, atom } from '@tldraw/state'
2
+ import type { Editor } from '../editor/Editor'
3
+
4
+ const mounted = atom<readonly Editor[]>('mounted editors', [])
5
+
6
+ /**
7
+ * A global registry of currently mounted editor instances. Use it to discover live editors from
8
+ * outside the React tree, for example from sidebar chrome, keyboard shortcuts, or multi-editor
9
+ * layouts.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * tleditors.getMounted() // readonly Editor[]
14
+ *
15
+ * const editors = useValue('mounted editors', () => tleditors.getMounted(), [])
16
+ * ```
17
+ *
18
+ * @public
19
+ */
20
+ export const tleditors = {
21
+ /**
22
+ * A reactive list of currently mounted editor instances.
23
+ *
24
+ * An editor is added when it emits the `mount` event and removed when it emits `unmount`
25
+ * (including when the editor is disposed while still mounted).
26
+ *
27
+ * @public
28
+ */
29
+ mounted: mounted as Signal<readonly Editor[]>,
30
+
31
+ /**
32
+ * Get the currently mounted editor instances.
33
+ *
34
+ * @public
35
+ */
36
+ getMounted(): readonly Editor[] {
37
+ return mounted.get()
38
+ },
39
+ }
40
+
41
+ /** @internal */
42
+ export function registerMountedEditor(editor: Editor) {
43
+ const current = mounted.get()
44
+ if (current.includes(editor)) return
45
+ mounted.set([...current, editor])
46
+ }
47
+
48
+ /** @internal */
49
+ export function unregisterMountedEditor(editor: Editor) {
50
+ const current = mounted.get()
51
+ if (!current.includes(editor)) return
52
+ mounted.set(current.filter((e) => e !== editor))
53
+ }
@@ -0,0 +1,33 @@
1
+ import { tlmenus } from './menus'
2
+
3
+ describe('tlmenus', () => {
4
+ afterEach(() => {
5
+ tlmenus.clearOpenMenus()
6
+ tlmenus._hiddenMenus = []
7
+ })
8
+
9
+ it('reports whether a menu is open with and without a context', () => {
10
+ tlmenus.addOpenMenu('main')
11
+ tlmenus.addOpenMenu('main', 'editor-1')
12
+
13
+ expect(tlmenus.isMenuOpen('main')).toBe(true)
14
+ expect(tlmenus.isMenuOpen('main', 'editor-1')).toBe(true)
15
+ expect(tlmenus.isMenuOpen('main', 'editor-2')).toBe(false)
16
+ expect(tlmenus.isMenuOpen('other')).toBe(false)
17
+ })
18
+
19
+ it('hides and restores the open menus of a context', () => {
20
+ tlmenus.addOpenMenu('main', 'editor-1')
21
+ tlmenus.addOpenMenu('main', 'editor-2')
22
+
23
+ tlmenus.hideOpenMenus('editor-1')
24
+ expect(tlmenus.getOpenMenus()).toEqual(['main-editor-2'])
25
+
26
+ tlmenus.showOpenMenus('editor-1')
27
+ expect(tlmenus.getOpenMenus().sort()).toEqual(['main-editor-1', 'main-editor-2'])
28
+
29
+ // closing the real menu afterwards leaves nothing behind
30
+ tlmenus.deleteOpenMenu('main', 'editor-1')
31
+ expect(tlmenus.hasOpenMenus('editor-1')).toBe(false)
32
+ })
33
+ })
@@ -106,10 +106,12 @@ export const tlmenus = {
106
106
  * @public
107
107
  */
108
108
  hideOpenMenus(contextId?: string) {
109
+ // ids read back from the store already carry their context suffix, so they are deleted
110
+ // (and later re-added) as-is rather than suffixed again
109
111
  this._hiddenMenus = [...this.getOpenMenus(contextId)]
110
112
  if (this._hiddenMenus.length === 0) return
111
113
  for (const menu of this._hiddenMenus) {
112
- this.deleteOpenMenu(menu, contextId)
114
+ this.deleteOpenMenu(menu)
113
115
  }
114
116
  },
115
117
 
@@ -128,10 +130,15 @@ export const tlmenus = {
128
130
  */
129
131
  showOpenMenus(contextId?: string) {
130
132
  if (this._hiddenMenus.length === 0) return
133
+ const stillHidden: string[] = []
131
134
  for (const menu of this._hiddenMenus) {
132
- this.addOpenMenu(menu, contextId)
135
+ if (!contextId || menu.endsWith('-' + contextId)) {
136
+ this.addOpenMenu(menu)
137
+ } else {
138
+ stillHidden.push(menu)
139
+ }
133
140
  }
134
- this._hiddenMenus = []
141
+ this._hiddenMenus = stillHidden
135
142
  },
136
143
 
137
144
  /**
@@ -148,7 +155,7 @@ export const tlmenus = {
148
155
  * @public
149
156
  */
150
157
  isMenuOpen(id: string, contextId?: string): boolean {
151
- return this.getOpenMenus(contextId).includes(`${id}-${contextId}`)
158
+ return this.getOpenMenus(contextId).includes(contextId ? `${id}-${contextId}` : id)
152
159
  },
153
160
 
154
161
  /**
@@ -153,6 +153,7 @@ export function useCanvasEvents() {
153
153
  if (e.dataTransfer?.files?.length) {
154
154
  const files = Array.from(e.dataTransfer.files)
155
155
 
156
+ editor.markHistoryStoppingPoint('drop')
156
157
  await editor.putExternalContent({
157
158
  type: 'files',
158
159
  files,
@@ -163,6 +164,7 @@ export function useCanvasEvents() {
163
164
 
164
165
  const url = e.dataTransfer.getData('url')
165
166
  if (url) {
167
+ editor.markHistoryStoppingPoint('drop')
166
168
  await editor.putExternalContent({
167
169
  type: 'url',
168
170
  url,
@@ -1,5 +1,5 @@
1
1
  import { useValue } from '@tldraw/state-react'
2
- import { useEffect } from 'react'
2
+ import { useEffect, useRef } from 'react'
3
3
  import { Editor } from '../editor/Editor'
4
4
  import { TLKeyboardEventInfo } from '../editor/types/event-types'
5
5
  import { activeElementShouldCaptureKeys, preventDefault } from '../utils/dom'
@@ -14,6 +14,10 @@ export function useDocumentEvents() {
14
14
  const isEditing = useValue('isEditing', () => editor.getEditingShapeId(), [editor])
15
15
  const isAppFocused = useValue('isFocused', () => editor.getIsFocused(), [editor])
16
16
 
17
+ // `inputs.keys` only stores codes, but tools match `onKeyUp` on `info.key`, so the window
18
+ // blur release below needs the key remembered per code.
19
+ const heldKeysRef = useRef(new Map<string, string>())
20
+
17
21
  // Prevent the browser's default drag and drop behavior on our container (UI, etc)
18
22
  useEffect(() => {
19
23
  if (!container) return
@@ -29,8 +33,12 @@ export function useDocumentEvents() {
29
33
  // re-dispatched, which would lead to an infinite loop.
30
34
  if ((e as any).isSpecialRedispatchedEvent) return
31
35
  preventDefault(e)
32
- e.stopPropagation()
33
36
  const cvs = container.querySelector('.tl-canvas')
37
+ // A drop on the canvas itself already reaches the canvas (and any shape's own React
38
+ // drop handlers) by bubbling; stopping it here would hide it from React, which
39
+ // delegates from the root above this container.
40
+ if (cvs?.contains(e.target as Node | null)) return
41
+ e.stopPropagation()
34
42
  if (!cvs) return
35
43
  const newEvent = new DragEvent(e.type, e)
36
44
  ;(newEvent as any).isSpecialRedispatchedEvent = true
@@ -208,6 +216,7 @@ export function useDocumentEvents() {
208
216
  accelKey: isAccelKey(e),
209
217
  }
210
218
 
219
+ heldKeysRef.current.set(e.code, e.key)
211
220
  editor.dispatch(info)
212
221
  }
213
222
 
@@ -235,24 +244,26 @@ export function useDocumentEvents() {
235
244
  accelKey: isAccelKey(e),
236
245
  }
237
246
 
247
+ heldKeysRef.current.delete(e.code)
238
248
  editor.dispatch(info)
239
249
  }
240
250
 
241
251
  function handleTouchStart(e: TouchEvent) {
242
252
  if (container.contains(e.target as Node)) {
243
- // Center point of the touch area
244
- const touchXPosition = e.touches[0].pageX
253
+ // Center point of the touch area, measured from the edge of the window the touch
254
+ // has to reach to trigger the navigation
255
+ const touchXPosition = e.touches[0].clientX
245
256
  // Size of the touch area
246
257
  const touchXRadius = e.touches[0].radiusX || 0
247
258
 
248
- // We set a threshold (10px) on both sizes of the screen,
249
- // if the touch area overlaps with the screen edges
250
- // it's likely to trigger the navigation. We prevent the
251
- // touchstart event in that case.
252
- // todo: make this relative to the actual window, not the editor's screen bounds
259
+ // If the touch area overlaps with the screen edges it's likely to trigger the
260
+ // navigation. We prevent the touchstart event in that case. The gesture belongs to
261
+ // the window, so an editor inset from the window's edges — beside a sidebar, say —
262
+ // is not near an edge the system reacts to.
263
+ const windowWidth = editor.getContainerWindow().innerWidth
253
264
  if (
254
265
  touchXPosition - touchXRadius < 10 ||
255
- touchXPosition + touchXRadius > editor.getViewportScreenBounds().width - 10
266
+ touchXPosition + touchXRadius > windowWidth - 10
256
267
  ) {
257
268
  if ((e.target as HTMLElement)?.tagName === 'BUTTON') {
258
269
  // Force a click before bailing
@@ -276,10 +287,13 @@ export function useDocumentEvents() {
276
287
 
277
288
  container.addEventListener('wheel', handleWheel, { passive: false })
278
289
 
290
+ // Not the shared `preventDefault` reference: the DOM dedupes identical listeners, so with two
291
+ // editors in one document the first cleanup would otherwise remove it for both.
292
+ const handleGesture = (e: Event) => preventDefault(e)
279
293
  const ownerDoc = container.ownerDocument
280
- ownerDoc.addEventListener('gesturestart', preventDefault)
281
- ownerDoc.addEventListener('gesturechange', preventDefault)
282
- ownerDoc.addEventListener('gestureend', preventDefault)
294
+ ownerDoc.addEventListener('gesturestart', handleGesture)
295
+ ownerDoc.addEventListener('gesturechange', handleGesture)
296
+ ownerDoc.addEventListener('gestureend', handleGesture)
283
297
 
284
298
  container.addEventListener('keydown', handleKeyDown)
285
299
  container.addEventListener('keyup', handleKeyUp)
@@ -289,14 +303,44 @@ export function useDocumentEvents() {
289
303
 
290
304
  container.removeEventListener('wheel', handleWheel)
291
305
 
292
- ownerDoc.removeEventListener('gesturestart', preventDefault)
293
- ownerDoc.removeEventListener('gesturechange', preventDefault)
294
- ownerDoc.removeEventListener('gestureend', preventDefault)
306
+ ownerDoc.removeEventListener('gesturestart', handleGesture)
307
+ ownerDoc.removeEventListener('gesturechange', handleGesture)
308
+ ownerDoc.removeEventListener('gestureend', handleGesture)
295
309
 
296
310
  container.removeEventListener('keydown', handleKeyDown)
297
311
  container.removeEventListener('keyup', handleKeyUp)
298
312
  }
299
313
  }, [editor, container, isAppFocused, isEditing])
314
+
315
+ // Alt+Tab / Cmd+Tab away while holding a key delivers the keyup to the other app, so without
316
+ // this Space-panning (grab cursor, drags pan) stuck until the key was pressed again (#10442).
317
+ // Replaying a key_up per held key runs the normal release path, including tool onKeyUp.
318
+ useEffect(() => {
319
+ const win = editor.getContainerWindow()
320
+
321
+ const handleWindowBlur = () => {
322
+ const heldKeys = heldKeysRef.current
323
+ for (const code of [...editor.inputs.keys]) {
324
+ editor.dispatch({
325
+ type: 'keyboard',
326
+ name: 'key_up',
327
+ key: heldKeys.get(code) ?? code,
328
+ code,
329
+ shiftKey: false,
330
+ altKey: false,
331
+ ctrlKey: false,
332
+ metaKey: false,
333
+ accelKey: false,
334
+ })
335
+ }
336
+ heldKeys.clear()
337
+ }
338
+
339
+ win.addEventListener('blur', handleWindowBlur)
340
+ return () => {
341
+ win.removeEventListener('blur', handleWindowBlur)
342
+ }
343
+ }, [editor])
300
344
  }
301
345
 
302
346
  function areShortcutsDisabled(editor: Editor) {