富文本編輯器 quill.js 開發(一): 自定義工具欄

前言

在前端開發中, 富文本是一種常見的業務場景, 而本文要講的就是富文本框架 quill.js 中的自定義工具欄的開發

介紹

Quill.js 是一個具有跨平臺和跨瀏覽器支持的富文本編輯器。憑藉其可擴展架構和富有表現力的 API,可以完全自定義它以滿足個性化的需求。由於其模塊化架構和富有表現力的 API,可以從 Quill 核心開始,然後根據需要自定義其模塊或將自己的擴展添加到這個富文本編輯器中。它提供了兩個用於更改編輯器外觀的主題,可以使用插件或覆蓋其 CSS 樣式表中的規則進一步自定義。Quill 還支持任何自定義內容和格式,因此可以添加嵌入式幻燈片、3D 模型等。

該富文本編輯器的特點

  • 由於其 API 驅動的設計,無需像在其他文本編輯器中那樣解析 HTML 或不同的 DOM 樹;
  • 跨平臺和瀏覽器支持,快速輕便;
  • 通過其模塊和富有表現力的 API 完全可定製;
  • 可以將內容表示爲 JSON,更易於處理和轉換爲其他格式;
  • 提供兩個主題以快速輕鬆地更改編輯器的外觀。

自定義工具欄的開發

本次的編輯器使用 react-quill 組件庫, 他在 quill.js 外層包裝了一層 react 組件, 使得開發者在 react 框架用使用更加友好
相關鏈接: https://github.com/zenoamaro/react-quill

使用:

import React, { useState } from 'react';
import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';

function App() {
  const [value, setValue] = useState('');

  return <ReactQuill theme="snow" value={value} onChange={setValue} />;
}

自定義 toolbar

傳遞自定義 toolbar 的值

toolbar自定義的按鈕, 可以用 iconfontsvg 或者 class, 這裏爲了方便, 我們直接用文字

const CustomButton = () => <span className="iconfont">
    find
</span>;
function App() {
    const [value, setValue] = useState('');
    
    function insertStar() {
        // 點擊自定義圖標後的回調
    }
    
    // 自定義的 toolbar, useCallback 重渲染會有顯示問題
    const CustomToolbar = useCallback(() => (
        <div id="toolbar">
            <select
                className="ql-header"
                defaultValue={''}
                onChange={(e) => e.persist()}
            >
                <option value="1"></option>
                <option value="2"></option>
                <option selected></option>
            </select>
            <button className="ql-bold"></button>
            <button className="ql-italic"></button>
            <button className="ql-insertStar">
                <CustomButton/>
            </button>
        </div>
    ), []);
    
    // 直接聲明會有顯示問題
    const modules = useMemo(() => ({
        toolbar: {
            container: '#toolbar',
            handlers: {
                insertStar: insertStar,
            },
        },
    }), []);
    
    return (<div>
        <CustomToolbar/>
        <ReactQuill theme="snow" value={value} modules={modules} onChange={setValue}/>
    </div>)
}   

通過此方案, 可以打造一個屬於自己的工具欄了

但是也有一個缺點: 原有的 quill.js 工具欄功能需要自己手寫或者去官方 copy 下來

例子

首先我們上在線例子: https://d1nrnh.csb.app/

現在可以自定義添加工具欄了, 那就開始我們的開發之旅

本次的例子是一個查找與替換功能的工具欄開發

首先根據 自定義 toolbar 中的方案添加按鈕, 因爲上面已經有了例子, 這裏就忽略掉自定義按鈕的代碼

主要結構

現在根據點擊之後的回調, 顯示如下的樣式:

class FindModal extends React.Component {
    render(){
        return <div className={'find-modal'}>
            <span className={'close'} onClick={this.props.closeFindModal}>x</span>
            <Tabs defaultActiveKey="1" size={'small'}>
                <TabPane tab={'查找'} key="1">
                    {this.renderSearch()}
                </TabPane>
                <TabPane tab={'替換'} key="2">
                    {this.renderSearch()}
                    <div className={'find-input-box replace-input'}>
                        <label>{'替換'}</label>
                        <Input onChange={this.replaceOnChange}/>
                    </div>
                    <div className={'replace-buttons'}>
                        <Button disabled={!indices.length} size={'small'} onClick={this.replaceAll}>
                            {'全部替換'}
                        </Button>
                        <Button
                            disabled={!indices.length}
                            size={'small'}
                            type={'primary'}
                            onClick={this.replace}
                        >
                            {'替換'}
                        </Button>
                    </div>
                </TabPane>
            </Tabs>
        </div>
    }
}

在外部使用 statevisible 控制即可:

visible ? (<FindModal/>) : null

搜索欄的處理

這裏我們從用戶的輸入關鍵詞開始入手: 當用戶輸入搜索關鍵詞時, 觸發回調:

<Input
    onChange={this.onChange}
    value={searchKey}
/>

onChange 輸入時的觸發 (這裏我們可以加上 debounce):

首先我們保存輸入的值, 將搜索結果 indices 重置爲空:

this.setState({
    searchKey: value,
    indices: [],
});

通過 quill 獲取所有文本格式:

const {getEditor} = this.props;
const quill = getEditor();
const totalText = quill.getText();

解析用戶輸入的詞, 將其轉換成正則 (注意這裏要對用戶輸入轉義, 避免一些關鍵詞影響正則)
之後則是是非大小寫敏感: 使用 i 標記, g 表示全局匹配的意思(不加上就只會匹配一次):

function escapeRegExp(string) {
    return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const re = new RegExp(escapeRegExp(searchKey), this.state.checked ? 'g' : 'gi');

之後我們就要利用 totalTextre 進行循環正則匹配:

while ((match = re.exec(totalText)) !== null) {
    // 目標文本在文檔中的位置
    let index = match.index;
    // 計算 從最初到 index 有多少個特殊 insert
    index = this.countSpecial(index, indices.length ? indices[indices.length - 1].index : 0);
    
    // 來自於 formatText 的方法, 使其高亮, 第 0 個默認選中
    quill.formatText(index, searchKey.length, 'SearchedString', true, 'api');
    // 最後記錄搜索到的座標
    indices.push({index});
}

特殊字符問題

這裏需要注意的是 countSpecial 方法

具體實現:

countSpecial = (index, lastIndex) => {
        const {getEditor} = this.props;
        const quill = getEditor();
        const delta = quill.getContents();
        // 獲取上一個節點到當前節點的 delta
        const restDelta = delta.slice(lastIndex, index);
        const initValue = this.specialArray.length
            ? this.specialArray[this.specialArray.length - 1]
            : 0;
        const num = restDelta.reduce((num, op) => {
            if (typeof op.insert === 'object') {
                return num + 1;
            }
            return num;
        }, initValue);
        this.specialArray.push(num);
        return index + num;
    };

他的主要作用是用來計算編輯器中的特殊字符數量, 如圖片、emoji、附件等等

這樣做的原因在於, 通過 quill 的方法 quill.getText(); 並不能完全返回所有的顯示, 他只能返回文本, 而像是圖片這樣的, 他是沒有實際的文本, 但是卻有着真實的佔位符

像這些特殊符號只能通過 delta 的方案來獲取 它是否存在, 而如果全局使用 delta 方案的話, 他就不能完成搜索了;

舉個例子

比如我現在輸入一句古詩 但願人長久,千里共嬋娟。, 其中 長久 兩個字使用了加粗的格式, 他顯示的 delta 是這樣的:

[
    {insert: '但願人'},
    {attributes: {bold: true}, insert: '長久'},
    {insert: ',千里共嬋娟。\n'},
]

可以看到 delta 的文字是斷裂的, 會被任意的格式所拆開;

所以現在使用的是這樣一種 text + delta 組合的方案

搜索結束

搜索完畢之後, 格局結果的座標一次賦予對應格式, 同時記錄當前選中的第 0 個搜索關鍵詞

if (indices.length) {
  this.currentIndex = indices[0].index;
  // 使得 indices[0].index 到 length 的距離的文本 添加 SearchedStringActive 格式
  quill.formatText(indices[0].index, length, 'SearchedStringActive', true, Emitter.sources.API);
  this.setState({
    currentPosition: 0,
    indices,
  });
}

quill 格式

在上面搜索功能中我們使用了一個 API: quill.formatText 這裏我們就來介紹一下他

quill.js 中我們可以給他添加自定義的格式, 以這個 SearchedString 格式爲例子:

quill.formatText(index, length, 'SearchedString', true, 'api');

想要讓他起效我們就要先創建文件 SearchedString.ts(使用 js 也沒問題):

import {Quill} from 'react-quill';
const Inline = Quill.import('blots/inline');

class SearchedStringBlot extends Inline {
  static blotName: string;
  static className: string;
  static tagName: string;
}

SearchedStringBlot.blotName = 'SearchedString';
SearchedStringBlot.className = 'ql-searched-string';
SearchedStringBlot.tagName = 'div';

export default SearchedStringBlot;

在入口使用:

import SearchedStringBlot from './SearchedString'

Quill.register(SearchedStringBlot);

添加這樣一個格式, 在我們搜索調用之後, 搜索到的結果就會有對應的類名了:

在這裏我們還需要在 CSS 中添加對應的樣式即可完成高亮功能:

  .ql-searched-string {
    // 這裏需要保證權重, 避免查找的顯示被背景色和字體顏色覆蓋
    background-color: #ffaf0f !important;
    display: inline;
  }

搜索的選中

在搜索完畢之後, 默認選中的是第 0 個, 並且我們還需要賦予另一個格式: SearchedStringActive,
按照上述方案同樣添加這個 formats

之後添加樣式:

  // 選中的規則權限需要大於 ql-searched-string 的規則, 並且要不一樣的顏色和背景
  .ql-searched-string-active {
    display: inline;
    .ql-searched-string {
        background-color: #337eff !important;
        color: #fff !important;
    }
  }

給我們的輸入框末尾添加上一個下一個功能, 這裏就直接用圖標來做按鈕, 中間顯示當前索引和總數:

<Input
    onChange={this.onChange}
    value={searchKey}
    suffix={
        indices.length ? (
            <span className={'search-range'}>
                <LeftOutlined onClick={this.leftClick} />
                {currentPosition + 1} / {indices.length}
                <RightOutlined onClick={this.rightClick} />
            </span>
        ) : null
    }
/>

點擊事件

在點擊下一個圖標之後, 我們只需要做四步:

  1. 清除上一個索引的樣式
  2. 索引數加一, 並判斷下一個是否存在, 如果不存在則賦值爲 0
  3. 獲取下一個的索引, 並添加高亮
  4. 檢查下一個的位置是否在視窗中, 不在則滾動窗口

上述的數據獲取來源都在於搜索函數中的 indices 數組, 它標記着每一個搜索結果的索引

下一個事件相反的就是上一個事件了, 他的步驟和下一個步驟類似

視窗的檢查

在點擊之後我們需要對當前高亮的索引位置進行判斷, 依賴於 quill原生的位置 API 來做出調整:


const scrollingContainer = quill.scrollingContainer;
const bounds = quill.getBounds(index + searchKey.length, 1);
// bounds.top + scrollingContainer.scrollTop 等於目標到最頂部的距離
if (
    bounds.top < 0 ||
    bounds.top > scrollingContainer.scrollTop + scrollingContainer.offsetHeight
) {
    scrollingContainer.scrollTop = bounds.top - scrollingContainer.offsetHeight / 3;
}

替換

在查找功能之後, 我們就需要添加替換的功能

單個的替換是非常簡單的, 只需要三步: 刪除原有詞, 添加新詞, 重新搜索:

quill.deleteText(this.currentIndex, searchKey.length, 'user');
quill.insertText(this.currentIndex, this.replaceKey, 'user');
this.search();

想要實現全部替換, 就不是循環單個替換了, 這樣花費的性能較多, 甚至會產生卡頓, 對用戶是否不友好

目前我使用的方案是, 倒序刪除:

let length = indices.length;
        // 遍歷 indices 尾部替換
while (length--) {
    // 先刪除再添加
    quill.deleteText(indices[length].index, oldStringLen, 'user');
    quill.insertText(indices[length].index, newString, 'user');
}
// 結束後重新搜索
this.search();

總結

目前 quill 存在了兩點問題:

  • 不支持表格等格式, 需要升級到 2.0dev 版本, 但是此版本更改了很多東西
  • 當前此倉庫的人員稱已經停止維護了, 後續的更新維護是一個大問題

本文從單個工具欄的開發, 介紹了 quill 富文本編輯器的部分開發流程, 整個結構是很簡單的, 基本也是都用了 quill 的官方 API

當前功能只涉及到了 format 格式, 在下一篇文章中, 我講繼續講述 table modules[email protected] 的開發

本文中例子的源碼: 點擊查看

引用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章