Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Undo/Redo Framework #156

Closed
jsmith opened this issue Mar 3, 2020 · 0 comments · Fixed by #157
Closed

Undo/Redo Framework #156

jsmith opened this issue Mar 3, 2020 · 0 comments · Fixed by #157
Labels
enhancement New feature or request

Comments

@jsmith
Copy link
Member

jsmith commented Mar 3, 2020

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:

  1. 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 the onDidChange and the onExecute functions. The key thing to understand here is that onDidChange is called once for every the the value is changed but is not called when redone. The onExecute 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
  1. This example concerns audio samples and the scheduled playlist elements and uses the onDidRemove function. Notice that this doesn't use the onDidExecute 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
@jsmith jsmith added the enhancement New feature or request label Mar 3, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

1 participant