你這磨人的小妖精——選中文本並標註的實現過程

需求背景:給現有的頁面加上標註解讀功標註一段文本的功能:選中一段文字,在光標結束位置旁邊彈出小tips,有一個按鈕表示添加解讀。添加了解讀後,那段文字高亮(加上下劃線)。此後每次頁面loaded,被加過標註的文字也要高亮html

效果圖: 前端

實現分析

通常的實現方式是整個頁面內容html存起來,用一些特殊標記表示已經高亮:node

// magic-highlight表示高亮,高亮'666'
` <section> abc <a>def</a> <span>12334<magic-highlight id="1">666</magic-highlight>345</span> </section> `
複製代碼

渲染的時候,把特殊標記換成正確的html元素渲染便可react

可是如今問題來了,咱們這是一個現成的react頁面,是一個詳情頁,頁面的內容是多個接口返回填進去的:api

<section>
  <h1>標題1</h1>
  {接口1返回}
  <h1>標題2</h1>
  {接口2返回}
</section>

複製代碼

咱們若是高亮了接口2返回的內容,那就意味着接口2返回的內容裏面有特殊標記:數組

// before
12334666345

// after
'12334<magic-highlight id="1">666</magic-highlight>345'
複製代碼

這裏會遇到一個很棘手的難點——修改、刪除的時候數據同步。由於你修改的時候展現到頁面的確定是字符串自己,修改後須要作字符串diff,再根據diff結果去同步這個帶magic-highlight的字符串,這個過程極其繁瑣,case不少。這一塊先放下,本身去看看selection和range相關的api,研究一下有沒有另外的解決方案app

基於selection & range的方案

執行getSelection()後,會獲得一個selection對象,其中有一個getRangeAt方法能夠獲取range對象。range對象有幾個屬性:dom

  • commonAncestorContainer: 公共父容器(多是node多是htmlelement)
  • startContainer: 光標的起點容器
  • endContainer: 光標的終點容器
  • startOffset: 光標index距離起點容器文本起點的index距離
  • endOffset: 光標index距離終點容器文本起點的index距離

整個流程怎麼跑起來:ide

  1. 監聽selectionchange事件,防抖0.8秒,處理的時候用getSelection().getRangeAt(0)獲取range對象(有時候會失敗,由於沒選,須要catch錯誤)
  2. 獲取某個字相對於容器內全部的innertext的index(其實就是爲了知道光標相對於innertext的index位置)
  3. 獲取第index個字符距離容器的左上角的距離
  4. 把彈窗準確掛在所選文字結束光標下

基於這一套,服務端只須要存儲的信息是:光標起點位置、光標終點位置、所選文字,前端這邊徹底能夠實現全部的需求。下面開始從0到1實現post

前端頁面loaded

先拉數據,獲取{ from, to, string, key }[]高亮信息數組,key表示當前是什麼字段(如title、description)做爲索引

渲染每個字段的時候,從高亮信息數組裏面拿到對應的key,再根據from、to、string就能夠渲染

<span class="container">加了標註功能的這段文本</span>
複製代碼

下面class爲container的span統稱container。咱們這裏基於dangerouslySetInnerHTML來渲染的container:

function renderStringToDangerHTML(html: string, markList: Partial<MarkListItem>[]): string {
  const indexMap = markList.reduce(
    (acc, { from, to, cardId: id }) => {
      (acc.from[from] || (acc.from[from] = [])).push(id);
(acc.to[to - 1] || (acc.to[to - 1] = [])).push(id);
      return acc;
    },
    { from: {}, to: {} }
  );
  return [].reduce.call(
    html,
    (acc, rune, idx) =>
      `${acc}${(indexMap.from[idx] || []).reduce( (res, id) => `${res}<span id="lhyt-${id || `backup-${Math.random()}`}" data-id=${ id || Math.random() } class="${HIGHT_LIGHT_A_TAG_CLASS}">`, '' )}${rune}${(indexMap.to[idx] || []).reduce(res => `${res}</span>`, '')}`,
    []
  );
}
// HIGHT_LIGHT_A_TAG_CLASS表示加上下劃線
複製代碼

渲染的時候:

// before
<h1>
title
</h1>
12334666345

// after
<h1>
title
</h1>
<span class="container">
{renderStringToDangerHTML('12334666345', [{ from: 5, to: 7, value: 666, key: 'title' }])}
</span>
複製代碼

綁定事件

  • 點擊查看詳情: 事件監聽掛在document下,經過事件代理來判斷是否點擊了高亮文字,展現標註以及下劃線文本加上背景(表示被點擊查看標註詳情)。渲染的時候有補上id了,因此這些信息都是能夠知道的。原生dom操做選擇元素,加上一個active激活類。當點擊的是其餘地方,把這些active的元素都取消active狀態
  • selectionchange事件: 若是選中的範圍的commonAncestorContainer在包住經過dangerouslySetInnerHTML來渲染的container下,則進行處理——彈出tips到合適的位置。問題等於,判斷commonAncestorContainer是否屬於container下

獲取起點光標和結束點光標距離container全部的innertext的index

經過container、startOffset和startContainer得到光標起點距離container全部的innertext的index。光標結束點同理

function getContainrtInnerTextIndexByBackward(container: Node, node: Node, initial = 0) {
  let idx = initial;
  let cur = node;
  // 下面*表明光標
  /** * <div><a>123</a>4*56</div> initial = 1 * <div><a>123</a><a>4*56</a></div> initial = 1 * <div>123<a>4*56</a></div> initial = 1 * <div>1234*56</div> initial = 4 */
  while (cur !== container) {
    Array.from(cur.parentNode.childNodes).find(child => {
      if (child !== cur) {
        // 多是element,多是文本節點,須要注意
        const s = (child.innerText || child.data).length;
        idx += s;
      }
      return child === cur;
    });
    cur = cur.parentNode;
  }
  return idx;
}

const startIndex = getContainrtInnerTextIndexByBackward(container, startContainer, startOffset);
const endIndex = getContainrtInnerTextIndexByBackward(container, endContainer, endOffset);
複製代碼

爲何不直接用selection對象的anchorOffset, focusOffset?

anchorOffsetfocusOffset表示的是起點index和終點index。在多段落的時候,這兩個數值只是相對於當前段落,因此會不許確。而一行文字的時候的確是沒什麼問題,所以須要咱們本身實現一下這個回溯獲取index的功能

第index個字符串距離左上角的距離

已經獲取到index,再獲取container下第index個字符串距離左上角的距離

但注意鼠標選擇的方向:從右往左、從左往右。從右往左須要取startindex,從左往右取endindex

解釋: anchorOffsetfocusOffset表示的是起點index和終點index,這兩個key的值完全按照鼠標順序的,若是從後面開始選,起點index < 結束index。range對象就不會有這個狀況,會按照文本流順序,但沒法知道方向了。

思路也很簡單,拷貝一份元素,fixed到左上角,透明。先拿innertext再把第index個變成span包裹,而後渲染innerhtml,最後拿到這個span的getboundingclientrect,就是準確的位置了

function getTextOffset(ele: HTMLElement, start: number, end: number) {
  const newNode = ele.cloneNode(true);
  const styles = getComputedStyle(ele);
  Object.assign(newNode.style, {
    ...Array.from(styles)
      .reduce((acc, key) => {
        acc[key] = styles[key];
        return acc;
      }, {}),
    position: 'fixed',
    pointerEvents: 'none',
    opacity: 0,
    top: 0,
    left: 0,
  });
  const uid = Math.random().toString(36).slice(2);
  const temp = document.createElement('div');
  const NEW_LINE_PLACE_HOLDER = `${Math.random().toString(36).slice(2)}-lhyt`;
  temp.innerHTML = ele.innerHTML.replace(/\n/g, NEW_LINE_PLACE_HOLDER);
  const realText = temp.innerText.replace(RegExp(NEW_LINE_PLACE_HOLDER, 'g'), '\n');
  // 是不是從右邊選到左邊
  const isReverse = start > end;
  // 01234
  // abcde
  // d => b, start = 3, end = 1, from = end
  // b => d, start = 1, end = 3, from = start
  const from = isReverse ? Math.min(start, end) : Math.max(start, end) - 1;
  newNode.innerHTML = `${realText.slice(0, from)}<span id="${uid}">${realText.slice( from, from + 1 )}</span>${realText.slice(from + 1)}`;
  document.body.appendChild(newNode);

  const mesureEle = document.getElementById(uid);
  const ret = mesureEle.getBoundingClientRect();
  removeElement(mesureEle, newNode); // 刪掉這些輔助元素
  return ret;
}
複製代碼

根據位置渲染小tips。補充一下,前面所說的container是relative定位的,正是爲了讓彈層absolute定位。思路很簡單,但問題來了,react下如何掛到dangerouslySetInnerHTML渲染出來的container下?

小tips如何定位在container下

很天然的回想到,使用reactDOM.createPortal,很相似原生js的appendChild,掛在container下。當選擇完成,渲染了container,拿到它的ref引用,再setstate(當前container元素)

頁面內操做徹底沒問題,但問題來了,當props改變,須要刪除元素的時候,馬上報錯了。由於react下進行原生js操做是很危險的,從新渲染,刪除元素的時候分分鐘頁面白屏——a不是b的子節點。詳細問題分析可見 上一篇文章

其實,使用reactDOM.createPortal的確是不科學,由於dangerouslySetInnerHTML的結果須要用原生js獲取到container,而後setstate,經過reactDOM.createPortal把小tips掛在container下。這個操做過程,夾雜react+原生js,當遇到各類複雜的state、props變化,整個組件從新渲染,新的innerhtml,刪除createPortal產生的節點的瞬間,由於它真實的父節點也不在了,最後就報錯

原生仍是和原生一塊兒,react仍是和react一塊兒,因此這一塊只須要container.appendChild便可。

這樣的狀況下,一切手動來解決,先append,當state、props變化的時候,又把它刪除,這些全是原生js操做,並且都在container裏面作的,徹底能夠不直接碰到react的state相關的信息

// before
const RenderPopover: React.FC<RenderPopoverProps> = ({ rect, onTipsClick = () => {}, container }) => {
// portal渲染的組件返回的react元素
  return rect && createPortal(
    <aside style={style} id="lhyt-selection-portal" onClick={onTipsClick}> <span>xxx</span> </aside>,
    container
  )
};

// 改一下組件
const RenderPopover: React.FC<{}> = ({ rect, onTipsClick = () => {}, container }) => {
  const { left, top } = rect || {};
  // 涉及dom操做用useLayoutEffect
  React.useLayoutEffect(() => {
    const aside = document.createElement('aside');
    // left還有一個細節:相似popover,在很靠左是bottomleft,很靠右是bottomright,中間就中間
    Object.assign(aside.style, {
      left: `${left}px`,
      top: `${top}px`,
      width: `${currentWidth}px`,
    });
    aside.onclick = onTipsClick;
    aside.id = 'lhyt-selection-portal';
    // 本來這就是portal渲染的組件返回的react元素
    // 如今所有換成原生js字符串拼接 + 原生的dom操做
    aside.innerHTML = ` <span> xxxxx </span> `;
    container.appendChild(aside);
    return () => {
      aside.parentElement.removeChild(aside);
    };
  });

  return <span />; }; 複製代碼

雖然是組件,但其實是一個空殼子,核心全是原生js操做,把小tips掛到container下。本來設計是一個組件,實際上應該作成一個hook的,改起來也很簡單,就不說了

最後

  • 這個小功能使用只是一瞬間,但實現過程很複雜,涉及到的知識點比較多
  • react下使用原生js,避免直接和state、props掛鉤
  • react下使用原生js,react操做和原生js的dom操做嚴格分開,不可夾雜着一塊兒使用 標註

關注公衆號《不同的前端》,以不同的視角學習前端,快速成長,一塊兒把玩最新的技術、探索各類黑科技

相關文章
相關標籤/搜索