你这磨人的小妖精——选中文本并标注的实现过程

4,571 阅读7分钟

需求背景:给现有的页面加上标注解读功标注一段文本的功能:选中一段文字,在光标结束位置旁边弹出小tips,有一个按钮表示添加解读。添加了解读后,那段文字高亮(加上下划线)。此后每次页面loaded,被加过标注的文字也要高亮

效果图:

实现分析

一般的实现方式是整个页面内容html存起来,用一些特殊标记表示已经高亮:

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

渲染的时候,把特殊标记换成正确的html元素渲染即可

但是现在问题来了,我们这是一个现成的react页面,是一个详情页,页面的内容是多个接口返回填进去的:

<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,研究一下有没有另外的解决方案

基于selection & range的方案

执行getSelection()后,会得到一个selection对象,其中有一个getRangeAt方法可以获取range对象。range对象有几个属性:

  • commonAncestorContainer: 公共父容器(可能是node可能是htmlelement)
  • startContainer: 光标的起点容器
  • endContainer: 光标的终点容器
  • startOffset: 光标index距离起点容器文本起点的index距离
  • endOffset: 光标index距离终点容器文本起点的index距离

整个流程怎么跑起来:

  1. 监听selectionchange事件,防抖0.8秒,处理的时候用getSelection().getRangeAt(0)获取range对象(有时候会失败,因为没选,需要catch错误)
  2. 获取某个字相对于容器内所有的innertext的index(其实就是为了知道光标相对于innertext的index位置)
  3. 获取第index个字符距离容器的左上角的距离
  4. 把弹窗准确挂在所选文字结束光标下

基于这一套,服务端只需要存储的信息是:光标起点位置、光标终点位置、所选文字,前端这边完全可以实现所有的需求。下面开始从0到1实现

前端页面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操作严格分开,不可夹杂着一起使用 标注

关注公众号《不一样的前端》,以不一样的视角学习前端,快速成长,一起把玩最新的技术、探索各种黑科技