JS-实现手动标注文章

JS-实现手动标注文章

作者:@ 很菜的小白在分享
时间:2022年11月16日

背景介绍

手动标注文本,这个功能也是最近公司业务需求需要实现的一个功能,需要对文章进行内容标注,实现的时候感觉还是很有技术性,所以写这篇文章记录一下,也希望给其他前端的同学一些参考建议。

技术点

在实现这个功能前,需要对以下前端知识点做了解
electionrange
没了解过没关系,下面会简单介绍一下这两个是什么东东。

election

传送门
MDN是这样介绍的,Selection 对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。要获取用于检查或修改的 Selection 对象,请调用 window.getSelection()。,大白话就是用来获取你鼠标拖选的文本,如下图蓝色部分区域。
在这里插入图片描述
到这一步其实有些同学可能会想,我可以直接拿到这个内容给他替换成带样式的文本就可以了,但仔细想想就会发现,如果文章中出现了多个相同的文本内容,你如何确定你替换的内容就是你框选的内容呢?这是就需要另个一个API了selection.getRangeAt,该方法会返回一个包含当前选区内容的区域对象,它接收一个参数index该参数指定需要被处理的子集编号(从零开始计数),该API会返回一个range对象。

Range

MDN是这样介绍的,Range接口表示一个包含节点与文本节点的一部分的文档片段。
我们可以通过这个range对象来获取我们选中内容在整个文档中的位置。下面介绍一下选区是如何定义的

如下图,span标签将整个文本区划分为3个范围,分别为TextNode、Span、TextNode
选区插图1
当用户通过鼠标选择内容后,所选的内容由两个选区组成,TextNode、Span,所有当我们在计算最终标注内容在文档中的位置时就需要将两个选区的位置进行相加,而恰好Range对象也为我们提供了这些属性。下面介绍一下Range对象都有哪些属性。
选区插图2

属性描述
Range.startContainer返回包含Range开始的节点,也就是当前选择内容的起始位置所在选区节点
Range.endContainer返回包含Range终点的节点,也就是当前选择内容的结束位置所在选区节点
Range.startOffset返回一个表示 Range 起点在 startContainer 中的位置的数字。
Range.endOffset返回一个表示 Range 终点在 endContainer 中的位置的数字。

应用

这里仅完成一个基础文本框选的逻辑。

step1:创建基础页面

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>文本框选</title>
  <style>
    #text-container {
      width: 500px;
      border: 2px solid gray;
      border-radius: 10px;
      padding: 10px;
      margin: 200px auto;
    }
  </style>
</head>
<body>
  <div id="text-container">
    JavaScript(简称“JS”) 是一种具有函数优先的轻量级,解释型或即时编译型的编程语言。虽然它是作为开发Web页面的脚本语言而出名,但是它也被用到了很多非浏览器环境中,JavaScript 基于原型编程、多范式的动态脚本语言,并且支持面向对象、命令式、声明式、函数式编程范式。
  </div>
</body>
</html>

大概是这个样子

效果图

step2:定义各种变量和事件绑定

// 文本内容区
let container = document.querySelector('#text-container')
// 记录当前鼠标操作状态,本文中暂时用不到
let mouseStatus = 'none'
// 接收文本标注的数据
let selectionData = null

container.addEventListener('mousedown', mousedownHandler)
container.addEventListener('mouseup', mouseupHandler)
initData()

step3:根据待标注文本生成数据用于之后更新页面

/**
 1. 初始化生成数据
*/
function initData() {
  let result = {}
  result[container.id] = {}
  // 存储静态原始待标注文本
  result[container.id]['htmlString'] = container.textContent
  // 存储各种标注信息,本文仅涉及[startIndex, endIndex, color]
  result[container.id]['infoArray'] = []
  selectionData = result
}
/**
 2. 生成随机色
*/
function getColor() {
  let random1 = parseInt(Math.random()*256)
  let random2 = parseInt(Math.random()*256)
  let random3 = parseInt(Math.random()*256)
  return `rgb(${random1}, ${random2}, ${random3})`
}

step4:处理鼠标按下事件

因为本文只完成基础Demo所以没有处理逻辑

function mousedownHandler(event) {
 mouseStatus = 'mousedown'
}

step5:处理鼠标抬起事件

因为实现文本标注的主要代码就是这部分,所以这里简单梳理一下大致的逻辑。
注意:本文的Demo暂时不涉及重叠框选。

  1. 获取当前鼠标框选的选区(Selection)以及当前选区的Range
  2. 找到当前选区所对应的节点元素
  3. 计算当前框选内容的起始和结束坐标
  4. 更新标注数据
  5. 更新对应的html内容
/**
 * 鼠标抬起事件
*/
function mouseupHandler(event) {
  mouseStatus = 'mouseup';
  
  // 第一步:获取当前鼠标框选的选区(Selection)以及当前选区的Range
  let selection = window.getSelection()
  let text = selection.toString()

  if(text.length === 0) return;

  let range = selection.getRangeAt(0)
  
  // 第二步:找到当前选区所对应的节点元素
  const {startContainer, endContainer, startOffset, endOffset} = range
  // 这里获取框选的节点
  let startNode = startContainer.parentElement
  let endNode = endContainer.parentElement
  while (!startNode.id.includes('text-container')) startNode = startNode.parentElement
  while (!endNode.id.includes('text-container')) endNode = endNode.parentElement

  let curNode = startNode
  
  // 第三步:计算当前框选内容的起始和结束坐标
  while (true) {
    // 记录起始和结束文本的位置
    let startIdx = 0
    let endIdx = 0
    // 记录框选范围的不同情况
    let isStartNode = (curNode === startNode)
    let isEndNode = (curNode === endNode)

    // 处理起始节点与结束节点相同的情况
    if(isStartNode && isEndNode) {
      // 记录是否找到开始和结束节点
      let isFinishStart = false
      let isFinishEnd = false

      for (let child of curNode.childNodes) {
        // 判断是否为文本节点
        let isTextNode = (child.tagName == null)           
        
        // 判断当前子节点是否为开始标注的起始节点
        let isStartContainer = (child === startContainer || child === startContainer.parentElement)
        // 判断当前子节点是否为结束标注的节点
        let isEndContainer = (child === endContainer || child === endContainer.parentElement)

        // 获取当前节点内容的长度,这里使用 textContent 属于可以获取文本内容,这里不包括编写代码时的换行符
        let childLength = isTextNode ? child.length : child.textContent.length

        // 当没找到起始位置时,累加起始和结束位置的坐标
        if(!isFinishStart && !isStartContainer) {
          startIdx += childLength
          endIdx += childLength
        } 
        // 只找到开始位置
        else if(!isFinishStart && isStartContainer && !isEndContainer) {
          startIdx += startOffset
          endIdx += childLength
          isFinishStart = true
        } 
        // 开始和结束位置同时找到
        else if(!isFinishStart && isStartContainer && isEndContainer) {
          startIdx += startOffset
          endIdx += endOffset
          isFinishStart = true
          isFinishEnd = true
        } 
        // 未找到结束位置,累加结束位置坐标
        else if(isFinishStart && !isFinishEnd && !isEndContainer) {
          endIdx += childLength
        } 
        // 找到结束位置
        else if(isFinishStart && !isFinishEnd && isEndContainer) {
          endIdx += endOffset
          isFinishEnd = true
        }
      }
    }
    
    // 第四步:更新标注数据
    const status = updateSelectionData(curNode.id, [startIdx, endIdx])
    if (status == 0) {
      // 暂不处理重叠框选的情况
      alert('重叠了')
      document.getSelection().empty()
      return;
    }
    
    // 第五步:更新对应的html内容
    curNode.innerHTML = highlightHelper(curNode.id)
    if (curNode === endNode) break
  }

  mouseStatus = 'none'
}

step6:更新标注数据

注意:这里在更新数据时同样不处理重叠框选的方式

/**
 * 更新标注数据
 * @retrurn 0: 发生重叠 1: 未发生重叠
*/
function updateSelectionData(id, position) {
  let data = selectionData[id].infoArray
  let color = getColor()
  let result = []

  let [newStartIdx, newEndIdx] = position

  for (const originData of data) {
    let [orgStartIdx, orgEndIdx, orgColor, id] = originData
	// 从前面框选交叉
    if (orgStartIdx >= newStartIdx && orgStartIdx <= newEndIdx) {
      if (orgStartIdx < newEndIdx) {
        return 0
      }
    } 
    // 从后面框选交叉
    else if (orgEndIdx >= newStartIdx && orgEndIdx <= newEndIdx) {
      if (orgEndIdx > newStartIdx) {
        return 0
      }
    }
    // 从中间框选
    else if (orgStartIdx <= newStartIdx && orgEndIdx >= newEndIdx) {
      return 0
    }
    else {
      // 当未发生框选重叠时,保留当前框选节点的Id
      result.push([orgStartIdx, orgEndIdx, orgColor, id])
    }
  }

  let newInfo = position
  newInfo[2] = color
  result.push(newInfo)
  result.sort((a, b) => { return a[0] <= b[0] ? a[0] == b[0] ? 0 : 1 : -1 })
  selectionData[id].infoArray = result
  return 1
}

step7:更新对应的html内容

因为是demo的原因所有在更新页面这里就统一全部更新一遍,勿喷😅😅。
解决方案:在我的项目中,我会为每一个标注的内容生成一个uuid,在更新时通过uuid是否一致进行页面更新。

/**
 * 更新对应需要高亮的html内容
*/
function highlightHelper(id) {
  let curNode = document.querySelector(`#${id}`)
  let htmlString = selectionData[id]['htmlString']
  let infoArray = selectionData[id]['infoArray']
  let result = htmlString
  for (let info of infoArray) {
      let [startIdx, endIdx, color] = info
      let startHtml = result.substring(0, startIdx)
      let middleHtml = result.substring(startIdx, endIdx)
      let endHtml = result.substring(endIdx)
      result = startHtml + `<span style="background-color:${color};color: #fff;">` + middleHtml + '</span>' + endHtml
  }
  return result
}

step8:查看效果

最终效果

完结

到这里一个简单的文本框选功能就实现了,需要demo源码的同学可以私信我获取源码。
如果本文对你有帮助,记得留下点痕迹,让我知道你来过。
如果有更好的方案或本文内容有问题,欢迎评论区讨论,共同进步,2022 加油!!
在这里插入图片描述

  • 4
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 11
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值