Attribution timeline

Scrub through per-user change history with identity-aware timeline attribution.

import { useCallback, useEffect, useMemo, useState } from 'react'
import {
	atom,
	computed,
	createCachedUserResolve,
	createUserId,
	RecordsDiff,
	reverseRecordsDiff,
	squashRecordDiffs,
	Tldraw,
	TldrawUiButton,
	TldrawUiSlider,
	TLUser,
	TLUserStore,
	track,
	useEditor,
	UserRecordType,
} from 'tldraw'
import 'tldraw/tldraw.css'
import './attribution-timeline.css'

// There's a guide at the bottom of this file!

// [1]
const USERS: Record<string, TLUser> = {
	[createUserId('alice')]: UserRecordType.create({
		id: createUserId('alice'),
		name: 'Alice',
		color: '#e03131',
	}),
	[createUserId('bob')]: UserRecordType.create({
		id: createUserId('bob'),
		name: 'Bob',
		color: '#1971c2',
	}),
	[createUserId('carol')]: UserRecordType.create({
		id: createUserId('carol'),
		name: 'Carol',
		color: '#2f9e44',
	}),
}

const currentUserIdAtom = atom('currentUserId', createUserId('alice'))

const currentUserSignal = computed('currentUser', () => {
	return USERS[currentUserIdAtom.get()] ?? null
})

const users: TLUserStore = {
	currentUser: currentUserSignal,
	resolve: createCachedUserResolve((userId) => USERS[createUserId(userId)] ?? null),
}

// [2]
interface AttributionTimelineEntry {
	timestamp: number
	diff: RecordsDiff<any>
	userId: string | null
	userName: string | null
	userColor: string | undefined
}

interface AttributionTimelineState {
	entries: AttributionTimelineEntry[]
	currentIndex: number
	filterUserId: string | null
	filteredAppliedCount: number | null
}

// [3]
export default function AttributionTimelineExample() {
	return (
		<div className="attribution-timeline-example">
			<Tldraw
				persistenceKey="attribution-timeline-example"
				users={users}
				components={{
					TopPanel: UserSwitcher,
				}}
			>
				<AttributionTimeline />
			</Tldraw>
		</div>
	)
}

function UserSwitcher() {
	const [activeUserId, setActiveUserId] = useState(currentUserIdAtom.get())

	return (
		<div className="tlui-menu attribution-timeline-user-switcher">
			{Object.values(USERS).map((user) => (
				<TldrawUiButton
					key={user.id}
					type={activeUserId === user.id ? 'primary' : 'normal'}
					onClick={() => {
						currentUserIdAtom.set(user.id)
						setActiveUserId(user.id)
					}}
				>
					<span className="attribution-timeline-dot" style={{ backgroundColor: user.color }} />
					{user.name}
				</TldrawUiButton>
			))}
		</div>
	)
}

// [4]
const AttributionTimeline = track(() => {
	const editor = useEditor()

	const [timeline, setTimeline] = useState<AttributionTimelineState>({
		entries: [],
		currentIndex: 0,
		filterUserId: null,
		filteredAppliedCount: null,
	})

	// [5]
	const recordChange = useCallback(
		(diff: RecordsDiff<any>) => {
			const user = editor.store.props.users.currentUser.get()
			const newEntry: AttributionTimelineEntry = {
				timestamp: Date.now(),
				diff,
				userId: user?.id ?? null,
				userName: user?.name ?? null,
				userColor: user?.color,
			}

			setTimeline((prev) => {
				const bumpFiltered =
					prev.filterUserId &&
					prev.filteredAppliedCount !== null &&
					newEntry.userId === prev.filterUserId

				if (prev.currentIndex < prev.entries.length) {
					const newEntries = prev.entries.slice(0, prev.currentIndex)
					newEntries.push(newEntry)
					return {
						...prev,
						entries: newEntries,
						currentIndex: newEntries.length,
						filteredAppliedCount: bumpFiltered
							? prev.filteredAppliedCount! + 1
							: prev.filteredAppliedCount,
					}
				} else {
					return {
						...prev,
						entries: [...prev.entries, newEntry],
						currentIndex: prev.entries.length + 1,
						filteredAppliedCount: bumpFiltered
							? prev.filteredAppliedCount! + 1
							: prev.filteredAppliedCount,
					}
				}
			})
		},
		[editor]
	)

	useEffect(() => {
		if (!editor) return

		return editor.store.listen(
			({ changes }) => {
				recordChange(changes)
			},
			{ scope: 'document', source: 'user' }
		)
	}, [editor, recordChange])

	// [6]
	const filteredGlobalIndices = useMemo(() => {
		if (!timeline.filterUserId) return null
		const indices: number[] = []
		for (let i = 0; i < timeline.entries.length; i++) {
			if (timeline.entries[i].userId === timeline.filterUserId) {
				indices.push(i + 1)
			}
		}
		return indices
	}, [timeline.entries, timeline.filterUserId])

	const filteredSteps = filteredGlobalIndices?.length ?? timeline.entries.length
	const filteredValue = filteredGlobalIndices
		? (timeline.filteredAppliedCount ?? filteredGlobalIndices.length)
		: timeline.currentIndex

	// [7]
	const navigateToIndex = useCallback(
		(targetIndex: number) => {
			if (!editor || targetIndex === timeline.currentIndex) return

			const { entries, currentIndex } = timeline

			const isForward = targetIndex > currentIndex
			const diffsToApply = entries
				.slice(Math.min(currentIndex, targetIndex), Math.max(currentIndex, targetIndex))
				.map((entry) => entry.diff)

			if (diffsToApply.length > 0) {
				let diffToApply =
					diffsToApply.length === 1 ? diffsToApply[0] : squashRecordDiffs(diffsToApply)

				if (!isForward) {
					diffToApply = reverseRecordsDiff(diffToApply)
				}

				editor.store.mergeRemoteChanges(() => {
					editor.store.applyDiff(diffToApply)
				})
			}

			setTimeline((prev) => ({ ...prev, currentIndex: targetIndex }))
		},
		[timeline, editor]
	)

	// [8]
	const handleSliderChange = useCallback(
		(sliderValue: number) => {
			if (filteredGlobalIndices && timeline.filteredAppliedCount !== null) {
				const prevApplied = timeline.filteredAppliedCount
				const nextApplied = sliderValue
				if (nextApplied === prevApplied) return

				const isForward = nextApplied > prevApplied
				const lo = Math.min(prevApplied, nextApplied)
				const hi = Math.max(prevApplied, nextApplied)

				const userDiffs: RecordsDiff<any>[] = []
				for (let i = lo; i < hi; i++) {
					userDiffs.push(timeline.entries[filteredGlobalIndices[i] - 1].diff)
				}

				if (userDiffs.length > 0) {
					let diff = userDiffs.length === 1 ? userDiffs[0] : squashRecordDiffs(userDiffs)
					if (!isForward) {
						diff = reverseRecordsDiff(diff)
					}
					editor.store.mergeRemoteChanges(() => {
						editor.store.applyDiff(diff)
					})
				}

				setTimeline((prev) => ({ ...prev, filteredAppliedCount: nextApplied }))
			} else {
				navigateToIndex(sliderValue)
			}
		},
		[navigateToIndex, filteredGlobalIndices, timeline, editor]
	)

	// [9]
	const setFilter = useCallback(
		(userId: string | null) => {
			const { entries, filterUserId, filteredAppliedCount, currentIndex } = timeline

			// Restore any reverted diffs from the previous filter
			if (filterUserId && filteredAppliedCount !== null) {
				const oldIndices: number[] = []
				for (let i = 0; i < entries.length; i++) {
					if (entries[i].userId === filterUserId) oldIndices.push(i)
				}
				if (filteredAppliedCount < oldIndices.length) {
					const diffs: RecordsDiff<any>[] = []
					for (let i = filteredAppliedCount; i < oldIndices.length; i++) {
						diffs.push(entries[oldIndices[i]].diff)
					}
					if (diffs.length > 0) {
						const diff = diffs.length === 1 ? diffs[0] : squashRecordDiffs(diffs)
						editor.store.mergeRemoteChanges(() => {
							editor.store.applyDiff(diff)
						})
					}
				}
			}

			// If switching from unfiltered (scrubbed back) into a filter, restore to end first
			if (!filterUserId && currentIndex < entries.length && userId) {
				const diffs = entries.slice(currentIndex).map((e) => e.diff)
				if (diffs.length > 0) {
					const diff = diffs.length === 1 ? diffs[0] : squashRecordDiffs(diffs)
					editor.store.mergeRemoteChanges(() => {
						editor.store.applyDiff(diff)
					})
				}
			}

			if (userId) {
				let count = 0
				for (const entry of entries) {
					if (entry.userId === userId) count++
				}
				setTimeline((prev) => ({
					...prev,
					filterUserId: userId,
					currentIndex: prev.entries.length,
					filteredAppliedCount: count,
				}))
			} else {
				setTimeline((prev) => ({
					...prev,
					filterUserId: null,
					currentIndex: prev.entries.length,
					filteredAppliedCount: null,
				}))
			}
		},
		[timeline, editor]
	)

	const activeUsers = useMemo(() => {
		const seen = new Set<string>()
		for (const entry of timeline.entries) {
			if (entry.userId) seen.add(entry.userId)
		}
		return Object.values(USERS).filter((u) => seen.has(u.id))
	}, [timeline.entries])

	const isEmpty = filteredSteps === 0
	const length = Math.max(3, String(filteredSteps).length)

	const sliderTitle = (() => {
		if (filteredGlobalIndices && timeline.filteredAppliedCount !== null) {
			if (timeline.filteredAppliedCount === 0) return 'No changes from this user'
			const gi = filteredGlobalIndices[timeline.filteredAppliedCount - 1]
			const entry = timeline.entries[gi - 1]
			if (!entry) return ''
			const time = new Date(entry.timestamp).toLocaleTimeString()
			return `${entry.userName ?? 'Unknown'} — ${time}`
		}
		if (timeline.currentIndex === 0) return 'Empty canvas'
		const entry = timeline.entries[timeline.currentIndex - 1]
		if (!entry) return ''
		const time = new Date(entry.timestamp).toLocaleTimeString()
		const who = entry.userName ?? 'Unknown'
		return `${who} — ${time}`
	})()

	return (
		<div className="attribution-timeline-controls">
			<div className="attribution-timeline-filters">
				<TldrawUiButton
					type={timeline.filterUserId === null ? 'primary' : 'normal'}
					onClick={() => setFilter(null)}
				>
					All
				</TldrawUiButton>
				{activeUsers.map((user) => (
					<TldrawUiButton
						key={user.id}
						type={timeline.filterUserId === user.id ? 'primary' : 'normal'}
						onClick={() => setFilter(user.id)}
					>
						<span className="attribution-timeline-dot" style={{ backgroundColor: user.color }} />
						{user.name}
					</TldrawUiButton>
				))}
			</div>
			<div className="attribution-timeline-info">
				{isEmpty
					? '000 / 000'
					: `${filteredValue.toString().padStart(length, '0')} / ${filteredSteps.toString().padStart(length, '0')}`}
			</div>
			<TldrawUiSlider
				steps={filteredSteps}
				value={isEmpty ? 1 : filteredValue}
				label="History"
				title={sliderTitle}
				onValueChange={handleSliderChange}
			/>
		</div>
	)
})

/*
[1]
A fake user directory. In a real app this would be backed by your auth system.
The TLUserStore tells the editor who is "logged in" — the editor reads
currentUser for attribution purposes, and resolve when rendering
attribution labels.

[2]
Each timeline entry extends the basic diff with the userId, name, and color
of whoever was active when the change was recorded.

[3]
The main component wires everything together: the TopPanel shows the user
switcher, the user store is passed as the `users` prop, and the
AttributionTimeline child renders the bottom controls bar.

[4]
The timeline component tracks all document changes, the current playback
position, and the active user filter.

[5]
When the store fires a document change (source: 'user'), we capture the
current user from the identity provider and push a new entry. If we're
scrubbed back in time, future entries are truncated to create a new branch.

[6]
When a user filter is active, we build an array of 1-based global indices
where that user made changes. The slider steps and value are derived from
this filtered view.

[7]
Navigation collects the diffs between the current position and target,
squashes them, and applies (reversing if going backward). Uses
mergeRemoteChanges so the scrub doesn't trigger our own listener.

[8]
When a filter is active, the slider only applies/reverses the filtered
user's diffs — other users' shapes stay on canvas. `filteredAppliedCount`
tracks how many of the filtered user's diffs are currently applied.

[9]
Switching filters first restores any reverted diffs from the previous filter,
then sets up the new filter with all its entries applied. Switching from an
unfiltered scrubbed-back position into a filter navigates to the end first.
*/

This example combines the user store with a timeline scrubber to create an attribution-aware history viewer. Switch between users (Alice, Bob, Carol) using the buttons at the top, make changes as each user, then use the slider at the bottom to scrub through the timeline. In "All" mode the slider navigates the full global history. Filter by a user to selectively remove or restore only that user's changes — other users' shapes stay on canvas while you scrub.

Is this page helpful?
Prev
Attribution
Next
Tldraw component