@tldraw/editor 4.6.0-next.d15997ff5a4b → 4.6.0-next.d8328a2dcc3d

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 (54) hide show
  1. package/dist-cjs/index.d.ts +60 -18
  2. package/dist-cjs/index.js +5 -4
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +4 -2
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/config/{createTLUser.js → createTLCurrentUser.js} +9 -9
  7. package/dist-cjs/lib/config/createTLCurrentUser.js.map +7 -0
  8. package/dist-cjs/lib/config/createTLStore.js +23 -0
  9. package/dist-cjs/lib/config/createTLStore.js.map +2 -2
  10. package/dist-cjs/lib/editor/Editor.js +111 -4
  11. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  12. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +1 -1
  13. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +10 -0
  14. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  15. package/dist-cjs/lib/editor/types/clipboard-types.js.map +1 -1
  16. package/dist-cjs/lib/hooks/useGestureEvents.js +171 -127
  17. package/dist-cjs/lib/hooks/useGestureEvents.js.map +3 -3
  18. package/dist-cjs/version.js +3 -3
  19. package/dist-cjs/version.js.map +1 -1
  20. package/dist-esm/index.d.mts +60 -18
  21. package/dist-esm/index.mjs +9 -4
  22. package/dist-esm/index.mjs.map +2 -2
  23. package/dist-esm/lib/TldrawEditor.mjs +4 -2
  24. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  25. package/dist-esm/lib/config/{createTLUser.mjs → createTLCurrentUser.mjs} +6 -6
  26. package/dist-esm/lib/config/createTLCurrentUser.mjs.map +7 -0
  27. package/dist-esm/lib/config/createTLStore.mjs +27 -1
  28. package/dist-esm/lib/config/createTLStore.mjs.map +2 -2
  29. package/dist-esm/lib/editor/Editor.mjs +113 -4
  30. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  31. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +1 -1
  32. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +10 -0
  33. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  34. package/dist-esm/lib/hooks/useGestureEvents.mjs +171 -127
  35. package/dist-esm/lib/hooks/useGestureEvents.mjs.map +3 -3
  36. package/dist-esm/version.mjs +3 -3
  37. package/dist-esm/version.mjs.map +1 -1
  38. package/editor.css +13 -0
  39. package/package.json +8 -9
  40. package/src/index.ts +6 -1
  41. package/src/lib/TldrawEditor.tsx +8 -6
  42. package/src/lib/config/{createTLUser.ts → createTLCurrentUser.ts} +6 -6
  43. package/src/lib/config/createTLStore.ts +35 -1
  44. package/src/lib/editor/Editor.ts +140 -3
  45. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +2 -2
  46. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +2 -2
  47. package/src/lib/editor/shapes/ShapeUtil.ts +11 -0
  48. package/src/lib/editor/types/clipboard-types.ts +2 -1
  49. package/src/lib/hooks/useGestureEvents.ts +240 -168
  50. package/src/lib/primitives/Box.test.ts +30 -0
  51. package/src/lib/primitives/geometry/Geometry2d.test.ts +21 -0
  52. package/src/version.ts +3 -3
  53. package/dist-cjs/lib/config/createTLUser.js.map +0 -7
  54. package/dist-esm/lib/config/createTLUser.mjs.map +0 -7
@@ -1,5 +1,5 @@
1
1
  import { atom, computed } from '@tldraw/state'
2
- import { TLUser } from '../../../config/createTLUser'
2
+ import { TLCurrentUser } from '../../../config/createTLCurrentUser'
3
3
  import { TLUserPreferences, defaultUserPreferences } from '../../../config/TLUserPreferences'
4
4
  import { getGlobalWindow } from '../../../utils/dom'
5
5
 
@@ -11,7 +11,7 @@ export class UserPreferencesManager {
11
11
  this.disposables.forEach((d) => d())
12
12
  }
13
13
  constructor(
14
- private readonly user: TLUser,
14
+ private readonly user: TLCurrentUser,
15
15
  private readonly inferDarkMode: boolean
16
16
  ) {
17
17
  if (typeof window === 'undefined' || !getGlobalWindow().matchMedia) return
@@ -590,6 +590,17 @@ export abstract class ShapeUtil<Shape extends TLShape = TLShape> {
590
590
  return undefined
591
591
  }
592
592
 
593
+ /**
594
+ * Return user IDs referenced in shape-specific props.
595
+ * Used when copying shapes to include referenced users on the clipboard.
596
+ * Override this if your shape stores user IDs in custom props.
597
+ *
598
+ * @public
599
+ */
600
+ getReferencedUserIds(shape: Shape): string[] {
601
+ return EMPTY_ARRAY
602
+ }
603
+
593
604
  getAriaDescriptor(shape: Shape): string | undefined {
594
605
  return undefined
595
606
  }
@@ -1,5 +1,5 @@
1
1
  import { SerializedSchema } from '@tldraw/store'
2
- import { TLAsset, TLBinding, TLShape, TLShapeId } from '@tldraw/tlschema'
2
+ import { TLAsset, TLBinding, TLShape, TLShapeId, TLUser } from '@tldraw/tlschema'
3
3
 
4
4
  /** @public */
5
5
  export interface TLContent {
@@ -8,4 +8,5 @@ export interface TLContent {
8
8
  rootShapeIds: TLShapeId[]
9
9
  assets: TLAsset[]
10
10
  schema: SerializedSchema
11
+ users?: TLUser[]
11
12
  }
@@ -1,7 +1,6 @@
1
- import type { AnyHandlerEventTypes, EventTypes, GestureKey, Handler } from '@use-gesture/core/types'
2
- import { createUseGesture, pinchAction, wheelAction } from '@use-gesture/react'
3
1
  import * as React from 'react'
4
2
  import { TLWheelEventInfo } from '../editor/types/event-types'
3
+ import { tlenv } from '../globals/environment'
5
4
  import { Vec } from '../primitives/Vec'
6
5
  import { preventDefault } from '../utils/dom'
7
6
  import { isAccelKey } from '../utils/keyboard'
@@ -12,27 +11,27 @@ import { useEditor } from './useEditor'
12
11
 
13
12
  # How does pinching work?
14
13
 
15
- The pinching handler is fired under two circumstances:
14
+ The pinching handler is fired under two circumstances:
16
15
  - when a user is on a MacBook trackpad and is ZOOMING with a two-finger pinch
17
16
  - when a user is on a touch device and is ZOOMING with a two-finger pinch
18
17
  - when a user is on a touch device and is PANNING with two fingers
19
18
 
20
- Zooming is much more expensive than panning (because it causes shapes to render),
21
- so we want to be sure that we don't zoom while two-finger panning.
19
+ Zooming is much more expensive than panning (because it causes shapes to render),
20
+ so we want to be sure that we don't zoom while two-finger panning.
22
21
 
23
22
  In order to do this, we keep track of a "pinchState", which is either:
24
23
  - "zooming"
25
24
  - "panning"
26
25
  - "not sure"
27
26
 
28
- If a user is on a trackpad, the pinchState will be set to "zooming".
27
+ If a user is on a trackpad, the pinchState will be set to "zooming".
29
28
 
30
29
  If the user is on a touch screen, then we start in the "not sure" state and switch back and forth
31
30
  between "zooming", "panning", and "not sure" based on what the user is doing with their fingers.
32
31
 
33
32
  In the "not sure" state, we examine whether the user has moved the center of the gesture far enough
34
33
  to suggest that they're panning; or else that they've moved their fingers further apart or closer
35
- together enough to suggest that they're zooming.
34
+ together enough to suggest that they're zooming.
36
35
 
37
36
  In the "panning" state, we check whether the user's fingers have moved far enough apart to suggest
38
37
  that they're zooming. If they have, we switch to the "zooming" state.
@@ -42,62 +41,37 @@ In the "zooming" state, we just stay zooming—it's not YET possible to switch b
42
41
  todo: compare velocities of change in order to determine whether the user has switched back to panning
43
42
  */
44
43
 
45
- type check<T extends AnyHandlerEventTypes, Key extends GestureKey> = undefined extends T[Key]
46
- ? EventTypes[Key]
47
- : T[Key]
48
- type PinchHandler = Handler<'pinch', check<EventTypes, 'pinch'>>
49
-
50
- const useGesture = createUseGesture([wheelAction, pinchAction])
51
-
52
- /**
53
- * GOTCHA
54
- *
55
- * UseGesture fires a wheel event 140ms after the gesture actually ends, with a momentum-adjusted
56
- * delta. This creates a messed up interaction where after you stop scrolling suddenly the dang page
57
- * jumps a tick. why do they do this? you are asking the wrong person. it seems intentional though.
58
- * anyway we want to ignore that last event, but there's no way to directly detect it so we need to
59
- * keep track of timestamps. Yes this is awful, I am sorry.
60
- */
61
- let lastWheelTime = undefined as undefined | number
62
-
63
- const isWheelEndEvent = (time: number) => {
64
- if (lastWheelTime === undefined) {
65
- lastWheelTime = time
66
- return false
67
- }
68
-
69
- if (time - lastWheelTime > 120 && time - lastWheelTime < 160) {
70
- lastWheelTime = time
71
- return true
72
- }
73
-
74
- lastWheelTime = time
75
- return false
44
+ /** Safari's non-standard GestureEvent */
45
+ interface GestureEvent extends Event {
46
+ scale: number
47
+ rotation: number
48
+ clientX: number
49
+ clientY: number
50
+ shiftKey: boolean
51
+ altKey: boolean
52
+ metaKey: boolean
53
+ ctrlKey: boolean
76
54
  }
77
55
 
78
56
  export function useGestureEvents(ref: React.RefObject<HTMLDivElement | null>) {
79
57
  const editor = useEditor()
80
58
 
81
- const events = React.useMemo(() => {
59
+ React.useEffect(() => {
60
+ const elm = ref.current
61
+ if (!elm) return
62
+
82
63
  let pinchState = 'not sure' as 'not sure' | 'zooming' | 'panning'
83
64
 
84
- const onWheel: Handler<'wheel', WheelEvent> = ({ event }) => {
65
+ // --- Wheel handling ---
66
+
67
+ function onWheel(event: WheelEvent) {
85
68
  if (!editor.getInstanceState().isFocused) {
86
69
  return
87
70
  }
88
71
 
89
72
  pinchState = 'not sure'
90
73
 
91
- if (isWheelEndEvent(Date.now())) {
92
- // ignore wheelEnd events
93
- return
94
- }
95
-
96
- // Awful tht we need to put this logic here, but basically
97
- // we don't want to handle the the wheel event (or call prevent
98
- // default on the evnet) if the user is wheeling over an a shape
99
- // that is scrollable which they're currently editing.
100
-
74
+ // Don't handle wheel events over a scrollable editing shape
101
75
  const editingShapeId = editor.getEditingShapeId()
102
76
  if (editingShapeId) {
103
77
  const shape = editor.getShape(editingShapeId)
@@ -133,43 +107,38 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement | null>) {
133
107
  editor.dispatch(info)
134
108
  }
135
109
 
110
+ // --- Touch pinch handling ---
111
+
136
112
  let initDistanceBetweenFingers = 1 // the distance between the two fingers when the pinch starts
137
- let initZoom = 1 // the browser's zoom level when the pinch starts
113
+ let initZoom = 1 // the zoom level when the pinch starts
138
114
  let currDistanceBetweenFingers = 0
139
115
  const initPointBetweenFingers = new Vec()
140
116
  const prevPointBetweenFingers = new Vec()
141
117
 
142
- const onPinchStart: PinchHandler = (gesture) => {
143
- const elm = ref.current
144
- pinchState = 'not sure'
145
-
146
- const { event, origin, da } = gesture
147
-
148
- if (event instanceof WheelEvent) return
149
- if (!(event.target === elm || elm?.contains(event.target as Node))) return
150
-
151
- prevPointBetweenFingers.x = origin[0]
152
- prevPointBetweenFingers.y = origin[1]
153
- initPointBetweenFingers.x = origin[0]
154
- initPointBetweenFingers.y = origin[1]
155
- initDistanceBetweenFingers = da[0]
156
- initZoom = editor.getZoomLevel()
118
+ // Track active touches
119
+ let activeTouches: Touch[] = []
120
+
121
+ function getScaleBounds() {
122
+ const baseZoom = editor.getBaseZoom()
123
+ const { zoomSteps, zoomSpeed } = editor.getCameraOptions()
124
+ const zoomMin = zoomSteps[0] * baseZoom
125
+ const zoomMax = zoomSteps[zoomSteps.length - 1] * baseZoom
126
+ return {
127
+ min: zoomMin ** (1 / zoomSpeed),
128
+ max: zoomMax ** (1 / zoomSpeed),
129
+ }
130
+ }
157
131
 
158
- editor.dispatch({
159
- type: 'pinch',
160
- name: 'pinch_start',
161
- point: { x: origin[0], y: origin[1], z: editor.getZoomLevel() },
162
- delta: { x: 0, y: 0 },
163
- shiftKey: event.shiftKey,
164
- altKey: event.altKey,
165
- ctrlKey: event.metaKey || event.ctrlKey,
166
- metaKey: event.metaKey,
167
- accelKey: isAccelKey(event),
168
- })
132
+ function getScaleFrom() {
133
+ const { zoomSpeed } = editor.getCameraOptions()
134
+ return editor.getZoomLevel() ** (1 / zoomSpeed)
169
135
  }
170
136
 
171
- // let timeout: any
172
- const updatePinchState = (isSafariTrackpadPinch: boolean) => {
137
+ // Accumulated scale offset, clamped to bounds — replaces @use-gesture's offset[0]
138
+ let scaleOffset = 1
139
+ let initScaleFrom = 1 // the scale-space zoom level when the pinch started
140
+
141
+ function updatePinchState(isSafariTrackpadPinch: boolean) {
173
142
  if (isSafariTrackpadPinch) {
174
143
  pinchState = 'zooming'
175
144
  }
@@ -183,7 +152,7 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement | null>) {
183
152
  // |----| |------------|
184
153
  // originDistance ^ ^ touchDistance
185
154
 
186
- // How far have the two touch points moved towards or away from eachother?
155
+ // How far have the two touch points moved towards or away from each other?
187
156
  const touchDistance = Math.abs(currDistanceBetweenFingers - initDistanceBetweenFingers)
188
157
  // How far has the point between the touches moved?
189
158
  const originDistance = Vec.Dist(initPointBetweenFingers, prevPointBetweenFingers)
@@ -207,123 +176,226 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement | null>) {
207
176
  }
208
177
  }
209
178
 
210
- const onPinch: PinchHandler = (gesture) => {
211
- const elm = ref.current
212
- const { event, origin, offset, da } = gesture
179
+ function dispatchPinchEvent(
180
+ name: 'pinch_start' | 'pinch' | 'pinch_end',
181
+ origin: { x: number; y: number },
182
+ delta: { x: number; y: number },
183
+ zoom: number,
184
+ event: TouchEvent | GestureEvent
185
+ ) {
186
+ editor.dispatch({
187
+ type: 'pinch',
188
+ name,
189
+ point: { x: origin.x, y: origin.y, z: zoom },
190
+ delta,
191
+ shiftKey: event.shiftKey,
192
+ altKey: event.altKey,
193
+ ctrlKey: event.metaKey || event.ctrlKey,
194
+ metaKey: event.metaKey,
195
+ accelKey: isAccelKey(event),
196
+ })
197
+ }
198
+
199
+ function getOriginAndDistance(t0: Touch, t1: Touch) {
200
+ const origin = {
201
+ x: (t0.clientX + t1.clientX) / 2,
202
+ y: (t0.clientY + t1.clientY) / 2,
203
+ }
204
+ const distance = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY)
205
+ return { origin, distance }
206
+ }
213
207
 
214
- if (event instanceof WheelEvent) return
208
+ function onTouchStart(event: TouchEvent) {
215
209
  if (!(event.target === elm || elm?.contains(event.target as Node))) return
216
210
 
217
- // In (desktop) Safari, a two finger trackpad pinch will be a "gesturechange" event
218
- // and will have 0 touches; on iOS, a two-finger pinch will be a "pointermove" event
219
- // with two touches.
220
- const isSafariTrackpadPinch =
221
- gesture.type === 'gesturechange' || gesture.type === 'gestureend'
211
+ activeTouches = Array.from(event.touches)
212
+
213
+ if (activeTouches.length === 2) {
214
+ // Two fingers down — start pinch
215
+ pinchState = 'not sure'
216
+ const { origin, distance } = getOriginAndDistance(activeTouches[0], activeTouches[1])
217
+
218
+ prevPointBetweenFingers.x = origin.x
219
+ prevPointBetweenFingers.y = origin.y
220
+ initPointBetweenFingers.x = origin.x
221
+ initPointBetweenFingers.y = origin.y
222
+ initDistanceBetweenFingers = Math.max(distance, 1)
223
+ currDistanceBetweenFingers = distance
224
+ initZoom = editor.getZoomLevel()
225
+ initScaleFrom = getScaleFrom()
226
+ scaleOffset = initScaleFrom
222
227
 
223
- // The distance between the two touch points
224
- currDistanceBetweenFingers = da[0]
228
+ dispatchPinchEvent('pinch_start', origin, { x: 0, y: 0 }, editor.getZoomLevel(), event)
229
+ }
230
+ }
231
+
232
+ function onTouchMove(event: TouchEvent) {
233
+ activeTouches = Array.from(event.touches)
234
+
235
+ if (activeTouches.length < 2) return
236
+
237
+ const { origin, distance } = getOriginAndDistance(activeTouches[0], activeTouches[1])
238
+ currDistanceBetweenFingers = distance
239
+
240
+ const dx = origin.x - prevPointBetweenFingers.x
241
+ const dy = origin.y - prevPointBetweenFingers.y
242
+
243
+ prevPointBetweenFingers.x = origin.x
244
+ prevPointBetweenFingers.y = origin.y
245
+
246
+ updatePinchState(false)
225
247
 
226
248
  // Only update the zoom if the pointers are far enough apart;
227
249
  // a very small touchDistance means that the user has probably
228
250
  // pinched out and their fingers are touching; this produces
229
251
  // very unstable zooming behavior.
230
-
231
- const dx = origin[0] - prevPointBetweenFingers.x
232
- const dy = origin[1] - prevPointBetweenFingers.y
233
-
234
- prevPointBetweenFingers.x = origin[0]
235
- prevPointBetweenFingers.y = origin[1]
236
-
237
- updatePinchState(isSafariTrackpadPinch)
252
+ const bounds = getScaleBounds()
253
+ const rawScale = initScaleFrom * (distance / initDistanceBetweenFingers)
254
+ scaleOffset = Math.min(bounds.max, Math.max(bounds.min, rawScale))
238
255
 
239
256
  switch (pinchState) {
240
257
  case 'zooming': {
241
- const currZoom = offset[0] ** editor.getCameraOptions().zoomSpeed
242
-
243
- editor.dispatch({
244
- type: 'pinch',
245
- name: 'pinch',
246
- point: { x: origin[0], y: origin[1], z: currZoom },
247
- delta: { x: dx, y: dy },
248
- shiftKey: event.shiftKey,
249
- altKey: event.altKey,
250
- ctrlKey: event.metaKey || event.ctrlKey,
251
- metaKey: event.metaKey,
252
- accelKey: isAccelKey(event),
253
- })
258
+ const currZoom = scaleOffset ** editor.getCameraOptions().zoomSpeed
259
+ dispatchPinchEvent('pinch', origin, { x: dx, y: dy }, currZoom, event)
254
260
  break
255
261
  }
256
262
  case 'panning': {
257
- editor.dispatch({
258
- type: 'pinch',
259
- name: 'pinch',
260
- point: { x: origin[0], y: origin[1], z: initZoom },
261
- delta: { x: dx, y: dy },
262
- shiftKey: event.shiftKey,
263
- altKey: event.altKey,
264
- ctrlKey: event.metaKey || event.ctrlKey,
265
- metaKey: event.metaKey,
266
- accelKey: isAccelKey(event),
267
- })
263
+ dispatchPinchEvent('pinch', origin, { x: dx, y: dy }, initZoom, event)
268
264
  break
269
265
  }
270
266
  }
271
267
  }
272
268
 
273
- const onPinchEnd: PinchHandler = (gesture) => {
274
- const elm = ref.current
275
- const { event, origin, offset } = gesture
269
+ function onTouchEnd(event: TouchEvent) {
270
+ const wasPinching = activeTouches.length >= 2
271
+ activeTouches = Array.from(event.touches)
276
272
 
277
- if (event instanceof WheelEvent) return
278
- if (!(event.target === elm || elm?.contains(event.target as Node))) return
273
+ if (wasPinching && activeTouches.length < 2) {
274
+ // Pinch ended
275
+ const scale = scaleOffset ** editor.getCameraOptions().zoomSpeed
276
+ const origin = { ...prevPointBetweenFingers }
277
+ pinchState = 'not sure'
278
+
279
+ editor.timers.requestAnimationFrame(() => {
280
+ dispatchPinchEvent('pinch_end', origin, { x: origin.x, y: origin.y }, scale, event)
281
+ })
282
+ }
283
+ }
284
+
285
+ // --- Safari trackpad pinch (GestureEvent) ---
286
+
287
+ let safariGestureInitialScale = 1
288
+
289
+ function onGestureStart(event: Event) {
290
+ const e = event as GestureEvent
291
+ if (!(e.target === elm || elm?.contains(e.target as Node))) return
292
+
293
+ preventDefault(e)
294
+ e.stopPropagation()
295
+
296
+ pinchState = 'not sure'
297
+ safariGestureInitialScale = getScaleFrom()
298
+ scaleOffset = safariGestureInitialScale
299
+ initZoom = editor.getZoomLevel()
300
+
301
+ prevPointBetweenFingers.x = e.clientX
302
+ prevPointBetweenFingers.y = e.clientY
303
+ initPointBetweenFingers.x = e.clientX
304
+ initPointBetweenFingers.y = e.clientY
305
+ initDistanceBetweenFingers = 1
306
+ currDistanceBetweenFingers = 1
307
+
308
+ dispatchPinchEvent(
309
+ 'pinch_start',
310
+ { x: e.clientX, y: e.clientY },
311
+ { x: 0, y: 0 },
312
+ editor.getZoomLevel(),
313
+ e
314
+ )
315
+ }
316
+
317
+ function onGestureChange(event: Event) {
318
+ const e = event as GestureEvent
319
+ if (!(e.target === elm || elm?.contains(e.target as Node))) return
320
+
321
+ preventDefault(e)
322
+ e.stopPropagation()
323
+
324
+ const dx = e.clientX - prevPointBetweenFingers.x
325
+ const dy = e.clientY - prevPointBetweenFingers.y
326
+
327
+ prevPointBetweenFingers.x = e.clientX
328
+ prevPointBetweenFingers.y = e.clientY
329
+
330
+ // Safari GestureEvent.scale is a multiplier relative to gesture start
331
+ const bounds = getScaleBounds()
332
+ const rawScale = safariGestureInitialScale * e.scale
333
+ scaleOffset = Math.min(bounds.max, Math.max(bounds.min, rawScale))
334
+
335
+ // Update distance tracking for pinch state (treat scale change as distance change)
336
+ currDistanceBetweenFingers = e.scale * initDistanceBetweenFingers
337
+
338
+ updatePinchState(true)
279
339
 
280
- const scale = offset[0] ** editor.getCameraOptions().zoomSpeed
340
+ const currZoom = scaleOffset ** editor.getCameraOptions().zoomSpeed
281
341
 
342
+ dispatchPinchEvent('pinch', { x: e.clientX, y: e.clientY }, { x: dx, y: dy }, currZoom, e)
343
+ }
344
+
345
+ function onGestureEnd(event: Event) {
346
+ const e = event as GestureEvent
347
+ if (!(e.target === elm || elm?.contains(e.target as Node))) return
348
+
349
+ preventDefault(e)
350
+ e.stopPropagation()
351
+
352
+ const scale = scaleOffset ** editor.getCameraOptions().zoomSpeed
282
353
  pinchState = 'not sure'
283
354
 
284
355
  editor.timers.requestAnimationFrame(() => {
285
- editor.dispatch({
286
- type: 'pinch',
287
- name: 'pinch_end',
288
- point: { x: origin[0], y: origin[1], z: scale },
289
- delta: { x: origin[0], y: origin[1] },
290
- shiftKey: event.shiftKey,
291
- altKey: event.altKey,
292
- ctrlKey: event.metaKey || event.ctrlKey,
293
- metaKey: event.metaKey,
294
- accelKey: isAccelKey(event),
295
- })
356
+ dispatchPinchEvent(
357
+ 'pinch_end',
358
+ { x: e.clientX, y: e.clientY },
359
+ { x: e.clientX, y: e.clientY },
360
+ scale,
361
+ e
362
+ )
296
363
  })
297
364
  }
298
365
 
299
- return {
300
- onWheel,
301
- onPinchStart,
302
- onPinchEnd,
303
- onPinch,
366
+ // --- Attach event listeners ---
367
+
368
+ elm.addEventListener('wheel', onWheel, { passive: false })
369
+
370
+ // On touch devices (iOS), use pointer events for pinch.
371
+ // On non-touch Safari (macOS trackpad), use GestureEvent.
372
+ // Never use both simultaneously — on iOS Safari, both event types fire
373
+ // for the same pinch gesture, causing conflicting state updates.
374
+ const useGestureEvents = !tlenv.isIos && 'GestureEvent' in window
375
+
376
+ if (useGestureEvents) {
377
+ elm.addEventListener('gesturestart', onGestureStart)
378
+ elm.addEventListener('gesturechange', onGestureChange)
379
+ elm.addEventListener('gestureend', onGestureEnd)
380
+ } else {
381
+ elm.addEventListener('touchstart', onTouchStart)
382
+ elm.addEventListener('touchmove', onTouchMove)
383
+ elm.addEventListener('touchend', onTouchEnd)
384
+ elm.addEventListener('touchcancel', onTouchEnd)
304
385
  }
305
- }, [editor, ref])
306
386
 
307
- useGesture(events, {
308
- target: ref,
309
- eventOptions: { passive: false },
310
- pinch: {
311
- from: () => {
312
- const { zoomSpeed } = editor.getCameraOptions()
313
- const level = editor.getZoomLevel() ** (1 / zoomSpeed)
314
- return [level, 0]
315
- }, // Return the camera z to use when pinch starts
316
- scaleBounds: () => {
317
- const baseZoom = editor.getBaseZoom()
318
- const { zoomSteps, zoomSpeed } = editor.getCameraOptions()
319
- const zoomMin = zoomSteps[0] * baseZoom
320
- const zoomMax = zoomSteps[zoomSteps.length - 1] * baseZoom
321
-
322
- return {
323
- max: zoomMax ** (1 / zoomSpeed),
324
- min: zoomMin ** (1 / zoomSpeed),
325
- }
326
- },
327
- },
328
- })
387
+ return () => {
388
+ elm.removeEventListener('wheel', onWheel)
389
+ if (useGestureEvents) {
390
+ elm.removeEventListener('gesturestart', onGestureStart)
391
+ elm.removeEventListener('gesturechange', onGestureChange)
392
+ elm.removeEventListener('gestureend', onGestureEnd)
393
+ } else {
394
+ elm.removeEventListener('touchstart', onTouchStart)
395
+ elm.removeEventListener('touchmove', onTouchMove)
396
+ elm.removeEventListener('touchend', onTouchEnd)
397
+ elm.removeEventListener('touchcancel', onTouchEnd)
398
+ }
399
+ }
400
+ }, [editor, ref])
329
401
  }
@@ -728,4 +728,34 @@ describe('Box', () => {
728
728
  expect(result).not.toBe(zeroBox) // different object
729
729
  })
730
730
  })
731
+
732
+ describe('Box.isValid', () => {
733
+ it('returns true for normal box', () => {
734
+ expect(new Box(10, 20, 100, 200).isValid()).toBe(true)
735
+ })
736
+
737
+ it('returns false when x is NaN', () => {
738
+ expect(new Box(NaN, 0, 100, 100).isValid()).toBe(false)
739
+ })
740
+
741
+ it('returns false when y is NaN', () => {
742
+ expect(new Box(0, NaN, 100, 100).isValid()).toBe(false)
743
+ })
744
+
745
+ it('returns false when w is NaN', () => {
746
+ expect(new Box(0, 0, NaN, 100).isValid()).toBe(false)
747
+ })
748
+
749
+ it('returns false when h is NaN', () => {
750
+ expect(new Box(0, 0, 100, NaN).isValid()).toBe(false)
751
+ })
752
+
753
+ it('returns false for Infinity', () => {
754
+ expect(new Box(Infinity, 0, 100, 100).isValid()).toBe(false)
755
+ })
756
+
757
+ it('returns true for zero-sized box', () => {
758
+ expect(new Box(0, 0, 0, 0).isValid()).toBe(true)
759
+ })
760
+ })
731
761
  })
@@ -1,5 +1,6 @@
1
1
  import { Mat } from '../Mat'
2
2
  import { Vec, VecLike } from '../Vec'
3
+ import { Edge2d } from './Edge2d'
3
4
  import { Geometry2dFilters } from './Geometry2d'
4
5
  import { Group2d } from './Group2d'
5
6
  import { Rectangle2d } from './Rectangle2d'
@@ -456,6 +457,26 @@ describe('Group2d getBoundsVertices', () => {
456
457
  })
457
458
  })
458
459
 
460
+ describe('interpolateAlongEdge', () => {
461
+ it('returns vertex when segment has zero length', () => {
462
+ const edge = new Edge2d({ start: new Vec(5, 5), end: new Vec(5, 5) })
463
+ const result = edge.interpolateAlongEdge(0.5)
464
+ expect(result.x).toBe(5)
465
+ expect(result.y).toBe(5)
466
+ expect(Number.isFinite(result.x)).toBe(true)
467
+ expect(Number.isFinite(result.y)).toBe(true)
468
+ })
469
+ })
470
+
471
+ describe('uninterpolateAlongEdge', () => {
472
+ it('returns 0 when geometry has zero length', () => {
473
+ const edge = new Edge2d({ start: new Vec(5, 5), end: new Vec(5, 5) })
474
+ const result = edge.uninterpolateAlongEdge(new Vec(5, 5))
475
+ expect(result).toBe(0)
476
+ expect(Number.isFinite(result)).toBe(true)
477
+ })
478
+ })
479
+
459
480
  function expectApproxMatch(a: VecLike, b: VecLike) {
460
481
  expect(a.x).toBeCloseTo(b.x, 0.0001)
461
482
  expect(a.y).toBeCloseTo(b.y, 0.0001)
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 = '4.6.0-next.d15997ff5a4b'
4
+ export const version = '4.6.0-next.d8328a2dcc3d'
5
5
  export const publishDates = {
6
6
  major: '2025-09-18T14:39:22.803Z',
7
- minor: '2026-03-26T11:23:28.088Z',
8
- patch: '2026-03-26T11:23:28.088Z',
7
+ minor: '2026-04-02T09:41:44.660Z',
8
+ patch: '2026-04-02T09:41:44.660Z',
9
9
  }
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../src/lib/config/createTLUser.ts"],
4
- "sourcesContent": ["import { Signal, computed, isSignal } from '@tldraw/state'\nimport { useAtom } from '@tldraw/state-react'\nimport { useEffect, useMemo } from 'react'\nimport { useShallowObjectIdentity } from '../hooks/useIdentity'\nimport { TLUserPreferences, getUserPreferences, setUserPreferences } from './TLUserPreferences'\n\n/** @public */\nexport interface TLUser {\n\treadonly userPreferences: Signal<TLUserPreferences>\n\t// eslint-disable-next-line tldraw/method-signature-style\n\treadonly setUserPreferences: (userPreferences: TLUserPreferences) => void\n}\n\nconst defaultLocalStorageUserPrefs = computed('defaultLocalStorageUserPrefs', () =>\n\tgetUserPreferences()\n)\n\n/** @public */\nexport function createTLUser(\n\topts = {} as {\n\t\tuserPreferences?: Signal<TLUserPreferences>\n\t\t// eslint-disable-next-line tldraw/method-signature-style\n\t\tsetUserPreferences?: (userPreferences: TLUserPreferences) => void\n\t}\n): TLUser {\n\treturn {\n\t\tuserPreferences: opts.userPreferences ?? defaultLocalStorageUserPrefs,\n\t\tsetUserPreferences: opts.setUserPreferences ?? setUserPreferences,\n\t}\n}\n\n/**\n * @public\n */\nexport function useTldrawUser(opts: {\n\tuserPreferences?: Signal<TLUserPreferences> | TLUserPreferences\n\t// eslint-disable-next-line tldraw/method-signature-style\n\tsetUserPreferences?: (userPreferences: TLUserPreferences) => void\n}): TLUser {\n\tconst prefs = useShallowObjectIdentity(opts.userPreferences ?? defaultLocalStorageUserPrefs)\n\tconst userAtom = useAtom<TLUserPreferences | Signal<TLUserPreferences>>('userAtom', prefs)\n\tuseEffect(() => {\n\t\tuserAtom.set(prefs)\n\t}, [prefs, userAtom])\n\n\treturn useMemo(\n\t\t() =>\n\t\t\tcreateTLUser({\n\t\t\t\tuserPreferences: computed('userPreferences', () => {\n\t\t\t\t\tconst userStuff = userAtom.get()\n\t\t\t\t\treturn isSignal(userStuff) ? userStuff.get() : userStuff\n\t\t\t\t}),\n\t\t\t\tsetUserPreferences: opts.setUserPreferences,\n\t\t\t}),\n\t\t[userAtom, opts.setUserPreferences]\n\t)\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAA2C;AAC3C,yBAAwB;AACxB,mBAAmC;AACnC,yBAAyC;AACzC,+BAA0E;AAS1E,MAAM,mCAA+B;AAAA,EAAS;AAAA,EAAgC,UAC7E,6CAAmB;AACpB;AAGO,SAAS,aACf,OAAO,CAAC,GAKC;AACT,SAAO;AAAA,IACN,iBAAiB,KAAK,mBAAmB;AAAA,IACzC,oBAAoB,KAAK,sBAAsB;AAAA,EAChD;AACD;AAKO,SAAS,cAAc,MAInB;AACV,QAAM,YAAQ,6CAAyB,KAAK,mBAAmB,4BAA4B;AAC3F,QAAM,eAAW,4BAAuD,YAAY,KAAK;AACzF,8BAAU,MAAM;AACf,aAAS,IAAI,KAAK;AAAA,EACnB,GAAG,CAAC,OAAO,QAAQ,CAAC;AAEpB,aAAO;AAAA,IACN,MACC,aAAa;AAAA,MACZ,qBAAiB,uBAAS,mBAAmB,MAAM;AAClD,cAAM,YAAY,SAAS,IAAI;AAC/B,mBAAO,uBAAS,SAAS,IAAI,UAAU,IAAI,IAAI;AAAA,MAChD,CAAC;AAAA,MACD,oBAAoB,KAAK;AAAA,IAC1B,CAAC;AAAA,IACF,CAAC,UAAU,KAAK,kBAAkB;AAAA,EACnC;AACD;",
6
- "names": []
7
- }
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../src/lib/config/createTLUser.ts"],
4
- "sourcesContent": ["import { Signal, computed, isSignal } from '@tldraw/state'\nimport { useAtom } from '@tldraw/state-react'\nimport { useEffect, useMemo } from 'react'\nimport { useShallowObjectIdentity } from '../hooks/useIdentity'\nimport { TLUserPreferences, getUserPreferences, setUserPreferences } from './TLUserPreferences'\n\n/** @public */\nexport interface TLUser {\n\treadonly userPreferences: Signal<TLUserPreferences>\n\t// eslint-disable-next-line tldraw/method-signature-style\n\treadonly setUserPreferences: (userPreferences: TLUserPreferences) => void\n}\n\nconst defaultLocalStorageUserPrefs = computed('defaultLocalStorageUserPrefs', () =>\n\tgetUserPreferences()\n)\n\n/** @public */\nexport function createTLUser(\n\topts = {} as {\n\t\tuserPreferences?: Signal<TLUserPreferences>\n\t\t// eslint-disable-next-line tldraw/method-signature-style\n\t\tsetUserPreferences?: (userPreferences: TLUserPreferences) => void\n\t}\n): TLUser {\n\treturn {\n\t\tuserPreferences: opts.userPreferences ?? defaultLocalStorageUserPrefs,\n\t\tsetUserPreferences: opts.setUserPreferences ?? setUserPreferences,\n\t}\n}\n\n/**\n * @public\n */\nexport function useTldrawUser(opts: {\n\tuserPreferences?: Signal<TLUserPreferences> | TLUserPreferences\n\t// eslint-disable-next-line tldraw/method-signature-style\n\tsetUserPreferences?: (userPreferences: TLUserPreferences) => void\n}): TLUser {\n\tconst prefs = useShallowObjectIdentity(opts.userPreferences ?? defaultLocalStorageUserPrefs)\n\tconst userAtom = useAtom<TLUserPreferences | Signal<TLUserPreferences>>('userAtom', prefs)\n\tuseEffect(() => {\n\t\tuserAtom.set(prefs)\n\t}, [prefs, userAtom])\n\n\treturn useMemo(\n\t\t() =>\n\t\t\tcreateTLUser({\n\t\t\t\tuserPreferences: computed('userPreferences', () => {\n\t\t\t\t\tconst userStuff = userAtom.get()\n\t\t\t\t\treturn isSignal(userStuff) ? userStuff.get() : userStuff\n\t\t\t\t}),\n\t\t\t\tsetUserPreferences: opts.setUserPreferences,\n\t\t\t}),\n\t\t[userAtom, opts.setUserPreferences]\n\t)\n}\n"],
5
- "mappings": "AAAA,SAAiB,UAAU,gBAAgB;AAC3C,SAAS,eAAe;AACxB,SAAS,WAAW,eAAe;AACnC,SAAS,gCAAgC;AACzC,SAA4B,oBAAoB,0BAA0B;AAS1E,MAAM,+BAA+B;AAAA,EAAS;AAAA,EAAgC,MAC7E,mBAAmB;AACpB;AAGO,SAAS,aACf,OAAO,CAAC,GAKC;AACT,SAAO;AAAA,IACN,iBAAiB,KAAK,mBAAmB;AAAA,IACzC,oBAAoB,KAAK,sBAAsB;AAAA,EAChD;AACD;AAKO,SAAS,cAAc,MAInB;AACV,QAAM,QAAQ,yBAAyB,KAAK,mBAAmB,4BAA4B;AAC3F,QAAM,WAAW,QAAuD,YAAY,KAAK;AACzF,YAAU,MAAM;AACf,aAAS,IAAI,KAAK;AAAA,EACnB,GAAG,CAAC,OAAO,QAAQ,CAAC;AAEpB,SAAO;AAAA,IACN,MACC,aAAa;AAAA,MACZ,iBAAiB,SAAS,mBAAmB,MAAM;AAClD,cAAM,YAAY,SAAS,IAAI;AAC/B,eAAO,SAAS,SAAS,IAAI,UAAU,IAAI,IAAI;AAAA,MAChD,CAAC;AAAA,MACD,oBAAoB,KAAK;AAAA,IAC1B,CAAC;AAAA,IACF,CAAC,UAAU,KAAK,kBAAkB;AAAA,EACnC;AACD;",
6
- "names": []
7
- }