mobx-bonsai
Version:
A fast lightweight alternative to MobX-State-Tree + Y.js two-way binding
482 lines (407 loc) • 13.1 kB
text/typescript
import { action, runInAction, when } from "mobx"
import { failure } from "../error/failure"
import { onDeepChange, onDeepInterceptedChange } from "../node/node"
import { isChildOfParent } from "../node/tree/isChildOfParent"
import type { Dispose } from "../utils/disposable"
import { isInsideMobxAction } from "../utils/isInsideMobxAction"
import { applyChange } from "./applyChange"
import { captureChange, type NodeChange } from "./captureChange"
import type { UndoableChange } from "./types"
import { createUndoStore, TUndoEvent, type UndoEvent, type UndoStore } from "./UndoStore"
/**
* Interface for managing attached state with undo events.
*/
export interface AttachedStateHandler<TAttachedState = unknown> {
save(): TAttachedState
restore(state: TAttachedState): void
}
/**
* Options for creating an UndoManager.
*/
export interface UndoManagerOptions<TAttachedState = unknown> {
/**
* The subtree root to track changes on.
*/
rootNode: object
/**
* Optional UndoStore to use. If not provided, a new one will be created.
*
* Note: The UndoStore is not subject to the undo manager, even if it is
* a child of the rootNode. Changes to the UndoStore itself will not
* be tracked or undoable.
*/
store?: UndoStore
/**
* Maximum number of undo levels to keep.
* @default Infinity
*/
maxUndoLevels?: number
/**
* Maximum number of redo levels to keep.
* @default Infinity
*/
maxRedoLevels?: number
/**
* Attached state management for storing additional state with each undo event.
*/
attachedState?: AttachedStateHandler<TAttachedState>
/**
* Time window in milliseconds for grouping changes into a single undo event.
* If undefined (default), changes are only grouped within the same MobX action.
* If set to a number, changes that occur within this time window will be merged
* into the last undo event, even if they come from different actions.
*
* @default undefined
*/
groupingDebounceMs?: number
}
/**
* Manages undo/redo functionality for a mobx-bonsai node tree.
* Automatically groups changes that occur within a single MobX action.
*/
export class UndoManager<TAttachedState = unknown> {
private disposer: Dispose | undefined
private interceptDisposer: Dispose | undefined
private isRecordingDisabled = false
private readonly _maxUndoLevels: number
private readonly _maxRedoLevels: number
private readonly attachedState?: AttachedStateHandler<TAttachedState>
private readonly groupingDebounceMs?: number
/**
* Timestamp (performance.now()) of when the last undo event was recorded.
* Used for grouping debounce logic.
*/
private lastEventTimestamp: number | undefined
/**
* The root node being tracked.
*/
readonly rootNode: object
/**
* The UndoStore holding the undo/redo queues.
*/
readonly store: UndoStore
/**
* Array of changes accumulated during the current action.
* These will be flushed as a single UndoEvent when the action completes.
* If length > 0, a flush is already enqueued.
*/
private pendingChanges: UndoableChange[] = []
/**
* State saved at the start of the current pending event (before any changes).
* This is captured lazily when the first change is intercepted.
* Undefined means it hasn't been captured yet for the current event.
*/
private attachedStateBeforeNextEvent: TAttachedState | undefined
constructor(options: UndoManagerOptions<TAttachedState>) {
this.rootNode = options.rootNode
this.store = options.store ?? createUndoStore()
this._maxUndoLevels = options.maxUndoLevels ?? Number.POSITIVE_INFINITY
this._maxRedoLevels = options.maxRedoLevels ?? Number.POSITIVE_INFINITY
this.attachedState = options.attachedState
this.groupingDebounceMs = options.groupingDebounceMs
// Start tracking changes
this.startTracking()
}
/**
* Starts tracking changes on the root node.
*/
private startTracking(): void {
// Track intercepted changes to capture attached state BEFORE changes are committed
this.interceptDisposer = onDeepInterceptedChange(this.rootNode, (change) => {
// Skip if recording is disabled
if (this.isRecordingDisabled) {
return change
}
// Skip if the change is to the UndoStore itself
const isUndoStoreChange =
change.object === this.store || isChildOfParent(change.object, this.store)
if (isUndoStoreChange) {
return change
}
// Capture attached state before the first change of this event
if (this.attachedState && this.pendingChanges.length === 0) {
this.attachedStateBeforeNextEvent = this.attachedState.save()
}
return change
})
// Track committed changes to record them in the undo queue
this.disposer = onDeepChange(this.rootNode, (change: NodeChange) => {
// Skip if recording is disabled
if (this.isRecordingDisabled) {
return
}
// Skip if the change is to the UndoStore itself
const isUndoStoreChange =
change.object === this.store || isChildOfParent(change.object, this.store)
if (isUndoStoreChange) {
return
}
// Record the change
this.recordChange(captureChange(change, this.rootNode))
})
}
/**
* Records a change. If this is the first change in an action, schedules a flush.
*/
private recordChange(change: UndoableChange): void {
this.pendingChanges.push(change)
// If this is the first change, schedule a flush
if (this.pendingChanges.length === 1) {
this.scheduleFlush()
}
}
/**
* Schedules a flush to occur when the current action completes.
*/
private scheduleFlush(): void {
when(
() => true,
() => {
this.flushPendingChanges()
}
)
}
/**
* Flushes pending changes as a single UndoEvent.
*/
private flushPendingChanges(): void {
if (this.pendingChanges.length === 0) {
return
}
const now = performance.now()
runInAction(() => {
// Save attached state after the event (this becomes the "before" state for the next event)
const attachedStateAfterEvent = this.attachedState?.save()
const shouldMergeWithLastEvent =
this.groupingDebounceMs !== undefined &&
this.lastEventTimestamp !== undefined &&
this.store.undoEvents.length > 0 &&
now - this.lastEventTimestamp <= this.groupingDebounceMs
if (shouldMergeWithLastEvent) {
// Merge with the last undo event by creating a new event with combined changes
const lastEvent = this.store.undoEvents[this.store.undoEvents.length - 1]
const mergedChanges = [...lastEvent.changes, ...this.pendingChanges]
// Create new event with merged changes
const mergedEvent = TUndoEvent({
changes: mergedChanges,
attachedState: lastEvent.attachedState
? {
beforeEvent: lastEvent.attachedState.beforeEvent,
afterEvent: attachedStateAfterEvent,
}
: undefined,
})
// Replace the last event with the merged one
this.store.undoEvents[this.store.undoEvents.length - 1] = mergedEvent
} else {
// Create the undo event
const event = TUndoEvent({
changes: this.pendingChanges,
attachedState: this.attachedState
? {
beforeEvent: this.attachedStateBeforeNextEvent,
afterEvent: attachedStateAfterEvent,
}
: undefined,
})
// Add to undo queue
this.store.undoEvents.push(event)
}
// Enforce max undo levels
while (this.store.undoEvents.length > this._maxUndoLevels) {
this.store.undoEvents.shift()
}
// Clear redo queue
this.store.redoEvents.length = 0
// Reset pending changes to a new array
this.pendingChanges = []
// Reset the attached state so the next event will capture new state
this.attachedStateBeforeNextEvent = undefined
// Update the timestamp for debounce logic
this.lastEventTimestamp = now
})
}
/**
* The undo queue.
*/
get undoQueue(): ReadonlyArray<UndoEvent> {
return this.store.undoEvents
}
/**
* The redo queue.
*/
get redoQueue(): ReadonlyArray<UndoEvent> {
return this.store.redoEvents
}
/**
* Number of undo steps available.
*/
get undoLevels(): number {
return this.store.undoEvents.length
}
/**
* Whether undo is possible.
*/
get canUndo(): boolean {
return this.undoLevels > 0
}
/**
* Number of redo steps available.
*/
get redoLevels(): number {
return this.store.redoEvents.length
}
/**
* Whether redo is possible.
*/
get canRedo(): boolean {
return this.redoLevels > 0
}
/**
* Maximum number of undo levels to keep.
*/
get maxUndoLevels(): number {
return this._maxUndoLevels
}
/**
* Maximum number of redo levels to keep.
*/
get maxRedoLevels(): number {
return this._maxRedoLevels
}
/**
* Undoes the last change.
* @throws if called while inside a MobX action
* @throws if there are no undo events
*/
undo(): void {
if (isInsideMobxAction()) {
throw failure("cannot call undo() while inside a MobX action")
}
this.#undoAction()
}
#undoAction = action(() => {
if (!this.canUndo) {
throw failure("nothing to undo")
}
const event = this.store.undoEvents.pop()!
// Save current attached state before restoring and create updated event
let eventToStore = event
if (event.attachedState && this.attachedState) {
const currentState = this.attachedState.save()
// Create a new event with updated afterEvent state
eventToStore = TUndoEvent({
changes: event.changes,
attachedState: {
beforeEvent: event.attachedState.beforeEvent,
afterEvent: currentState,
},
})
}
// Apply changes in reverse
this.withoutUndo(() => {
for (let i = event.changes.length - 1; i >= 0; i--) {
applyChange(this.rootNode, event.changes[i], "reverse")
}
})
// Add to redo queue
this.store.redoEvents.push(eventToStore)
// Enforce max redo levels (keep most recent, remove oldest from end)
while (this.store.redoEvents.length > this._maxRedoLevels) {
this.store.redoEvents.pop()
}
// Restore attached state
if (event.attachedState && this.attachedState) {
this.attachedState.restore(event.attachedState.beforeEvent as TAttachedState)
}
})
/**
* Redoes the last undone change.
* @throws if called while inside a MobX action
* @throws if there are no redo events
*/
redo(): void {
if (isInsideMobxAction()) {
throw failure("cannot call redo() while inside a MobX action")
}
this.#redoAction()
}
#redoAction = action(() => {
if (!this.canRedo) {
throw failure("nothing to redo")
}
const event = this.store.redoEvents.pop()!
// Save current attached state before restoring and create updated event
let eventToStore = event
if (event.attachedState && this.attachedState) {
const currentState = this.attachedState.save()
// Create a new event with updated beforeEvent state
eventToStore = TUndoEvent({
changes: event.changes,
attachedState: {
beforeEvent: currentState,
afterEvent: event.attachedState.afterEvent,
},
})
}
// Apply changes forward
this.withoutUndo(() => {
for (const change of event.changes) {
applyChange(this.rootNode, change, "forward")
}
})
// Add back to undo queue
this.store.undoEvents.push(eventToStore)
// Enforce max undo levels
while (this.store.undoEvents.length > this._maxUndoLevels) {
this.store.undoEvents.shift()
}
// Restore attached state
if (event.attachedState && this.attachedState) {
this.attachedState.restore(event.attachedState.afterEvent as TAttachedState)
}
})
/**
* Clears the undo queue.
*/
clearUndo = action(() => {
this.store.undoEvents.length = 0
})
/**
* Clears the redo queue.
*/
clearRedo = action(() => {
this.store.redoEvents.length = 0
})
/**
* Executes a function without recording changes.
*/
withoutUndo<T>(fn: () => T): T {
const wasDisabled = this.isRecordingDisabled
this.isRecordingDisabled = true
try {
return fn()
} finally {
this.isRecordingDisabled = wasDisabled
}
}
/**
* Whether undo recording is currently disabled.
*/
get isUndoRecordingDisabled(): boolean {
return this.isRecordingDisabled
}
/**
* Disposes the undo manager, stopping change tracking.
*/
dispose(): void {
if (this.disposer) {
this.disposer()
this.disposer = undefined
}
if (this.interceptDisposer) {
this.interceptDisposer()
this.interceptDisposer = undefined
}
}
}