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

需求背景:给现有的页面加上标注解读功标注一段文本的功能:选中一段文字,在光标结束位置旁边弹出小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操做严格分开,不可夹杂着一块儿使用 标注

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

相关文章
相关标签/搜索