Skip to content

Commit 84b3964

Browse files
mimecuvalosteveruizokclaude
authored
refactor(editor): add CollaboratorsManager; split collaborator indicators (tldraw#8648)
In order to clean up a few rough edges from the OverlayUtil system landed in tldraw#8633, this PR (a) encapsulates collaborator state in a new manager, (b) gets remote-collaborator rendering out of the local-indicator overlay util, and (c) makes `setCursor` cheap to call from pointer-move hot paths. ### Concepts | Term | Type | Meaning | | ---- | ---- | ------- | | `CollaboratorsManager` | class | Lives on `editor.collaborators`; owns the visibility clock and exposes `getCollaborators*` queries | | `CollaboratorShapeIndicatorOverlayUtil` | class | tldraw-package overlay util that draws remote collaborators' selection indicators (split out of `ShapeIndicatorOverlayUtil`) | | `strokeShapeIndicators` | function | Shared canvas helper exported from `@tldraw/editor`, used by both local and collaborator indicator overlays | ### What changed **`CollaboratorsManager`.** The visibility clock atom and the collaborator-presence queries used to live directly on `Editor`. They now live on a small dedicated manager, in the same style as `ClickManager` / `EdgeScrollManager` / `FocusManager` etc. The previously `@internal` `Editor._collaboratorVisibilityClock` is now a private field on the manager. The four `Editor.getCollaborators*()` methods stay as thin convenience wrappers that delegate to `editor.collaborators.*` so existing callers don't break. **Collaborator indicators split out.** `ShapeIndicatorOverlayUtil` (in `@tldraw/editor`) used to also render remote collaborators' selected shapes via a third `collaboratorIndicators` prop on its overlay. That mixed two concerns: a generic editor-package util shouldn't know about presence records. Remote-indicator rendering is now its own `CollaboratorShapeIndicatorOverlayUtil` in `@tldraw/tldraw`, registered ahead of the local indicator util in `defaultOverlayUtils` and given a lower `zIndex` so local selection always paints on top. To avoid duplicating the canvas drawing code, the stroke logic was extracted into a public `strokeShapeIndicators(editor, ctx, shapeIds)` helper. **`setCursor` equality guard.** `updateHoveredOverlayId` (and various tool states) call `editor.setCursor(...)` from pointer-move handlers. Without a guard, every pointer move builds a fresh cursor object, enters a history batch, spreads instance state, and walks the validator before the store deduplicates the no-op write. The guard short-circuits when the requested `type`/`rotation` already match `instanceState.cursor`, skipping all of that on the hot path. The store-level `validateUsingKnownGoodVersion` dedup is still the source of truth — this is purely a perf shortcut. ### Editor API | Method / property | Description | | ----------------- | ----------- | | `editor.collaborators` | The `CollaboratorsManager` instance | | `editor.collaborators.getCollaborators()` / `getCollaboratorsOnCurrentPage()` / `getVisibleCollaborators()` / `getVisibleCollaboratorsOnCurrentPage()` | Same shape as the existing `Editor.getCollaborators*()` methods (which now delegate here) | | `strokeShapeIndicators(editor, ctx, shapeIds)` | Strokes the indicator path for each shape into the given 2D context. Uses the current stroke style — set color/`lineWidth`/`globalAlpha` on the context first | | `CollaboratorShapeIndicatorOverlayUtil` | New default overlay util in `@tldraw/tldraw`. Subclass to customize remote-indicator rendering independently of local selection | ### Change type - [x] `api` ### Test plan 1. Run `yarn dev` and confirm local selection / hover / hint indicators still render identically to before. 2. Open a multiplayer room (e.g. `yarn dev-app`); remote collaborators' selections should render below your own selection if you both select the same shape. 3. Move the pointer continuously over and off overlays — cursor should still update correctly, with no visible regression in responsiveness. 4. Confirm `editor.collaborators.getVisibleCollaborators()` and `editor.getVisibleCollaborators()` return identical results. - [ ] Unit tests - [ ] End to end tests ### Release notes - Add `editor.collaborators` (`CollaboratorsManager`) owning the collaborator visibility clock and presence queries. `Editor.getCollaborators()` / `getCollaboratorsOnCurrentPage()` / `getVisibleCollaborators()` / `getVisibleCollaboratorsOnCurrentPage()` are unchanged and now delegate to it. - Add `CollaboratorShapeIndicatorOverlayUtil` in `@tldraw/tldraw` for remote-collaborator selection indicators (split out of `ShapeIndicatorOverlayUtil`); add `strokeShapeIndicators` helper from `@tldraw/editor` for sharing indicator rendering between overlay utils. - `Editor.setCursor` now skips redundant writes when the requested cursor type and rotation already match the current cursor. ### API changes - Added `CollaboratorsManager` and `Editor.collaborators`. - Added `strokeShapeIndicators(editor, ctx, shapeIds)`. - Added `CollaboratorShapeIndicatorOverlayUtil` and `TLCollaboratorShapeIndicatorOverlay` to `@tldraw/tldraw`; added it to `defaultOverlayUtils` (registered before `ShapeIndicatorOverlayUtil`). - Removed the `@internal` `Editor._collaboratorVisibilityClock` atom (now private to `CollaboratorsManager`). - Removed the `collaboratorIndicators` prop from `TLShapeIndicatorOverlay`; remote-collaborator rendering moved to `CollaboratorShapeIndicatorOverlayUtil`. ### Code changes | Section | LOC change | | --------------- | ---------- | | Core code | +295 / -137 | | Automated files | +43 / -7 | Relates to tldraw#8633. Made with [Cursor](https://cursor.com) --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f6b4d01 commit 84b3964

9 files changed

Lines changed: 340 additions & 144 deletions

File tree

packages/editor/api-report.api.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,15 @@ export class ClickManager {
534534
// @public
535535
export function clockwiseAngleDist(a0: number, a1: number): number;
536536

537+
// @public
538+
export class CollaboratorsManager {
539+
constructor(editor: Editor);
540+
getCollaborators(): TLInstancePresence[];
541+
getCollaboratorsOnCurrentPage(): TLInstancePresence[];
542+
getVisibleCollaborators(): TLInstancePresence[];
543+
getVisibleCollaboratorsOnCurrentPage(): TLInstancePresence[];
544+
}
545+
537546
// @public (undocumented)
538547
export function ContainerProvider({ container, children }: ContainerProviderProps): JSX.Element;
539548

@@ -911,8 +920,7 @@ export class Editor extends EventEmitter<TLEventMap> {
911920
clearHistory(): this;
912921
// @internal
913922
protected _clickManager: ClickManager;
914-
// @internal (undocumented)
915-
readonly _collaboratorVisibilityClock: Atom<number, unknown>;
923+
readonly collaborators: CollaboratorsManager;
916924
complete(): this;
917925
// (undocumented)
918926
readonly contextId: string;
@@ -3261,6 +3269,9 @@ export const stopEventPropagation: (e: any) => any;
32613269
// @internal (undocumented)
32623270
export type StoreName = (typeof Table)[keyof typeof Table];
32633271

3272+
// @public
3273+
export function strokeShapeIndicators(editor: Editor, ctx: CanvasRenderingContext2D, shapeIds: TLShapeId[]): void;
3274+
32643275
// @public (undocumented)
32653276
export function suffixSafeId(id: SafeId, suffix: string): SafeId;
32663277

@@ -4600,10 +4611,6 @@ export type TLShapeErrorFallbackComponent = ComponentType<{
46004611
export interface TLShapeIndicatorOverlay extends TLOverlay {
46014612
// (undocumented)
46024613
props: {
4603-
collaboratorIndicators: Array<{
4604-
color: string;
4605-
shapeIds: TLShapeId[];
4606-
}>;
46074614
hintingShapeIds: TLShapeId[];
46084615
idsToDisplay: TLShapeId[];
46094616
};

packages/editor/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export {
136136
export { DEFAULT_THEME } from './lib/editor/managers/ThemeManager/defaultThemes'
137137
export { ThemeManager, resolveThemes } from './lib/editor/managers/ThemeManager/ThemeManager'
138138
export { TickManager } from './lib/editor/managers/TickManager/TickManager'
139+
export { CollaboratorsManager } from './lib/editor/managers/CollaboratorsManager/CollaboratorsManager'
139140
export { PerformanceApiAdapter } from './lib/editor/managers/PerformanceManager/PerformanceApiAdapter'
140141
export { PerformanceManager } from './lib/editor/managers/PerformanceManager/PerformanceManager'
141142
export {
@@ -182,6 +183,7 @@ export {
182183
} from './lib/editor/overlays/OverlayUtil'
183184
export {
184185
ShapeIndicatorOverlayUtil,
186+
strokeShapeIndicators,
185187
type TLShapeIndicatorOverlay,
186188
} from './lib/editor/overlays/ShapeIndicatorOverlayUtil'
187189
export {

packages/editor/src/lib/editor/Editor.ts

Lines changed: 33 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ import {
8989
hasOwnProperty,
9090
last,
9191
lerp,
92-
maxBy,
9392
minBy,
9493
sortById,
9594
sortByIndex,
@@ -132,10 +131,6 @@ import { PI, approximately, areAnglesCompatible, clamp, pointInPolygon } from '.
132131
import { Vec, VecLike } from '../primitives/Vec'
133132
import { areShapesContentEqual } from '../utils/areShapesContentEqual'
134133
import { dataUrlToFile } from '../utils/assets'
135-
import {
136-
getCollaboratorStateFromElapsedTime,
137-
shouldShowCollaborator,
138-
} from '../utils/collaboratorState'
139134
import { debugFlags } from '../utils/debug-flags'
140135
import {
141136
TLDeepLink,
@@ -156,6 +151,7 @@ import { notVisibleShapes } from './derivations/notVisibleShapes'
156151
import { parentsToChildren } from './derivations/parentsToChildren'
157152
import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage'
158153
import { ClickManager } from './managers/ClickManager/ClickManager'
154+
import { CollaboratorsManager } from './managers/CollaboratorsManager/CollaboratorsManager'
159155
import { EdgeScrollManager } from './managers/EdgeScrollManager/EdgeScrollManager'
160156
import { FocusManager } from './managers/FocusManager/FocusManager'
161157
import { FontManager } from './managers/FontManager/FontManager'
@@ -422,9 +418,7 @@ export class Editor extends EventEmitter<TLEventMap> {
422418
this.inputs = new InputsManager(this)
423419
this.performance = new PerformanceManager(this)
424420
this.disposables.add(() => this.performance.dispose())
425-
this.timers.setInterval(() => {
426-
this._collaboratorVisibilityClock.set(Date.now())
427-
}, this.options.collaboratorCheckIntervalMs)
421+
this.collaborators = new CollaboratorsManager(this)
428422

429423
class NewRoot extends RootState {
430424
static override initial = initialState ?? ''
@@ -1057,8 +1051,12 @@ export class Editor extends EventEmitter<TLEventMap> {
10571051
*/
10581052
readonly timers = tltime.forContext(this.contextId)
10591053

1060-
/** @internal */
1061-
readonly _collaboratorVisibilityClock = atom('collaboratorVisibilityClock', Date.now())
1054+
/**
1055+
* A manager for remote peer collaborators connected to this editor.
1056+
*
1057+
* @public
1058+
*/
1059+
readonly collaborators: CollaboratorsManager
10621060

10631061
/**
10641062
* A manager for the user and their preferences.
@@ -1946,11 +1944,23 @@ export class Editor extends EventEmitter<TLEventMap> {
19461944
/**
19471945
* Set the cursor.
19481946
*
1947+
* No-op when the partial wouldn't change the current cursor — `setCursor`
1948+
* is called from pointer-move hot paths (see `updateHoveredOverlayId`,
1949+
* various tool states) and skipping redundant writes avoids needlessly
1950+
* dirtying instance state.
1951+
*
19491952
* @param cursor - The cursor to set.
19501953
* @public
19511954
*/
19521955
setCursor(cursor: Partial<TLCursor>) {
1953-
this.updateInstanceState({ cursor: { ...this.getInstanceState().cursor, ...cursor } })
1956+
const current = this.getInstanceState().cursor
1957+
if (
1958+
(cursor.type === undefined || cursor.type === current.type) &&
1959+
(cursor.rotation === undefined || cursor.rotation === current.rotation)
1960+
) {
1961+
return this
1962+
}
1963+
this.updateInstanceState({ cursor: { ...current, ...cursor } })
19541964
return this
19551965
}
19561966

@@ -4256,43 +4266,28 @@ export class Editor extends EventEmitter<TLEventMap> {
42564266
}
42574267
// Collaborators
42584268

4259-
@computed
4260-
private _getCollaboratorsQuery() {
4261-
return this.store.query.records('instance_presence', () => ({
4262-
userId: { neq: this.user.getId() },
4263-
}))
4264-
}
4265-
42664269
/**
42674270
* Returns a list of presence records for all peer collaborators.
42684271
* This will return the latest presence record for each connected user.
42694272
*
4273+
* Convenience wrapper for {@link CollaboratorsManager.getCollaborators}.
4274+
*
42704275
* @public
42714276
*/
4272-
@computed
42734277
getCollaborators() {
4274-
const allPresenceRecords = this._getCollaboratorsQuery().get()
4275-
if (!allPresenceRecords.length) return EMPTY_ARRAY
4276-
const userIds = [...new Set(allPresenceRecords.map((c) => c.userId))].sort()
4277-
return userIds.map((id) => {
4278-
const latestPresence = maxBy(
4279-
allPresenceRecords.filter((c) => c.userId === id),
4280-
(p) => p.lastActivityTimestamp ?? 0
4281-
)
4282-
return latestPresence!
4283-
})
4278+
return this.collaborators.getCollaborators()
42844279
}
42854280

42864281
/**
42874282
* Returns a list of presence records for all peer collaborators on the current page.
42884283
* This will return the latest presence record for each connected user.
42894284
*
4285+
* Convenience wrapper for {@link CollaboratorsManager.getCollaboratorsOnCurrentPage}.
4286+
*
42904287
* @public
42914288
*/
4292-
@computed
42934289
getCollaboratorsOnCurrentPage() {
4294-
const currentPageId = this.getCurrentPageId()
4295-
return this.getCollaborators().filter((c) => c.currentPageId === currentPageId)
4290+
return this.collaborators.getCollaboratorsOnCurrentPage()
42964291
}
42974292

42984293
/**
@@ -4302,29 +4297,24 @@ export class Editor extends EventEmitter<TLEventMap> {
43024297
* users. Re-evaluates on the collaborator visibility clock, so callers don't need to
43034298
* drive their own activity timer.
43044299
*
4300+
* Convenience wrapper for {@link CollaboratorsManager.getVisibleCollaborators}.
4301+
*
43054302
* @public
43064303
*/
4307-
@computed
43084304
getVisibleCollaborators() {
4309-
this._collaboratorVisibilityClock.get()
4310-
const now = Date.now()
4311-
return this.getCollaborators().filter((presence) => {
4312-
const elapsed = Math.max(0, now - (presence.lastActivityTimestamp ?? Infinity))
4313-
const state = getCollaboratorStateFromElapsedTime(this, elapsed)
4314-
return shouldShowCollaborator(this, presence, state)
4315-
})
4305+
return this.collaborators.getVisibleCollaborators()
43164306
}
43174307

43184308
/**
43194309
* Returns a list of presence records for peer collaborators who should currently be
43204310
* shown in the UI, filtered to those on the current page.
43214311
*
4312+
* Convenience wrapper for {@link CollaboratorsManager.getVisibleCollaboratorsOnCurrentPage}.
4313+
*
43224314
* @public
43234315
*/
4324-
@computed
43254316
getVisibleCollaboratorsOnCurrentPage() {
4326-
const currentPageId = this.getCurrentPageId()
4327-
return this.getVisibleCollaborators().filter((c) => c.currentPageId === currentPageId)
4317+
return this.collaborators.getVisibleCollaboratorsOnCurrentPage()
43284318
}
43294319

43304320
// Attribution
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { EMPTY_ARRAY, atom, computed } from '@tldraw/state'
2+
import { TLInstancePresence } from '@tldraw/tlschema'
3+
import { maxBy } from '@tldraw/utils'
4+
import {
5+
getCollaboratorStateFromElapsedTime,
6+
shouldShowCollaborator,
7+
} from '../../../utils/collaboratorState'
8+
import type { Editor } from '../../Editor'
9+
10+
/**
11+
* Tracks remote peers and exposes the collaborator-related queries used by the
12+
* editor and its overlays. Encapsulates the visibility clock that periodically
13+
* re-evaluates which collaborators should be visible based on activity.
14+
*
15+
* Accessed via {@link Editor.collaborators}.
16+
*
17+
* @public
18+
*/
19+
export class CollaboratorsManager {
20+
constructor(private readonly editor: Editor) {
21+
// Editor disposes `editor.timers` on its own teardown, so the interval is
22+
// automatically cleared when the editor is disposed.
23+
editor.timers.setInterval(() => {
24+
this._visibilityClock.set(Date.now())
25+
}, editor.options.collaboratorCheckIntervalMs)
26+
}
27+
28+
/**
29+
* Drives reactive re-evaluation of {@link CollaboratorsManager.getVisibleCollaborators}.
30+
* Ticked on a fixed interval so callers don't need to manage their own activity timers.
31+
*/
32+
private readonly _visibilityClock = atom('collaboratorVisibilityClock', Date.now())
33+
34+
@computed
35+
private _getCollaboratorsQuery() {
36+
return this.editor.store.query.records('instance_presence', () => ({
37+
userId: { neq: this.editor.user.getId() },
38+
}))
39+
}
40+
41+
/**
42+
* Returns a list of presence records for all peer collaborators.
43+
* This will return the latest presence record for each connected user.
44+
*/
45+
@computed
46+
getCollaborators(): TLInstancePresence[] {
47+
const allPresenceRecords = this._getCollaboratorsQuery().get()
48+
if (!allPresenceRecords.length) return EMPTY_ARRAY
49+
const userIds = [...new Set(allPresenceRecords.map((c) => c.userId))].sort()
50+
return userIds.map((id) => {
51+
const latestPresence = maxBy(
52+
allPresenceRecords.filter((c) => c.userId === id),
53+
(p) => p.lastActivityTimestamp ?? 0
54+
)
55+
return latestPresence!
56+
})
57+
}
58+
59+
/**
60+
* Returns a list of presence records for all peer collaborators on the current page.
61+
* This will return the latest presence record for each connected user.
62+
*/
63+
@computed
64+
getCollaboratorsOnCurrentPage(): TLInstancePresence[] {
65+
const currentPageId = this.editor.getCurrentPageId()
66+
return this.getCollaborators().filter((c) => c.currentPageId === currentPageId)
67+
}
68+
69+
/**
70+
* Returns a list of presence records for peer collaborators who should currently be
71+
* shown in the UI. Filters {@link CollaboratorsManager.getCollaborators} by activity
72+
* state (active / idle / inactive) and visibility rules such as following and
73+
* highlighted users. Re-evaluates on the visibility clock, so callers don't need to
74+
* drive their own activity timer.
75+
*/
76+
@computed
77+
getVisibleCollaborators(): TLInstancePresence[] {
78+
this._visibilityClock.get()
79+
const now = Date.now()
80+
return this.getCollaborators().filter((presence) => {
81+
// Treat a missing `lastActivityTimestamp` as "active right now" (elapsed = 0)
82+
// 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)
86+
})
87+
}
88+
89+
/**
90+
* Returns a list of presence records for peer collaborators who should currently be
91+
* shown in the UI, filtered to those on the current page.
92+
*/
93+
@computed
94+
getVisibleCollaboratorsOnCurrentPage(): TLInstancePresence[] {
95+
const currentPageId = this.editor.getCurrentPageId()
96+
return this.getVisibleCollaborators().filter((c) => c.currentPageId === currentPageId)
97+
}
98+
}

0 commit comments

Comments
 (0)