博客 / 詳情

返回

如何通過 draftjs 設計留言框

draftjs 簡介

draftjs 是用於 react 的富文本編輯器框架,它並不能開箱即用,但是它提供了很多用於開發富文本的 API。基於此,開發者能夠搭建出定製化的富文本編輯器。draftjs 有幾個重要的概念:EditorState、Entity、SelectionState、CompositeDecorator。

EditorState

EditorState 是編輯器的頂級狀態對象。它是一個不可變數據,表示 Draft 編輯器的整個狀態,包括:

  • 當前文本內容狀態(ContentState)
  • 當前選擇狀態(SelectionState)
  • 內容的裝飾器(Decorator)
  • 撤銷/重做堆棧
  • 對內容所做的最新類型的更改(EditorChangeType)

draftjs 基於不可變(immutable)數據,因此對編輯器的修改都需要新生成一個 EditorState 對象傳入編輯器,以實現數據更新。

Entity

Entity 用來描述帶有元數據的文本,使一段文本可以攜帶任意類型的數據,提供了更加豐富的功能,鏈接、提及和嵌入的內容都可以通過 Entity 來實現。

Entity的結構

{
    type: 'string', 
    // 表示Entity的類型; eg:'LINK', 'TOKEN', 'PHOTO', 'IMAGE'
    mutability: 'MUTABLE' | 'IMMUTABLE' | 'SEGMENTED', 
    // 此屬性表示在編輯器中編輯文本範圍時使用此實體對象註釋的文本範圍的行為。
    data: 'object', 
    // Entity的元數據; 用於存儲你想要存儲在該Entity裏的任何信息
}

其中 Mutability 這條屬性三個值的含義分別是:

  • Immutable:此 Entity 作為一個整體,一刪則整體都刪除,無法更改文本;
  • Mutable:Entity 在編輯器中的文字可以自由修改,比如鏈接文本;
  • Segmented:於 Immutable 類似,區別是可以刪除部分文字;

SelectionState

SelectionState 表示編輯器中的選擇範圍。一個選擇範圍有兩點:錨點(起點)和焦點(終點)。

  • 錨點位置 === 焦點位置,沒有選擇文本;
  • 錨點位置 > 焦點位置,從右至左選擇文本;
  • 錨點位置 < 焦點位置,從左至右選擇文本;

CompositeDecorator

Decorator 概念的基礎是掃描給定 ContentBlock 的內容,根據定義的策略定位到匹配位置,然後用指定的 React 組件呈現它們。

實現一個留言框

首先明確需求:

  1. 有長度限制,暫定 200 個字;
  2. 提及(@)時高亮,當用户輸入 @ 符號後將 @ 符號後面的文字高亮;
  3. 插入鏈接;

先實現一個基礎的編輯器:

import React from 'react'
import { Editor, EditorState } from 'draft-js';
import 'draft-js/dist/Draft.css';

import './App.css';

function MyEditor() {
  const [editorState, setEditorState] = React.useState(
    () => EditorState.createEmpty(),
  );

  const handleEditorChange = (newEditorState) => {
    setEditorState(newEditorState);
  }

  return (
    <div className='box'>
      <Editor editorState={editorState} onChange={handleEditorChange} />
      <button className='btn'>提交</button>
    </div>
  );
}

export default MyEditor;

可以看到並沒有出現一個帶工具欄的文本框,而是生成一個可編輯區域,接下來我們將賦予他獨特的功能。

需求一:限制留言長度

編輯器的輸入形式有兩種:鍵盤錄入和粘貼,一般的 input 輸入框我們可以通過 maxLength 來限制,draftjs 沒有這個屬性,不過提供了 handleBeforeInputhandlePastedText 這兩種方法。

handleBeforeInput
handleBeforeInput?: (
  chars: string, // 輸入的內容
  editorState: EditorState, // 編輯器的文本內容狀態
  eventTimeStamp: number,
) => 'handled' | 'not-handled'

當 handleBeforeInput 返回 handled 的時候輸入的默認行為會被阻止,handlePastedText 同理。

handlePastedText
handlePastedText?: (
  text: string,
  html?: string,
  editorState: EditorState,
) => 'handled' | 'not-handled'

接下來修改我們的代碼:

const MAX_LENGTH = 200;

function MyEditor() {
  const [editorState, setEditorState] = React.useState(
    () => EditorState.createEmpty(),
  );

  const handleEditorChange = (newEditorState) => {
    setEditorState(newEditorState);
  }

  const handleBeforeInput = (_, editorState) => {
    // 獲取編輯器的文本內容狀態
    const currentContent = editorState.getCurrentContent(); 
    // 獲取編輯器文本長度,getPlainText返回當前編輯器的文本內容,字符串類型
    const currentContentLength = currentContent.getPlainText('').length;
    if (currentContentLength > MAX_LENGTH - 1) {
     // 當前文本長度大於最大長度的時候阻止輸入,反之允許輸入
      return 'handled';
    }

    return 'not-handled';
  }

  return (
    <div className='box'>
      <Editor
        editorState={editorState}
        onChange={handleEditorChange}
        handleBeforeInput={handleBeforeInput}
      />
      <button className='btn'>提交</button>
    </div>
  );
}

這裏可能有個疑惑:MAX_LENGTH 為什麼要減一?

原因是 handleBeforeInput 觸發在輸入之前,所以 getPlainText 返回的是編輯器內容變化之前的內容。之前的內容長度+輸入的內容長度<最大長度,因為是鍵盤輸入,所以輸入的內容長度始終為1。這還沒完,還有選擇文本內容後再輸入的情況沒有處理。這就需要用到SelectionState了。

添加 getLengthOfSelectedText 函數:

  const getLengthOfSelectedText = () => {
    // 獲取編輯器的選擇狀態
    const currentSelection = editorState.getSelection();
    // 返回選擇狀態,錨點和焦點的偏移量相同(沒有選擇)和錨點和焦點的block_key相同時返回true
    const isCollapsed = currentSelection.isCollapsed();
    let length = 0;
    if (!isCollapsed) {
      const currentContent = editorState.getCurrentContent();
      // 獲取選擇範圍的起始位置block_key
      const startKey = currentSelection.getStartKey();
      // 獲取選擇範圍的結束位置block_key
      const endKey = currentSelection.getEndKey();
      if (startKey === endKey) {
        // 選擇範圍在同一個block,那麼選擇長度=終點偏移量-起點偏移量
        length += currentSelection.getEndOffset() - currentSelection.getStartOffset();
      } else {
        const startBlockTextLength = currentContent.getBlockForKey(startKey).getLength();
        // 起始block的選擇長度 = 起始block的長度-起點偏移量
        const startSelectedTextLength = startBlockTextLength -                                   currentSelection.getStartOffset();
        // 終點在結束block中的偏移量
        const endSelectedTextLength = currentSelection.getEndOffset();
        // getKeyAfter返回指定key的block後面一個block的key
        const keyAfterEnd = currentContent.getKeyAfter(endKey);
        let currentKey = startKey;
        // 累加起始block到結束block中間的block的選擇長度
        while (currentKey && currentKey !== keyAfterEnd) {
          if (currentKey === startKey) {
            length += startSelectedTextLength + 1;
          } else if (currentKey === endKey) {
            length += endSelectedTextLength;
          } else {
            length += currentContent.getBlockForKey(currentKey).getLength() + 1;
          }

          currentKey = currentContent.getKeyAfter(currentKey);
        }
      }
    }

    return length;
  };

這個方法有些長,又涉及到 draftjs 的幾個 api 和 block 的概念,稍微複雜點,不過用途很簡單,就是獲取選擇的長度。現在我們來改造下 handleBeforeInput:

    const handleBeforeInput = (_, editorState) => {
    const currentContent = editorState.getCurrentContent();
    const currentContentLength = currentContent.getPlainText('').length;
    // 實際長度 = 當前內容的長度-選擇的長度(被替換的長度)
    if (currentContentLength - getLengthOfSelectedText() > MAX_LENGTH - 1) {
      return 'handled';
    }

    return 'not-handled';
  }

依葫蘆畫瓢,現在我們來添加 handlePastedText,如果是粘貼情況下,則多了個 pastedText(被粘貼的文本)參數。

  const handlePastedText = (pastedText) => {
    const currentContent = editorState.getCurrentContent();
    const currentContentLength = currentContent.getPlainText('').length;
    const selectedTextLength = getLengthOfSelectedText();
    if (currentContentLength + pastedText.length - selectedTextLength > maxLength - 1) {
      return 'handled';
    }

    return 'not-handled';
  };

為了有更好的使用體驗,可以在編輯器右下角加一個當前內容長度/最大長度的提示。改造一下 handleEditorChange 方法,把當前文本長度用 state 存儲起來。

  const handleEditorChange = (newEditorState) => {
    const currentContent = newEditorState.getCurrentContent();
    const currentContentLength = currentContent.getPlainText('').length;
    setLength(currentContentLength);
    setEditorState(newEditorState);
  }

調整一下樣式,看下效果:

至此我們就完成了第一個需求。

需求二:提及(@)時高亮

一般提及都會有把 @ 符號後面的文字改變顏色以示區別,我們可以用一個正則表達式來匹配 @ 符號和後面的文本,然後在替換成我們自定義的 ReactNode,就可以實現高亮,這正是 Decorator 的用武之地。

我們只需要創建一個 CompositeDecorator 實例,在編輯器初始化的時候傳入 createEmpty 中就可以了。

  const HANDLE_REGEX = /@[\w]+/g;
  const compositeDecorator = new CompositeDecorator([
    {
      strategy: (contentBlock, callback) => {
        // 編輯器每次change都會觸發此函數,得到內容文本。
        const text = contentBlock.getText();
        let matchArr, start;
        while ((matchArr = HANDLE_REGEX.exec(text)) !== null) {
          // 得到匹配值的起始位置和偏移量,callback之後就會被此decorator的component替換
          start = matchArr.index;
          callback(start, start + matchArr[0].length);
        }
      },
      component: (props) => {
        return (
          <span
            className='mention'
            data-offset-key={props.offsetKey}
          >
            {props.children}
          </span>
        );
      },
    },
  ]);

  const [editorState, setEditorState] = React.useState(
    () => EditorState.createEmpty(compositeDecorator),
  );

看下效果:

需求三:插入鏈接

鏈接顯示文字,鼠標移入提示 url。純文本已經無法描述這段信息了,這就需要用到 Entity。添加 insertEntity 函數:

  const insertEntity = (entityData) => {
    let contentState = editorState.getCurrentContent();
      // 創建實體
    contentState = contentState.createEntity('LINK', 'IMMUTABLE', entityData);
    const entityKey = contentState.getLastCreatedEntityKey();
    let selection = editorState.getSelection();
      // 判斷是替換還是插入
    if (selection.isCollapsed()) {
      contentState = Modifier.insertText(
        contentState, selection, entityData.name + ' ', undefined, entityKey,
      );
    } else {
      contentState = Modifier.replaceText(
        contentState, selection, entityData.name + ' ', undefined, entityKey,
      );
    }

    let end;
      // 獲取實體在編輯器中顯示的範圍,目的是讓光標在插入實體後停留在實體尾部
    contentState.getFirstBlock().findEntityRanges(
      (character) => character.getEntity() === entityKey,
      (_, _end) => {
        end = _end;
      });

    let newEditorState = EditorState.set(editorState, { currentContent: contentState });
    selection = selection.merge({
      anchorOffset: end,
      focusOffset: end,
    });
    newEditorState = EditorState.forceSelection(newEditorState, selection);
    handleEditorChange(newEditorState);
  };

看下效果:

完成!

完整代碼

由於完整代碼佔用篇幅較大,獲取完整代碼請關注公眾號“全象雲低代碼”,回覆“留言框完整代碼”即可獲取。

引用內容

draftjs:https://draftjs.org/

公眾號:全象雲低代碼
GitHub:https://github.com/quanxiang-...

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.