匠心打造canvas簽名組件

本文首發於CSDN網站,下面的版本又通過進一步的修訂。
原文:匠心打造canvas簽名組件html

導讀

6月又是項目吃緊的時候,一大波需求襲來,猝不及防。vue

度過了漫長而煎熬的6月,是時候總結一波。最近移動端的一款產品原計劃是引入第三方的簽名插件,該插件依賴複雜,若干個js使用document.write順序加載,插件源碼是ES5的,甚至說是ES3都不爲過。爲了可以順利嵌入咱們的VUE項目,我閱讀了兩天插件的源碼(demo及文檔不全,囧),而後花了一天多點的時間使用ES6引用它。鑑於單頁應用中,任何非全局資源都不應提早加載的指導性原則,爲了作到動態加載,我甚至還專門寫了一個simple的vue組件iload.js去順序加載這些資源並執行回調。一切看似很完美,結果發現demo引用的一個壓縮的js中竟然寫死了插件相關DOM節點的id和style,此刻個人心裏幾乎是崩潰的。這樣的一個插件我怕是無力引入了吧。ios

雖然嘴上這麼說,身體仍是很誠實的,費盡千辛萬苦我仍是把這個插件用在了項目中。隨着項目推動,業務上通過屢次溝通,咱們砍掉了該簽名插件的數字證書驗證部分。也就是說,這麼大的一個插件,只剩下用戶簽名的功能,我徹底能夠本身作啊。因而我悄悄移除了這個插件,爲這幾天的調研和碼字過程劃上了一個完美的句號(深藏功與名)。git

簽名是若干操做的集合,起於用戶手寫姓名,終於簽名圖片上傳,中間還包含圖片的處理,好比說減小鋸齒、旋轉、縮小、預覽等。canvas幾乎是最適合的解決方案。github

手寫

從交互上看,用戶簽名的過程,只有開始的手寫部分是有交互的,後面是自動處理。爲了完成手寫,須要監聽畫布的兩個事件:touchstart、touchmove(移動端touchend在touchmove以後不觸發)。前者定義起始點,後者不停地描線。web

const canvas = document.getElementById('canvas');
const touchstart = (e) => {
  /* TODO 定義起點 */
};
const touchmove = (e) => {
  /* TODO 連點成線,而且填充顏色 */
};
canvas.addEventListener('touchstart', touchstart);
canvas.addEventListener('touchmove', touchmove);

注: 如下默認canvas和context對象已有。ajax

能夠先戳這裏體驗把後面將要提到的簽名組件 canvas-drawcanvas

描線

既然要連點成線,天然須要一個變量來存儲這些點。axios

const point = {};

接下來就是畫線的部分。canvas畫線只需4行代碼:api

  1. 開始路徑(beginPath)

  2. 定位起點(moveTo)

  3. 移動畫筆(lineTo)

  4. 繪製路徑(stroke)

考慮到start和move兩個動做,那麼一個描線的方法就呼之欲出了,以下:

const paint = (signal) => {
  switch (signal) {
    case 1: // 開始路徑
      context.beginPath();
      context.moveTo(point.x, point.y);
    case 2: // 前面之因此沒有break語句,是爲了點擊時就能描畫出一個點
      context.lineTo(point.x, point.y);
      context.stroke();
      break;
  }
};

綁定事件

爲了兼容PC端的相似需求,咱們有必要區分下平臺。移動端,使用手指操做,須要綁定的是touchstart和touchmove;PC端,使用鼠標操做,須要綁定的是mousedown和mousemove。以下一行代碼可用於判斷是否移動端:

const isMobile = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i.test(navigator.userAgent);

描線的方法準備穩當後,剩下的就是在適當的時候,記錄當前劃過的點,而且調用paint方法進行繪製。這裏能夠抽象出一個事件生成器:

let pressed = false; // 標示是否發生鼠標按下或者手指按下事件
const create = signal => (e) => {
  if (signal === 1) {
    pressed = true;
  }
  if (signal === 1 || pressed) {
    e = isMobile ? e.touches[0] : e;
    point.x = e.clientX - left + 0.5; // 不加0.5,整數座標處繪製直線,直線寬度將會多1px(不理解的不妨谷歌下)
    point.y = e.clientY - top + 0.5;
    paint(signal);
  }
};

以上代碼中的left和top並不是內置變量,它們分別表示着畫布距屏幕左邊和頂部的像素距離,主要用於將屏幕座標點轉換爲畫布座標點。如下是一種獲取方法:

const { left, top } = canvas.getBoundingClientRect();

很明顯,上述的事件生成器是一個高階函數,用於固化signal參數並返回一個新的Function。基於此,start和move回調便呈現了。

const start = create(1);
const move = create(2);

爲了不UI過分繪製,讓move操做執行得更加流暢,requestAnimationFrame優化天然是少不了的。

const requestAnimationFrame = window.requestAnimationFrame;
const optimizedMove = requestAnimationFrame ? (e) => {
  requestAnimationFrame(() => {
    move(e);
  });
} : move;

剩下的也是綁定事件中關鍵的一步。PC端中,mousedown和mousemove沒有前後順序,不是每一次畫布之上的鼠標移動都是有效的操做,所以咱們使用pressed變量來保證mousemove事件回調只在mousedown事件以後執行。實際上,設置後的pressed變量總須要還原,還原的契機就是mouseup和mouseleave回調,因爲mouseup事件並不總能觸發(好比說鼠標移動到別的節點上才彈起,此時觸發的是其餘節點的mouseup事件),mouseleave即是鼠標移出畫布時的兜底邏輯。而移動端的touch事件,其自然的連續性,保證了touchmove只會在touchstart以後觸發,所以無須設置pressed變量,也不須要還原它。代碼以下:

if (isMobile) {
  canvas.addEventListener('touchstart', start);
  canvas.addEventListener('touchmove', optimizedMove);
} else {
  canvas.addEventListener('mousedown', start);
  canvas.addEventListener('mousemove', optimizedMove);
  ['mouseup', 'mouseleave'].forEach((event) => {
    canvas.addEventListener(event, () => {
      pressed = false;
    });
  });
}

旋轉

想要在移動端簽名,每每面臨着屏幕寬度不夠的尷尬。豎屏下寫不了幾個漢字,甚至三個都夠嗆。若是app webview或瀏覽器不支持橫屏展現,此時並非意味着沒有了辦法,起碼咱們能夠將整個網頁旋轉90°。

方案一:起初個人想法是將畫布也一同旋轉90°,後來發現難以處理旋轉後的座標系和屏幕座標系的對應關係,所以我採起了旋轉90°繪製頁面,可是正常佈局畫布的方案,從而保證座標系的一致性(這樣就不用從新糾正canvas畫布的座標系了,關於糾正座標系後續還有方案二,請耐心閱讀)。

因爲用戶是橫屏操做畫布的,完成簽名後,圖片須要逆時針旋轉90°才能保上傳到服務器。所以還差一個旋轉的方法。實際上,rotate方法能夠旋轉畫布,drawImage方法能夠在新的畫布中繪製一張圖片或老的畫布,這種繪製的定製化程度很高。

rotate

rotate用於旋轉當前的畫布。

語法: rotate(angle),angle表示旋轉的弧度,這裏須要將角度轉換爲弧度計算,好比順時針旋轉90°,angle的值就等於-90 * Math.PI / 180。ratate旋轉時默認以畫布左上角爲中心,若是須要以畫布中心位置爲中心,須要在rotate方法執行前將畫布的座標原點移至中心位置,旋轉完成後,再移動回來。以下:

const { width, height } = canvas;
context.translate(width / 2, height / 2); // 座標原點移至畫布中心
context.rotate(90 * Math.PI / 180); // 順時針旋轉90°
context.translate(-width / 2, -height / 2); // 座標原點還原到起始位置

實際上,這種變換處理,使用transform(Math.cos(90 * Math.PI / 180), 1, -1, Math.cos(90 * Math.PI / 180), 0, 0)一樣能夠順時針旋轉90°。

drawImage

drawImage用於繪製圖片、畫布或者視頻,可自定義寬高、位置、甚至局部裁剪。它有三種形態的api:

  • drawImage(img,x,y),x,y爲畫布中的座標,img能夠是圖片、畫布或視頻資源,表示在畫布的指定座標處繪製。

  • drawImage(img,x,y,width,height),width,height表示指定圖片繪製後的寬高(能夠任意縮放或調整寬高比例)。

  • context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height),sx,sy表示從指定的座標位置裁剪原始圖片,而且裁剪swidth的寬度和sheight的高度。

一般狀況下,咱們可能須要旋轉一張圖片90°、180°或者-90°。代碼以下:

const rotate = (degree, image) => {
  degree = ~~degree;
  if (degree !== 0) {
    const maxDegree = 180;
    const minDegree = -90;
    if (degree > maxDegree) {
      degree = maxDegree;
    } else if (degree < minDegree) {
      degree = minDegree;
    }

    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    const height = image.height;
    const width = image.width;
    const angle = (degree * Math.PI) / 180;

    switch (degree) {
      // 逆時針旋轉90°
      case -90:
        canvas.width = height;
        canvas.height = width;
        context.rotate(angle);
        context.drawImage(image, -width, 0);
        break;
      // 順時針旋轉90°
      case 90:
        canvas.width = height;
        canvas.height = width;
        context.rotate(angle);
        context.drawImage(image, 0, -height);
        break;
      // 順時針旋轉180°
      case 180:
        canvas.width = width;
        canvas.height = height;
        context.rotate(angle);
        context.drawImage(image, -width, -height);
        break;
    }
    image = canvas;
  }
  return image;
};

縮放

旋轉後的畫布,一般須要進一步格式化其寬高才能上傳。此處仍是利用drawImage去改變畫布寬高,以達到縮小和放大的目的。以下:

const scale = (width, height) => {
  const w = canvas.width;
  const h = canvas.height;
  width = width || w;
  height = height || h;
  if (width !== w || height !== h) {
    const tmpCanvas = document.createElement('canvas');
    const tmpContext = tmpCanvas.getContext('2d');
    tmpCanvas.width = width;
    tmpCanvas.height = height;
    tmpContext.drawImage(canvas, 0, 0, w, h, 0, 0, width, height);
    canvas = tmpCanvas;
  }
  return canvas;
};

上傳

咱們作了這麼多的操做和轉換,最終的目的仍是上傳圖片。

首先,獲取畫布中的圖片:

const getPNGImage = () => {
  return canvas.toDataURL('image/png');
};

getPNGImage方法返回的是dataURL,須要轉換爲Blob對象才能上傳。以下:

const dataURLtoBlob = (dataURL) => {
  const arr = dataURL.split(',');
  const mime = arr[0].match(/:(.*?);/)[1];
  const bStr = atob(arr[1]);
  let n = bStr.length;
  const u8arr = new Uint8Array(n);
  while (n--) {
    u8arr[n] = bStr.charCodeAt(n);
  }
  return new Blob([u8arr], { type: mime });
};

完成了上面這些,才能一波ajax請求(xhr、fetch、axios均可)帶走簽名圖片。

const upload = (blob, url, callback) => {
  const formData = new FormData();
  const xhr = new XMLHttpRequest();
  xhr.withCredentials = true;
  formData.append('image', blob, 'sign');

  xhr.open('POST', url, true);
  xhr.onload = () => {
    if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
      callback(xhr.responseText);
    }
  };
  xhr.onerror = (e) => {
    console.log(`upload img error: ${e}`);
  };
  xhr.send(formData);
};

設置

完成了上述功能,一個簽名插件就已經成型了。除非你火燒眉毛想要發佈,不然,這樣的代碼我是不建議拿出去的。一些必要的設置一般是不能忽略的。

一般畫布中的直線是1px大小,這麼細的線,是不能模擬筆觸的,可若是你要放大至10px,便會發現,繪製的直線實際上是矩形。這在簽名過程當中也是不合適的,咱們指望的是圓滑的筆觸,所以須要儘可能模擬手寫。實際上,lineCap就可指定直線首尾圓滑,lineJoin能夠指定線條交匯時的邊角圓滑。以下是一個simple的設置:

context.lineWidth = 10;         // 直線寬度
context.strokeStyle = 'black';     // 路徑的顏色
context.lineCap = 'round';         // 直線首尾端圓滑
context.lineJoin = 'round';     // 當兩條線條交匯時,建立圓形邊角
context.shadowBlur = 1;         // 邊緣模糊,防止直線邊緣出現鋸齒
context.shadowColor = 'black';  // 邊緣顏色

優化

一切看似很完美,直到遇到了retina屏幕。retina屏是用4個物理像素繪製一個虛擬像素,屏幕寬度相同的畫布,其每一個像素點都會由4倍物理像素去繪製,畫布中點與點之間的距離增長,會產生較爲明顯的鋸齒,可經過放大畫布而後壓縮展現來解決這個問題。

let { width, height } = window.getComputedStyle(canvas, null);
width = width.replace('px', '');
height = height.replace('px', '');

// 根據設備像素比優化canvas繪圖
const devicePixelRatio = window.devicePixelRatio;
if (devicePixelRatio) {
  canvas.style.width = `${width}px`;
  canvas.style.height = `${height}px`;
  canvas.height = height * devicePixelRatio; // 畫布寬高放大
  canvas.width = width * devicePixelRatio;
  context.scale(devicePixelRatio, devicePixelRatio); // 畫布內容放大相同的倍數
} else {
  canvas.width = width;
  canvas.height = height;
}

重置座標系

因爲採起了方案一,簽名的工做流變成了:『頁面順時針旋轉90°繪製、畫布正常豎屏繪製』—>『手寫簽名』—>『逆時針旋轉畫布90°』—> 『合理縮放畫布至屏幕寬度』—> 『導出圖片並上傳』。因而可知方案一流程複雜,處理起來也比較麻煩。

換個角度想一想,既然畫布是能夠旋轉的,我恰好能夠利用這種座標系的反向旋轉去抵消頁面的正向旋轉,這樣頁面上點的座標就能夠映射到畫布自己的座標上。因而有了方案二。

方案二:頁面順時針旋轉90°,畫布跟隨着一塊兒旋轉(畫布的座標系也跟着旋轉90°);而後再逆向旋轉畫布90°,重置畫布的座標系,使之與頁面座標系映射起來。

順時針旋轉90°的頁面以下所示:

頁面順時針旋轉90°

此時canvas畫布也隨着頁面順時針旋轉90°,想要重置畫布座標系,可藉由rotate逆向旋轉90°,而後由translate平移座標系。如下代碼包含了順逆時針旋轉90°、180° 的處理(爲了便於描述,假設畫布充滿屏幕):

context.rotate((degree * Math.PI) / 180);
switch (degree) {
  // 頁面順時針旋轉90°後,畫布左上角的原點位置落到了屏幕的右上角(此時寬高互換),圍繞原點逆時針旋轉90°後,畫布與原位置垂直,居於屏幕右側,須要向左平移畫布當前高度相同的距離。
  case -90:
    context.translate(-height, 0);
    break;
  // 頁面逆時針旋轉90°後,畫布左上角的原點位置落到了屏幕的左下角(此時寬高互換),圍繞原點順時針旋轉90°後,畫布與原位置垂直,居於屏幕下側,須要向上平移畫布當前寬度相同的距離。
  case 90:
    context.translate(0, -width);
    break;
  // 頁面順逆時針旋轉180°回到了同一個位置(即頁面倒立),畫布左上角的原點位置落到了屏幕的右下角(此時寬高不變),圍繞原點反方向旋轉180°後,畫布與原位置平行,居於屏幕右側的下側,須要向左平移畫布寬度相同的距離,向右平移畫布高度的距離。
  case -180:
  case 180:
    context.translate(-width, -height);
}

擁有了對畫布座標系重置的能力,咱們可以將畫布逆時針旋轉90°、甚至180°,都是可行的。以下:

頁面逆時針旋轉90°

頁面順時針旋轉180°

固然重置畫布座標系後,須要注意清屏時,清屏的範圍也有可能發生變化,須要稍做以下處理。

const clear = () => {
  let width;
  let height;
  switch (this.degree) { // this.degree是畫布座標系旋轉的度數
    case -90:
    case 90:
      width = this.height; // 畫布旋轉以前的高度
      height = this.width; // 畫布選擇以前的寬度
      break;
    default:
      width = this.width;
      height = this.height;
  }
  this.context.clearRect(0, 0, width, height);
};

方案一簡單粗暴,佈局上,canvas畫布雖然不須要旋轉,但須要單獨絕對定位佈局,給頁面視覺展現帶來不便,同時,上傳圖片以前須要對圖片作旋轉、縮放等處理,流程複雜。

方案二用糾正畫布座標系的方式,省去了佈局和圖片上的特殊處理,一步到位,所以方案二更佳。

以上,涉及的代碼能夠在這裏找到:canvas-draw,這是一個藉助vue cli 搭建起來的殼,主要是爲了方便調試,核心代碼見 canvas-draw/draw.js,喜歡的同窗不妨輕點star。


本問就討論這麼多內容,你們有什麼問題或好的想法歡迎在下方參與留言和評論.

本文做者:louis

本文連接: http://louiszhai.github.io/20...

參考文章:

相關文章
相關標籤/搜索