Appearance
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;
}
}