Skip to content

Instantly share code, notes, and snippets.

@jamonholmgren
Last active May 3, 2024 23:30
Show Gist options
  • Save jamonholmgren/60e11208e44c38f39813048584208382 to your computer and use it in GitHub Desktop.
Save jamonholmgren/60e11208e44c38f39813048584208382 to your computer and use it in GitHub Desktop.
undo/redo in Legend State

Use this trackHistory functionality like so:

const state$ = observable({
  name: "Hello",
  whatever: [],
})

const { undo, redo, undoable$, redoable$ } = trackHistory(state$)

if (undoable$.get()) undo()
if (redoable$.get()) redo()

The undoable$ and redoable$ observables are useful because you may want to dim out or enable the undo/redo buttons.

When you undo() n times, you can redo() n times as well; however, if you change state before you redo() then it throws away the redo stack.

Caveat: It's currently unbounded and saves deep copies of every state change, so if you want to avoid that you'll have to build in where it drops state changes after a certain size.

import { type ObservablePrimitive, internal, observable } from "@legendapp/state"
import { Todo } from "./state"
export function trackHistory<T>(obs: ObservablePrimitive<T>) {
let history = [] as Todo[]
let historyPointer = 0
let restoringFromHistory = false
const undoable$ = observable(false)
const redoable$ = observable(false)
obs.onChange(({ getPrevious }) => {
if (restoringFromHistory) return
// Don't save history if this is a remote change.
// History will be saved remotely by the client making the local change.
if (internal.globalState.isLoadingRemote || internal.globalState.isLoadingLocal) return
// if the history array is empty, grab the previous value (it's probably the best initial value)
if (!history.length) {
const previous = getPrevious()
if (previous) history.push(internal.clone(previous))
historyPointer = 0
}
// We're just going to store a copy of the whole object every time it changes.
const snapshot = internal.clone(obs.get())
// Because we may have undone to a previous state, we need to truncate the history now
// that we are making this new change. We'll keep the last 40 states from the pointer.
history = history.slice(0, historyPointer + 1)
history.push(snapshot)
// We're going to keep a pointer to the current history state.
// This way, we can undo to many previous states, and redo.
historyPointer = history.length - 1
// update undoable/redoable
undoable$.set(historyPointer > 0)
redoable$.set(historyPointer < history.length - 1)
})
return {
undo() {
if (historyPointer > 0) {
historyPointer--
const snapshot = history[historyPointer]
restoringFromHistory = true
obs.set(snapshot as any)
restoringFromHistory = false
} else {
console.warn("Already at the beginning of history")
}
// update undoable/redoable
undoable$.set(historyPointer > 0)
redoable$.set(historyPointer < history.length - 1)
},
redo() {
if (historyPointer < history.length - 1) {
historyPointer++
const snapshot = history[historyPointer]
restoringFromHistory = true
obs.set(snapshot as any)
restoringFromHistory = false
} else {
console.warn("Already at the end of history")
}
// update undoable/redoable
undoable$.set(historyPointer > 0)
redoable$.set(historyPointer < history.length - 1)
},
undoable$,
redoable$,
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment