需求背景:给现有的页面加上标注解读功标注一段文本的功能:选中一段文字,在光标结束位置旁边弹出小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
执行getSelection()
后,会获得一个selection对象,其中有一个getRangeAt
方法能够获取range对象。range对象有几个属性:dom
整个流程怎么跑起来:ide
getSelection().getRangeAt(0)
获取range对象(有时候会失败,由于没选,须要catch错误)基于这一套,服务端只须要存储的信息是:光标起点位置、光标终点位置、所选文字,前端这边彻底能够实现全部的需求。下面开始从0到1实现post
先拉数据,获取{ 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>
复制代码
经过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,再获取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下?
很天然的回想到,使用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的,改起来也很简单,就不说了
关注公众号《不同的前端》,以不同的视角学习前端,快速成长,一块儿把玩最新的技术、探索各类黑科技