此插件基于Vue撞芍。每一大章節(jié)都有對(duì)應(yīng)的源碼和DEMO夜牡。
前兩天在公司項(xiàng)目上遇到了一個(gè)簽署合同的需求芳誓,詳細(xì)是在PAD端實(shí)現(xiàn)手寫板完成名字簽署,不需要考慮筆畫粗細(xì)、顏色等兔簇。效果如下圖所示。
實(shí)現(xiàn)起來很簡(jiǎn)單硬耍,于是就自己寫了一個(gè)小插件垄琐。此篇文章就在此基礎(chǔ)上對(duì)筆畫粗細(xì)、顏色等加以擴(kuò)展经柴,寫一個(gè)移動(dòng)端的手寫板狸窘。
實(shí)現(xiàn)原理
對(duì)touch和canvas有所了解的可以略過本節(jié)。
在畫板上寫字坯认,整個(gè)寫字的過程大概分為三部分翻擒,分別為落筆、運(yùn)筆牛哺、提筆(自己瞎概括的)陋气。這三個(gè)步驟分別對(duì)應(yīng)了三個(gè)移動(dòng)端的監(jiān)聽方法。其中
- 落筆對(duì)應(yīng)的為:
touchstart
引润,此方法當(dāng)手指觸摸到屏幕時(shí)觸發(fā)巩趁。 - 運(yùn)筆對(duì)應(yīng)的為:
touchmove
,此方法當(dāng)手指在屏幕上滑動(dòng)時(shí)觸發(fā)淳附。 - 提筆對(duì)應(yīng)的為:
touchend
议慰,此方法當(dāng)手指離開屏幕時(shí)觸發(fā)。
運(yùn)行這三個(gè)方法就可以模擬寫字的整個(gè)過程奴曙。
我想應(yīng)該學(xué)會(huì)了如何在移動(dòng)端控制自己手指觸控屏幕的過程别凹,但是現(xiàn)在還沒有筆跡,沒有畫板洽糟,所以我們需要先通過canvas構(gòu)建一個(gè)畫板炉菲,然后生成筆跡。下面介紹一下這個(gè)插件所需要用到的canvas相關(guān)方法脊框。
Canvas
- 方法
-
getContext('2d')
獲取一個(gè)context 2d對(duì)象颁督,即渲染上下文,其中有很多畫圖相關(guān)的方法浇雹,對(duì)應(yīng)的還有3d對(duì)象沉御。 -
beginPath()
開始繪制路徑 -
lineTo()
線段的終點(diǎn) -
moveTo()
線段的地點(diǎn) -
stroke()
給線段上色 -
closePath()
結(jié)束路徑繪制 -
clearRect(x1, y1, x2, y2)
清空一定范圍內(nèi)的內(nèi)容 -
toDataURL()
將Canvas數(shù)據(jù)重新轉(zhuǎn)化成圖片文件
- 線條相關(guān)屬性
-
strokeStyle
線條顏色 -
lineWidth
線條寬度 -
lineCap
線條結(jié)束線帽- butt:默認(rèn)值。平直邊緣
- round:圓形線帽
- square:正方形線帽
-
lineJoin
線條轉(zhuǎn)彎處的線帽- 值類型同上昭灵。
插件編寫
搭架子
首先我們先搭起一個(gè)大的架子吠裆。其中需要通過props傳入的值均先由data替代伐谈。
<template>
<div class="hand-writing">
<canvas
ref="writingCanvas"
class="writing-box"
:width="canvasWidth"
:height="canvasHeight"
@touchstart="onStart"
@touchmove="onMove"
@touchend="onEnd">
</canvas>
<div class="btn-box">
<div class="btn btn-clear" @click="onClear">清屏</div>
<div class="btn btn-generate" @click="onGenerate">生成</div>
</div>
</div>
</template>
<script>
export default {
name: 'HandWriting',
data: function() {
return {
// 畫板坐標(biāo)
offsetWidth: 0,
offsetHeight: 0,
// 畫板寬度
canvasWidth: '',
// 畫板高度
canvasHeight: '',
// 線條寬度
lineWidth: 10,
// 線條顏色
lineColor: '#000',
};
},
mounted() {
this.init()
},
methods: {
// 畫板初始化
init () {},
// 開始觸摸
onStart (e) {},
// 移動(dòng)
onMove (e) {},
// 停止觸摸
onEnd (e) {},
// 點(diǎn)擊取消
onClear () {},
// 點(diǎn)擊確認(rèn)
onGenerate () {},
},
}
</script>
<style lang="css" scoped>
.hand-writing {
width: 100%;
height: 100%;
background: #fff;
}
.writing-box {
display: block;
margin: 0 auto;
width: 100%;
height: 80%;
background: #ccc;
}
.btn-box {
margin: 0 auto;
width: 300px;
height: 20%;
}
.btn {
box-sizing: border-box;
margin: 20px 25px;
display: inline-block;
width: 100px;
height: 50px;
border: 1px solid #1890ff;
border-radius: 10px;
background: #1890ff;
color: #fff;
text-align: center;
line-height: 50px;
}
.btn:active {
background: #fff;
color: #000;
}
</style>
這樣一個(gè)架子就搭完了,效果如下圖所示试疙。
初始化
然后我們開始進(jìn)入整體诵棵,首先需要獲取canvas的context對(duì)象。在init()
方法中添加如下代碼
init () {
// 獲取canvas
const canvas = this.$refs.writingCanvas
// 顯式的寬高賦值
this.canvasWidth = canvas.offsetWidth
this.canvasHeight = canvas.offsetHeight
// 獲取context對(duì)象
this.ctx = canvas.getContext('2d')
},
onStart
獲取到了context對(duì)象就可以開始畫東西了祝旷。在onStart()
方法中添加如下代碼
onStart (e) {
// 獲取畫板相對(duì)于屏幕的偏移量履澳,即左上角的坐標(biāo)
this.offsetLeft = e.target.offsetLeft
this.offsetTop = e.target.offsetTop
// 獲取點(diǎn)擊點(diǎn)的坐標(biāo)(實(shí)際坐標(biāo) = 點(diǎn)擊點(diǎn)相對(duì)于屏幕的坐標(biāo) - 畫板相對(duì)于屏幕的坐標(biāo))
let x = e.touches[0].clientX - this.offsetLeft
let y = e.touches[0].clientY - this.offsetTop
// 開始繪制
this.ctx.beginPath()
// 設(shè)置線條屬性
this.ctx.lineWidth = this.lineWidth
this.ctx.strokeStyle = this.lineColor
this.ctx.lineCap = 'round'
this.ctx.lineJoin = 'round'
// 繪制點(diǎn)擊點(diǎn)
this.ctx.lineTo(x, y)
this.ctx.stroke()
},
onMove
現(xiàn)在調(diào)試這個(gè)DEMO,就可以發(fā)現(xiàn)已經(jīng)可以在畫板中點(diǎn)擊畫點(diǎn)了怀跛。下面開始讓這個(gè)點(diǎn)移動(dòng)起來距贷。在onMove()
方法中添加如下代碼
onMove (e) {
// 獲取點(diǎn)擊點(diǎn)的坐標(biāo)
let x = e.touches[0].clientX - this.offsetLeft
let y = e.touches[0].clientY - this.offsetTop
// 繪制
this.ctx.lineTo(x, y)
this.ctx.stroke()
},
onEnd
現(xiàn)在再調(diào)試,已經(jīng)可以正常的畫線了吻谋,但是有始有終忠蝗,當(dāng)我們停止手指觸摸時(shí),應(yīng)該關(guān)閉路徑繪制漓拾。在onEnd()
方法中添加如下代碼
onEnd () {
// 停止繪制
this.ctx.closePath()
},
onClear
到此畫圖的部分已經(jīng)做完了阁最,下面我們來實(shí)現(xiàn)清除畫板功能和生成圖片功能。首先是清除功能骇两。在onClear()
方法中添加如下代碼
onClear () {
// 清空畫板
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
},
onGenerate
然后來添加生成圖片的功能速种。在onGenerate()
方法中添加如下代碼
onGenerate () {
const filePath = this.$refs.writingCanvas.toDataURL()
console.log(filePath)
},
圖片顯示
點(diǎn)擊生成按鈕就可以在控制臺(tái)看到打印的png類型的圖片的base64地址。現(xiàn)在讓我們將其顯示在屏幕上低千。修改整體代碼如下
<template>
<div class="hand-writing">
<img v-if="filePath" :src="filePath" alt="">
<canvas
v-else
ref="writingCanvas"
class="writing-box"
:width="canvasWidth"
:height="canvasHeight"
@touchstart="onStart"
@touchmove="onMove"
@touchend="onEnd">
</canvas>
<div class="btn-box">
<div class="btn btn-clear" @click="onClear">清屏</div>
<div class="btn btn-generate" @click="onGenerate">生成</div>
</div>
</div>
</template>
<script>
export default {
name: 'HandWriting',
data: function() {
return {
...
// 圖片地址
filePath: '',
};
},
mounted() {
this.init()
},
methods: {
...
// 點(diǎn)擊取消
onClear () {
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
this.filePath = ''
// 清空filePath會(huì)造成DOM更新哟旗,而DOM更新會(huì)有延遲,所以放在nextTick中在DOM更新完后執(zhí)行
// 清空filePath會(huì)讓canvas重新生成栋操,所以需要重新初始化
this.$nextTick(() => {
this.init()
})
},
// 點(diǎn)擊確認(rèn)
onGenerate () {
// 當(dāng)生成之后再點(diǎn)擊將無效
this.filePath = this.filePath ? this.filePath : this.$refs.writingCanvas.toDataURL()
},
},
}
</script>
代碼優(yōu)化
可以看到剛剛寫的代碼非常冗余闸餐,讓我們優(yōu)化一下》剑可以將所有的繪制提取出來舍沙,如下
// 將onStart 和 onMove中的公共代碼提取出來
handleDraw (e) {
// 獲取點(diǎn)擊點(diǎn)的坐標(biāo)
let x = e.touches[0].clientX - this.offsetLeft
let y = e.touches[0].clientY - this.offsetTop
// 繪制
this.ctx.lineTo(x, y)
this.ctx.stroke()
},
作為一個(gè)插件我們需要提供一些暴露給外部的方法,變量剔宪,以及需要接收一些變量拂铡,更改如下
export default {
name: 'HandWriting',
props: {
path: {
type: String,
default: '',
}
},
...
methods: {
// 畫板初始化
init () {
...
if (this.path !== '') {
this.filePath = this.path
}
},
...
// 點(diǎn)擊取消
onClear () {
...
// 清除的回調(diào)
this.$emit('onClear')
},
// 點(diǎn)擊確認(rèn)
onGenerate () {
if(this.filePath) {
this.filePath = this.filePath
} else {
this.filePath = this.$refs.writingCanvas.toDataURL()
// 生成圖片的回調(diào)
this.$emit('onComplete', this.filePath)
}
},
},
}
這里props只接收了圖片,當(dāng)然也可以接收canvas的寬度葱绒、高度等感帅。方法都一樣,就不再贅述地淀。
在線預(yù)覽&源碼
預(yù)覽請(qǐng)打開控制臺(tái)在手機(jī)模式下運(yùn)行失球。筆畫位置有誤差請(qǐng)刷新瀏覽器。
DEMO
源碼:GitHub
功能擴(kuò)展
此章節(jié)開始所有的ui及樣式將使用Ant Design Vue。之前的代碼無需更改实苞,并無沖突豺撑。
更改畫筆粗細(xì)
更改筆畫粗細(xì)首先需要一個(gè)調(diào)節(jié)筆畫粗細(xì)的組件。這里使用了antd的氣泡卡片作為調(diào)節(jié)的容器黔牵。使用滑動(dòng)輸入條調(diào)節(jié)粗細(xì)聪轿。在文件中添加、更改如下代碼
<template>
<div class="hand-writing">
<img v-if="filePath" :src="filePath" alt="" />
<canvas
v-else
ref="writingCanvas"
class="writing-box"
:width="canvasWidth"
:height="canvasHeight"
@touchstart="onStart"
@touchmove="onMove"
@touchend="onEnd"
>
</canvas>
<div class="btn-box">
<!-- 添加開始 -->
<!-- 調(diào)節(jié)彈框 -->
<a-popover v-model="adjustVisible">
<template slot="title">
<div class="adjust-header">
<span>調(diào)節(jié)筆畫</span>
<a-icon type="close-circle" @click="adjustVisible = false" />
</div>
</template>
<template slot="content">
<div class="adjust-content">
<a-row>
<a-col :span="4">線條粗細(xì)</a-col>
<a-col :span="12">
<a-slider :min="1" :max="20" v-model="lineWidth" />
</a-col>
</a-row>
</div>
</template>
<a-button
type="primary"
class="btn btn-adjust"
@click="adjustVisible = true"
>
調(diào)節(jié)筆畫
</a-button>
</a-popover>
<!-- 添加結(jié)束 -->
<a-button type="primary" class="btn btn-clear" @click="onClear"
>清屏</a-button
>
<a-button type="primary" class="btn btn-generate" @click="onGenerate"
>生成</a-button
>
</div>
</div>
</template>
<script>
export default {
name: "HandWriting",
props: {
path: {
type: String,
default: ""
}
},
data: function() {
return {
...
// 添加卡片隱藏控制變量
adjustVisible: false
};
},
...
};
</script>
<style lang="css" scoped>
...
.btn-box {
margin: 0 auto;
padding-top: 10px;
height: 20%;
}
.btn {
margin: 0 15px;
width: 100px;
height: 50px;
line-height: 50px;
}
.adjust-header {
display: flex;
}
.adjust-header span {
flex: 1;
}
.adjust-header .anticon{
flex: 1;
text-align: right;
line-height: 21px;
}
</style>
更改之后猾浦,點(diǎn)擊調(diào)節(jié)筆畫陆错,就可以在彈框中調(diào)節(jié)粗細(xì)了。
更改畫筆顏色
這里使用vue-color調(diào)色板來調(diào)節(jié)顏色金赦。
首先安裝vue-color危号。
npm i vue-color
or
yarn add vue-color
然后在文件中引入,這里使用的Chrome樣式的調(diào)色板素邪。
import { Chrome } from 'vue-color'
...
data() {
return {
pickerColor: {},
}
},
components: {
"chrome-picker": Chrome
},
...
<chrome-picker v-model="pickerColor" />
直接通過這個(gè)插件獲取到的值是一個(gè)對(duì)象,我們需要處理這個(gè)對(duì)象猪半。添加如下代碼兔朦,當(dāng)調(diào)色板顏色發(fā)生變化時(shí),改變線條顏色磨确。
watch: {
pickerColor: function(now) {
this.lineColor = now.hex8
}
},
添加完成之后沽甥,就可以開始愉快的更改畫筆顏色,但是有點(diǎn)丑乏奥,讓我們稍微修改一下樣式摆舟。在文件中添加一些代碼。
在template中邓了,添加以下代碼恨诱。其中整個(gè)線條的顏色的選擇器放在了一個(gè)浮動(dòng)的div里面,沒有繼續(xù)使用popover是因?yàn)槠瑑?nèi)層的關(guān)閉會(huì)造成外層同時(shí)關(guān)閉照宝。所以自己寫了一個(gè)類似的。
<a-popover
v-model="adjustVisible"
:arrowPointAtCenter="true"
>
<template slot="title">
<div class="adjust-header">
<span>調(diào)節(jié)筆畫</span>
<a-icon type="close-circle" @click="adjustVisible = false" />
</div>
</template>
<template slot="content">
<div class="adjust-content">
<a-row>
<a-col :span="8">線條粗細(xì)</a-col>
<a-col :span="16">
<a-slider :min="1" :max="20" v-model="lineWidth" />
</a-col>
</a-row>
<a-row>
<a-col :span="8">線條顏色</a-col>
<a-col :span="16">
<div
class="color-body"
:style="{ background: lineColor }"
@click="colorVisible = true"
></div>
<div class="picker-box" v-if="colorVisible">
<a-icon
class="picker-cancel"
type="close-circle"
@click="colorVisible = false"
/>
<chrome-picker v-model="pickerColor" />
</div>
</a-col>
</a-row>
</div>
</template>
<a-button
type="primary"
class="btn btn-adjust"
@click="adjustVisible = true"
>
調(diào)節(jié)筆畫
</a-button>
</a-popover>
因?yàn)閜opover是直接掛載在 body 上的句葵,所以我們還需要將其掛載在當(dāng)前組件的根節(jié)點(diǎn)上才可以改變器樣式厕鹃。首先在根節(jié)點(diǎn)上注冊(cè)一個(gè)ref。
<div ref="box" class="hand-writing">
...
</div>
然后寫一個(gè)方法乍丈,返回注冊(cè)好的節(jié)點(diǎn)
handleGetContainer() {
return this.$refs.box;
}
然后在popover上使用此方法剂碴,就可以更改popover的樣式了。
<a-popover
:getPopupContainer="handleGetContainer"
>
...
</a-popover>
添加如下樣式代碼轻专。
.adjust-header {
display: flex;
width: 188px;
}
.adjust-header span {
flex: 1;
}
.adjust-header .anticon{
flex: 1;
text-align: right;
line-height: 21px;
}
.ant-row {
line-height: 36px;
}
.color-body {
margin-left: 5px;
vertical-align: sub;
display: inline-block;
width: 15px;
height: 15px;
border-radius: 50%;
background: #000;
}
.picker-box {
position: absolute;
padding: 10px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
bottom: 30px;
left: 10px;
}
.picker-box .picker-cancel {
float: right;
height: 20px;
line-height: 20px;
}
.picker-box .vc-chrome {
box-shadow: 0 0 0;
}
然后看看效果就會(huì)發(fā)現(xiàn)好很多了忆矛,雖然還是有點(diǎn)丑,就不再繼續(xù)優(yōu)化了请垛。
橡皮檫功能
橡皮檫的實(shí)現(xiàn)類似于清除功能洪碳,不同的是递览,清除是擦除整個(gè)屏幕,而橡皮檫是擦除某一部分瞳腌。一樣的需要用到 onStart绞铃、 onMove、 onEnd 三個(gè)方法嫂侍。所以我們需要一個(gè)新變量來存儲(chǔ)當(dāng)前鼠標(biāo)(也就是手指儿捧?我也不知道叫什么合適。挑宠。菲盾。)的狀態(tài),判斷當(dāng)前究竟是畫筆還是橡皮擦各淀。不同的狀態(tài)執(zhí)行不同的方法懒鉴。
首先在data中添加一個(gè)變量 mouseStatus: "brush"
。
然后在template中添加一個(gè)按鈕控制其變化
<a-button
type="primary"
class="btn btn-switch"
@click="mouseStatus = mouseStatus === 'brush' ? 'eraser' : 'brush'"
>
切換為{{ mouseStatus === "brush" ? "橡皮檫" : "畫筆" }}
</a-button>
代碼優(yōu)化
然后為了避免代碼的冗余碎浇,我們需要對(duì)代碼進(jìn)行一次大換血临谱,首先定義以下幾個(gè)方法。
/**
*
* 通過mouseStatus的值以及鼠標(biāo)當(dāng)前的動(dòng)作階段來判斷該執(zhí)行哪個(gè)方法
* e: event
* type: 鼠標(biāo)當(dāng)前的動(dòng)作階段(start奴璃、move悉默、end)
*
*/
handleSelectTouch(e, type) {}
/**
*
* 畫筆畫圖三步
*
*/
onBrushStart(e) {}
onBrushMove(e) {}
onBrushEnd() {}
/**
*
* 橡皮檫三步
*
*/
onEraserStart(e) {}
onEraserMove(e) {}
onEraserEnd() {}
/**
*
* 擦除方法,擦除以點(diǎn)擊點(diǎn)為圓心苟穆,畫筆寬度為直徑的圓
* x: 點(diǎn)擊點(diǎn)x坐標(biāo)
* y: 點(diǎn)擊點(diǎn)y坐標(biāo)
* radius: 半徑
*
*/
clearArc(x, y, radius) {}
/**
*
* 擦除輔助方法抄课,將擦出點(diǎn)連接起來
* e: event
* radius: 半徑
*
*/
clearLine(e, radius) {}
然后我們需要將以下幾個(gè)方法的內(nèi)容移至新方法內(nèi),并刪除雳旅。
-> onStart()
onBrushStart()
-> onMove()
onBrushMove()
-> onEnd()
onBrushEnd()
更改部分html代碼如下
<!-- 將判斷是畫筆還是橡皮檫的代碼全部放在一個(gè)方法里跟磨,方便處理 -->
<canvas
...
@touchstart="handleSelectTouch($event, 'start')"
@touchmove="handleSelectTouch($event, 'move')"
@touchend="handleSelectTouch($event, 'end')"
>
</canvas>
添加判斷畫筆屬性的代碼
handleSelectTouch(e, type) {
let _data = this.getInitialCapital(this.mouseStatus);
switch (type) {
case "start":
this[`on${_data}Start`](e); // 這種調(diào)用方法看習(xí)慣了就好了
break;
case "move":
this[`on${_data}Move`](e);
break;
case "end":
this[`on${_data}End`](e);
break;
default:
break;
}
},
// 將字符串轉(zhuǎn)換為首字母大寫的形式
getInitialCapital(val) {
return val.replace(/\S/, item => item.toUpperCase());
}
這時(shí)候在橡皮檫的三步方法里添加打印代碼后,通過改變畫筆屬性就可以看到不同的效果了攒盈。接下來進(jìn)入擦除的正題吱晒。
擦除圓形
在 canvas 的API里面我們發(fā)現(xiàn)擦除的方法只有一個(gè) clearRect,而且此方法只能擦除一個(gè)矩形沦童,而不能擦除其他圖形仑濒,這不符合我們的想法。我們只能通過其他方法曲線救國(guó)偷遗。也就是 clip墩瞳,clip 是 Canvas 2D API 將當(dāng)前創(chuàng)建的路徑設(shè)置為當(dāng)前剪切路徑的方法。也就是說我們可以先畫一個(gè)圓氏豌,然后將這個(gè)圓設(shè)置為剪切路徑喉酌,然后使用 clearReact 方法將其擦除。添加如下代碼
onEraserStart(e) {
this.offsetLeft = e.target.offsetLeft;
this.offsetTop = e.target.offsetTop;
this.c1px = e.touches[0].clientX - this.offsetLeft;
this.c1py = e.touches[0].clientY - this.offsetTop;
this.clearArc(this.c1px, this.c1py, this.lineWidth / 2);
},
onEraserMove(e) {
this.offsetLeft = e.target.offsetLeft;
this.offsetTop = e.target.offsetTop;
this.c1px = e.touches[0].clientX - this.offsetLeft;
this.c1py = e.touches[0].clientY - this.offsetTop;
this.clearArc(this.c1px, this.c1py, this.lineWidth / 2);
},
onEraserEnd() {
console.log("end");
},
clearArc(x, y, radius) {
this.ctx.save();
this.ctx.beginPath();
// 畫圓,以點(diǎn)擊點(diǎn)為圓心坐標(biāo)泪电,線條的寬度為直徑畫圓
this.ctx.arc(x, y, radius, 0, 2 * Math.PI);
// 設(shè)置為剪切路徑
this.ctx.clip();
// 擦除
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
this.ctx.restore();
},
這時(shí)就可以正常的使用橡皮擦了般妙,但是當(dāng)鼠標(biāo)移動(dòng)快一點(diǎn)的時(shí)候就會(huì)發(fā)現(xiàn)擦除的路徑是不連續(xù)的,造成這種問題的原因是相速,快速移動(dòng)的時(shí)候碟渺,只會(huì)畫一個(gè)圓,但是圓與圓之間的路徑是不會(huì)被畫上的突诬。下面苫拍,讓我們來優(yōu)化一下這個(gè)橡皮擦。
優(yōu)化橡皮擦
因?yàn)楫嫻P的寬度在使用的途中是固定的旺隙,也就是說我們?cè)诋嬄窂降臅r(shí)候只需要將兩個(gè)圓之間的矩形也畫上就可以了绒极。如下圖所示。
畫這個(gè)矩形我們需要知道矩形的四個(gè)頂點(diǎn)蔬捷,但是目前我們只知道其中兩條邊的中點(diǎn)垄提,也就是兩個(gè)圓的圓心,我們需要計(jì)算以下這四個(gè)頂點(diǎn)周拐。
首先铡俐,我們?cè)谏蠄D中添加幾條輔助線。如下圖所示速妖。
可以看出我們需要得出四個(gè)頂點(diǎn)的坐標(biāo)就必須先求得頂點(diǎn)與圓心的所在的直角三角形的兩條直角邊的長(zhǎng)度。非常簡(jiǎn)單的相似三角形聪黎,有多種方法可以解決罕容,這里使用的是三角函數(shù)。方法如下
let sinX = Math.sin(Math.atan((y2 - y1) / (x2 - x1)))
let cosY = Math.cos(Math.atan((y2 - y1) / (x2 - x1)))
借助上面的圖稿饰,可以很輕松的理解這兩個(gè)計(jì)算式锦秒。得到了兩條直角邊的長(zhǎng)度,剩下的就是計(jì)算四個(gè)頂點(diǎn)坐標(biāo)喉镰,然后畫矩形了旅择。添加及更改如下代碼
onEraserStart(e) {
this.offsetLeft = e.target.offsetLeft;
this.offsetTop = e.target.offsetTop;
this.c1px = e.touches[0].clientX - this.offsetLeft;
this.c1py = e.touches[0].clientY - this.offsetTop;
// 在矩形起點(diǎn)畫圓
this.clearArc(this.c1px, this.c1py, this.lineWidth / 2);
},
onEraserMove(e) {
this.clearLine(e, this.lineWidth / 2);
},
onEraserEnd() {
console.log("end");
},
clearArc(x, y, radius) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.arc(x, y, radius, 0, 2 * Math.PI);
this.ctx.clip();
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
this.ctx.restore();
},
clearLine(e, radius) {
this.offsetLeft = e.target.offsetLeft;
this.offsetTop = e.target.offsetTop;
let endX = e.touches[0].clientX - this.offsetLeft;
let endY = e.touches[0].clientY - this.offsetTop;
// 在矩形重點(diǎn)畫圓
this.clearArc(endX, endY, radius);
// 計(jì)算輔助邊長(zhǎng)
let sinX =
radius * Math.sin(Math.atan((endY - this.c1py) / (endX - this.c1px)));
let cosY =
radius * Math.cos(Math.atan((endY - this.c1py) / (endX - this.c1px)));
this.ctx.save();
// 畫矩形
this.ctx.beginPath();
this.ctx.moveTo(this.c1px - sinX, this.c1py + cosY);
this.ctx.lineTo(this.c1px + sinX, this.c1py - cosY);
this.ctx.lineTo(endX + sinX, endY - cosY);
this.ctx.lineTo(endX - sinX, endY + cosY);
this.ctx.closePath();
this.ctx.clip();
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
this.ctx.restore();
this.c1px = endX;
this.c1py = endY;
},
細(xì)心的可以看到在方法中有這樣兩句代碼 this.ctx.save();
以及 this.ctx.restore();
,第一句代碼是保存當(dāng)前的繪圖環(huán)境侣姆,第二句是重置當(dāng)前的繪圖環(huán)境生真,如果不加這兩句的話,在使用完橡皮擦后捺宗,繪圖會(huì)被限制在剪切范圍內(nèi)柱蟀。
撤銷&還原
撤銷和還原應(yīng)該算是一個(gè)畫圖板的基本功能,這里就來實(shí)現(xiàn)一下蚜厉。主要思路是定義一個(gè)棧(就是數(shù)組)來放置每一次更改畫板的狀態(tài)长已,然后通過一個(gè)指針(就是數(shù)組下標(biāo))來確定當(dāng)前所在的狀態(tài)。撤銷操作就是將指針移至上一個(gè)狀態(tài)(數(shù)組下標(biāo)減一),還原操作就是將指針移至下一個(gè)狀態(tài)(數(shù)組下標(biāo)加一)∈跷停現(xiàn)在來實(shí)現(xiàn)這個(gè)思路康聂,首先我們需要兩個(gè)按鍵來幫助我們實(shí)現(xiàn)這兩個(gè)功能。添加如下代碼胞四。
<a-button type="primary" class="btn btn-undo" @click="onUndo">
撤銷
</a-button>
<a-button type="primary" class="btn btn-reduction" @click="onReduction">
還原
</a-button>
// 撤銷
onUndo() {},
// 還原
onReduction() {},
然后在data中定義兩個(gè)輔助變量恬汁。
boardData: [],
boardStatus: 0
然后定義一個(gè)方法來執(zhí)行入棧操作(向數(shù)組push值)。
setBoardStatus(data) {
this.boardData.push(data);
this.boardStatus += 1;
}
然后在 init 方法中對(duì)其初始化撬讽。
init() {
// 這里賦值為 -1 是因?yàn)橄旅娴娜霔2僮鲿?huì)對(duì)boardStatus進(jìn)行 +1 處理蕊连,這里為了保證一致性
this.boardStatus = -1;
this.boardData = [];
if (this.path !== "") {
this.filePath = this.path;
} else {
// 如果沒有默認(rèn)圖片再 push 默認(rèn)狀態(tài)
this.setBoardStatus(
this.ctx.getImageData(0, 0, this.canvasWidth, this.canvasHeight)
);
}
},
然后我們需要在每個(gè)改變畫板狀態(tài)的動(dòng)作執(zhí)行完后執(zhí)行入棧操作。
onBrushEnd() {
this.ctx.closePath();
this.setBoardStatus(
this.ctx.getImageData(0, 0, this.canvasWidth, this.canvasHeight)
);
},
onEraserEnd() {
this.setBoardStatus(
this.ctx.getImageData(0, 0, this.canvasWidth, this.canvasHeight)
);
},
接下來我們開始實(shí)現(xiàn)撤銷功能游昼,就是將當(dāng)前的畫板置為上一個(gè)狀態(tài)甘苍。在 onUndo() 中添加如下代碼
onUndo() {
this.boardStatus--;
// 判斷上一個(gè)狀態(tài)是否存在,存在則將當(dāng)前畫板置為上一個(gè)狀態(tài)烘豌,否則提示錯(cuò)誤载庭,下標(biāo)歸位。
if (this.boardStatus >= 0) {
this.ctx.putImageData(this.boardData[this.boardStatus], 0, 0);
} else {
this.boardStatus++;
this.$message.info("已經(jīng)是第一步了");
}
},
還原功能類似與撤銷功能,就是將當(dāng)前的畫板置為下一個(gè)狀態(tài)。在 onReduction() 中添加如下代碼
onReduction() {
this.boardStatus++;
// 判斷下一個(gè)狀態(tài)是否存在毕匀,存在則將當(dāng)前畫板置為下一個(gè)狀態(tài)侨嘀,否則提示錯(cuò)誤,下標(biāo)歸位擦秽。
if (this.boardStatus < this.boardData.length) {
this.ctx.putImageData(this.boardData[this.boardStatus], 0, 0);
} else {
this.boardStatus--;
this.$message.info("已經(jīng)是最新的了");
}
},
至此撤銷還原功能就完全實(shí)現(xiàn)了,有一個(gè)缺點(diǎn)就是當(dāng)動(dòng)作執(zhí)行的比較多了之后會(huì)比較占內(nèi)存∥剿桑可以考慮使用 history 實(shí)現(xiàn)同樣的功能。
源碼&在線預(yù)覽
預(yù)覽請(qǐng)打開控制臺(tái)在手機(jī)模式下運(yùn)行践剂。筆畫位置有誤差請(qǐng)刷新瀏覽器鬼譬。
DEMO
源碼:GitHub
功能菜單樣式優(yōu)化
看著慘不忍睹的界面是時(shí)候優(yōu)化一下樣式了。目前的想法是做一個(gè)收縮可移動(dòng)的菜單逊脯。效果如圖所示优质。
首先說一下實(shí)現(xiàn)這個(gè)所需要掌握的知識(shí)點(diǎn)。包括:touch相關(guān)三個(gè)方法军洼、transform與transition巩螃。(emmmm,好像也沒有什么高深的技術(shù)匕争。)
菜單控制按鈕的實(shí)現(xiàn)
這是一個(gè)在移動(dòng)端比較常見的菜單按鍵牺六。這里自己實(shí)現(xiàn)一下。
菜單按鈕
首先我們需要在上一章節(jié)的基礎(chǔ)上更改一下HTML的結(jié)構(gòu)汗捡。如下
<template>
<div ref="box" class="hand-writing">
<div v-if="!app" style="height: 100%">
<img v-if="filePath" :src="filePath" alt="" />
<canvas></canvas>
<div
class="menu-box"
:style="{ top: `${menuTop}px`, left: `${menuLeft}px` }"
@touchstart="onMenuStart($event)"
@touchmove="onMenuMove($event)"
@touchend="onMenuEnd($event)"
>
<div :class="{ 'list-show': menuShow }" class="menu-list">
<div
:class="{ 'btn-checked': menuShow }"
class="menu-btn"
@click="onClickMenuBtn"
>
<span class="menu-btn-item"></span>
<span class="menu-btn-item"></span>
<span class="menu-btn-item"></span>
</div>
<div class="list-item item-checked">1</div>
<div class="list-item">2</div>
<div class="list-item">3</div>
<div class="list-item">4</div>
</div>
</div>
</div>
<div v-else>暫不支持QQ瀏覽器,請(qǐng)選擇其他瀏覽器打開淑际。</div>
</div>
</template>
在canvas同層結(jié)構(gòu)外添加了一層div畏纲,是為了將QQ瀏覽器屏蔽掉。你也可以去掉這一層春缕。然后刪除了之前所有的功能按鈕盗胀,添加了一個(gè)菜單控制按鈕。菜單按鈕的動(dòng)態(tài)style是為了方便控制菜單的位置锄贼。然后我們添加及更改一下樣式票灰。
.menu-box {
position: absolute;
top: 0;
left: 0;
z-index: 999;
width: auto;
height: auto;
background: #fff;
}
.menu-box div.list-show {
height: 100%;
padding-bottom: 10px;
}
.menu-box .menu-list {
padding-bottom: 10px;
width: 35px;
height: 27px;
border: 1px solid #000;
border-radius: 5px;
text-align: center;
overflow: hidden;
}
.menu-box .menu-list .menu-btn {
display: inline-block;
width: 20px;
height: 100%;
line-height: 3px;
}
.menu-box .menu-list .menu-btn .menu-btn-item {
display: inline-block;
width: 20px;
height: 2px;
background: #666;
border-radius: 5px;
transition: transform .5s;
}
.btn-checked .menu-btn-item:first-child {
transform: translateY(7px) rotate(45deg);
}
.menu-box .menu-list .btn-checked .menu-btn-item:nth-child(2) {
display: none;
}
.btn-checked .menu-btn-item:last-child {
transform: rotate(-45deg);
}
.menu-box .menu-list .list-item {
display: inline-block;
width: 25px;
height: 25px;
border-radius: 5px;
margin-top: 5px;
}
.item-checked {
border: 1px solid #000;
}
樣式很簡(jiǎn)單,就不多介紹了宅荤,如果你對(duì)transform和transition不太了解屑迂,可以關(guān)注一下我的下一篇博客。
然后我們定義一個(gè)控制菜單開關(guān)的方法冯键。
onClickMenuBtn() {
this.menuShow = !this.menuShow;
},
到了這里其實(shí)大部分都已經(jīng)完成了惹盼,剩下的只有div的移動(dòng),而在前兩個(gè)章節(jié)惫确,我們都在于此打交道手报,所以是很簡(jiǎn)單了。我們繼續(xù)改化。
菜單移動(dòng)
在data中添加幾個(gè)輔助變量掩蛤。
data() {
return {
// 菜單相關(guān)
menuShow: false, // 控制菜單的顯示隱藏
menuTop: 0, // 菜單的top
menuLeft: 0, // 菜單的left
menuX: 0, // 輔助坐標(biāo)X
menuY: 0 // 輔助坐標(biāo)Y
}
}
然后我們添加一下控制移動(dòng)的三個(gè)方法。
onMenuStart(e) {
// 將點(diǎn)擊點(diǎn)存起來
this.menuX = e.touches[0].clientX;
this.menuY = e.touches[0].clientY;
},
onMenuMove(e) {
// 獲取當(dāng)前點(diǎn)擊點(diǎn)
let x = e.touches[0].clientX;
let y = e.touches[0].clientY;
// 當(dāng)前點(diǎn)和在onMenuStart中保存的點(diǎn)的距離就是菜單移動(dòng)的距離
this.menuTop += y - this.menuY;
this.menuLeft += x - this.menuX;
// 將當(dāng)前點(diǎn)存起來
this.menuX = x;
this.menuY = y;
},
onMenuEnd() {
// console.log("object");
},
這時(shí)候就可以進(jìn)行正常的移動(dòng)的陈肛,但是一不小心移出去了怎么辦揍鸟?讓我們添加一個(gè)邊界判斷的方法,讓菜單只在可視內(nèi)容區(qū)域移動(dòng)句旱。
// 傳入當(dāng)前的left阳藻、top,如果超出邊界就處理一下前翎。
handleBorderJudgment(left, top) {
if (left < 0) {
left = 0;
} else if (left > this.canvasWidth - 35) { // 這里減去菜單的寬度為了避免菜單寬度造成的影響
left = this.canvasWidth - 35;
}
if (top < 0) {
top = 0;
} else if (top > this.canvasHeight - 27) { // 這里減去菜單的高度為了避免菜單高度造成的影響
top = this.canvasHeight - 27;
}
return {
left,
top
};
},
然后我們只需要在移動(dòng)過程中使用這個(gè)方法就可以了稚配。
onMenuMove(e) {
let x = e.touches[0].clientX;
let y = e.touches[0].clientY;
this.menuTop += y - this.menuY;
this.menuLeft += x - this.menuX;
// 將計(jì)算出來的值判斷一下然后重新賦值
let _data = this.handleBorderJudgment(this.menuLeft, this.menuTop);
this.menuTop = _data.top;
this.menuLeft = _data.left;
this.menuX = x;
this.menuY = y;
},
這時(shí)候移動(dòng)到邊界會(huì)發(fā)現(xiàn)還有一個(gè)bug畅涂,就是打開菜單的時(shí)候會(huì)撐開內(nèi)容區(qū)域港华,這不是我們想要的,所以在 onMenuMove 第一行添加一行代碼 this.menuShow = false;
午衰,當(dāng)移動(dòng)時(shí)立宜,我們手動(dòng)讓他關(guān)閉就可以了。
菜單功能添加
菜單使用的都是icon臊岸,但是ant的圖標(biāo)不多橙数,所以我在阿里圖標(biāo)庫(kù)創(chuàng)建了一個(gè)小應(yīng)用,你可以直接拿去用帅戒。
首先灯帮,按照ant的介紹崖技,添加如下代碼
import { Icon } from 'ant-design-vue';
const IconFont = Icon.createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/font_1372570_awwwx5suzr.js',
})
export default {
components: {
IconFont,
}
}
然后更改HTML部分代碼如下
<div
:class="{ 'item-checked': mouseStatus === 'brush' }"
class="list-item"
>
<a-popover
v-model="adjustVisible"
placement="right"
:arrowPointAtCenter="true"
:getPopupContainer="handleGetContainer"
>
<template slot="title">
<div class="adjust-header">
<span>調(diào)節(jié)筆畫</span>
<a-icon type="close-circle" @click="adjustVisible = false" />
</div>
</template>
<template slot="content">
<div class="adjust-content">
<a-row>
<a-col :span="8">線條粗細(xì)</a-col>
<a-col :span="16">
<a-slider :min="1" :max="20" v-model="lineWidth" />
</a-col>
</a-row>
<a-row>
<a-col :span="8">線條顏色</a-col>
<a-col :span="16">
<div
class="color-body"
:style="{ background: lineColor }"
@click="colorVisible = true"
></div>
<div class="picker-box" v-if="colorVisible">
<a-icon
class="picker-cancel"
type="close-circle"
@click="colorVisible = false"
/>
<chrome-picker v-model="pickerColor" />
</div>
</a-col>
</a-row>
</div>
</template>
<a-icon type="edit" @click="onClickEdit" />
</a-popover>
</div>
<div
:class="{ 'item-checked': mouseStatus === 'eraser' }"
class="list-item"
>
<a-popover
v-model="eraserVisible"
placement="right"
:arrowPointAtCenter="true"
:getPopupContainer="handleGetContainer"
>
<template slot="title">
<div class="adjust-header">
<span>調(diào)節(jié)橡皮擦</span>
<a-icon type="close-circle" @click="eraserVisible = false" />
</div>
</template>
<template slot="content">
<div class="adjust-content">
<a-row>
<a-col :span="8">橡皮寬度</a-col>
<a-col :span="16">
<a-slider :min="1" :max="50" v-model="eraserWidth" />
</a-col>
</a-row>
</div>
</template>
<icon-font type="icon-eraser" @click="onClickEraser" />
</a-popover>
</div>
<div class="list-item"><a-icon type="undo" @click="onUndo" /></div>
<div class="list-item">
<a-icon type="redo" @click="onReduction" />
</div>
<div class="list-item"><a-icon type="delete" @click="onClear" /></div>
<div class="list-item">
<a-icon type="save" @click="onGenerate" />
</div>
主要功能都是將之前章節(jié)的直接拿過來了,無需更多的更改钟哥。然后添加兩個(gè)方法迎献。
// 點(diǎn)擊畫筆
onClickEdit() {
this.adjustVisible = true;
this.mouseStatus = "brush";
},
// 點(diǎn)擊橡皮
onClickEraser() {
this.eraserVisible = true;
this.mouseStatus = "eraser";
}
至此,整個(gè)插件的功能也擴(kuò)展完成了腻贰,樣式也改了吁恍,其他的小細(xì)節(jié),就自己再改一改就好了播演。
源碼&在線預(yù)覽
預(yù)覽請(qǐng)打開控制臺(tái)在手機(jī)模式下運(yùn)行冀瓦。筆畫位置有誤差請(qǐng)刷新瀏覽器。
DEMO
源碼:GitHub