Canvas 绘图——使用 JavaScript 在 HTML5 Canvas 上自由绘制和涂画
构建一个 Canvas 绘图工具,让用户直接在 HTML5 Canvas 上绘制自由笔画。这是 Canvas 应用最常见的功能之一,包括白板、标注工具、设计编辑器和签名板。
使用 Konva 进行 Canvas 绘图有两种常见方法:
- 基于 Konva 的矢量图形——每条笔画都是一个 Konva.Line 对象,以后可以选择、移动和删除(简单,适合大多数场景)
- 手动 2D Canvas 绘图——直接绘制到 Canvas 像素缓冲区,在包含大量笔画时获得最高性能(高级)。
使用 Konva 节点自由绘制
第一种方法可能也是最简单的方法:
- 创建新的
Konva.Line,并在mousedown/touchstart时开始绘制 - 在
mousemove/touchmove时向线条添加新点
此方法适用于许多应用。也可以在某处以矢量形式轻松存储绘图 state,例如存储在 React store 中或以 JSON 格式保存到数据库。
- Vanilla
- React
- Vue
import Konva from 'konva';
// create tool select
const select = document.createElement('select');
select.innerHTML = `
<option value="brush">Brush</option>
<option value="eraser">Eraser</option>
`;
document.body.appendChild(select);
const width = window.innerWidth;
const height = window.innerHeight - 25;
// first we need Konva core things: stage and layer
const stage = new Konva.Stage({
container: 'container',
width: width,
height: height,
});
const layer = new Konva.Layer();
stage.add(layer);
let isPaint = false;
let mode = 'brush';
let lastLine;
stage.on('mousedown touchstart', function (e) {
isPaint = true;
const pos = stage.getPointerPosition();
lastLine = new Konva.Line({
stroke: '#df4b26',
strokeWidth: 5,
globalCompositeOperation:
mode === 'brush' ? 'source-over' : 'destination-out',
// round cap for smoother lines
lineCap: 'round',
lineJoin: 'round',
// add point twice, so we have some drawings even on a simple click
points: [pos.x, pos.y, pos.x, pos.y],
});
layer.add(lastLine);
});
stage.on('mouseup touchend', function () {
isPaint = false;
});
// and core function - drawing
stage.on('mousemove touchmove', function (e) {
if (!isPaint) {
return;
}
// prevent scrolling on touch devices
e.evt.preventDefault();
const pos = stage.getPointerPosition();
const newPoints = lastLine.points().concat([pos.x, pos.y]);
lastLine.points(newPoints);
});
select.addEventListener('change', function () {
mode = select.value;
});
import React from 'react';
import { Stage, Layer, Line } from 'react-konva';
const App = () => {
const [tool, setTool] = React.useState('brush');
const [lines, setLines] = React.useState([]);
const isDrawing = React.useRef(false);
const handleMouseDown = (e) => {
isDrawing.current = true;
const pos = e.target.getStage().getPointerPosition();
setLines([...lines, { tool, points: [pos.x, pos.y] }]);
};
const handleMouseMove = (e) => {
// no drawing - skipping
if (!isDrawing.current) {
return;
}
// prevent scrolling on touch devices
e.evt.preventDefault();
const stage = e.target.getStage();
const point = stage.getPointerPosition();
// To draw line
let lastLine = lines[lines.length - 1];
// add point
lastLine.points = lastLine.points.concat([point.x, point.y]);
// replace last
lines.splice(lines.length - 1, 1, lastLine);
setLines(lines.concat());
};
const handleMouseUp = () => {
isDrawing.current = false;
};
return (
<>
<select
value={tool}
onChange={(e) => {
setTool(e.target.value);
}}
>
<option value="brush">Brush</option>
<option value="eraser">Eraser</option>
</select>
<Stage
width={window.innerWidth}
height={window.innerHeight - 25}
onMouseDown={handleMouseDown}
onMousemove={handleMouseMove}
onMouseup={handleMouseUp}
onTouchStart={handleMouseDown}
onTouchMove={handleMouseMove}
onTouchEnd={handleMouseUp}
>
<Layer>
{lines.map((line, i) => (
<Line
key={i}
points={line.points}
stroke="#df4b26"
strokeWidth={5}
tension={0.5}
lineCap="round"
lineJoin="round"
globalCompositeOperation={
line.tool === 'eraser' ? 'destination-out' : 'source-over'
}
/>
))}
</Layer>
</Stage>
</>
);
};
export default App;
<template>
<div>
<select v-model="tool">
<option value="brush">Brush</option>
<option value="eraser">Eraser</option>
</select>
<v-stage
:config="stageConfig"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@touchstart="handleMouseDown"
@touchmove="handleMouseMove"
@touchend="handleMouseUp"
>
<v-layer>
<v-line
v-for="(line, i) in lines"
:key="i"
:config="{
points: line.points,
stroke: '#df4b26',
strokeWidth: 5,
tension: 0.5,
lineCap: 'round',
lineJoin: 'round',
globalCompositeOperation:
line.tool === 'eraser' ? 'destination-out' : 'source-over'
}"
/>
</v-layer>
</v-stage>
</div>
</template>
<script setup>
import { ref } from 'vue';
const tool = ref('brush');
const lines = ref([]);
const isDrawing = ref(false);
const stageConfig = {
width: window.innerWidth,
height: window.innerHeight - 25
};
const handleMouseDown = (e) => {
isDrawing.value = true;
const pos = e.target.getStage().getPointerPosition();
lines.value.push({ tool: tool.value, points: [pos.x, pos.y] });
};
const handleMouseMove = (e) => {
if (!isDrawing.value) {
return;
}
// prevent scrolling on touch devices
e.evt.preventDefault();
const stage = e.target.getStage();
const point = stage.getPointerPosition();
let lastLine = lines.value[lines.value.length - 1];
lastLine.points = lastLine.points.concat([point.x, point.y]);
lines.value.splice(lines.value.length - 1, 1, { ...lastLine });
};
const handleMouseUp = () => {
isDrawing.value = false;
};
</script>
手动自由绘制
如果要直接使用底层 2D Canvas API,第一种方法存在限制。如果需要对 Canvas 进行高级访问,最好使用原生上下文访问。
我们将创建一个特殊的离屏 Canvas,并在其中添加所有绘图。
通过原生 Canvas 访问,可以使用底层 2D 上下文函数。
要在舞台上显示此 Canvas,将使用 Konva.Image。
- Vanilla
- React
- Vue
import Konva from 'konva';
// create tool select
const select = document.createElement('select');
select.innerHTML = `
<option value="brush">Brush</option>
<option value="eraser">Eraser</option>
`;
document.body.appendChild(select);
const width = window.innerWidth;
const height = window.innerHeight - 25;
// first we need Konva core things: stage and layer
const stage = new Konva.Stage({
container: 'container',
width: width,
height: height,
});
const layer = new Konva.Layer();
stage.add(layer);
// then we are going to draw into special canvas element
const canvas = document.createElement('canvas');
canvas.width = stage.width();
canvas.height = stage.height();
// created canvas we can add to layer as "Konva.Image" element
const image = new Konva.Image({
image: canvas,
x: 0,
y: 0,
});
layer.add(image);
// Good. Now we need to get access to context element
const context = canvas.getContext('2d');
context.strokeStyle = '#df4b26';
context.lineJoin = 'round';
context.lineWidth = 5;
let isPaint = false;
let lastPointerPosition;
let mode = 'brush';
// now we need to bind some events
// we need to start drawing on mousedown
// and stop drawing on mouseup
image.on('mousedown touchstart', function () {
isPaint = true;
lastPointerPosition = stage.getPointerPosition();
});
stage.on('mouseup touchend', function () {
isPaint = false;
});
// and core function - drawing
stage.on('mousemove touchmove', function () {
if (!isPaint) {
return;
}
if (mode === 'brush') {
context.globalCompositeOperation = 'source-over';
}
if (mode === 'eraser') {
context.globalCompositeOperation = 'destination-out';
}
context.beginPath();
const localPos = {
x: lastPointerPosition.x - image.x(),
y: lastPointerPosition.y - image.y(),
};
context.moveTo(localPos.x, localPos.y);
const pos = stage.getPointerPosition();
const newLocalPos = {
x: pos.x - image.x(),
y: pos.y - image.y(),
};
context.lineTo(newLocalPos.x, newLocalPos.y);
context.closePath();
context.stroke();
lastPointerPosition = pos;
// redraw manually
layer.batchDraw();
});
select.addEventListener('change', function () {
mode = select.value;
});
import React from 'react';
import { Stage, Layer, Image } from 'react-konva';
const App = () => {
const [tool, setTool] = React.useState('brush');
const isDrawing = React.useRef(false);
const imageRef = React.useRef(null);
const lastPos = React.useRef(null);
const { canvas, context } = React.useMemo(() => {
const canvas = document.createElement('canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight - 25;
const context = canvas.getContext('2d');
context.strokeStyle = '#df4b26';
context.lineJoin = 'round';
context.lineWidth = 5;
return { canvas, context };
}, []);
const handleMouseDown = (e) => {
isDrawing.current = true;
lastPos.current = e.target.getStage().getPointerPosition();
};
const handleMouseUp = () => {
isDrawing.current = false;
};
const handleMouseMove = (e) => {
if (!isDrawing.current) {
return;
}
const image = imageRef.current;
const stage = e.target.getStage();
context.globalCompositeOperation = tool === 'eraser' ? 'destination-out' : 'source-over';
context.beginPath();
const localPos = {
x: lastPos.current.x - image.x(),
y: lastPos.current.y - image.y(),
};
context.moveTo(localPos.x, localPos.y);
const pos = stage.getPointerPosition();
const newLocalPos = {
x: pos.x - image.x(),
y: pos.y - image.y(),
};
context.lineTo(newLocalPos.x, newLocalPos.y);
context.closePath();
context.stroke();
lastPos.current = pos;
image.getLayer().batchDraw();
};
return (
<>
<select
value={tool}
onChange={(e) => {
setTool(e.target.value);
}}
>
<option value="brush">Brush</option>
<option value="eraser">Eraser</option>
</select>
<Stage
width={window.innerWidth}
height={window.innerHeight - 25}
onMouseDown={handleMouseDown}
onMousemove={handleMouseMove}
onMouseup={handleMouseUp}
onTouchStart={handleMouseDown}
onTouchMove={handleMouseMove}
onTouchEnd={handleMouseUp}
>
<Layer>
<Image
ref={imageRef}
image={canvas}
x={0}
y={0}
/>
</Layer>
</Stage>
</>
);
};
export default App;
<template>
<div>
<select v-model="tool">
<option value="brush">Brush</option>
<option value="eraser">Eraser</option>
</select>
<v-stage
:config="stageConfig"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@touchstart="handleMouseDown"
@touchmove="handleMouseMove"
@touchend="handleMouseUp"
>
<v-layer ref="layerRef">
<v-image
ref="imageRef"
:config="imageConfig"
/>
</v-layer>
</v-stage>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const tool = ref('brush');
const isDrawing = ref(false);
const lastPos = ref(null);
const imageRef = ref(null);
const layerRef = ref(null);
const stageConfig = {
width: window.innerWidth,
height: window.innerHeight - 25
};
// create canvas element
const canvas = document.createElement('canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight - 25;
// get context
const context = canvas.getContext('2d');
context.strokeStyle = '#df4b26';
context.lineJoin = 'round';
context.lineWidth = 5;
const imageConfig = {
image: canvas,
x: 0,
y: 0
};
const handleMouseDown = (e) => {
isDrawing.value = true;
lastPos.value = e.target.getStage().getPointerPosition();
};
const handleMouseUp = () => {
isDrawing.value = false;
};
const handleMouseMove = (e) => {
if (!isDrawing.value) {
return;
}
const ctx = context;
const image = imageRef.value.getNode();
const stage = e.target.getStage();
ctx.globalCompositeOperation = tool.value === 'eraser' ? 'destination-out' : 'source-over';
ctx.beginPath();
const localPos = {
x: lastPos.value.x - image.x(),
y: lastPos.value.y - image.y(),
};
ctx.moveTo(localPos.x, localPos.y);
const pos = stage.getPointerPosition();
const newLocalPos = {
x: pos.x - image.x(),
y: pos.y - image.y(),
};
ctx.lineTo(newLocalPos.x, newLocalPos.y);
ctx.closePath();
ctx.stroke();
lastPos.value = pos;
layerRef.value.getNode().batchDraw();
};
</script>