Skip to content
  1. Examples
  2. Graph operations

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 (const 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://cdn.jsdelivr.net/npm/@linkurious/ogma-ui-kit@latest/dist/ogma-ui-kit.min.js"></script>
    <link type="text/css" rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div id="graph-container"></div>
    <div id="info" class="panel panel-right">
      <sl-button id="undo">
        <sl-icon library="lucide" slot="prefix" name="undo"></sl-icon>
        Undo
      </sl-button>
      <sl-button id="redo" variant="default">
        <sl-icon library="lucide" slot="prefix" name="redo"></sl-icon>
        Redo
      </sl-button>
      <sl-divider></sl-divider>
      <sl-button id="add-node" variant="default">
        <sl-icon library="lucide" slot="prefix" name="circle-small"></sl-icon>
        Add Node
      </sl-button>
      <sl-button id="add-edge" variant="default">
        <sl-icon library="lucide" slot="prefix" name="minus"></sl-icon>
        Add Edge
      </sl-button>
      <sl-button id="remove-node" variant="default">
        <sl-icon library="lucide" slot="prefix" name="trash-2"></sl-icon>
        Delete Node
      </sl-button>
      <sl-button id="remove-edge" variant="default">
        <sl-icon library="lucide" slot="prefix" name="scissors"></sl-icon>
        Delete Edge
      </sl-button>
    </div>
    <script src="index.ts" type="module"></script>
  </body>
</html>
css
body {
  display: flex;
  height: 100vh;
  width: 100vw;
  font-family: 'IBM Plex Sans', Arial, sans-serif;
}

#graph-container {
  width: 100%;
  height: 100%;
}

button {
  margin-bottom: 4px;
  font-family: 'IBM Plex Sans', Arial, sans-serif;
}

.panel-right {
  right: 1em;
  left: auto;
}
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;
  }
}