@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
@@ -1,5 +1,5 @@
1
- import { TLAsset, TLAssetStore, TLStoreSnapshot } from '@tldraw/tlschema'
2
- import { WeakCache } from '@tldraw/utils'
1
+ import { TLAssetId, TLAssetStore, TLStoreSnapshot } from '@tldraw/tlschema'
2
+ import { noop } from '@tldraw/utils'
3
3
  import { useEffect } from 'react'
4
4
  import { TLStoreOptions, createTLStore } from '../config/createTLStore'
5
5
  import { TLEditorSnapshot } from '../config/TLEditorSnapshot'
@@ -33,7 +33,9 @@ export function useLocalStore(
33
33
 
34
34
  setState({ status: 'loading' })
35
35
 
36
- const objectURLCache = new WeakCache<TLAsset, Promise<string | null>>()
36
+ // Keyed by asset id rather than record identity so that a record update doesn't mint
37
+ // another object url for the same blob; every url minted here is revoked on cleanup.
38
+ const objectURLCache = new Map<TLAssetId, Promise<string | null>>()
37
39
  const assets: TLAssetStore = {
38
40
  upload: async (asset, file) => {
39
41
  await client.db.storeAsset(asset.id, file)
@@ -43,11 +45,14 @@ export function useLocalStore(
43
45
  if (!asset.props.src) return null
44
46
 
45
47
  if (asset.props.src.startsWith('asset:')) {
46
- return await objectURLCache.get(asset, async () => {
47
- const blob = await client.db.getAsset(asset.id)
48
- if (!blob) return null
49
- return URL.createObjectURL(blob)
50
- })
48
+ let objectURL = objectURLCache.get(asset.id)
49
+ if (!objectURL) {
50
+ objectURL = client.db
51
+ .getAsset(asset.id)
52
+ .then((blob) => (blob ? URL.createObjectURL(blob) : null))
53
+ objectURLCache.set(asset.id, objectURL)
54
+ }
55
+ return await objectURL
51
56
  }
52
57
 
53
58
  return asset.props.src
@@ -78,6 +83,9 @@ export function useLocalStore(
78
83
  return () => {
79
84
  isClosed = true
80
85
  client.close()
86
+ for (const objectURL of objectURLCache.values()) {
87
+ objectURL.then((url) => url && URL.revokeObjectURL(url), noop)
88
+ }
81
89
  }
82
90
  }, [options, setState])
83
91
 
@@ -70,6 +70,9 @@ export function usePassThroughWheelEvents(ref: RefObject<HTMLElement | null>) {
70
70
  const newEvent = new WheelEvent('wheel', e as any)
71
71
  ;(newEvent as any).isSpecialRedispatchedEvent = true
72
72
  cvs.dispatchEvent(newEvent)
73
+ // Flag the original too: it keeps bubbling, and an enclosing pass-through root (the
74
+ // navigation panel around the minimap) would otherwise redispatch it a second time.
75
+ ;(e as any).isSpecialRedispatchedEvent = true
73
76
  })
74
77
 
75
78
  // The element the listener is currently on, which isn't necessarily `ref.current`: a `RefObject`
@@ -1,4 +1,5 @@
1
1
  import { useComputed, useValue } from '@tldraw/state-react'
2
+ import { areArraysShallowEqual } from '@tldraw/utils'
2
3
  import { uniq } from '../utils/uniq'
3
4
  import { useEditor } from './useEditor'
4
5
 
@@ -17,7 +18,7 @@ export function usePeerIds() {
17
18
  const $userIds = useComputed(
18
19
  'userIds',
19
20
  () => uniq(editor.getVisibleCollaborators().map((p) => p.userId)).sort(),
20
- { isEqual: (a, b) => a.join(',') === b.join?.(',') },
21
+ { isEqual: areArraysShallowEqual },
21
22
  [editor]
22
23
  )
23
24
 
@@ -316,6 +316,42 @@ describe('LicenseManager', () => {
316
316
  expect(result.isDomainValid).toBe(true)
317
317
  })
318
318
 
319
+ it('Fails if the wildcard domain is a prefix of the current hostname', async () => {
320
+ // @ts-ignore
321
+ delete window.location
322
+ // @ts-ignore
323
+ window.location = new URL('https://sub.example.com.other.io')
324
+
325
+ const permissiveHostsInfo = JSON.parse(STANDARD_LICENSE_INFO)
326
+ permissiveHostsInfo[PROPERTIES.HOSTS] = ['*.example.com']
327
+ const permissiveLicenseKey = await generateLicenseKey(
328
+ JSON.stringify(permissiveHostsInfo),
329
+ keyPair
330
+ )
331
+ const result = (await licenseManager.getLicenseFromKey(
332
+ permissiveLicenseKey
333
+ )) as ValidLicenseKeyResult
334
+ expect(result.isDomainValid).toBe(false)
335
+ })
336
+
337
+ it('Fails if the current hostname only ends with the wildcard domain', async () => {
338
+ // @ts-ignore
339
+ delete window.location
340
+ // @ts-ignore
341
+ window.location = new URL('https://notexample.com')
342
+
343
+ const permissiveHostsInfo = JSON.parse(STANDARD_LICENSE_INFO)
344
+ permissiveHostsInfo[PROPERTIES.HOSTS] = ['*.example.com']
345
+ const permissiveLicenseKey = await generateLicenseKey(
346
+ JSON.stringify(permissiveHostsInfo),
347
+ keyPair
348
+ )
349
+ const result = (await licenseManager.getLicenseFromKey(
350
+ permissiveLicenseKey
351
+ )) as ValidLicenseKeyResult
352
+ expect(result.isDomainValid).toBe(false)
353
+ })
354
+
319
355
  it('Fails if has a subdomain wildcard isnt for the same base domain', async () => {
320
356
  // @ts-ignore
321
357
  delete window.location
@@ -422,6 +458,107 @@ describe('LicenseManager', () => {
422
458
  expect(result.isDomainValid).toBe(true)
423
459
  })
424
460
 
461
+ it('Is not development mode on a custom protocol', async () => {
462
+ process.env.NODE_ENV = 'production'
463
+ try {
464
+ // @ts-ignore
465
+ delete window.location
466
+ // @ts-ignore
467
+ window.location = new URL('app-bundle://app/index.html')
468
+
469
+ const nativeLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
470
+ nativeLicenseInfo[PROPERTIES.FLAGS] = FLAGS.NATIVE_LICENSE
471
+ nativeLicenseInfo[PROPERTIES.HOSTS] = ['app-bundle:']
472
+ const nativeLicenseKey = await generateLicenseKey(
473
+ JSON.stringify(nativeLicenseInfo),
474
+ keyPair
475
+ )
476
+ const nativeLicenseManager = new LicenseManager('', keyPair.publicKey)
477
+ const result = (await nativeLicenseManager.getLicenseFromKey(
478
+ nativeLicenseKey
479
+ )) as ValidLicenseKeyResult
480
+ expect(result).toMatchObject({
481
+ isLicenseParseable: true,
482
+ isNativeLicense: true,
483
+ isDomainValid: true,
484
+ isDevelopment: false,
485
+ })
486
+ expect(getLicenseState(result, () => {}, result.isDevelopment)).toBe('licensed')
487
+ } finally {
488
+ process.env.NODE_ENV = 'test'
489
+ }
490
+ })
491
+
492
+ it('Validates the domain on a custom protocol', async () => {
493
+ process.env.NODE_ENV = 'production'
494
+ try {
495
+ // @ts-ignore
496
+ delete window.location
497
+ // @ts-ignore
498
+ window.location = new URL('app-bundle://app/index.html')
499
+
500
+ const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
501
+ const customProtocolLicenseManager = new LicenseManager('', keyPair.publicKey)
502
+ const result = (await customProtocolLicenseManager.getLicenseFromKey(
503
+ licenseKey
504
+ )) as ValidLicenseKeyResult
505
+ expect(result).toMatchObject({ isDomainValid: false, isDevelopment: false })
506
+ expect(getLicenseState(result, () => {}, result.isDevelopment)).toBe(
507
+ 'unlicensed-production'
508
+ )
509
+ } finally {
510
+ process.env.NODE_ENV = 'test'
511
+ }
512
+ })
513
+
514
+ it('Is development mode over http on a non-loopback host', async () => {
515
+ process.env.NODE_ENV = 'production'
516
+ try {
517
+ // @ts-ignore
518
+ delete window.location
519
+ // @ts-ignore
520
+ window.location = new URL('http://www.example.com')
521
+
522
+ const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
523
+ const httpLicenseManager = new LicenseManager('', keyPair.publicKey)
524
+ const result = (await httpLicenseManager.getLicenseFromKey(
525
+ licenseKey
526
+ )) as ValidLicenseKeyResult
527
+ expect(result.isDevelopment).toBe(true)
528
+ } finally {
529
+ process.env.NODE_ENV = 'test'
530
+ }
531
+ })
532
+
533
+ it.each([
534
+ ['http://localhost:5173/', true],
535
+ ['http://127.0.0.1:3000/', true],
536
+ ['https://localhost:8443/', true],
537
+ ['https://[::1]:8443/', true],
538
+ ['http://192.168.1.5:3000/', true],
539
+ ['http://staging-box/', true],
540
+ ['https://www.example.com/', false],
541
+ ['tauri://localhost/index.html', false],
542
+ ['http://tauri.localhost/index.html', false],
543
+ ['app://tauri.localhost/com.example.app', false],
544
+ ['app-bundle://localhost/index.html', false],
545
+ ])('Development mode at %s is %s', async (href, expected) => {
546
+ process.env.NODE_ENV = 'production'
547
+ try {
548
+ // @ts-ignore
549
+ delete window.location
550
+ // @ts-ignore
551
+ window.location = new URL(href)
552
+
553
+ const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
554
+ const manager = new LicenseManager('', keyPair.publicKey)
555
+ const result = (await manager.getLicenseFromKey(licenseKey)) as ValidLicenseKeyResult
556
+ expect(result.isDevelopment).toBe(expected)
557
+ } finally {
558
+ process.env.NODE_ENV = 'test'
559
+ }
560
+ })
561
+
425
562
  it('Fails if it is a native app with the wrong protocol', async () => {
426
563
  // @ts-ignore
427
564
  delete window.location
@@ -437,6 +574,29 @@ describe('LicenseManager', () => {
437
574
  )) as ValidLicenseKeyResult
438
575
  expect(result.isDomainValid).toBe(false)
439
576
  })
577
+
578
+ it.each(['tauri://localhost/index.html', 'http://tauri.localhost/index.html'])(
579
+ 'Does not license an unrelated key at %s',
580
+ async (href) => {
581
+ process.env.NODE_ENV = 'production'
582
+ try {
583
+ // @ts-ignore
584
+ delete window.location
585
+ // @ts-ignore
586
+ window.location = new URL(href)
587
+
588
+ const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
589
+ const manager = new LicenseManager('', keyPair.publicKey)
590
+ const result = (await manager.getLicenseFromKey(licenseKey)) as ValidLicenseKeyResult
591
+ expect(result).toMatchObject({ isDevelopment: false, isDomainValid: false })
592
+ expect(getLicenseState(result, () => {}, result.isDevelopment)).toBe(
593
+ 'unlicensed-production'
594
+ )
595
+ } finally {
596
+ process.env.NODE_ENV = 'test'
597
+ }
598
+ }
599
+ )
440
600
  })
441
601
 
442
602
  describe('License types and flags', () => {
@@ -174,10 +174,17 @@ export class LicenseManager {
174
174
  }
175
175
 
176
176
  private getIsDevelopment() {
177
- // If we are using https on a non-loopback domain we assume it's a production env and a development one otherwise
177
+ const protocol = window.location.protocol
178
+ const hostname = window.location.hostname
179
+
180
+ // Tauri uses `tauri://localhost` on macOS and Linux and `http://tauri.localhost` on Windows.
181
+ if (hostname.toLowerCase().endsWith('.localhost')) {
182
+ return process.env.NODE_ENV !== 'production'
183
+ }
184
+
178
185
  return (
179
- !['https:', 'vscode-webview:'].includes(window.location.protocol) ||
180
- this.isLoopbackHost(window.location.hostname) ||
186
+ protocol === 'http:' ||
187
+ (protocol === 'https:' && this.isLoopbackHost(hostname)) ||
181
188
  process.env.NODE_ENV !== 'production'
182
189
  )
183
190
  }
@@ -407,7 +414,9 @@ export class LicenseManager {
407
414
 
408
415
  // Glob testing, we only support '*.somedomain.com' right now.
409
416
  if (host.includes('*')) {
410
- const globToRegex = new RegExp(host.replace(/\*/g, '.*?'))
417
+ const globToRegex = new RegExp(
418
+ normalizedHostOrUrlRegex.replace(/\./g, '\\.').replace(/\*/g, '.*?') + '$'
419
+ )
411
420
  return globToRegex.test(currentHostname) || globToRegex.test(`www.${currentHostname}`)
412
421
  }
413
422
 
@@ -1,13 +1,34 @@
1
1
  import { useValue } from '@tldraw/state-react'
2
2
  import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
3
+ import { useMaybeEditor } from '../hooks/useEditor'
3
4
  import { LicenseManager } from './LicenseManager'
4
5
 
5
6
  /** @internal */
6
- export const LicenseContext = createContext({} as LicenseManager)
7
+ export const LicenseContext = createContext<LicenseManager | null>(null)
7
8
 
8
9
  /** @internal */
9
- export function useLicenseContext() {
10
- return useContext(LicenseContext)
10
+ export function useLicenseContext(): LicenseManager {
11
+ const licenseManager = useMaybeLicenseManager()
12
+ if (!licenseManager) {
13
+ throw new Error(
14
+ 'useLicenseContext must be used inside of the <Tldraw /> or <TldrawEditor /> components, or inside an <EditorProvider /> wrapping an editor created by them'
15
+ )
16
+ }
17
+ return licenseManager
18
+ }
19
+
20
+ /**
21
+ * Returns the license manager for the current editor, or `null` if there is none. Reads the
22
+ * license context when inside `<TldrawEditor />`, and otherwise falls back to the license manager
23
+ * of the nearest editor, so UI mounted outside the editor tree (via `EditorProvider`) resolves the
24
+ * same license as the editor it belongs to.
25
+ *
26
+ * @internal
27
+ */
28
+ export function useMaybeLicenseManager(): LicenseManager | null {
29
+ const licenseManager = useContext(LicenseContext)
30
+ const editor = useMaybeEditor()
31
+ return licenseManager ?? editor?.licenseManager ?? null
11
32
  }
12
33
 
13
34
  function shouldHideEditorAfterDelay(licenseState: string): boolean {
@@ -8,16 +8,18 @@ export function useLicenseManagerState(licenseManager: LicenseManager): LicenseS
8
8
 
9
9
  /**
10
10
  * Reactively reads whether a licensable feature is enabled for the current license. Re-renders when
11
- * license validation resolves.
11
+ * license validation resolves. Returns `false` when there is no license manager, so gated UI stays
12
+ * hidden when mounted without an editor or license context.
12
13
  *
13
14
  * @internal
14
15
  */
15
16
  export function useLicenseFeatureFlag(
16
- licenseManager: LicenseManager,
17
+ licenseManager: LicenseManager | null,
17
18
  feature: LicenseFeatureName
18
19
  ): boolean {
19
- return useValue('licenseFeature', () => licenseManager.isFeatureEnabled(feature), [
20
- licenseManager,
21
- feature,
22
- ])
20
+ return useValue(
21
+ 'licenseFeature',
22
+ () => (licenseManager ? licenseManager.isFeatureEnabled(feature) : false),
23
+ [licenseManager, feature]
24
+ )
23
25
  }
@@ -1,4 +1,4 @@
1
- import { Box } from './Box'
1
+ import { Box, rotateSelectionHandle } from './Box'
2
2
  import { Vec } from './Vec'
3
3
 
4
4
  describe('Box', () => {
@@ -759,3 +759,11 @@ describe('Box', () => {
759
759
  })
760
760
  })
761
761
  })
762
+
763
+ describe('rotateSelectionHandle', () => {
764
+ it('wraps around for negative rotations', () => {
765
+ expect(rotateSelectionHandle('top', Math.PI / 2)).toBe('right')
766
+ expect(rotateSelectionHandle('top', -Math.PI / 2)).toBe('left')
767
+ expect(rotateSelectionHandle('top_left', -Math.PI)).toBe('bottom_right')
768
+ })
769
+ })
@@ -678,7 +678,9 @@ export function rotateSelectionHandle(handle: SelectionHandle, rotation: number)
678
678
  const numSteps = Math.round(rotation / (PI / 4))
679
679
 
680
680
  const currentIndex = ORDERED_SELECTION_HANDLES.indexOf(handle)
681
- return ORDERED_SELECTION_HANDLES[(currentIndex + numSteps) % ORDERED_SELECTION_HANDLES.length]
681
+ // numSteps is negative for negative rotations, so wrap the index into range
682
+ const n = ORDERED_SELECTION_HANDLES.length
683
+ return ORDERED_SELECTION_HANDLES[(((currentIndex + numSteps) % n) + n) % n]
682
684
  }
683
685
 
684
686
  /** @public */
@@ -336,6 +336,16 @@ describe('Vec.Slope', () => {
336
336
  expect(Vec.Slope(new Vec(0, 0), new Vec(50, 100))).toEqual(2)
337
337
  expect(Vec.Slope(new Vec(0, 0), new Vec(-50, 100))).toEqual(-2)
338
338
  expect(Vec.Slope(new Vec(123, 456), new Vec(789, 24))).toEqual(-0.6486486486486487)
339
+ // A.x happened to equal B.y here, which used to trip the vertical guard
340
+ expect(Vec.Slope(new Vec(1, 0), new Vec(0, 1))).toEqual(-1)
341
+ expect(Vec.Slope(new Vec(5, 0), new Vec(5, 10))).toBeNaN()
342
+ })
343
+ })
344
+
345
+ describe('Vec.cross', () => {
346
+ it('Matches the static Vec.Cross.', () => {
347
+ expect(Vec.Cross(new Vec(1, 2, 3), new Vec(4, 5, 6))).toMatchObject({ x: -3, y: 6 })
348
+ expect(new Vec(1, 2, 3).cross(new Vec(4, 5, 6))).toMatchObject({ x: -3, y: 6 })
339
349
  })
340
350
  })
341
351
 
@@ -164,9 +164,9 @@ export class Vec {
164
164
  }
165
165
 
166
166
  cross(V: VecLike) {
167
- this.x = this.y * V.z! - this.z * V.y
168
- this.y = this.z * V.x - this.x * V.z!
169
- // this.z = this.x * V.y - this.y * V.x
167
+ const { x, y, z } = this
168
+ this.x = y * V.z! - z * V.y
169
+ this.y = z * V.x - x * V.z!
170
170
  return this
171
171
  }
172
172
 
@@ -497,7 +497,7 @@ export class Vec {
497
497
  }
498
498
 
499
499
  static Slope(A: VecLike, B: VecLike): number {
500
- if (A.x === B.y) return NaN
500
+ if (A.x === B.x) return NaN
501
501
  return (A.y - B.y) / (A.x - B.x)
502
502
  }
503
503
 
@@ -1,5 +1,228 @@
1
- describe('Arc2d', () => {
2
- it('should be tested', () => {
3
- expect(true).toBe(true)
1
+ import { Vec } from '../Vec'
2
+ import { Arc2d } from './Arc2d'
3
+
4
+ // A quarter circle of radius 10 centred at the origin, from (10, 0) clockwise (in screen
5
+ // space, i.e. with increasing angle) to (0, 10).
6
+ const quarter = () =>
7
+ new Arc2d({
8
+ center: new Vec(0, 0),
9
+ start: new Vec(10, 0),
10
+ end: new Vec(0, 10),
11
+ sweepFlag: 1,
12
+ largeArcFlag: 0,
13
+ })
14
+
15
+ // The complementary three-quarter arc between the same points, going the other way round.
16
+ const threeQuarter = () =>
17
+ new Arc2d({
18
+ center: new Vec(0, 0),
19
+ start: new Vec(10, 0),
20
+ end: new Vec(0, 10),
21
+ sweepFlag: 0,
22
+ largeArcFlag: 1,
23
+ })
24
+
25
+ function expectVec(actual: Vec, expected: { x: number; y: number }) {
26
+ expect(actual.x).toBeCloseTo(expected.x)
27
+ expect(actual.y).toBeCloseTo(expected.y)
28
+ }
29
+
30
+ describe('Arc2d construction', () => {
31
+ it('is never closed or filled', () => {
32
+ expect(quarter()).toMatchObject({ isClosed: false, isFilled: false })
33
+ })
34
+
35
+ it('throws when start and end are the same point', () => {
36
+ expect(
37
+ () =>
38
+ new Arc2d({
39
+ center: new Vec(0, 0),
40
+ start: new Vec(10, 0),
41
+ end: new Vec(10, 0),
42
+ sweepFlag: 1,
43
+ largeArcFlag: 0,
44
+ })
45
+ ).toThrow('Arc must have different start and end points.')
46
+ })
47
+ })
48
+
49
+ describe('Arc2d.getLength', () => {
50
+ it('is the arc measure times the radius', () => {
51
+ expect(quarter().length).toBeCloseTo((Math.PI / 2) * 10)
52
+ expect(threeQuarter().length).toBeCloseTo(((3 * Math.PI) / 2) * 10)
53
+ })
54
+
55
+ it('is half a circumference for a semicircle in either direction', () => {
56
+ const semi = new Arc2d({
57
+ center: new Vec(25, 25),
58
+ start: new Vec(25, 50),
59
+ end: new Vec(25, 0),
60
+ sweepFlag: 1,
61
+ largeArcFlag: 1,
62
+ })
63
+ expect(semi.length).toBeCloseTo(Math.PI * 25)
64
+ })
65
+ })
66
+
67
+ describe('Arc2d.getVertices', () => {
68
+ it('samples at least eight segments from start to end', () => {
69
+ const { vertices } = quarter()
70
+ expect(vertices.length).toBe(9)
71
+ expectVec(vertices[0], { x: 10, y: 0 })
72
+ expectVec(vertices[4], { x: 10 / Math.SQRT2, y: 10 / Math.SQRT2 })
73
+ expectVec(vertices[8], { x: 0, y: 10 })
74
+ })
75
+
76
+ it('keeps every vertex on the circle', () => {
77
+ for (const v of quarter().vertices) {
78
+ expect(Vec.Dist(v, new Vec(0, 0))).toBeCloseTo(10)
79
+ }
80
+ })
81
+
82
+ it('walks the long way round for a large arc', () => {
83
+ const { vertices } = threeQuarter()
84
+ expect(vertices.length).toBe(9)
85
+ expectVec(vertices[0], { x: 10, y: 0 })
86
+ // the midpoint of the long arc is opposite the midpoint of the short arc
87
+ expectVec(vertices[4], { x: -10 / Math.SQRT2, y: -10 / Math.SQRT2 })
88
+ expectVec(vertices[8], { x: 0, y: 10 })
89
+ })
90
+
91
+ it('samples more vertices for longer arcs', () => {
92
+ const big = new Arc2d({
93
+ center: new Vec(0, 0),
94
+ start: new Vec(100, 0),
95
+ end: new Vec(0, 100),
96
+ sweepFlag: 1,
97
+ largeArcFlag: 0,
98
+ })
99
+ // length is 50π ≈ 157, one vertex per 20 units -> 8 segments
100
+ expect(big.vertices.length).toBe(9)
101
+ const bigger = new Arc2d({
102
+ center: new Vec(0, 0),
103
+ start: new Vec(200, 0),
104
+ end: new Vec(0, 200),
105
+ sweepFlag: 1,
106
+ largeArcFlag: 0,
107
+ })
108
+ // length is 100π ≈ 314 -> 16 segments
109
+ expect(bigger.vertices.length).toBe(17)
110
+ })
111
+ })
112
+
113
+ describe('Arc2d.bounds', () => {
114
+ it('is the box around the sampled vertices', () => {
115
+ const { bounds } = quarter()
116
+ expect(bounds.x).toBeCloseTo(0)
117
+ expect(bounds.y).toBeCloseTo(0)
118
+ expect(bounds.w).toBeCloseTo(10)
119
+ expect(bounds.h).toBeCloseTo(10)
120
+ })
121
+ })
122
+
123
+ describe('Arc2d.nearestPoint', () => {
124
+ it('projects radially onto the arc when the angle is within the sweep', () => {
125
+ expectVec(quarter().nearestPoint(new Vec(5, 5)), { x: 10 / Math.SQRT2, y: 10 / Math.SQRT2 })
126
+ expectVec(quarter().nearestPoint(new Vec(20, 20)), {
127
+ x: 10 / Math.SQRT2,
128
+ y: 10 / Math.SQRT2,
129
+ })
130
+ })
131
+
132
+ it('returns the start point for angles before the arc', () => {
133
+ expect(quarter().nearestPoint(new Vec(20, -5))).toMatchObject({ x: 10, y: 0 })
134
+ expect(quarter().nearestPoint(new Vec(20, 0))).toMatchObject({ x: 10, y: 0 })
135
+ })
136
+
137
+ it('returns the end point for angles after the arc', () => {
138
+ expect(quarter().nearestPoint(new Vec(-5, 20))).toMatchObject({ x: 0, y: 10 })
139
+ expect(quarter().nearestPoint(new Vec(0, 20))).toMatchObject({ x: 0, y: 10 })
140
+ })
141
+
142
+ it('returns the start point for the centre itself', () => {
143
+ expect(quarter().nearestPoint(new Vec(0, 0))).toMatchObject({ x: 10, y: 0 })
144
+ })
145
+
146
+ it('projects onto the long arc', () => {
147
+ expectVec(threeQuarter().nearestPoint(new Vec(-20, 0)), { x: -10, y: 0 })
148
+ expectVec(threeQuarter().nearestPoint(new Vec(0, -3)), { x: 0, y: -10 })
149
+ // a point in the gap of the long arc snaps to the nearer end point
150
+ expect(threeQuarter().nearestPoint(new Vec(20, 5))).toMatchObject({ x: 10, y: 0 })
151
+ })
152
+ })
153
+
154
+ describe('Arc2d.distanceToPoint', () => {
155
+ it('measures to the arc or its end points', () => {
156
+ expect(quarter().distanceToPoint(new Vec(20, 20))).toBeCloseTo(Math.hypot(20, 20) - 10)
157
+ expect(quarter().distanceToPoint(new Vec(20, 0))).toBe(10)
158
+ expect(quarter().distanceToPoint(new Vec(0, 0))).toBe(10)
159
+ })
160
+
161
+ it('is never negative because an arc has no interior', () => {
162
+ expect(quarter().distanceToPoint(new Vec(1, 1), true)).toBeGreaterThan(0)
163
+ })
164
+ })
165
+
166
+ describe('Arc2d.hitTestPoint', () => {
167
+ it('hits points on the arc within the margin', () => {
168
+ expect(quarter().hitTestPoint(new Vec(10 / Math.SQRT2, 10 / Math.SQRT2), 0.001)).toBe(true)
169
+ expect(quarter().hitTestPoint(new Vec(10, 0))).toBe(true)
170
+ expect(quarter().hitTestPoint(new Vec(5, 5), 3)).toBe(true)
171
+ expect(quarter().hitTestPoint(new Vec(5, 5), 2)).toBe(false)
172
+ })
173
+
174
+ it('does not hit the sector inside the arc even with hitInside', () => {
175
+ expect(quarter().hitTestPoint(new Vec(5, 5), 0, true)).toBe(false)
176
+ })
177
+ })
178
+
179
+ describe('Arc2d.hitTestLineSegment', () => {
180
+ it('hits a segment crossing the arc', () => {
181
+ expect(quarter().hitTestLineSegment(new Vec(5, 0), new Vec(5, 20))).toBe(true)
182
+ })
183
+
184
+ it('misses a segment crossing the circle outside the arc', () => {
185
+ // crosses the circle at (-5, 8.66), which is at 120° and not on the quarter arc
186
+ expect(quarter().hitTestLineSegment(new Vec(-5, 0), new Vec(-5, 20))).toBe(false)
187
+ // but it is on the complementary arc
188
+ expect(threeQuarter().hitTestLineSegment(new Vec(-5, 0), new Vec(-5, 20))).toBe(true)
189
+ })
190
+
191
+ it('misses a segment that does not reach the circle', () => {
192
+ expect(quarter().hitTestLineSegment(new Vec(20, 20), new Vec(30, 30))).toBe(false)
193
+ expect(quarter().hitTestLineSegment(new Vec(1, 1), new Vec(2, 2))).toBe(false)
194
+ })
195
+
196
+ // Locks in current behaviour, see #10555.
197
+ it('hits crossings just beyond the end points (known quirk)', () => {
198
+ // (5, -8.66) is at -60°, off the quarter arc, but getPointInArcT clamps it to the start
199
+ expect(quarter().hitTestLineSegment(new Vec(5, -20), new Vec(5, 0))).toBe(true)
200
+ })
201
+
202
+ it('checks both directions of the long arc', () => {
203
+ expect(threeQuarter().hitTestLineSegment(new Vec(5, -20), new Vec(5, 0))).toBe(true)
204
+ expect(threeQuarter().hitTestLineSegment(new Vec(5, 0), new Vec(5, 20))).toBe(false)
205
+ })
206
+ })
207
+
208
+ describe('Arc2d.getSvgPathData', () => {
209
+ it('emits a move and an arc command with the flags', () => {
210
+ expect(quarter().getSvgPathData()).toBe('M10, 0 A10 10 0 0 1 0, 10')
211
+ expect(threeQuarter().getSvgPathData()).toBe('M10, 0 A10 10 0 1 0 0, 10')
212
+ })
213
+
214
+ it('omits the move command when not first', () => {
215
+ expect(quarter().getSvgPathData(false)).toBe(' A10 10 0 0 1 0, 10')
216
+ })
217
+
218
+ it('rounds coordinates to two decimals', () => {
219
+ const arc = new Arc2d({
220
+ center: new Vec(0, 0),
221
+ start: new Vec(10, 0),
222
+ end: new Vec(10 / Math.SQRT2, 10 / Math.SQRT2),
223
+ sweepFlag: 1,
224
+ largeArcFlag: 0,
225
+ })
226
+ expect(arc.getSvgPathData()).toBe('M10, 0 A10 10 0 0 1 7.07, 7.07')
4
227
  })
5
228
  })