浏览器划词高亮实践与方案探讨
本文介绍后两种方案的实现,第一种方法已经有成熟的文档和工具了。
获取划词文字
step by step:
- document.getSelection
- selection.getRangeAt(0)
- range.getClientRects() /range.getBoundingClientRect()
通过在页面上选择一部分文字完成划词,监听指定区域内鼠标抬起事件,事件触发后,可以通过document.getSelection
获取用户选区。当然也有没有任何选区,只是单纯的鼠标点击抬起的情况,可以通过判断selection的属性将两者区别开。
通过selection.getRangeAt(0)
获取选区的range,参数输入0是因为只需要第一个选区,可以通过selection.rangeCount
查看当前有多少个选区。
根据selection和range的一些属性比较,可以判断选区是否跨越了节点,选区是从左往右还是从右往左。对于selection和range的属性,可以参考MDN或这篇文章JavaScript中对光标和选区的操作。
这里简单说下,需要限制选区是否跨节点,可以通过range.commonAncestorContainer
属性判断range的节点和selection的节点是否为同一个,如果没有跨节点,range.commonAncestorContainer
应该是文本节点。如果限制选区跨节点,需要判断一下是从左到右选区(selection.anchorNode===range.startContainer
)还是从右到左选区(selection.anchorNode!==range.startContainer
),重新生成一个range。
获取到一个合法的range后,考虑到选区有可能出现换行,使用getClientRects()
获取选区的所有位置信息,是个list,包含了每一行的位置信息,在使用canvas方法会用到,如果使用custom highlight 方法的话,getBoundingClientRect()
就够了。
添加划词区域样式
两种不同的方法添加划词区域的样式,它们使用的基础数据是相同的(上文准备的range)。
custom highlight
custom highlight是CSS自定义高亮API。可以自定义文档任意文本范围的样式,类似与选区时,通过::selection
伪类,可以自定义选区样式一样。
这个特性在chorme内核的浏览器105版本后实装,在safari 99以上的版本可以通过实验特性打开,但火狐目前还不支持。所以使用前,最好判断一下浏览器支不支持CSS.highlights
。
使用方法很简单:
const HighlightObj = new Highlight(range1,range2...)
CSS.highlights.set("highlight-classname",HighlightObj)
我们创建一个list存放每次划词生成的range,并在划词后用上面的方法设置选区样式。
const rangeArr = []
rangeArr.push(range)
CSS.highlights.set('highlight-classname',new Highlight(...rangeArr))
别忘了css
::highlight(highlight-classname){
background-color: #98e9e9dc;
}
canvas
step by step:
- 接上文的 range.getClientRects()
- parent.getBoundingClientRect()
- ctx.fillRect()
使用canvas就相对来说麻烦一点了。基本原理是这样的,在划词范围的下方覆盖一层canvas,当鼠标抬起完成划词时,在canvas的对应区域绘制填充颜色的长方形。
麻烦的地方有两点: 1.如何将划词区域的坐标与canvas对齐 2.canvas与划词范围的层级关系
假设划词范围的html结构是这样的:
<划词范围>
<文本内容>
<canvas>
</划词范围>
看看这张图: 红框范围是canvas,与划词范围一致大小。 蓝框是划词区域 黄框是划词区域相对于划词范围的位置,也是canvas要绘制填充长方形的地方。
这是最基础的样式分析,直接蓝框的left/top减去红框的left/top就可以了。
但如果html结构是这样的呢?
<卡片>
<卡片标题>
<乱七八糟的其他组件>
<文本内容>
<canvas>
</卡片>
卡片内的区域都可以被划词,但……如果组件自带背景色,就会遮住canvas的图像,将canvas置于组件上方会遮住原先的文字。所以这种方法要求划词范围的背景色为透明,如果有背景色样式的需求,只能通过其他手段(伪元素,增加绝对定位的div等)实现。 而且对样式也有局限性,划词部分高亮很简单,添加一个填充长方形就可以了,但如果需要将划词部分的字体颜色也改变呢,基础的canvas就做不到了。
在页面加载完成后,设定canvas的宽高并获取ctx:
const parentBounding = card.getBoundingClientRect()
innerCanvas.width = parentBounding.width
innerCanvas.height = parentBounding.height
ctx = innerCanvas.getContext("2d")
注意,当card的宽高发生改变时,需要清除canvas画布,同步更改canvas宽高并重新绘制
鼠标抬起,完成划词后,获取新鲜的range
const range = selection.getRangeAt(0)
获取range相对于canvas的位置,在canvas对应位置绘制填充长方形
const boundings = range.getClientRects()
const suppleBoundings = innerCanvas.getBoundingClientRect()
for (const bounding of boundings) {
const currentRect = {
width: bounding.width,
height: bounding.height,
left: bounding.left - suppleBoundings.left,
top: bounding.top - suppleBoundings.top
}
const {left,top,width,height} = currentRect
ctx.fillStyle = 'red'
ctx.fillRect(left, top, width, height)
}
点击和回显划词区域
回显不多说了,无论选择的是上面哪种方法,所有的选区range信息都存在rangeArr里。
如果选择的是custom highlight方法,重新执行一次CSS.highlights.set('highlight-classname',new Highlight(...rangeArr))
即可。
如果选择的是canvas方法,遍历rangeArr,一次执行绘图方法即可。
如果需要点击划词区域,出现popover。建议单独写一个全局绝对定位的组件。鼠标抬起时,获取当前鼠标的坐标。判断rangeArr中是否有符合位置的range,若有,在指定位置显示popover。 可以在向rangeArr添加range的时候,就存一份range的位置信息
const {left,right,top,bottom} = range.getBoundingClientRect()
const rangeInfo = {
BoundingXRange:[left,right],
BoundingYRange:[top,bottom],
rangeText:'...'
}
文本可编辑的情况
在找其他高亮方案参考的时候,发现都提出了一个问题,一旦文本内容发生改变,划词区域就和文字对不上了。
想了一段时间,提供一个思路,之所以说是思路,是因为我还没写出来……
既然允许文本在任意内容编辑,而我们又希望能最大程度保留划词区域,只需要规定规定以下在什么情况下,保留/更新片段即可。
假设段落为今天天气很好,可以出去玩。
,其中一个片段为今天天气很好
,此片段可以最小化分割为['今','天','天','气','很','好']
看看这几个例子:
今天天气很好
->今天天气不好
(头尾都没被替换,更新后的片段为今天天气不好
)今天天气很好
->今天天气超级无敌棒
(尾被替换,更新后的片段为今天天气
)今天天气很好
->阿巴阿巴好
(头被替换了,但尾没有)此时的片段只剩下好
今天天气很好
->一张可湿水面纸
(头尾都被替换了,舍弃该标签)
那么规则我们可以定为:当文档内容发生改变时,先确定头尾是否被替换。
- 头尾没被替换,更新片段
- 头被替换了,获取剩余符合原先片段最大的子片段,更新片段
- 尾被替换了,获取剩余符合原先片段最大的子片段,更新片段
- 头尾都被替换了,舍弃这个标签
参考资料
转载自:https://juejin.cn/post/7234779572759625788