富文本原理了解一下?

緣起

最近產品想讓我在富文本里加個旋轉圖片的功能,我一想🤔,就以爲事情並不簡單,由於印象中好像沒見過這種操做。果真,通過一番百度以後,確實沒怎麼看到相關信息,這也就意味着要本身動手豐衣足食了😢。但我本身對富文本又沒什麼瞭解,因此順帶稍微看了下富文本的實現方式,特此來沉澱一下,仍是那句話不喜勿噴哈🙄。
ok,這裏先簡要說下爲何會有富文本這種東西吧🤓!大概可能也許是由於有一天產品用着用着 textarea 感受太單調了,單純的文字已經沒法表達他們心裏的需求🤯,因而就想來點樣式,順便加個圖片,來篇圖文並茂的文章,就像小型 Word 那樣,就再好不過了!因而富文本就這樣誕生了,開發者們也紛紛開始了踩坑之旅🕳🕳🕳。javascript

前置知識

好了,交代完了背景,讓咱們先補充一些基礎知識吧,不懂的請務必不要跳過🧐!css

contenteditable 屬性

假如咱們給一個標籤加上 contenteditable="true" 的屬性,就像這樣:html

<div contenteditable="true"></div>
複製代碼

那麼在這個 div 中咱們就能夠對其進行任意編輯了。若是想要插入的子節點不可編輯,咱們只須要把子節點的屬性設置爲 contenteditable="false" 便可,就像這樣:java

<div contenteditable="true">
    <p>這是可編輯的</p>
    <p contenteditable="false">這是不可編輯的</p>
</div>
複製代碼

該屬性最先是在 IE 上實現的(厲害哦👍),且能夠做用於其它標籤,不限於 div,你們應該或多或少都據說過這個屬性。chrome

document.execCommand 方法

既然咱們能夠對上面的 div 隨意編輯,那具體怎麼編輯呢,目前好像也仍是隻能輸入文本,要怎樣才能進行其餘操做呢(好比加粗、傾斜、插入圖片等等)🤔?其實瀏覽器給咱們提供了這樣的一個方法 document.execCommand,經過它咱們就可以操縱上面的可編輯區。具體語法以下:npm

// document.execCommand(命令名稱,是否展現用戶界面,命令須要的額外參數)
document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
複製代碼

其中第一個參數就是一些命令名稱,具體的能夠查看 MDN;第二個參數寫死爲 false 就好了,由於早前 IE 有這樣一個參數,爲了兼容吧,不過這個參數在現代瀏覽器中是沒有影響的;第三個參數就是一些命令可能須要額外的參數,好比插入圖片就要多傳個 urlbase64 的參數,沒有的話傳個 null 就行。
咱們簡要列舉下它的幾個使用方式,你們就知道怎麼用了👇:瀏覽器

// 加粗
document.execCommand('bold', false, null);
// 添加圖片
document.execCommand('insertImage', false, url || base64);
// 把一段文字用 p 標籤包裹起來
document.execCommand('formatblock', false, '<p>');
複製代碼

這個命令就是富文本的核心(因此務必記住),瀏覽器把大部分咱們能想到的功能也都實現了,固然各瀏覽器之間仍是有差別的,這裏就不考慮了。服務器

Selection 和 Range 對象

咱們在執行 document.execCommand 這個命令以前首先要知道對誰執行,因此這裏會有一個選區的概念,也就是 Selection 對象,它用來表示用戶選擇的範圍或光標位置(光標能夠看作是範圍重合的特殊狀態),一個頁面用戶可能選擇多個範圍(好比 Firefox)。也就是說 Selection 包含一個或多個 Range 對象(Selection 能夠說是 Range 的集合),固然對於富文本編輯器來講,通常狀況下,咱們只會有一個選擇區域,也就是一個 Range 對象,事實上大部分狀況也是如此。
因此一般咱們能夠用 let range = window.getSelection().getRangeAt(0) 來獲取選中的內容信息(getRangeAt 接受一個索引值,由於會有多個 Range,而如今只有一個,因此寫0)。
看得一頭霧水😴?不要緊,看下面兩張圖就懂了😮: app

一句話說就是:經過上面那句命令咱們可以獲取到當前的選中信息,通常會先保存下來,而後在須要的時候還原。此外 Selection 對象還有幾個經常使用的方法, addRangeremoveAllRangescollapsecollapseToEnd 等等。
這個知識點是很重要的,由於它讓咱們有了操縱光標的能力(好比插入內容以後設置光標的位置),不過這篇文章中我並無去深刻它,只是淺出😏。

目標

開篇一頓扯,下面讓咱們抓緊時間作一個屬於本身的富文本吧💪,大概會包含如下幾個功能:加粗、段落、H一、水平線、無序列表、插入連接、插入圖片、後退一步、向前一步等等。🆗,Let's do it!編輯器

起步

首先一個富文本大致分爲兩個區域,一個是按鈕區,一個是編輯區。因此它的大體結構就像下面這樣:

<template>
    <div class="xr-editor">
        <!--按鈕區-->
        <div class="nav">
            <button>加粗</button>
            ...
        </div>
        <!--編輯區-->
        <div class="editor" contenteditable="true"></div>
    </div>
</template>
<!--所有樣式就這些,這裏就都先給出來了-->
<style lang="scss"> .xr-editor { margin: 50px auto; width: 1000px; .nav { display: flex; button { cursor: pointer; } &__img { position: relative; input { width: 100%; height: 100%; position: absolute; left: 0; top: 0; opacity: 0; } } } .row { display: flex; width: 100%; height: 300px; } .editor { flex: 1; position: relative; margin-right: 20px; padding: 10px; outline: none; border: 1px solid #000; overflow-y: scroll; img { max-width: 300px; max-height: 300px; vertical-align: middle; } } .content { flex: 1; border: 1px solid #000; word-break: break-all; word-wrap: break-word; overflow: scroll; } } </style>
複製代碼

嗯,起步工做到此結束,接下來就能夠直接開始實現功能了😬。

加粗

如今假如咱們要實現加粗的效果,該怎麼作呢?很簡單,只要在點擊加粗按鈕的時候執行 document.execCommand('bold', false, null) 這句話,就能達到加粗的效果,就像下面這樣:

<template>
    <div class="nav">
        <button @click="execCommand">加粗</button>
    </div>
    ...
</template>
<script> export default { name: 'XrEditor', methods: { execCommand() { document.execCommand('bold', false, null); } } }; </script>
複製代碼

讓咱們運行一下看看效果:

嗯,是的,就是這麼簡單的一句話就能搞定😒。
固然了,咱們開篇也說了咱們的一切命令都是基於 document.execCommand 的,因此咱們先小小改寫一下上面代碼中的 execCommand 方法,就像下面這樣:

<template>
    <div class="nav">
        <button @click="execCommand('bold')">加粗</button>
    </div>
    ...
</template>
<script> export default { name: 'XrEditor', methods: { execCommand(name, args = null) { document.execCommand(name, false, args); } } }; </script>
複製代碼

這樣一來代碼就更具通用性了。實現列表、水平線、前進、後退功能和加粗是同樣樣的,只需傳入不一樣的命令名便可,就像下面這樣,這裏就不一一贅述了:

<button @click="execCommand('insertUnorderedList')">無序列表</button>
<button @click="execCommand('insertHorizontalRule')">水平線</button>
<button @click="execCommand('undo')">後退</button>
<button @click="execCommand('redo')">前進</button>
複製代碼

順帶給你們說幾個注意點✍️:

  1. 有的同窗可能用的不是 button 標籤,而後執行命令就會無效,是由於點擊其餘標籤大多都會形成先失去焦點(或者不知不覺就忽然失去焦點了),再執行點擊事件,此時沒有選區或光標因此會沒有效果,這點要留意一下。
  2. 咱們執行的是原生的 document.execCommand 方法,瀏覽器自身會對 contenteditable 這個可編輯區維護一個 undo 棧和一個 redo 棧,因此咱們才能執行前進和後退的操做,若是咱們改寫了原生方法,就會破壞原有的棧結構,這時就須要本身去維護,那就麻煩了。
  3. style 裏面若是加上 scope 的話,裏面的樣式對編輯區的內容是不生效的,由於編輯區裏面是後來才建立的元素,因此要麼刪了 scope,要麼用 /deep/ 解決(Vue 是這樣)。

段落

這個功能就是把光標所在行的文字用 p 標籤包裹起來,爲了演示方便,咱們順便把編輯區的 html 結構打印出來,因此讓咱們稍微改一下代碼,就像下面這樣:

<template>
    <div class="xr-editor">
        <div class="nav">
            <button @click="execCommand('bold')">加粗</button>
            <button @click="execCommand('formatBlock', '<p>')">段落</button>
        </div>
        <div class="row">
            <div class="editor" contenteditable="true" @input="print"></div>
            <div class="content">{{ html }}</div>
        </div>
    </div>
</template>
<script> export default { name: 'XrEditor', data() { return { html: '' }; }, methods: { execCommand(name, args = null) { document.execCommand(name, false, args); }, print() { this.html = document.querySelector('.editor').innerHTML; } } }; </script>
複製代碼

運行效果以下:

怎麼樣,是否是也很 easy,同理, h1 ~ h6 也是同樣的,命令爲 execCommand('formatBlock', '<h1>'),也不贅述了。

插入連接

這個功能由於須要第三個參數,因此咱們通常會給個提示框獲取用戶輸入,而後再執行 execCommand('createLink', 連接地址),代碼以下:

<button @click="createLink">連接</button>
複製代碼
createLink() {
  let url = window.prompt('請輸入連接地址');
  if (url) this.execCommand('createLink', url);
}
複製代碼

效果以下:

插入圖片連接也是殊途同歸,只不過命令名不同而已:

insertImgLink() {
    let url = window.prompt('請輸入圖片地址');
    if (url) this.execCommand('insertImage', url);
}
複製代碼

插入圖片

圖片除了能夠經過添加地址的形式外,還能夠添加 base64 格式的圖片,這裏咱們經過 readAsDataURL(file) 來讀取圖片,並執行 execCommand('insertImage', base64) 就大功告成啦,具體代碼以下,並不複雜:

<button class="nav__img">插入圖片
    <!--這個 input 是隱藏的-->
    <input type="file" accept="image/gif, image/jpeg, image/png" @change="insertImg">
</button>
複製代碼
insertImg(e) {
    let reader = new FileReader();
    let file = e.target.files[0];
    reader.onload = () => {
        let base64Img = reader.result;
        this.execCommand('insertImage', base64Img);
        document.querySelector('.nav__img input').value = ''; // 解決同一張圖片上傳無效的問題
    };
    reader.readAsDataURL(file);
}
複製代碼

運行一下,看看效果:

這應該也不是很難。固然了,你也能夠先上傳到服務器處理返回 url 地址再插入也是能夠的。
👌至此,一個簡易版的富文本就完成了(固然了 bug 也是有的🤭,不過並不妨礙咱們理解),具體代碼能夠參考 npm 上的 pell 包,它已是個極簡版的了。

進階

其實富文本對文本的操做大多均可以用原生命令來實現,可是對圖片的操做也許就不那麼容易了,來個拉伸、旋轉啥的就夠咱們折騰了🤨,因此這裏以圖片拉伸爲例子着重講解一下。

圖片拉伸

咱們先看下大體效果,你們也能夠先停下來思考一分鐘看看如何實現🤔:

👌,首先咱們要知道的是圖片已經在編輯區了,因此當用戶點擊編輯區裏面的圖片時咱們須要作些事件監聽並有所處理,具體思路以下(這部分代碼較多,不想看的能夠略過,但標題要看):

1. 判斷用戶點擊的是不是編輯區裏面的圖片

這個就是看點擊事件 e.target.tagName 是否是 img 標籤了,代碼以下,應該比較簡單:

mounted() {
    this.editor = document.querySelector('.editor');
    this.editor.addEventListener('click', this.handleClick);
},
methods: {
    handleClick(e) {
        if (
            e.target &&
            e.target.tagName &&
            e.target.tagName.toUpperCase() === 'IMG'
        ) {
            this.handleClickImg(e.target);
        }
    }
}
複製代碼

2. 在點擊的圖片上建立個大小同樣的 div

若是點擊的是一個圖片,那咱們就建立一個 div ,暫且把這個 div 叫作蒙層吧,順便先看張示意圖:

也就是動態建立一個蒙層(和圖片同樣大小)以及四個拖拽頂點,並定位到和圖片同樣的位置,代碼以下(代碼有點多,可跳過,知道大體意思就行😬):

handleClickImg(img) {
    this.nowImg = img;
    this.showOverlay();
}
showOverlay() {
    // 添加蒙層
    this.overlay = document.createElement('div');
    this.editor.appendChild(this.overlay);
    // 定位蒙層和大小
    this.repositionOverlay();
},
repositionOverlay() {
    let imgRect = this.nowImg.getBoundingClientRect();
    let editorRect = this.editor.getBoundingClientRect();
    // 設置蒙層寬高和位置
    Object.assign(this.overlay.style, {
        position: 'absolute',
        top: `${imgRect.top - editorRect.top + this.editor.scrollTop}px`,
        left: `${imgRect.left - editorRect.left - 1 + this.editor.scrollLeft}px`,
        width: `${imgRect.width}px`,
        height: `${imgRect.height}px`,
        boxSizing: 'border-box',
        border: '1px dashed red'
    });
    // 添加四個頂點拖拽框
    this.createBox();
},
createBox() {
    this.boxes = [];
    this.addBox('nwse-resize'); // top left
    this.addBox('nesw-resize'); // top right
    this.addBox('nwse-resize'); // bottom right
    this.addBox('nesw-resize'); // bottom left
    this.positionBoxes(); // 設置四個拖拽框位置
},
addBox(cursor) {
    const box = document.createElement('div');
    Object.assign(box.style, {
        position: 'absolute',
        height: '12px',
        width: '12px',
        backgroundColor: 'white',
        border: '1px solid #777',
        boxSizing: 'border-box',
        opacity: '0.80'
    });
    box.style.cursor = cursor;
    box.addEventListener('mousedown', this.handleMousedown);  // 順便添加事件
    this.overlay.appendChild(box);
    this.boxes.push(box);
},
positionBoxes() {
    let handleXOffset = `-6px`;
    let handleYOffset = `-6px`;
    [{ left: handleXOffset, top: handleYOffset },
    { right: handleXOffset, top: handleYOffset },
    { right: handleXOffset, bottom: handleYOffset },
    { left: handleXOffset, bottom: handleYOffset }].forEach((pos, idx) => {
        Object.assign(this.boxes[idx].style, pos);
    });
},
複製代碼

3. 在四個頂點框上添加拖拽事件

這裏咱們會在四個頂點監聽 mousedown 事件,按下鼠標時,首先會改變鼠標樣式(就是鼠標會變成調整大小的那種圖標),而後監聽 mousemovemouseup 事件,計算出水平拖拽距離,而後從新設置圖片大小和浮層大小,大概這麼個意思,簡要代碼以下:

handleMousedown(e) {
    this.dragBox = e.target;
    this.dragStartX = e.clientX;
    this.preDragWidth = this.nowImg.width;
    this.setCursor(this.dragBox.style.cursor);
    document.addEventListener('mousemove', this.handleDrag);
    document.addEventListener('mouseup', this.handleMouseup);
},
handleDrag(e) {
    // 計算水平拖動距離
    const deltaX = e.clientX - this.dragStartX;
    // 修改圖片大小
    if (this.dragBox === this.boxes[0] || this.dragBox ===     this.boxes[3]) { // 左邊的兩個框
        this.nowImg.width = Math.round(this.preDragWidth - deltaX);
    } else { // 右邊的兩個框
        this.nowImg.width = Math.round(this.preDragWidth + deltaX);
    }
    // 同時修改蒙層大小
    this.repositionOverlay();
},
handleMouseup() {
    this.setCursor('');
    // 拖拽結束移除事件監聽
    document.removeEventListener('mousemove', this.handleDrag);
    document.removeEventListener('mouseup', this.handleMouseup);
},
setCursor(value) {
    // 設置鼠標樣式
    [document.body, this.nowImg].forEach(el => {
        el.style.cursor = value;
    });
}
複製代碼

固然問題仍是有的,不過咱們知道這個思路就行。具體代碼能夠去看下 npm 上的 quill-image-resize-module 包,我也是按照這個包的思路來說解的😂。。。

操縱光標

除了很差對圖片進行處理外,光標應該也是一大坑,你可能不知道何時就失去焦點了,此時再點擊按鈕執行命令就無效了;有時你又須要還原或設置光標的位置,好比插入圖片後,光標要設置到圖片後面等等之類的。
因此咱們須要具備控制光標的能力,具體操做就是在點擊按鈕以前咱們能夠先存儲當前光標的狀態,執行完命令或者在須要的時候後再還原或設置光標的狀態便可。因爲在 chrome 中,失去焦點並不會清除 Seleciton 對象和 Range 對象,因此就像我一開始說的我沒怎麼去了解🙄。。。這裏就只簡要展現兩個方法給你們看下:

function saveSelection() { // 保存當前Range對象
    let selection = window.getSelection();
    if(selection.rangeCount > 0){
        return sel.getRangeAt(0);
    }
    return null;
};
let selectedRange = saveSelection();
function restoreSelection() {   
    let selection = window.getSelection();   
    if (selectedRange) {   
        selection.removeAllRanges();  // 清空全部 Range 對象
        selection.addRange(selectedRange); // 恢復保存的 Range
    }
}
複製代碼

以上就是今天所要分享的內容,感謝你的閱讀,大讚無疆👀 。。。。

結語

回到開頭咱們講的那個需求,關於圖片旋轉的,根據上面的思路,你能夠在蒙層上加個旋轉圖標,並添加點擊事件,而後修改圖片和蒙層 transform 屬性,固然了位置也要變,可能須要些計算,我也沒試過,不知道效果咋樣😂。 另一種方法就是在插入圖片以前先對圖片進行處理(好比多一步相似裁剪的功能)再上傳,這樣就能夠不用在編輯區裏面處理圖片啦,嘿嘿,目前我就想到這兩種方案了,實際工做中採用的是第二種方式,由於產品的需求不止於旋轉😭。 最後的最後,不知道你們有沒有更好的方法來對圖片或內容進行處理,歡迎在下面留言探討,See you👋。

相關文章
相關標籤/搜索