@tldraw/editor 5.4.0-canary.042da660fa66 → 5.4.0-canary.059d049864ed

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 (155) hide show
  1. package/dist-cjs/index.d.ts +31 -1
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/components/MenuClickCapture.js +6 -8
  4. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  5. package/dist-cjs/lib/components/default-components/CanvasOverlays.js +1 -3
  6. package/dist-cjs/lib/components/default-components/CanvasOverlays.js.map +2 -2
  7. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +1 -1
  8. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +1 -1
  9. package/dist-cjs/lib/config/TLUserPreferences.js +6 -2
  10. package/dist-cjs/lib/config/TLUserPreferences.js.map +2 -2
  11. package/dist-cjs/lib/config/createTLStore.js +1 -1
  12. package/dist-cjs/lib/config/createTLStore.js.map +2 -2
  13. package/dist-cjs/lib/editor/Editor.js +139 -268
  14. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  15. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +8 -8
  16. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  17. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +2 -1
  18. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +79 -6
  20. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +0 -2
  22. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +1 -0
  24. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/overlays/OverlayManager.js +3 -7
  26. package/dist-cjs/lib/editor/overlays/OverlayManager.js.map +2 -2
  27. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js +0 -1
  28. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js.map +2 -2
  29. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js +3 -1
  30. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js.map +2 -2
  31. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js +1 -0
  32. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js.map +2 -2
  33. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +1 -1
  34. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
  35. package/dist-cjs/lib/editor/tools/StateNode.js +3 -2
  36. package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
  37. package/dist-cjs/lib/globals/menus.js +9 -4
  38. package/dist-cjs/lib/globals/menus.js.map +2 -2
  39. package/dist-cjs/lib/hooks/useCanvasEvents.js +2 -0
  40. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  41. package/dist-cjs/lib/hooks/useDocumentEvents.js +9 -7
  42. package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
  43. package/dist-cjs/lib/hooks/useLocalStore.js +10 -6
  44. package/dist-cjs/lib/hooks/useLocalStore.js.map +2 -2
  45. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +1 -0
  46. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
  47. package/dist-cjs/lib/hooks/usePeerIds.js +2 -1
  48. package/dist-cjs/lib/hooks/usePeerIds.js.map +2 -2
  49. package/dist-cjs/lib/primitives/Box.js +2 -1
  50. package/dist-cjs/lib/primitives/Box.js.map +2 -2
  51. package/dist-cjs/lib/primitives/Vec.js +4 -3
  52. package/dist-cjs/lib/primitives/Vec.js.map +2 -2
  53. package/dist-cjs/lib/primitives/utils.js +2 -1
  54. package/dist-cjs/lib/primitives/utils.js.map +2 -2
  55. package/dist-cjs/lib/utils/rotation.js +1 -9
  56. package/dist-cjs/lib/utils/rotation.js.map +2 -2
  57. package/dist-cjs/version.js +3 -3
  58. package/dist-cjs/version.js.map +1 -1
  59. package/dist-esm/index.d.mts +31 -1
  60. package/dist-esm/index.mjs +1 -1
  61. package/dist-esm/lib/components/MenuClickCapture.mjs +6 -8
  62. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  63. package/dist-esm/lib/components/default-components/CanvasOverlays.mjs +1 -3
  64. package/dist-esm/lib/components/default-components/CanvasOverlays.mjs.map +2 -2
  65. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +1 -1
  66. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +1 -1
  67. package/dist-esm/lib/config/TLUserPreferences.mjs +6 -2
  68. package/dist-esm/lib/config/TLUserPreferences.mjs.map +2 -2
  69. package/dist-esm/lib/config/createTLStore.mjs +1 -1
  70. package/dist-esm/lib/config/createTLStore.mjs.map +2 -2
  71. package/dist-esm/lib/editor/Editor.mjs +147 -269
  72. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  73. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +9 -9
  74. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  75. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +2 -1
  76. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  77. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +81 -8
  78. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
  79. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +0 -2
  80. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
  81. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +1 -0
  82. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  83. package/dist-esm/lib/editor/overlays/OverlayManager.mjs +3 -7
  84. package/dist-esm/lib/editor/overlays/OverlayManager.mjs.map +2 -2
  85. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs +0 -1
  86. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs.map +2 -2
  87. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs +3 -1
  88. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs.map +2 -2
  89. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs +1 -0
  90. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs.map +2 -2
  91. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +1 -1
  92. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
  93. package/dist-esm/lib/editor/tools/StateNode.mjs +3 -2
  94. package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
  95. package/dist-esm/lib/globals/menus.mjs +9 -4
  96. package/dist-esm/lib/globals/menus.mjs.map +2 -2
  97. package/dist-esm/lib/hooks/useCanvasEvents.mjs +2 -0
  98. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  99. package/dist-esm/lib/hooks/useDocumentEvents.mjs +9 -7
  100. package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
  101. package/dist-esm/lib/hooks/useLocalStore.mjs +11 -7
  102. package/dist-esm/lib/hooks/useLocalStore.mjs.map +2 -2
  103. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +1 -0
  104. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
  105. package/dist-esm/lib/hooks/usePeerIds.mjs +2 -1
  106. package/dist-esm/lib/hooks/usePeerIds.mjs.map +2 -2
  107. package/dist-esm/lib/primitives/Box.mjs +2 -1
  108. package/dist-esm/lib/primitives/Box.mjs.map +2 -2
  109. package/dist-esm/lib/primitives/Vec.mjs +4 -3
  110. package/dist-esm/lib/primitives/Vec.mjs.map +2 -2
  111. package/dist-esm/lib/primitives/utils.mjs +2 -1
  112. package/dist-esm/lib/primitives/utils.mjs.map +2 -2
  113. package/dist-esm/lib/utils/rotation.mjs +1 -9
  114. package/dist-esm/lib/utils/rotation.mjs.map +2 -2
  115. package/dist-esm/version.mjs +3 -3
  116. package/dist-esm/version.mjs.map +1 -1
  117. package/package.json +7 -7
  118. package/src/lib/components/MenuClickCapture.tsx +19 -18
  119. package/src/lib/components/default-components/CanvasOverlays.tsx +1 -3
  120. package/src/lib/components/default-components/DefaultErrorFallback.tsx +1 -1
  121. package/src/lib/config/TLUserPreferences.test.ts +18 -1
  122. package/src/lib/config/TLUserPreferences.ts +8 -4
  123. package/src/lib/config/createTLStore.ts +1 -1
  124. package/src/lib/editor/Editor.ts +196 -341
  125. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +9 -9
  126. package/src/lib/editor/managers/FontManager/FontManager.ts +2 -1
  127. package/src/lib/editor/managers/InputsManager/InputsManager.test.ts +199 -3
  128. package/src/lib/editor/managers/InputsManager/InputsManager.ts +128 -9
  129. package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +0 -2
  130. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +1 -0
  131. package/src/lib/editor/overlays/OverlayManager.ts +3 -7
  132. package/src/lib/editor/overlays/strokeShapeIndicators.ts +0 -1
  133. package/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx +5 -1
  134. package/src/lib/editor/shapes/shared/getPerfectDashProps.ts +3 -0
  135. package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +4 -1
  136. package/src/lib/editor/tools/StateNode.test.ts +64 -0
  137. package/src/lib/editor/tools/StateNode.ts +12 -2
  138. package/src/lib/globals/menus.test.ts +33 -0
  139. package/src/lib/globals/menus.ts +11 -4
  140. package/src/lib/hooks/useCanvasEvents.ts +2 -0
  141. package/src/lib/hooks/useDocumentEvents.ts +14 -7
  142. package/src/lib/hooks/useLocalStore.ts +16 -8
  143. package/src/lib/hooks/usePassThroughWheelEvents.ts +3 -0
  144. package/src/lib/hooks/usePeerIds.ts +2 -1
  145. package/src/lib/primitives/Box.test.ts +9 -1
  146. package/src/lib/primitives/Box.ts +3 -1
  147. package/src/lib/primitives/Vec.test.ts +10 -0
  148. package/src/lib/primitives/Vec.ts +4 -4
  149. package/src/lib/primitives/utils.test.ts +10 -1
  150. package/src/lib/primitives/utils.ts +4 -1
  151. package/src/lib/test/useCanvasEvents.test.tsx +74 -0
  152. package/src/lib/test/useDocumentEvents.test.tsx +31 -0
  153. package/src/lib/test/usePassThroughWheelEvents.test.tsx +25 -2
  154. package/src/lib/utils/rotation.ts +4 -12
  155. package/src/version.ts +3 -3
@@ -1,6 +1,6 @@
1
1
  import { EMPTY_ARRAY, atom, computed } from '@tldraw/state'
2
2
  import type { TLInstancePresence } from '@tldraw/tlschema'
3
- import { areArraysShallowEqual, maxBy } from '@tldraw/utils'
3
+ import { areArraysShallowEqual } from '@tldraw/utils'
4
4
  import type { Editor } from '../../Editor'
5
5
 
6
6
  /**
@@ -54,14 +54,14 @@ export class CollaboratorsManager {
54
54
  getCollaborators(): TLInstancePresence[] {
55
55
  const allPresenceRecords = this._getCollaboratorsQuery().get()
56
56
  if (!allPresenceRecords.length) return EMPTY_ARRAY
57
- const userIds = [...new Set(allPresenceRecords.map((c) => c.userId))].sort()
58
- return userIds.map((id) => {
59
- const latestPresence = maxBy(
60
- allPresenceRecords.filter((c) => c.userId === id),
61
- (p) => p.lastActivityTimestamp ?? 0
62
- )
63
- return latestPresence!
64
- })
57
+ const latestByUserId = new Map<string, TLInstancePresence>()
58
+ for (const presence of allPresenceRecords) {
59
+ const latest = latestByUserId.get(presence.userId)
60
+ if (!latest || (presence.lastActivityTimestamp ?? 0) > (latest.lastActivityTimestamp ?? 0)) {
61
+ latestByUserId.set(presence.userId, presence)
62
+ }
63
+ }
64
+ return [...latestByUserId.keys()].sort().map((id) => latestByUserId.get(id)!)
65
65
  }
66
66
 
67
67
  /**
@@ -8,6 +8,7 @@ import {
8
8
  mapObjectMapValues,
9
9
  objectMapEntries,
10
10
  } from '@tldraw/utils'
11
+ import { areShapesContentEqual } from '../../../utils/areShapesContentEqual'
11
12
  import type { Editor } from '../../Editor'
12
13
 
13
14
  interface FontState {
@@ -41,7 +42,7 @@ export class FontManager {
41
42
  },
42
43
  {
43
44
  areResultsEqual: areArraysShallowEqual,
44
- areRecordsEqual: (a, b) => a.props === b.props && a.meta === b.meta,
45
+ areRecordsEqual: areShapesContentEqual,
45
46
  }
46
47
  )
47
48
 
@@ -1,16 +1,39 @@
1
+ import { InstancePresenceRecordType, TLPOINTER_ID, createUserId } from '@tldraw/tlschema'
2
+ import { vi } from 'vitest'
1
3
  import { createTLStore } from '../../../config/createTLStore'
4
+ import { TldrawOptions } from '../../../options'
2
5
  import { Editor } from '../../Editor'
6
+ import { StateNode } from '../../tools/StateNode'
3
7
 
4
- function createTestEditor() {
8
+ function createPresence(editor: Editor) {
9
+ return InstancePresenceRecordType.create({
10
+ id: InstancePresenceRecordType.createId('peer'),
11
+ userId: createUserId('peer'),
12
+ userName: 'Peer',
13
+ currentPageId: editor.getCurrentPageId(),
14
+ })
15
+ }
16
+
17
+ function createTestEditor(options?: Partial<TldrawOptions>) {
5
18
  const store = createTLStore({})
6
19
  store.ensureStoreIsUsable()
7
- return new Editor({
20
+ // Attached to the document so that events dispatched on the container
21
+ // propagate to the window, where the activity listeners live.
22
+ const container = document.createElement('div')
23
+ document.body.appendChild(container)
24
+ const editor = new Editor({
8
25
  store,
9
26
  bindingUtils: [],
10
27
  shapeUtils: [],
11
- getContainer: () => document.createElement('div'),
28
+ getContainer: () => container,
12
29
  tools: [],
30
+ options,
13
31
  })
32
+ editor.disposables.add(() => container.remove())
33
+ // Input listeners only attach once the editor is mounted, which is normally
34
+ // emitted by `TldrawEditor` when the container is in the DOM.
35
+ editor.emit('mount')
36
+ return editor
14
37
  }
15
38
 
16
39
  describe('InputsManager', () => {
@@ -37,6 +60,179 @@ describe('InputsManager', () => {
37
60
  expect(editor.inputs.getPointerVelocity().len()).toBeGreaterThan(0)
38
61
  })
39
62
 
63
+ describe('markActivity', () => {
64
+ beforeEach(() => {
65
+ vi.useFakeTimers()
66
+ })
67
+
68
+ afterEach(() => {
69
+ vi.useRealTimers()
70
+ })
71
+
72
+ it('does nothing when there are no collaborators', () => {
73
+ editor.inputs.markActivity()
74
+
75
+ expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(0)
76
+ })
77
+
78
+ it('stamps the pointer record, preserving its position', () => {
79
+ editor.store.put([createPresence(editor)])
80
+ editor.store.put([{ ...editor.store.get(TLPOINTER_ID)!, x: 5, y: 6 }])
81
+
82
+ editor.inputs.markActivity()
83
+
84
+ expect(editor.store.get(TLPOINTER_ID)!).toMatchObject({
85
+ x: 5,
86
+ y: 6,
87
+ lastActivityTimestamp: Date.now(),
88
+ })
89
+ })
90
+
91
+ it('throttles stamps on the leading edge', () => {
92
+ editor.store.put([createPresence(editor)])
93
+
94
+ editor.inputs.markActivity()
95
+ const firstStamp = editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp
96
+
97
+ vi.advanceTimersByTime(500)
98
+ editor.inputs.markActivity()
99
+ expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(firstStamp)
100
+
101
+ vi.advanceTimersByTime(600)
102
+ editor.inputs.markActivity()
103
+ expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(firstStamp + 1100)
104
+ })
105
+
106
+ it('stamps on keyboard input in the container', () => {
107
+ editor.store.put([createPresence(editor)])
108
+
109
+ editor.getContainer().dispatchEvent(new KeyboardEvent('keydown', { key: 'a', code: 'KeyA' }))
110
+
111
+ expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(Date.now())
112
+ })
113
+
114
+ it('stamps on pointer input in the container', () => {
115
+ editor.store.put([createPresence(editor)])
116
+
117
+ editor.getContainer().dispatchEvent(new Event('pointermove', { bubbles: true }))
118
+
119
+ expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(Date.now())
120
+ })
121
+
122
+ it('stamps on text input that fires no keydown, e.g. IME and gesture typing', () => {
123
+ editor.store.put([createPresence(editor)])
124
+
125
+ editor.getContainer().dispatchEvent(new Event('beforeinput', { bubbles: true }))
126
+
127
+ expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(Date.now())
128
+ })
129
+
130
+ it('stamps on input elsewhere in the tab, outside the container', () => {
131
+ editor.store.put([createPresence(editor)])
132
+
133
+ document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', code: 'KeyA' }))
134
+
135
+ expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(Date.now())
136
+ })
137
+
138
+ it('keeps a minimum throttle window when the idle timeout is tiny', () => {
139
+ const tinyEditor = createTestEditor({ collaboratorIdleTimeoutMs: 0 })
140
+ try {
141
+ tinyEditor.store.put([createPresence(tinyEditor)])
142
+
143
+ tinyEditor.inputs.markActivity()
144
+ const stamp = tinyEditor.store.get(TLPOINTER_ID)!.lastActivityTimestamp
145
+
146
+ vi.advanceTimersByTime(50)
147
+ tinyEditor.inputs.markActivity()
148
+ expect(tinyEditor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(stamp)
149
+ } finally {
150
+ tinyEditor.dispose()
151
+ }
152
+ })
153
+
154
+ it('attaches input listeners only while sharing with someone', () => {
155
+ const addEventListener = vi.spyOn(window, 'addEventListener')
156
+ const removeEventListener = vi.spyOn(window, 'removeEventListener')
157
+ const soloEditor = createTestEditor()
158
+ try {
159
+ expect(addEventListener).not.toHaveBeenCalled()
160
+
161
+ soloEditor.store.put([createPresence(soloEditor)])
162
+ expect(addEventListener).toHaveBeenCalledWith('pointermove', expect.anything(), {
163
+ capture: true,
164
+ })
165
+
166
+ soloEditor.store.remove([createPresence(soloEditor).id])
167
+ expect(removeEventListener).toHaveBeenCalledWith('pointermove', expect.anything(), {
168
+ capture: true,
169
+ })
170
+ } finally {
171
+ soloEditor.dispose()
172
+ addEventListener.mockRestore()
173
+ removeEventListener.mockRestore()
174
+ }
175
+ })
176
+
177
+ it('attaches no input listeners when construction fails before mount', () => {
178
+ class DuplicateTool extends StateNode {
179
+ static override id = 'duplicate-tool'
180
+ }
181
+
182
+ const addEventListener = vi.spyOn(window, 'addEventListener')
183
+ try {
184
+ const store = createTLStore({})
185
+ store.ensureStoreIsUsable()
186
+ expect(
187
+ () =>
188
+ new Editor({
189
+ store,
190
+ bindingUtils: [],
191
+ shapeUtils: [],
192
+ getContainer: () => document.createElement('div'),
193
+ // The duplicate id makes the constructor throw after the inputs
194
+ // manager is built, so the editor is never disposed.
195
+ tools: [DuplicateTool, DuplicateTool],
196
+ })
197
+ ).toThrow()
198
+
199
+ expect(addEventListener).not.toHaveBeenCalled()
200
+ } finally {
201
+ addEventListener.mockRestore()
202
+ }
203
+ })
204
+
205
+ it('does not stamp on pointer moves with no DOM input, e.g. following a user', () => {
206
+ editor.store.put([createPresence(editor)])
207
+
208
+ editor.inputs.markActivity()
209
+ const stamp = editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp
210
+
211
+ vi.advanceTimersByTime(5000)
212
+ editor.inputs.updateFromEvent({
213
+ type: 'pointer',
214
+ name: 'pointer_move',
215
+ point: { x: 100, y: 50 },
216
+ pointerId: 1,
217
+ button: 0,
218
+ isPen: false,
219
+ target: 'canvas',
220
+ shiftKey: false,
221
+ altKey: false,
222
+ ctrlKey: false,
223
+ metaKey: false,
224
+ accelKey: false,
225
+ })
226
+
227
+ // The pointer position updates, but the activity clock doesn't.
228
+ expect(editor.store.get(TLPOINTER_ID)!).toMatchObject({
229
+ x: 100,
230
+ y: 50,
231
+ lastActivityTimestamp: stamp,
232
+ })
233
+ })
234
+ })
235
+
40
236
  it('stops updating pointer velocity after dispose', () => {
41
237
  const point = editor.inputs.getCurrentScreenPoint()
42
238
  point.x = 0
@@ -1,8 +1,7 @@
1
- import { atom, computed, unsafe__withoutCapture } from '@tldraw/state'
1
+ import { atom, computed, react, unsafe__withoutCapture } from '@tldraw/state'
2
2
  import { AtomSet } from '@tldraw/store'
3
3
  import { TLINSTANCE_ID, TLPOINTER_ID } from '@tldraw/tlschema'
4
- import { bind } from '@tldraw/utils'
5
- import { INTERNAL_POINTER_IDS } from '../../../constants'
4
+ import { bind, throttle } from '@tldraw/utils'
6
5
  import { Vec } from '../../../primitives/Vec'
7
6
  import { isAccelKey } from '../../../utils/keyboard'
8
7
  import type { Editor } from '../../Editor'
@@ -12,11 +11,85 @@ import { EditorManager } from '../EditorManager'
12
11
  const POINTER_VELOCITY_REFERENCE_INTERVAL_MS = 16
13
12
  const POINTER_VELOCITY_REFERENCE_SMOOTHING = 0.5
14
13
 
14
+ // How often `markActivity` is allowed to stamp the activity timestamp. Each
15
+ // stamp re-broadcasts presence, so this must be long enough to keep input
16
+ // bursts (e.g. typing, held keys) from flooding the network. It must also stay
17
+ // comfortably shorter than `collaboratorIdleTimeoutMs` so a continuously-active
18
+ // peer never flickers to idle between stamps; the throttle window is capped
19
+ // below that option when it's configured shorter than the default, but never
20
+ // below the floor, so a degenerate idle timeout can't disable throttling.
21
+ const ACTIVITY_TIMESTAMP_THROTTLE_MS = 1000
22
+ const ACTIVITY_TIMESTAMP_THROTTLE_FLOOR_MS = 100
23
+
24
+ // DOM events that count as presence activity. `beforeinput` covers IME and
25
+ // mobile keyboards (e.g. gesture typing), which often don't fire per-character
26
+ // keydown events. `gesturestart`/`gesturechange` are Safari's proprietary
27
+ // trackpad-pinch events, which produce neither pointer nor wheel events.
28
+ const ACTIVITY_EVENTS = [
29
+ 'pointerdown',
30
+ 'pointermove',
31
+ 'pointerup',
32
+ 'keydown',
33
+ 'beforeinput',
34
+ 'wheel',
35
+ 'gesturestart',
36
+ 'gesturechange',
37
+ ]
38
+
15
39
  /** @public */
16
40
  export class InputsManager extends EditorManager {
17
41
  constructor(editor: Editor) {
18
42
  super(editor)
19
43
  this.addEditorEvent('frame', this._onFrame)
44
+
45
+ // Track input only while mounted and sharing with someone: a solo user
46
+ // pays for no listeners at all, and the window is resolved on attach
47
+ // rather than up front, when the container is guaranteed to exist.
48
+ //
49
+ // The mount check must come first. This runs eagerly during the editor's
50
+ // constructor, where `editor.collaborators` isn't assigned yet, so reading
51
+ // collaborators here would throw; a never-mounted editor short-circuits
52
+ // before that, and re-runs once mount flips the atom.
53
+ this.register(
54
+ react('presence activity listeners', () => {
55
+ if (this.editor.getIsMounted() && this._getHasCollaborators()) {
56
+ this._attachActivityListeners()
57
+ } else {
58
+ this._detachActivityListeners()
59
+ }
60
+ })
61
+ )
62
+ this.register(() => {
63
+ this._detachActivityListeners()
64
+ this._throttledActivityStamp.cancel()
65
+ })
66
+ }
67
+
68
+ private _detachActivityListenersFn: null | (() => void) = null
69
+
70
+ /**
71
+ * User input anywhere in the editor's tab counts as activity for collaborator presence —
72
+ * presence means "this person is here", whether or not the input lands on the editor itself
73
+ * (e.g. typing in a sidebar next to the canvas). Listen in the capture phase so input that
74
+ * other handlers swallow still counts.
75
+ */
76
+ private _attachActivityListeners() {
77
+ if (this._detachActivityListenersFn) return
78
+
79
+ const win = this.editor.getContainerWindow()
80
+ for (const name of ACTIVITY_EVENTS) {
81
+ win.addEventListener(name, this.markActivity, { capture: true })
82
+ }
83
+ this._detachActivityListenersFn = () => {
84
+ for (const name of ACTIVITY_EVENTS) {
85
+ win.removeEventListener(name, this.markActivity, { capture: true })
86
+ }
87
+ }
88
+ }
89
+
90
+ private _detachActivityListeners() {
91
+ this._detachActivityListenersFn?.()
92
+ this._detachActivityListenersFn = null
20
93
  }
21
94
 
22
95
  @bind
@@ -462,6 +535,53 @@ export class InputsManager extends EditorManager {
462
535
  return this.editor.getCollaborators().length > 0 // could we do this more efficiently?
463
536
  }
464
537
 
538
+ private readonly _throttledActivityStamp = throttle(
539
+ () => {
540
+ const pointer = this.editor.store.unsafeGetWithoutCapture(TLPOINTER_ID)
541
+ if (!pointer) return
542
+
543
+ this.editor.run(
544
+ () => {
545
+ this.editor.store.put([{ ...pointer, lastActivityTimestamp: Date.now() }])
546
+ },
547
+ { history: 'ignore' }
548
+ )
549
+ },
550
+ // Stay comfortably below the idle timeout so a continuously-active peer
551
+ // never flickers to idle between throttled stamps.
552
+ Math.max(
553
+ ACTIVITY_TIMESTAMP_THROTTLE_FLOOR_MS,
554
+ Math.min(ACTIVITY_TIMESTAMP_THROTTLE_MS, this.editor.options.collaboratorIdleTimeoutMs / 3)
555
+ ),
556
+ { trailing: false }
557
+ )
558
+
559
+ /**
560
+ * Mark the current user as active for collaborator presence. User input anywhere in the
561
+ * editor's tab counts as activity automatically; call this to make input that doesn't produce
562
+ * DOM events count too, so peers don't classify this user as idle or inactive while they're
563
+ * still interacting. Calls are throttled on the leading edge, so it's safe to call from
564
+ * high-frequency input events.
565
+ *
566
+ * @example
567
+ * ```ts
568
+ * // Gamepads are polled rather than event-driven, so stamp activity from the poll
569
+ * editor.timers.setInterval(() => {
570
+ * const isPressed = navigator
571
+ * .getGamepads()
572
+ * .some((pad) => pad?.buttons.some((button) => button.pressed))
573
+ * if (isPressed) editor.inputs.markActivity()
574
+ * }, 50)
575
+ * ```
576
+ */
577
+ @bind
578
+ markActivity() {
579
+ // Check for collaborators before consuming the throttle window, so input
580
+ // while alone doesn't delay the first stamp after a peer joins.
581
+ if (!this._getHasCollaborators()) return
582
+ this._throttledActivityStamp()
583
+ }
584
+
465
585
  /**
466
586
  * The previous point used for velocity calculation (updated each tick, not each pointer event).
467
587
  * @internal
@@ -553,13 +673,12 @@ export class InputsManager extends EditorManager {
553
673
  typeName: 'pointer',
554
674
  x: pagePoint.x,
555
675
  y: pagePoint.y,
676
+ // The activity timestamp is stamped by the container's input
677
+ // listeners (see `markActivity`), not by pointer position updates:
678
+ // synthetic moves, like the camera following another user, don't
679
+ // count as activity.
556
680
  lastActivityTimestamp:
557
- // If our pointer moved only because we're following some other user, then don't
558
- // update our last activity timestamp; otherwise, update it to the current timestamp.
559
- info.type === 'pointer' && info.pointerId === INTERNAL_POINTER_IDS.CAMERA_MOVE
560
- ? (this.editor.store.unsafeGetWithoutCapture(TLPOINTER_ID)
561
- ?.lastActivityTimestamp ?? Date.now())
562
- : Date.now(),
681
+ this.editor.store.unsafeGetWithoutCapture(TLPOINTER_ID)?.lastActivityTimestamp ?? 0,
563
682
  meta: {},
564
683
  },
565
684
  ])
@@ -206,12 +206,10 @@ export class BoundsSnaps {
206
206
  const { editor } = this
207
207
  return editor.store.createComputedCache<BoundsSnapPoint[], TLShape>('snapPoints', (shape) => {
208
208
  const pageTransform = editor.getShapePageTransform(shape.id)
209
- if (!pageTransform) return undefined
210
209
  const boundsSnapGeometry = editor.getShapeUtil(shape).getBoundsSnapGeometry(shape)
211
210
  const snapPoints =
212
211
  boundsSnapGeometry.points ?? editor.getShapeGeometry(shape).bounds.cornersAndCenter
213
212
 
214
- if (!pageTransform || !snapPoints) return undefined
215
213
  return snapPoints.map((point, i) => {
216
214
  const { x, y } = Mat.applyToPoint(pageTransform, point)
217
215
  return { x, y, id: `${shape.id}:${i}` }
@@ -10,6 +10,7 @@ export class UserPreferencesManager {
10
10
  disposables = new Set<() => void>()
11
11
  dispose() {
12
12
  this.disposables.forEach((d) => d())
13
+ this.disposables.clear()
13
14
  }
14
15
  constructor(
15
16
  private readonly user: TLCurrentUser,
@@ -1,4 +1,5 @@
1
1
  import { atom, computed } from '@tldraw/state'
2
+ import { WeakCache } from '@tldraw/utils'
2
3
  import { Geometry2d } from '../../primitives/geometry/Geometry2d'
3
4
  import { VecLike } from '../../primitives/Vec'
4
5
  import type { Editor } from '../Editor'
@@ -119,7 +120,7 @@ export class OverlayManager {
119
120
  // while getActiveOverlayEntries() keeps returning the same overlay
120
121
  // instances; when its reactive deps change, getOverlays() emits fresh
121
122
  // objects and stale entries fall out by GC.
122
- private _geometryCache = new WeakMap<TLOverlay, Geometry2d | null>()
123
+ private _geometryCache = new WeakCache<TLOverlay, Geometry2d | null>()
123
124
 
124
125
  /**
125
126
  * Get hit-test geometry for an overlay, cached by overlay identity. Lets
@@ -130,12 +131,7 @@ export class OverlayManager {
130
131
  * @public
131
132
  */
132
133
  getOverlayGeometry(overlay: TLOverlay): Geometry2d | null {
133
- const cached = this._geometryCache.get(overlay)
134
- if (cached !== undefined) return cached
135
- const util = this.getOverlayUtil(overlay)
136
- const geometry = util.getGeometry(overlay)
137
- this._geometryCache.set(overlay, geometry)
138
- return geometry
134
+ return this._geometryCache.get(overlay, (o) => this.getOverlayUtil(o).getGeometry(o))
139
135
  }
140
136
 
141
137
  /**
@@ -40,7 +40,6 @@ export function strokeShapeIndicators(
40
40
  if (!shape || shape.isLocked) continue
41
41
 
42
42
  const pageTransform = editor.getShapePageTransform(shape)
43
- if (!pageTransform) continue
44
43
 
45
44
  const indicatorPath = indicatorPathCache.get(editor, shape.id)
46
45
  if (!indicatorPath) continue
@@ -93,7 +93,11 @@ export abstract class BaseFrameLikeShapeUtil<
93
93
  const currentChildren = compact(
94
94
  editor.getSortedChildIdsForParent(shape).map((id) => editor.getShape(id))
95
95
  )
96
- if (previousChildren.every((s) => !currentChildren.find((c) => c.index === s.index))) {
96
+ if (
97
+ previousChildren.every(
98
+ (s) => !currentChildren.find((c) => c.index === initialIndices.get(s.id))
99
+ )
100
+ ) {
97
101
  canRestoreOriginalIndices = true
98
102
  }
99
103
  }
@@ -87,6 +87,9 @@ export function getPerfectDashProps(
87
87
 
88
88
  dashCount = Math.floor(totalLength / dashLength / (2 * ratio))
89
89
  dashCount -= dashCount % snap
90
+ // A path shorter than one dash period (or of zero length, where the count is NaN) would
91
+ // otherwise divide by zero below and produce an invalid dasharray; draw a single dash instead
92
+ if (!(dashCount >= 1)) dashCount = 1
90
93
 
91
94
  if (dashCount < 3 && style === 'dashed') {
92
95
  if (totalLength / strokeWidth < 4) {
@@ -75,7 +75,10 @@ export class Pointing extends StateNode {
75
75
  }
76
76
 
77
77
  override onComplete() {
78
- this.complete()
78
+ // `complete` arrives mid-press from undo, menus opening, page changes, and focus loss,
79
+ // none of which is a release; committing here would drop a default-size shape the user
80
+ // never asked for. Match the text and arrow tools and leave nothing behind.
81
+ this.cancel()
79
82
  }
80
83
 
81
84
  override onInterrupt() {
@@ -238,6 +238,70 @@ describe('StateNode.addChild', () => {
238
238
  })
239
239
  })
240
240
 
241
+ describe('StateNode.enter', () => {
242
+ let idleExits = 0
243
+
244
+ class Idle extends StateNode {
245
+ static override id = 'idle'
246
+ override onExit() {
247
+ idleExits++
248
+ }
249
+ }
250
+
251
+ class Pointing extends StateNode {
252
+ static override id = 'pointing'
253
+ }
254
+
255
+ class ToolWithEnterTransition extends StateNode {
256
+ static override id = 'enterTransition'
257
+ static override initial = 'idle'
258
+ static override children() {
259
+ return [Idle, Pointing]
260
+ }
261
+
262
+ override onEnter(info: { startPointing?: boolean }) {
263
+ if (info?.startPointing) this.transition('pointing', info)
264
+ }
265
+ }
266
+
267
+ class OtherTool extends StateNode {
268
+ static override id = 'other'
269
+ }
270
+
271
+ let editor: Editor
272
+
273
+ beforeEach(() => {
274
+ idleExits = 0
275
+ editor = new Editor({
276
+ initialState: 'other',
277
+ shapeUtils: [],
278
+ bindingUtils: [],
279
+ tools: [OtherTool, ToolWithEnterTransition],
280
+ store: createTLStore({ shapeUtils: [], bindingUtils: [] }),
281
+ getContainer: () => document.body,
282
+ })
283
+ })
284
+
285
+ it('enters the initial child when onEnter does not transition', () => {
286
+ editor.setCurrentTool('enterTransition')
287
+ expect(editor.root.getPath()).toBe('root.enterTransition.idle')
288
+ expect(editor.root.children!['enterTransition'].children!['idle'].getIsActive()).toBe(true)
289
+ })
290
+
291
+ it('does not enter the initial child on top of a child that onEnter transitioned to', () => {
292
+ editor.setCurrentTool('enterTransition', { startPointing: true })
293
+ const tool = editor.root.children!['enterTransition']
294
+ expect(editor.root.getPath()).toBe('root.enterTransition.pointing')
295
+ expect(tool.children!['pointing'].getIsActive()).toBe(true)
296
+ expect(tool.children!['idle'].getIsActive()).toBe(false)
297
+ // The stale (never re-entered) idle child must not get a spurious onExit
298
+ expect(idleExits).toBe(0)
299
+
300
+ editor.setCurrentTool('other')
301
+ expect(tool.children!['pointing'].getIsActive()).toBe(false)
302
+ })
303
+ })
304
+
241
305
  describe('current tool id mask', () => {
242
306
  // Tool mask test classes
243
307
  class ToolA extends StateNode {
@@ -152,7 +152,9 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
152
152
  }
153
153
 
154
154
  if (prevChildState?.id !== nextChildState.id) {
155
- prevChildState?.exit(info, id)
155
+ // The previous child is inactive when transitioning from a parent's onEnter (or from a
156
+ // node that was already exited); exiting it again would fire a spurious onExit
157
+ if (prevChildState?.getIsActive()) prevChildState.exit(info, id)
156
158
  currState._current.set(nextChildState)
157
159
  nextChildState.enter(info, prevChildState?.id || 'initial')
158
160
  if (!nextChildState.getIsActive()) break
@@ -188,10 +190,18 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
188
190
  this.editor.performance._notifyInteractionStart(this.id, this.getPath())
189
191
  }
190
192
 
193
+ const currentBeforeEnter = this.getCurrent()
191
194
  this._isActive.set(true)
192
195
  this.onEnter?.(info, from)
193
196
 
194
- if (this.children && this.initial && this.getIsActive()) {
197
+ // If onEnter already transitioned to a child, entering the initial child on top of it would
198
+ // leave that child active but orphaned
199
+ if (
200
+ this.children &&
201
+ this.initial &&
202
+ this.getIsActive() &&
203
+ this.getCurrent() === currentBeforeEnter
204
+ ) {
195
205
  const initial = this.children[this.initial]
196
206
  this._current.set(initial)
197
207
  initial.enter(info, from)
@@ -0,0 +1,33 @@
1
+ import { tlmenus } from './menus'
2
+
3
+ describe('tlmenus', () => {
4
+ afterEach(() => {
5
+ tlmenus.clearOpenMenus()
6
+ tlmenus._hiddenMenus = []
7
+ })
8
+
9
+ it('reports whether a menu is open with and without a context', () => {
10
+ tlmenus.addOpenMenu('main')
11
+ tlmenus.addOpenMenu('main', 'editor-1')
12
+
13
+ expect(tlmenus.isMenuOpen('main')).toBe(true)
14
+ expect(tlmenus.isMenuOpen('main', 'editor-1')).toBe(true)
15
+ expect(tlmenus.isMenuOpen('main', 'editor-2')).toBe(false)
16
+ expect(tlmenus.isMenuOpen('other')).toBe(false)
17
+ })
18
+
19
+ it('hides and restores the open menus of a context', () => {
20
+ tlmenus.addOpenMenu('main', 'editor-1')
21
+ tlmenus.addOpenMenu('main', 'editor-2')
22
+
23
+ tlmenus.hideOpenMenus('editor-1')
24
+ expect(tlmenus.getOpenMenus()).toEqual(['main-editor-2'])
25
+
26
+ tlmenus.showOpenMenus('editor-1')
27
+ expect(tlmenus.getOpenMenus().sort()).toEqual(['main-editor-1', 'main-editor-2'])
28
+
29
+ // closing the real menu afterwards leaves nothing behind
30
+ tlmenus.deleteOpenMenu('main', 'editor-1')
31
+ expect(tlmenus.hasOpenMenus('editor-1')).toBe(false)
32
+ })
33
+ })