@tldraw/editor 5.2.0-next.355d68cf24e8 → 5.2.0-next.43d958280e72

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 (101) hide show
  1. package/README.md +7 -1
  2. package/dist-cjs/index.d.ts +44 -40
  3. package/dist-cjs/index.js +4 -4
  4. package/dist-cjs/index.js.map +2 -2
  5. package/dist-cjs/lib/components/MenuClickCapture.js +8 -5
  6. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  7. package/dist-cjs/lib/editor/Editor.js +91 -44
  8. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  9. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +3 -3
  10. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  11. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js +24 -2
  12. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js.map +2 -2
  13. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +14 -3
  14. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
  15. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +4 -2
  16. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
  17. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +7 -3
  18. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +0 -1
  20. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +15 -2
  22. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js +79 -0
  24. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js.map +7 -0
  25. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +3 -0
  26. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
  27. package/dist-cjs/lib/hooks/useCanvasEvents.js +14 -7
  28. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  29. package/dist-cjs/lib/hooks/usePresence.js.map +2 -2
  30. package/dist-cjs/lib/utils/getPointerInfo.js +2 -1
  31. package/dist-cjs/lib/utils/getPointerInfo.js.map +2 -2
  32. package/dist-cjs/lib/utils/pointer.js +32 -0
  33. package/dist-cjs/lib/utils/pointer.js.map +7 -0
  34. package/dist-cjs/version.js +3 -3
  35. package/dist-cjs/version.js.map +1 -1
  36. package/dist-esm/index.d.mts +44 -40
  37. package/dist-esm/index.mjs +5 -7
  38. package/dist-esm/index.mjs.map +2 -2
  39. package/dist-esm/lib/components/MenuClickCapture.mjs +8 -5
  40. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  41. package/dist-esm/lib/editor/Editor.mjs +91 -44
  42. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  43. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +3 -3
  44. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  45. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs +24 -2
  46. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs.map +2 -2
  47. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +14 -3
  48. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
  49. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +4 -2
  50. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
  51. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +7 -3
  52. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  53. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +0 -1
  54. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
  55. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +15 -2
  56. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  57. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs +59 -0
  58. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs.map +7 -0
  59. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +3 -0
  60. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
  61. package/dist-esm/lib/hooks/useCanvasEvents.mjs +14 -7
  62. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  63. package/dist-esm/lib/hooks/usePresence.mjs.map +2 -2
  64. package/dist-esm/lib/utils/getPointerInfo.mjs +2 -1
  65. package/dist-esm/lib/utils/getPointerInfo.mjs.map +2 -2
  66. package/dist-esm/lib/utils/pointer.mjs +12 -0
  67. package/dist-esm/lib/utils/pointer.mjs.map +7 -0
  68. package/dist-esm/version.mjs +3 -3
  69. package/dist-esm/version.mjs.map +1 -1
  70. package/editor.css +5 -3
  71. package/package.json +8 -8
  72. package/src/index.ts +2 -5
  73. package/src/lib/components/MenuClickCapture.tsx +8 -4
  74. package/src/lib/editor/Editor.ts +115 -53
  75. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts +43 -16
  76. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +8 -5
  77. package/src/lib/editor/managers/FontManager/FontManager.test.ts +13 -9
  78. package/src/lib/editor/managers/HistoryManager/HistoryManager.test.ts +32 -0
  79. package/src/lib/editor/managers/HistoryManager/HistoryManager.ts +34 -4
  80. package/src/lib/editor/managers/InputsManager/InputsManager.test.ts +61 -0
  81. package/src/lib/editor/managers/InputsManager/InputsManager.ts +16 -4
  82. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +9 -2
  83. package/src/lib/editor/managers/TextManager/TextManager.test.ts +16 -14
  84. package/src/lib/editor/managers/TextManager/TextManager.ts +17 -2
  85. package/src/lib/editor/managers/TickManager/TickManager.test.ts +0 -40
  86. package/src/lib/editor/managers/TickManager/TickManager.ts +0 -1
  87. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +12 -2
  88. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +27 -2
  89. package/src/lib/editor/overlays/strokeShapeIndicators.ts +86 -0
  90. package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +4 -0
  91. package/src/lib/hooks/useCanvasEvents.ts +19 -12
  92. package/src/lib/hooks/usePresence.ts +2 -2
  93. package/src/lib/utils/getPointerInfo.ts +2 -1
  94. package/src/lib/utils/pointer.test.ts +48 -0
  95. package/src/lib/utils/pointer.ts +18 -0
  96. package/src/version.ts +3 -3
  97. package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js +0 -161
  98. package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js.map +0 -7
  99. package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs +0 -141
  100. package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs.map +0 -7
  101. package/src/lib/editor/overlays/ShapeIndicatorOverlayUtil.ts +0 -216
package/editor.css CHANGED
@@ -182,6 +182,7 @@
182
182
  --tl-color-text-0: hsl(0, 0%, 11%);
183
183
  --tl-color-text-1: hsl(0, 0%, 18%);
184
184
  --tl-color-text-3: hsl(204, 4%, 45%);
185
+ --tl-color-text-disabled: hsl(204, 4%, 55%);
185
186
  --tl-color-text-shadow: hsl(0, 0%, 100%);
186
187
  --tl-color-text-highlight: hsl(52, 100%, 50%);
187
188
  --tl-color-text-highlight-p3: color(display-p3 0.972 0.8205 0.05);
@@ -236,6 +237,7 @@
236
237
  --tl-color-text-0: hsl(0, 9%, 94%);
237
238
  --tl-color-text-1: hsl(0, 0%, 85%);
238
239
  --tl-color-text-3: hsl(204, 4%, 75%);
240
+ --tl-color-text-disabled: hsl(204, 4%, 50%);
239
241
  --tl-color-text-shadow: hsl(210, 13%, 18%);
240
242
  --tl-color-text-highlight: hsl(52, 100%, 41%);
241
243
  --tl-color-text-highlight-p3: color(display-p3 0.8078 0.6225 0.0312);
@@ -762,17 +764,17 @@ input,
762
764
  .tl-rich-text ol {
763
765
  text-align: left;
764
766
  margin: 0;
765
- padding-left: 3.25ch;
767
+ padding-left: 1.625ch;
766
768
  /* Some resets, like Tailwind, nix the list styling. */
767
769
  list-style: revert;
768
770
  }
769
771
 
770
772
  .tl-rich-text ol:has(> li:nth-child(10)) {
771
- padding-left: 4.25ch;
773
+ padding-left: 2.625ch;
772
774
  }
773
775
 
774
776
  .tl-rich-text ol:has(> li:nth-child(100)) {
775
- padding-left: 5.25ch;
777
+ padding-left: 3.625ch;
776
778
  }
777
779
 
778
780
  .tl-rich-text h1,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tldraw/editor",
3
3
  "description": "tldraw infinite canvas SDK (editor).",
4
- "version": "5.2.0-next.355d68cf24e8",
4
+ "version": "5.2.0-next.43d958280e72",
5
5
  "author": {
6
6
  "name": "tldraw Inc.",
7
7
  "email": "hello@tldraw.com"
@@ -49,12 +49,12 @@
49
49
  "@tiptap/core": "^3.12.1",
50
50
  "@tiptap/pm": "^3.12.1",
51
51
  "@tiptap/react": "^3.12.1",
52
- "@tldraw/state": "5.2.0-next.355d68cf24e8",
53
- "@tldraw/state-react": "5.2.0-next.355d68cf24e8",
54
- "@tldraw/store": "5.2.0-next.355d68cf24e8",
55
- "@tldraw/tlschema": "5.2.0-next.355d68cf24e8",
56
- "@tldraw/utils": "5.2.0-next.355d68cf24e8",
57
- "@tldraw/validate": "5.2.0-next.355d68cf24e8",
52
+ "@tldraw/state": "5.2.0-next.43d958280e72",
53
+ "@tldraw/state-react": "5.2.0-next.43d958280e72",
54
+ "@tldraw/store": "5.2.0-next.43d958280e72",
55
+ "@tldraw/tlschema": "5.2.0-next.43d958280e72",
56
+ "@tldraw/utils": "5.2.0-next.43d958280e72",
57
+ "@tldraw/validate": "5.2.0-next.43d958280e72",
58
58
  "classnames": "^2.5.1",
59
59
  "eventemitter3": "^4.0.7",
60
60
  "idb": "^7.1.1",
@@ -80,7 +80,7 @@
80
80
  "react": "^19.2.1",
81
81
  "react-dom": "^19.2.1",
82
82
  "resize-observer-polyfill": "^1.5.1",
83
- "vitest": "^3.2.4"
83
+ "vitest": "^4.1.7"
84
84
  },
85
85
  "module": "dist-esm/index.mjs",
86
86
  "source": "src/index.ts",
package/src/index.ts CHANGED
@@ -128,6 +128,7 @@ export {
128
128
  export { SpatialIndexManager } from './lib/editor/managers/SpatialIndexManager/SpatialIndexManager'
129
129
  export {
130
130
  TextManager,
131
+ resolveLineHeightPx,
131
132
  type BatchMeasurementRequest,
132
133
  type TLMeasuredTextSize,
133
134
  type TLMeasureTextOpts,
@@ -185,11 +186,7 @@ export {
185
186
  type TLOverlay,
186
187
  type TLOverlayUtilConstructor,
187
188
  } from './lib/editor/overlays/OverlayUtil'
188
- export {
189
- ShapeIndicatorOverlayUtil,
190
- strokeShapeIndicators,
191
- type TLShapeIndicatorOverlay,
192
- } from './lib/editor/overlays/ShapeIndicatorOverlayUtil'
189
+ export { strokeShapeIndicators } from './lib/editor/overlays/strokeShapeIndicators'
193
190
  export {
194
191
  getPerfectDashProps,
195
192
  type PerfectDashTerminal,
@@ -6,6 +6,7 @@ import { useEditor } from '../hooks/useEditor'
6
6
  import { Vec } from '../primitives/Vec'
7
7
  import { releasePointerCapture, setPointerCapture } from '../utils/dom'
8
8
  import { getPointerInfo } from '../utils/getPointerInfo'
9
+ import { getPointerEventButton } from '../utils/pointer'
9
10
 
10
11
  /**
11
12
  * When a menu is open, this component prevents the user from interacting with the canvas.
@@ -68,18 +69,19 @@ export function MenuClickCapture() {
68
69
 
69
70
  const handlePointerDown = useCallback(
70
71
  (e: PointerEvent) => {
71
- if (e.button !== 0 && e.button !== 2) return
72
+ const button = getPointerEventButton(e)
73
+ if (button !== 0 && button !== 2) return
72
74
 
73
75
  flushSync(() => setIsPointing(true))
74
76
  setPointerCapture(e.currentTarget, e)
75
77
  rPointerState.current = {
76
78
  isDown: true,
77
79
  isDragging: false,
78
- button: e.button,
80
+ button,
79
81
  start: new Vec(e.clientX, e.clientY),
80
82
  }
81
83
 
82
- if (e.button === 2) {
84
+ if (button === 2) {
83
85
  if (!editor.options.rightClickPanning) {
84
86
  // Right-click panning off: close the open menu and swallow the native
85
87
  // contextmenu that would otherwise briefly open a new one (causing a flash).
@@ -141,13 +143,15 @@ export function MenuClickCapture() {
141
143
 
142
144
  const handlePointerUp = useCallback(
143
145
  (e: PointerEvent) => {
144
- const isStaticRightClick = e.button === 2 && !rPointerState.current.isDragging
146
+ const isStaticRightClick =
147
+ rPointerState.current.button === 2 && !rPointerState.current.isDragging
145
148
 
146
149
  editor.dispatch({
147
150
  type: 'pointer',
148
151
  target: 'canvas',
149
152
  name: 'pointer_up',
150
153
  ...getPointerInfo(editor, e),
154
+ button: rPointerState.current.button === 2 ? 2 : getPointerEventButton(e),
151
155
  })
152
156
 
153
157
  if (isStaticRightClick && editor.options.rightClickPanning) {
@@ -409,9 +409,6 @@ export class Editor extends EventEmitter<TLEventMap> {
409
409
  this._tickManager = new TickManager(this)
410
410
  this.disposables.add(() => this._tickManager.dispose())
411
411
  this.disposables.add(() => {
412
- // Reset camera state to 'idle' so the store isn't left stuck at 'moving'
413
- // when tick events stop (e.g. React strict mode disposes while camera is moving)
414
- this.off('tick', this._decayCameraStateTimeout)
415
412
  this._setCameraState('idle')
416
413
  })
417
414
 
@@ -419,6 +416,7 @@ export class Editor extends EventEmitter<TLEventMap> {
419
416
  this.disposables.add(() => this.fonts.dispose())
420
417
 
421
418
  this.inputs = new InputsManager(this)
419
+ this.disposables.add(() => this.inputs.dispose())
422
420
  this.performance = new PerformanceManager(this)
423
421
  this.disposables.add(() => this.performance.dispose())
424
422
  this.collaborators = new CollaboratorsManager(this)
@@ -1179,6 +1177,7 @@ export class Editor extends EventEmitter<TLEventMap> {
1179
1177
  this.store.dispose()
1180
1178
  this.isDisposed = true
1181
1179
  this.emit('dispose')
1180
+ this.removeAllListeners()
1182
1181
  }
1183
1182
 
1184
1183
  /* ------------------ Themes (shadowing the theme manager) ------------------ */
@@ -1551,6 +1550,15 @@ export class Editor extends EventEmitter<TLEventMap> {
1551
1550
  return this.history.getMarkIdMatching(idSubstring)
1552
1551
  }
1553
1552
 
1553
+ /**
1554
+ * Whether the editor is currently replaying history (i.e. an undo or redo is being applied).
1555
+ *
1556
+ * @internal
1557
+ */
1558
+ isReplayingHistory(): boolean {
1559
+ return this.history.isReplaying()
1560
+ }
1561
+
1554
1562
  /**
1555
1563
  * Coalesces all changes since the given mark into a single change, removing any intermediate marks.
1556
1564
  *
@@ -3068,8 +3076,8 @@ export class Editor extends EventEmitter<TLEventMap> {
3068
3076
  return baseCamera
3069
3077
  }
3070
3078
 
3071
- private _getFollowingPresence(targetUserId: string | null) {
3072
- const visited = [this.user.getId()]
3079
+ private _getFollowingPresence(targetUserId: TLUserId | null) {
3080
+ const visited = [this.user.getRecordId()]
3073
3081
  const collaborators = this.getCollaborators()
3074
3082
  let leaderPresence = null as null | TLInstancePresence
3075
3083
  while (targetUserId && !visited.includes(targetUserId)) {
@@ -4029,16 +4037,34 @@ export class Editor extends EventEmitter<TLEventMap> {
4029
4037
 
4030
4038
  this.once('stop-camera-animation', cancel)
4031
4039
 
4040
+ const dirZ = direction.z ?? 0
4041
+
4032
4042
  const moveCamera = (elapsed: number) => {
4033
4043
  const { x: cx, y: cy, z: cz } = this.getCamera()
4034
- const movementVec = Vec.Mul(direction, (currentSpeed * elapsed) / cz)
4044
+
4045
+ // Pan movement from x/y direction
4046
+ const dx = (direction.x * (currentSpeed * elapsed)) / cz
4047
+ const dy = (direction.y * (currentSpeed * elapsed)) / cz
4048
+
4049
+ let newCx = cx + dx
4050
+ let newCy = cy + dy
4051
+ let newCz = cz
4052
+
4053
+ // animate zoom if z direction is passed in
4054
+ if (dirZ !== 0) {
4055
+ newCz = cz * (1 + dirZ * currentSpeed * elapsed)
4056
+ // Adjust x/y to keep the viewport center fixed while zooming
4057
+ const center = this.getViewportScreenCenter()
4058
+ newCx += center.x / newCz - center.x / cz
4059
+ newCy += center.y / newCz - center.y / cz
4060
+ }
4035
4061
 
4036
4062
  // Apply friction
4037
4063
  currentSpeed *= 1 - friction
4038
4064
  if (currentSpeed < speedThreshold) {
4039
4065
  cancel()
4040
4066
  } else {
4041
- this._setCamera(new Vec(cx + movementVec.x, cy + movementVec.y, cz))
4067
+ this._setCamera(new Vec(newCx, newCy, newCz))
4042
4068
  }
4043
4069
  }
4044
4070
 
@@ -4060,7 +4086,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4060
4086
  * @param opts - The camera move options.
4061
4087
  * @public
4062
4088
  */
4063
- zoomToUser(userId: string, opts: TLCameraMoveOptions = { animation: { duration: 500 } }): this {
4089
+ zoomToUser(userId: TLUserId, opts: TLCameraMoveOptions = { animation: { duration: 500 } }): this {
4064
4090
  const presence = this.getCollaborators().find((c) => c.userId === userId)
4065
4091
 
4066
4092
  if (!presence) return this
@@ -4432,11 +4458,11 @@ export class Editor extends EventEmitter<TLEventMap> {
4432
4458
  *
4433
4459
  * @public
4434
4460
  */
4435
- startFollowingUser(userId: string): this {
4461
+ startFollowingUser(userId: TLUserId): this {
4436
4462
  // if we were already following someone, stop following them
4437
4463
  this.stopFollowingUser()
4438
4464
 
4439
- const thisUserId = this.user.getId()
4465
+ const thisUserId = this.user.getExternalId()
4440
4466
 
4441
4467
  if (!thisUserId) {
4442
4468
  console.warn('You should set the userId for the current instance before following a user')
@@ -8084,32 +8110,53 @@ export class Editor extends EventEmitter<TLEventMap> {
8084
8110
  * @public
8085
8111
  */
8086
8112
  resizeShape(shape: TLShapeId | TLShape, scale: VecLike, opts: TLResizeShapeOptions = {}): this {
8113
+ const partial = this.getResizeShapePartial(shape, scale, opts)
8114
+ if (partial) this.updateShapes([partial])
8115
+ return this
8116
+ }
8117
+
8118
+ /**
8119
+ * Get the update for a resized shape without committing it to the store. Interactions that
8120
+ * resize many shapes at once use this to collect all of the updates and commit them in a
8121
+ * single batch. Returns null when there is nothing to update.
8122
+ *
8123
+ * Shapes that are rotated out of alignment with the scale axis cannot be resized with a
8124
+ * single update; those shapes are resized immediately (as `resizeShape` would do) and null
8125
+ * is returned.
8126
+ *
8127
+ * @internal
8128
+ */
8129
+ getResizeShapePartial(
8130
+ shape: TLShapeId | TLShape,
8131
+ scale: VecLike,
8132
+ opts: TLResizeShapeOptions = {}
8133
+ ): TLShapePartial | null {
8087
8134
  const id = typeof shape === 'string' ? shape : shape.id
8088
- if (this.getIsReadonly()) return this
8135
+ if (this.getIsReadonly()) return null
8089
8136
 
8090
8137
  if (!Number.isFinite(scale.x)) scale = new Vec(1, scale.y)
8091
8138
  if (!Number.isFinite(scale.y)) scale = new Vec(scale.x, 1)
8092
8139
 
8093
8140
  const initialShape = opts.initialShape ?? this.getShape(id)
8094
- if (!initialShape) return this
8141
+ if (!initialShape) return null
8095
8142
 
8096
8143
  const scaleOrigin = opts.scaleOrigin ?? this.getShapePageBounds(id)?.center
8097
- if (!scaleOrigin) return this
8144
+ if (!scaleOrigin) return null
8098
8145
 
8099
8146
  const pageTransform = opts.initialPageTransform
8100
8147
  ? Mat.Cast(opts.initialPageTransform)
8101
8148
  : this.getShapePageTransform(id)
8102
- if (!pageTransform) return this
8149
+ if (!pageTransform) return null
8103
8150
 
8104
8151
  const pageRotation = pageTransform.rotation()
8105
8152
 
8106
- if (pageRotation == null) return this
8153
+ if (pageRotation == null) return null
8107
8154
 
8108
8155
  const scaleAxisRotation = opts.scaleAxisRotation ?? pageRotation
8109
8156
 
8110
8157
  const initialBounds = opts.initialBounds ?? this.getShapeGeometry(id).bounds
8111
8158
 
8112
- if (!initialBounds) return this
8159
+ if (!initialBounds) return null
8113
8160
 
8114
8161
  const isAspectRatioLocked =
8115
8162
  opts.isAspectRatioLocked ?? this.getShapeUtil(initialShape).isAspectRatioLocked(initialShape)
@@ -8119,7 +8166,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8119
8166
  // from whichever axis is being scaled the least, to avoid the shape getting bigger
8120
8167
  // than the bounds of the selection
8121
8168
  // const minScale = Math.min(Math.abs(scale.x), Math.abs(scale.y))
8122
- return this._resizeUnalignedShape(id, scale, {
8169
+ this._resizeUnalignedShape(id, scale, {
8123
8170
  ...opts,
8124
8171
  initialBounds,
8125
8172
  scaleOrigin,
@@ -8128,6 +8175,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8128
8175
  isAspectRatioLocked,
8129
8176
  initialShape,
8130
8177
  })
8178
+ return null
8131
8179
  }
8132
8180
 
8133
8181
  const util = this.getShapeUtil(initialShape)
@@ -8140,7 +8188,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8140
8188
  }
8141
8189
  }
8142
8190
 
8143
- let didResize = false
8191
+ let workingShape: TLShape | null = null
8144
8192
 
8145
8193
  if (util.onResize && util.canResize(initialShape)) {
8146
8194
  // get the model changes from the shape util
@@ -8172,7 +8220,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8172
8220
  // need to adjust the shape's x and y points in case the parent has moved since start of resizing
8173
8221
  const { x, y } = this.getPointInParentSpace(initialShape.id, initialPagePoint)
8174
8222
 
8175
- let workingShape = initialShape
8223
+ workingShape = initialShape
8176
8224
  if (!opts.skipStartAndEndCallbacks) {
8177
8225
  workingShape = applyPartialToRecordWithProps(
8178
8226
  initialShape,
@@ -8194,10 +8242,6 @@ export class Editor extends EventEmitter<TLEventMap> {
8194
8242
  }
8195
8243
  )
8196
8244
 
8197
- if (resizedShape) {
8198
- didResize = true
8199
- }
8200
-
8201
8245
  workingShape = applyPartialToRecordWithProps(workingShape, {
8202
8246
  id,
8203
8247
  type: initialShape.type as any,
@@ -8213,40 +8257,47 @@ export class Editor extends EventEmitter<TLEventMap> {
8213
8257
  )
8214
8258
  }
8215
8259
 
8216
- this.updateShapes([workingShape])
8260
+ if (resizedShape) {
8261
+ return workingShape
8262
+ }
8217
8263
  }
8218
8264
 
8219
- if (!didResize) {
8220
- // reposition shape (rather than resizing it) based on where its resized center would be
8265
+ // the shape was not resized by its util, so reposition it (rather than resizing it)
8266
+ // based on where its resized center would be
8221
8267
 
8222
- const initialPageCenter = Mat.applyToPoint(pageTransform, initialBounds.center)
8223
- // get the model changes from the shape util
8224
- const newPageCenter = this._scalePagePoint(
8225
- initialPageCenter,
8226
- scaleOrigin,
8227
- scale,
8228
- scaleAxisRotation
8229
- )
8268
+ const initialPageCenter = Mat.applyToPoint(pageTransform, initialBounds.center)
8269
+ // get the model changes from the shape util
8270
+ const newPageCenter = this._scalePagePoint(
8271
+ initialPageCenter,
8272
+ scaleOrigin,
8273
+ scale,
8274
+ scaleAxisRotation
8275
+ )
8230
8276
 
8231
- const initialPageCenterInParentSpace = this.getPointInParentSpace(
8232
- initialShape.id,
8233
- initialPageCenter
8234
- )
8235
- const newPageCenterInParentSpace = this.getPointInParentSpace(initialShape.id, newPageCenter)
8277
+ const initialPageCenterInParentSpace = this.getPointInParentSpace(
8278
+ initialShape.id,
8279
+ initialPageCenter
8280
+ )
8281
+ const newPageCenterInParentSpace = this.getPointInParentSpace(initialShape.id, newPageCenter)
8236
8282
 
8237
- const delta = Vec.Sub(newPageCenterInParentSpace, initialPageCenterInParentSpace)
8238
- // apply the changes to the model
8239
- this.updateShapes([
8240
- {
8241
- id,
8242
- type: initialShape.type as any,
8243
- x: initialShape.x + delta.x,
8244
- y: initialShape.y + delta.y,
8245
- },
8246
- ])
8283
+ const delta = Vec.Sub(newPageCenterInParentSpace, initialPageCenterInParentSpace)
8284
+
8285
+ if (workingShape) {
8286
+ // the util's onResize ran but returned no change; keep the working update (which may
8287
+ // include changes from onResizeStart / onResizeEnd) and reposition the shape
8288
+ return {
8289
+ ...workingShape,
8290
+ x: initialShape.x + delta.x,
8291
+ y: initialShape.y + delta.y,
8292
+ }
8247
8293
  }
8248
8294
 
8249
- return this
8295
+ return {
8296
+ id,
8297
+ type: initialShape.type as any,
8298
+ x: initialShape.x + delta.x,
8299
+ y: initialShape.y + delta.y,
8300
+ }
8250
8301
  }
8251
8302
 
8252
8303
  /** @internal */
@@ -11173,7 +11224,12 @@ export class Editor extends EventEmitter<TLEventMap> {
11173
11224
  inputs.setIsDragging(false)
11174
11225
 
11175
11226
  // If pen mode is off but we're not already in pen mode, turn that on
11176
- if (!isPenMode && isPen) this.updateInstanceState({ isPenMode: true })
11227
+ if (!isPenMode && isPen) {
11228
+ this.updateInstanceState({ isPenMode: true })
11229
+ // Once pen mode is on, touch input is ignored, so we discard the
11230
+ // in-progress touch interaction .
11231
+ this.interrupt()
11232
+ }
11177
11233
 
11178
11234
  // On devices with erasers (like the Surface Pen or Wacom Pen), button 5 is the eraser
11179
11235
  if (info.button === STYLUS_ERASER_BUTTON) {
@@ -11323,7 +11379,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11323
11379
  // Don't pass right-click panning events to the state chart
11324
11380
  // as it causes unintended shape selection on release
11325
11381
  if (slideSpeed > 0) {
11326
- this.slideCamera({ speed: slideSpeed, direction: slideDirection })
11382
+ this.slideCamera({
11383
+ speed: slideSpeed,
11384
+ direction: { x: slideDirection.x, y: slideDirection.y, z: 0 },
11385
+ })
11327
11386
  }
11328
11387
  this._selectedShapeIdsAtPointerDown = []
11329
11388
  this._didCaptureSelectionAtPointerDown = false
@@ -11332,7 +11391,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11332
11391
  }
11333
11392
 
11334
11393
  if (slideSpeed > 0) {
11335
- this.slideCamera({ speed: slideSpeed, direction: slideDirection })
11394
+ this.slideCamera({
11395
+ speed: slideSpeed,
11396
+ direction: { x: slideDirection.x, y: slideDirection.y, z: 0 },
11397
+ })
11336
11398
  }
11337
11399
  } else {
11338
11400
  if (info.button === STYLUS_ERASER_BUTTON) {
@@ -1,11 +1,17 @@
1
- import { PageRecordType, type TLInstancePresence } from '@tldraw/tlschema'
1
+ import {
2
+ PageRecordType,
3
+ createUserId,
4
+ type TLInstancePresence,
5
+ type TLUserId,
6
+ } from '@tldraw/tlschema'
2
7
  import { vi } from 'vitest'
8
+ import { createTLStore } from '../../../config/createTLStore'
3
9
  import type { Editor } from '../../Editor'
4
10
  import { CollaboratorsManager } from './CollaboratorsManager'
5
11
 
6
12
  const currentPageId = PageRecordType.createId('page')
7
13
 
8
- function createPresence(userId: string): TLInstancePresence {
14
+ function createPresence(userId: TLUserId): TLInstancePresence {
9
15
  return {
10
16
  typeName: 'instance_presence',
11
17
  id: `instance_presence:${userId}` as TLInstancePresence['id'],
@@ -34,6 +40,9 @@ function createEditor(presences: TLInstancePresence[] = []) {
34
40
  }))
35
41
  const userGetId = vi.fn(() => 'current-user')
36
42
 
43
+ const store = createTLStore()
44
+ store.put(presences)
45
+
37
46
  const editor = {
38
47
  options: {
39
48
  collaboratorCheckIntervalMs: 1000,
@@ -45,14 +54,9 @@ function createEditor(presences: TLInstancePresence[] = []) {
45
54
  },
46
55
  user: {
47
56
  getId: userGetId,
57
+ getRecordId: () => createUserId(userGetId()),
48
58
  },
49
- store: {
50
- query: {
51
- records: vi.fn(() => ({
52
- get: () => presences,
53
- })),
54
- },
55
- },
59
+ store,
56
60
  getInstanceState,
57
61
  getCurrentPageId: vi.fn(() => currentPageId),
58
62
  } as unknown as Editor
@@ -88,10 +92,19 @@ describe(CollaboratorsManager, () => {
88
92
  expect(setInterval).toHaveBeenCalledTimes(1)
89
93
  })
90
94
 
95
+ it("excludes the local user's own other sessions", () => {
96
+ const ownSession = createPresence(createUserId('current-user'))
97
+ const peer = createPresence(createUserId('peer'))
98
+ const { editor } = createEditor([ownSession, peer])
99
+ const manager = new CollaboratorsManager(editor)
100
+
101
+ expect(manager.getCollaborators()).toEqual([peer])
102
+ })
103
+
91
104
  it('reads instance state once when filtering visible collaborators', () => {
92
105
  const { editor, getInstanceState } = createEditor([
93
- createPresence('user-1'),
94
- createPresence('user-2'),
106
+ createPresence(createUserId('user-1')),
107
+ createPresence(createUserId('user-2')),
95
108
  ])
96
109
  const manager = new CollaboratorsManager(editor)
97
110
 
@@ -101,9 +114,9 @@ describe(CollaboratorsManager, () => {
101
114
  })
102
115
 
103
116
  it('hides idle collaborators that are following us', () => {
104
- const presence = createPresence('peer')
117
+ const presence = createPresence(createUserId('peer'))
105
118
  presence.lastActivityTimestamp = Date.now() - 4000
106
- presence.followingUserId = 'current-user'
119
+ presence.followingUserId = createUserId('current-user')
107
120
  const { editor } = createEditor([presence])
108
121
  const manager = new CollaboratorsManager(editor)
109
122
 
@@ -111,9 +124,9 @@ describe(CollaboratorsManager, () => {
111
124
  })
112
125
 
113
126
  it('shows idle collaborators that are following us when they have a chat message', () => {
114
- const presence = createPresence('peer')
127
+ const presence = createPresence(createUserId('peer'))
115
128
  presence.lastActivityTimestamp = Date.now() - 4000
116
- presence.followingUserId = 'current-user'
129
+ presence.followingUserId = createUserId('current-user')
117
130
  presence.chatMessage = 'hi'
118
131
  const { editor } = createEditor([presence])
119
132
  const manager = new CollaboratorsManager(editor)
@@ -122,11 +135,25 @@ describe(CollaboratorsManager, () => {
122
135
  })
123
136
 
124
137
  it('shows idle collaborators that are not following us', () => {
125
- const presence = createPresence('peer')
138
+ const presence = createPresence(createUserId('peer'))
126
139
  presence.lastActivityTimestamp = Date.now() - 4000
127
140
  const { editor } = createEditor([presence])
128
141
  const manager = new CollaboratorsManager(editor)
129
142
 
130
143
  expect(manager.getVisibleCollaborators()).toHaveLength(1)
131
144
  })
145
+
146
+ it('shows newly-joined collaborators that have not recorded any activity yet', () => {
147
+ // A peer who has joined but not moved their pointer broadcasts the default
148
+ // `lastActivityTimestamp` of 0. They should still be treated as active so
149
+ // they appear in the people menu / face pile. See issue #9017.
150
+ const zero = createPresence(createUserId('zero'))
151
+ zero.lastActivityTimestamp = 0
152
+ const nullish = createPresence(createUserId('nullish'))
153
+ nullish.lastActivityTimestamp = null
154
+ const { editor } = createEditor([zero, nullish])
155
+ const manager = new CollaboratorsManager(editor)
156
+
157
+ expect(manager.getVisibleCollaborators()).toHaveLength(2)
158
+ })
132
159
  })
@@ -37,7 +37,7 @@ export class CollaboratorsManager {
37
37
  @computed
38
38
  private _getCollaboratorsQuery() {
39
39
  return this.editor.store.query.records('instance_presence', () => ({
40
- userId: { neq: this.editor.user.getId() },
40
+ userId: { neq: this.editor.user.getRecordId() },
41
41
  }))
42
42
  }
43
43
 
@@ -88,14 +88,17 @@ export class CollaboratorsManager {
88
88
  if (!collaborators.length) return EMPTY_ARRAY
89
89
 
90
90
  const { followingUserId, highlightedUserIds } = this.editor.getInstanceState()
91
- const currentUserId = this.editor.user.getId()
91
+ const currentUserId = this.editor.user.getRecordId()
92
92
 
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
@@ -15,12 +15,14 @@ import { FontManager } from './FontManager'
15
15
  vi.mock('../../Editor')
16
16
 
17
17
  // Mock globals
18
- global.FontFace = vi.fn().mockImplementation((family, src, descriptors) => ({
19
- family,
20
- src,
21
- ...descriptors,
22
- load: vi.fn(() => Promise.resolve()),
23
- }))
18
+ global.FontFace = vi.fn().mockImplementation(function (family: any, src: any, descriptors: any) {
19
+ return {
20
+ family,
21
+ src,
22
+ ...descriptors,
23
+ load: vi.fn(() => Promise.resolve()),
24
+ }
25
+ })
24
26
 
25
27
  Object.defineProperty(global.document, 'fonts', {
26
28
  value: {
@@ -200,9 +202,11 @@ describe('FontManager', () => {
200
202
  const font = createMockFont()
201
203
  const error = new Error('Font load failed')
202
204
 
203
- ;(global.FontFace as Mock).mockReturnValue({
204
- family: font.family,
205
- load: vi.fn(() => Promise.reject(error)),
205
+ ;(global.FontFace as Mock).mockImplementationOnce(function () {
206
+ return {
207
+ family: font.family,
208
+ load: vi.fn(() => Promise.reject(error)),
209
+ }
206
210
  })
207
211
 
208
212
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})