@tldraw/editor 5.2.0-next.b91d4a4551c9 → 5.2.0-next.cd4a35fc06d5
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/README.md +1 -1
- package/dist-cjs/index.d.ts +7 -43
- package/dist-cjs/index.js +3 -4
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +55 -16
- package/dist-cjs/lib/editor/Editor.js.map +3 -3
- package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +8 -58
- package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +1 -1
- package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js +1 -2
- package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js.map +2 -2
- package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js +79 -0
- package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js.map +7 -0
- package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
- package/dist-cjs/lib/editor/types/event-types.js +0 -2
- package/dist-cjs/lib/editor/types/event-types.js.map +2 -2
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +7 -43
- package/dist-esm/index.mjs +2 -6
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +55 -16
- package/dist-esm/lib/editor/Editor.mjs.map +3 -3
- package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +8 -58
- package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +1 -1
- package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs +1 -2
- package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs.map +2 -2
- package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs +59 -0
- package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs.map +7 -0
- package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
- package/dist-esm/lib/editor/types/event-types.mjs +0 -2
- package/dist-esm/lib/editor/types/event-types.mjs.map +2 -2
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +2 -0
- package/package.json +8 -8
- package/src/index.ts +1 -5
- package/src/lib/editor/Editor.ts +87 -24
- package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +54 -74
- package/src/lib/editor/managers/ClickManager/ClickManager.ts +15 -65
- package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts +14 -0
- package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +6 -3
- package/src/lib/editor/managers/FocusManager/FocusManager.test.ts +4 -4
- package/src/lib/editor/managers/FocusManager/FocusManager.ts +1 -2
- package/src/lib/editor/managers/FontManager/FontManager.test.ts +13 -9
- package/src/lib/editor/managers/TextManager/TextManager.test.ts +16 -14
- package/src/lib/editor/overlays/strokeShapeIndicators.ts +86 -0
- package/src/lib/editor/tools/StateNode.ts +0 -2
- package/src/lib/editor/types/event-types.ts +2 -6
- package/src/version.ts +3 -3
- package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js +0 -161
- package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js.map +0 -7
- package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs +0 -141
- package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs.map +0 -7
- package/src/lib/editor/overlays/ShapeIndicatorOverlayUtil.ts +0 -216
package/src/lib/editor/Editor.ts
CHANGED
|
@@ -3336,6 +3336,15 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
3336
3336
|
|
|
3337
3337
|
let { x, y, z = currentCamera.z } = point
|
|
3338
3338
|
|
|
3339
|
+
// `requested` kept the caller's focal point (e.g. the cursor) fixed at
|
|
3340
|
+
// zoom `rz`. When `rz` gets clamped, keep that same focal point fixed at
|
|
3341
|
+
// the clamped zoom `z` rather than snapping to the viewport center.
|
|
3342
|
+
const preserveFocalPoint = (current: number, requested: number, rz: number, z: number) => {
|
|
3343
|
+
const cz = currentCamera.z
|
|
3344
|
+
if (rz === cz) return current
|
|
3345
|
+
return current + ((requested - current) * (1 / z - 1 / cz)) / (1 / rz - 1 / cz)
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3339
3348
|
// If force is true, then we'll set the camera to the point regardless of
|
|
3340
3349
|
// the camera options, so that we can handle gestures that permit elasticity
|
|
3341
3350
|
// or decay, or animations that occur while the camera is locked.
|
|
@@ -3378,17 +3387,14 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
3378
3387
|
}
|
|
3379
3388
|
|
|
3380
3389
|
if (z < minZ || z > maxZ) {
|
|
3381
|
-
// We're trying to zoom out past the minimum zoom level,
|
|
3382
|
-
//
|
|
3383
|
-
//
|
|
3384
|
-
|
|
3385
|
-
const
|
|
3386
|
-
const cyA = -cy + vsb.h / cz / 2
|
|
3390
|
+
// We're trying to zoom out past the minimum zoom level, or in
|
|
3391
|
+
// past the maximum zoom level, so clamp the zoom while keeping
|
|
3392
|
+
// the caller's focal point fixed. Axis constraints below still
|
|
3393
|
+
// apply on top of this.
|
|
3394
|
+
const rz = z
|
|
3387
3395
|
z = clamp(z, minZ, maxZ)
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
x = cx + cxB - cxA
|
|
3391
|
-
y = cy + cyB - cyA
|
|
3396
|
+
x = preserveFocalPoint(currentCamera.x, x, rz, z)
|
|
3397
|
+
y = preserveFocalPoint(currentCamera.y, y, rz, z)
|
|
3392
3398
|
}
|
|
3393
3399
|
|
|
3394
3400
|
// Calculate available space
|
|
@@ -3477,12 +3483,12 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
3477
3483
|
}
|
|
3478
3484
|
}
|
|
3479
3485
|
} else {
|
|
3480
|
-
// constrain the zoom,
|
|
3486
|
+
// constrain the zoom, keeping the caller's focal point fixed
|
|
3481
3487
|
if (z > zoomMax || z < zoomMin) {
|
|
3482
|
-
const
|
|
3488
|
+
const rz = z
|
|
3483
3489
|
z = clamp(z, zoomMin, zoomMax)
|
|
3484
|
-
x =
|
|
3485
|
-
y =
|
|
3490
|
+
x = preserveFocalPoint(currentCamera.x, x, rz, z)
|
|
3491
|
+
y = preserveFocalPoint(currentCamera.y, y, rz, z)
|
|
3486
3492
|
}
|
|
3487
3493
|
}
|
|
3488
3494
|
}
|
|
@@ -4023,16 +4029,34 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
4023
4029
|
|
|
4024
4030
|
this.once('stop-camera-animation', cancel)
|
|
4025
4031
|
|
|
4032
|
+
const dirZ = direction.z ?? 0
|
|
4033
|
+
|
|
4026
4034
|
const moveCamera = (elapsed: number) => {
|
|
4027
4035
|
const { x: cx, y: cy, z: cz } = this.getCamera()
|
|
4028
|
-
|
|
4036
|
+
|
|
4037
|
+
// Pan movement from x/y direction
|
|
4038
|
+
const dx = (direction.x * (currentSpeed * elapsed)) / cz
|
|
4039
|
+
const dy = (direction.y * (currentSpeed * elapsed)) / cz
|
|
4040
|
+
|
|
4041
|
+
let newCx = cx + dx
|
|
4042
|
+
let newCy = cy + dy
|
|
4043
|
+
let newCz = cz
|
|
4044
|
+
|
|
4045
|
+
// animate zoom if z direction is passed in
|
|
4046
|
+
if (dirZ !== 0) {
|
|
4047
|
+
newCz = cz * (1 + dirZ * currentSpeed * elapsed)
|
|
4048
|
+
// Adjust x/y to keep the viewport center fixed while zooming
|
|
4049
|
+
const center = this.getViewportScreenCenter()
|
|
4050
|
+
newCx += center.x / newCz - center.x / cz
|
|
4051
|
+
newCy += center.y / newCz - center.y / cz
|
|
4052
|
+
}
|
|
4029
4053
|
|
|
4030
4054
|
// Apply friction
|
|
4031
4055
|
currentSpeed *= 1 - friction
|
|
4032
4056
|
if (currentSpeed < speedThreshold) {
|
|
4033
4057
|
cancel()
|
|
4034
4058
|
} else {
|
|
4035
|
-
this._setCamera(new Vec(
|
|
4059
|
+
this._setCamera(new Vec(newCx, newCy, newCz))
|
|
4036
4060
|
}
|
|
4037
4061
|
}
|
|
4038
4062
|
|
|
@@ -10768,6 +10792,16 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
10768
10792
|
/** @internal */
|
|
10769
10793
|
private _selectedShapeIdsAtPointerDown: TLShapeId[] = []
|
|
10770
10794
|
|
|
10795
|
+
/**
|
|
10796
|
+
* Whether `_selectedShapeIdsAtPointerDown` holds a pre-gesture selection
|
|
10797
|
+
* captured by a `pointer_down` (the touch path) that a following pinch
|
|
10798
|
+
* should restore. False when no pointer_down preceded the pinch (the
|
|
10799
|
+
* Safari trackpad path uses gesture events), in which case `pinch_start`
|
|
10800
|
+
* captures the live selection instead.
|
|
10801
|
+
* @internal
|
|
10802
|
+
*/
|
|
10803
|
+
private _didCaptureSelectionAtPointerDown = false
|
|
10804
|
+
|
|
10771
10805
|
/** @internal */
|
|
10772
10806
|
private _longPressTimeout = -1 as any
|
|
10773
10807
|
|
|
@@ -10933,16 +10967,28 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
10933
10967
|
if (inputs.getIsPinching()) return
|
|
10934
10968
|
|
|
10935
10969
|
if (!inputs.getIsEditing()) {
|
|
10936
|
-
//
|
|
10937
|
-
//
|
|
10938
|
-
//
|
|
10939
|
-
|
|
10970
|
+
// If a pointer_down already captured the pre-gesture selection,
|
|
10971
|
+
// keep it: on touch, the first finger's pointer_down can change
|
|
10972
|
+
// the selection before the second finger starts the pinch, and we
|
|
10973
|
+
// want to restore the selection from before that change. When no
|
|
10974
|
+
// pointer_down preceded the pinch (Safari delivers trackpad pinches
|
|
10975
|
+
// as gesture events), capture the live selection now.
|
|
10976
|
+
if (!this._didCaptureSelectionAtPointerDown) {
|
|
10977
|
+
this._selectedShapeIdsAtPointerDown = [...pageState.selectedShapeIds]
|
|
10978
|
+
}
|
|
10940
10979
|
|
|
10941
10980
|
this._didPinch = true
|
|
10942
10981
|
|
|
10943
10982
|
inputs.setIsPinching(true)
|
|
10944
10983
|
|
|
10945
10984
|
this.interrupt()
|
|
10985
|
+
|
|
10986
|
+
// If the first finger changed the selection, roll it back now rather
|
|
10987
|
+
// than waiting for the pinch to end, so the pre-gesture selection is
|
|
10988
|
+
// what's shown during the pinch.
|
|
10989
|
+
if (this._didCaptureSelectionAtPointerDown) {
|
|
10990
|
+
this.setSelectedShapes(this._selectedShapeIdsAtPointerDown)
|
|
10991
|
+
}
|
|
10946
10992
|
}
|
|
10947
10993
|
|
|
10948
10994
|
this.emit('event', info)
|
|
@@ -10994,6 +11040,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
10994
11040
|
const { _selectedShapeIdsAtPointerDown: shapesToReselect } = this
|
|
10995
11041
|
this.setSelectedShapes(this._selectedShapeIdsAtPointerDown)
|
|
10996
11042
|
this._selectedShapeIdsAtPointerDown = []
|
|
11043
|
+
this._didCaptureSelectionAtPointerDown = false
|
|
10997
11044
|
|
|
10998
11045
|
if (this._didPinch) {
|
|
10999
11046
|
this._didPinch = false
|
|
@@ -11122,8 +11169,15 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
11122
11169
|
}, this.options.longPressDurationMs)
|
|
11123
11170
|
}
|
|
11124
11171
|
|
|
11125
|
-
// Save the selected ids at
|
|
11126
|
-
|
|
11172
|
+
// Save the selected ids at the start of an interaction so a pinch can
|
|
11173
|
+
// restore the pre-gesture selection. Only capture on the first pointer:
|
|
11174
|
+
// on touch, the second finger's pointer_down arrives after the first
|
|
11175
|
+
// has already changed the selection, and we want the earlier snapshot.
|
|
11176
|
+
// Cleared on pointer_up / pinch_end.
|
|
11177
|
+
if (!this._didCaptureSelectionAtPointerDown) {
|
|
11178
|
+
this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds()
|
|
11179
|
+
this._didCaptureSelectionAtPointerDown = true
|
|
11180
|
+
}
|
|
11127
11181
|
|
|
11128
11182
|
// Firefox bug fix...
|
|
11129
11183
|
// If it's a left-mouse-click, we store the pointer id for later user
|
|
@@ -11243,6 +11297,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
11243
11297
|
if (this.inputs.getIsRightPointing() && !this.inputs.getIsPanning()) {
|
|
11244
11298
|
this.inputs.setIsRightPointing(false)
|
|
11245
11299
|
this._selectedShapeIdsAtPointerDown = []
|
|
11300
|
+
this._didCaptureSelectionAtPointerDown = false
|
|
11246
11301
|
break // fall through to state chart dispatch as right_click
|
|
11247
11302
|
}
|
|
11248
11303
|
|
|
@@ -11286,15 +11341,22 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
11286
11341
|
// Don't pass right-click panning events to the state chart
|
|
11287
11342
|
// as it causes unintended shape selection on release
|
|
11288
11343
|
if (slideSpeed > 0) {
|
|
11289
|
-
this.slideCamera({
|
|
11344
|
+
this.slideCamera({
|
|
11345
|
+
speed: slideSpeed,
|
|
11346
|
+
direction: { x: slideDirection.x, y: slideDirection.y, z: 0 },
|
|
11347
|
+
})
|
|
11290
11348
|
}
|
|
11291
11349
|
this._selectedShapeIdsAtPointerDown = []
|
|
11350
|
+
this._didCaptureSelectionAtPointerDown = false
|
|
11292
11351
|
return this
|
|
11293
11352
|
}
|
|
11294
11353
|
}
|
|
11295
11354
|
|
|
11296
11355
|
if (slideSpeed > 0) {
|
|
11297
|
-
this.slideCamera({
|
|
11356
|
+
this.slideCamera({
|
|
11357
|
+
speed: slideSpeed,
|
|
11358
|
+
direction: { x: slideDirection.x, y: slideDirection.y, z: 0 },
|
|
11359
|
+
})
|
|
11298
11360
|
}
|
|
11299
11361
|
} else {
|
|
11300
11362
|
if (info.button === STYLUS_ERASER_BUTTON) {
|
|
@@ -11307,6 +11369,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
11307
11369
|
// Clear the stashed selection so the next pinch captures fresh state.
|
|
11308
11370
|
// This fixes Safari pinch zoom restoring outdated selections.
|
|
11309
11371
|
this._selectedShapeIdsAtPointerDown = []
|
|
11372
|
+
this._didCaptureSelectionAtPointerDown = false
|
|
11310
11373
|
|
|
11311
11374
|
break
|
|
11312
11375
|
}
|
|
@@ -122,7 +122,7 @@ describe('ClickManager', () => {
|
|
|
122
122
|
expect(result.type).toBe('click')
|
|
123
123
|
expect(result.name).toBe('double_click')
|
|
124
124
|
expect(result.phase).toBe('down')
|
|
125
|
-
expect(clickManager.clickState).toBe('
|
|
125
|
+
expect(clickManager.clickState).toBe('pendingOverflow')
|
|
126
126
|
})
|
|
127
127
|
|
|
128
128
|
it('should generate double_click up event on pointer_up after double_click down', () => {
|
|
@@ -139,12 +139,34 @@ describe('ClickManager', () => {
|
|
|
139
139
|
expect(result.phase).toBe('up')
|
|
140
140
|
})
|
|
141
141
|
|
|
142
|
-
it('should dispatch double_click settle event after timeout in
|
|
142
|
+
it('should dispatch double_click settle-down event after timeout in pendingOverflow (pointer held)', () => {
|
|
143
143
|
const firstDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
|
|
144
144
|
const secondDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
|
|
145
145
|
|
|
146
146
|
clickManager.handlePointerEvent(firstDown)
|
|
147
147
|
clickManager.handlePointerEvent(secondDown)
|
|
148
|
+
// no pointer_up between or after — pointer is still down at settle time
|
|
149
|
+
|
|
150
|
+
vi.advanceTimersByTime(350)
|
|
151
|
+
|
|
152
|
+
expect(editor.dispatch).toHaveBeenCalledWith(
|
|
153
|
+
expect.objectContaining({
|
|
154
|
+
type: 'click',
|
|
155
|
+
name: 'double_click',
|
|
156
|
+
phase: 'settle-down',
|
|
157
|
+
})
|
|
158
|
+
)
|
|
159
|
+
expect(clickManager.clickState).toBe('idle')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('should dispatch double_click settle-up event after timeout in pendingOverflow (pointer released)', () => {
|
|
163
|
+
const down = createPointerEvent('pointer_down', { x: 100, y: 100 })
|
|
164
|
+
const up = createPointerEvent('pointer_up', { x: 100, y: 100 })
|
|
165
|
+
|
|
166
|
+
clickManager.handlePointerEvent(down)
|
|
167
|
+
clickManager.handlePointerEvent(up)
|
|
168
|
+
clickManager.handlePointerEvent(down)
|
|
169
|
+
clickManager.handlePointerEvent(up)
|
|
148
170
|
|
|
149
171
|
vi.advanceTimersByTime(350)
|
|
150
172
|
|
|
@@ -152,124 +174,84 @@ describe('ClickManager', () => {
|
|
|
152
174
|
expect.objectContaining({
|
|
153
175
|
type: 'click',
|
|
154
176
|
name: 'double_click',
|
|
155
|
-
phase: 'settle',
|
|
177
|
+
phase: 'settle-up',
|
|
156
178
|
})
|
|
157
179
|
)
|
|
158
180
|
expect(clickManager.clickState).toBe('idle')
|
|
159
181
|
})
|
|
160
182
|
})
|
|
161
183
|
|
|
162
|
-
describe('
|
|
163
|
-
it('should
|
|
184
|
+
describe('overflow click handling', () => {
|
|
185
|
+
it('should enter overflow on the third pointer_down without emitting another click', () => {
|
|
164
186
|
const firstDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
|
|
165
187
|
const secondDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
|
|
166
188
|
const thirdDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
|
|
167
189
|
|
|
168
190
|
clickManager.handlePointerEvent(firstDown)
|
|
169
191
|
clickManager.handlePointerEvent(secondDown)
|
|
170
|
-
const result = clickManager.handlePointerEvent(thirdDown)
|
|
192
|
+
const result = clickManager.handlePointerEvent(thirdDown)
|
|
171
193
|
|
|
172
|
-
expect(result
|
|
173
|
-
expect(
|
|
174
|
-
expect(result.phase).toBe('down')
|
|
175
|
-
expect(clickManager.clickState).toBe('pendingQuadruple')
|
|
194
|
+
expect(result).toBe(thirdDown)
|
|
195
|
+
expect(clickManager.clickState).toBe('overflow')
|
|
176
196
|
})
|
|
177
197
|
|
|
178
|
-
it('should
|
|
198
|
+
it('should keep overflow active on further clicks', () => {
|
|
179
199
|
const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
|
|
180
200
|
|
|
181
201
|
clickManager.handlePointerEvent(pointerDown) // first
|
|
182
202
|
clickManager.handlePointerEvent(pointerDown) // second (double_click)
|
|
183
|
-
clickManager.handlePointerEvent(pointerDown) // third (
|
|
184
|
-
const result = clickManager.handlePointerEvent(pointerDown)
|
|
185
|
-
|
|
186
|
-
expect(result.type).toBe('click')
|
|
187
|
-
expect(result.name).toBe('quadruple_click')
|
|
188
|
-
expect(result.phase).toBe('down')
|
|
189
|
-
expect(clickManager.clickState).toBe('pendingOverflow')
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
it('should handle overflow state after quadruple click', () => {
|
|
193
|
-
const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
|
|
194
|
-
|
|
195
|
-
clickManager.handlePointerEvent(pointerDown) // first
|
|
196
|
-
clickManager.handlePointerEvent(pointerDown) // second
|
|
197
|
-
clickManager.handlePointerEvent(pointerDown) // third
|
|
198
|
-
clickManager.handlePointerEvent(pointerDown) // fourth
|
|
199
|
-
const result = clickManager.handlePointerEvent(pointerDown) // fifth
|
|
203
|
+
clickManager.handlePointerEvent(pointerDown) // third (overflow)
|
|
204
|
+
const result = clickManager.handlePointerEvent(pointerDown) // fourth
|
|
200
205
|
|
|
201
206
|
expect(result).toBe(pointerDown)
|
|
202
207
|
expect(clickManager.clickState).toBe('overflow')
|
|
203
208
|
})
|
|
204
209
|
|
|
205
|
-
it('should
|
|
206
|
-
const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
|
|
207
|
-
const pointerUp = createPointerEvent('pointer_up', { x: 100, y: 100 })
|
|
208
|
-
|
|
209
|
-
clickManager.handlePointerEvent(pointerDown) // first
|
|
210
|
-
clickManager.handlePointerEvent(pointerDown) // second
|
|
211
|
-
clickManager.handlePointerEvent(pointerDown) // third
|
|
212
|
-
const result = clickManager.handlePointerEvent(pointerUp) as TLClickEventInfo
|
|
213
|
-
|
|
214
|
-
expect(result.type).toBe('click')
|
|
215
|
-
expect(result.name).toBe('triple_click')
|
|
216
|
-
expect(result.phase).toBe('up')
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
it('should generate quadruple_click up event on pointer_up after quadruple_click down', () => {
|
|
210
|
+
it('should not emit double_click up events while in overflow', () => {
|
|
220
211
|
const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
|
|
221
212
|
const pointerUp = createPointerEvent('pointer_up', { x: 100, y: 100 })
|
|
222
213
|
|
|
223
214
|
clickManager.handlePointerEvent(pointerDown) // first
|
|
224
215
|
clickManager.handlePointerEvent(pointerDown) // second
|
|
225
216
|
clickManager.handlePointerEvent(pointerDown) // third
|
|
226
|
-
clickManager.handlePointerEvent(
|
|
227
|
-
const result = clickManager.handlePointerEvent(pointerUp) as TLClickEventInfo
|
|
217
|
+
const result = clickManager.handlePointerEvent(pointerUp)
|
|
228
218
|
|
|
229
|
-
expect(result
|
|
230
|
-
expect(
|
|
231
|
-
expect(result.phase).toBe('up')
|
|
219
|
+
expect(result).toBe(pointerUp)
|
|
220
|
+
expect(clickManager.clickState).toBe('overflow')
|
|
232
221
|
})
|
|
233
|
-
})
|
|
234
222
|
|
|
235
|
-
|
|
236
|
-
it('should dispatch triple_click settle event after timeout in pendingQuadruple', () => {
|
|
223
|
+
it('should return to idle after overflow timeout without dispatching a settle event', () => {
|
|
237
224
|
const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
|
|
238
225
|
|
|
239
226
|
clickManager.handlePointerEvent(pointerDown) // first
|
|
240
227
|
clickManager.handlePointerEvent(pointerDown) // second
|
|
241
|
-
clickManager.handlePointerEvent(pointerDown) // third
|
|
228
|
+
clickManager.handlePointerEvent(pointerDown) // third -> overflow
|
|
242
229
|
|
|
243
230
|
vi.advanceTimersByTime(350)
|
|
244
231
|
|
|
245
|
-
expect(editor.dispatch).
|
|
246
|
-
expect.objectContaining({
|
|
247
|
-
type: 'click',
|
|
248
|
-
name: 'triple_click',
|
|
249
|
-
phase: 'settle',
|
|
250
|
-
})
|
|
251
|
-
)
|
|
232
|
+
expect(editor.dispatch).not.toHaveBeenCalled()
|
|
252
233
|
expect(clickManager.clickState).toBe('idle')
|
|
253
234
|
})
|
|
235
|
+
})
|
|
254
236
|
|
|
255
|
-
|
|
256
|
-
|
|
237
|
+
describe('timeout behavior and settle events', () => {
|
|
238
|
+
it('should track press/release state across the pending window (settle-down then release → settle-up)', () => {
|
|
239
|
+
const down = createPointerEvent('pointer_down', { x: 100, y: 100 })
|
|
240
|
+
const up = createPointerEvent('pointer_up', { x: 100, y: 100 })
|
|
257
241
|
|
|
258
|
-
clickManager.handlePointerEvent(
|
|
259
|
-
clickManager.handlePointerEvent(
|
|
260
|
-
clickManager.handlePointerEvent(
|
|
261
|
-
clickManager.handlePointerEvent(
|
|
242
|
+
clickManager.handlePointerEvent(down)
|
|
243
|
+
clickManager.handlePointerEvent(up)
|
|
244
|
+
clickManager.handlePointerEvent(down) // second press — pointer is down...
|
|
245
|
+
clickManager.handlePointerEvent(up) // ...but released before timeout
|
|
262
246
|
|
|
263
247
|
vi.advanceTimersByTime(350)
|
|
264
248
|
|
|
265
249
|
expect(editor.dispatch).toHaveBeenCalledWith(
|
|
266
250
|
expect.objectContaining({
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
phase: 'settle',
|
|
251
|
+
name: 'double_click',
|
|
252
|
+
phase: 'settle-up',
|
|
270
253
|
})
|
|
271
254
|
)
|
|
272
|
-
expect(clickManager.clickState).toBe('idle')
|
|
273
255
|
})
|
|
274
256
|
|
|
275
257
|
it('should use different timeout durations for different states', () => {
|
|
@@ -316,7 +298,7 @@ describe('ClickManager', () => {
|
|
|
316
298
|
|
|
317
299
|
expect(result.type).toBe('click')
|
|
318
300
|
expect(result.name).toBe('double_click')
|
|
319
|
-
expect(clickManager.clickState).toBe('
|
|
301
|
+
expect(clickManager.clickState).toBe('pendingOverflow')
|
|
320
302
|
})
|
|
321
303
|
})
|
|
322
304
|
|
|
@@ -396,7 +378,7 @@ describe('ClickManager', () => {
|
|
|
396
378
|
|
|
397
379
|
clickManager.handlePointerEvent(pointerDown)
|
|
398
380
|
clickManager.handlePointerEvent(pointerDown) // double click
|
|
399
|
-
expect(clickManager.clickState).toBe('
|
|
381
|
+
expect(clickManager.clickState).toBe('pendingOverflow')
|
|
400
382
|
|
|
401
383
|
clickManager.cancelDoubleClickTimeout()
|
|
402
384
|
|
|
@@ -416,9 +398,7 @@ describe('ClickManager', () => {
|
|
|
416
398
|
// Get to overflow state
|
|
417
399
|
clickManager.handlePointerEvent(pointerDown) // 1
|
|
418
400
|
clickManager.handlePointerEvent(pointerDown) // 2
|
|
419
|
-
clickManager.handlePointerEvent(pointerDown) // 3
|
|
420
|
-
clickManager.handlePointerEvent(pointerDown) // 4
|
|
421
|
-
clickManager.handlePointerEvent(pointerDown) // 5 -> overflow
|
|
401
|
+
clickManager.handlePointerEvent(pointerDown) // 3 -> overflow
|
|
422
402
|
|
|
423
403
|
expect(clickManager.clickState).toBe('overflow')
|
|
424
404
|
|
|
@@ -4,13 +4,7 @@ import type { Editor } from '../../Editor'
|
|
|
4
4
|
import { TLClickEventInfo, TLPointerEventInfo } from '../../types/event-types'
|
|
5
5
|
|
|
6
6
|
/** @public */
|
|
7
|
-
export type TLClickState =
|
|
8
|
-
| 'idle'
|
|
9
|
-
| 'pendingDouble'
|
|
10
|
-
| 'pendingTriple'
|
|
11
|
-
| 'pendingQuadruple'
|
|
12
|
-
| 'pendingOverflow'
|
|
13
|
-
| 'overflow'
|
|
7
|
+
export type TLClickState = 'idle' | 'pendingDouble' | 'pendingOverflow' | 'overflow'
|
|
14
8
|
|
|
15
9
|
const MAX_CLICK_DISTANCE = 40
|
|
16
10
|
|
|
@@ -26,6 +20,8 @@ export class ClickManager {
|
|
|
26
20
|
|
|
27
21
|
private _previousScreenPoint?: Vec
|
|
28
22
|
|
|
23
|
+
private _isPressingWhilePending = false
|
|
24
|
+
|
|
29
25
|
@bind
|
|
30
26
|
_getClickTimeout(state: TLClickState, id = uniqueId()) {
|
|
31
27
|
this._clickId = id
|
|
@@ -34,30 +30,12 @@ export class ClickManager {
|
|
|
34
30
|
() => {
|
|
35
31
|
if (this._clickState === state && this._clickId === id) {
|
|
36
32
|
switch (this._clickState) {
|
|
37
|
-
case 'pendingTriple': {
|
|
38
|
-
this.editor.dispatch({
|
|
39
|
-
...this.lastPointerInfo,
|
|
40
|
-
type: 'click',
|
|
41
|
-
name: 'double_click',
|
|
42
|
-
phase: 'settle',
|
|
43
|
-
})
|
|
44
|
-
break
|
|
45
|
-
}
|
|
46
|
-
case 'pendingQuadruple': {
|
|
47
|
-
this.editor.dispatch({
|
|
48
|
-
...this.lastPointerInfo,
|
|
49
|
-
type: 'click',
|
|
50
|
-
name: 'triple_click',
|
|
51
|
-
phase: 'settle',
|
|
52
|
-
})
|
|
53
|
-
break
|
|
54
|
-
}
|
|
55
33
|
case 'pendingOverflow': {
|
|
56
34
|
this.editor.dispatch({
|
|
57
35
|
...this.lastPointerInfo,
|
|
58
36
|
type: 'click',
|
|
59
|
-
name: '
|
|
60
|
-
phase: 'settle',
|
|
37
|
+
name: 'double_click',
|
|
38
|
+
phase: this._isPressingWhilePending ? 'settle-down' : 'settle-up',
|
|
61
39
|
})
|
|
62
40
|
break
|
|
63
41
|
}
|
|
@@ -100,6 +78,8 @@ export class ClickManager {
|
|
|
100
78
|
if (!this._clickState) return info
|
|
101
79
|
this._clickScreenPoint = Vec.From(info.point)
|
|
102
80
|
|
|
81
|
+
this._isPressingWhilePending = true
|
|
82
|
+
|
|
103
83
|
if (
|
|
104
84
|
this._previousScreenPoint &&
|
|
105
85
|
Vec.Dist2(this._previousScreenPoint, this._clickScreenPoint) > MAX_CLICK_DISTANCE ** 2
|
|
@@ -113,32 +93,12 @@ export class ClickManager {
|
|
|
113
93
|
|
|
114
94
|
switch (this._clickState) {
|
|
115
95
|
case 'pendingDouble': {
|
|
116
|
-
this._clickState = 'pendingTriple'
|
|
117
|
-
this._clickTimeout = this._getClickTimeout(this._clickState)
|
|
118
|
-
return {
|
|
119
|
-
...info,
|
|
120
|
-
type: 'click',
|
|
121
|
-
name: 'double_click',
|
|
122
|
-
phase: 'down',
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
case 'pendingTriple': {
|
|
126
|
-
this._clickState = 'pendingQuadruple'
|
|
127
|
-
this._clickTimeout = this._getClickTimeout(this._clickState)
|
|
128
|
-
return {
|
|
129
|
-
...info,
|
|
130
|
-
type: 'click',
|
|
131
|
-
name: 'triple_click',
|
|
132
|
-
phase: 'down',
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
case 'pendingQuadruple': {
|
|
136
96
|
this._clickState = 'pendingOverflow'
|
|
137
97
|
this._clickTimeout = this._getClickTimeout(this._clickState)
|
|
138
98
|
return {
|
|
139
99
|
...info,
|
|
140
100
|
type: 'click',
|
|
141
|
-
name: '
|
|
101
|
+
name: 'double_click',
|
|
142
102
|
phase: 'down',
|
|
143
103
|
}
|
|
144
104
|
}
|
|
@@ -159,30 +119,17 @@ export class ClickManager {
|
|
|
159
119
|
}
|
|
160
120
|
case 'pointer_up': {
|
|
161
121
|
if (!this._clickState) return info
|
|
122
|
+
|
|
162
123
|
this._clickScreenPoint = Vec.From(info.point)
|
|
163
124
|
|
|
125
|
+
this._isPressingWhilePending = false
|
|
126
|
+
|
|
164
127
|
switch (this._clickState) {
|
|
165
|
-
case 'pendingTriple': {
|
|
166
|
-
return {
|
|
167
|
-
...this.lastPointerInfo,
|
|
168
|
-
type: 'click',
|
|
169
|
-
name: 'double_click',
|
|
170
|
-
phase: 'up',
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
case 'pendingQuadruple': {
|
|
174
|
-
return {
|
|
175
|
-
...this.lastPointerInfo,
|
|
176
|
-
type: 'click',
|
|
177
|
-
name: 'triple_click',
|
|
178
|
-
phase: 'up',
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
128
|
case 'pendingOverflow': {
|
|
182
129
|
return {
|
|
183
130
|
...this.lastPointerInfo,
|
|
184
131
|
type: 'click',
|
|
185
|
-
name: '
|
|
132
|
+
name: 'double_click',
|
|
186
133
|
phase: 'up',
|
|
187
134
|
}
|
|
188
135
|
}
|
|
@@ -219,5 +166,8 @@ export class ClickManager {
|
|
|
219
166
|
cancelDoubleClickTimeout() {
|
|
220
167
|
this._clickTimeout = clearTimeout(this._clickTimeout)
|
|
221
168
|
this._clickState = 'idle'
|
|
169
|
+
// when a double click is cancelled, we are no longer pending any further
|
|
170
|
+
// clicks, so we set this to false even if the user is still pressing
|
|
171
|
+
this._isPressingWhilePending = false
|
|
222
172
|
}
|
|
223
173
|
}
|
|
@@ -129,4 +129,18 @@ describe(CollaboratorsManager, () => {
|
|
|
129
129
|
|
|
130
130
|
expect(manager.getVisibleCollaborators()).toHaveLength(1)
|
|
131
131
|
})
|
|
132
|
+
|
|
133
|
+
it('shows newly-joined collaborators that have not recorded any activity yet', () => {
|
|
134
|
+
// A peer who has joined but not moved their pointer broadcasts the default
|
|
135
|
+
// `lastActivityTimestamp` of 0. They should still be treated as active so
|
|
136
|
+
// they appear in the people menu / face pile. See issue #9017.
|
|
137
|
+
const zero = createPresence('zero')
|
|
138
|
+
zero.lastActivityTimestamp = 0
|
|
139
|
+
const nullish = createPresence('nullish')
|
|
140
|
+
nullish.lastActivityTimestamp = null
|
|
141
|
+
const { editor } = createEditor([zero, nullish])
|
|
142
|
+
const manager = new CollaboratorsManager(editor)
|
|
143
|
+
|
|
144
|
+
expect(manager.getVisibleCollaborators()).toHaveLength(2)
|
|
145
|
+
})
|
|
132
146
|
})
|
|
@@ -93,9 +93,12 @@ export class CollaboratorsManager {
|
|
|
93
93
|
return collaborators.filter((presence) => {
|
|
94
94
|
const { lastActivityTimestamp, userId, chatMessage } = presence
|
|
95
95
|
|
|
96
|
-
// Treat a missing `lastActivityTimestamp` as "active right now"
|
|
97
|
-
// so newly-joined peers aren't immediately classified as
|
|
98
|
-
|
|
96
|
+
// Treat a missing or zero `lastActivityTimestamp` as "active right now"
|
|
97
|
+
// (elapsed = 0) so newly-joined peers aren't immediately classified as
|
|
98
|
+
// idle/inactive. The broadcast default for peers who haven't moved their
|
|
99
|
+
// pointer yet is `0` (e.g. someone on a touch device who joins and just
|
|
100
|
+
// watches), so a plain `?? now` would leave them hidden. See issue #9017.
|
|
101
|
+
const elapsed = lastActivityTimestamp ? Math.max(0, now - lastActivityTimestamp) : 0
|
|
99
102
|
|
|
100
103
|
if (elapsed > collaboratorInactiveTimeoutMs) {
|
|
101
104
|
// Inactive: If they're inactive, only show if we're following them or they're highlighted
|
|
@@ -14,7 +14,7 @@ describe('FocusManager', () => {
|
|
|
14
14
|
getInstanceState: Mock
|
|
15
15
|
updateInstanceState: Mock
|
|
16
16
|
getContainer: Mock
|
|
17
|
-
|
|
17
|
+
getEditingShapeId: Mock
|
|
18
18
|
getSelectedShapeIds: Mock
|
|
19
19
|
complete: Mock
|
|
20
20
|
}
|
|
@@ -51,7 +51,7 @@ describe('FocusManager', () => {
|
|
|
51
51
|
updateInstanceState: vi.fn(),
|
|
52
52
|
getContainer: vi.fn(() => mockContainer),
|
|
53
53
|
getContainerDocument: vi.fn(() => document),
|
|
54
|
-
|
|
54
|
+
getEditingShapeId: vi.fn(() => null),
|
|
55
55
|
getSelectedShapeIds: vi.fn(() => []),
|
|
56
56
|
complete: vi.fn(),
|
|
57
57
|
} as any
|
|
@@ -243,7 +243,7 @@ describe('FocusManager', () => {
|
|
|
243
243
|
})
|
|
244
244
|
|
|
245
245
|
it('should return early when editor is in editing mode', () => {
|
|
246
|
-
editor.
|
|
246
|
+
editor.getEditingShapeId.mockReturnValue('shape:1')
|
|
247
247
|
const event = new KeyboardEvent('keydown', { key: 'Tab' })
|
|
248
248
|
|
|
249
249
|
keydownHandler(event)
|
|
@@ -407,7 +407,7 @@ describe('FocusManager', () => {
|
|
|
407
407
|
const keydownCall = addEventListenerCalls.find((call: any) => call[0] === 'keydown')
|
|
408
408
|
const keydownHandler = keydownCall![1]
|
|
409
409
|
|
|
410
|
-
editor.
|
|
410
|
+
editor.getEditingShapeId.mockReturnValue('shape:1') // Editing mode
|
|
411
411
|
|
|
412
412
|
const event = new KeyboardEvent('keydown', { key: 'Tab' })
|
|
413
413
|
keydownHandler(event)
|
|
@@ -63,8 +63,7 @@ export class FocusManager {
|
|
|
63
63
|
const activeEl = container.ownerDocument.activeElement
|
|
64
64
|
// Edit mode should remove the focus ring, however if the active element's
|
|
65
65
|
// parent is the contextual toolbar, then allow it.
|
|
66
|
-
if (this.editor.
|
|
67
|
-
return
|
|
66
|
+
if (this.editor.getEditingShapeId() && !activeEl?.closest('.tlui-contextual-toolbar')) return
|
|
68
67
|
if (activeEl === container && this.editor.getSelectedShapeIds().length > 0) return
|
|
69
68
|
if (['Tab', 'ArrowUp', 'ArrowDown'].includes(keyEvent.key)) {
|
|
70
69
|
container.classList.remove('tl-container__no-focus-ring')
|