接下来,我们继续完善功能。作为一个手写应用,undo/redo操作是必须的。要现实undo/redo,最容易想到的实现方式,就是我们记住每一次操作的结果,在用户undo的时候,显示之前的结果给用户就可以了。但是这样有一个很大的问题,就是我们的canvas,实际上一张图片。要记住结果,就需要记住这张图片的内容。而图片本身的数据量是很大的。因此我们没有办法记住很多次结果,也就是限制了undo的次数。 还有一种办法,就是我们记住用户的操作,例如我们记住第一次用户用铅笔,画了一条线,这条线上面每一个点的坐标是什么,我们都给记下来。在用户undo的时候,我们可以清空画板,然后从头到尾再画一次所有的操作,这样就可以实现undo功能了。由于用户操作相对的数据,相对于图片本身的数据来说,要小很多,因此,我们几乎可以无限制的undo/redo了。 要记住用户操作,那么在鼠标操作的时候,我们就需要记住每一个鼠标的位置,然后加入到数组里面。在用户鼠标松开的时候,我们再把整个路径的坐标,以及笔画粗细,颜色,作为一个完整的操作,添加到用户数组里面。 下面是完整的代码
<html>
<head>
<meta charset="utf-8">
</head>
<body style='background:lightgrey'>
<div>
<button id='pencil' onclick="handleChoosePencil()">pencil</button>
<button id='highlighter' onclick="handleChooseHighlighter()">highlighter</button>
<button id='eraser' onclick="handleChooseEraser()">eraser</button>
<button id='undo' onclick="handleUndo()">undo</button>
<button id='redo' onclick="handleRedo()">redo</button>
</div>
<canvas id='pad' width='800px' height='600px' style='background:white'></canvas>
</body>
<script>
//
let actions = [];
let points = [];
let undoCursor = -1;
//
const pad = document.getElementById('pad');
const ctx = pad.getContext('2d');
ctx.lineWidth = 2;
ctx.strokeStyle = 'blue';
//
updateButtonStatus();
//
//
pad.addEventListener('mousedown', handleMouseDown);
//
function handleMouseDown(event) {
//
if (undoCursor != -1) {
actions = actions.slice(0, undoCursor);
}
undoCursor = -1;
//
ctx.beginPath();
ctx.moveTo(event.offsetX, event.offsetY);
//
pad.addEventListener('mousemove', handleMouseMove);
pad.addEventListener('mouseup', handleMouseUp);
//
points.push({x: event.offsetX, y: event.offsetY});
}
//
function handleMouseMove(event) {
ctx.lineTo(event.offsetX, event.offsetY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(event.offsetX, event.offsetY);
points.push({x: event.offsetX, y: event.offsetY});
}
//
function handleMouseUp(event) {
pad.removeEventListener('mousemove', handleMouseMove);
pad.removeEventListener('mouseup', handleMouseUp);
//
actions.push({
lineWidth: ctx.lineWidth,
strokeStyle: ctx.strokeStyle,
points,
});
//
points = [];
updateButtonStatus();
}
//
function handleChoosePencil(event) {
ctx.strokeStyle = 'rgb(0, 0, 255)';
ctx.lineWidth = 2;
}
//
function handleChooseHighlighter(event) {
ctx.strokeStyle = 'rgba(255, 255, 0, 0.5)';
ctx.lineWidth = 8;
}
//
function handleChooseEraser(event) {
ctx.strokeStyle = 'white';
ctx.lineWidth = 8;
}
//
function canUndo() {
if (actions.length == 0) {
return false;
}
//
if (undoCursor == 0) {
return false;
}
//
return true;
}
//
function canRedo() {
//
if (actions.length == 0) {
return false;
}
//
if (undoCursor == -1 || undoCursor == actions.length) {
return false;
}
//
return true;
}
//
function handleUndo(event) {
if (!canUndo()) {
return;
}
//
if (undoCursor == -1) {
undoCursor = actions.length;
}
//
undoCursor--;
//
repaint();
//
updateButtonStatus();
}
//
function handleRedo(event) {
if (!canRedo()) {
return;
}
//
undoCursor++;
//
repaint();
//
updateButtonStatus();
}
//
function updateButtonStatus() {
const undoButton = document.getElementById('undo');
const redoButton = document.getElementById('redo');
undoButton.disabled = !canUndo();
redoButton.disabled = !canRedo();
}
//
function repaint() {
ctx.clearRect(0, 0, pad.width, pad.height);
//
let toIndex = undoCursor == -1 ? actions.length : undoCursor;
for (let i = 0; i < toIndex; i++) {
//
let action = actions[i];
//
ctx.beginPath();
ctx.lineWidth = action.lineWidth;
ctx.strokeStyle = action.strokeStyle;
//
let points = action.points;
if (points.length == 0) {
continue;
}
//
let firstPoint = points[0];
ctx.moveTo(firstPoint.x, firstPoint.y);
for (let j = 1; j < points.length; j++) {
const point = points[j];
ctx.lineTo(point.x, point.y);
}
ctx.stroke();
//
}
}
</script>
</html>
记录用户操作:
首先,我们定义了一个points数组,用来在鼠标按下以及移动的时候,记录完整的鼠标坐标。 我们还定义了一个actions数组,这个就是用来存放用户每一个操作的数组。在用户鼠标松开的时候,我们会把当前操作添加到这个actions数组后面:
function handleMouseUp(event) {
pad.removeEventListener('mousemove', handleMouseMove);
pad.removeEventListener('mouseup', handleMouseUp);
//
actions.push({
lineWidth: ctx.lineWidth,
strokeStyle: ctx.strokeStyle,
points,
});
//
points = [];
updateButtonStatus();
}
我们在这里记录了当前路径所有的坐标(points),当前路径的宽度,以及颜色。把他们组合成一个对象添加到actions数组里面。在添加完之后,我们还需要记住,要把points数组重置,准备记录下一个操作的坐标。
执行undo
在用户进行undo的时候,最容易想到的办法,就是用户每执行一次undo,我们就把actions数组中最后一个元素删除,然后重新绘制actions里面的所有元素。但是这样以来,我们就没办法实现redo操作了。因此,在用户undo的时候,我们可以通过一个游标(actions数组下标),记录当前用户undo到哪一步了。当用户undo的时候,游标向数组头部移动。当用户redo的时候,游标向数组尾部移动。 首先,我们需要定义一个undoCursor,并把他的初始值设置成-1。之所以设置成-1,是因为数组的下标永远应该是大于等于0的。那么如果是-1,表示当前游标在数组最后面,用户没有任何undo操作。 下面的代码,可以判断当前是否允许undo/redo
function canUndo() {
if (actions.length == 0) {
return false;
}
//
if (undoCursor == 0) {
return false;
}
//
return true;
}
function canRedo() {
//
if (actions.length == 0) {
return false;
}
//
if (undoCursor == -1 || undoCursor == actions.length) {
return false;
}
//
return true;
}
当用户undo/redo的时候,我们去移动游标:
function handleUndo(event) {
if (!canUndo()) {
return;
}
//
if (undoCursor == -1) {
undoCursor = actions.length;
}
//
undoCursor--;
//
repaint();
//
updateButtonStatus();
}
//
function handleRedo(event) {
if (!canRedo()) {
return;
}
//
undoCursor++;
//
repaint();
//
updateButtonStatus();
}
在移动完游标后,我们还需要进行重绘。 重绘很简单,就是清空canvas,然后从头到尾重新绘制路径即可:
function repaint() {
ctx.clearRect(0, 0, pad.width, pad.height);
//
let toIndex = undoCursor == -1 ? actions.length : undoCursor;
for (let i = 0; i < toIndex; i++) {
//
let action = actions[i];
//
ctx.beginPath();
ctx.lineWidth = action.lineWidth;
ctx.strokeStyle = action.strokeStyle;
//
let points = action.points;
if (points.length == 0) {
continue;
}
//
let firstPoint = points[0];
ctx.moveTo(firstPoint.x, firstPoint.y);
for (let j = 1; j < points.length; j++) {
const point = points[j];
ctx.lineTo(point.x, point.y);
}
ctx.stroke();
//
}
}
当用户undo/redo之后,继续进行新的绘画的时候,我们还需要删除游标后面的数据,并且重置游标的位置:
if (undoCursor != -1) {
actions = actions.slice(0, undoCursor);
}
undoCursor = -1;
最后,我们还需要在添加新的undo/redo按钮,并响应按钮消息,进行undo/redo处理。同时,在合适的时机,我们还需要更新undo/redo按钮的状态,以便告诉用户,什么时候可以undo/redo。