@tldraw/editor 5.1.0-next.d7c83ba698ae → 5.1.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 (91) hide show
  1. package/dist-cjs/index.d.ts +19 -2
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/editor/Editor.js +3 -5
  4. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  5. package/dist-cjs/lib/editor/derivations/notVisibleShapes.js +23 -21
  6. package/dist-cjs/lib/editor/derivations/notVisibleShapes.js.map +2 -2
  7. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +27 -8
  8. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  9. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +4 -2
  10. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
  11. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js +2 -2
  12. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js.map +2 -2
  13. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +8 -0
  14. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  15. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +7 -1
  16. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
  17. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js +13 -15
  18. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +43 -24
  20. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/overlays/OverlayManager.js +5 -0
  22. package/dist-cjs/lib/editor/overlays/OverlayManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/overlays/OverlayUtil.js +5 -0
  24. package/dist-cjs/lib/editor/overlays/OverlayUtil.js.map +2 -2
  25. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +1 -1
  26. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +2 -2
  27. package/dist-cjs/lib/hooks/usePeerIds.js.map +2 -2
  28. package/dist-cjs/lib/options.js +1 -0
  29. package/dist-cjs/lib/options.js.map +2 -2
  30. package/dist-cjs/lib/utils/dom.js +1 -0
  31. package/dist-cjs/lib/utils/dom.js.map +2 -2
  32. package/dist-cjs/version.js +3 -3
  33. package/dist-cjs/version.js.map +1 -1
  34. package/dist-esm/index.d.mts +19 -2
  35. package/dist-esm/index.mjs +1 -1
  36. package/dist-esm/lib/editor/Editor.mjs +3 -5
  37. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  38. package/dist-esm/lib/editor/derivations/notVisibleShapes.mjs +23 -21
  39. package/dist-esm/lib/editor/derivations/notVisibleShapes.mjs.map +2 -2
  40. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +27 -11
  41. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  42. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +4 -2
  43. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
  44. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs +2 -2
  45. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs.map +2 -2
  46. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +8 -0
  47. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  48. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +7 -1
  49. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
  50. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs +13 -15
  51. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs.map +2 -2
  52. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +43 -24
  53. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
  54. package/dist-esm/lib/editor/overlays/OverlayManager.mjs +5 -0
  55. package/dist-esm/lib/editor/overlays/OverlayManager.mjs.map +2 -2
  56. package/dist-esm/lib/editor/overlays/OverlayUtil.mjs +5 -0
  57. package/dist-esm/lib/editor/overlays/OverlayUtil.mjs.map +2 -2
  58. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +1 -1
  59. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +2 -2
  60. package/dist-esm/lib/hooks/usePeerIds.mjs.map +2 -2
  61. package/dist-esm/lib/options.mjs +1 -0
  62. package/dist-esm/lib/options.mjs.map +2 -2
  63. package/dist-esm/lib/utils/dom.mjs +1 -0
  64. package/dist-esm/lib/utils/dom.mjs.map +2 -2
  65. package/dist-esm/version.mjs +3 -3
  66. package/dist-esm/version.mjs.map +1 -1
  67. package/package.json +7 -7
  68. package/src/lib/editor/Editor.ts +3 -5
  69. package/src/lib/editor/derivations/notVisibleShapes.ts +34 -26
  70. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts +132 -0
  71. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +40 -12
  72. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +12 -2
  73. package/src/lib/editor/managers/FocusManager/FocusManager.test.ts +7 -0
  74. package/src/lib/editor/managers/FocusManager/FocusManager.ts +2 -2
  75. package/src/lib/editor/managers/FontManager/FontManager.test.ts +33 -2
  76. package/src/lib/editor/managers/FontManager/FontManager.ts +20 -2
  77. package/src/lib/editor/managers/InputsManager/InputsManager.ts +11 -2
  78. package/src/lib/editor/managers/SpatialIndexManager/RBushIndex.ts +13 -14
  79. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +67 -40
  80. package/src/lib/editor/overlays/OverlayManager.ts +6 -0
  81. package/src/lib/editor/overlays/OverlayUtil.ts +5 -0
  82. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +1 -1
  83. package/src/lib/hooks/usePeerIds.ts +1 -0
  84. package/src/lib/options.ts +8 -0
  85. package/src/lib/utils/dom.ts +1 -0
  86. package/src/version.ts +3 -3
  87. package/dist-cjs/lib/utils/collaboratorState.js +0 -42
  88. package/dist-cjs/lib/utils/collaboratorState.js.map +0 -7
  89. package/dist-esm/lib/utils/collaboratorState.mjs +0 -22
  90. package/dist-esm/lib/utils/collaboratorState.mjs.map +0 -7
  91. package/src/lib/utils/collaboratorState.ts +0 -54
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.1.0-next.d7c83ba698ae",
4
+ "version": "5.1.0",
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.1.0-next.d7c83ba698ae",
53
- "@tldraw/state-react": "5.1.0-next.d7c83ba698ae",
54
- "@tldraw/store": "5.1.0-next.d7c83ba698ae",
55
- "@tldraw/tlschema": "5.1.0-next.d7c83ba698ae",
56
- "@tldraw/utils": "5.1.0-next.d7c83ba698ae",
57
- "@tldraw/validate": "5.1.0-next.d7c83ba698ae",
52
+ "@tldraw/state": "5.1.0",
53
+ "@tldraw/state-react": "5.1.0",
54
+ "@tldraw/store": "5.1.0",
55
+ "@tldraw/tlschema": "5.1.0",
56
+ "@tldraw/utils": "5.1.0",
57
+ "@tldraw/validate": "5.1.0",
58
58
  "classnames": "^2.5.1",
59
59
  "eventemitter3": "^4.0.7",
60
60
  "idb": "^7.1.1",
@@ -416,6 +416,7 @@ export class Editor extends EventEmitter<TLEventMap> {
416
416
  })
417
417
 
418
418
  this.fonts = new FontManager(this, fontAssetUrls)
419
+ this.disposables.add(() => this.fonts.dispose())
419
420
 
420
421
  this.inputs = new InputsManager(this)
421
422
  this.performance = new PerformanceManager(this)
@@ -502,6 +503,7 @@ export class Editor extends EventEmitter<TLEventMap> {
502
503
 
503
504
  // Overlay utils
504
505
  this.overlays = new OverlayManager(this)
506
+ this.disposables.add(() => this.overlays.dispose())
505
507
  if (overlayUtilConstructors) {
506
508
  for (const Util of overlayUtilConstructors) {
507
509
  const util = new Util(this)
@@ -10370,11 +10372,7 @@ export class Editor extends EventEmitter<TLEventMap> {
10370
10372
  */
10371
10373
  blur({ blurContainer = true } = {}): this {
10372
10374
  if (!this.getIsFocused()) return this
10373
- if (blurContainer) {
10374
- this.focusManager.blur()
10375
- } else {
10376
- this.complete() // stop any interaction
10377
- }
10375
+ this.focusManager.blur({ blurContainer })
10378
10376
  this.updateInstanceState({ isFocused: false })
10379
10377
  return this
10380
10378
  }
@@ -1,6 +1,7 @@
1
1
  import { computed, isUninitialized } from '@tldraw/state'
2
- import { TLShape, TLShapeId } from '@tldraw/tlschema'
2
+ import { TLShapeId } from '@tldraw/tlschema'
3
3
  import type { Editor } from '../Editor'
4
+ import { ShapeUtil } from '../shapes/ShapeUtil'
4
5
 
5
6
  /**
6
7
  * Non visible shapes are shapes outside of the viewport page bounds.
@@ -10,48 +11,55 @@ import type { Editor } from '../Editor'
10
11
  */
11
12
  export function notVisibleShapes(editor: Editor) {
12
13
  const emptySet = new Set<TLShapeId>()
14
+ const defaultCanCull = ShapeUtil.prototype.canCull
13
15
 
14
16
  return computed<Set<TLShapeId>>('notVisibleShapes', function (prevValue) {
15
- const allShapes = editor.getCurrentPageShapes()
17
+ const allShapeIds = editor.getCurrentPageShapeIds()
16
18
  const viewportPageBounds = editor.getViewportPageBounds()
17
19
  const visibleIds = editor.getShapeIdsInsideBounds(viewportPageBounds)
18
20
 
19
- let shape: TLShape | undefined
20
-
21
21
  // Fast path: if all shapes are visible, return empty set
22
- if (visibleIds.size === allShapes.length) {
22
+ if (visibleIds.size === allShapeIds.size) {
23
23
  if (isUninitialized(prevValue) || prevValue.size > 0) {
24
24
  return emptySet
25
25
  }
26
26
  return prevValue
27
27
  }
28
28
 
29
- // First run: compute from scratch
30
- if (isUninitialized(prevValue)) {
31
- const nextValue = new Set<TLShapeId>()
32
- for (let i = 0; i < allShapes.length; i++) {
33
- shape = allShapes[i]
34
- if (visibleIds.has(shape.id)) continue
35
- if (!editor.getShapeUtil(shape.type).canCull(shape)) continue
36
- nextValue.add(shape.id)
29
+ const notVisibleIds = new Set<TLShapeId>()
30
+ for (const id of allShapeIds) {
31
+ if (visibleIds.has(id)) continue
32
+
33
+ // Peek at the shape without subscribing — we only need its type to look up the util.
34
+ // Type is treated as immutable for a given id, so this is safe.
35
+ const peek = editor.store.unsafeGetWithoutCapture(id)
36
+ if (!peek) continue
37
+ const util = editor.getShapeUtil(peek.type)
38
+
39
+ // If canCull is the default (always-true), skip per-shape subscription entirely.
40
+ // >99% of shapes hit this path in practice.
41
+ if (util.canCull === defaultCanCull) {
42
+ notVisibleIds.add(id)
43
+ continue
37
44
  }
38
- return nextValue
45
+
46
+ // Custom canCull — subscribe so prop flips invalidate this derivation.
47
+ const shape = editor.getShape(id)
48
+ if (!shape) continue
49
+ if (!util.canCull(shape)) continue
50
+ notVisibleIds.add(id)
39
51
  }
40
52
 
41
- // Subsequent runs: single pass to collect IDs and detect changes
42
- const notVisibleIds: TLShapeId[] = []
43
- for (let i = 0; i < allShapes.length; i++) {
44
- shape = allShapes[i]
45
- if (visibleIds.has(shape.id)) continue
46
- if (!editor.getShapeUtil(shape.type).canCull(shape)) continue
47
- notVisibleIds.push(shape.id)
53
+ // First run
54
+ if (isUninitialized(prevValue)) {
55
+ return notVisibleIds
48
56
  }
49
57
 
50
- // Check if the result changed
51
- if (notVisibleIds.length === prevValue.size) {
58
+ // Reuse prev set when contents are unchanged
59
+ if (notVisibleIds.size === prevValue.size) {
52
60
  let same = true
53
- for (let i = 0; i < notVisibleIds.length; i++) {
54
- if (!prevValue.has(notVisibleIds[i])) {
61
+ for (const id of notVisibleIds) {
62
+ if (!prevValue.has(id)) {
55
63
  same = false
56
64
  break
57
65
  }
@@ -59,6 +67,6 @@ export function notVisibleShapes(editor: Editor) {
59
67
  if (same) return prevValue
60
68
  }
61
69
 
62
- return new Set(notVisibleIds)
70
+ return notVisibleIds
63
71
  })
64
72
  }
@@ -0,0 +1,132 @@
1
+ import { PageRecordType, type TLInstancePresence } from '@tldraw/tlschema'
2
+ import { vi } from 'vitest'
3
+ import type { Editor } from '../../Editor'
4
+ import { CollaboratorsManager } from './CollaboratorsManager'
5
+
6
+ const currentPageId = PageRecordType.createId('page')
7
+
8
+ function createPresence(userId: string): TLInstancePresence {
9
+ return {
10
+ typeName: 'instance_presence',
11
+ id: `instance_presence:${userId}` as TLInstancePresence['id'],
12
+ userId,
13
+ userName: userId,
14
+ lastActivityTimestamp: Date.now(),
15
+ color: '#000000',
16
+ camera: null,
17
+ selectedShapeIds: [],
18
+ currentPageId,
19
+ brush: null,
20
+ scribbles: [],
21
+ screenBounds: null,
22
+ followingUserId: null,
23
+ cursor: null,
24
+ chatMessage: '',
25
+ meta: {},
26
+ }
27
+ }
28
+
29
+ function createEditor(presences: TLInstancePresence[] = []) {
30
+ const setInterval = vi.fn(() => 123)
31
+ const getInstanceState = vi.fn(() => ({
32
+ followingUserId: null,
33
+ highlightedUserIds: [],
34
+ }))
35
+ const userGetId = vi.fn(() => 'current-user')
36
+
37
+ const editor = {
38
+ options: {
39
+ collaboratorCheckIntervalMs: 1000,
40
+ collaboratorIdleTimeoutMs: 3000,
41
+ collaboratorInactiveTimeoutMs: 5000,
42
+ },
43
+ timers: {
44
+ setInterval,
45
+ },
46
+ user: {
47
+ getId: userGetId,
48
+ },
49
+ store: {
50
+ query: {
51
+ records: vi.fn(() => ({
52
+ get: () => presences,
53
+ })),
54
+ },
55
+ },
56
+ getInstanceState,
57
+ getCurrentPageId: vi.fn(() => currentPageId),
58
+ } as unknown as Editor
59
+
60
+ return { editor, setInterval, getInstanceState, userGetId }
61
+ }
62
+
63
+ describe(CollaboratorsManager, () => {
64
+ afterEach(() => {
65
+ vi.clearAllMocks()
66
+ })
67
+
68
+ it('starts the visibility clock on the first visible collaborators read', () => {
69
+ const { editor, setInterval } = createEditor()
70
+ const manager = new CollaboratorsManager(editor)
71
+
72
+ expect(setInterval).not.toHaveBeenCalled()
73
+
74
+ expect(manager.getVisibleCollaborators()).toEqual([])
75
+
76
+ expect(setInterval).toHaveBeenCalledTimes(1)
77
+ expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 1000)
78
+ })
79
+
80
+ it('only starts the visibility clock once across repeated reads', () => {
81
+ const { editor, setInterval } = createEditor()
82
+ const manager = new CollaboratorsManager(editor)
83
+
84
+ manager.getVisibleCollaborators()
85
+ manager.getVisibleCollaborators()
86
+ manager.getVisibleCollaborators()
87
+
88
+ expect(setInterval).toHaveBeenCalledTimes(1)
89
+ })
90
+
91
+ it('reads instance state once when filtering visible collaborators', () => {
92
+ const { editor, getInstanceState } = createEditor([
93
+ createPresence('user-1'),
94
+ createPresence('user-2'),
95
+ ])
96
+ const manager = new CollaboratorsManager(editor)
97
+
98
+ expect(manager.getVisibleCollaborators()).toHaveLength(2)
99
+
100
+ expect(getInstanceState).toHaveBeenCalledTimes(1)
101
+ })
102
+
103
+ it('hides idle collaborators that are following us', () => {
104
+ const presence = createPresence('peer')
105
+ presence.lastActivityTimestamp = Date.now() - 4000
106
+ presence.followingUserId = 'current-user'
107
+ const { editor } = createEditor([presence])
108
+ const manager = new CollaboratorsManager(editor)
109
+
110
+ expect(manager.getVisibleCollaborators()).toEqual([])
111
+ })
112
+
113
+ it('shows idle collaborators that are following us when they have a chat message', () => {
114
+ const presence = createPresence('peer')
115
+ presence.lastActivityTimestamp = Date.now() - 4000
116
+ presence.followingUserId = 'current-user'
117
+ presence.chatMessage = 'hi'
118
+ const { editor } = createEditor([presence])
119
+ const manager = new CollaboratorsManager(editor)
120
+
121
+ expect(manager.getVisibleCollaborators()).toHaveLength(1)
122
+ })
123
+
124
+ it('shows idle collaborators that are not following us', () => {
125
+ const presence = createPresence('peer')
126
+ presence.lastActivityTimestamp = Date.now() - 4000
127
+ const { editor } = createEditor([presence])
128
+ const manager = new CollaboratorsManager(editor)
129
+
130
+ expect(manager.getVisibleCollaborators()).toHaveLength(1)
131
+ })
132
+ })
@@ -1,10 +1,6 @@
1
1
  import { EMPTY_ARRAY, atom, computed } from '@tldraw/state'
2
- import { TLInstancePresence } from '@tldraw/tlschema'
2
+ import type { TLInstancePresence } from '@tldraw/tlschema'
3
3
  import { maxBy } from '@tldraw/utils'
4
- import {
5
- getCollaboratorStateFromElapsedTime,
6
- shouldShowCollaborator,
7
- } from '../../../utils/collaboratorState'
8
4
  import type { Editor } from '../../Editor'
9
5
 
10
6
  /**
@@ -17,12 +13,19 @@ import type { Editor } from '../../Editor'
17
13
  * @public
18
14
  */
19
15
  export class CollaboratorsManager {
20
- constructor(private readonly editor: Editor) {
16
+ constructor(private readonly editor: Editor) {}
17
+
18
+ private _visibilityClockStarted = false
19
+
20
+ private _startVisibilityClock() {
21
+ if (this._visibilityClockStarted) return
22
+ this._visibilityClockStarted = true
23
+
21
24
  // Editor disposes `editor.timers` on its own teardown, so the interval is
22
25
  // automatically cleared when the editor is disposed.
23
- editor.timers.setInterval(() => {
26
+ this.editor.timers.setInterval(() => {
24
27
  this._visibilityClock.set(Date.now())
25
- }, editor.options.collaboratorCheckIntervalMs)
28
+ }, this.editor.options.collaboratorCheckIntervalMs)
26
29
  }
27
30
 
28
31
  /**
@@ -75,14 +78,39 @@ export class CollaboratorsManager {
75
78
  */
76
79
  @computed
77
80
  getVisibleCollaborators(): TLInstancePresence[] {
81
+ const { editor } = this
82
+ const { collaboratorInactiveTimeoutMs, collaboratorIdleTimeoutMs } = editor.options
83
+
84
+ this._startVisibilityClock()
78
85
  this._visibilityClock.get()
79
86
  const now = Date.now()
80
- return this.getCollaborators().filter((presence) => {
87
+ const collaborators = this.getCollaborators()
88
+ if (!collaborators.length) return EMPTY_ARRAY
89
+
90
+ const { followingUserId, highlightedUserIds } = this.editor.getInstanceState()
91
+ const currentUserId = this.editor.user.getId()
92
+
93
+ return collaborators.filter((presence) => {
94
+ const { lastActivityTimestamp, userId, chatMessage } = presence
95
+
81
96
  // Treat a missing `lastActivityTimestamp` as "active right now" (elapsed = 0)
82
97
  // so newly-joined peers aren't immediately classified as idle/inactive.
83
- const elapsed = Math.max(0, now - (presence.lastActivityTimestamp ?? now))
84
- const state = getCollaboratorStateFromElapsedTime(this.editor, elapsed)
85
- return shouldShowCollaborator(this.editor, presence, state)
98
+ const elapsed = Math.max(0, now - (lastActivityTimestamp ?? now))
99
+
100
+ if (elapsed > collaboratorInactiveTimeoutMs) {
101
+ // Inactive: If they're inactive, only show if we're following them or they're highlighted
102
+ return followingUserId === userId || highlightedUserIds.includes(userId)
103
+ }
104
+
105
+ if (elapsed > collaboratorIdleTimeoutMs) {
106
+ // Idle: If they're idle and following us, hide them unless they have a chat message or are highlighted
107
+ if (presence.followingUserId === currentUserId) {
108
+ return !!(chatMessage || highlightedUserIds.includes(userId))
109
+ }
110
+ }
111
+
112
+ // Active
113
+ return true
86
114
  })
87
115
  }
88
116
 
@@ -2,6 +2,10 @@ import { EASINGS } from '../../../primitives/easings'
2
2
  import { Vec } from '../../../primitives/Vec'
3
3
  import type { Editor } from '../../Editor'
4
4
 
5
+ // Tuned for touch/small-screen feel; adjust together with coarsePointerWidth.
6
+ const EDGE_SCROLL_SMALL_SCREEN_THRESHOLD_PX = 1000
7
+ const EDGE_SCROLL_SMALL_SCREEN_SPEED_FACTOR = 0.612
8
+
5
9
  /** @public */
6
10
  export class EdgeScrollManager {
7
11
  constructor(public editor: Editor) {}
@@ -115,8 +119,14 @@ export class EdgeScrollManager {
115
119
  const screenBounds = editor.getViewportScreenBounds()
116
120
 
117
121
  // Determines how much the speed is affected by the screen size
118
- const screenSizeFactorX = screenBounds.w < 1000 ? 0.612 : 1
119
- const screenSizeFactorY = screenBounds.h < 1000 ? 0.612 : 1
122
+ const screenSizeFactorX =
123
+ screenBounds.w < EDGE_SCROLL_SMALL_SCREEN_THRESHOLD_PX
124
+ ? EDGE_SCROLL_SMALL_SCREEN_SPEED_FACTOR
125
+ : 1
126
+ const screenSizeFactorY =
127
+ screenBounds.h < EDGE_SCROLL_SMALL_SCREEN_THRESHOLD_PX
128
+ ? EDGE_SCROLL_SMALL_SCREEN_SPEED_FACTOR
129
+ : 1
120
130
 
121
131
  // Determines the base speed of the scroll
122
132
  const zoomLevel = editor.getZoomLevel()
@@ -334,6 +334,13 @@ describe('FocusManager', () => {
334
334
 
335
335
  expect(callOrder).toEqual(['complete', 'blur'])
336
336
  })
337
+
338
+ it('should complete without blurring the container when blurContainer is false', () => {
339
+ focusManager.blur({ blurContainer: false })
340
+
341
+ expect(editor.complete).toHaveBeenCalled()
342
+ expect(mockContainer.blur).not.toHaveBeenCalled()
343
+ })
337
344
  })
338
345
 
339
346
  describe('dispose', () => {
@@ -80,9 +80,9 @@ export class FocusManager {
80
80
  this.editor.getContainer().focus()
81
81
  }
82
82
 
83
- blur() {
83
+ blur({ blurContainer = true } = {}) {
84
84
  this.editor.complete() // stop any interaction
85
- this.editor.getContainer().blur() // blur the container
85
+ if (blurContainer) this.editor.getContainer().blur() // blur the container
86
86
  }
87
87
 
88
88
  dispose() {
@@ -36,6 +36,8 @@ describe('FontManager', () => {
36
36
  let editor: Mocked<Editor>
37
37
  let fontManager: FontManager
38
38
  let mockAssetUrls: { [key: string]: string }
39
+ let mockShapeFontFacesCacheGet: Mock
40
+ let mockShapeFontLoadStateCacheGet: Mock
39
41
 
40
42
  const createMockFont = (overrides: Partial<TLFontFace> = {}): TLFontFace => ({
41
43
  family: 'Test Font',
@@ -78,12 +80,15 @@ describe('FontManager', () => {
78
80
  getFontFaces: vi.fn(() => []),
79
81
  }
80
82
 
83
+ mockShapeFontFacesCacheGet = vi.fn(() => [])
84
+ mockShapeFontLoadStateCacheGet = vi.fn(() => ({ get: vi.fn(() => []) }))
85
+
81
86
  const mockStore = {
82
87
  createComputedCache: vi.fn(() => ({
83
- get: vi.fn(() => []),
88
+ get: mockShapeFontFacesCacheGet,
84
89
  })),
85
90
  createCache: vi.fn(() => ({
86
- get: vi.fn(() => ({ get: vi.fn(() => []) })),
91
+ get: mockShapeFontLoadStateCacheGet,
87
92
  })),
88
93
  }
89
94
 
@@ -110,6 +115,32 @@ describe('FontManager', () => {
110
115
  })
111
116
  })
112
117
 
118
+ describe('dispose', () => {
119
+ it('clears font state and caches', async () => {
120
+ const font = createMockFont()
121
+ const shapeId = createShapeId('test')
122
+ mockShapeFontFacesCacheGet.mockReturnValue([font])
123
+ const firstPromise = fontManager.ensureFontIsLoaded(font)
124
+
125
+ expect(fontManager.getShapeFontFaces(shapeId)).toEqual([font])
126
+ fontManager.trackFontsForShape(shapeId)
127
+ fontManager.requestFonts([font])
128
+ await firstPromise
129
+ fontManager.dispose()
130
+ fontManager.requestFonts([font])
131
+ const secondPromise = fontManager.ensureFontIsLoaded(font)
132
+
133
+ expect(fontManager.getShapeFontFaces(shapeId)).toEqual([])
134
+ fontManager.trackFontsForShape(shapeId)
135
+ expect(mockShapeFontFacesCacheGet).toHaveBeenCalledTimes(1)
136
+ expect(mockShapeFontLoadStateCacheGet).toHaveBeenCalledTimes(1)
137
+ expect(queueMicrotask).toHaveBeenCalledTimes(2)
138
+ expect(secondPromise).not.toBe(firstPromise)
139
+
140
+ await secondPromise
141
+ })
142
+ })
143
+
113
144
  describe('getShapeFontFaces', () => {
114
145
  it('should return empty array when no fonts found', () => {
115
146
  const shape = createMockShape()
@@ -16,6 +16,17 @@ interface FontState {
16
16
  readonly loadingPromise: Promise<void>
17
17
  }
18
18
 
19
+ interface ShapeFontFacesCache {
20
+ get(id: TLShapeId): TLFontFace[] | undefined
21
+ }
22
+
23
+ interface ShapeFontLoadStateCache {
24
+ get(id: TLShapeId): (FontState | null)[] | undefined
25
+ }
26
+
27
+ const EMPTY_SHAPE_FONT_FACES_CACHE: ShapeFontFacesCache = { get: () => undefined }
28
+ const EMPTY_SHAPE_FONT_LOAD_STATE_CACHE: ShapeFontLoadStateCache = { get: () => undefined }
29
+
19
30
  /** @public */
20
31
  export class FontManager {
21
32
  constructor(
@@ -49,8 +60,15 @@ export class FontManager {
49
60
  )
50
61
  }
51
62
 
52
- private readonly shapeFontFacesCache
53
- private readonly shapeFontLoadStateCache
63
+ dispose() {
64
+ this.fontStates.clear()
65
+ this.fontsToLoad.clear()
66
+ this.shapeFontFacesCache = EMPTY_SHAPE_FONT_FACES_CACHE
67
+ this.shapeFontLoadStateCache = EMPTY_SHAPE_FONT_LOAD_STATE_CACHE
68
+ }
69
+
70
+ private shapeFontFacesCache: ShapeFontFacesCache
71
+ private shapeFontLoadStateCache: ShapeFontLoadStateCache
54
72
 
55
73
  getShapeFontFaces(shape: TLShape | TLShapeId): TLFontFace[] {
56
74
  const shapeId = typeof shape === 'string' ? shape : shape.id
@@ -7,6 +7,9 @@ import { isAccelKey } from '../../../utils/keyboard'
7
7
  import type { Editor } from '../../Editor'
8
8
  import { TLPinchEventInfo, TLPointerEventInfo, TLWheelEventInfo } from '../../types/event-types'
9
9
 
10
+ const POINTER_VELOCITY_REFERENCE_INTERVAL_MS = 16
11
+ const POINTER_VELOCITY_REFERENCE_SMOOTHING = 0.5
12
+
10
13
  /** @public */
11
14
  export class InputsManager {
12
15
  constructor(private readonly editor: Editor) {}
@@ -473,8 +476,14 @@ export class InputsManager {
473
476
  const length = delta.len()
474
477
  const direction = length ? delta.div(length) : new Vec(0, 0)
475
478
 
476
- // consider adjusting this with an easing rather than a linear interpolation
477
- const next = pointerVelocity.clone().lrp(direction.mul(length / elapsed), 0.5)
479
+ // Preserve the old 16ms smoothing with alpha = 1 - (1 - 0.5)^(elapsed / 16).
480
+ const smoothing =
481
+ 1 -
482
+ Math.pow(
483
+ 1 - POINTER_VELOCITY_REFERENCE_SMOOTHING,
484
+ elapsed / POINTER_VELOCITY_REFERENCE_INTERVAL_MS
485
+ )
486
+ const next = pointerVelocity.clone().lrp(direction.mul(length / elapsed), smoothing)
478
487
 
479
488
  // if the velocity is very small, just set it to 0
480
489
  if (Math.abs(next.x) < 0.01) next.x = 0
@@ -113,25 +113,24 @@ export class RBushIndex {
113
113
  }
114
114
 
115
115
  /**
116
- * Get all shape IDs currently in the spatial index.
116
+ * Get the raw stored element for a shape, without allocating a Box.
117
+ * Use when you only need to read the indexed bounds for comparison.
118
+ *
119
+ * @internal
117
120
  */
118
- getAllShapeIds(): TLShapeId[] {
119
- return Array.from(this.elementsInTree.keys())
121
+ getElement(id: TLShapeId): SpatialElement | undefined {
122
+ return this.elementsInTree.get(id)
120
123
  }
121
124
 
122
125
  /**
123
- * Get the bounds currently stored in the spatial index for a shape.
124
- * Returns undefined if the shape is not in the index.
126
+ * Iterate the entries currently in the index. Callers may upsert existing
127
+ * keys or remove keys during iteration; current callers do not insert new
128
+ * keys.
129
+ *
130
+ * @internal
125
131
  */
126
- getBounds(id: TLShapeId): Box | undefined {
127
- const element = this.elementsInTree.get(id)
128
- if (!element) return undefined
129
- return new Box(
130
- element.minX,
131
- element.minY,
132
- element.maxX - element.minX,
133
- element.maxY - element.minY
134
- )
132
+ entries(): IterableIterator<[TLShapeId, SpatialElement]> {
133
+ return this.elementsInTree.entries()
135
134
  }
136
135
 
137
136
  /**