@tldraw/editor 5.4.0-canary.042da660fa66 → 5.4.0-canary.059d049864ed

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 (155) hide show
  1. package/dist-cjs/index.d.ts +31 -1
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/components/MenuClickCapture.js +6 -8
  4. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  5. package/dist-cjs/lib/components/default-components/CanvasOverlays.js +1 -3
  6. package/dist-cjs/lib/components/default-components/CanvasOverlays.js.map +2 -2
  7. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +1 -1
  8. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +1 -1
  9. package/dist-cjs/lib/config/TLUserPreferences.js +6 -2
  10. package/dist-cjs/lib/config/TLUserPreferences.js.map +2 -2
  11. package/dist-cjs/lib/config/createTLStore.js +1 -1
  12. package/dist-cjs/lib/config/createTLStore.js.map +2 -2
  13. package/dist-cjs/lib/editor/Editor.js +139 -268
  14. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  15. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +8 -8
  16. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  17. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +2 -1
  18. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +79 -6
  20. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +0 -2
  22. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +1 -0
  24. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/overlays/OverlayManager.js +3 -7
  26. package/dist-cjs/lib/editor/overlays/OverlayManager.js.map +2 -2
  27. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js +0 -1
  28. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js.map +2 -2
  29. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js +3 -1
  30. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js.map +2 -2
  31. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js +1 -0
  32. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js.map +2 -2
  33. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +1 -1
  34. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
  35. package/dist-cjs/lib/editor/tools/StateNode.js +3 -2
  36. package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
  37. package/dist-cjs/lib/globals/menus.js +9 -4
  38. package/dist-cjs/lib/globals/menus.js.map +2 -2
  39. package/dist-cjs/lib/hooks/useCanvasEvents.js +2 -0
  40. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  41. package/dist-cjs/lib/hooks/useDocumentEvents.js +9 -7
  42. package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
  43. package/dist-cjs/lib/hooks/useLocalStore.js +10 -6
  44. package/dist-cjs/lib/hooks/useLocalStore.js.map +2 -2
  45. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +1 -0
  46. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
  47. package/dist-cjs/lib/hooks/usePeerIds.js +2 -1
  48. package/dist-cjs/lib/hooks/usePeerIds.js.map +2 -2
  49. package/dist-cjs/lib/primitives/Box.js +2 -1
  50. package/dist-cjs/lib/primitives/Box.js.map +2 -2
  51. package/dist-cjs/lib/primitives/Vec.js +4 -3
  52. package/dist-cjs/lib/primitives/Vec.js.map +2 -2
  53. package/dist-cjs/lib/primitives/utils.js +2 -1
  54. package/dist-cjs/lib/primitives/utils.js.map +2 -2
  55. package/dist-cjs/lib/utils/rotation.js +1 -9
  56. package/dist-cjs/lib/utils/rotation.js.map +2 -2
  57. package/dist-cjs/version.js +3 -3
  58. package/dist-cjs/version.js.map +1 -1
  59. package/dist-esm/index.d.mts +31 -1
  60. package/dist-esm/index.mjs +1 -1
  61. package/dist-esm/lib/components/MenuClickCapture.mjs +6 -8
  62. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  63. package/dist-esm/lib/components/default-components/CanvasOverlays.mjs +1 -3
  64. package/dist-esm/lib/components/default-components/CanvasOverlays.mjs.map +2 -2
  65. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +1 -1
  66. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +1 -1
  67. package/dist-esm/lib/config/TLUserPreferences.mjs +6 -2
  68. package/dist-esm/lib/config/TLUserPreferences.mjs.map +2 -2
  69. package/dist-esm/lib/config/createTLStore.mjs +1 -1
  70. package/dist-esm/lib/config/createTLStore.mjs.map +2 -2
  71. package/dist-esm/lib/editor/Editor.mjs +147 -269
  72. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  73. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +9 -9
  74. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  75. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +2 -1
  76. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  77. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +81 -8
  78. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
  79. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +0 -2
  80. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
  81. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +1 -0
  82. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  83. package/dist-esm/lib/editor/overlays/OverlayManager.mjs +3 -7
  84. package/dist-esm/lib/editor/overlays/OverlayManager.mjs.map +2 -2
  85. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs +0 -1
  86. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs.map +2 -2
  87. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs +3 -1
  88. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs.map +2 -2
  89. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs +1 -0
  90. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs.map +2 -2
  91. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +1 -1
  92. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
  93. package/dist-esm/lib/editor/tools/StateNode.mjs +3 -2
  94. package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
  95. package/dist-esm/lib/globals/menus.mjs +9 -4
  96. package/dist-esm/lib/globals/menus.mjs.map +2 -2
  97. package/dist-esm/lib/hooks/useCanvasEvents.mjs +2 -0
  98. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  99. package/dist-esm/lib/hooks/useDocumentEvents.mjs +9 -7
  100. package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
  101. package/dist-esm/lib/hooks/useLocalStore.mjs +11 -7
  102. package/dist-esm/lib/hooks/useLocalStore.mjs.map +2 -2
  103. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +1 -0
  104. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
  105. package/dist-esm/lib/hooks/usePeerIds.mjs +2 -1
  106. package/dist-esm/lib/hooks/usePeerIds.mjs.map +2 -2
  107. package/dist-esm/lib/primitives/Box.mjs +2 -1
  108. package/dist-esm/lib/primitives/Box.mjs.map +2 -2
  109. package/dist-esm/lib/primitives/Vec.mjs +4 -3
  110. package/dist-esm/lib/primitives/Vec.mjs.map +2 -2
  111. package/dist-esm/lib/primitives/utils.mjs +2 -1
  112. package/dist-esm/lib/primitives/utils.mjs.map +2 -2
  113. package/dist-esm/lib/utils/rotation.mjs +1 -9
  114. package/dist-esm/lib/utils/rotation.mjs.map +2 -2
  115. package/dist-esm/version.mjs +3 -3
  116. package/dist-esm/version.mjs.map +1 -1
  117. package/package.json +7 -7
  118. package/src/lib/components/MenuClickCapture.tsx +19 -18
  119. package/src/lib/components/default-components/CanvasOverlays.tsx +1 -3
  120. package/src/lib/components/default-components/DefaultErrorFallback.tsx +1 -1
  121. package/src/lib/config/TLUserPreferences.test.ts +18 -1
  122. package/src/lib/config/TLUserPreferences.ts +8 -4
  123. package/src/lib/config/createTLStore.ts +1 -1
  124. package/src/lib/editor/Editor.ts +196 -341
  125. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +9 -9
  126. package/src/lib/editor/managers/FontManager/FontManager.ts +2 -1
  127. package/src/lib/editor/managers/InputsManager/InputsManager.test.ts +199 -3
  128. package/src/lib/editor/managers/InputsManager/InputsManager.ts +128 -9
  129. package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +0 -2
  130. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +1 -0
  131. package/src/lib/editor/overlays/OverlayManager.ts +3 -7
  132. package/src/lib/editor/overlays/strokeShapeIndicators.ts +0 -1
  133. package/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx +5 -1
  134. package/src/lib/editor/shapes/shared/getPerfectDashProps.ts +3 -0
  135. package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +4 -1
  136. package/src/lib/editor/tools/StateNode.test.ts +64 -0
  137. package/src/lib/editor/tools/StateNode.ts +12 -2
  138. package/src/lib/globals/menus.test.ts +33 -0
  139. package/src/lib/globals/menus.ts +11 -4
  140. package/src/lib/hooks/useCanvasEvents.ts +2 -0
  141. package/src/lib/hooks/useDocumentEvents.ts +14 -7
  142. package/src/lib/hooks/useLocalStore.ts +16 -8
  143. package/src/lib/hooks/usePassThroughWheelEvents.ts +3 -0
  144. package/src/lib/hooks/usePeerIds.ts +2 -1
  145. package/src/lib/primitives/Box.test.ts +9 -1
  146. package/src/lib/primitives/Box.ts +3 -1
  147. package/src/lib/primitives/Vec.test.ts +10 -0
  148. package/src/lib/primitives/Vec.ts +4 -4
  149. package/src/lib/primitives/utils.test.ts +10 -1
  150. package/src/lib/primitives/utils.ts +4 -1
  151. package/src/lib/test/useCanvasEvents.test.tsx +74 -0
  152. package/src/lib/test/useDocumentEvents.test.tsx +31 -0
  153. package/src/lib/test/usePassThroughWheelEvents.test.tsx +25 -2
  154. package/src/lib/utils/rotation.ts +4 -12
  155. package/src/version.ts +3 -3
@@ -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,
@@ -29,8 +29,12 @@ export function useDocumentEvents() {
29
29
  // re-dispatched, which would lead to an infinite loop.
30
30
  if ((e as any).isSpecialRedispatchedEvent) return
31
31
  preventDefault(e)
32
- e.stopPropagation()
33
32
  const cvs = container.querySelector('.tl-canvas')
33
+ // A drop on the canvas itself already reaches the canvas (and any shape's own React
34
+ // drop handlers) by bubbling; stopping it here would hide it from React, which
35
+ // delegates from the root above this container.
36
+ if (cvs?.contains(e.target as Node | null)) return
37
+ e.stopPropagation()
34
38
  if (!cvs) return
35
39
  const newEvent = new DragEvent(e.type, e)
36
40
  ;(newEvent as any).isSpecialRedispatchedEvent = true
@@ -277,10 +281,13 @@ export function useDocumentEvents() {
277
281
 
278
282
  container.addEventListener('wheel', handleWheel, { passive: false })
279
283
 
284
+ // Not the shared `preventDefault` reference: the DOM dedupes identical listeners, so with two
285
+ // editors in one document the first cleanup would otherwise remove it for both.
286
+ const handleGesture = (e: Event) => preventDefault(e)
280
287
  const ownerDoc = container.ownerDocument
281
- ownerDoc.addEventListener('gesturestart', preventDefault)
282
- ownerDoc.addEventListener('gesturechange', preventDefault)
283
- ownerDoc.addEventListener('gestureend', preventDefault)
288
+ ownerDoc.addEventListener('gesturestart', handleGesture)
289
+ ownerDoc.addEventListener('gesturechange', handleGesture)
290
+ ownerDoc.addEventListener('gestureend', handleGesture)
284
291
 
285
292
  container.addEventListener('keydown', handleKeyDown)
286
293
  container.addEventListener('keyup', handleKeyUp)
@@ -290,9 +297,9 @@ export function useDocumentEvents() {
290
297
 
291
298
  container.removeEventListener('wheel', handleWheel)
292
299
 
293
- ownerDoc.removeEventListener('gesturestart', preventDefault)
294
- ownerDoc.removeEventListener('gesturechange', preventDefault)
295
- ownerDoc.removeEventListener('gestureend', preventDefault)
300
+ ownerDoc.removeEventListener('gesturestart', handleGesture)
301
+ ownerDoc.removeEventListener('gesturechange', handleGesture)
302
+ ownerDoc.removeEventListener('gestureend', handleGesture)
296
303
 
297
304
  container.removeEventListener('keydown', handleKeyDown)
298
305
  container.removeEventListener('keyup', handleKeyUp)
@@ -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
 
@@ -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,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,31 @@
1
+ import { act, render } from '@testing-library/react'
2
+ import { vi } from 'vitest'
3
+ import { createTLStore } from '../config/createTLStore'
4
+ import { TldrawEditor } from '../TldrawEditor'
5
+
6
+ describe('useDocumentEvents drop handling', () => {
7
+ // The container's native drop listener used to stop propagation before the event reached
8
+ // React's root, so React onDrop handlers inside the canvas never fired.
9
+ it('lets a drop inside the canvas reach React drop handlers there', async () => {
10
+ const onDrop = vi.fn((e: React.DragEvent) => e.stopPropagation())
11
+ function DropTarget() {
12
+ return <div data-testid="drop-target" onDrop={onDrop} />
13
+ }
14
+ const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
15
+ await act(async () => {
16
+ render(<TldrawEditor store={store} components={{ OnTheCanvas: DropTarget }} />)
17
+ })
18
+
19
+ const target = document.querySelector('[data-testid="drop-target"]')!
20
+ expect(target.closest('.tl-canvas')).not.toBeNull()
21
+ const event = new Event('drop', { bubbles: true, cancelable: true })
22
+ Object.defineProperty(event, 'dataTransfer', { value: { files: [], getData: () => '' } })
23
+ act(() => {
24
+ target.dispatchEvent(event)
25
+ })
26
+
27
+ expect(onDrop).toHaveBeenCalledTimes(1)
28
+ // the browser default (navigating to the dropped file) is still prevented
29
+ expect(event.defaultPrevented).toBe(true)
30
+ })
31
+ })
@@ -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"]')!
@@ -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 {
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-canary.042da660fa66'
4
+ export const version = '5.4.0-canary.059d049864ed'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-08-19T12:47:12.111Z',
8
- patch: '2026-08-19T12:47:12.111Z',
7
+ minor: '2026-08-22T06:21:04.451Z',
8
+ patch: '2026-08-22T06:21:04.451Z',
9
9
  }