@tldraw/editor 5.4.0-next.ebc95cf01337 → 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 (233) hide show
  1. package/dist-cjs/index.d.ts +67 -1
  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 +164 -280
  17. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  18. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +3 -3
  19. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
  20. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +8 -8
  21. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  22. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +2 -1
  23. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  24. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +79 -6
  25. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
  26. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +7 -0
  27. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  28. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +0 -2
  29. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
  30. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +1 -2
  31. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  32. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +6 -3
  33. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
  34. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +12 -1
  35. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  36. package/dist-cjs/lib/editor/overlays/OverlayManager.js +3 -7
  37. package/dist-cjs/lib/editor/overlays/OverlayManager.js.map +2 -2
  38. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js +0 -1
  39. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js.map +2 -2
  40. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js +3 -1
  41. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js.map +2 -2
  42. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js +1 -0
  43. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js.map +2 -2
  44. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +1 -1
  45. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
  46. package/dist-cjs/lib/editor/tools/StateNode.js +3 -2
  47. package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
  48. package/dist-cjs/lib/globals/editors.js +57 -0
  49. package/dist-cjs/lib/globals/editors.js.map +7 -0
  50. package/dist-cjs/lib/globals/menus.js +9 -4
  51. package/dist-cjs/lib/globals/menus.js.map +2 -2
  52. package/dist-cjs/lib/hooks/useCanvasEvents.js +2 -0
  53. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  54. package/dist-cjs/lib/hooks/useDocumentEvents.js +39 -9
  55. package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
  56. package/dist-cjs/lib/hooks/useLocalStore.js +10 -6
  57. package/dist-cjs/lib/hooks/useLocalStore.js.map +2 -2
  58. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +1 -0
  59. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
  60. package/dist-cjs/lib/hooks/usePeerIds.js +2 -1
  61. package/dist-cjs/lib/hooks/usePeerIds.js.map +2 -2
  62. package/dist-cjs/lib/license/LicenseManager.js +9 -2
  63. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  64. package/dist-cjs/lib/license/LicenseProvider.js +16 -3
  65. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  66. package/dist-cjs/lib/license/useLicenseManagerState.js +5 -4
  67. package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
  68. package/dist-cjs/lib/primitives/Box.js +2 -1
  69. package/dist-cjs/lib/primitives/Box.js.map +2 -2
  70. package/dist-cjs/lib/primitives/Vec.js +4 -3
  71. package/dist-cjs/lib/primitives/Vec.js.map +2 -2
  72. package/dist-cjs/lib/primitives/utils.js +2 -1
  73. package/dist-cjs/lib/primitives/utils.js.map +2 -2
  74. package/dist-cjs/lib/utils/deepLinks.js +5 -1
  75. package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
  76. package/dist-cjs/lib/utils/rotation.js +1 -9
  77. package/dist-cjs/lib/utils/rotation.js.map +2 -2
  78. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +17 -0
  79. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  80. package/dist-cjs/lib/utils/sync/alerts.js +1 -1
  81. package/dist-cjs/lib/utils/sync/alerts.js.map +1 -1
  82. package/dist-cjs/version.js +3 -3
  83. package/dist-cjs/version.js.map +1 -1
  84. package/dist-esm/index.d.mts +67 -1
  85. package/dist-esm/index.mjs +9 -2
  86. package/dist-esm/index.mjs.map +2 -2
  87. package/dist-esm/lib/TldrawEditor.mjs +9 -4
  88. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  89. package/dist-esm/lib/components/MenuClickCapture.mjs +6 -8
  90. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  91. package/dist-esm/lib/components/default-components/CanvasOverlays.mjs +1 -3
  92. package/dist-esm/lib/components/default-components/CanvasOverlays.mjs.map +2 -2
  93. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +1 -1
  94. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +1 -1
  95. package/dist-esm/lib/config/TLUserPreferences.mjs +6 -2
  96. package/dist-esm/lib/config/TLUserPreferences.mjs.map +2 -2
  97. package/dist-esm/lib/config/createTLStore.mjs +1 -1
  98. package/dist-esm/lib/config/createTLStore.mjs.map +2 -2
  99. package/dist-esm/lib/editor/Editor.mjs +172 -281
  100. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  101. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +3 -3
  102. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
  103. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +9 -9
  104. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  105. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +2 -1
  106. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  107. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +81 -8
  108. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
  109. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +7 -0
  110. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  111. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +0 -2
  112. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
  113. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +2 -3
  114. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  115. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +6 -3
  116. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
  117. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +12 -1
  118. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  119. package/dist-esm/lib/editor/overlays/OverlayManager.mjs +3 -7
  120. package/dist-esm/lib/editor/overlays/OverlayManager.mjs.map +2 -2
  121. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs +0 -1
  122. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs.map +2 -2
  123. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs +3 -1
  124. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs.map +2 -2
  125. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs +1 -0
  126. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs.map +2 -2
  127. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +1 -1
  128. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
  129. package/dist-esm/lib/editor/tools/StateNode.mjs +3 -2
  130. package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
  131. package/dist-esm/lib/globals/editors.mjs +37 -0
  132. package/dist-esm/lib/globals/editors.mjs.map +7 -0
  133. package/dist-esm/lib/globals/menus.mjs +9 -4
  134. package/dist-esm/lib/globals/menus.mjs.map +2 -2
  135. package/dist-esm/lib/hooks/useCanvasEvents.mjs +2 -0
  136. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  137. package/dist-esm/lib/hooks/useDocumentEvents.mjs +40 -10
  138. package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
  139. package/dist-esm/lib/hooks/useLocalStore.mjs +11 -7
  140. package/dist-esm/lib/hooks/useLocalStore.mjs.map +2 -2
  141. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +1 -0
  142. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
  143. package/dist-esm/lib/hooks/usePeerIds.mjs +2 -1
  144. package/dist-esm/lib/hooks/usePeerIds.mjs.map +2 -2
  145. package/dist-esm/lib/license/LicenseManager.mjs +9 -2
  146. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  147. package/dist-esm/lib/license/LicenseProvider.mjs +16 -3
  148. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  149. package/dist-esm/lib/license/useLicenseManagerState.mjs +5 -4
  150. package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
  151. package/dist-esm/lib/primitives/Box.mjs +2 -1
  152. package/dist-esm/lib/primitives/Box.mjs.map +2 -2
  153. package/dist-esm/lib/primitives/Vec.mjs +4 -3
  154. package/dist-esm/lib/primitives/Vec.mjs.map +2 -2
  155. package/dist-esm/lib/primitives/utils.mjs +2 -1
  156. package/dist-esm/lib/primitives/utils.mjs.map +2 -2
  157. package/dist-esm/lib/utils/deepLinks.mjs +5 -1
  158. package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
  159. package/dist-esm/lib/utils/rotation.mjs +1 -9
  160. package/dist-esm/lib/utils/rotation.mjs.map +2 -2
  161. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +17 -0
  162. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  163. package/dist-esm/lib/utils/sync/alerts.mjs +1 -1
  164. package/dist-esm/lib/utils/sync/alerts.mjs.map +1 -1
  165. package/dist-esm/version.mjs +3 -3
  166. package/dist-esm/version.mjs.map +1 -1
  167. package/package.json +13 -7
  168. package/src/index.ts +6 -1
  169. package/src/lib/TldrawEditor.tsx +16 -4
  170. package/src/lib/components/MenuClickCapture.tsx +19 -18
  171. package/src/lib/components/default-components/CanvasOverlays.tsx +1 -3
  172. package/src/lib/components/default-components/DefaultErrorFallback.tsx +1 -1
  173. package/src/lib/config/TLUserPreferences.test.ts +18 -1
  174. package/src/lib/config/TLUserPreferences.ts +8 -4
  175. package/src/lib/config/createTLStore.ts +1 -1
  176. package/src/lib/editor/Editor.ts +241 -359
  177. package/src/lib/editor/managers/ClickManager/ClickManager.ts +5 -3
  178. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +9 -9
  179. package/src/lib/editor/managers/FontManager/FontManager.ts +2 -1
  180. package/src/lib/editor/managers/InputsManager/InputsManager.test.ts +199 -3
  181. package/src/lib/editor/managers/InputsManager/InputsManager.ts +128 -9
  182. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +12 -1
  183. package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +0 -2
  184. package/src/lib/editor/managers/TextManager/TextManager.ts +2 -6
  185. package/src/lib/editor/managers/TickManager/TickManager.test.ts +13 -0
  186. package/src/lib/editor/managers/TickManager/TickManager.ts +9 -3
  187. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +64 -3
  188. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +18 -1
  189. package/src/lib/editor/overlays/OverlayManager.ts +3 -7
  190. package/src/lib/editor/overlays/strokeShapeIndicators.ts +0 -1
  191. package/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx +5 -1
  192. package/src/lib/editor/shapes/shared/getPerfectDashProps.ts +3 -0
  193. package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +4 -1
  194. package/src/lib/editor/tools/StateNode.test.ts +64 -0
  195. package/src/lib/editor/tools/StateNode.ts +12 -2
  196. package/src/lib/globals/editors.test.ts +76 -0
  197. package/src/lib/globals/editors.ts +53 -0
  198. package/src/lib/globals/menus.test.ts +33 -0
  199. package/src/lib/globals/menus.ts +11 -4
  200. package/src/lib/hooks/useCanvasEvents.ts +2 -0
  201. package/src/lib/hooks/useDocumentEvents.ts +60 -16
  202. package/src/lib/hooks/useLocalStore.ts +16 -8
  203. package/src/lib/hooks/usePassThroughWheelEvents.ts +3 -0
  204. package/src/lib/hooks/usePeerIds.ts +2 -1
  205. package/src/lib/license/LicenseManager.test.ts +160 -0
  206. package/src/lib/license/LicenseManager.ts +13 -4
  207. package/src/lib/license/LicenseProvider.tsx +24 -3
  208. package/src/lib/license/useLicenseManagerState.ts +8 -6
  209. package/src/lib/primitives/Box.test.ts +9 -1
  210. package/src/lib/primitives/Box.ts +3 -1
  211. package/src/lib/primitives/Vec.test.ts +10 -0
  212. package/src/lib/primitives/Vec.ts +4 -4
  213. package/src/lib/primitives/geometry/Arc2d.test.ts +226 -3
  214. package/src/lib/primitives/geometry/CubicBezier2d.test.ts +173 -3
  215. package/src/lib/primitives/geometry/CubicSpline2d.test.ts +160 -3
  216. package/src/lib/primitives/geometry/Ellipse2d.test.ts +243 -3
  217. package/src/lib/primitives/geometry/Geometry2d.test.ts +553 -1
  218. package/src/lib/primitives/geometry/Group2d.test.ts +398 -3
  219. package/src/lib/primitives/geometry/Polyline2d.test.ts +207 -0
  220. package/src/lib/primitives/geometry/Stadium2d.test.ts +191 -3
  221. package/src/lib/primitives/utils.test.ts +10 -1
  222. package/src/lib/primitives/utils.ts +4 -1
  223. package/src/lib/test/useCanvasEvents.test.tsx +74 -0
  224. package/src/lib/test/useDocumentEvents.test.tsx +107 -0
  225. package/src/lib/test/usePassThroughWheelEvents.test.tsx +25 -2
  226. package/src/lib/utils/deepLinks.test.ts +15 -0
  227. package/src/lib/utils/deepLinks.ts +7 -1
  228. package/src/lib/utils/rotation.ts +4 -12
  229. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +56 -0
  230. package/src/lib/utils/sync/TLLocalSyncClient.ts +24 -0
  231. package/src/lib/utils/sync/alerts.ts +1 -1
  232. package/src/version.ts +3 -3
  233. package/src/lib/primitives/geometry/Polyline.test.ts +0 -5
@@ -1,5 +1,193 @@
1
- describe('Stadium2d', () => {
2
- it('should be tested', () => {
3
- expect(true).toBe(true)
1
+ import { Vec } from '../Vec'
2
+ import { Stadium2d } from './Stadium2d'
3
+
4
+ // A wide 100x50 stadium: semicircles of radius 25 centred at (25, 25) and (75, 25)
5
+ const wide = () => new Stadium2d({ width: 100, height: 50, isFilled: false })
6
+ const wideFilled = () => new Stadium2d({ width: 100, height: 50, isFilled: true })
7
+ // A tall 50x100 stadium: semicircles of radius 25 centred at (25, 25) and (25, 75)
8
+ const tall = () => new Stadium2d({ width: 50, height: 100, isFilled: false })
9
+
10
+ function expectVec(actual: Vec, expected: { x: number; y: number }) {
11
+ expect(actual.x).toBeCloseTo(expected.x)
12
+ expect(actual.y).toBeCloseTo(expected.y)
13
+ }
14
+
15
+ describe('Stadium2d construction', () => {
16
+ it('is always closed and keeps the fill flag from config', () => {
17
+ expect(wide()).toMatchObject({ isClosed: true, isFilled: false })
18
+ expect(wideFilled()).toMatchObject({ isClosed: true, isFilled: true })
19
+ })
20
+ })
21
+
22
+ describe('Stadium2d.bounds', () => {
23
+ it('is the width by height box at the origin', () => {
24
+ expect(wide().bounds).toMatchObject({ x: 0, y: 0, w: 100, h: 50 })
25
+ expect(wide().center).toMatchObject({ x: 50, y: 25 })
26
+ expect(tall().bounds).toMatchObject({ x: 0, y: 0, w: 50, h: 100 })
27
+ })
28
+ })
29
+
30
+ describe('Stadium2d.getLength', () => {
31
+ it('is two straight sides plus a full circle', () => {
32
+ expect(wide().length).toBeCloseTo(2 * 50 + Math.PI * 50)
33
+ expect(tall().length).toBeCloseTo(2 * 50 + Math.PI * 50)
34
+ })
35
+
36
+ it('is a circle circumference when width equals height', () => {
37
+ expect(new Stadium2d({ width: 50, height: 50, isFilled: false }).length).toBeCloseTo(
38
+ Math.PI * 50
39
+ )
40
+ })
41
+ })
42
+
43
+ describe('Stadium2d.getVertices', () => {
44
+ it('concatenates the vertices of the two arcs and two edges', () => {
45
+ const { vertices } = wide()
46
+ // each semicircle samples the minimum 8 segments (9 vertices), each edge has 2
47
+ expect(vertices.length).toBe(9 + 2 + 9 + 2)
48
+ // left arc runs from the bottom to the top through the leftmost point
49
+ expectVec(vertices[0], { x: 25, y: 50 })
50
+ expectVec(vertices[4], { x: 0, y: 25 })
51
+ expectVec(vertices[8], { x: 25, y: 0 })
52
+ // top edge
53
+ expect(vertices[9]).toMatchObject({ x: 25, y: 0 })
54
+ expect(vertices[10]).toMatchObject({ x: 75, y: 0 })
55
+ // right arc runs from the top to the bottom through the rightmost point
56
+ expectVec(vertices[11], { x: 75, y: 0 })
57
+ expectVec(vertices[15], { x: 100, y: 25 })
58
+ expectVec(vertices[19], { x: 75, y: 50 })
59
+ // bottom edge
60
+ expect(vertices[20]).toMatchObject({ x: 75, y: 50 })
61
+ expect(vertices[21]).toMatchObject({ x: 25, y: 50 })
62
+ })
63
+
64
+ it('keeps every vertex inside the bounds', () => {
65
+ for (const s of [wide(), tall()]) {
66
+ for (const v of s.vertices) {
67
+ expect(v.x).toBeGreaterThanOrEqual(-1e-9)
68
+ expect(v.y).toBeGreaterThanOrEqual(-1e-9)
69
+ expect(v.x).toBeLessThanOrEqual(s.bounds.w + 1e-9)
70
+ expect(v.y).toBeLessThanOrEqual(s.bounds.h + 1e-9)
71
+ }
72
+ }
73
+ })
74
+
75
+ it('starts a tall stadium with the top arc from left to right', () => {
76
+ const { vertices } = tall()
77
+ expectVec(vertices[0], { x: 0, y: 25 })
78
+ expectVec(vertices[4], { x: 25, y: 0 })
79
+ expectVec(vertices[8], { x: 50, y: 25 })
80
+ expect(vertices[10]).toMatchObject({ x: 50, y: 75 })
81
+ expectVec(vertices[15], { x: 25, y: 100 })
82
+ expect(vertices[21]).toMatchObject({ x: 0, y: 25 })
83
+ })
84
+ })
85
+
86
+ describe('Stadium2d.nearestPoint', () => {
87
+ it('projects onto the straight edges', () => {
88
+ expect(wide().nearestPoint(new Vec(50, -10))).toMatchObject({ x: 50, y: 0 })
89
+ expect(wide().nearestPoint(new Vec(50, 60))).toMatchObject({ x: 50, y: 50 })
90
+ expect(tall().nearestPoint(new Vec(-10, 50))).toMatchObject({ x: 0, y: 50 })
91
+ })
92
+
93
+ it('projects radially onto the arcs', () => {
94
+ expectVec(wide().nearestPoint(new Vec(-10, 25)), { x: 0, y: 25 })
95
+ expectVec(wide().nearestPoint(new Vec(110, 25)), { x: 100, y: 25 })
96
+ expectVec(tall().nearestPoint(new Vec(25, -10)), { x: 25, y: 0 })
97
+ // towards the top-left corner of the bounds the nearest point is on the arc, not the corner
98
+ const p = wide().nearestPoint(new Vec(0, 0))
99
+ expectVec(p, { x: 25 - 25 / Math.SQRT2, y: 25 - 25 / Math.SQRT2 })
100
+ })
101
+
102
+ it('projects from inside', () => {
103
+ expect(wide().nearestPoint(new Vec(50, 20))).toMatchObject({ x: 50, y: 0 })
104
+ })
105
+ })
106
+
107
+ describe('Stadium2d.distanceToPoint', () => {
108
+ it('is positive outside', () => {
109
+ expect(wide().distanceToPoint(new Vec(50, -10))).toBe(10)
110
+ expect(wide().distanceToPoint(new Vec(-10, 25))).toBeCloseTo(10)
111
+ expect(wideFilled().distanceToPoint(new Vec(50, -10))).toBe(10)
112
+ })
113
+
114
+ it('is negative inside only when filled or hitInside', () => {
115
+ expect(wide().distanceToPoint(new Vec(50, 25))).toBe(25)
116
+ expect(wideFilled().distanceToPoint(new Vec(50, 25))).toBe(-25)
117
+ expect(wide().distanceToPoint(new Vec(50, 25), true)).toBe(-25)
118
+ })
119
+
120
+ it('measures to the arc inside the rounded corners', () => {
121
+ // (5, 5) is inside the bounds but outside the left semicircle
122
+ expect(wide().distanceToPoint(new Vec(5, 5))).toBeCloseTo(Math.hypot(20, 20) - 25)
123
+ expect(wideFilled().distanceToPoint(new Vec(5, 5))).toBeCloseTo(Math.hypot(20, 20) - 25)
124
+ })
125
+ })
126
+
127
+ describe('Stadium2d.hitTestPoint', () => {
128
+ it('hits the interior only when filled or hitInside', () => {
129
+ expect(wideFilled().hitTestPoint(new Vec(50, 25))).toBe(true)
130
+ expect(wide().hitTestPoint(new Vec(50, 25))).toBe(false)
131
+ expect(wide().hitTestPoint(new Vec(50, 25), 0, true)).toBe(true)
132
+ })
133
+
134
+ it('misses the rounded corners even when filled', () => {
135
+ expect(wideFilled().hitTestPoint(new Vec(5, 5))).toBe(false)
136
+ expect(wideFilled().hitTestPoint(new Vec(95, 45))).toBe(false)
137
+ })
138
+
139
+ it('hits the rounded corners within the margin', () => {
140
+ const d = Math.hypot(20, 20) - 25 // ≈ 3.28
141
+ expect(wide().hitTestPoint(new Vec(5, 5), d + 0.01)).toBe(true)
142
+ expect(wide().hitTestPoint(new Vec(5, 5), d - 0.01)).toBe(false)
143
+ })
144
+
145
+ it('hits the straight edge and respects the margin', () => {
146
+ expect(wide().hitTestPoint(new Vec(50, 0))).toBe(true)
147
+ expect(wide().hitTestPoint(new Vec(50, -5), 5)).toBe(true)
148
+ expect(wide().hitTestPoint(new Vec(50, -5), 4)).toBe(false)
149
+ })
150
+ })
151
+
152
+ describe('Stadium2d.hitTestLineSegment', () => {
153
+ it('hits a segment crossing a straight edge', () => {
154
+ expect(wide().hitTestLineSegment(new Vec(50, -10), new Vec(50, 60))).toBe(true)
155
+ })
156
+
157
+ it('hits a segment crossing an arc', () => {
158
+ expect(wide().hitTestLineSegment(new Vec(-10, 25), new Vec(10, 25))).toBe(true)
159
+ // the diagonal from the bounds corner reaches the arc at t ≈ 7.3
160
+ expect(wide().hitTestLineSegment(new Vec(0, 0), new Vec(10, 10))).toBe(true)
161
+ })
162
+
163
+ it('misses a segment that stays within a rounded corner gap', () => {
164
+ expect(wide().hitTestLineSegment(new Vec(0, 0), new Vec(5, 5))).toBe(false)
165
+ })
166
+
167
+ it('misses segments entirely outside or entirely inside', () => {
168
+ expect(wide().hitTestLineSegment(new Vec(110, 0), new Vec(110, 50))).toBe(false)
169
+ expect(wideFilled().hitTestLineSegment(new Vec(40, 25), new Vec(45, 25))).toBe(false)
170
+ expect(wideFilled().hitTestLineSegment(new Vec(45, 2), new Vec(55, 2))).toBe(false)
171
+ })
172
+
173
+ // Locks in current behaviour, see #10555.
174
+ it('hits an interior segment that crosses the far side of an arc circle (known quirk)', () => {
175
+ // (50, 25) is on the left arc's circle but opposite the arc itself; Arc2d clamps points on
176
+ // the complementary arc to the nearest end point, so the crossing counts as a hit
177
+ expect(wideFilled().hitTestLineSegment(new Vec(30, 25), new Vec(70, 25))).toBe(true)
178
+ })
179
+ })
180
+
181
+ describe('Stadium2d.getSvgPathData', () => {
182
+ it('draws arc, line, arc, line and closes', () => {
183
+ expect(wide().getSvgPathData()).toBe(
184
+ 'M25, 50 A25 25 0 1 1 25, 0 L75, 0 A25 25 0 1 1 75, 50 L25, 50 Z'
185
+ )
186
+ })
187
+
188
+ it('draws a tall stadium starting from the left of the top arc', () => {
189
+ expect(tall().getSvgPathData()).toBe(
190
+ 'M0, 25 A25 25 0 1 1 50, 25 L50, 75 A25 25 0 1 1 0, 75 L0, 25 Z'
191
+ )
4
192
  })
5
193
  })
@@ -1,4 +1,4 @@
1
- import { getPointInArcT } from './utils'
1
+ import { areAnglesCompatible, getPointInArcT } from './utils'
2
2
 
3
3
  describe('getPointInArcT', () => {
4
4
  it('should return 0 for the start of the arc', () => {
@@ -57,3 +57,12 @@ describe('getPointInArcT', () => {
57
57
  expect(getPointInArcT(mAB, A, B, P)).toBe(1)
58
58
  })
59
59
  })
60
+
61
+ describe('areAnglesCompatible', () => {
62
+ it('treats angles a multiple of PI/2 apart as compatible regardless of sign', () => {
63
+ expect(areAnglesCompatible(-Math.PI / 4, Math.PI / 4)).toBe(true)
64
+ expect(areAnglesCompatible(Math.PI / 2 - 1e-9, 0)).toBe(true)
65
+ expect(areAnglesCompatible(Math.PI, -Math.PI / 2)).toBe(true)
66
+ expect(areAnglesCompatible(0, Math.PI / 3)).toBe(false)
67
+ })
68
+ })
@@ -189,7 +189,10 @@ export function snapAngle(r: number, segments: number): number {
189
189
  * @public
190
190
  */
191
191
  export function areAnglesCompatible(a: number, b: number) {
192
- return a === b || approximately((a % (Math.PI / 2)) - (b % (Math.PI / 2)), 0)
192
+ // Reduce the difference, not each angle: a sign-preserving % on each made
193
+ // -PI/4 and PI/4 incompatible. A remainder just under PI/2 is a multiple too.
194
+ const d = Math.abs(a - b) % (Math.PI / 2)
195
+ return approximately(d, 0) || approximately(d, Math.PI / 2)
193
196
  }
194
197
 
195
198
  /**
@@ -0,0 +1,74 @@
1
+ import { act, render } from '@testing-library/react'
2
+ import { Editor } from '../editor/Editor'
3
+ import { TldrawEditor } from '../TldrawEditor'
4
+
5
+ function dropEvent(dataTransfer: { files: File[]; getData(type: string): string }) {
6
+ const event = new Event('drop', { bubbles: true, cancelable: true })
7
+ Object.defineProperty(event, 'dataTransfer', { value: dataTransfer })
8
+ return event
9
+ }
10
+
11
+ describe('useCanvasEvents drop handling', () => {
12
+ let editor: Editor
13
+ let canvas: HTMLElement
14
+
15
+ beforeEach(async () => {
16
+ await act(async () => {
17
+ render(
18
+ <TldrawEditor
19
+ shapeUtils={[]}
20
+ bindingUtils={[]}
21
+ onMount={(e) => {
22
+ editor = e
23
+ }}
24
+ />
25
+ )
26
+ })
27
+ canvas = document.querySelector('[data-testid="canvas"]') as HTMLElement
28
+
29
+ // Stand-ins for the default handlers: any undoable change will do.
30
+ editor.registerExternalContentHandler('files', async () => {
31
+ editor.createPage({ name: 'dropped' })
32
+ })
33
+ editor.registerExternalContentHandler('url', async () => {
34
+ editor.createPage({ name: 'dropped' })
35
+ })
36
+
37
+ // An unmarked change before the drop, like a stroke the user just drew.
38
+ editor.createPage({ name: 'before drop' })
39
+ })
40
+
41
+ afterEach(() => {
42
+ editor?.dispose()
43
+ })
44
+
45
+ function pageNames() {
46
+ return editor.getPages().map((p) => p.name)
47
+ }
48
+
49
+ it('makes a dropped file its own undo step', async () => {
50
+ await act(async () => {
51
+ canvas.dispatchEvent(
52
+ dropEvent({ files: [new File(['x'], 'x.png', { type: 'image/png' })], getData: () => '' })
53
+ )
54
+ })
55
+ expect(pageNames()).toEqual(['Page 1', 'before drop', 'dropped'])
56
+
57
+ editor.undo()
58
+
59
+ expect(pageNames()).toEqual(['Page 1', 'before drop'])
60
+ })
61
+
62
+ it('makes a dropped url its own undo step', async () => {
63
+ await act(async () => {
64
+ canvas.dispatchEvent(
65
+ dropEvent({ files: [], getData: (type) => (type === 'url' ? 'https://example.com' : '') })
66
+ )
67
+ })
68
+ expect(pageNames()).toEqual(['Page 1', 'before drop', 'dropped'])
69
+
70
+ editor.undo()
71
+
72
+ expect(pageNames()).toEqual(['Page 1', 'before drop'])
73
+ })
74
+ })
@@ -0,0 +1,107 @@
1
+ import { act, cleanup, render } from '@testing-library/react'
2
+ import { vi } from 'vitest'
3
+ import { createTLStore } from '../config/createTLStore'
4
+ import { Editor } from '../editor/Editor'
5
+ import { TLKeyboardEventInfo } from '../editor/types/event-types'
6
+ import { TL_CONTAINER_CLASS, TldrawEditor } from '../TldrawEditor'
7
+
8
+ describe('useDocumentEvents drop handling', () => {
9
+ // The container's native drop listener used to stop propagation before the event reached
10
+ // React's root, so React onDrop handlers inside the canvas never fired.
11
+ it('lets a drop inside the canvas reach React drop handlers there', async () => {
12
+ const onDrop = vi.fn((e: React.DragEvent) => e.stopPropagation())
13
+ function DropTarget() {
14
+ return <div data-testid="drop-target" onDrop={onDrop} />
15
+ }
16
+ const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
17
+ await act(async () => {
18
+ render(<TldrawEditor store={store} components={{ OnTheCanvas: DropTarget }} />)
19
+ })
20
+
21
+ const target = document.querySelector('[data-testid="drop-target"]')!
22
+ expect(target.closest('.tl-canvas')).not.toBeNull()
23
+ const event = new Event('drop', { bubbles: true, cancelable: true })
24
+ Object.defineProperty(event, 'dataTransfer', { value: { files: [], getData: () => '' } })
25
+ act(() => {
26
+ target.dispatchEvent(event)
27
+ })
28
+
29
+ expect(onDrop).toHaveBeenCalledTimes(1)
30
+ // the browser default (navigating to the dropped file) is still prevented
31
+ expect(event.defaultPrevented).toBe(true)
32
+ })
33
+ })
34
+
35
+ describe('useDocumentEvents window blur', () => {
36
+ async function renderEditor() {
37
+ let editor!: Editor
38
+ const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
39
+ await act(async () => {
40
+ render(<TldrawEditor store={store} autoFocus onMount={(e) => void (editor = e)} />)
41
+ })
42
+ const container = document.querySelector<HTMLElement>(`.${TL_CONTAINER_CLASS}`)!
43
+ return { editor, container }
44
+ }
45
+
46
+ function keyDown(container: HTMLElement, init: KeyboardEventInit) {
47
+ act(() => {
48
+ container.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init }))
49
+ })
50
+ }
51
+
52
+ function blurWindow() {
53
+ act(() => {
54
+ window.dispatchEvent(new Event('blur'))
55
+ })
56
+ }
57
+
58
+ afterEach(() => {
59
+ cleanup()
60
+ })
61
+
62
+ // Alt+Tab / Cmd+Tab while holding Space: the keyup goes to the other app, so the editor
63
+ // used to stay in spacebar panning mode with the grab cursor until Space was pressed again.
64
+ it('ends spacebar panning and restores the cursor', async () => {
65
+ const { editor, container } = await renderEditor()
66
+ editor.setCursor({ type: 'cross', rotation: 0 })
67
+
68
+ keyDown(container, { key: ' ', code: 'Space' })
69
+ expect(editor.inputs.keys.has('Space')).toBe(true)
70
+ expect(editor.inputs.getIsSpacebarPanning()).toBe(true)
71
+ expect(editor.inputs.getIsPanning()).toBe(true)
72
+ expect(editor.getInstanceState().cursor.type).toBe('grab')
73
+
74
+ blurWindow()
75
+ expect(editor.inputs.keys.has('Space')).toBe(false)
76
+ expect(editor.inputs.getIsSpacebarPanning()).toBe(false)
77
+ expect(editor.inputs.getIsPanning()).toBe(false)
78
+ expect(editor.getInstanceState().cursor.type).toBe('cross')
79
+ })
80
+
81
+ it('releases every held key through a key_up that tools can match on key', async () => {
82
+ const { editor, container } = await renderEditor()
83
+ const keyUps: TLKeyboardEventInfo[] = []
84
+ editor.on('event', (info) => {
85
+ if (info.type === 'keyboard' && info.name === 'key_up') keyUps.push(info)
86
+ })
87
+
88
+ keyDown(container, { key: 'ArrowLeft', code: 'ArrowLeft' })
89
+ keyDown(container, { key: 'a', code: 'KeyA' })
90
+ expect([...editor.inputs.keys]).toEqual(['ArrowLeft', 'KeyA'])
91
+
92
+ blurWindow()
93
+ expect(editor.inputs.keys.size).toBe(0)
94
+ expect(keyUps.map((info) => [info.key, info.code])).toEqual([
95
+ ['ArrowLeft', 'ArrowLeft'],
96
+ ['a', 'KeyA'],
97
+ ])
98
+ })
99
+
100
+ it('does nothing when no keys are held', async () => {
101
+ const { editor } = await renderEditor()
102
+ const onEvent = vi.fn()
103
+ editor.on('event', onEvent)
104
+ blurWindow()
105
+ expect(onEvent).not.toHaveBeenCalled()
106
+ })
107
+ })
@@ -22,11 +22,27 @@ function Culled() {
22
22
  return <button ref={ref} data-testid="marker" />
23
23
  }
24
24
 
25
+ /**
26
+ * The shape the navigation panel has: a pass-through root wrapping another pass-through root (the
27
+ * minimap).
28
+ */
29
+ function Nested() {
30
+ const outer = useRef<HTMLDivElement>(null)
31
+ const inner = useRef<HTMLButtonElement>(null)
32
+ usePassThroughWheelEvents(outer)
33
+ usePassThroughWheelEvents(inner)
34
+ return (
35
+ <div ref={outer}>
36
+ <button ref={inner} data-testid="marker" />
37
+ </div>
38
+ )
39
+ }
40
+
25
41
  describe('usePassThroughWheelEvents', () => {
26
- async function renderEditor() {
42
+ async function renderEditor(InFrontOfTheCanvas = Culled) {
27
43
  const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
28
44
  await act(async () => {
29
- render(<TldrawEditor store={store} components={{ InFrontOfTheCanvas: Culled }} />)
45
+ render(<TldrawEditor store={store} components={{ InFrontOfTheCanvas }} />)
30
46
  })
31
47
  return document.querySelector(`.${TL_CONTAINER_CLASS}`)!
32
48
  }
@@ -64,6 +80,13 @@ describe('usePassThroughWheelEvents', () => {
64
80
  expect(wheelOverMarker(container)).toHaveLength(1)
65
81
  })
66
82
 
83
+ // Issue #10441: the original event keeps bubbling after the inner root redispatches it, so
84
+ // without a flag on it the outer root would redispatch it again.
85
+ it('redispatches once when the element is inside another pass-through element', async () => {
86
+ const container = await renderEditor(Nested)
87
+ expect(wheelOverMarker(container)).toHaveLength(1)
88
+ })
89
+
67
90
  it('stops redispatching once the owner unmounts', async () => {
68
91
  const container = await renderEditor()
69
92
  const marker = container.querySelector('[data-testid="marker"]')!
@@ -70,3 +70,18 @@ test.each(testCases)('works with $name', ({ deepLink, expected }) => {
70
70
  expect(createDeepLinkString(deepLink)).toBe(expected)
71
71
  expect(parseDeepLinkString(expected)).toEqual(deepLink)
72
72
  })
73
+
74
+ describe('parseDeepLinkString', () => {
75
+ it('throws on a viewport link with non-numeric bounds', () => {
76
+ expect(() => parseDeepLinkString('vabc.def.ghi.jkl')).toThrow('Invalid deep link string')
77
+ })
78
+
79
+ it('throws on a viewport link with missing bounds', () => {
80
+ expect(() => parseDeepLinkString('v1.2')).toThrow('Invalid deep link string')
81
+ expect(() => parseDeepLinkString('v')).toThrow('Invalid deep link string')
82
+ })
83
+
84
+ it('throws on an unknown link type', () => {
85
+ expect(() => parseDeepLinkString('xabc')).toThrow('Invalid deep link string')
86
+ })
87
+ })
@@ -73,9 +73,15 @@ export function parseDeepLinkString(deepLinkString: string): TLDeepLink {
73
73
  }
74
74
  case 'v': {
75
75
  const [x, y, w, h, pageId] = deepLinkString.slice(1).split('.')
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)) {
80
+ throw Error('Invalid deep link string')
81
+ }
76
82
  return {
77
83
  type: 'viewport',
78
- bounds: new Box(Number(x), Number(y), Number(w), Number(h)),
84
+ bounds,
79
85
  pageId: pageId ? PageRecordType.createId(decodeURIComponent(pageId)) : undefined,
80
86
  }
81
87
  }
@@ -1,7 +1,6 @@
1
- import { isShapeId, TLShape, TLShapeId, TLShapePartial } from '@tldraw/tlschema'
1
+ import { TLShape, TLShapeId, TLShapePartial } from '@tldraw/tlschema'
2
2
  import { compact } from '@tldraw/utils'
3
3
  import type { Editor } from '../editor/Editor'
4
- import { Mat } from '../primitives/Mat'
5
4
  import { canonicalizeRotation } from '../primitives/utils'
6
5
  import { Vec, VecLike } from '../primitives/Vec'
7
6
 
@@ -75,18 +74,11 @@ export function applyRotationToSnapshotShapes({
75
74
  // We need to both rotate each shape individually and rotate the shapes
76
75
  // around the pivot point (the average center of all rotating shapes.)
77
76
 
78
- const parentTransform = isShapeId(shape.parentId)
79
- ? editor.getShapePageTransform(shape.parentId)!
80
- : Mat.Identity()
81
-
82
77
  const newPagePoint = Vec.RotWith(initialPagePoint, centerOverride ?? initialPageCenter, delta)
83
78
 
84
- const newLocalPoint = Mat.applyToPoint(
85
- // use the current parent transform in case it has moved/resized since the start
86
- // (e.g. if rotating a shape at the edge of a group)
87
- Mat.Inverse(parentTransform),
88
- newPagePoint
89
- )
79
+ // uses the current parent transform in case it has moved/resized since the start
80
+ // (e.g. if rotating a shape at the edge of a group)
81
+ const newLocalPoint = editor.getPointInParentSpace(shape.id, newPagePoint)
90
82
  const newRotation = canonicalizeRotation(shape.rotation + delta)
91
83
 
92
84
  return {
@@ -172,3 +172,59 @@ test('writes that come in during a persist operation will get persisted afterwar
172
172
  await tick()
173
173
  expect(client.db.storeChanges).toHaveBeenCalledTimes(1)
174
174
  })
175
+
176
+ test('pagehide flushes pending changes without waiting for the throttle', async () => {
177
+ const { client, tick } = testClient()
178
+ await tick()
179
+ client.store.put([PageRecordType.create({ name: 'test', index: 'a0' as IndexKey })])
180
+ await tick()
181
+ expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
182
+
183
+ client.store.put([PageRecordType.create({ name: 'test2', index: 'a1' as IndexKey })])
184
+ expect(client.db.storeChanges).not.toHaveBeenCalled()
185
+
186
+ window.dispatchEvent(new Event('pagehide'))
187
+ expect(client.db.storeChanges).toHaveBeenCalledTimes(1)
188
+ })
189
+
190
+ test('hiding the tab flushes pending changes without waiting for the throttle', async () => {
191
+ const { client, tick } = testClient()
192
+ await tick()
193
+ client.store.put([PageRecordType.create({ name: 'test', index: 'a0' as IndexKey })])
194
+ await tick()
195
+ expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
196
+
197
+ client.store.put([PageRecordType.create({ name: 'test2', index: 'a1' as IndexKey })])
198
+ expect(client.db.storeChanges).not.toHaveBeenCalled()
199
+
200
+ const visibilityState = vi.spyOn(document, 'visibilityState', 'get')
201
+ try {
202
+ visibilityState.mockReturnValue('hidden')
203
+ document.dispatchEvent(new Event('visibilitychange'))
204
+ expect(client.db.storeChanges).toHaveBeenCalledTimes(1)
205
+ } finally {
206
+ visibilityState.mockRestore()
207
+ }
208
+ })
209
+
210
+ test('pagehide does not write before the initial load has completed', async () => {
211
+ const { client } = testClient()
212
+ window.dispatchEvent(new Event('pagehide'))
213
+ expect(client.db.storeSnapshot).not.toHaveBeenCalled()
214
+ expect(client.db.storeChanges).not.toHaveBeenCalled()
215
+ })
216
+
217
+ test('pagehide and visibilitychange listeners are removed when the client closes', async () => {
218
+ const removeWindowListener = vi.spyOn(window, 'removeEventListener')
219
+ const removeDocumentListener = vi.spyOn(document, 'removeEventListener')
220
+ try {
221
+ const { client, tick } = testClient()
222
+ await tick()
223
+ client.close()
224
+ expect(removeWindowListener).toHaveBeenCalledWith('pagehide', expect.any(Function))
225
+ expect(removeDocumentListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
226
+ } finally {
227
+ removeWindowListener.mockRestore()
228
+ removeDocumentListener.mockRestore()
229
+ }
230
+ })
@@ -68,6 +68,7 @@ export class TLLocalSyncClient {
68
68
  private disposables = new Set<() => void>()
69
69
  private diffQueue: Array<RecordsDiff<UnknownRecord> | typeof UPDATE_INSTANCE_STATE> = []
70
70
  private didDispose = false
71
+ private didLoad = false
71
72
  private shouldDoFullDBWrite = true
72
73
  private isReloading = false
73
74
  readonly persistenceKey: string
@@ -143,6 +144,28 @@ export class TLLocalSyncClient {
143
144
  )
144
145
  )
145
146
 
147
+ if (typeof window !== 'undefined') {
148
+ // Persists are throttled, so without this the last PERSIST_THROTTLE_MS of edits are lost
149
+ // whenever the tab is closed or reloaded. `pagehide` is the last reliable event before
150
+ // unload on desktop; on mobile the page can be discarded without it firing, so we also
151
+ // flush when the tab is hidden.
152
+ const flush = () => {
153
+ // Before the initial load completes a full write would overwrite the saved
154
+ // document with an empty store.
155
+ if (!this.didLoad) return
156
+ this.persistIfNeeded()
157
+ }
158
+ const onVisibilityChange = () => {
159
+ if (document.visibilityState === 'hidden') flush()
160
+ }
161
+ window.addEventListener('pagehide', flush)
162
+ document.addEventListener('visibilitychange', onVisibilityChange)
163
+ this.disposables.add(() => {
164
+ window.removeEventListener('pagehide', flush)
165
+ document.removeEventListener('visibilitychange', onVisibilityChange)
166
+ })
167
+ }
168
+
146
169
  this.connect(onLoad, onLoadError)
147
170
 
148
171
  this.documentTypes = new Set(
@@ -249,6 +272,7 @@ export class TLLocalSyncClient {
249
272
  this.disposables.add(() => {
250
273
  this.channel.close()
251
274
  })
275
+ this.didLoad = true
252
276
  onLoad(this)
253
277
  } catch (e: any) {
254
278
  this.debug('error loading data from store', e)
@@ -12,7 +12,7 @@ Keep seeing this message?
12
12
  /** @internal */
13
13
  export function showCantReadFromIndexDbAlert() {
14
14
  window.alert(
15
- `Oops! We could not access your browser's storage—and the app won't work correctly without that. We now need to reload the page and try again.
15
+ `Oops! We could not access your browser's storage—and the app won't work correctly without that. Reload the page to try again.
16
16
 
17
17
  Keep seeing this message?
18
18
  • If you're using tldraw in a private or "incognito" window, try loading tldraw in a regular window or in a different browser.`
package/src/version.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '5.4.0-next.ebc95cf01337'
4
+ export const version = '5.4.0'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-08-13T15:10:11.500Z',
8
- patch: '2026-08-13T15:10:11.500Z',
7
+ minor: '2026-09-02T11:04:02.901Z',
8
+ patch: '2026-09-02T11:04:02.901Z',
9
9
  }
@@ -1,5 +0,0 @@
1
- describe('Polyline2d', () => {
2
- it('should be tested', () => {
3
- expect(true).toBe(true)
4
- })
5
- })