需求背景:给现有的页面加上标注解读功标注一段文本的功能:选中一段文字,在光标结束位置旁边弹出小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距离
整个流程怎么跑起来:
- 监听selectionchange事件,防抖0.8秒,处理的时候用
getSelection().getRangeAt(0)
获取range对象(有时候会失败,因为没选,需要catch错误) - 获取某个字相对于容器内所有的innertext的index(其实就是为了知道光标相对于innertext的index位置)
- 获取第index个字符距离容器的左上角的距离
- 把弹窗准确挂在所选文字结束光标下
基于这一套,服务端只需要存储的信息是:光标起点位置、光标终点位置、所选文字,前端这边完全可以实现所有的需求。下面开始从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?
anchorOffset
和focusOffset
表示的是起点index和终点index。在多段落的时候,这两个数值只是相对于当前段落,所以会不准确。而一行文字的时候的确是没什么问题,因此需要我们自己实现一下这个回溯获取index的功能
第index个字符串距离左上角的距离
已经获取到index,再获取container下第index个字符串距离左上角的距离
但注意鼠标选择的方向:从右往左、从左往右。从右往左需要取startindex,从左往右取endindex
解释:
anchorOffset
和focusOffset
表示的是起点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操作严格分开,不可夹杂着一起使用 标注
关注公众号《不一样的前端》,以不一样的视角学习前端,快速成长,一起把玩最新的技术、探索各种黑科技