Comments and undo
Decide whether comment writes land on the editor's undo stack.
import {
CanvasComments,
CommentAuthor,
CommentTool,
commentToolOverrides,
useComments,
useCommentThreads,
} from '@tldraw/commenting'
import { getLicenseKey } from '@tldraw/dotcom-shared'
import { useMemo, useState } from 'react'
import {
commentSchemaRecords,
createTLSchema,
createTLStore,
Editor,
TLComponents,
Tldraw,
TldrawUiButton,
TldrawUiButtonLabel,
toRichText,
useEditor,
useValue,
} from 'tldraw'
import '@tldraw/commenting/commenting.css'
import 'tldraw/tldraw.css'
import './comment-history.css'
// [1]
const MODE_TOOLS = {
ignore: [CommentTool],
record: [CommentTool.configure({ history: 'record' })],
drag: [CommentTool.configure({ dragHistory: 'record' })],
}
type HistoryMode = keyof typeof MODE_TOOLS
const MODE_LABELS: Record<HistoryMode, string> = {
ignore: 'Ignore (default)',
record: 'Record everything',
drag: 'Record pin drags only',
}
const MODE_HINTS: Record<HistoryMode, string> = {
ignore: 'Undo rewinds the shape. Comments and pin positions stay put.',
record: 'Undo rewinds comments too — the last thing you did, whatever it was.',
drag: 'Undo rewinds the shape and pin drags, but never a posted comment.',
}
const AUTHORS: Record<string, CommentAuthor> = { me: { name: 'You', color: '#EC5E41' } }
const resolveAuthor = (id: string): CommentAuthor => AUTHORS[id] ?? { name: id }
function handleMount(editor: Editor) {
// The store outlives a mode switch, so only seed on the first mount.
if (editor.getCurrentPageShapeIds().size === 0) {
editor.run(
() => {
editor.createShapes([
{
type: 'geo',
x: 180,
y: 180,
props: { geo: 'rectangle', w: 300, h: 200, richText: toRichText('Move me') },
},
])
},
// [2]
{ history: 'ignore' }
)
}
editor.zoomToBounds({ x: 100, y: 100, w: 620, h: 380 }, { immediate: true })
}
// [3]
function HistoryPanel({
mode,
onModeChange,
}: {
mode: HistoryMode
onModeChange(mode: HistoryMode): void
}) {
const editor = useEditor()
const threads = useCommentThreads(editor)
const comments = useComments(editor)
const canUndo = useValue('can undo', () => editor.getCanUndo(), [editor])
const canRedo = useValue('can redo', () => editor.getCanRedo(), [editor])
return (
<div className="tlui-menu comment-history-panel">
<div className="comment-history-panel__row">
{(Object.keys(MODE_LABELS) as HistoryMode[]).map((id) => (
<TldrawUiButton
key={id}
type={mode === id ? 'primary' : 'normal'}
onClick={() => onModeChange(id)}
>
<TldrawUiButtonLabel>{MODE_LABELS[id]}</TldrawUiButtonLabel>
</TldrawUiButton>
))}
</div>
<div className="comment-history-panel__row">
<TldrawUiButton type="normal" disabled={!canUndo} onClick={() => editor.undo()}>
<TldrawUiButtonLabel>Undo</TldrawUiButtonLabel>
</TldrawUiButton>
<TldrawUiButton type="normal" disabled={!canRedo} onClick={() => editor.redo()}>
<TldrawUiButtonLabel>Redo</TldrawUiButtonLabel>
</TldrawUiButton>
<span className="comment-history-panel__count">
{threads.length} {threads.length === 1 ? 'thread' : 'threads'}, {comments.length}{' '}
{comments.length === 1 ? 'comment' : 'comments'}
</span>
</div>
<p className="comment-history-panel__hint">{MODE_HINTS[mode]}</p>
</div>
)
}
export default function CommentHistoryExample() {
const [mode, setMode] = useState<HistoryMode>('ignore')
// Comments are records in the editor's own store. Sharing one store across mode switches keeps
// every thread you place while the tool is reconfigured.
const store = useMemo(
() => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
[]
)
const components = useMemo<TLComponents>(
() => ({
InFrontOfTheCanvas: () => <CanvasComments currentUserId="me" resolveAuthor={resolveAuthor} />,
}),
[]
)
return (
<div className="tldraw__editor">
<Tldraw
// [4]
key={mode}
// Commenting is a licensed feature. Every feature is enabled in local development, but a
// deployed app needs a license key that includes commenting — swap in your own key here.
licenseKey={getLicenseKey()}
store={store}
onMount={handleMount}
tools={MODE_TOOLS[mode]}
overrides={[commentToolOverrides]}
components={components}
>
<HistoryPanel mode={mode} onModeChange={setMode} />
</Tldraw>
</div>
)
}
/*
[1]
One configured comment tool per mode. `history` governs how every comment write — posting,
replying, editing, resolving, deleting — interacts with the editor's undo stack, and `dragHistory`
overrides it for pin drags alone.
`'ignore'` is the default, and it's the right one for a shared document: an undoable delete
resurrects a thread a collaborator already removed, and an undoable resolve reverts their newer
state. `'record'` is safe single-player, or against a comment store that isn't synced.
Pin drags are the interesting exception. Re-anchoring a comment is a spatial edit that may
reasonably undo alongside a shape move, so it can be recorded while posts stay ignored — the third
mode here.
[2]
The same option, used directly. Seeded shapes shouldn't be undoable either, so the seeding run is
wrapped in `history: 'ignore'` — otherwise the first undo would delete the shape the example is
about.
[3]
The counts come from `useCommentThreads` and `useComments`, which read the comment records
reactively. Watching them while you press undo is the whole point: in `'ignore'` mode they never
move, and in `'record'` mode they tick down.
[4]
Commenting options are fixed when the tool is registered, so switching modes remounts the editor
with a newly configured tool. The shared store carries the comments across, but the undo stack is
part of the editor, not the store — so it starts empty after every switch.
*/
CommentingOptions.history governs every comment write — posting, replying, editing, resolving, deleting — and defaults to 'ignore'. In a shared document an undoable delete would resurrect a thread a collaborator already removed.
Pin drags are the exception, since re-anchoring may reasonably undo alongside a shape move. dragHistory overrides history for drags alone.
Move the shape, post a comment, drag its pin, then press undo and watch the counts.
Is this page helpful?
Prev
Shape comment precisionNext
Region comments