用Canvas實現文本編輯器(支持藝術字渲染與動畫)

導言

目前富文本編輯器的實現主要有兩種技術方案:一個是利用contenteditable屬性直接對html元素進行編輯,如draft.js;另外一種是代理textarea + 自定義div + 模擬光標實現。對於相似"word"的經典富文本編輯器,通常會採用以上兩種技術方案之一,而不會考慮用canvas實現。css

事實上,官方最佳實踐中已經特別聲明瞭不推薦用canvas實現編輯器,詳見https://www.w3.org/TR/2dconte...
不推薦的緣由包括光標位置維護、鍵盤移動的實現、以及沒有原生文本輸入處理等等。html

既然如此,爲什麼還要用canvas製做文本編輯器呢?這是由於對一些特殊的創做來講,canvas能更好的實現展現需求。好比藝術字效果的渲染,以及文本、背景動畫等。git

基於這點想法,便有了「簡詩」這個自娛自樂的小項目。github

簡詩是爲短詩文創做而開發的文本編輯器,主要面向中文寫做。中文最特別之處便在於其筆畫,因此在開發之初,我便想對文字進行處理之時,必定要把漢字進行筆畫分割,以便實現更多有趣的效果的。canvas

項目中文字由WebGL進行渲染。基本思路是先根據用戶選擇的字體,將文字寫在離屏canvas上,而後利用getImageData api獲取文字像素數據,進行連通域查詢、分割、邊緣查找及三角化後,由WebGL進行渲染。api

(注:這種處理方式的好處是對任意系統支持的字體均可以實現藝術效果,而無需額外的字體開發。目前項目中沒有引入字體文件,用到的字體都是Mac內置的字體,Mac用戶如發現其中有的字體系統沒有默認安裝,只需到「字體冊」中安裝一下便可)數組

這一系列過程會單開一篇文章來寫,本文主要描述canvas編輯器核心的實現。架構

實現效果

圖片描述

預覽地址:https://moyuer1992.github.io/...
源碼地址:https://github.com/moyuer1992...app

技術關鍵點

文字鍵入(代理輸入框)

用canvas實現編輯器最關鍵的一點就是如何監聽鍵盤文字輸入,若是經過鍵盤事件本身處理,英文尚可,中文確定是不可行的。因此仍是須要使用原生textarea作一層代理。編輯器

代理textarea輸入框是不可見的。這裏需特別注意下,若用display: none隱藏輸入框,則沒法觸發focus事件,因此輸入框須要利用z-index來作隱藏。

當用戶點擊canvas時,程序控制觸發textarea的focus事件,繼而用戶輸入時,也天然觸發了textarea的input事件:

var pos = this._convertWindowPosToCanvas(e.clientX, e.clientY);
if (pos.x !== -1 && pos.y !== -1) {
  this.focus(pos.x, pos.y);
} else {
  this.blur();
}
focus (x, y) {
  var pos = this.findPosfromMap(x, y);
  this.selection.update(pos.row, pos.col);
  this.updateCursor();
  this.$input.focus();
  this.$cursor.css('visibility', 'visible');
  this.onFocus = true;
}

中文輸入

按照上述方法,很容易想處處理文本輸入的流程:

  • 監聽隱藏輸入框的input事件

  • 觸發input事件時,將輸入框value取出,渲染到canvas中對應位置

  • 清空輸入框,繼續監聽

然而,當輸入中文時,一些輸入法會出現這種現象:
圖片描述

顯然,當使用中文輸入法鍵入拼音時,拼音字母已經寫入輸入框中,觸發了input事件,但事實上用戶並無鍵入完畢。這就致使了最終拼音字母和漢字所有被寫到了canvas上,這並不是咱們想要的結果。

如何解決呢?這裏須要用到input元素的onCompStart和onCompEnd事件。

當中文輸入開始時,會觸發onCompStart事件,此時作一個標記,告知程序用戶正在中文輸入,input事件觸發時,判斷當前是否正在鍵入中文,如果,則不做任何操做。待onCompEnd觸發時,取消中文輸入標記,將文字渲染到canvas上。

this.$input.on('compositionstart', this.onCompStart.bind(this));
this.$input.on('compositionend', this.onCompEnd.bind(this));
this.$input.on('input', this.onInputChar.bind(this));
onCompStart (e) {
  this.inputStatus = 'CHINESE_TYPING';
}

onCompEnd (e) {
  var that = this;
  setTimeout(function () {
    that.input();
    that.inputStatus = 'CHINESE_TYPE_END';
  }, 100)
}

onInputChar (e) {
  if (this.inputStatus === 'CHINESE_TYPING') {
    return;
  }

  this.inputStatus = 'CHAR_TYPING';
  this.input();
}

虛擬光標

用canvas實現編輯器須要模擬光標,這裏用一個div來實現,設置position爲absolute,用top、left來定位光標位置。

this.$cursor = $('<div class="cursor"></div>');
this.cursorNode = this.$cursor.get(0);
this.$cursor.css('width', '1px');
this.$cursor.css('height', this.style.lineHeight() + 'px');
this.$cursor.css('position', 'absolute');
this.$cursor.css('top', this.selection.rowIndex * this.style.lineHeight());
this.$cursor.css('left', this.selection.colIndex * this.fontSize);
this.$cursor.css('background-color', 'black');

用css動畫實現光標1s閃動一次。

@keyframes cursor {
  from {
    opacity: 0;
  }

  50% {
    opacity: 1;
  }

  to {
    opacity: 0;
  }
}

.cursor {
  animation: cursor 1s ease infinite;
}

原理雖然簡單,可是隨着文字、排版、用戶操做變動,如何維護光標位置,是一件較爲繁瑣的事。

這裏定義了Selection類以存儲用戶選擇區域。未選擇任何文本的狀況下,selection位置及爲光標所在位置。(目前此項目還沒有支持選擇文本功能,但Selection類的設計方式對之後此功能的添加是支持的。)

selection對象中,位置存儲徹底是針對文本矩陣的,而非對應屏幕上真正的座標。項目中另外定義了map矩陣存儲文本位置數據。map的具體設計下面一節會詳細講到。

更新光標函數以下:

updateCursor () {
  var pos = this.selection.getSelEndPosition();
  this.$cursor.css('height', this.style.lineHeight() + 'px');
  this.$cursor.css('left', this.map[pos.rowIndex][pos.colIndex].cursorX + 'px');
  this.$cursor.css('top', this.map[pos.rowIndex][pos.colIndex].cursorY + 'px');
}

文字排版

上一節中已經提到,項目中定義了map矩陣存儲文本位置信息。每次渲染文字時,會依據當前樣式(版式、文字大小等)更新map數據。
目前項目支持居中和左對齊兩個版式,map更新時,這兩個版式的位置計算有所不一樣。

對於左對齊版式,邏輯比較簡單,只要從左邊邊距處開始,逐個寫入文字,直至換行便可。
而對於居中版式,邏輯要稍微複雜一些,處理每段文字時,要先根據每段文字總長度、canvas寬度、邊距大小來肯定文字位置。若是此段文字不足一行,則直接居中顯示,若超過一行,將每行填滿後,對不足一行的部分居中顯示。

每一個map元素結構以下:

{
  char: 對應字符/文字,
  x: 文字起始x座標,
  y: 文字起始y座標,
  cursorX: 對應光標x座標,
  cursorY: 對應光標y座標
}

動畫精靈

之因此用canvas實現文本編輯器,即是爲了藝術效果的渲染以及文字、背景動畫。項目但願實現文字、背景樣式的自由切換,爲了下降耦合度,爲每種文字、背景樣式單獨定義精靈。

文本精靈基類:https://github.com/moyuer1992...
文本精靈文件夾:https://github.com/moyuer1992...
背景精靈基類:https://github.com/moyuer1992...
背景精靈文件夾:https://github.com/moyuer1992...

精靈類中的核心是drawStatic、drawFrame、advance三個方法。
advance函數中,對進入下一幀時須要改變的參數進行定義。

drawStatic用於靜態效果的渲染。Editor類中,每次須要從新渲染靜態文字時,都會調用此方法。

_fillText () {
  if (this.map.length === 1 && this.map[0].length === 1) {
    this.clearText();
  } else {
    $('.render-tip').addClass('show');
    setTimeout(this.textSprite.drawStatic.bind(this.textSprite), 0);
  }
}

drawFrame用於動畫效果每一幀的渲染,當動畫播放時,會逐幀調用此方法。

play () {
  this.animating = true;
  this.animationInfo = {
    textStop: false,
    bgStop: false
  };
  this.startTime = Date.now();
  this.textSprite.update();
  this.bgSprite.update();

  window.requestAnimationFrame(this.tick.bind(this));
}
tick () {
  if (!this.animating) {
    return;
  }

  var t = Date.now() - this.startTime;
  !this.animationInfo.textStop && (this.animationInfo.textStop = this.textSprite.advance(t));
  !this.animationInfo.bgStop && (this.animationInfo.bgStop = this.bgSprite.advance(t));

  if (this.animationInfo.textStop && this.animationInfo.bgStop) {
    this.stopPlay();
  } else {
    this.animationInfo.bgStop ? this.bgSprite.drawStatic() : this.bgSprite.drawFrame();
    this.animationInfo.textStop ? this.textSprite.drawStatic() : this.textSprite.drawFrame();
    window.requestAnimationFrame(this.tick.bind(this));
  }
}

程序架構

圖片描述

程序的總體架構如上圖所示,在入口main.js中,直接新建Editor類實例,並初始化UI組件。

項目中最核心的部分就是Editor類。

Editor包含的數據:

  • data對象,用於存儲文本數據

  • selection對象,用於存儲選擇信息

  • style對象,用於存儲當前樣式信息

  • map矩陣,用於存儲當前文本對應位置

Editor包含的渲染精靈

  • bgSprite, 當前渲染背景的精靈

  • textSprite, 當前渲染文字的精靈

Editor包含的節點元素:

  • $input, 隱藏輸入框

  • $canvas, 用於渲染普通canvas文本

  • $glcanvas, 用於渲染WebGL文本

  • $bgCanvas, 用於渲染普通背景

  • $bgGlcanvas, 用於渲染WebGL背景

這裏須要解釋一下爲什麼將文本、背景進行解耦分層。

首先, 每一個canvas一旦調用getContext('2d')方法,再調用getContext('WebGL')方法則會返回null。也就是說,同一個canvas只能獲取普通2d context和WebGL context中的一個,這意味着咱們沒法同時調用WebGL api和原生canvas api。因此對於文字或背景的渲染,都分紅WebGL和原生canvas兩種。

另外,因爲項目中文本、背景樣式均可以自由切換,若都用同一個canvas進行渲染,保持文本樣式不變,而對背景樣式進行切換時,則整個canvas都要重繪。爲避免這樣的開銷,項目中將文本、背景進行分層繪製。

此處或許有人會考慮到最終圖像保存的問題。是的,進行分層後,圖像保存須要另外作一些處理,但並不太複雜,只需將每層canvas圖像逐層繪製到一個離屏canvas上便可。

例如,導出png格式圖片代碼以下:

generatePng () {
  var canvas = document.createElement('canvas');
  canvas.width = this.canvasNode.width;
  canvas.height = this.canvasNode.height;
  var ctx = canvas.getContext('2d');
  ctx.drawImage(this.bgCanvasNode, 0, 0);
  ctx.drawImage(this.bgGlcanvasNode, 0, 0);
  ctx.drawImage(this.canvasNode, 0, 0);
  ctx.drawImage(this.glcanvasNode, 0, 0);

  var imgData = canvas.toDataURL("image/png");
  return imgData;
}

下圖描述了項目核心結構、流程:
clipboard.png

其中,樣式切換是一個關鍵流程。項目中將樣式配置統一保存在config.js文件中。
其中樣式索引保存在config.state對象中:

state: {
  fontIndex: 0,
  fontSizeIndex: 0,
  fontColorIndex: 0,
  textStyleIndex: 0,
  textAlignIndex: 0,
  backgroundIndex: 0,
  animationIndex: 1,
  bgColorIndex: 0
}

而對應可切換的樣式定義保存在相應map數組中。舉個例子,對背景樣式的配置以下:

backgroundMap: [
  {
    Klass: 'PureBgSprite',
    label: '純色',
    value: 0,
    colors: ['rgb(235, 235, 235)', '#FEFEFE', '#3a3a3a']
  },
  {
    Klass: 'TreeBgSprite',
    label: '月下林間',
    value: 1,
    colors: ['rgb(235, 235, 235)', '#b1a69b', '#3a3a3a']
  }
]

backgroundMap數組中每項對應一個樣式選擇,Klass描述了定義該樣式的精靈類名,label定義了工具欄中顯示的樣式名稱,value即對應的樣式索引,colors定義了該背景支持的切換顏色。

每次切換背景樣式時,程序會根據Klass獲取相應精靈實例,並將editor對象中的bgSprite指向該精靈實例。這裏特別注意一下,爲保證每一個精靈對象從始至終都只有一個實例,這裏應用了單例模式。

根據類名獲取對象實例的方法定義以下:

getSpriteEntity: function () {
  var entities = [];
  return function (className, editor) {
    var Klass = eval(className);
    return entities[className] ? entities[className] : entities[className] = new Klass(editor);
  };
}()

每次樣式切換時,會把map中定義的具體參數賦給style對象,渲染時根據樣式參數進行不一樣處理。

後續

到此爲止,本文主要描述了編輯器的架構以及實現。而其中一些有趣的細節實現(如WebGL文本渲染,對中文筆畫分割實現有趣的動畫等)並無描寫。這些未來會單開博文來寫。

同時項目還有許多經常使用功能沒有實現,好比光標位置切換不支持上下鍵,沒法選擇文本等,這些留做之後完善吧。

相關文章
相關標籤/搜索