@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.
- package/dist-cjs/index.d.ts +60 -18
- package/dist-cjs/index.js +5 -4
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TldrawEditor.js +4 -2
- package/dist-cjs/lib/TldrawEditor.js.map +2 -2
- package/dist-cjs/lib/config/{createTLUser.js → createTLCurrentUser.js} +9 -9
- package/dist-cjs/lib/config/createTLCurrentUser.js.map +7 -0
- package/dist-cjs/lib/config/createTLStore.js +23 -0
- package/dist-cjs/lib/config/createTLStore.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +111 -4
- package/dist-cjs/lib/editor/Editor.js.map +3 -3
- package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +1 -1
- package/dist-cjs/lib/editor/shapes/ShapeUtil.js +10 -0
- package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
- package/dist-cjs/lib/editor/types/clipboard-types.js.map +1 -1
- package/dist-cjs/lib/hooks/useGestureEvents.js +171 -127
- package/dist-cjs/lib/hooks/useGestureEvents.js.map +3 -3
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +60 -18
- package/dist-esm/index.mjs +9 -4
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +4 -2
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/config/{createTLUser.mjs → createTLCurrentUser.mjs} +6 -6
- package/dist-esm/lib/config/createTLCurrentUser.mjs.map +7 -0
- package/dist-esm/lib/config/createTLStore.mjs +27 -1
- package/dist-esm/lib/config/createTLStore.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +113 -4
- package/dist-esm/lib/editor/Editor.mjs.map +3 -3
- package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +1 -1
- package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +10 -0
- package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/hooks/useGestureEvents.mjs +171 -127
- package/dist-esm/lib/hooks/useGestureEvents.mjs.map +3 -3
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +13 -0
- package/package.json +8 -9
- package/src/index.ts +6 -1
- package/src/lib/TldrawEditor.tsx +8 -6
- package/src/lib/config/{createTLUser.ts → createTLCurrentUser.ts} +6 -6
- package/src/lib/config/createTLStore.ts +35 -1
- package/src/lib/editor/Editor.ts +140 -3
- package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +2 -2
- package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +2 -2
- package/src/lib/editor/shapes/ShapeUtil.ts +11 -0
- package/src/lib/editor/types/clipboard-types.ts +2 -1
- package/src/lib/hooks/useGestureEvents.ts +240 -168
- package/src/lib/primitives/Box.test.ts +30 -0
- package/src/lib/primitives/geometry/Geometry2d.test.ts +21 -0
- package/src/version.ts +3 -3
- package/dist-cjs/lib/config/createTLUser.js.map +0 -7
- package/dist-esm/lib/config/createTLUser.mjs.map +0 -7
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { atom, computed } from '@tldraw/state'
|
|
2
|
-
import {
|
|
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:
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
//
|
|
172
|
-
|
|
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
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
208
|
+
function onTouchStart(event: TouchEvent) {
|
|
215
209
|
if (!(event.target === elm || elm?.contains(event.target as Node))) return
|
|
216
210
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
232
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
269
|
+
function onTouchEnd(event: TouchEvent) {
|
|
270
|
+
const wasPinching = activeTouches.length >= 2
|
|
271
|
+
activeTouches = Array.from(event.touches)
|
|
276
272
|
|
|
277
|
-
if (
|
|
278
|
-
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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.
|
|
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-
|
|
8
|
-
patch: '2026-
|
|
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
|
-
}
|