Search

Undo/Redo

Interactions

Add a stack to give users unlimited undo/redo options.

Undo/Redo
View live example →

Undo and Redo are reassuring additions to a KeyLines user experience, providing a simple way to roll back through recent actions and restore a previous version.

This example shows how to undo and redo chart changes made by the user. Each new action saves a chart state to the Undo stack. Reverting an action will add it to the Redo stack.

There is no limit to the number of actions you can save in the Undo/Redo stack.

Key functions used:

import KeyLines from "keylines";
import dataGenerator from "./data.js";

let chart;
const undoStack = [];
const redoStack = [];

const addButton = document.getElementById("add-node");
const removeButton = document.getElementById("remove-node");
const undoButton = document.getElementById("undo");
const redoButton = document.getElementById("redo");
const layoutButton = document.getElementById("layout");

function disableButtons(isDisabled) {
  addButton.disabled = isDisabled;
  removeButton.disabled = isDisabled;
  undoButton.disabled = isDisabled;
  redoButton.disabled = isDisabled;
  layoutButton.disabled = isDisabled;
}

function refreshUI() {
  disableButtons(false);

  undoButton.value = `Undo (${undoStack.length})`;
  undoButton.disabled = undoStack.length === 0;

  redoButton.value = `Redo (${redoStack.length})`;
  redoButton.disabled = redoStack.length === 0;

  // disables "remove button" if no more items to remove
  removeButton.disabled = chart.serialize().items.length === 0;
}

async function undoStackAdd(callbackFunction) {
  // save the current chart state into the undo stack
  undoStack.push(chart.serialize());
  // clear the redo stack
  redoStack.length = 0;

  if (callbackFunction) {
    await callbackFunction();
  }
  refreshUI();
}

async function expandNewItems() {
  disableButtons(true);
  // If a node is selected then adds new nodes to that node, otherwise adds to a random node
  const selectedIds = chart.selection();
  const selectedId =
    selectedIds.length > 0 && chart.getItem(selectedIds[0]).type === "node"
      ? selectedIds[0]
      : undefined;

  await chart.expand(dataGenerator.newNodeAndLink(selectedId), {
    time: 500,
    layout: { name: "organic", fix: "all" },
  });
  addButton.disabled = false;
  disableButtons(false);
}

function removeNode() {
  // If a node is selected then removed that node, otherwise remove random node
  const selectedIds = chart.selection();
  const selectedId = selectedIds[0] || dataGenerator.getRemoveNodeId(chart);
  chart.removeItem(selectedId);
}

function initialiseInteractions() {
  chart.load(dataGenerator.makeChart());
  chart.layout("organic");

  undoButton.addEventListener("click", () => {
    redoStack.push(chart.serialize());
    chart.load(undoStack.pop());
    refreshUI();
  });

  redoButton.addEventListener("click", () => {
    undoStack.push(chart.serialize());
    chart.load(redoStack.pop());
    refreshUI();
  });

  chart.on("prechange", ({ type }) => {
    if (/(move|offset)/.test(type)) {
      // Before moving a link / node, we save the chart state in the undo stack
      undoStackAdd();
    }
  });

  layoutButton.addEventListener("click", async () => {
    // Disable buttons to stop button used while layout happening
    disableButtons(true);
    await undoStackAdd(() => chart.layout("organic"));
    refreshUI();
  });

  addButton.addEventListener("click", () => {
    undoStackAdd(expandNewItems);
  });

  removeButton.addEventListener("click", () => {
    undoStackAdd(removeNode);
  });
}

async function loadKeyLines() {
  const options = {
    logo: { u: "/images/Logo.png" },
    handMode: true,
  };
  chart = await KeyLines.create({ container: "klchart", options });

  initialiseInteractions();
}

window.addEventListener("DOMContentLoaded", loadKeyLines);
const nodeIds = [];
let nextNodeId = 0;

function refreshNodeIds(chart) {
  nodeIds.length = 0;
  chart.each({ type: "node" }, (item) => {
    if (item.type === "node") {
      nodeIds.push(item.id);
    }
  });
}

function randomColor() {
  const green = 50 + Math.floor(125 * Math.random());
  return `rgb(255, ${green}, 0)`;
}

function newNode() {
  const id = nextNodeId++;
  nodeIds.push(id);
  return {
    id,
    type: "node",
    c: randomColor(),
  };
}

function newLink(nodeId1, nodeId2) {
  const id1 = nodeId1 || nodeIds[`${Math.round(Math.random() * (nodeIds.length - 1))}`];
  const id2 = nodeId2 || nodeIds[`${Math.round(Math.random() * (nodeIds.length - 1))}`];
  return {
    type: "link",
    id: `${id1}-${id2}`,
    id1,
    id2,
    c: "rgb(0,0,127)",
  };
}

function makeItemsList() {
  nodeIds.length = 0;
  nextNodeId = 0;

  const items = [];
  for (let i = 0; i < 30; i++) {
    const node = newNode();
    items.push(node, newLink(node.id));
  }
  return items;
}

const dataGenerator = {
  makeChart() {
    return {
      type: "LinkChart",
      items: makeItemsList(),
    };
  },
  newNodeAndLink(selectedId) {
    const newNodeData = newNode();
    const newLinkData = newLink(newNodeData.id, selectedId);

    return [newNodeData, newLinkData];
  },
  getRemoveNodeId(chart) {
    refreshNodeIds(chart);
    const index = `${Math.round(Math.random() * (nodeIds.length - 1))}`;
    const id = nodeIds[index];
    nodeIds.splice(index, 1);
    return id;
  },
};

export default dataGenerator;
<!doctype html>
<html lang="en" style="background-color: #2d383f">
  <head>
    <meta charset="utf-8" />
    <title>Undo/Redo</title>
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/keylines.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/minimalsdk.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/sdk-layout.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/demo.css" />
  </head>
  <body>
    <div id="klchart" class="klchart"></div>
    <script type="module" src="./code.js"></script>
  </body>
</html>

Terms of use

These terms do not alter or supersede any existing agreements between you (or your employer) and us.

By accessing or using any Content you agree to be bound by these Terms of Use. Please review these terms carefully before using the website.

The contents of this website, including but not limited to any text, code samples, API references, schemas, interactive tools, and other materials (collectively, the 'Content'), are made available for informational and internal evaluation purposes only. All intellectual property rights in the Content are reserved. No licence is granted to use the Content for any commercial purpose, or to copy, distribute, modify, reverse-engineer, or incorporate any part of the Content into any product or service, without our prior written consent.

This Content is provided “as is” and “as available,” without any representations, warranties, or guarantees of any kind, whether express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, non-infringement, or accuracy. To the fullest extent permitted by applicable law, we expressly exclude and disclaim all implied warranties, conditions, and other terms that might otherwise be implied.

We disclaim all liability for any loss or damage, whether direct, indirect, incidental, consequential, or otherwise, arising from any reliance placed on the Content or from your use of it, to the fullest extent permitted by applicable law. By continuing to access or use the Content, you acknowledge and agree to these terms.