本文與個人 Segmentfault專欄 同步。javascript
最近折騰 Websocket,打算開發一個聊天室應用練練手。在應用開發的過程當中發現能夠插入 emoji ,粘貼圖片的富文本輸入框其實蘊含着許多有趣的知識,因而便打算記錄下來和你們分享。html
倉庫地址:chat-input-boxjava
預覽地址:codepengit
首先來看看 demo 效果:github
是否是以爲很神奇?接下來我會一步步講解這裏面的功能都是如何實現的。canvas
傳統的輸入框都是使用 <textarea>
來製做的,它的優點是很是簡單,但最大的缺陷倒是沒法展現圖片。爲了可以讓輸入框可以展現圖片(富文本化),咱們能夠採用設置了 contenteditable="true"
屬性的 <div>
來實現這裏面的功能。segmentfault
簡單建立一個 index.html
文件,而後寫入以下內容:瀏覽器
<div class="editor" contenteditable="true">
<img src="https://static.easyicon.net/preview/121/1214124.gif" alt="">
</div>
複製代碼
打開瀏覽器,就能看到一個默認已經帶了一張圖片的輸入框:dom
光標能夠在圖片先後移動,同時也能夠輸入內容,甚至經過退格鍵刪除這張圖片——換句話說,圖片也是可編輯內容的一部分,也意味着輸入框的富文本化已經體現出來了。socket
接下來的任務,就是思考如何直接經過 control + v
把圖片粘貼進去了。
任何經過「複製」或者 control + c
所複製的內容(包括屏幕截圖)都會儲存在剪貼板,在粘貼的時候能夠在輸入框的 onpaste
事件裏面監聽到。
document.querySelector('.editor').addEventListener('paste', (e) => {
console.log(e.clipboardData.items)
})
複製代碼
而剪貼板的的內容則存放在 DataTransferItemList
對象中,能夠經過 e.clipboardData.items
訪問到:
細心的讀者會發現,若是直接在控制檯點開 DataTransferItemList
前的小箭頭,會發現對象的 length
屬性爲0。說好的剪貼板內容呢?其實這是 Chrome 調試的一個小坑。在開發者工具裏面,console.log
出來的對象是一個引用,會隨着原始數據的改變而改變。因爲剪貼板的數據已經被「粘貼」進輸入框了,因此展開小箭頭之後看到的 DataTransferItemList
就變成空的了。爲此,咱們能夠改用 console.table
來展現實時的結果。
在明白了剪貼板數據的存放位置之後,就能夠編寫代碼來處理它們了。因爲咱們的富文本輸入框比較簡單,因此只須要處理兩類數據便可,其一是普通的文本類型數據,包括 emoji 表情;其二則是圖片類型數據。
新建 paste.js
文件:
const onPaste = (e) => {
// 若是剪貼板沒有數據則直接返回
if (!(e.clipboardData && e.clipboardData.items)) {
return
}
// 用Promise封裝便於未來使用
return new Promise((resolve, reject) => {
// 複製的內容在剪貼板裏位置不肯定,因此經過遍從來保證數據準確
for (let i = 0, len = e.clipboardData.items.length; i < len; i++) {
const item = e.clipboardData.items[i]
// 文本格式內容處理
if (item.kind === 'string') {
item.getAsString((str) => {
resolve(str)
})
// 圖片格式內容處理
} else if (item.kind === 'file') {
const pasteFile = item.getAsFile()
// 處理pasteFile
// TODO(pasteFile)
} else {
reject(new Error('Not allow to paste this type!'))
}
}
})
}
export default onPaste
複製代碼
而後就能夠在 onPaste
事件裏面直接使用了:
document.querySelector('.editor').addEventListener('paste', async (e) => {
const result = await onPaste(e)
console.log(result)
})
複製代碼
上面的代碼支持文本格式,接下來就要對圖片格式進行處理了。玩過 <input type="file">
的同窗會知道,包括圖片在內的全部文件格式內容都會儲存在 File
對象裏面,這在剪貼板裏面也是同樣的。因而咱們能夠編寫一套通用的函數,專門來讀取 File
對象裏的圖片內容,並把它轉化成 base64
字符串。
爲了更好地在輸入框裏展現圖片,必須限制圖片的大小,因此這個圖片處理函數不只可以讀取 File
對象裏面的圖片,還可以對其進行壓縮。
新建一個 chooseImg.js
文件:
/** * 預覽函數 * * @param {*} dataUrl base64字符串 * @param {*} cb 回調函數 */
function toPreviewer (dataUrl, cb) {
cb && cb(dataUrl)
}
/** * 圖片壓縮函數 * * @param {*} img 圖片對象 * @param {*} fileType 圖片類型 * @param {*} maxWidth 圖片最大寬度 * @returns base64字符串 */
function compress (img, fileType, maxWidth) {
let canvas = document.createElement('canvas')
let ctx = canvas.getContext('2d')
const proportion = img.width / img.height
const width = maxWidth
const height = maxWidth / proportion
canvas.width = width
canvas.height = height
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0, width, height)
const base64data = canvas.toDataURL(fileType, 0.75)
canvas = ctx = null
return base64data
}
/** * 選擇圖片函數 * * @param {*} e input.onchange事件對象 * @param {*} cb 回調函數 * @param {number} [maxsize=200 * 1024] 圖片最大致積 */
function chooseImg (e, cb, maxsize = 200 * 1024) {
const file = e.target.files[0]
if (!file || !/\/(?:jpeg|jpg|png)/i.test(file.type)) {
return
}
const reader = new FileReader()
reader.onload = function () {
const result = this.result
let img = new Image()
if (result.length <= maxsize) {
toPreviewer(result, cb)
return
}
img.onload = function () {
const compressedDataUrl = compress(img, file.type, maxsize / 1024)
toPreviewer(compressedDataUrl, cb)
img = null
}
img.src = result
}
reader.readAsDataURL(file)
}
export default chooseImg
複製代碼
關於使用
canvas
壓縮圖片和使用FileReader
讀取文件的內容在這裏就不贅述了,感興趣的讀者能夠自行查閱。
回到上一步的 paste.js
函數,把其中的 TODO()
改寫成 chooseImg()
便可:
const imgEvent = {
target: {
files: [pasteFile]
}
}
chooseImg(imgEvent, (url) => {
resolve(url)
})
複製代碼
回到瀏覽器,若是咱們複製一張圖片並在輸入框中執行粘貼的動做,將能夠在控制檯看到打印出了以 data:image/png;base64
開頭的圖片地址。
通過前面兩個步驟,咱們後已經能夠讀取剪貼板中的文本內容和圖片內容了,接下來就是把它們正確的插入輸入框的光標位置當中。
對於插入內容,咱們能夠直接經過 document.execCommand
方法進行。關於這個方法詳細用法能夠在MDN文檔裏面找到,在這裏咱們只須要使用 insertText
和 insertImage
便可。
document.querySelector('.editor').addEventListener('paste', async (e) => {
const result = await onPaste(e)
const imgRegx = /^data:image\/png;base64,/
const command = imgRegx.test(result) ? 'insertImage': 'insertText'
document.execCommand(command, false, result)
})
複製代碼
可是在某些版本的 Chrome 瀏覽器下,insertImage
方法可能會失效,這時候即可以採用另一種方法,利用 Selection
來實現。而以後選擇並插入 emoji 的操做也會用到它,所以不妨先來了解一下。
當咱們在代碼中調用 window.getSelection()
後會得到一個 Selection
對象。若是在頁面中選中一些文字,而後在控制檯執行 window.getSelection().toString()
,就會看到輸出是你所選擇的那部分文字。
與這部分區域文字相對應的,是一個 range
對象,使用 window.getSelection().getRangeAt(0)
便可以訪問它。range
不只包含了選中區域文字的內容,還包括了區域的起點位置 startOffset
和終點位置 endOffset
。
咱們也能夠經過 document.createRange()
的辦法手動建立一個 range
,往它裏面寫入內容並展現在輸入框中。
對於插入圖片來講,要先從 window.getSelection()
獲取range
,而後往裏面插入圖片。
document.querySelector('.editor').addEventListener('paste', async (e) => {
// 讀取剪貼板的內容
const result = await onPaste(e)
const imgRegx = /^data:image\/png;base64,/
// 若是是圖片格式(base64),則經過構造range的辦法把<img>標籤插入正確的位置
// 若是是文本格式,則經過document.execCommand('insertText')方法把文本插入
if (imgRegx.test(result)) {
const sel = window.getSelection()
if (sel && sel.rangeCount === 1 && sel.isCollapsed) {
const range = sel.getRangeAt(0)
const img = new Image()
img.src = result
range.insertNode(img)
range.collapse(false)
sel.removeAllRanges()
sel.addRange(range)
}
} else {
document.execCommand('insertText', false, result)
}
})
複製代碼
這種辦法也能很好地完成粘貼圖片的功能,而且通用性會更好。接下來咱們還會利用 Selection
,來完成 emoji 的插入。
不論是粘貼文本也好,仍是圖片也好,咱們的輸入框始終是處於聚焦(focus)狀態。而當咱們從表情面板裏選擇 emoji 表情的時候,輸入框會先失焦(blur),而後再從新聚焦。因爲 document.execCommand
方法必須在輸入框聚焦狀態下才能觸發,因此對於處理 emoji 插入來講就沒法使用了。
上一小節講過,Selection
可讓咱們拿到聚焦狀態下所選文本的起點位置 startOffset
和終點位置 endOffset
,若是沒有選擇文本而僅僅處於聚焦狀態,那麼這兩個位置的值相等(至關於選擇文本爲空),也就是光標的位置。只要咱們可以在失焦前記錄下這個位置,那麼就可以經過 range
把 emoji 插入正確的地方了。
首先編寫兩個工具方法。新建一個 cursorPosition.js
文件:
/** * 獲取光標位置 * @param {DOMElement} element 輸入框的dom節點 * @return {Number} 光標位置 */
export const getCursorPosition = (element) => {
let caretOffset = 0
const doc = element.ownerDocument || element.document
const win = doc.defaultView || doc.parentWindow
const sel = win.getSelection()
if (sel.rangeCount > 0) {
const range = win.getSelection().getRangeAt(0)
const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(element)
preCaretRange.setEnd(range.endContainer, range.endOffset)
caretOffset = preCaretRange.toString().length
}
return caretOffset
}
/** * 設置光標位置 * @param {DOMElement} element 輸入框的dom節點 * @param {Number} cursorPosition 光標位置的值 */
export const setCursorPosition = (element, cursorPosition) => {
const range = document.createRange()
range.setStart(element.firstChild, cursorPosition)
range.setEnd(element.firstChild, cursorPosition)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
}
複製代碼
有了這兩個方法之後,就能夠放入 editor 節點裏面使用了。首先在節點的 keyup
和 click
事件裏記錄光標位置:
let cursorPosition = 0
const editor = document.querySelector('.editor')
editor.addEventListener('click', async (e) => {
cursorPosition = getCursorPosition(editor)
})
editor.addEventListener('keyup', async (e) => {
cursorPosition = getCursorPosition(editor)
})
複製代碼
記錄下光標位置後,即可經過調用 insertEmoji()
方法插入 emoji 字符了。
insertEmoji (emoji) {
const text = editor.innerHTML
// 插入 emoji
editor.innerHTML = text.slice(0, cursorPosition) + emoji + text.slice(cursorPosition, text.length)
// 光標位置後挪一位,以保證在剛插入的 emoji 後面
setCursorPosition(editor, this.cursorPosition + 1)
// 更新本地保存的光標位置變量(注意 emoji 佔兩個字節大小,因此要加1)
cursorPosition = getCursorPosition(editor) + 1 // emoji 佔兩位
}
複製代碼
文章涉及的代碼已經上傳到倉庫,爲了簡便起見採用 VueJS
處理了一下,不影響閱讀。最後想說的是,這個 Demo 僅僅完成了輸入框最基礎的部分,關於複製粘貼還有不少細節要處理(好比把別處的行內樣式也複製了進來等等),在這裏就不一一展開了,感興趣的讀者能夠自行研究,更歡迎和我留言交流~