@tldraw/editor 5.1.1 → 5.2.0-canary.0878dbd31f0d

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 (117) hide show
  1. package/README.md +7 -1
  2. package/dist-cjs/index.d.ts +33 -50
  3. package/dist-cjs/index.js +3 -4
  4. package/dist-cjs/index.js.map +2 -2
  5. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +4 -1
  6. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +3 -3
  7. package/dist-cjs/lib/components/default-components/DefaultLoadingScreen.js +2 -2
  8. package/dist-cjs/lib/components/default-components/DefaultLoadingScreen.js.map +2 -2
  9. package/dist-cjs/lib/components/default-components/DefaultShapeErrorFallback.js +1 -1
  10. package/dist-cjs/lib/components/default-components/DefaultShapeErrorFallback.js.map +3 -3
  11. package/dist-cjs/lib/components/default-components/DefaultSvgDefs.js +2 -2
  12. package/dist-cjs/lib/components/default-components/DefaultSvgDefs.js.map +2 -2
  13. package/dist-cjs/lib/editor/Editor.js +57 -18
  14. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  15. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -2
  16. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  17. package/dist-cjs/lib/editor/derivations/parentsToChildren.js +2 -2
  18. package/dist-cjs/lib/editor/derivations/parentsToChildren.js.map +2 -2
  19. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +2 -2
  20. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +8 -58
  22. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +3 -3
  24. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js +1 -2
  26. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js.map +2 -2
  27. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +15 -2
  28. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  29. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js +79 -0
  30. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js.map +7 -0
  31. package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
  32. package/dist-cjs/lib/editor/types/event-types.js +0 -2
  33. package/dist-cjs/lib/editor/types/event-types.js.map +2 -2
  34. package/dist-cjs/lib/hooks/usePresence.js.map +2 -2
  35. package/dist-cjs/lib/license/LicenseProvider.js +3 -1
  36. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  37. package/dist-cjs/lib/primitives/utils.js +2 -2
  38. package/dist-cjs/lib/primitives/utils.js.map +2 -2
  39. package/dist-cjs/lib/utils/dom.js +5 -3
  40. package/dist-cjs/lib/utils/dom.js.map +2 -2
  41. package/dist-cjs/version.js +3 -3
  42. package/dist-cjs/version.js.map +1 -1
  43. package/dist-esm/index.d.mts +33 -50
  44. package/dist-esm/index.mjs +2 -6
  45. package/dist-esm/index.mjs.map +2 -2
  46. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +4 -1
  47. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
  48. package/dist-esm/lib/components/default-components/DefaultLoadingScreen.mjs +2 -2
  49. package/dist-esm/lib/components/default-components/DefaultLoadingScreen.mjs.map +2 -2
  50. package/dist-esm/lib/components/default-components/DefaultShapeErrorFallback.mjs +1 -1
  51. package/dist-esm/lib/components/default-components/DefaultShapeErrorFallback.mjs.map +3 -3
  52. package/dist-esm/lib/components/default-components/DefaultSvgDefs.mjs +2 -2
  53. package/dist-esm/lib/components/default-components/DefaultSvgDefs.mjs.map +2 -2
  54. package/dist-esm/lib/editor/Editor.mjs +57 -18
  55. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  56. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -2
  57. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  58. package/dist-esm/lib/editor/derivations/parentsToChildren.mjs +2 -2
  59. package/dist-esm/lib/editor/derivations/parentsToChildren.mjs.map +2 -2
  60. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +2 -2
  61. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
  62. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +8 -58
  63. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
  64. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +3 -3
  65. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  66. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs +1 -2
  67. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs.map +2 -2
  68. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +15 -2
  69. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  70. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs +59 -0
  71. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs.map +7 -0
  72. package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
  73. package/dist-esm/lib/editor/types/event-types.mjs +0 -2
  74. package/dist-esm/lib/editor/types/event-types.mjs.map +2 -2
  75. package/dist-esm/lib/hooks/usePresence.mjs.map +2 -2
  76. package/dist-esm/lib/license/LicenseProvider.mjs +3 -1
  77. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  78. package/dist-esm/lib/primitives/utils.mjs +2 -2
  79. package/dist-esm/lib/primitives/utils.mjs.map +2 -2
  80. package/dist-esm/lib/utils/dom.mjs +5 -3
  81. package/dist-esm/lib/utils/dom.mjs.map +2 -2
  82. package/dist-esm/version.mjs +3 -3
  83. package/dist-esm/version.mjs.map +1 -1
  84. package/editor.css +2 -0
  85. package/package.json +8 -8
  86. package/src/index.ts +1 -5
  87. package/src/lib/components/default-components/DefaultErrorFallback.tsx +4 -1
  88. package/src/lib/components/default-components/DefaultLoadingScreen.tsx +1 -1
  89. package/src/lib/components/default-components/DefaultShapeErrorFallback.tsx +4 -3
  90. package/src/lib/components/default-components/DefaultSvgDefs.tsx +1 -1
  91. package/src/lib/editor/Editor.ts +92 -29
  92. package/src/lib/editor/derivations/bindingsIndex.ts +1 -1
  93. package/src/lib/editor/derivations/parentsToChildren.ts +1 -1
  94. package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +1 -1
  95. package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +54 -74
  96. package/src/lib/editor/managers/ClickManager/ClickManager.ts +15 -65
  97. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts +43 -16
  98. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +8 -5
  99. package/src/lib/editor/managers/FocusManager/FocusManager.test.ts +4 -4
  100. package/src/lib/editor/managers/FocusManager/FocusManager.ts +1 -2
  101. package/src/lib/editor/managers/FontManager/FontManager.test.ts +13 -9
  102. package/src/lib/editor/managers/TextManager/TextManager.test.ts +16 -14
  103. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +12 -2
  104. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +27 -2
  105. package/src/lib/editor/overlays/strokeShapeIndicators.ts +86 -0
  106. package/src/lib/editor/tools/StateNode.ts +0 -2
  107. package/src/lib/editor/types/event-types.ts +2 -6
  108. package/src/lib/hooks/usePresence.ts +2 -2
  109. package/src/lib/license/LicenseProvider.tsx +3 -1
  110. package/src/lib/primitives/utils.ts +1 -1
  111. package/src/lib/utils/dom.ts +5 -3
  112. package/src/version.ts +3 -3
  113. package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js +0 -161
  114. package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js.map +0 -7
  115. package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs +0 -141
  116. package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs.map +0 -7
  117. package/src/lib/editor/overlays/ShapeIndicatorOverlayUtil.ts +0 -216
@@ -3068,8 +3068,8 @@ export class Editor extends EventEmitter<TLEventMap> {
3068
3068
  return baseCamera
3069
3069
  }
3070
3070
 
3071
- private _getFollowingPresence(targetUserId: string | null) {
3072
- const visited = [this.user.getId()]
3071
+ private _getFollowingPresence(targetUserId: TLUserId | null) {
3072
+ const visited = [this.user.getRecordId()]
3073
3073
  const collaborators = this.getCollaborators()
3074
3074
  let leaderPresence = null as null | TLInstancePresence
3075
3075
  while (targetUserId && !visited.includes(targetUserId)) {
@@ -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
 
@@ -4054,7 +4078,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4054
4078
  * @param opts - The camera move options.
4055
4079
  * @public
4056
4080
  */
4057
- zoomToUser(userId: string, opts: TLCameraMoveOptions = { animation: { duration: 500 } }): this {
4081
+ zoomToUser(userId: TLUserId, opts: TLCameraMoveOptions = { animation: { duration: 500 } }): this {
4058
4082
  const presence = this.getCollaborators().find((c) => c.userId === userId)
4059
4083
 
4060
4084
  if (!presence) return this
@@ -4426,11 +4450,11 @@ export class Editor extends EventEmitter<TLEventMap> {
4426
4450
  *
4427
4451
  * @public
4428
4452
  */
4429
- startFollowingUser(userId: string): this {
4453
+ startFollowingUser(userId: TLUserId): this {
4430
4454
  // if we were already following someone, stop following them
4431
4455
  this.stopFollowingUser()
4432
4456
 
4433
- const thisUserId = this.user.getId()
4457
+ const thisUserId = this.user.getExternalId()
4434
4458
 
4435
4459
  if (!thisUserId) {
4436
4460
  console.warn('You should set the userId for the current instance before following a user')
@@ -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
@@ -11248,6 +11302,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11248
11302
  if (this.inputs.getIsRightPointing() && !this.inputs.getIsPanning()) {
11249
11303
  this.inputs.setIsRightPointing(false)
11250
11304
  this._selectedShapeIdsAtPointerDown = []
11305
+ this._didCaptureSelectionAtPointerDown = false
11251
11306
  break // fall through to state chart dispatch as right_click
11252
11307
  }
11253
11308
 
@@ -11291,15 +11346,22 @@ export class Editor extends EventEmitter<TLEventMap> {
11291
11346
  // Don't pass right-click panning events to the state chart
11292
11347
  // as it causes unintended shape selection on release
11293
11348
  if (slideSpeed > 0) {
11294
- this.slideCamera({ speed: slideSpeed, direction: slideDirection })
11349
+ this.slideCamera({
11350
+ speed: slideSpeed,
11351
+ direction: { x: slideDirection.x, y: slideDirection.y, z: 0 },
11352
+ })
11295
11353
  }
11296
11354
  this._selectedShapeIdsAtPointerDown = []
11355
+ this._didCaptureSelectionAtPointerDown = false
11297
11356
  return this
11298
11357
  }
11299
11358
  }
11300
11359
 
11301
11360
  if (slideSpeed > 0) {
11302
- this.slideCamera({ speed: slideSpeed, direction: slideDirection })
11361
+ this.slideCamera({
11362
+ speed: slideSpeed,
11363
+ direction: { x: slideDirection.x, y: slideDirection.y, z: 0 },
11364
+ })
11303
11365
  }
11304
11366
  } else {
11305
11367
  if (info.button === STYLUS_ERASER_BUTTON) {
@@ -11312,6 +11374,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11312
11374
  // Clear the stashed selection so the next pinch captures fresh state.
11313
11375
  // This fixes Safari pinch zoom restoring outdated selections.
11314
11376
  this._selectedShapeIdsAtPointerDown = []
11377
+ this._didCaptureSelectionAtPointerDown = false
11315
11378
 
11316
11379
  break
11317
11380
  }
@@ -29,7 +29,7 @@ function fromScratch(bindingsQuery: Computed<TLBinding[], unknown>) {
29
29
  return shapesToBindings
30
30
  }
31
31
 
32
- export const bindingsIndex = (editor: Editor): Computed<TLBindingsIndex> => {
32
+ export function bindingsIndex(editor: Editor): Computed<TLBindingsIndex> {
33
33
  const { store } = editor
34
34
  const bindingsHistory = store.query.filterHistory('binding')
35
35
  const bindingsQuery = store.query.records('binding')
@@ -22,7 +22,7 @@ function fromScratch(
22
22
  return result
23
23
  }
24
24
 
25
- export const parentsToChildren = (store: TLStore) => {
25
+ export function parentsToChildren(store: TLStore) {
26
26
  const shapeIdsQuery = store.query.ids<'shape'>('shape')
27
27
  const shapeHistory = store.query.filterHistory('shape')
28
28
 
@@ -33,7 +33,7 @@ const isShapeInPage = (store: TLStore, pageId: TLPageId, shape: TLShape): boolea
33
33
  * @param store - The tldraw store.
34
34
  * @param getCurrentPageId - A function that returns the current page id.
35
35
  */
36
- export const deriveShapeIdsInCurrentPage = (store: TLStore, getCurrentPageId: () => TLPageId) => {
36
+ export function deriveShapeIdsInCurrentPage(store: TLStore, getCurrentPageId: () => TLPageId) {
37
37
  const shapesIndex = store.query.ids('shape')
38
38
  let lastPageId: null | TLPageId = null
39
39
  function fromScratch() {
@@ -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
  }