Canvas Drawing — 使用 JavaScript 在 HTML5 Canvas 上自由绘图
构建一个画布绘图工具,让用户可以直接在 HTML5 Canvas 上绘制自由笔触。这是基于画布的应用中最常见的功能之一——从白板和标注工具到设计编辑器和签名板。
使用 Konva 进行画布绘图有两种常见方法:
- 基于 Konva 的矢量图形 — 每条笔触都是一个 Konva.Line 对象,你之后可以选择、移动和删除它(简单,推荐用于大多数场景)
- 手动 2D 画布绘图 — 直接绘制到画布像素缓冲区上,以便在大量笔触时获得最高性能(高级)
使用 Konva 节点进行自由绘画
所以第一种方法也是可能是最简单的方法是:
- 在
mousedown/touchstart时开始一个新的Konva.Line - 在
mousemove/touchmove时向该线添加新的点
这种方式在许多应用中运行良好。要将绘图的状态存储在某处(如 React store 或 JSON 保存到数据库)也很简单。
- Vanilla
- React
- Vue
import Konva from 'konva';
// 创建工具选择
const select = document.createElement('select');
select.innerHTML = `
<option value="brush">画笔</option>
<option value="eraser">橡皮擦</option>
`;
document.body.appendChild(select);
const width = window.innerWidth;
const height = window.innerHeight - 25;
// 首先我们需要 Konva 的核心组件:舞台和图层
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',
// 为了更平滑的线条,使用圆形端点
lineCap: 'round',
lineJoin: 'round',
// 添加点两次,以便我们即使在简单点击时也有一些绘制
points: [pos.x, pos.y, pos.x, pos.y],
});
layer.add(lastLine);
});
stage.on('mouseup touchend', function () {
isPaint = false;
});
// 核心功能 - 绘画
stage.on('mousemove touchmove', function (e) {
if (!isPaint) {
return;
}
// 防止触摸设备上的滚动
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) => {
// 没有绘图 - 跳过
if (!isDrawing.current) {
return;
}
// 防止触摸设备上的滚动
e.evt.preventDefault();
const stage = e.target.getStage();
const point = stage.getPointerPosition();
// 绘制线条
let lastLine = lines[lines.length - 1];
// 添加点
lastLine.points = lastLine.points.concat([point.x, point.y]);
// 替换最后一项
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">画笔</option>
<option value="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">画笔</option>
<option value="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;
}
// 防止触摸设备上的滚动
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 画布 API,第一个方法有局限性。如果需要对画布进行高级访问,最好使用 原生上下文访问
我们将创建一个特殊的离屏画布,用于添加所有绘图。
使用对画布的原生访问,我们可以使用低级别的 2D 上下文函数。
要在舞台上显示画布,我们将使用 Konva.Image。
- Vanilla
- React
- Vue
import Konva from 'konva';
// 创建工具选择
const select = document.createElement('select');
select.innerHTML = `
<option value="brush">画笔</option>
<option value="eraser">橡皮擦</option>
`;
document.body.appendChild(select);
const width = window.innerWidth;
const height = window.innerHeight - 25;
// 首先我们需要 Konva 的核心组件:舞台和图层
const stage = new Konva.Stage({
container: 'container',
width: width,
height: height,
});
const layer = new Konva.Layer();
stage.add(layer);
// 然后我们将绘制到特殊的画布元素中
const canvas = document.createElement('canvas');
canvas.width = stage.width();
canvas.height = stage.height();
// 创建的画布我们可以作为 "Konva.Image" 元素添 加到图层
const image = new Konva.Image({
image: canvas,
x: 0,
y: 0,
});
layer.add(image);
// 很好。现在我们需要访问上下文元素
const context = canvas.getContext('2d');
context.strokeStyle = '#df4b26';
context.lineJoin = 'round';
context.lineWidth = 5;
let isPaint = false;
let lastPointerPosition;
let mode = 'brush';
// 现在我们需要绑定一些事件
// 我们需要在鼠标按下时开始绘制
// 在鼠标抬起时停止绘制
image.on('mousedown touchstart', function () {
isPaint = true;
lastPointerPosition = stage.getPointerPosition();
});
stage.on('mouseup touchend', function () {
isPaint = false;
});
// 核心功能 - 绘画
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;
// 手动重绘
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">画笔</option>
<option value="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">画笔</option>
<option value="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
};
// 创建画布元素
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;
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;
}
// 防止触摸设备上的滚动
e.evt.preventDefault();
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>