Skip to content
  1. Examples

Undo redo

This example shows how to implement undo/redo behavior with Ogma.

ts
import Ogma from '@linkurious/ogma';
import { HistoryController, RedoEvent, UndoEvent } from './history';

const ogma = new Ogma({
  container: 'graph-container'
});

const buttons = {
  undo: document.getElementById('undo') as HTMLButtonElement,
  redo: document.getElementById('redo') as HTMLButtonElement,
  addNode: document.getElementById('add-node') as HTMLButtonElement,
  addEdge: document.getElementById('add-edge') as HTMLButtonElement,
  removeNode: document.getElementById('remove-node') as HTMLButtonElement,
  removeEdge: document.getElementById('remove-edge') as HTMLButtonElement
};

const graph = await ogma.generate.random({ nodes: 2, edges: 1 });
await ogma.addGraph(graph);
await ogma.layouts.force({ locate: true });
const historyState = new HistoryController(ogma);
historyState.addEventListener('snapshot', () => {
  buttons.redo.disabled = true;
  buttons.undo.disabled = false;
});
historyState.addEventListener('undo', evt => {
  const availableUndos = (evt as UndoEvent).detail.availableUndos;
  buttons.undo.disabled = availableUndos <= 0;
  buttons.redo.disabled = false;
});
historyState.addEventListener('redo', evt => {
  const availableRedos = (evt as RedoEvent).detail.availableRedos;
  buttons.redo.disabled = availableRedos <= 0;
  buttons.undo.disabled = false;
});
buttons.undo.disabled = true;
buttons.redo.disabled = true;

function setEnabled(enabled: boolean) {
  for (let key in buttons) {
    buttons[key as keyof typeof buttons].disabled = !enabled;
  }
}

document
  .getElementById('undo')!
  .addEventListener('click', () => historyState.undo());
document
  .getElementById('redo')!
  .addEventListener('click', () => historyState.redo());
document.getElementById('add-node')!.addEventListener('click', async () => {
  setEnabled(false);
  const id = ogma.getNodes().size;
  ogma.addNode({ id });
  await ogma.layouts.force({ locate: true });
  setEnabled(true);
  historyState.snapshot();
});

document.getElementById('remove-node')!.addEventListener('click', async () => {
  const selectedNodes = ogma.getSelectedNodes();
  if (!selectedNodes.size) return;
  setEnabled(false);
  await ogma.removeNodes(selectedNodes.toArray());
  await ogma.layouts.force({ locate: true });
  setEnabled(true);
  historyState.snapshot();
});

document.getElementById('add-edge')!.addEventListener('click', () => {
  setEnabled(false);
  ogma.tools.connectNodes.enable({
    onComplete: async () => {
      await ogma.layouts.force();
      setEnabled(true);
      historyState.snapshot();
    }
  });
});

document.getElementById('remove-edge')!.addEventListener('click', async () => {
  const selectedEdges = ogma.getSelectedEdges();
  if (!selectedEdges.size) return;
  setEnabled(false);
  await ogma.removeEdges(selectedEdges.toArray());
  await ogma.layouts.force({ locate: true });
  setEnabled(true);
  historyState.snapshot();
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <script src="https://cdnjs.cloudflare.com/ajax/libs/eventemitter3/4.0.7/index.min.js"></script>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
      rel="stylesheet"
    />
    <link type="text/css" rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div id="graph-container"></div>
    <div id="ui">
      <button id="undo">Undo</button>
      <button id="redo">Redo</button>
      <button id="add-node">Add Node</button>
      <button id="add-edge">Add Edge</button>
      <button id="remove-node">Delete Node</button>
      <button id="remove-edge">Delete Edge</button>
    </div>
    <script src="index.ts" type="module"></script>
  </body>
</html>
css
body {
  display: flex;
  height: 100vh;
  font-family: 'IBM Plex Sans', Arial, sans-serif;
}

#graph-container {
  flex: 5;
}

#ui {
  flex: 1;
  display: flex;
  flex-direction: column;
}
button {
  margin-bottom: 4px;
  font-family: 'IBM Plex Sans', Arial, sans-serif;
}
ts
import Ogma, {
  ItemId,
  Point,
  RawEdge,
  RawItem,
  RawNode
} from '@linkurious/ogma';

interface Snapshot {
  action?: string;
  addedNodes: RawNode[];
  addedEdges: RawEdge[];
  removedNodes: RawNode[];
  removedEdges: RawEdge[];
  positions: Point[];
}

export type HistoryEvent = 'snapshot' | 'undo' | 'redo';
export type UndoEvent = CustomEvent<{ availableUndos: number }>;
export type RedoEvent = CustomEvent<{ availableRedos: number }>;

export class HistoryController extends EventTarget {
  private ogma: Ogma;
  private nodes: RawNode[];
  private edges: RawEdge[];
  private offset: number;
  private snapshots: Snapshot[];
  private maxSnapshots: number;

  constructor(ogma: Ogma) {
    super();
    this.ogma = ogma;
    const { nodes, edges } = this._getAppState();
    this.nodes = nodes;
    this.edges = edges;
    this.offset = 0;
    this.snapshots = [];
    this.maxSnapshots = 10;
    this.snapshot('__init__');
  }

  snapshot(actionName?: string) {
    const { ogma } = this;
    const { nodes, edges } = this._getAppState();
    this.snapshots.splice(this.snapshots.length + this.offset + 1);
    const addedNodes = this._subtract(nodes, this.nodes);
    const removedNodes = this._subtract(this.nodes, nodes);
    const addedEdges = this._subtract(edges, this.edges);
    const removedEdges = this._subtract(this.edges, edges);
    const positions = ogma.getNodes().getPosition();
    this.snapshots.push({
      action: actionName,
      addedNodes,
      addedEdges,
      removedNodes,
      removedEdges,
      positions
    });
    if (this.snapshots.length > this.maxSnapshots) {
      this.snapshots.pop();
    }
    this.offset = -1;
    this.nodes = nodes;
    this.edges = edges;
    this.dispatchEvent(new CustomEvent('snapshot'));
  }

  undo() {
    if (this.snapshots.length + this.offset - 1 < 0) return;
    const { ogma } = this;
    const snapshotIndex = this.snapshots.length + this.offset;
    const snapshot = this.snapshots[snapshotIndex];
    ogma.addNodes(snapshot.removedNodes);
    ogma.removeNodes(snapshot.addedNodes.map(n => n.id!));
    ogma.addEdges(snapshot.removedEdges);
    ogma.removeEdges(snapshot.addedEdges.map(e => e.id!));
    ogma.getNodes().setAttributes(snapshot.positions);
    this.offset -= 1;
    const { nodes, edges } = this._getAppState();
    this.nodes = nodes;
    this.edges = edges;
    const availableUndos = this.snapshots.length + this.offset;
    this.dispatchEvent(new CustomEvent('undo', { detail: { availableUndos } }));
  }

  redo() {
    if (this.offset >= -1) return;
    const { ogma } = this;
    const snapshotIndex = this.snapshots.length + this.offset + 1;
    const snapshot = this.snapshots[snapshotIndex];
    ogma.addNodes(snapshot.addedNodes);
    ogma.removeNodes(snapshot.removedNodes.map(n => n.id!));
    ogma.addEdges(snapshot.addedEdges);
    ogma.removeEdges(snapshot.removedEdges.map(e => e.id!));
    ogma.getNodes().setAttributes(snapshot.positions);
    this.offset += 1;
    const { nodes, edges } = this._getAppState();
    this.nodes = nodes;
    this.edges = edges;
    const availableRedos = Math.abs(this.offset) - 1;
    this.dispatchEvent(new CustomEvent('redo', { detail: { availableRedos } }));
  }

  private _getAppState() {
    return {
      nodes: this.ogma.getNodes().toJSON({ attributes: [] }),
      edges: this.ogma.getEdges().toJSON({ attributes: [] })
    };
  }
  private _subtract<T extends RawItem<unknown>>(a: T[], b: T[]): T[] {
    const map = new Map<ItemId, boolean>();
    const res: T[] = [];
    for (let i = 0; i < b.length; i++) {
      map.set(b[i].id!, true);
    }
    for (let i = 0; i < a.length; i++) {
      if (map.has(a[i].id!)) continue;
      res.push(a[i]);
    }
    return res;
  }
}