Skip to content

Commit 839e118

Browse files
authored
feat(editor): add shape attribution, TLUserStore, and extensible user records (tldraw#8147)
In order to let developers track who created and last edited shapes — and to display that information in the UI — this PR introduces a shape attribution system built on a reactive `TLUserStore` provider interface, note-specific "first edited by" labeling, and extensible user records via `createTLSchema`. ### What's included **`TLUserStore` interface** (`@tldraw/tlschema`) A new optional `users` prop on the editor (and `<Tldraw />`) that connects tldraw to your auth system. Both methods return reactive `Signal`s so the editor automatically tracks changes to user data: - `getCurrentUser()` — returns a `Signal<TLUser | null>` for the active user (used for both presence broadcasting and shape attribution) - `resolve(userId)` — returns a `Signal<TLUser | null>` to resolve any user ID to display info (for rendering attribution labels) When no provider is given, a default implementation falls back to `UserPreferences` + the collaborator presence list. **Unified `TLUser` record type** (`@tldraw/tlschema`) Collapsed the previous `TLUser` + `TLUserInfo` types into a single `TLUser` store record (following the `TLAsset` pattern). User records are document-scoped and persist alongside shapes, assets, and pages — surviving across boards, clipboard paste, and `.tldr` files. **Extensible user metadata via `createTLSchema`** SDK users can now extend user records with custom validated metadata: ```ts const schema = createTLSchema({ user: { meta: { isAdmin: T.boolean, department: T.string, }, }, }) ``` Custom meta fields are validated when present but treated as optional, so existing user records remain valid. The new `createUserRecordType(config?)` function builds the record type with extended validators. **Note shape "first edited by" label** `NoteShapeUtil` now tracks `textFirstEditedBy` in its props and renders a small attribution label (first name) in the bottom-right corner of the note. The label resolves the display name through the user store and shows a tooltip with the full name. This tracks who first added text to the note — subsequent typo fixes by others don't change the attribution. Includes a note shape migration (`AddFirstEditedBy`). <img width="757" height="414" alt="Screenshot 2026-03-04 at 11 20 58" src="https://github.com/user-attachments/assets/190ea36d-873e-44d7-aa3c-8fefebc7db56" /> **New editor methods** - `editor.getAttributionUser(userId)` — resolves a user record by ID, asking the `TLUserStore` first (the app's source of truth), falling back to the `user:` record in the store - `editor.getAttributionUserId()` — returns the current user's ID for attribution purposes - `editor.getAttributionDisplayName(userId)` — resolves a display name for a user ID, with the same lookup order **New "Users" example category with four examples** - **Attribution** — demonstrates wiring up `TLUserStore` with a mock user switcher and an attribution inspector panel - **Attribution timeline** — shows a history scrubber that steps through undo history and displays per-shape attribution metadata - **Custom user metadata** — shows how to extend `TLUser` records with custom meta fields (isAdmin, department) - **Multiplayer sync with custom user data** — integrates custom user data into tldraw sync https://github.com/user-attachments/assets/bd476596-67ca-483b-8d1d-bec3f82e35d5 https://github.com/user-attachments/assets/c31f8db6-c7ae-4e21-b0d5-48e079eb5767 fixes tldraw#6115 **Unit tests** New `attribution.test.ts` covering attribution user stamping, custom user stores, note `textFirstEditedBy` tracking, and display name resolution. ### Change type - [x] `feature` ### Test plan 1. Open the Attribution example — switch between users and create/edit shapes; verify attribution labels update in the inspector panel 2. Open the Attribution Timeline example — draw shapes, use the scrubber to step through history, verify per-shape metadata display 3. Open the Custom User Metadata example — switch between users and verify custom meta (department, admin badge) displays 4. Create note shapes — type text, verify the first name appears in the bottom-right corner; hover for full name tooltip 5. Copy a note with attribution to a different board — verify the attribution label persists - [x] Unit tests ### API changes - Added `TLUserStore` interface (with reactive `Signal`-based methods) - Added `TLUser` record type (unified from previous `TLUser` + `TLUserInfo`) - Added `UserRecordType`, `createUserId`, `isUserId`, `userIdValidator` - Added `createUserRecordType(config?)` for extensible user schemas - Added `UserSchemaInfo` interface - Added `user` parameter to `createTLSchema()` - Added `textFirstEditedBy` prop to `TLNoteShapeProps` - Added `Editor.getAttributionUser()` - Added `Editor.getAttributionUserId()` - Added `Editor.getAttributionDisplayName()` - Removed `TLUserInfo` type (collapsed into `TLUser`) ### Release notes - Add shape attribution system with `TLUserStore` for connecting tldraw to your auth system. - `TLUserStore` methods are reactive (`Signal`-based) so the editor automatically tracks user data changes. - Add extensible user records: pass custom meta validators to `createTLSchema({ user: { meta: ... } })`. - Add `createUserRecordType()` for building user record types with custom validation. - Note shapes now display a small "first edited by" label in the corner. - New "Users" example category with attribution, attribution timeline, custom user metadata, and sync examples.
1 parent 22eccd8 commit 839e118

59 files changed

Lines changed: 2507 additions & 402 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,10 @@ license-report.md
124124
opencode.json
125125

126126
# PR walkthrough intermediate files
127+
.claude/worktrees
127128
.claude/skills/pr-walkthrough/tmp
128129
.claude/skills/pr-walkthrough/out
129130
.claude/skills/pr-walkthrough/video/node_modules
130131
.claude/skills/pr-walkthrough/video/dist
131132
.claude/skills/pr-walkthrough/video/public/audio-*
132-
.claude/skills/pr-walkthrough/video/public/manifest.json
133+
.claude/skills/pr-walkthrough/video/public/manifest.json

apps/docs/content/sdk-features/collaboration.mdx

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -117,47 +117,53 @@ See the [Assets](/docs/assets) documentation for more on implementing asset stor
117117

118118
## User identity
119119

120-
By default, users get a random name and color. To customize this, pass `userInfo`:
120+
By default, users get a random name and color from localStorage. To customize this, pass a `users` store whose methods return reactive `Signal` values:
121121

122122
```tsx
123-
const store = useSyncDemo({
124-
roomId: 'my-room',
125-
userInfo: {
126-
id: 'user-123',
123+
import { computed, UserRecordType, createUserId } from 'tldraw'
124+
125+
const currentUser = computed('currentUser', () =>
126+
UserRecordType.create({
127+
id: createUserId('user-123'),
127128
name: 'Alice',
128129
color: '#ff0000',
130+
})
131+
)
132+
133+
const store = useSyncDemo({
134+
roomId: 'my-room',
135+
users: {
136+
currentUser,
129137
},
130138
})
131139
```
132140

133-
For dynamic user info that updates during the session, use an atom:
141+
The `users` store is also used for shape attribution — the same `currentUser` signal stamps shapes with user IDs. Provide a `resolve` method to look up other users by ID:
134142

135143
```tsx
136-
import { atom } from 'tldraw'
137-
138-
const userInfo = atom('userInfo', {
139-
id: 'user-123',
140-
name: 'Alice',
141-
color: '#ff0000',
142-
})
143-
144-
// Later, update the user info
145-
userInfo.set({ ...userInfo.get(), name: 'Alice (away)' })
146-
147-
const store = useSyncDemo({
148-
roomId: 'my-room',
149-
userInfo,
150-
})
144+
const users: TLUserStore = {
145+
currentUser: myAuth.currentUser$,
146+
resolve: (userId) => myUserCache.getSignal(userId),
147+
}
151148
```
152149

153-
### Integrating with useTldrawUser
150+
### Integrating with useTldrawCurrentUser
154151

155-
If you need to let users edit their preferences through tldraw's UI, use `useTldrawUser`:
152+
If you need to let users edit their preferences through tldraw's UI, use `useTldrawCurrentUser`:
156153

157154
```tsx
158155
import { useSyncDemo } from '@tldraw/sync'
159-
import { useState } from 'react'
160-
import { TLUserPreferences, Tldraw, useTldrawUser } from 'tldraw'
156+
import { useEffect, useMemo, useRef, useState } from 'react'
157+
import {
158+
atom,
159+
computed,
160+
createUserId,
161+
TLUserPreferences,
162+
TLUserStore,
163+
Tldraw,
164+
UserRecordType,
165+
useTldrawCurrentUser,
166+
} from 'tldraw'
161167

162168
export default function App({ roomId }: { roomId: string }) {
163169
const [userPreferences, setUserPreferences] = useState<TLUserPreferences>({
@@ -167,8 +173,25 @@ export default function App({ roomId }: { roomId: string }) {
167173
colorScheme: 'dark',
168174
})
169175

170-
const store = useSyncDemo({ roomId, userInfo: userPreferences })
171-
const user = useTldrawUser({ userPreferences, setUserPreferences })
176+
const userPrefsAtom = useRef(atom('userPrefs', userPreferences)).current
177+
useEffect(() => {
178+
userPrefsAtom.set(userPreferences)
179+
}, [userPreferences, userPrefsAtom])
180+
181+
const users: TLUserStore = useMemo(() => {
182+
const currentUser = computed('currentUser', () => {
183+
const p = userPrefsAtom.get()
184+
return UserRecordType.create({
185+
id: createUserId(p.id),
186+
name: p.name ?? '',
187+
color: p.color ?? undefined,
188+
})
189+
})
190+
return { currentUser }
191+
}, [userPrefsAtom])
192+
193+
const store = useSyncDemo({ roomId, users })
194+
const user = useTldrawCurrentUser({ userPreferences, setUserPreferences })
172195

173196
return (
174197
<div style={{ position: 'fixed', inset: 0 }}>

apps/docs/content/sdk-features/options.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ These props configure how the editor initializes:
324324
| `textOptions` | `TLTextOptions` | Rich text editor configuration |
325325
| `deepLinks` | `true \| TLDeepLinkOptions` | Sync camera state with URL |
326326
| `getShapeVisibility` | `(shape, editor) => 'visible' \| 'hidden' \| 'inherit'` | Conditionally hide shapes |
327-
| `user` | `TLUser` | Current user information |
327+
| `user` | `TLCurrentUser` | Current user information |
328328
| `inferDarkMode` | `boolean` | Infer dark mode from OS preference |
329329
| `licenseKey` | `string` | License key to remove watermark |
330330

apps/dotcom/client/src/pages/admin.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { RefObject, useCallback, useEffect, useRef, useState } from 'react'
1010
import { Navigate } from 'react-router-dom'
1111
import { fetch } from 'tldraw'
1212
import { TlaButton } from '../tla/components/TlaButton/TlaButton'
13-
import { useTldrawUser } from '../tla/hooks/useUser'
14-
import { saveMigrationLog } from './migrationLogsDB'
13+
import { useTldrawCurrentUser } from '../tla/hooks/useUser'
1514
import styles from './admin.module.css'
15+
import { saveMigrationLog } from './migrationLogsDB'
1616

1717
// Helper component for structured data display.
1818
function StructuredDataDisplay({ data }: { data: ZStoreData }) {
@@ -81,7 +81,7 @@ function UserDataSummary({ data }: { data: ZStoreData }) {
8181
}
8282

8383
export function Component() {
84-
const user = useTldrawUser()
84+
const user = useTldrawCurrentUser()
8585
const [data, setData] = useState<any>(null)
8686
const [error, setError] = useState(null as string | null)
8787
const [replicatorData, setReplicatorData] = useState(null)

apps/dotcom/client/src/tla/app/TldrawApp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ import {
5656
assertExists,
5757
atom,
5858
computed,
59+
createTLCurrentUser,
5960
createTLSchema,
60-
createTLUser,
6161
dataUrlToFile,
6262
defaultUserPreferences,
6363
getUserPreferences,
@@ -491,7 +491,7 @@ export class TldrawApp {
491491
this.lastGroupFileOrderings.delete(groupId)
492492
}
493493

494-
tlUser = createTLUser({
494+
tlUser = createTLCurrentUser({
495495
userPreferences: computed('user prefs', () => {
496496
const user = this.getUser()
497497
return {

apps/dotcom/client/src/tla/components/TlaEditor/TlaEditor.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ import {
88
TLComponents,
99
TLSessionStateSnapshot,
1010
TLUiDialogsContextType,
11+
TLUserStore,
1112
Tldraw,
1213
TldrawUiMenuItem,
14+
UserRecordType,
15+
computed,
1316
createSessionStateSnapshotSignal,
17+
createUserId,
1418
parseDeepLinkString,
1519
react,
1620
throttle,
@@ -36,7 +40,7 @@ import { TldrawApp } from '../../app/TldrawApp'
3640
import { useMaybeApp } from '../../hooks/useAppState'
3741
import { ReadyWrapper, useSetIsReady } from '../../hooks/useIsReady'
3842
import { useNewRoomCreationTracking } from '../../hooks/useNewRoomCreationTracking'
39-
import { useTldrawUser } from '../../hooks/useUser'
43+
import { useTldrawCurrentUser } from '../../hooks/useUser'
4044
import { maybeSlurp } from '../../utils/slurping'
4145
import { TlaEditorErrorFallback } from './editor-components/TlaEditorErrorFallback'
4246
import { TlaEditorMenuPanel } from './editor-components/TlaEditorMenuPanel'
@@ -170,7 +174,7 @@ function TlaEditorInner({ fileSlug, deepLinks }: TlaEditorProps) {
170174
[addDialog, trackRoomLoaded, trackNewRoomCreation, app, fileId, remountImageShapes, setIsReady]
171175
)
172176

173-
const user = useTldrawUser()
177+
const user = useTldrawCurrentUser()
174178
const getUserToken = useEvent(async () => {
175179
return (await user?.getToken()) ?? 'not-logged-in'
176180
})
@@ -179,6 +183,22 @@ function TlaEditorInner({ fileSlug, deepLinks }: TlaEditorProps) {
179183
return multiplayerAssetStore({ getFileId: () => fileId, getToken: getUserToken })
180184
}, [fileId, getUserToken])
181185

186+
const users: TLUserStore | undefined = useMemo(() => {
187+
const prefs = app?.tlUser.userPreferences
188+
if (!prefs) return undefined
189+
const currentUser = computed('currentUser', () => {
190+
const p = prefs.get()
191+
return UserRecordType.create({
192+
id: createUserId(p.id),
193+
name: p.name ?? '',
194+
color: p.color ?? '',
195+
})
196+
})
197+
return {
198+
currentUser,
199+
}
200+
}, [app?.tlUser.userPreferences])
201+
182202
const store = useSync({
183203
uri: useCallback(async () => {
184204
const url = new URL(`${MULTIPLAYER_SERVER}/app/file/${fileSlug}`)
@@ -188,7 +208,7 @@ function TlaEditorInner({ fileSlug, deepLinks }: TlaEditorProps) {
188208
return url.toString()
189209
}, [fileSlug, hasUser, getUserToken]),
190210
assets,
191-
userInfo: app?.tlUser.userPreferences,
211+
users,
192212
onCustomMessageReceived: useCallback((message: TLCustomServerEvent) => {
193213
trackEvent(message.type)
194214
}, []),

apps/dotcom/client/src/tla/components/TlaFileShareMenu/Tabs/TlaInviteTab.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { routes } from '../../../../routeDefs'
55
import { useApp } from '../../../hooks/useAppState'
66
import { useEditorDeepLink } from '../../../hooks/useDeepLink'
77
import { useHasFileAdminRights } from '../../../hooks/useIsFileOwner'
8-
import { useTldrawUser } from '../../../hooks/useUser'
8+
import { useTldrawCurrentUser } from '../../../hooks/useUser'
99
import { useTldrawAppUiEvents } from '../../../utils/app-ui-events'
1010
import { copyTextToClipboard } from '../../../utils/copy'
1111
import { F, defineMessages, useMsg } from '../../../utils/i18n'
@@ -60,7 +60,7 @@ export function TlaInviteTab({ fileId }: { fileId: string }) {
6060

6161
function TlaSharedToggle({ isShared, fileId }: { isShared: boolean; fileId: string }) {
6262
const app = useApp()
63-
const user = useTldrawUser()
63+
const user = useTldrawCurrentUser()
6464
const trackEvent = useTldrawAppUiEvents()
6565
if (!user) throw Error('should have auth')
6666

@@ -95,7 +95,7 @@ function TlaSharedToggle({ isShared, fileId }: { isShared: boolean; fileId: stri
9595

9696
function TlaSelectSharedLinkType({ fileId }: { fileId: string }) {
9797
const app = useApp()
98-
const user = useTldrawUser()
98+
const user = useTldrawCurrentUser()
9999
const trackEvent = useTldrawAppUiEvents()
100100
if (!user) throw Error('should have auth')
101101

apps/dotcom/client/src/tla/hooks/useUser.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,12 @@ export function UserProvider({ children }: { children: ReactNode }) {
5757
return <UserContext.Provider value={value}>{children}</UserContext.Provider>
5858
}
5959

60-
export function useTldrawUser() {
60+
export function useTldrawCurrentUser() {
6161
return useContext(UserContext)
6262
}
6363

6464
export function useLoggedInUser() {
65-
const user = useTldrawUser()
65+
const user = useTldrawCurrentUser()
6666
if (!user) throw new Error('User not signed in')
6767
return user
6868
}

apps/dotcom/client/src/utils/csp.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,10 @@ export const csp = Object.keys(cspDirectives)
7575

7676
export const cspDev = Object.keys(cspDirectives)
7777
.filter((key) => key !== 'report-uri')
78-
.map((directive) => `${directive} ${cspDirectives[directive].join(' ')}`)
78+
.map((directive) => {
79+
const values = cspDirectives[directive]
80+
// We allow data: urls for frame-src to allow debugging SVG embeds in dev.
81+
if (directive === 'frame-src') return `${directive} ${[...values, 'data:'].join(' ')}`
82+
return `${directive} ${values.join(' ')}`
83+
})
7984
.join('; ')

apps/examples/src/examples.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const categories = [
2020
['layout', 'Page layout'],
2121
['events', 'Events & effects'],
2222
['shapes/tools', 'Shapes & tools'],
23+
['users', 'Users'],
2324
['collaboration', 'Collaboration'],
2425
['data/assets', 'Data & assets'],
2526
['use-cases', 'Use cases'],

0 commit comments

Comments
 (0)