目前富文本編輯器的實現主要有兩種技術方案:一個是利用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; }
下圖描述了項目核心結構、流程:
其中,樣式切換是一個關鍵流程。項目中將樣式配置統一保存在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文本渲染,對中文筆畫分割實現有趣的動畫等)並無描寫。這些未來會單開博文來寫。
同時項目還有許多經常使用功能沒有實現,好比光標位置切換不支持上下鍵,沒法選擇文本等,這些留做之後完善吧。