Save and Load HTML5 Canvas Stage Best Practices

What is the best way to save/load full stage content and how to implement undo/redo?

If you want to save/load simple canvas content you can use the built-in Konva methods: node.toJSON() and Node.create(json).
See simple and complex demos.

But those methods are useful only in very small apps. In bigger apps it is VERY hard to use those methods. Why? Because the tree structure is usually very complex in larger apps, you may have a lot of event listeners, images, filters, etc. That data is not serializable into JSON (or it is very hard to do that).

Also it is very common that nodes in a tree have a lot information that is not directly related to the state of your app, but just used to describe visual view of your app.

For instance, let’s think we have a game, that draws several balls in canvas. The balls are not just circles, but the complex visual groups of objects with shadows and texts inside them (like “Made in China”). Now let’s think you want to serialize state of your app and use it somewhere else. Like send to another computer or implement undo/redo. Almost all the visual information (shadows, texts, sizes) is not critical and may be you don’t need to save it. Because all balls have the same shadows, sizes, etc. But what is critical? In that case it is just a number of balls and their coordinates. You need to save/load only that information. It will be just a simple array:

var state = [{x: 10, y: 10}, { x: 160, y: 1041}]

Now when you have that information, you need to have a function, that can create the whole canvas structure.
If you want to update your canvas, for instance, you want to create a new ball, you don’t need to create a new canvas node directly (like creating new instance of Konva.Circle), you just need to push a new object into a state and update (or recreate) canvas.

In that case you don’t need to care about image loading, filters, event listeners, etc in saving/loading phases. Because you do all these actions in your create or update functions.

You would better understand what I am talking about if you know how many modern frameworks work (like React, Vue, Angular and many other).

Also take a look into these demos to have a better idea:

  1. Undo/redo with react
  2. Save/load with Vue

How to implement that create and update functions? It depends. From my point of view it will be easier to use frameworks that can do that job for you, like react-konva.

If you don’t want to use such frameworks you need to think in terms of your own app. Here I will try to make a small demo to give you an idea.

The super naive method is to implement just one function create(state) that will do all the complex job of loading.
If you have some changes in your app you just need to destroy the canvas and create a new one. But the drawback of such approach is possibly a bad performance.

A bit smarter implementation is to create two functions create(state) and update(state). create will make instances of all required objects, attach events and load images. update will update properties of nodes. If number of objects is changed - destroy all and create from scratch. If only some properties changed - call update.

Instructions: In that demo we will have a bunch of images with filters, and you can add more, move them, apply a new filter by clicking on images and use undo/redo.

Konva Load Complex Stage Demoview raw
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/[email protected]/konva.min.js"></script>
<meta charset="utf-8" />
<title>Konva Load Complex Stage Demo</title>
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
background-color: #f0f0f0;
}
</style>
</head>
<body>
<button id="create-yoda">Create yoda</button>
<button id="create-darth">Create darth</button>
<button id="undo">Undo</button>
<button id="redo">Redo</button>
<div id="container"></div>
<script>
var possibleFilters = ['', 'blur', 'invert'];

function createObject(attrs) {
return Object.assign({}, attrs, {
// define position
x: 0,
y: 0,
// here should be url to image
src: '',
// and define filter on it, let's define that we can have only
// "blur", "invert" or "" (none)
filter: 'blur',
});
}
function createYoda(attrs) {
return Object.assign(createObject(attrs), {
src: '/assets/yoda.jpg',
});
}

function createDarth(attrs) {
return Object.assign(createObject(attrs), {
src: '/assets/darth-vader.jpg',
});
}

// initial state
var state = [createYoda({ x: 50, y: 50 })];

// our history
var appHistory = [state];
var appHistoryStep = 0;

var width = window.innerWidth;
var height = window.innerHeight;
var stage = new Konva.Stage({
container: 'container',
width: width,
height: height,
});

var layer = new Konva.Layer();
stage.add(layer);

// create function will destroy previous drawing
// then it will created required nodes and attach all events
function create() {
layer.destroyChildren();
state.forEach((item, index) => {
var node = new Konva.Image({
draggable: true,
name: 'item-' + index,
// make it smaller
scaleX: 0.5,
scaleY: 0.5,
});
layer.add(node);
node.on('dragend', () => {
// make new state
state = state.slice();
// update object data
state[index] = Object.assign({}, state[index], {
x: node.x(),
y: node.y(),
});
// save it into history
saveStateToHistory(state);
// don't need to call update here
// because changes already in node
});

node.on('click', () => {
// find new filter
var oldFilterIndex = possibleFilters.indexOf(state[index].filter);
var nextIndex = (oldFilterIndex + 1) % possibleFilters.length;
var filter = possibleFilters[nextIndex];

// apply state changes
state = state.slice();
state[index] = Object.assign({}, state[index], {
filter: filter,
});
// save state to history
saveStateToHistory(state);
// update canvas from state
update(state);
});

var img = new window.Image();
img.onload = function () {
node.image(img);
update(state);
};
img.src = item.src;
});
update(state);
}

function update() {
state.forEach(function (item, index) {
var node = stage.findOne('.item-' + index);
node.setAttrs({
x: item.x,
y: item.y,
});

if (!node.image()) {
return;
}
if (item.filter === 'blur') {
node.filters([Konva.Filters.Blur]);
node.blurRadius(10);
node.cache();
} else if (item.filter === 'invert') {
node.filters([Konva.Filters.Invert]);
node.cache();
} else {
node.filters([]);
node.clearCache();
}
});
}

//
function saveStateToHistory(state) {
appHistory = appHistory.slice(0, appHistoryStep + 1);
appHistory = appHistory.concat([state]);
appHistoryStep += 1;
}
create(state);

document
.querySelector('#create-yoda')
.addEventListener('click', function () {
// create new object
state.push(
createYoda({
x: width * Math.random(),
y: height * Math.random(),
})
);
// recreate canvas
create(state);
});

document
.querySelector('#create-darth')
.addEventListener('click', function () {
// create new object
state.push(
createDarth({
x: width * Math.random(),
y: height * Math.random(),
})
);
// recreate canvas
create(state);
});

document.querySelector('#undo').addEventListener('click', function () {
if (appHistoryStep === 0) {
return;
}
appHistoryStep -= 1;
state = appHistory[appHistoryStep];
// create everything from scratch
create(state);
});

document.querySelector('#redo').addEventListener('click', function () {
if (appHistoryStep === appHistory.length - 1) {
return;
}
appHistoryStep += 1;
state = appHistory[appHistoryStep];
// create everything from scratch
create(state);
});
</script>
</body>
</html>
Enjoying Konva? Please consider to support the project.