@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.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/dist-cjs/index.d.ts +7 -43
  3. package/dist-cjs/index.js +3 -4
  4. package/dist-cjs/index.js.map +2 -2
  5. package/dist-cjs/lib/editor/Editor.js +55 -16
  6. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  7. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +8 -58
  8. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
  9. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +1 -1
  10. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  11. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js +1 -2
  12. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js.map +2 -2
  13. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js +79 -0
  14. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js.map +7 -0
  15. package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
  16. package/dist-cjs/lib/editor/types/event-types.js +0 -2
  17. package/dist-cjs/lib/editor/types/event-types.js.map +2 -2
  18. package/dist-cjs/version.js +3 -3
  19. package/dist-cjs/version.js.map +1 -1
  20. package/dist-esm/index.d.mts +7 -43
  21. package/dist-esm/index.mjs +2 -6
  22. package/dist-esm/index.mjs.map +2 -2
  23. package/dist-esm/lib/editor/Editor.mjs +55 -16
  24. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  25. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +8 -58
  26. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
  27. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +1 -1
  28. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  29. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs +1 -2
  30. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs.map +2 -2
  31. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs +59 -0
  32. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs.map +7 -0
  33. package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
  34. package/dist-esm/lib/editor/types/event-types.mjs +0 -2
  35. package/dist-esm/lib/editor/types/event-types.mjs.map +2 -2
  36. package/dist-esm/version.mjs +3 -3
  37. package/dist-esm/version.mjs.map +1 -1
  38. package/editor.css +2 -0
  39. package/package.json +8 -8
  40. package/src/index.ts +1 -5
  41. package/src/lib/editor/Editor.ts +87 -24
  42. package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +54 -74
  43. package/src/lib/editor/managers/ClickManager/ClickManager.ts +15 -65
  44. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts +14 -0
  45. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +6 -3
  46. package/src/lib/editor/managers/FocusManager/FocusManager.test.ts +4 -4
  47. package/src/lib/editor/managers/FocusManager/FocusManager.ts +1 -2
  48. package/src/lib/editor/managers/FontManager/FontManager.test.ts +13 -9
  49. package/src/lib/editor/managers/TextManager/TextManager.test.ts +16 -14
  50. package/src/lib/editor/overlays/strokeShapeIndicators.ts +86 -0
  51. package/src/lib/editor/tools/StateNode.ts +0 -2
  52. package/src/lib/editor/types/event-types.ts +2 -6
  53. package/src/version.ts +3 -3
  54. package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js +0 -161
  55. package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js.map +0 -7
  56. package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs +0 -141
  57. package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs.map +0 -7
  58. package/src/lib/editor/overlays/ShapeIndicatorOverlayUtil.ts +0 -216
@@ -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
- // or in past the maximum zoom level, so stop the camera
3383
- // but keep the current center
3384
- const { x: cx, y: cy, z: cz } = currentCamera
3385
- const cxA = -cx + vsb.w / cz / 2
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
- const cxB = -cx + vsb.w / z / 2
3389
- const cyB = -cy + vsb.h / z / 2
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, preserving the center
3486
+ // constrain the zoom, keeping the caller's focal point fixed
3481
3487
  if (z > zoomMax || z < zoomMin) {
3482
- const { x: cx, y: cy, z: cz } = currentCamera
3488
+ const rz = z
3483
3489
  z = clamp(z, zoomMin, zoomMax)
3484
- x = cx + (-cx + vsb.w / z / 2) - (-cx + vsb.w / cz / 2)
3485
- y = cy + (-cy + vsb.h / z / 2) - (-cy + vsb.h / cz / 2)
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
- const movementVec = Vec.Mul(direction, (currentSpeed * elapsed) / cz)
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(cx + movementVec.x, cy + movementVec.y, cz))
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
- // Always capture the current selection when pinch starts.
10937
- // This ensures Safari (which uses gesture events instead of wheel)
10938
- // doesn't restore a stale selection from an earlier pointer_down.
10939
- this._selectedShapeIdsAtPointerDown = [...pageState.selectedShapeIds]
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 pointer down
11126
- this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds()
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({ speed: slideSpeed, direction: slideDirection })
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({ speed: slideSpeed, direction: slideDirection })
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('pendingTriple')
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 pendingTriple', () => {
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('triple and quadruple click detection', () => {
163
- it('should detect triple click on third pointer_down', () => {
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) as TLClickEventInfo
192
+ const result = clickManager.handlePointerEvent(thirdDown)
171
193
 
172
- expect(result.type).toBe('click')
173
- expect(result.name).toBe('triple_click')
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 detect quadruple click on fourth pointer_down', () => {
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 (triple_click)
184
- const result = clickManager.handlePointerEvent(pointerDown) as TLClickEventInfo // fourth
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 generate triple_click up event on pointer_up after triple_click down', () => {
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(pointerDown) // fourth
227
- const result = clickManager.handlePointerEvent(pointerUp) as TLClickEventInfo
217
+ const result = clickManager.handlePointerEvent(pointerUp)
228
218
 
229
- expect(result.type).toBe('click')
230
- expect(result.name).toBe('quadruple_click')
231
- expect(result.phase).toBe('up')
219
+ expect(result).toBe(pointerUp)
220
+ expect(clickManager.clickState).toBe('overflow')
232
221
  })
233
- })
234
222
 
235
- describe('timeout behavior and settle events', () => {
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).toHaveBeenCalledWith(
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
- it('should dispatch quadruple_click settle event after timeout in pendingOverflow', () => {
256
- const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
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(pointerDown) // first
259
- clickManager.handlePointerEvent(pointerDown) // second
260
- clickManager.handlePointerEvent(pointerDown) // third
261
- clickManager.handlePointerEvent(pointerDown) // fourth
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
- type: 'click',
268
- name: 'quadruple_click',
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('pendingTriple')
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('pendingTriple')
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: 'quadruple_click',
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: 'quadruple_click',
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: 'quadruple_click',
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" (elapsed = 0)
97
- // so newly-joined peers aren't immediately classified as idle/inactive.
98
- const elapsed = Math.max(0, now - (lastActivityTimestamp ?? now))
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
- isIn: Mock
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
- isIn: vi.fn(() => false),
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.isIn.mockReturnValue(true)
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.isIn.mockReturnValue(true) // Editing mode
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.isIn('select.editing_shape') && !activeEl?.closest('.tlui-contextual-toolbar'))
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')