最新更新時間:2020年03月11日22:07:14
《猛戳-查看我的博客地圖-總有你意想不到的驚喜》
本文內容:在研發C端教育類產品的時候,會遇到在作答題目的時候需要驗算或者做草稿,因此誕生了一個草稿本或者小黑板的功能模塊,本文統稱爲畫板。本文基於canvas技術,深入討論實現一個畫板功能的原理和核心代碼。
成品示例圖
畫板的功能包括兩部分:控制面板區和繪畫區,控制面板區如下圖上半部分,集成了關閉畫板彈層、清空繪畫區、撤銷和重做功能;繪畫區實現單手指的書寫功能。
繪畫區的原理介紹
繪畫區由上下兩個canvas元素組成,稱作
canvas_top_dom
和canvas_bottom_dom
,兩個元素都是絕對定位,z-index
屬性分別爲1和0
canvas_top_dom
用來監聽用戶的三個交互行爲:onTouchStart、onTouchMove、onTouchEnd,接收用戶的單次繪製動作(手指按下屏幕、手指在屏幕上移動、手指離開屏幕),在單次繪製動作完成時將繪製的線條路徑轉化爲base64格式的圖片canvas_bottom_dom
用來展示用戶所有歷史單次繪製路徑的總數據,當canvas_top_dom
元素單次繪製動作完成時,將這次線條路徑的base64格式的圖片疊加繪製在canvas_bottom_dom
上- 用戶在屏幕上移動手指繪製的過程中,此時看到繪畫區的圖案是由
canvas_top_dom
和canvas_bottom_dom
組成,當用戶手指離開屏幕,此時看到繪畫區的圖案僅僅是canvas_bottom_dom
渲染出的數據
控制面板區的原理介紹
控制面板區集成的功能,從左到右依次是關閉畫板彈層、清空繪畫區、撤銷和重做的功能
- 關閉畫板彈層,隱藏或銷燬整個畫板組件DOM元素
- 清空繪畫區,清空
canvas_top_dom
和canvas_bottom_dom
上的數據即可- 撤銷功能,第一步,需要對用戶的每一次單次繪製動作(手指按下屏幕、手指在屏幕上移動、手指離開屏幕)進行記錄和保存,當
canvas_top_dom
元素單次繪製動作完成時,將這次線條路徑的base64格式的圖片疊加繪製在canvas_bottom_dom
上,然後將canvas_bottom_dom
上的所有數據轉化爲一個大的base64字符串,最後將這個大的base64字符串存儲在canvas_bottom_dom
歷史狀態數組canvas_bottom_base64_arr
的末尾;第二步,聲明一個撤銷記錄的指針變量undoIndex
,這個變量用來存儲用戶的撤銷次數,當用戶撤銷一次,undoIndex++
,此時更新canvas_bottom_dom
渲染的數據爲canvas_bottom_base64_arr
中的倒數第二條數據,以此類推- 重做功能,當用戶撤銷一次,此時更新
canvas_bottom_dom
渲染的數據爲canvas_bottom_base64_arr
中的倒數第二條數據,當用戶重做一次,undoIndex--
,此時更新canvas_bottom_dom
渲染的數據爲canvas_bottom_base64_arr
中的最後一條數據即可
用到的canvas相關API
HTMLCanvasElement.getContext() 方法返回canvas 的上下文,如果上下文沒有定義則返回 null .
Canvas 2D API 中的 CanvasRenderingContext2D.drawImage() 方法提供了多種方式在Canvas上繪製圖像。
The CanvasRenderingContext2D.clearRect() 是 Canvas 2D API 設置指定矩形區域內(以 點 (x, y) 爲起點,範圍是(width, height) )所有像素變成透明,並擦除之前繪製的所有內容的方法。
HTMLCanvasElement.toDataURL() 方法返回一個包含圖片展示的 data URI 。可以使用 type 參數其類型,默認爲 PNG 格式。圖片的分辨率爲96dpi。
CanvasRenderingContext2D.beginPath() 是 Canvas 2D API 通過清空子路徑列表開始一個新路徑的方法。 當你想創建一個新的路徑時,調用此方法。
CanvasRenderingContext2D.moveTo() 是 Canvas 2D API 將一個新的子路徑的起始點移動到(x,y)座標的方法。
CanvasRenderingContext2D.lineTo() 是 Canvas 2D API 使用直線連接子路徑的終點到x,y座標的方法(並不會真正地繪製)。
CanvasRenderingContext2D.stroke() 是 Canvas 2D API 使用非零環繞規則,根據當前的畫線樣式,繪製當前或已經存在的路徑的方法。
核心代碼
本文的畫板組件採用
react
框架開發
- DOM佈局
<div className={styles.canvasBox}>
{/*操控區*/}
<div className={styles.controler}>
{/*關閉按鈕*/}
<img
src={require('../../assets/blackboard/close.png')}
onClick={()=>{this.closeDrawingBoard()}}
/>
{/*清空按鈕*/}
<img
src={require('../../assets/blackboard/delete.png')}
onClick={()=>{this.clear()}}
/>
{/*撤銷高亮 撤銷置灰*/}
{this.state.displayUndo ?
<img
className={styles.preHighlight}
src={require('../../assets/blackboard/pre.png')}
onClick={()=>{this.undo()}}
/> :
<img
className={styles.preGrey}
src={require('../../assets/blackboard/next.png')}
/>
}
{/*重做高亮 重做置灰*/}
{this.state.displayRedo ?
<img
className={styles.nextHighlight}
src={require('../../assets/blackboard/pre.png')}
onClick={()=>{this.redo()}}
/> :
<img
className={styles.nextGrey}
src={require('../../assets/blackboard/next.png')}
/>
}
</div>
{/*畫板區*/}
<div className={styles.board}>
<canvas
id="canvas_top_dom"
width={375}
height={this.canvasHeight}
className={styles.top}
onTouchStart={(e)=>{this.brushDown(e)}}
onTouchMove={(e)=>{this.brushmove(e)}}
onTouchEnd={(e)=>{this.brushUp(e)}}
>瀏覽器不支持canvas</canvas>
<canvas
id="canvas_bottom_dom"
width={375}
height={this.canvasHeight}
className={styles.bottom}
>瀏覽器不支持canvas</canvas>
</div>
</div>
- CSS樣式
.canvasBox{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(1,1,1,0);
z-index: 2;
.controler{
height: 48px;
background: #FFFFFF;
display: flex;
align-items: center;justify-content: space-around;
img{
width: 24px;
height: 24px;
}
.preGrey, .nextHighlight{
transform: rotateY(180deg);//圖片水平鏡像翻轉
}
}
.board{
position: absolute;
top: 48px;
left: 0;
right: 0;
bottom: 0;
background: rgba(141,141,141,0.6);
canvas{
position: absolute;
top: 0;
left: 0;
}
.bottom{
z-index: 2;
}
.top{
z-index: 3;
}
}
}
- JavaScript
說明:全局多處使用數字48,48爲畫板頂部操控區高度
// react組件構造函數
constructor(props) {
super(props);
//UI數據
this.state = {
displayUndo: false,//撤銷按鈕狀態
displayRedo: false,//重做按鈕狀態
};
//非UI數據
this.canvas_top_dom = null;//上層canvas DOM元素
this.ctx_top = null//上層canvas 2D 渲染上下文
this.canvas_bottom_dom = null;//下層canvas DOM元素
this.ctx_bottom = null;//下層canvas 2D 渲染上下文
this.startAxisX = 0;//畫筆X軸起始座標
this.startAxisY = 0;//畫筆Y軸起始座標
this.hasInitHeight = false;//初始化canvas DOM元素高度 狀態
this.canvasWidth = 375;//canvas DOM元素高度 默認寬度
this.canvasHeight = 1000;//canvas DOM元素高度 默認高度
this.canvas_bottom_base64_arr = [];//下層canvas每個狀態的數據 每個狀態爲一個base64
this.undoIndex = 0;//撤銷次數
this.hasMove = false;//手指是否移動,如果沒有移動,攔截brushUp函數。主要用來區分觸摸事件和滑動事件
}
/**
* 初始化canvas
* @param
* @return
*/
initCanvas(){
this.canvas_top_dom = document.getElementById('canvas_top_dom');
this.canvas_bottom_dom = document.getElementById('canvas_bottom_dom');
this.ctx_top = this.canvas_top_dom.getContext("2d");
this.ctx_bottom = this.canvas_bottom_dom.getContext("2d");
//根據屏幕高度設置canvas可用高度
if(!this.hasInitHeight){
this.hasInitHeight = true;
this.canvas_top_dom.height = window.innerHeight - 48;
this.canvas_bottom_dom.height = window.innerHeight - 48;
this.canvasHeight = window.innerHeight - 48;
}
}
/**
* 手指剛按下畫板 初始化畫筆座標
* @param
* @return
*/
brushDown(e){
this.startAxisX = e.touches[0].pageX;
this.startAxisY = e.touches[0].pageY - 48;
}
/**
* 手指從畫板擡起
* @param
* @return
*/
brushUp(){
let _this = this;
if(!this.hasMove)
return
this. hasMove = false;
//以圖片形式在下層canvas繪製數據
let image = new Image();
//將上層canvas內容轉化爲base64格式的圖片數據
image.src = this.canvas_top_dom.toDataURL();//如果有問題 image.src 需要再 image.onload 下面
image.onload = function(){
//如果用戶畫了一筆 重做按鈕高亮並且撤銷次數大於0,丟棄撤銷記錄
if(_this.state.displayRedo && _this.undoIndex > 0){
_this.canvas_bottom_base64_arr = _this.canvas_bottom_base64_arr.slice(0, _this.canvas_bottom_base64_arr.length - _this.undoIndex);
_this.undoIndex = 0;
}
//如果用戶畫了一筆 撤銷按鈕高亮 重做按鈕置灰
_this.setState({
displayUndo: true,
displayRedo: false
})
//下層canvas渲染數據
_this.ctx_bottom.drawImage(image , 0 ,0 , image.width , image.height , 0 ,0 , _this.canvasWidth , _this.canvasHeight);
//上層canvas清空數據
_this.ctx_top.clearRect(0,0,_this.canvasWidth,_this.canvasHeight);
//下層canvas歷史數據數組 新增數據
_this.canvas_bottom_base64_arr.push(_this.canvas_bottom_dom.toDataURL());
}
}
/**
* 手指在畫板上移動
* @param
* @return
*/
brushmove(e){
this. hasMove = true;
//畫筆移動時X軸 實時座標
let stopAxisX = e.changedTouches[0].pageX;
//畫筆移動時Y軸 實時座標
let stopAxisY = e.changedTouches[0].pageY - 48;
this.ctx_top.beginPath();
this.ctx_top.moveTo(this.startAxisX,this.startAxisY);
this.ctx_top.lineTo(stopAxisX,stopAxisY);
this.ctx_top.strokeStyle = '#000000';
this.ctx_top.lineWidth = 1;
this.ctx_top.lineCap = "round";
this.ctx_top.stroke();
this.startAxisX = stopAxisX;
this.startAxisY = stopAxisY;
}
/**
* 撤銷
* @param
* @return
*/
undo(){
if(!this.state.displayUndo){
return
}
let _this = this;
this.undoIndex++;
//下層canvas清空數據
this.ctx_bottom.clearRect(0,0,this.canvasWidth,this.canvasHeight);
//需要渲染的數據索引
let renderIndex = this.canvas_bottom_base64_arr.length -1 - this.undoIndex;
//撤銷之前只有一條數據
if(renderIndex <= -1){
//撤銷按鈕置灰 重做按鈕高亮
this.setState({
displayUndo: false,
displayRedo: true
})
return
}
//以圖片形式在下層canvas繪製數據
let image = new Image();
image.src = this.canvas_bottom_base64_arr[renderIndex];
image.onload = function(){
_this.ctx_bottom.drawImage(image , 0 ,0 , image.width , image.height , 0 ,0 , _this.canvasWidth , _this.canvasHeight);
}
//重做按鈕高亮
this.setState({
displayRedo: true
})
}
/**
* 重做
* @param
* @return
*/
redo(){
if(!this.state.displayRedo){
return
}
let _this = this;
this.undoIndex--;
//下層canvas清空數據
this.ctx_bottom.clearRect(0,0,this.canvasWidth,this.canvasHeight);
//以圖片形式在下層canvas繪製數據
let image = new Image();
//需要渲染的數據索引
let renderIndex = this.canvas_bottom_base64_arr.length -1 - this.undoIndex;
image.src = this.canvas_bottom_base64_arr[renderIndex];
//重做次數用完
if(renderIndex >= this.canvas_bottom_base64_arr.length -1){
//重做按鈕置灰
this.setState({
displayRedo: false
})
}
//撤銷按鈕按鈕置灰
if(renderIndex >= 0){
this.setState({
displayUndo: true
})
}
image.onload = function(){
_this.ctx_bottom.drawImage(image , 0 ,0 , image.width , image.height , 0 ,0 , _this.canvasWidth , _this.canvasHeight);
}
}
/**
* 清空畫板
* @param
* @return
*/
clear(){
//畫板歷史數據爲空 不做清空記錄
if(this.canvas_bottom_base64_arr.length == 0)
return
this.setState({
displayUndo: false,
displayRedo: false
})
this.startAxisX = 0;//畫筆X軸起始座標
this.startAxisY = 0;//畫筆Y軸起始座標
this.canvas_bottom_base64_arr = [];//下層canvas每個狀態的數據 每個狀態爲一個base64
this.undoIndex = 0;//撤銷次數
this.hasMove = false;//手指是否移動,如果沒有移動,攔截brushUp函數。主要用來區分觸摸事件和滑動事件
//上層canvas清空數據
this.ctx_top.clearRect(0,0,this.canvasWidth,this.canvasHeight);
//下層canvas清空數據
this.ctx_bottom.clearRect(0,0,this.canvasWidth,this.canvasHeight);
}
/**
* 關閉畫板
* @param
* @return
*/
closeDrawingBoard(){
//丟棄撤銷記錄
if(this.state.displayRedo && this.undoIndex > 0){
this.canvas_bottom_base64_arr = this.canvas_bottom_base64_arr.slice(0, this.canvas_bottom_base64_arr.length - this.undoIndex);
this.undoIndex = 0;
}
//保存當前畫板數據 this.canvas_bottom_base64_arr
//關閉畫板
}
iOS端的兼容性問題
在畫板上手指上下移動時,iPhone上會出現橡皮筋效果,此時需要做如下特殊處理
/**
* 手指剛按下畫板 初始化畫筆座標
* @param
* @return
*/
brushDown(e){
let _this = this;
//攔截iOS頁面滑動的橡皮筋效果
document.body.addEventListener('touchmove', _this.preventScroll, {passive: false});//passive 參數不能省略,用來兼容ios和android
}
/**
* 手指從畫板擡起
* @param
* @return
*/
brushUp(){
let _this = this;
//關閉iOS頁面滑動的橡皮筋效果
document.body.removeEventListener('touchmove', _this.preventScroll);
}
/**
* 阻止頁面滾動
* @param
* @return
*/
preventScroll(e){
e.preventDefault();
}
參考資料
<canvas> - HTML(超文本標記語言) | MDN
Canvas - Web API 接口參考 | MDN
CanvasRenderingContext2D - Web API 接口參考 | MDN
- 繪畫板 - 軒楓閣
- xuanfeng/draw: HTML5繪畫板-軒楓閣
- 博客文章
- 博客文章
- 博客文章
感謝閱讀,歡迎評論^-^