Canvas绘图板应用开发指南,涵盖绑笔工具、颜色选择、撤销重做、图层管理等功能实现。Canvas绘图板是一个经典的应用场景,本节将介绍如何开发一个功能完善的绘图板应用。
class BrushTool {
constructor(canvas) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.isDrawing = false
this.lastX = 0
this.lastY = 0
this.options = {
color: '#000000',
size: 5,
opacity: 1,
lineCap: 'round',
lineJoin: 'round'
}
this.bindEvents()
}
bindEvents() {
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this))
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this))
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this))
this.canvas.addEventListener('mouseleave', this.onMouseUp.bind(this))
this.canvas.addEventListener('touchstart', this.onTouchStart.bind(this))
this.canvas.addEventListener('touchmove', this.onTouchMove.bind(this))
this.canvas.addEventListener('touchend', this.onTouchEnd.bind(this))
}
getPos(e) {
const rect = this.canvas.getBoundingClientRect()
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
}
getTouchPos(e) {
const rect = this.canvas.getBoundingClientRect()
const touch = e.touches[0]
return {
x: touch.clientX - rect.left,
y: touch.clientY - rect.top
}
}
onMouseDown(e) {
this.isDrawing = true
const pos = this.getPos(e)
this.lastX = pos.x
this.lastY = pos.y
this.ctx.beginPath()
this.ctx.arc(pos.x, pos.y, this.options.size / 2, 0, Math.PI * 2)
this.ctx.fillStyle = this.options.color
this.ctx.globalAlpha = this.options.opacity
this.ctx.fill()
this.ctx.globalAlpha = 1
}
onMouseMove(e) {
if (!this.isDrawing) return
const pos = this.getPos(e)
this.ctx.beginPath()
this.ctx.moveTo(this.lastX, this.lastY)
this.ctx.lineTo(pos.x, pos.y)
this.ctx.strokeStyle = this.options.color
this.ctx.lineWidth = this.options.size
this.ctx.lineCap = this.options.lineCap
this.ctx.lineJoin = this.options.lineJoin
this.ctx.globalAlpha = this.options.opacity
this.ctx.stroke()
this.ctx.globalAlpha = 1
this.lastX = pos.x
this.lastY = pos.y
}
onMouseUp() {
this.isDrawing = false
}
onTouchStart(e) {
e.preventDefault()
this.isDrawing = true
const pos = this.getTouchPos(e)
this.lastX = pos.x
this.lastY = pos.y
}
onTouchMove(e) {
e.preventDefault()
if (!this.isDrawing) return
const pos = this.getTouchPos(e)
this.ctx.beginPath()
this.ctx.moveTo(this.lastX, this.lastY)
this.ctx.lineTo(pos.x, pos.y)
this.ctx.strokeStyle = this.options.color
this.ctx.lineWidth = this.options.size
this.ctx.lineCap = this.options.lineCap
this.ctx.lineJoin = this.options.lineJoin
this.ctx.globalAlpha = this.options.opacity
this.ctx.stroke()
this.ctx.globalAlpha = 1
this.lastX = pos.x
this.lastY = pos.y
}
onTouchEnd() {
this.isDrawing = false
}
setColor(color) {
this.options.color = color
}
setSize(size) {
this.options.size = size
}
setOpacity(opacity) {
this.options.opacity = opacity
}
}
class EraserTool {
constructor(canvas) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.isDrawing = false
this.lastX = 0
this.lastY = 0
this.size = 20
this.bindEvents()
}
bindEvents() {
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this))
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this))
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this))
this.canvas.addEventListener('mouseleave', this.onMouseUp.bind(this))
}
onMouseDown(e) {
this.isDrawing = true
const pos = this.getPos(e)
this.lastX = pos.x
this.lastY = pos.y
this.ctx.save()
this.ctx.globalCompositeOperation = 'destination-out'
this.ctx.beginPath()
this.ctx.arc(pos.x, pos.y, this.size / 2, 0, Math.PI * 2)
this.ctx.fill()
this.ctx.restore()
}
onMouseMove(e) {
if (!this.isDrawing) return
const pos = this.getPos(e)
this.ctx.save()
this.ctx.globalCompositeOperation = 'destination-out'
this.ctx.beginPath()
this.ctx.moveTo(this.lastX, this.lastY)
this.ctx.lineTo(pos.x, pos.y)
this.ctx.lineWidth = this.size
this.ctx.lineCap = 'round'
this.ctx.stroke()
this.ctx.restore()
this.lastX = pos.x
this.lastY = pos.y
}
onMouseUp() {
this.isDrawing = false
}
getPos(e) {
const rect = this.canvas.getBoundingClientRect()
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
}
}
class HistoryManager {
constructor(maxHistory = 50) {
this.history = []
this.currentIndex = -1
this.maxHistory = maxHistory
}
push(state) {
this.history = this.history.slice(0, this.currentIndex + 1)
this.history.push(state)
if (this.history.length > this.maxHistory) {
this.history.shift()
} else {
this.currentIndex++
}
}
undo() {
if (this.currentIndex > 0) {
this.currentIndex--
return this.history[this.currentIndex]
}
return null
}
redo() {
if (this.currentIndex < this.history.length - 1) {
this.currentIndex++
return this.history[this.currentIndex]
}
return null
}
canUndo() {
return this.currentIndex > 0
}
canRedo() {
return this.currentIndex < this.history.length - 1
}
clear() {
this.history = []
this.currentIndex = -1
}
}
class DrawingBoard {
constructor(canvasId, options = {}) {
this.canvas = document.getElementById(canvasId)
this.ctx = this.canvas.getContext('2d')
this.options = {
backgroundColor: '#ffffff',
...options
}
this.history = new HistoryManager()
this.currentTool = null
this.tools = new Map()
this.init()
}
init() {
this.ctx.fillStyle = this.options.backgroundColor
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
this.saveState()
this.addTool('brush', new BrushTool(this.canvas))
this.addTool('eraser', new EraserTool(this.canvas))
this.setTool('brush')
this.bindKeyboard()
}
addTool(name, tool) {
this.tools.set(name, tool)
}
setTool(name) {
this.currentTool = this.tools.get(name)
}
saveState() {
const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)
this.history.push(imageData)
}
undo() {
const state = this.history.undo()
if (state) {
this.ctx.putImageData(state, 0, 0)
}
}
redo() {
const state = this.history.redo()
if (state) {
this.ctx.putImageData(state, 0, 0)
}
}
clear() {
this.saveState()
this.ctx.fillStyle = this.options.backgroundColor
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
}
bindKeyboard() {
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z') {
e.preventDefault()
if (e.shiftKey) {
this.redo()
} else {
this.undo()
}
}
}
})
}
exportImage(format = 'image/png') {
return this.canvas.toDataURL(format)
}
download(filename = 'drawing.png') {
const link = document.createElement('a')
link.download = filename
link.href = this.exportImage()
link.click()
}
}
class Layer {
constructor(width, height, name = 'Layer') {
this.canvas = document.createElement('canvas')
this.canvas.width = width
this.canvas.height = height
this.ctx = this.canvas.getContext('2d')
this.name = name
this.visible = true
this.opacity = 1
this.blendMode = 'source-over'
this.locked = false
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
}
getImageData() {
return this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)
}
putImageData(imageData) {
this.ctx.putImageData(imageData, 0, 0)
}
}
class LayerManager {
constructor(width, height) {
this.width = width
this.height = height
this.layers = []
this.activeLayerIndex = -1
}
addLayer(name) {
const layer = new Layer(this.width, this.height, name || `Layer ${this.layers.length + 1}`)
this.layers.push(layer)
this.activeLayerIndex = this.layers.length - 1
return layer
}
removeLayer(index) {
if (index >= 0 && index < this.layers.length) {
this.layers.splice(index, 1)
if (this.activeLayerIndex >= this.layers.length) {
this.activeLayerIndex = this.layers.length - 1
}
}
}
getActiveLayer() {
return this.layers[this.activeLayerIndex]
}
setActiveLayer(index) {
if (index >= 0 && index < this.layers.length) {
this.activeLayerIndex = index
}
}
moveLayer(fromIndex, toIndex) {
if (fromIndex >= 0 && fromIndex < this.layers.length &&
toIndex >= 0 && toIndex < this.layers.length) {
const layer = this.layers.splice(fromIndex, 1)[0]
this.layers.splice(toIndex, 0, layer)
}
}
mergeDown(index) {
if (index > 0 && index < this.layers.length) {
const upperLayer = this.layers[index]
const lowerLayer = this.layers[index - 1]
lowerLayer.ctx.globalAlpha = upperLayer.opacity
lowerLayer.ctx.globalCompositeOperation = upperLayer.blendMode
lowerLayer.ctx.drawImage(upperLayer.canvas, 0, 0)
lowerLayer.ctx.globalAlpha = 1
lowerLayer.ctx.globalCompositeOperation = 'source-over'
this.layers.splice(index, 1)
}
}
flatten() {
const result = new Layer(this.width, this.height, 'Merged')
this.layers.forEach(layer => {
if (layer.visible) {
result.ctx.globalAlpha = layer.opacity
result.ctx.globalCompositeOperation = layer.blendMode
result.ctx.drawImage(layer.canvas, 0, 0)
}
})
result.ctx.globalAlpha = 1
result.ctx.globalCompositeOperation = 'source-over'
return result
}
render(targetCtx) {
targetCtx.clearRect(0, 0, this.width, this.height)
this.layers.forEach(layer => {
if (layer.visible) {
targetCtx.globalAlpha = layer.opacity
targetCtx.globalCompositeOperation = layer.blendMode
targetCtx.drawImage(layer.canvas, 0, 0)
}
})
targetCtx.globalAlpha = 1
targetCtx.globalCompositeOperation = 'source-over'
}
}
class RectangleTool {
constructor(canvas, layerManager) {
this.canvas = canvas
this.layerManager = layerManager
this.isDrawing = false
this.startX = 0
this.startY = 0
this.options = {
fillColor: '#3498db',
strokeColor: '#2980b9',
strokeWidth: 2,
fill: true,
stroke: true,
cornerRadius: 0
}
this.tempCanvas = document.createElement('canvas')
this.tempCanvas.width = canvas.width
this.tempCanvas.height = canvas.height
this.tempCtx = this.tempCanvas.getContext('2d')
this.bindEvents()
}
bindEvents() {
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this))
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this))
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this))
}
onMouseDown(e) {
this.isDrawing = true
const pos = this.getPos(e)
this.startX = pos.x
this.startY = pos.y
const layer = this.layerManager.getActiveLayer()
if (layer) {
this.tempCtx.clearRect(0, 0, this.tempCanvas.width, this.tempCanvas.height)
this.tempCtx.drawImage(layer.canvas, 0, 0)
}
}
onMouseMove(e) {
if (!this.isDrawing) return
const pos = this.getPos(e)
const layer = this.layerManager.getActiveLayer()
if (layer) {
layer.clear()
layer.ctx.drawImage(this.tempCanvas, 0, 0)
const x = Math.min(this.startX, pos.x)
const y = Math.min(this.startY, pos.y)
const width = Math.abs(pos.x - this.startX)
const height = Math.abs(pos.y - this.startY)
this.drawRectangle(layer.ctx, x, y, width, height)
}
}
onMouseUp(e) {
this.isDrawing = false
}
drawRectangle(ctx, x, y, width, height) {
ctx.beginPath()
if (this.options.cornerRadius > 0) {
const r = Math.min(this.options.cornerRadius, width / 2, height / 2)
ctx.moveTo(x + r, y)
ctx.lineTo(x + width - r, y)
ctx.quadraticCurveTo(x + width, y, x + width, y + r)
ctx.lineTo(x + width, y + height - r)
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height)
ctx.lineTo(x + r, y + height)
ctx.quadraticCurveTo(x, y + height, x, y + height - r)
ctx.lineTo(x, y + r)
ctx.quadraticCurveTo(x, y, x + r, y)
} else {
ctx.rect(x, y, width, height)
}
if (this.options.fill) {
ctx.fillStyle = this.options.fillColor
ctx.fill()
}
if (this.options.stroke) {
ctx.strokeStyle = this.options.strokeColor
ctx.lineWidth = this.options.strokeWidth
ctx.stroke()
}
}
getPos(e) {
const rect = this.canvas.getBoundingClientRect()
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
}
}
class CircleTool {
constructor(canvas, layerManager) {
this.canvas = canvas
this.layerManager = layerManager
this.isDrawing = false
this.startX = 0
this.startY = 0
this.options = {
fillColor: '#e74c3c',
strokeColor: '#c0392b',
strokeWidth: 2,
fill: true,
stroke: true
}
this.tempCanvas = document.createElement('canvas')
this.tempCanvas.width = canvas.width
this.tempCanvas.height = canvas.height
this.tempCtx = this.tempCanvas.getContext('2d')
this.bindEvents()
}
bindEvents() {
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this))
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this))
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this))
}
onMouseDown(e) {
this.isDrawing = true
const pos = this.getPos(e)
this.startX = pos.x
this.startY = pos.y
const layer = this.layerManager.getActiveLayer()
if (layer) {
this.tempCtx.clearRect(0, 0, this.tempCanvas.width, this.tempCanvas.height)
this.tempCtx.drawImage(layer.canvas, 0, 0)
}
}
onMouseMove(e) {
if (!this.isDrawing) return
const pos = this.getPos(e)
const layer = this.layerManager.getActiveLayer()
if (layer) {
layer.clear()
layer.ctx.drawImage(this.tempCanvas, 0, 0)
const dx = pos.x - this.startX
const dy = pos.y - this.startY
const radius = Math.sqrt(dx * dx + dy * dy)
layer.ctx.beginPath()
layer.ctx.arc(this.startX, this.startY, radius, 0, Math.PI * 2)
if (this.options.fill) {
layer.ctx.fillStyle = this.options.fillColor
layer.ctx.fill()
}
if (this.options.stroke) {
layer.ctx.strokeStyle = this.options.strokeColor
layer.ctx.lineWidth = this.options.strokeWidth
layer.ctx.stroke()
}
}
}
onMouseUp() {
this.isDrawing = false
}
getPos(e) {
const rect = this.canvas.getBoundingClientRect()
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
}
}
class TextTool {
constructor(canvas, layerManager) {
this.canvas = canvas
this.layerManager = layerManager
this.options = {
fontFamily: 'Arial',
fontSize: 24,
fontWeight: 'normal',
fontStyle: 'normal',
color: '#000000',
align: 'left',
baseline: 'top'
}
this.input = null
this.bindEvents()
}
bindEvents() {
this.canvas.addEventListener('click', this.onClick.bind(this))
}
onClick(e) {
if (this.input) {
this.commitText()
return
}
const pos = this.getPos(e)
this.createInput(pos.x, pos.y)
}
createInput(x, y) {
this.input = document.createElement('input')
this.input.type = 'text'
this.input.style.position = 'absolute'
this.input.style.left = `${x}px`
this.input.style.top = `${y}px`
this.input.style.fontFamily = this.options.fontFamily
this.input.style.fontSize = `${this.options.fontSize}px`
this.input.style.fontWeight = this.options.fontWeight
this.input.style.fontStyle = this.options.fontStyle
this.input.style.color = this.options.color
this.input.style.background = 'transparent'
this.input.style.border = '1px dashed #3498db'
this.input.style.outline = 'none'
this.input.style.padding = '2px'
this.canvas.parentElement.appendChild(this.input)
this.input.focus()
this.input.addEventListener('blur', () => this.commitText())
this.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.commitText()
}
})
}
commitText() {
if (!this.input || !this.input.value) {
this.removeInput()
return
}
const layer = this.layerManager.getActiveLayer()
if (layer) {
const rect = this.input.getBoundingClientRect()
const canvasRect = this.canvas.getBoundingClientRect()
const x = rect.left - canvasRect.left
const y = rect.top - canvasRect.top
layer.ctx.font = `${this.options.fontStyle} ${this.options.fontWeight} ${this.options.fontSize}px ${this.options.fontFamily}`
layer.ctx.fillStyle = this.options.color
layer.ctx.textAlign = this.options.align
layer.ctx.textBaseline = this.options.baseline
layer.ctx.fillText(this.input.value, x, y)
}
this.removeInput()
}
removeInput() {
if (this.input) {
this.input.remove()
this.input = null
}
}
getPos(e) {
const rect = this.canvas.getBoundingClientRect()
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
}
}
class SelectionTool {
constructor(canvas) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.selection = null
this.isSelecting = false
this.startX = 0
this.startY = 0
this.marchingAntsOffset = 0
this.bindEvents()
this.startMarchingAnts()
}
bindEvents() {
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this))
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this))
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this))
}
onMouseDown(e) {
const pos = this.getPos(e)
if (this.selection && this.isInsideSelection(pos.x, pos.y)) {
this.startMove(pos.x, pos.y)
} else {
this.isSelecting = true
this.startX = pos.x
this.startY = pos.y
this.selection = null
}
}
onMouseMove(e) {
const pos = this.getPos(e)
if (this.isSelecting) {
this.selection = {
x: Math.min(this.startX, pos.x),
y: Math.min(this.startY, pos.y),
width: Math.abs(pos.x - this.startX),
height: Math.abs(pos.y - this.startY)
}
}
}
onMouseUp() {
this.isSelecting = false
}
isInsideSelection(x, y) {
if (!this.selection) return false
return x >= this.selection.x &&
x <= this.selection.x + this.selection.width &&
y >= this.selection.y &&
y <= this.selection.y + this.selection.height
}
startMarchingAnts() {
const animate = () => {
this.marchingAntsOffset = (this.marchingAntsOffset + 1) % 16
requestAnimationFrame(animate)
}
animate()
}
drawMarchingAnts() {
if (!this.selection) return
this.ctx.save()
this.ctx.strokeStyle = '#000'
this.ctx.lineWidth = 1
this.ctx.setLineDash([4, 4])
this.ctx.lineDashOffset = -this.marchingAntsOffset
this.ctx.strokeRect(
this.selection.x,
this.selection.y,
this.selection.width,
this.selection.height
)
this.ctx.strokeStyle = '#fff'
this.ctx.lineDashOffset = -this.marchingAntsOffset + 4
this.ctx.strokeRect(
this.selection.x,
this.selection.y,
this.selection.width,
this.selection.height
)
this.ctx.restore()
}
clearSelection() {
this.selection = null
}
getPos(e) {
const rect = this.canvas.getBoundingClientRect()
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
}
}
class ImageIO {
constructor(drawingBoard) {
this.drawingBoard = drawingBoard
}
loadImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
this.drawingBoard.ctx.drawImage(img, 0, 0)
this.drawingBoard.saveState()
resolve(img)
}
img.onerror = reject
img.src = e.target.result
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
loadImageFromURL(url) {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
this.drawingBoard.ctx.drawImage(img, 0, 0)
this.drawingBoard.saveState()
resolve(img)
}
img.onerror = reject
img.src = url
})
}
exportAsPNG() {
return this.drawingBoard.canvas.toDataURL('image/png')
}
exportAsJPEG(quality = 0.9) {
return this.drawingBoard.canvas.toDataURL('image/jpeg', quality)
}
download(filename = 'drawing.png', format = 'image/png') {
const link = document.createElement('a')
link.download = filename
link.href = this.drawingBoard.canvas.toDataURL(format)
link.click()
}
}
class AdvancedDrawingBoard {
constructor(canvasId, options = {}) {
this.canvas = document.getElementById(canvasId)
this.ctx = this.canvas.getContext('2d')
this.options = {
width: 800,
height: 600,
backgroundColor: '#ffffff',
...options
}
this.canvas.width = this.options.width
this.canvas.height = this.options.height
this.layerManager = new LayerManager(this.options.width, this.options.height)
this.layerManager.addLayer('Background')
this.history = new HistoryManager()
this.tools = new Map()
this.currentTool = null
this.init()
}
init() {
this.addTool('brush', new BrushTool(this.canvas))
this.addTool('eraser', new EraserTool(this.canvas))
this.addTool('rectangle', new RectangleTool(this.canvas, this.layerManager))
this.addTool('circle', new CircleTool(this.canvas, this.layerManager))
this.addTool('text', new TextTool(this.canvas, this.layerManager))
this.setTool('brush')
this.bindKeyboard()
this.ctx.fillStyle = this.options.backgroundColor
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
this.saveState()
}
addTool(name, tool) {
this.tools.set(name, tool)
}
setTool(name) {
this.currentTool = this.tools.get(name)
}
saveState() {
const layer = this.layerManager.getActiveLayer()
if (layer) {
this.history.push(layer.getImageData())
}
}
undo() {
const state = this.history.undo()
if (state) {
const layer = this.layerManager.getActiveLayer()
if (layer) {
layer.putImageData(state)
}
}
}
redo() {
const state = this.history.redo()
if (state) {
const layer = this.layerManager.getActiveLayer()
if (layer) {
layer.putImageData(state)
}
}
}
clear() {
const layer = this.layerManager.getActiveLayer()
if (layer) {
this.saveState()
layer.clear()
}
}
bindKeyboard() {
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
switch (e.key.toLowerCase()) {
case 'z':
e.preventDefault()
e.shiftKey ? this.redo() : this.undo()
break
case 's':
e.preventDefault()
this.download()
break
}
}
})
}
download(filename = 'drawing.png') {
const mergedLayer = this.layerManager.flatten()
const link = document.createElement('a')
link.download = filename
link.href = mergedLayer.canvas.toDataURL('image/png')
link.click()
}
}
绘图板应用开发要点:
通过组合这些功能,可以开发出功能完善的绘图板应用。