持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情
前言
最近看到女朋友公司有很多电子文档需要签字,发现都是用的canvas实现的,那就有了一个想法,可不可以弄个canvas画板,不仅可以写字,还能画画呢?
看看我画的丁老头吧
额。。。貌似。。好像。。可能。。不太好看哦。。😂😂😂
技术说明
👉 文章中的代码是用的uniapp,当然你可以改成你自己的HTML或者小程序写法,因为canvas技术都是一样的,只是包装起来的框架不一样而已。
先来做个画布吧
👉 页面代码
<view class="tn-sign-board__content">
<view class="tn-sign-board__content__wrapper">
<canvas class="tn-sign-board__content__canvas" :canvas-id="canvasName" :disableScroll="true" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd"></canvas>
</view>
</view>
-
👆
canvasId需要双向数据绑定的哦,为了后面方法中直接获取 -
👆 由于canvas限制大小,需要使用
disableScroll将画布设置为禁止滚动 -
👆 在canvas中设置了三个触摸事件
touchstart、touchmove、touchend
👉 页面上除了需要画布之外,还需要不同颜色的画笔哦
<view class="tn-sign-board__tools__color">
<view
v-for="(item, index) in signSelectColor"
:key="index"
class="tn-sign-board__tools__color__item"
:class="[{'tn-sign-board__tools__color__item--active': currentSelectColor === item}]"
:style="{backgroundColor: item}"
@tap="colorSwitch(item)"
></view>
</view>
signSelectColor用的是一个数组哦,数组里面就是配置的所有颜色currentSelectColor用来匹配画笔颜色的哦,默认就是第一个颜色了
data(){
return {
signSelectColor: ['#999888', '#E83A30', '#E73983', '#F88011', '#DDD912', '#01BEFF'],
currentSelectColor: '#999888'
}
}
写画布和画笔逻辑
👉 定义所有相关字段
data() {
return {
canvasName: 'tn-sign-canvas',
ctx: null,
canvasWidth: 300,
canvasHeight: 800,
// 第一次触摸
firstTouch: false,
// 透明度
transparent: 1,
// 笔迹倍数
lineSize: 1.5,
// 最小画笔半径
minLine: 0.5,
// 最大画笔半径
maxLine: 4,
// 画笔压力
pressure: 1,
// 顺滑度,用60的距离来计算速度
smoothness: 60,
// 当前触摸的点
currentPoint: {},
// 当前线条
currentLine: [],
// 画笔圆半径
radius: 1,
// 裁剪区域
cutArea: {
top: 0,
right: 0,
bottom: 0,
left: 0
},
// 上一个点
lastPoint: 0,
// 笔迹
chirography: [],
// 画线轨迹,生成线条的实际点
linePrack: []
}
}
👍 在created生命周期函数中创建canvas画布
created() {
// 创建canvas
this.ctx = uni.createCanvasContext(this.canvasName, this)
// 初始化Canvas
this.$nextTick(() => {
this.initCanvas('#FFFFFF')
})
}
👍 在methods中创建初始化的方法
methods: {
// 初始化Canvas
initCanvas(color) {
/* 将canvas背景设置为 白底,不设置 导出的canvas的背景为透明 */
// rect() 参数说明 矩形路径左上角的横坐标,左上角的纵坐标, 矩形路径的宽度, 矩形路径的高度
// 矩形的宽高需要减去边框的宽度
this.ctx.rect(0, 0, this.canvasWidth - uni.upx2px(4), this.canvasHeight - uni.upx2px(4))
this.ctx.setFillStyle(color)
this.ctx.fill()
this.ctx.draw()
},
}
👍 在canvas中定义了好几个画布的方法也需要在methods中创建哦
// 开始画
onTouchStart(e) {
if (e.type != 'touchstart') return false
// 设置线条颜色
this.ctx.setFillStyle(this.currentSelectColor)
// 设置透明度
this.ctx.setGlobalAlpha(this.transparent)
let currentPoint = {
x: e.touches[0].x,
y: e.touches[0].y
}
let currentLine = this.currentLine
currentLine.unshift({
time: new Date().getTime(),
dis: 0,
x: currentPoint.x,
y: currentPoint.y
})
this.currentPoint = currentPoint
if (this.firstTouch) {
this.cutArea = {
top: currentPoint.y,
right: currentPoint.x,
bottom: currentPoint.y,
left: currentPoint.x
}
this.firstTouch = false
}
this.pointToLine(currentLine)
},
// 正在画
onTouchMove(e) {
if (e.type != 'touchmove') return false
if (e.cancelable) {
// 判断默认行为是否已经被禁用
if (!e.defaultPrevented) {
e.preventDefault()
}
}
let point = {
x: e.touches[0].x,
y: e.touches[0].y
}
if (point.y < this.cutArea.top) {
this.cutArea.top = point.y
}
if (point.y < 0) this.cutArea.top = 0
if (point.x < this.cutArea.right) {
this.cutArea.right = point.x
}
if (this.canvasWidth - point.x <= 0) {
this.cutArea.right = this.canvasWidth
}
if (point.y > this.cutArea.bottom) {
this.cutArea.bottom = this.canvasHeight
}
if (this.canvasHeight - point.y <= 0) {
this.cutArea.bottom = this.canvasHeight
}
if (point.x < this.cutArea.left) {
this.cutArea.left = point.x
}
if (point.x < 0) this.cutArea.left = 0
this.lastPoint = this.currentPoint
this.currentPoint = point
let currentLine = this.currentLine
currentLine.unshift({
time: new Date().getTime(),
dis: this.distance(this.currentPoint, this.lastPoint),
x: point.x,
y: point.y
})
this.pointToLine(currentLine)
},
// 移动结束
onTouchEnd(e) {
if (e.type != 'touchend') return false
let point = {
x: e.changedTouches[0].x,
y: e.changedTouches[0].y
}
this.lastPoint = this.currentPoint
this.currentPoint = point
let currentLine = this.currentLine
currentLine.unshift({
time: new Date().getTime(),
dis: this.distance(this.currentPoint, this.lastPoint),
x: point.x,
y: point.y
})
//一笔结束,保存笔迹的坐标点,清空,当前笔迹
//增加判断是否在手写区域
this.pointToLine(currentLine)
let currentChirography = {
lineSize: this.lineSize,
lineColor: this.currentSelectColor
}
let chirography = this.chirography
chirography.unshift(currentChirography)
this.chirography = chirography
let linePrack = this.linePrack
linePrack.unshift(this.currentLine)
this.linePrack = linePrack
this.currentLine = []
}
👍 画笔中还有切换的事件哦
// 切换画笔颜色
colorSwitch(color) {
this.currentSelectColor = color
}
👍 绘制两点之间的线条时,还需要计算中间的插值,让线条变得更加顺滑
// 绘制两点之间的线条
pointToLine(line) {
this.calcBethelLine(line)
},
// 计算插值,让线条更加圆滑
calcBethelLine(line) {
if (line.length <= 1) {
line[0].r = this.radius
return
}
let x0,
x1,
x2,
y0,
y1,
y2,
r0,
r1,
r2,
len,
lastRadius,
dis = 0,
time = 0,
curveValue = 0.5;
if (line.length <= 2) {
x0 = line[1].x
y0 = line[1].y
x2 = line[1].x + (line[0].x - line[1].x) * curveValue
y2 = line[1].y + (line[0].y - line[1].y) * curveValue
x1 = x0 + (x2 - x0) * curveValue
y1 = y0 + (y2 - y0) * curveValue
} else {
x0 = line[2].x + (line[1].x - line[2].x) * curveValue
y0 = line[2].y + (line[1].y - line[2].y) * curveValue
x1 = line[1].x
y1 = line[1].y
x2 = x1 + (line[0].x - x1) * curveValue
y2 = y1 + (line[0].y - y1) * curveValue
}
// 三个点分别是(x0,y0),(x1,y1),(x2,y2) ;(x1,y1)这个是控制点,控制点不会落在曲线上;实际上,这个点还会手写获取的实际点,却落在曲线上
len = this.distance({
x: x2,
y: y2
}, {
x: x0,
y: y0
})
lastRadius = this.radius
for (let i = 0; i < line.length - 1; i++) {
dis += line[i].dis
time += line[i].time - line[i + 1].time
if (dis > this.smoothness) break
}
this.radius = Math.min((time / len) * this.pressure + this.minLine, this.maxLine) * this.lineSize
line[0].r = this.radius
// 计算笔迹半径
if (line.length <= 2) {
r0 = (lastRadius + this.radius) / 2
r1 = r0
r2 = r1
} else {
r0 = (line[2].r + line[1].r) / 2
r1 = line[1].r
r2 = (line[1].r + line[0].r) / 2
}
let n = 5
let point = []
for (let i = 0; i < n; i++) {
let t = i / (n - 1)
let x = (1 - t) * (1 - t) * x0 + 2 * t * (1 - t) * x1 + t * t * x2
let y = (1 - t) * (1 - t) * y0 + 2 * t * (1 - t) * y1 + t * t * y2
let r = lastRadius + ((this.radius - lastRadius) / n) * i
point.push({
x,
y,
r
})
if (point.length === 3) {
let a = this.ctaCalc(point[0].x, point[0].y, point[0].r, point[1].x, point[1].y, point[1].r, point[2].x, point[2].y, point[2].r)
a[0].color = this.currentSelectColor
this.drawBethel(a, true)
point = [{
x,
y,
r
}]
}
}
this.currentLine = line
}
👍 画了线条就得计算两点之间的距离
// 求两点之间的距离
distance(a, b) {
let x = b.x - a.x
let y = b.y - a.y
return Math.sqrt(x * x + y * y)
},
// 计算点信息
ctaCalc(x0, y0, r0, x1, y1, r1, x2, y2, r2) {
let a = [],
vx01,
vy01,
norm,
n_x0,
n_y0,
vx21,
vy21,
n_x2,
n_y2;
vx01 = x1 - x0
vy01 = y1 - y0
norm = Math.sqrt(vx01 * vx01 + vy01 * vy01 + 0.0001) * 2
vx01 = (vx01 / norm) * r0
vy01 = (vy01 / norm) * r0
n_x0 = vy01
n_y0 = -vx01
vx21 = x1 - x2
vy21 = y1 - y2
norm = Math.sqrt(vx21 * vx21 + vy21 * vy21 + 0.0001) * 2
vx21 = (vx21 / norm) * r2
vy21 = (vy21 / norm) * r2
n_x2 = -vy21
n_y2 = vx21
a.push({
mx: x0 + n_x0,
my: y0 + n_y0,
color: '#080808'
})
a.push({
c1x: x1 + n_x0,
c1y: y1 + n_y0,
c2x: x1 + n_x2,
c2y: y1 + n_y2,
ex: x2 + n_x2,
ey: y2 + n_y2
})
a.push({
c1x: x2 + n_x2 - vx21,
c1y: y2 + n_y2 - vy21,
c2x: x2 - n_x2 - vx21,
c2y: y2 - n_y2 - vy21,
ex: x2 - n_x2,
ey: y2 - n_y2
})
a.push({
c1x: x1 - n_x2,
c1y: y1 - n_y2,
c2x: x1 - n_x0,
c2y: y1 - n_y0,
ex: x0 - n_x0,
ey: y0 - n_y0
})
a.push({
c1x: x0 - n_x0 - vx01,
c1y: y0 - n_y0 - vy01,
c2x: x0 + n_x0 - vx01,
c2y: y0 + n_y0 - vy01,
ex: x0 + n_x0,
ey: y0 + n_y0
})
a[0].mx = a[0].mx.toFixed(1)
a[0].mx = parseFloat(a[0].mx)
a[0].my = a[0].my.toFixed(1)
a[0].my = parseFloat(a[0].my)
for (let i = 1; i < a.length; i++) {
a[i].c1x = a[i].c1x.toFixed(1)
a[i].c1x = parseFloat(a[i].c1x)
a[i].c1y = a[i].c1y.toFixed(1)
a[i].c1y = parseFloat(a[i].c1y)
a[i].c2x = a[i].c2x.toFixed(1)
a[i].c2x = parseFloat(a[i].c2x)
a[i].c2y = a[i].c2y.toFixed(1)
a[i].c2y = parseFloat(a[i].c2y)
a[i].ex = a[i].ex.toFixed(1)
a[i].ex = parseFloat(a[i].ex)
a[i].ey = a[i].ey.toFixed(1)
a[i].ey = parseFloat(a[i].ey)
}
return a
}
👍 最关键的一步当然是绘制贝塞尔曲线
// 绘制贝塞尔曲线
drawBethel(point, is_fill, color) {
this.ctx.beginPath()
this.ctx.moveTo(point[0].mx, point[0].my)
if (color != undefined) {
this.ctx.setFillStyle(color)
this.ctx.setStrokeStyle(color)
} else {
this.ctx.setFillStyle(point[0].color)
this.ctx.setStrokeStyle(point[0].color)
}
for (let i = 1; i < point.length; i++) {
this.ctx.bezierCurveTo(point[i].c1x, point[i].c1y, point[i].c2x, point[i].c2y, point[i].ex, point[i].ey)
}
this.ctx.stroke()
if (is_fill != undefined) {
//填充图形 ( 后绘制的图形会覆盖前面的图形, 绘制时注意先后顺序 )
this.ctx.fill()
}
this.ctx.draw(true)
}
写个字吧
可以画画之后,就需要变成图片保存下来咯
👉 在页面上放几个按钮吧
<view class="tn-sign-board__tools__button">
<view class="tn-sign-board__tools__button__item tn-bg-red" @tap="reDraw">清除</view>
<view class="tn-sign-board__tools__button__item tn-bg-blue" @tap="save">保存</view>
<view class="tn-sign-board__tools__button__item tn-bg-indigo" @tap="previewImage">预览</view>
</view>
👉 清空画布事件,直接调用初始化画布方法就可以
// 重置绘画板
reDraw() {
this.initCanvas('#FFFFFF')
}
👉 保存到本地事件
// 保存
save() {
// 在组件内使用需要第二个参数this
uni.canvasToTempFilePath({
canvasId: this.canvasName,
fileType: 'png',
quality: 1,
success: (res) => {
if (this.rotate) {
this.getRotateImage(res.tempFilePath).then((res) => {
this.$emit('save', res)
}).catch(err => {
this.$t.message.toast('旋转图片失败')
})
} else {
this.$emit('save', res.tempFilePath)
}
},
fail: () => {
this.$t.message.toast('保存失败')
}
}, this)
}
👉 预览图片事件
// 预览图片
previewImage() {
// 在组件内使用需要第二个参数this
uni.canvasToTempFilePath({
canvasId: this.canvasName,
fileType: 'png',
quality: 1,
success: (res) => {
if (this.rotate) {
this.getRotateImage(res.tempFilePath).then((res) => {
uni.previewImage({
urls: [res]
})
}).catch(err => {
this.$t.message.toast('旋转图片失败')
})
} else {
uni.previewImage({
urls: [res.tempFilePath]
})
}
},
fail: (e) => {
this.$t.message.toast('预览失败')
}
}, this)
}
👉 预览保存时,可选旋转图片
// 旋转图片
async getRotateImage(dataUrl) {
const url = dataUrl
// 创建新画布
const tempCtx = uni.createCanvasContext('temp-tn-sign-canvas', this)
const width = this.canvasWidth
const height = this.canvasHeight
tempCtx.restore()
tempCtx.save()
tempCtx.translate(0, height)
tempCtx.rotate(270 * Math.PI / 180)
tempCtx.drawImage(url, 0, 0, width, height)
tempCtx.draw()
return new Promise((resolve, reject) => {
setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: 'temp-tn-sign-canvas',
fileType: 'png',
x: 0,
y: height - width,
width: height,
height: width,
success: res => resolve(res.tempFilePath),
fail: reject
}, this)
}, 50)
})
}
页面样式(只有部分样式,其他的可以根据实际需求修改)
<style lang="scss">
.tn-sign-board {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background-color: #E6E6E6;
z-index: 997;
display: flex;
flex-direction: row-reverse;
&__content {
width: 84%;
height: 100%;
&__wrapper {
width: calc(100% - 60rpx);
height: calc(100% - 60rpx);
margin: 30rpx;
border-radius: 20rpx;
border: 2rpx dotted #AAAAAA;
overflow: hidden;
}
&__canvas {
width: 100%;
height: 100%;
background-color: #FFFFFF;
}
}
&__tools {
width: 16%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
&__color {
margin-top: 30rpx;
&__item {
width: 70rpx;
height: 70rpx;
border-radius: 100rpx;
margin: 20rpx auto;
&--active {
position: relative;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 40%;
height: 40%;
border-radius: 100rpx;
background-color: #FFFFFF;
transform: translate(-50%, -50%);
}
}
}
}
&__button {
margin-bottom: 30rpx;
display: flex;
flex-direction: column;
&__item {
width: 130rpx;
height: 60rpx;
line-height: 60rpx;
text-align: center;
margin: 60rpx auto;
border-radius: 10rpx;
color: #FFFFFF;
transform-origin: center center;
transform: rotateZ(90deg);
}
}
}
}
</style>
总结
还不快去给你女朋友画一张画像吧,看她会不会打死你~~~😂😂😂