Undo/Redo Framework
jsmith opened this issue · comments
Problem
Undo redo is very hard. A very simple implementation of undo/redo uses the command pattern however this becomes error prone and verbose.
Consider the following example:
interface Command {
execute(): void;
undo(): void;
}
interface Instrument {
volume: number;
setVolume(volume: number): void;
}
First, the volume
parameter needs to be reactive. This means that if the setVolume
is called and the volume attribute is updated, the UI should update as well. Second, the appropriate attribute in the web audio objects need to be updated. For example, if this instrument were a Synth, the gain value would need to be updated. As you can see, it is already fairly complex and this is one of the simple examples. The most difficult would be the array of scheduled elements (e.g. the notes in a score). These elements MUST be stored in an array for reactivity and must schedule/unschedule the element from the transport as the user adds/removes element from the sequencer.
Solution
An undo/redo framework (called olyger
) that allows dependency chains to be created, abstracts the complex logic and exposes a simple interface. The two core items are the following:
type Undo = () => void;
type Execute = () => Undo | Disposer[] | Disposer;
// OlyRef
interface ElementChangeContext<T> {
newValue: T;
oldValue: T;
onExecute: (cb: Execute) => void;
}
interface ElementChaining<T> {
onDidChange: (cb: (o: ElementChangeContext<T>) => void) => Disposer;
}
type OlyRef<T> = { value: T; } & ElementChaining<T>;
// OlyArr
interface Items<T> {
items: T[];
startingIndex: number;
onExecute: (cb: Execute) => void;
}
interface ArrayChaining<T> {
onDidAdd: (cb: (o: Items<T>) => void) => Disposer;
onDidRemove: (cb: (o: Items<T>) => void) => Disposer;
}
export type OlyArr<T> = T[] & ArrayChaining<T>;
The key components are the OlyRef
and the OlyArr
. The key idea for both of these is that once an action has been performed, all of the steps should be recorded such that they can be undone/redone. Here are two:
- When changing the
OlyRef
for the pan value of an instrument, the audio signal value must also be updated. See how this is done below using theonDidChange
and theonExecute
functions. The key thing to understand here is thatonDidChange
is called once for every the the value is changed but is not called when redone. TheonExecute
function allows us to register functions to be called when executed (either during the initial execution or during a redo) and when undone.
const ref = oly.olyRef(initialPan);
ref.onDidChange(({ onExecute, newValue, oldValue }) => {
onExecute(() => {
signal.value = newValue;
return () => {
signal.value = oldValue;
};
});
});
ref.value++; // the value is updated internally and onDidChange and onExecute are called
oly.undo(); // the value is updated internally and the function returned from onExecute is called
oly.redo(); // the value is updated internally and only onExecute is called
- This example concerns audio samples and the scheduled playlist elements and uses the
onDidRemove
function. Notice that this doesn't use theonDidExecute
and demonstrate the idea of chaining. To explain what this does, when a sample is deleted from the sample list we also have to remove all instances of this sample from the playlist. This is a single action that involves multiple steps.
// samples: oly.OlyArr<Sample>
samples.onDidRemove(({ items }) => {
const removed = new Set(items);
const toRemove: number[] = [];
master.elements.forEach((el, ind) => {
if (el.type === type && removed.has(el.element)) {
toRemove.push(ind);
}
});
for (const ind of reverse(toRemove)) {
master.elements.splice(ind, 1);
}
});
// Remove the third element from the list of samples
// During this call, the onDidRemove function is called the the appropriate element(s) are also removed
// from the playlist. `master.elements` is also an OlyArr so this registers additional steps in the *same*
// action.
samples.splice(3, 1);
samples.undo(); // Adds the removed element(s) from master and then re-adds the sample(s)
samples.redo(); // Removes the sample(s) and then removes the element(s) from master