Attribution timeline

Scrub the global timeline or each user's edit history independently 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
	userName: string
	userColor: string | undefined
}

interface AttributionTimelineState {
	entries: AttributionTimelineEntry[]
	appliedCounts: Record<string, number>
}

// [3]
export default function AttributionTimelineExample() {
	return (
		<div
			className="attribution-timeline-example"
			style={{ ['--timeline-row-count' as any]: Object.keys(USERS).length + 1 }}
		>
			<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 activeUserId = currentUserIdAtom.get()

	const [timeline, setTimeline] = useState<AttributionTimelineState>({
		entries: [],
		appliedCounts: {},
	})

	// [5]
	const recordChange = useCallback(
		(diff: RecordsDiff<any>) => {
			const user = editor.store.props.users.currentUser.get()
			if (!user) return

			const newEntry: AttributionTimelineEntry = {
				timestamp: Date.now(),
				diff,
				userId: user.id,
				userName: user.name,
				userColor: user.color,
			}

			setTimeline((prev) => {
				const userId = user.id
				const applied = prev.appliedCounts[userId] ?? 0
				let userSeen = 0
				let truncated = false
				const newEntries: AttributionTimelineEntry[] = []
				for (const entry of prev.entries) {
					if (entry.userId === userId) {
						if (userSeen < applied) {
							newEntries.push(entry)
							userSeen++
						} else {
							truncated = true
						}
					} else {
						newEntries.push(entry)
					}
				}
				newEntries.push(newEntry)

				return {
					entries: truncated ? newEntries : [...prev.entries, newEntry],
					appliedCounts: { ...prev.appliedCounts, [userId]: applied + 1 },
				}
			})
		},
		[editor]
	)

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

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

	// [6]
	const userIndices = useMemo(() => {
		const map: Record<string, number[]> = {}
		timeline.entries.forEach((entry, i) => {
			if (!map[entry.userId]) map[entry.userId] = []
			map[entry.userId].push(i)
		})
		return map
	}, [timeline.entries])

	// [7]
	const totalApplied = useMemo(
		() => Object.values(timeline.appliedCounts).reduce((sum, n) => sum + n, 0),
		[timeline.appliedCounts]
	)

	// [8]
	const handleUserSliderChange = useCallback(
		(userId: string, nextApplied: number) => {
			const prevApplied = timeline.appliedCounts[userId] ?? 0
			if (nextApplied === prevApplied) return

			const indices = userIndices[userId]
			if (!indices) 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[indices[i]].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,
				appliedCounts: { ...prev.appliedCounts, [userId]: nextApplied },
			}))
		},
		[editor, timeline, userIndices]
	)

	// [9]
	const handleReset = useCallback(() => {
		editor.store.mergeRemoteChanges(() => {
			const shapeIds = [...editor.getCurrentPageShapeIds()]
			if (shapeIds.length > 0) editor.deleteShapes(shapeIds)
		})
		setTimeline({ entries: [], appliedCounts: {} })
	}, [editor])

	// [10]
	const handleAllSliderChange = useCallback(
		(nextValue: number) => {
			const { entries, appliedCounts } = timeline

			const newAppliedCounts: Record<string, number> = {}
			for (let i = 0; i < nextValue; i++) {
				const id = entries[i].userId
				newAppliedCounts[id] = (newAppliedCounts[id] ?? 0) + 1
			}

			const currentlyApplied = new Array(entries.length).fill(false)
			const seen: Record<string, number> = {}
			entries.forEach((entry, i) => {
				const n = (seen[entry.userId] = (seen[entry.userId] ?? 0) + 1)
				if (n <= (appliedCounts[entry.userId] ?? 0)) currentlyApplied[i] = true
			})

			const ordered: RecordsDiff<any>[] = []
			for (let i = entries.length - 1; i >= 0; i--) {
				const targetApplied = i < nextValue
				if (currentlyApplied[i] && !targetApplied) {
					ordered.push(reverseRecordsDiff(entries[i].diff))
				}
			}
			for (let i = 0; i < entries.length; i++) {
				const targetApplied = i < nextValue
				if (!currentlyApplied[i] && targetApplied) {
					ordered.push(entries[i].diff)
				}
			}

			if (ordered.length > 0) {
				const diff = ordered.length === 1 ? ordered[0] : squashRecordDiffs(ordered)
				editor.store.mergeRemoteChanges(() => {
					editor.store.applyDiff(diff)
				})
			}

			setTimeline((prev) => ({ ...prev, appliedCounts: newAppliedCounts }))
		},
		[editor, timeline]
	)

	const totalEntries = timeline.entries.length
	const totalLength = Math.max(2, String(totalEntries).length)
	const allTitle = (() => {
		if (totalEntries === 0) return 'No changes yet'
		if (totalApplied === 0) return 'Empty canvas'
		const recent = timeline.entries[totalApplied - 1]
		if (!recent) return ''
		const time = new Date(recent.timestamp).toLocaleTimeString()
		return `${recent.userName} — ${time}`
	})()

	return (
		<div className="attribution-timeline-controls">
			<div className="attribution-timeline-row attribution-timeline-row--all">
				<div className="attribution-timeline-user">
					<span className="attribution-timeline-name">All</span>
				</div>
				<TldrawUiSlider
					steps={Math.max(totalEntries, 1)}
					value={totalEntries === 0 ? null : totalApplied}
					label="History"
					title={allTitle}
					onValueChange={handleAllSliderChange}
				/>
				<div className="attribution-timeline-info">
					{`${totalApplied.toString().padStart(totalLength, '0')} / ${totalEntries.toString().padStart(totalLength, '0')}`}
				</div>
				<TldrawUiButton
					type="normal"
					disabled={totalEntries === 0}
					onClick={handleReset}
					tooltip="Clear the canvas and timeline history"
					className="attribution-timeline-reset"
				>
					Reset
				</TldrawUiButton>
			</div>
			{Object.values(USERS).map((user) => {
				const indices = userIndices[user.id] ?? []
				const total = indices.length
				const applied = timeline.appliedCounts[user.id] ?? 0
				const length = Math.max(2, String(total).length)
				const isEmpty = total === 0

				const sliderTitle = (() => {
					if (isEmpty) return `${user.name} hasn't made any changes yet`
					if (applied === 0) return `None of ${user.name}'s changes applied`
					const entry = timeline.entries[indices[applied - 1]]
					if (!entry) return ''
					const time = new Date(entry.timestamp).toLocaleTimeString()
					return `${user.name} — ${time}`
				})()

				return (
					<div
						key={user.id}
						className={`attribution-timeline-row${user.id === activeUserId ? ' attribution-timeline-row--active' : ''}`}
					>
						<div className="attribution-timeline-user">
							<span className="attribution-timeline-dot" style={{ backgroundColor: user.color }} />
							<span className="attribution-timeline-name">{user.name}</span>
						</div>
						<TldrawUiSlider
							steps={Math.max(total, 1)}
							value={isEmpty ? null : applied}
							label="History"
							title={sliderTitle}
							onValueChange={(value) => handleUserSliderChange(user.id, value)}
						/>
						<div className="attribution-timeline-info">
							{`${applied.toString().padStart(length, '0')} / ${total.toString().padStart(length, '0')}`}
						</div>
					</div>
				)
			})}
		</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. State tracks all entries
plus a per-user count of how many of that user's changes are currently applied.

[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 with an "All"
scrubber and a separate scrubber per user.

[4]
The timeline component tracks all document changes and a per-user applied
count. The "All" scrubber and the per-user scrubbers operate on the same
underlying state from different angles.

[5]
When the store fires a document change (source: 'user'), we capture the
current user from the identity provider and append a new entry. If the
acting user was scrubbed back (some of their previous changes reverted),
those un-applied entries are dropped to create a new branch for that user.
Other users' entries are untouched.

[6]
We derive a per-user list of global indices into `entries`. Each user's
scrubber operates on its own slice of history.

[7]
The "All" slider's displayed value is just the sum of every per-user
applied count — a snapshot of how much of the combined history is on
canvas right now.

[8]
Moving a user's slider applies or reverses just that user's diffs between
the previous and next applied count. Other users' shapes stay on canvas.
We use mergeRemoteChanges so the scrub doesn't trigger our own listener.

[9]
Reset clears every shape on the current page and resets the timeline
state. We use mergeRemoteChanges so the deletion isn't recorded back into
the timeline by our own listener.

[10]
Moving the "All" slider rebuilds the canvas as the chronological prefix
entries[0..N-1]. We compute the current applied set (which may not be a
contiguous prefix if per-user sliders have been moved), diff it against
the target prefix, then queue reverses (in reverse chronological order)
followed by applies (in forward chronological order) into a single
squashed diff.
*/

This example combines the user store with a global "All" timeline scrubber and a separate scrubber per user. Switch between users (Alice, Bob, Carol) using the buttons at the top, make changes as each user, then use the sliders at the bottom to scrub through history. Move the "All" slider to walk the full chronological timeline; move a user's slider to selectively apply or revert just their contributions while everyone else's shapes stay on canvas. The two views stay in sync — the "All" total is the sum of every per-user count.

Is this page helpful?
Prev
Attribution
Next
Tldraw component