@tldraw/editor 5.2.0-next.e2b8d10bf10e → 5.2.0

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 (172) hide show
  1. package/README.md +7 -1
  2. package/dist-cjs/index.d.ts +112 -53
  3. package/dist-cjs/index.js +7 -8
  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/components/Shape.js +47 -1
  8. package/dist-cjs/lib/components/Shape.js.map +3 -3
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +1 -16
  10. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  11. package/dist-cjs/lib/editor/Editor.js +97 -45
  12. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  13. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +3 -3
  14. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  15. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +28 -4
  16. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +3 -3
  17. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js +24 -2
  18. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +14 -3
  20. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +4 -2
  22. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +7 -3
  24. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +0 -1
  26. package/dist-cjs/lib/editor/managers/TickManager/TickManager.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/shapes/ShapeUtil.js.map +2 -2
  32. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +4 -0
  33. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
  34. package/dist-cjs/lib/{hooks/useTransform.js → editor/tools/cancelShapeCreationOnLongPress.js} +8 -23
  35. package/dist-cjs/lib/editor/tools/cancelShapeCreationOnLongPress.js.map +7 -0
  36. package/dist-cjs/lib/editor/types/event-types.js.map +2 -2
  37. package/dist-cjs/lib/globals/environment.js +6 -1
  38. package/dist-cjs/lib/globals/environment.js.map +2 -2
  39. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  40. package/dist-cjs/lib/hooks/useCanvasEvents.js +17 -8
  41. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  42. package/dist-cjs/lib/hooks/useEditorComponents.js +0 -1
  43. package/dist-cjs/lib/hooks/useEditorComponents.js.map +2 -2
  44. package/dist-cjs/lib/hooks/usePresence.js.map +2 -2
  45. package/dist-cjs/lib/hooks/useScreenBounds.js +1 -0
  46. package/dist-cjs/lib/hooks/useScreenBounds.js.map +2 -2
  47. package/dist-cjs/lib/license/LicenseManager.js +5 -1
  48. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  49. package/dist-cjs/lib/primitives/Vec.js +3 -2
  50. package/dist-cjs/lib/primitives/Vec.js.map +2 -2
  51. package/dist-cjs/lib/utils/dom.js +12 -0
  52. package/dist-cjs/lib/utils/dom.js.map +2 -2
  53. package/dist-cjs/lib/utils/getPointerInfo.js +2 -1
  54. package/dist-cjs/lib/utils/getPointerInfo.js.map +2 -2
  55. package/dist-cjs/lib/utils/pointer.js +37 -0
  56. package/dist-cjs/lib/utils/pointer.js.map +7 -0
  57. package/dist-cjs/version.js +3 -3
  58. package/dist-cjs/version.js.map +1 -1
  59. package/dist-esm/index.d.mts +112 -53
  60. package/dist-esm/index.mjs +9 -13
  61. package/dist-esm/index.mjs.map +2 -2
  62. package/dist-esm/lib/components/MenuClickCapture.mjs +8 -5
  63. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  64. package/dist-esm/lib/components/Shape.mjs +48 -2
  65. package/dist-esm/lib/components/Shape.mjs.map +3 -3
  66. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +1 -16
  67. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  68. package/dist-esm/lib/editor/Editor.mjs +97 -45
  69. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  70. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +3 -3
  71. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  72. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +28 -4
  73. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +3 -3
  74. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs +24 -2
  75. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs.map +2 -2
  76. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +14 -3
  77. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
  78. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +4 -2
  79. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
  80. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +7 -3
  81. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  82. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +0 -1
  83. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
  84. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +15 -2
  85. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  86. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs +59 -0
  87. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs.map +7 -0
  88. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  89. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +4 -0
  90. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
  91. package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs +8 -0
  92. package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs.map +7 -0
  93. package/dist-esm/lib/editor/types/event-types.mjs.map +2 -2
  94. package/dist-esm/lib/globals/environment.mjs +6 -1
  95. package/dist-esm/lib/globals/environment.mjs.map +2 -2
  96. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  97. package/dist-esm/lib/hooks/useCanvasEvents.mjs +17 -8
  98. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  99. package/dist-esm/lib/hooks/useEditorComponents.mjs +0 -1
  100. package/dist-esm/lib/hooks/useEditorComponents.mjs.map +2 -2
  101. package/dist-esm/lib/hooks/usePresence.mjs.map +2 -2
  102. package/dist-esm/lib/hooks/useScreenBounds.mjs +1 -0
  103. package/dist-esm/lib/hooks/useScreenBounds.mjs.map +2 -2
  104. package/dist-esm/lib/license/LicenseManager.mjs +5 -1
  105. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  106. package/dist-esm/lib/primitives/Vec.mjs +3 -2
  107. package/dist-esm/lib/primitives/Vec.mjs.map +2 -2
  108. package/dist-esm/lib/utils/dom.mjs +12 -0
  109. package/dist-esm/lib/utils/dom.mjs.map +2 -2
  110. package/dist-esm/lib/utils/getPointerInfo.mjs +2 -1
  111. package/dist-esm/lib/utils/getPointerInfo.mjs.map +2 -2
  112. package/dist-esm/lib/utils/pointer.mjs +17 -0
  113. package/dist-esm/lib/utils/pointer.mjs.map +7 -0
  114. package/dist-esm/version.mjs +3 -3
  115. package/dist-esm/version.mjs.map +1 -1
  116. package/editor.css +15 -3
  117. package/package.json +11 -8
  118. package/src/index.ts +4 -10
  119. package/src/lib/components/MenuClickCapture.tsx +8 -4
  120. package/src/lib/components/Shape.tsx +88 -1
  121. package/src/lib/components/default-components/DefaultCanvas.tsx +1 -17
  122. package/src/lib/editor/Editor.test.ts +36 -0
  123. package/src/lib/editor/Editor.ts +145 -55
  124. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts +43 -16
  125. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +8 -5
  126. package/src/lib/editor/managers/FontManager/FontManager.test.ts +37 -10
  127. package/src/lib/editor/managers/FontManager/FontManager.ts +46 -1
  128. package/src/lib/editor/managers/HistoryManager/HistoryManager.test.ts +32 -0
  129. package/src/lib/editor/managers/HistoryManager/HistoryManager.ts +34 -4
  130. package/src/lib/editor/managers/InputsManager/InputsManager.test.ts +61 -0
  131. package/src/lib/editor/managers/InputsManager/InputsManager.ts +16 -4
  132. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +9 -2
  133. package/src/lib/editor/managers/TextManager/TextManager.test.ts +16 -14
  134. package/src/lib/editor/managers/TextManager/TextManager.ts +17 -2
  135. package/src/lib/editor/managers/TickManager/TickManager.test.ts +0 -40
  136. package/src/lib/editor/managers/TickManager/TickManager.ts +0 -1
  137. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +12 -2
  138. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +27 -2
  139. package/src/lib/editor/overlays/strokeShapeIndicators.ts +86 -0
  140. package/src/lib/editor/shapes/ShapeUtil.ts +41 -0
  141. package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +5 -0
  142. package/src/lib/editor/tools/cancelShapeCreationOnLongPress.ts +31 -0
  143. package/src/lib/editor/types/event-types.ts +7 -0
  144. package/src/lib/globals/environment.ts +7 -0
  145. package/src/lib/hooks/EditorComponentsContext.tsx +0 -2
  146. package/src/lib/hooks/useCanvasEvents.ts +24 -12
  147. package/src/lib/hooks/useEditorComponents.tsx +0 -1
  148. package/src/lib/hooks/usePresence.ts +2 -2
  149. package/src/lib/hooks/useScreenBounds.ts +3 -0
  150. package/src/lib/license/LicenseManager.test.ts +24 -16
  151. package/src/lib/license/LicenseManager.ts +8 -2
  152. package/src/lib/primitives/Vec.ts +5 -2
  153. package/src/lib/test/ContentElementSlot.test.tsx +201 -0
  154. package/src/lib/utils/dom.ts +34 -0
  155. package/src/lib/utils/getPointerInfo.ts +2 -1
  156. package/src/lib/utils/pointer.test.ts +76 -0
  157. package/src/lib/utils/pointer.ts +42 -0
  158. package/src/version.ts +3 -3
  159. package/dist-cjs/lib/components/default-components/DefaultSelectionBackground.js +0 -49
  160. package/dist-cjs/lib/components/default-components/DefaultSelectionBackground.js.map +0 -7
  161. package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js +0 -161
  162. package/dist-cjs/lib/editor/overlays/ShapeIndicatorOverlayUtil.js.map +0 -7
  163. package/dist-cjs/lib/hooks/useTransform.js.map +0 -7
  164. package/dist-esm/lib/components/default-components/DefaultSelectionBackground.mjs +0 -19
  165. package/dist-esm/lib/components/default-components/DefaultSelectionBackground.mjs.map +0 -7
  166. package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs +0 -141
  167. package/dist-esm/lib/editor/overlays/ShapeIndicatorOverlayUtil.mjs.map +0 -7
  168. package/dist-esm/lib/hooks/useTransform.mjs +0 -23
  169. package/dist-esm/lib/hooks/useTransform.mjs.map +0 -7
  170. package/src/lib/components/default-components/DefaultSelectionBackground.tsx +0 -25
  171. package/src/lib/editor/overlays/ShapeIndicatorOverlayUtil.ts +0 -216
  172. package/src/lib/hooks/useTransform.ts +0 -30
@@ -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
@@ -9,18 +9,20 @@ import {
9
9
  import { IndexKey } from '@tldraw/utils'
10
10
  import { Mock, Mocked, vi } from 'vitest'
11
11
  import { Editor } from '../../Editor'
12
- import { FontManager } from './FontManager'
12
+ import { clearFontFaceCacheForTests, FontManager } from './FontManager'
13
13
 
14
14
  // Mock the Editor class
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: {
@@ -71,6 +73,7 @@ describe('FontManager', () => {
71
73
 
72
74
  beforeEach(() => {
73
75
  vi.clearAllMocks()
76
+ clearFontFaceCacheForTests()
74
77
 
75
78
  mockAssetUrls = {
76
79
  'test-font.woff2': 'https://example.com/fonts/test-font.woff2',
@@ -200,9 +203,11 @@ describe('FontManager', () => {
200
203
  const font = createMockFont()
201
204
  const error = new Error('Font load failed')
202
205
 
203
- ;(global.FontFace as Mock).mockReturnValue({
204
- family: font.family,
205
- load: vi.fn(() => Promise.reject(error)),
206
+ ;(global.FontFace as Mock).mockImplementationOnce(function () {
207
+ return {
208
+ family: font.family,
209
+ load: vi.fn(() => Promise.reject(error)),
210
+ }
206
211
  })
207
212
 
208
213
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -310,4 +315,26 @@ describe('FontManager', () => {
310
315
  await expect(fontManager.ensureFontIsLoaded(minimalFont)).resolves.toBeUndefined()
311
316
  })
312
317
  })
318
+
319
+ describe('findOrCreateFontFace caching', () => {
320
+ it('reuses a font face across manager instances instead of recreating it on remount', async () => {
321
+ const font = createMockFont()
322
+ await fontManager.ensureFontIsLoaded(font)
323
+ expect(global.FontFace).toHaveBeenCalledTimes(1)
324
+
325
+ // A fresh manager on the same document simulates an editor remount.
326
+ const remounted = new FontManager(editor, mockAssetUrls)
327
+ await remounted.ensureFontIsLoaded(font)
328
+
329
+ // The per-document cache lets the second manager reuse the existing face
330
+ // rather than re-scanning document.fonts and creating a new one.
331
+ expect(global.FontFace).toHaveBeenCalledTimes(1)
332
+ })
333
+
334
+ it('creates separate font faces for fonts with different descriptors', async () => {
335
+ await fontManager.ensureFontIsLoaded(createMockFont({ weight: 'normal' }))
336
+ await fontManager.ensureFontIsLoaded(createMockFont({ weight: 'bold' }))
337
+ expect(global.FontFace).toHaveBeenCalledTimes(2)
338
+ })
339
+ })
313
340
  })
@@ -145,7 +145,26 @@ export class FontManager {
145
145
  }
146
146
 
147
147
  private findOrCreateFontFace(font: TLFontFace) {
148
- const fonts = this.editor.getContainerDocument().fonts
148
+ const containerDocument = this.editor.getContainerDocument()
149
+
150
+ // `findOrCreateFontFace` runs for every font on every editor mount, and a fresh
151
+ // editor (e.g. switching documents) gets a fresh FontManager with no memory of the
152
+ // previous one. The dedup below is an O(n) scan of the document's whole FontFaceSet,
153
+ // so without a cache that scan re-ran on every mount (measurably expensive on mobile
154
+ // Safari). Cache the resolved FontFace per document so repeated lookups - and
155
+ // remounts - are O(1). Keyed per document for cross-window embedding.
156
+ let cache = fontFaceCacheByDocument.get(containerDocument)
157
+ if (!cache) {
158
+ cache = new Map()
159
+ fontFaceCacheByDocument.set(containerDocument, cache)
160
+ }
161
+ const key = getFontFaceCacheKey(font)
162
+ const cached = cache.get(key)
163
+ if (cached) return cached
164
+
165
+ const fonts = containerDocument.fonts
166
+ // On a cache miss we still scan once, so font faces added outside this manager
167
+ // (e.g. preloaded fonts) are reused rather than duplicated.
149
168
  for (const existing of fonts) {
150
169
  if (
151
170
  existing.family === font.family &&
@@ -153,6 +172,7 @@ export class FontManager {
153
172
  ([key, defaultValue]) => existing[key] === (font[key] ?? defaultValue)
154
173
  )
155
174
  ) {
175
+ cache.set(key, existing)
156
176
  return existing
157
177
  }
158
178
  }
@@ -164,6 +184,7 @@ export class FontManager {
164
184
  })
165
185
 
166
186
  fonts.add(instance)
187
+ cache.set(key, instance)
167
188
 
168
189
  return instance
169
190
  }
@@ -205,3 +226,27 @@ const defaultFontFaceDescriptors = {
205
226
  descentOverride: 'normal',
206
227
  lineGapOverride: 'normal',
207
228
  }
229
+
230
+ // A FontFace is fully determined by its family and descriptors, so resolved faces can be
231
+ // cached per document and reused across FontManager instances (e.g. editor remounts),
232
+ // turning the per-lookup FontFaceSet scan into an O(1) map lookup. Faces are never removed
233
+ // from `document.fonts` (FontManager.dispose leaves them), so the cache stays valid for the
234
+ // document's lifetime. Keyed per document for cross-window embedding.
235
+ let fontFaceCacheByDocument = new WeakMap<Document, Map<string, FontFace>>()
236
+
237
+ function getFontFaceCacheKey(font: TLFontFace): string {
238
+ return JSON.stringify([
239
+ font.family,
240
+ ...objectMapEntries(defaultFontFaceDescriptors).map(
241
+ ([key, defaultValue]) => font[key] ?? defaultValue
242
+ ),
243
+ ])
244
+ }
245
+
246
+ /**
247
+ * Resets the per-document font-face cache. Only intended for tests.
248
+ * @internal
249
+ */
250
+ export function clearFontFaceCacheForTests() {
251
+ fontFaceCacheByDocument = new WeakMap()
252
+ }
@@ -584,6 +584,38 @@ describe('HistoryManager getters and utilities', () => {
584
584
  store.update(ids.a, (s) => ({ ...s, value: 2 }))
585
585
  expect(manager.getNumRedos()).toBe(0)
586
586
  })
587
+
588
+ it('should only report replaying while undoing or redoing', () => {
589
+ const replayStates: boolean[] = []
590
+ const dispose = store.addHistoryInterceptor(() => {
591
+ replayStates.push(manager.isReplaying())
592
+ })
593
+
594
+ store.update(ids.a, (s) => ({ ...s, value: 1 }))
595
+ expect(replayStates).toEqual([false])
596
+
597
+ replayStates.length = 0
598
+ manager.undo()
599
+ expect(replayStates).toEqual([true])
600
+ expect(manager.isReplaying()).toBe(false)
601
+
602
+ replayStates.length = 0
603
+ manager.redo()
604
+ expect(replayStates).toEqual([true])
605
+ expect(manager.isReplaying()).toBe(false)
606
+
607
+ replayStates.length = 0
608
+ manager.batch(
609
+ () => {
610
+ store.update(ids.a, (s) => ({ ...s, value: 2 }))
611
+ },
612
+ { history: 'ignore' }
613
+ )
614
+ expect(replayStates).toEqual([false])
615
+ expect(manager.isReplaying()).toBe(false)
616
+
617
+ dispose()
618
+ })
587
619
  })
588
620
 
589
621
  describe('getMarkIdMatching', () => {
@@ -48,7 +48,11 @@ export class HistoryManager<R extends UnknownRecord> {
48
48
  switch (this.state) {
49
49
  case HistoryRecorderState.Recording:
50
50
  this.pendingDiff.apply(entry.changes)
51
- this.stacks.update(({ undos }) => ({ undos, redos: stack() }))
51
+ // this interceptor runs for every store change, so skip the update when the
52
+ // redo stack is already empty
53
+ if (this.stacks.get().redos.length > 0) {
54
+ this.stacks.update(({ undos }) => ({ undos, redos: stack() }))
55
+ }
52
56
  break
53
57
  case HistoryRecorderState.RecordingPreserveRedoStack:
54
58
  this.pendingDiff.apply(entry.changes)
@@ -79,6 +83,14 @@ export class HistoryManager<R extends UnknownRecord> {
79
83
  return this.stacks.get().redos.length
80
84
  }
81
85
 
86
+ /** @internal */
87
+ private _isReplaying = false
88
+
89
+ /** @internal */
90
+ isReplaying() {
91
+ return this._isReplaying
92
+ }
93
+
82
94
  /** @internal */
83
95
  _isInBatch = false
84
96
 
@@ -115,7 +127,9 @@ export class HistoryManager<R extends UnknownRecord> {
115
127
  // History
116
128
  _undo({ pushToRedoStack, toMark = undefined }: { pushToRedoStack: boolean; toMark?: string }) {
117
129
  const previousState = this.state
130
+ const previousIsReplaying = this._isReplaying
118
131
  this.state = HistoryRecorderState.Paused
132
+ this._isReplaying = true
119
133
  try {
120
134
  let { undos, redos } = this.stacks.get()
121
135
 
@@ -176,11 +190,11 @@ export class HistoryManager<R extends UnknownRecord> {
176
190
  this.pendingDiff.restore(pendingDiff)
177
191
  return this
178
192
  }
179
-
180
193
  this.store.applyDiff(diffToUndo, { ignoreEphemeralKeys: true })
181
194
  this.store.ensureStoreIsUsable()
182
195
  this.stacks.set({ undos, redos })
183
196
  } finally {
197
+ this._isReplaying = previousIsReplaying
184
198
  this.state = previousState
185
199
  }
186
200
 
@@ -195,7 +209,9 @@ export class HistoryManager<R extends UnknownRecord> {
195
209
 
196
210
  redo() {
197
211
  const previousState = this.state
212
+ const previousIsReplaying = this._isReplaying
198
213
  this.state = HistoryRecorderState.Paused
214
+ this._isReplaying = true
199
215
  try {
200
216
  this.flushPendingDiff()
201
217
 
@@ -224,11 +240,11 @@ export class HistoryManager<R extends UnknownRecord> {
224
240
  break
225
241
  }
226
242
  }
227
-
228
243
  this.store.applyDiff(diffToRedo, { ignoreEphemeralKeys: true })
229
244
  this.store.ensureStoreIsUsable()
230
245
  this.stacks.set({ undos, redos })
231
246
  } finally {
247
+ this._isReplaying = previousIsReplaying
232
248
  this.state = previousState
233
249
  }
234
250
 
@@ -349,7 +365,16 @@ class PendingDiff<R extends UnknownRecord> {
349
365
 
350
366
  apply(diff: RecordsDiff<R>) {
351
367
  squashRecordDiffsMutable(this.diff, [diff])
352
- this.isEmptyAtom.set(isRecordsDiffEmpty(this.diff))
368
+ // Recomputing emptiness from the accumulated diff is O(N) (for-in over a
369
+ // dictionary-mode object pays an O(N) key-collection prologue), and this runs on
370
+ // every history interceptor call — e.g. every input tick while resizing many
371
+ // shapes. Updates can never cancel out existing entries during a squash, so the
372
+ // full recompute is only needed when the incoming diff adds or removes records.
373
+ if (hasAnyKey(diff.added) || hasAnyKey(diff.removed)) {
374
+ this.isEmptyAtom.set(isRecordsDiffEmpty(this.diff))
375
+ } else if (this.isEmptyAtom.__unsafe__getWithoutCapture()) {
376
+ this.isEmptyAtom.set(!hasAnyKey(diff.updated))
377
+ }
353
378
  }
354
379
 
355
380
  debug() {
@@ -357,6 +382,11 @@ class PendingDiff<R extends UnknownRecord> {
357
382
  }
358
383
  }
359
384
 
385
+ function hasAnyKey(obj: object) {
386
+ for (const _ in obj) return true
387
+ return false
388
+ }
389
+
360
390
  type Stack<T> = StackItem<T> | EmptyStackItem<T>
361
391
 
362
392
  function stack<T>(): Stack<T> {
@@ -0,0 +1,61 @@
1
+ import { createTLStore } from '../../../config/createTLStore'
2
+ import { Editor } from '../../Editor'
3
+
4
+ function createTestEditor() {
5
+ const store = createTLStore({})
6
+ store.ensureStoreIsUsable()
7
+ return new Editor({
8
+ store,
9
+ bindingUtils: [],
10
+ shapeUtils: [],
11
+ getContainer: () => document.createElement('div'),
12
+ tools: [],
13
+ })
14
+ }
15
+
16
+ describe('InputsManager', () => {
17
+ let editor: Editor
18
+
19
+ beforeEach(() => {
20
+ editor = createTestEditor()
21
+ })
22
+
23
+ afterEach(() => {
24
+ editor.dispose()
25
+ })
26
+
27
+ it('updates pointer velocity on frame events', () => {
28
+ const point = editor.inputs.getCurrentScreenPoint()
29
+ point.x = 0
30
+ point.y = 0
31
+ editor.emit('frame', 16)
32
+
33
+ point.x = 100
34
+ point.y = 0
35
+ editor.emit('frame', 16)
36
+
37
+ expect(editor.inputs.getPointerVelocity().len()).toBeGreaterThan(0)
38
+ })
39
+
40
+ it('stops updating pointer velocity after dispose', () => {
41
+ const point = editor.inputs.getCurrentScreenPoint()
42
+ point.x = 0
43
+ point.y = 0
44
+ editor.emit('frame', 16)
45
+
46
+ point.x = 100
47
+ point.y = 0
48
+ editor.emit('frame', 16)
49
+
50
+ const velocityBeforeDispose = editor.inputs.getPointerVelocity().clone()
51
+ expect(velocityBeforeDispose.len()).toBeGreaterThan(0)
52
+
53
+ editor.inputs.dispose()
54
+
55
+ point.x = 200
56
+ point.y = 0
57
+ editor.emit('frame', 16)
58
+
59
+ expect(editor.inputs.getPointerVelocity()).toEqual(velocityBeforeDispose)
60
+ })
61
+ })
@@ -1,6 +1,7 @@
1
1
  import { atom, computed, 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'
4
5
  import { INTERNAL_POINTER_IDS } from '../../../constants'
5
6
  import { Vec } from '../../../primitives/Vec'
6
7
  import { isAccelKey } from '../../../utils/keyboard'
@@ -12,7 +13,19 @@ const POINTER_VELOCITY_REFERENCE_SMOOTHING = 0.5
12
13
 
13
14
  /** @public */
14
15
  export class InputsManager {
15
- constructor(private readonly editor: Editor) {}
16
+ constructor(private readonly editor: Editor) {
17
+ this.editor.on('frame', this._onFrame)
18
+ }
19
+
20
+ /** @internal */
21
+ dispose() {
22
+ this.editor.off('frame', this._onFrame)
23
+ }
24
+
25
+ @bind
26
+ private _onFrame(elapsed: number) {
27
+ this.updatePointerVelocity(elapsed)
28
+ }
16
29
 
17
30
  private _originPagePoint = atom<Vec>('originPagePoint', new Vec())
18
31
  /**
@@ -120,8 +133,7 @@ export class InputsManager {
120
133
  }
121
134
 
122
135
  /**
123
- * Normally you shouldn't need to set the pointer velocity directly, this is set by the tick manager.
124
- * However, this is currently used in tests to fake pointer velocity.
136
+ * Normally you shouldn't need to set the pointer velocity directly. Used in tests to fake pointer velocity.
125
137
  * @param pointerVelocity - The pointer velocity.
126
138
  * @internal
127
139
  */
@@ -460,7 +472,7 @@ export class InputsManager {
460
472
  private _velocityPrevPoint = new Vec()
461
473
 
462
474
  /**
463
- * Update the pointer velocity based on elapsed time. Called by the tick manager.
475
+ * Update the pointer velocity based on elapsed time. Called each frame.
464
476
  * @param elapsed - The time elapsed since the last tick in milliseconds.
465
477
  * @internal
466
478
  */
@@ -46,6 +46,10 @@ export class SpatialIndexManager {
46
46
 
47
47
  private createSpatialIndexComputed() {
48
48
  const shapeHistory = this.editor.store.query.filterHistory('shape')
49
+ // Binding changes can move a shape's derived bounds (e.g. creating or
50
+ // deleting an arrow binding relocates the arrow's body) without
51
+ // touching any shape record, so they must also invalidate the index.
52
+ const bindingHistory = this.editor.store.query.filterHistory('binding')
49
53
 
50
54
  return computed<number>('spatialIndex', (_prevValue, lastComputedEpoch) => {
51
55
  if (isUninitialized(_prevValue)) {
@@ -53,8 +57,9 @@ export class SpatialIndexManager {
53
57
  }
54
58
 
55
59
  const shapeDiff = shapeHistory.getDiffSince(lastComputedEpoch)
60
+ const bindingDiff = bindingHistory.getDiffSince(lastComputedEpoch)
56
61
 
57
- if (shapeDiff === RESET_VALUE) {
62
+ if (shapeDiff === RESET_VALUE || bindingDiff === RESET_VALUE) {
58
63
  return this.rebuildAndBumpEpoch()
59
64
  }
60
65
 
@@ -63,8 +68,10 @@ export class SpatialIndexManager {
63
68
  return this.rebuildAndBumpEpoch()
64
69
  }
65
70
 
66
- if (shapeDiff.length === 0) return this._boundsEpoch
71
+ if (shapeDiff.length === 0 && bindingDiff.length === 0) return this._boundsEpoch
67
72
 
73
+ // A binding-only diff passes an empty shape diff: step 1 is a no-op
74
+ // and the step-2 sweep re-checks the indexed bounds of every shape.
68
75
  if (this.processIncrementalUpdate(shapeDiff)) {
69
76
  this._boundsEpoch++
70
77
  }
@@ -82,20 +82,22 @@ const mockEditor = {
82
82
  getContainerDocument: vi.fn(() => mockDocument),
83
83
  } as unknown as Editor
84
84
 
85
- global.Range = vi.fn(() => ({
86
- setStart: vi.fn(),
87
- setEnd: vi.fn(),
88
- getClientRects: vi.fn(() => [
89
- {
90
- width: 10,
91
- height: 16,
92
- left: 0,
93
- top: 0,
94
- right: 10,
95
- bottom: 16,
96
- },
97
- ]),
98
- })) as any
85
+ global.Range = vi.fn(function () {
86
+ return {
87
+ setStart: vi.fn(),
88
+ setEnd: vi.fn(),
89
+ getClientRects: vi.fn(() => [
90
+ {
91
+ width: 10,
92
+ height: 16,
93
+ left: 0,
94
+ top: 0,
95
+ right: 10,
96
+ bottom: 16,
97
+ },
98
+ ]),
99
+ }
100
+ }) as any
99
101
 
100
102
  describe('TextManager', () => {
101
103
  let textManager: TextManager
@@ -2,6 +2,21 @@ import { BoxModel, TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema'
2
2
  import { objectMapKeys } from '@tldraw/utils'
3
3
  import type { Editor } from '../../Editor'
4
4
 
5
+ /**
6
+ * The whole-pixel line-height for a given font size and tldraw's unitless line-height
7
+ * multiplier. tldraw's theme stores line-height as a multiplier (e.g. 1.35); resolving it
8
+ * to a whole pixel keeps line spacing identical across rendering engines, which otherwise
9
+ * disagree on fractional line boxes (WebKit snaps them to whole pixels, Blink keeps the
10
+ * fraction) and let multi-line text drift apart. Apply it everywhere line-height is used —
11
+ * measurement, on-canvas render, and export — so geometry and rendering agree.
12
+ * See https://github.com/tldraw/tldraw/issues/8970.
13
+ *
14
+ * @public
15
+ */
16
+ export function resolveLineHeightPx(fontSize: number, lineHeight: number): number {
17
+ return Math.round(fontSize * lineHeight)
18
+ }
19
+
5
20
  const fixNewLines = /\r?\n|\r/g
6
21
 
7
22
  function normalizeTextForDom(text: string) {
@@ -154,7 +169,7 @@ export class TextManager {
154
169
  'font-style': opts.fontStyle,
155
170
  'font-weight': opts.fontWeight,
156
171
  'font-size': opts.fontSize + 'px',
157
- 'line-height': opts.lineHeight.toString(),
172
+ 'line-height': `${resolveLineHeightPx(opts.fontSize, opts.lineHeight)}px`,
158
173
  padding: opts.padding,
159
174
  'max-width': opts.maxWidth ? opts.maxWidth + 'px' : undefined,
160
175
  'min-width': opts.minWidth ? opts.minWidth + 'px' : undefined,
@@ -382,7 +397,7 @@ export class TextManager {
382
397
  'font-style': opts.fontStyle,
383
398
  'font-weight': opts.fontWeight,
384
399
  'font-size': opts.fontSize + 'px',
385
- 'line-height': opts.lineHeight.toString(),
400
+ 'line-height': `${resolveLineHeightPx(opts.fontSize, opts.lineHeight)}px`,
386
401
  width: `${elementWidth}px`,
387
402
  height: 'min-content',
388
403
  'text-align': textAlignmentsForLtr[opts.textAlign],