Undo/Redo
Implementing per-user undo stacks with Yjs UndoManager.
Overview
In collaborative apps, users expect undo to reverse their own changes, not collaborators’. Yjs UndoManager provides this through “tracked origins.”
Basic Setup
import { UndoManager } from 'yjs'
const undoManager = new UndoManager ([content ])
undoManager .undo ()
undoManager .redo ()
undoManager .undoStack .length > 0 // canUndo
undoManager .redoStack .length > 0 // canRedo
Tracked Origins
Without tracked origins, undo captures remote changes too:
// WRONG: captures everything
const undoManager = new UndoManager ([content ])
// CORRECT: only capture local changes
const undoManager = new UndoManager ([content ], {
trackedOrigins : new Set (['user-action' ])
})
// Mark local changes
yDoc .transact (() => {
content .set ('title' , 'New Title' )
}, 'user-action' ) // Tracked
// Remote changes have no origin — not tracked
With Editor Bindings
const binding = new QuillBinding (yText , quill , awareness )
const undoManager = new UndoManager (yText , {
trackedOrigins : new Set ([binding ])
})
Scoping to Shared Types
// Only track changes to cells, rowOrder, colOrder
const undoManager = new UndoManager ([cells , rowOrder , colOrder ], {
trackedOrigins : new Set (['user-action' ])
})
// Changes to other types (metadata) not tracked
Restore cursor position after undo:
const undoManager = new UndoManager ([content ], {
trackedOrigins : new Set (['user-action' ]),
captureTransaction : (transaction ) => ({
cursorPosition : getCursorPosition ()
})
})
undoManager .on ('stack-item-popped' , (event ) => {
if (event .stackItem .meta .cursorPosition ) {
setCursorPosition (event .stackItem .meta .cursorPosition )
}
})
Transaction Grouping
Each transaction becomes one undo item:
yDoc .transact (() => {
items .set ('a' , data1 )
items .set ('b' , data2 )
items .set ('c' , data3 )
}, 'user-action' )
undoManager .undo () // Reverts all three at once
For typing, use captureTimeout to group rapid changes:
const undoManager = new UndoManager ([content ], {
trackedOrigins : new Set ([binding ]),
captureTimeout : 500 // Group changes within 500ms
})
Clear History
undoManager .clear () // Clear undo/redo stacks
undoManager .stopCapturing () // End current group, start new one
Stack Events
undoManager .on ('stack-item-added' , (event ) => {
updateUndoButtons ()
})
undoManager .on ('stack-item-popped' , (event ) => {
// Restore metadata
updateUndoButtons ()
})
Common Mistakes
Forgetting trackedOrigins:
// WRONG
const undoManager = new UndoManager ([content ])
// CORRECT
const undoManager = new UndoManager ([content ], {
trackedOrigins : new Set (['user-action' ])
})
Inconsistent origins:
// WRONG: one tracked, one not
content .set ('title' , 'New' ) // Not tracked
yDoc .transact (() => metadata .set ('time' , now ()), 'user-action' ) // Tracked
// CORRECT: consistent
yDoc .transact (() => {
content .set ('title' , 'New' )
metadata .set ('time' , now ())
}, 'user-action' )
See Also