vue3 + mark.js | 实现文字标注功能

2025-12-13 0 650

页面效果

vue3 + mark.js | 实现文字标注功能

vue3 + mark.js | 实现文字标注功能

具体实现

新增

  • 1、监听鼠标抬起事件,通过window.getSelection()方法获取鼠标用户选择的文本范围或光标的当前位置。
  • 2、通过选中的文字长度是否大于0或window.getSelection().isCollapsed(返回一个布尔值用于描述选区的起始点和终止点是否位于一个位置,即是否框选了)来判断是否展示标签选择的弹窗
  • 3、标签选择的弹窗采用子绝父相的定位方式,通过鼠标抬起的位置确认弹窗的topleft值。

const TAG_WIDTH = 280 //自定义最大范围,以保证不超过内容的最大宽度 const tagInfo = ref({ visible: false, top: 0, left: 0, }) const el = document.getElementById(\'text-container\') //鼠标抬起 el?.addEventListener(\'mouseup\', (e) => { const text = window?.getSelection()?.toString() || \'\' if (text.length > 0) { const left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX - 300 tagInfo.value = { visible: true, top: e.offsetY + 40, left: left, } getSelectedTextData() } else { tagInfo.value.visible = false } //清空重选/取消数据

resetEditTag()

const selectedText = reactive({ start: 0, end: 0, content: \'\', }) //获取选取的文字数据 const getSelectedTextData = () => { const select = window?.getSelection() as any console.log(\'selectselectselectselect\', select) const nodeValue = select.focusNode?.nodeValue const anchorOffset = select.anchorOffset const focusOffset = select.focusOffset const nodeValueSatrtIndex = markContent.value?.indexOf(nodeValue) selectedText.content = select.toString() if (anchorOffset < focusOffset) { //从左到右标注 selectedText.start = nodeValueSatrtIndex + anchorOffset selectedText.end = nodeValueSatrtIndex + focusOffset } else { //从右到左 selectedText.start = nodeValueSatrtIndex + focusOffset selectedText.end = nodeValueSatrtIndex + anchorOffset } }

javascript操作光标和选区详情可参考文档:https://blog.51cto.com/u_14524391/3712814

  • 4、选中标签后,采用markjs的markRanges()方式去创建一个选中的元素并为其添加样式和绑定事件。
  • 5、定义一个响应式的文字列表,专门记录标记的内容,添加完元素后可追加一条已标记的数据。


	
	
		import Mark from \'mark.js\'
import {ref} from \'vue
import { nanoid } from \'nanoid\'

const selectedTextList = ref([])

const handleSelectLabel = (t) => {
 const marker = new Mark(document.getElementById(\'text-container\'))
 const { tag_color, tag_name, tag_id } = t
 const markId = nanoid(10)
 marker.markRanges(
   [
    {
     start: selectedText.start, //必填
     length: selectedText.content.length, //必填
    },
   ],
   {
    className: \'text-selected\',
    element: \'span\',
    each: (element: any) => {
     //为元素添加样式和属性
     element.setAttribute(\'id\', markId)
     element.style.borderBottom = `2px solid ${t.tag_color}` //添加下划线
     element.style.color = t.tag_color
     //绑定事件
     element.onclick = function (e: any) {
      //
     }
    },
   }
  )
  selectedTextList.value.push({
   tag_color,
   tag_name,
   tag_id,
   start: selectedText.start,
   end: selectedText.end,
   mark_content:selectedText.content,
   mark_id: markId,
  })
} 
	


删除

vue3 + mark.js | 实现文字标注功能

vue3 + mark.js | 实现文字标注功能

点击已进行标记的文字————>重选/取消弹窗显示————>点击取消

如何判断点击的文字是否已标记,通过在创建的标记元素中绑定点击事件,触发则表示已标记。

  1. 在点击事件中记录该标记的相关内容,如颜色,文字,起始位置,以及唯一标识id(新建时给元素添加一个id属性,点击时即可通过e.target.id获取)

	
	
		   import { nanoid } from \'nanoid\'
  
   //选择标签后
   const markId = nanoid(10)
   marker.markRanges(
   [
    {
     start: isReset ? editTag.value.start : selectedText.start,
     length: isReset ? editTag.value.content.length : selectedText.content.length,
    },
   ],
   {
    className: \'text-selected\',
    element: \'span\',
    each: (element: any) => {
     element.setAttribute(\'id\', markId)
     //绑定事件
     element.onclick = function (e: any) {
      e.preventDefault()
      if (!e.target.id) return
      const left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX - 300
      const item = selectedTextList.value?.find?.((t) => t.mark_id == e.target.id) as any
      const { mark_content, tag_id, start, end } = item || {}
      editTag.value = {
       visible: true,
       top: e.offsetY + 40,
       left: e.offsetX,
       mark_id: e.target.id,
       content: mark_content || \'\',
       tag_id: tag_id || \'\',
       start: start,
       end: end,
      }
      tagInfo.value = {
       visible: false,
       top: e.offsetY + 40,
       left: left,
      }
     }
    },
   }
  )
	


  1. 点击取消后,获取在此前记录的id,根据id查询相关的标记元素
  • 使用markjs.unmark()方法即可删除此元素。
  • 绑定的响应式数据,可使用findIndex和splice()删除
  1. 编辑弹窗隐藏

	
	
		const handleCancel = () => {
  if (!editTag.value.mark_id) return
  const markEl = new Mark(document.getElementById(editTag.value.mark_id))
  markEl.unmark()
  selectedTextList.value.splice(
   selectedTextList.value?.findIndex((t) => t.mark_id == editTag.value.mark_id),
   1
  )
  tagInfo.value = {
   visible: false,
   top: 0,
   left: 0,
  }
  resetEditTag()
 }

const resetEditTag = () => {
  editTag.value = {
   visible: false,
   top: 0,
   left: 0,
   mark_id: \'\',
   content: \'\',
   tag_id: \'\',
   start: 0,
   end: 0,
  }
 }
	


重选

vue3 + mark.js | 实现文字标注功能

vue3 + mark.js | 实现文字标注功能

和取消的步骤一样,只不过在点击重选后,先弹出标签弹窗,选择标签后,需要先删除选中的元素,然后再新增一个标记元素。由于在标签选择,在标签选择中判断一下是否是重选,是重选的话就需删除后再创建元素,不是的话就代表是新增,直接新增标记元素(综上所述)。


	
	
		 const handleSelectLabel = (t: TTag) => {
  tagInfo.value.visible = false
  const { tag_color, tag_name, tag_id } = t
  const marker = new Mark(document.getElementById(\'text-container\'))
  const markId = nanoid(10)
  const isReset = selectedTextList.value?.map((j) => j.mark_id).includes(editTag.value.mark_id)
   ? 1
   : 0 // 1:重选 0:新增
  if (isReset) {
   //如若重选,则删除后再新增标签
   const markEl = new Mark(document.getElementById(editTag.value.mark_id))
   markEl.unmark()
   selectedTextList.value.splice(
    selectedTextList.value?.findIndex((t) => t.mark_id == editTag.value.mark_id),
    1
   )
  }
  marker.markRanges(
   [
    {
     start: isReset ? editTag.value.start : selectedText.start,
     length: isReset ? editTag.value.content.length : selectedText.content.length,
    },
   ],
   {
    className: \'text-selected\',
    element: \'span\',
    each: (element: any) => {
     element.setAttribute(\'id\', markId)
     element.style.borderBottom = `2px solid ${t.tag_color}`
     element.style.color = t.tag_color
     element.style.userSelect = \'none\'
     element.style.paddingBottom = \'6px\'
     element.onclick = function (e: any) {
      e.preventDefault()
      if (!e.target.id) return
      const left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX - 300
      const item = selectedTextList.value?.find?.((t) => t.mark_id == e.target.id) as any
      const { mark_content, tag_id, start, end } = item || {}
      editTag.value = {
       visible: true,
       top: e.offsetY + 40,
       left: e.offsetX,
       mark_id: e.target.id,
       content: mark_content || \'\',
       tag_id: tag_id || \'\',
       start: start,
       end: end,
      }
      tagInfo.value = {
       visible: false,
       top: e.offsetY + 40,
       left: left,
      }
     }
    },
   }
  )
  selectedTextList.value.push({
   tag_color,
   tag_name,
   tag_id,
   start: isReset ? editTag.value.start : selectedText.start,
   end: isReset ? editTag.value.end : selectedText.end,
   mark_content: isReset ? editTag.value.content : selectedText.content,
   mark_id: markId,
  })
 }
	


清空标记

vue3 + mark.js | 实现文字标注功能


	
	
		const handleAllDelete = () => {
  selectedTextList.value = []
  const marker = new Mark(document.getElementById(\'text-container\'))
  marker.unmark()
 }
	


完整代码


	
	
		<script setup lang=\"ts\">
 import { ref, onMounted, reactive } from \'vue\'
 import Mark from \'mark.js\'
 import { nanoid } from \'nanoid\'

 type TTag = {
  tag_name: string
  tag_id: string
  tag_color: string
 }

 type TSelectText = {
  tag_id: string
  tag_name: string
  tag_color: string
  start: number
  end: number
  mark_content: string
  mark_id: string
 }

 const TAG_WIDTH = 280

 const selectedTextList = ref<TSelectText[]>([])

 const selectedText = reactive({
  start: 0,
  end: 0,
  content: \'\',
 })

 const markContent = ref(
  \'这是标注的内容有业绩还是我我很快就很快就开完如突然好几个地方各级很大功夫数据库二极管捍卫国家和我回家很晚十九世纪俄国激活工具和丈母娘环境和颠覆国家的高房价奥苏爱哦因为i以太网图的还是觉得好看啊空间函数调用加快速度还是饥渴的发货可是磕碰日俄和那那么会就开始开会的数据库和也会觉得讲故事的而黄金九二额呵呵三角函数的吧合乎实际的和尽快核实当升科技看交互的接口和送二ui为人开朗少女都被你们进货金额麦当娜表面上的\'
 )

 const tagInfo = ref({
  visible: false,
  top: 0,
  left: 0,
 })

 const editTag = ref({
  visible: false,
  top: 0,
  left: 0,
  mark_id: \'\',
  content: \'\',
  tag_id: \'\',
  start: 0,
  end: 0,
 })

 const tagList: TTag[] = [
  {
   tag_name: \'标签一\',
   tag_color: `#DE050CFF`,
   tag_id: \'tag_id1\',
  },
  {
   tag_name: \'标签二\',
   tag_color: `#6ADE05FF`,
   tag_id: \'tag_id2\',
  },
  {
   tag_name: \'标签三\',
   tag_color: `#DE058BFF`,
   tag_id: \'tag_id3\',
  },
  {
   tag_name: \'标签四\',
   tag_color: `#9205DEFF`,
   tag_id: \'tag_id4\',
  },
  {
   tag_name: \'标签五\',
   tag_color: `#DE5F05FF`,
   tag_id: \'tag_id5\',
  },
 ]

 const handleAllDelete = () => {
  selectedTextList.value = []
  const marker = new Mark(document.getElementById(\'text-container\'))
  marker.unmark()
 }

 const handleCancel = () => {
  if (!editTag.value.mark_id) return
  const markEl = new Mark(document.getElementById(editTag.value.mark_id))
  markEl.unmark()
  selectedTextList.value.splice(
   selectedTextList.value?.findIndex((t) => t.mark_id == editTag.value.mark_id),
   1
  )
  tagInfo.value = {
   visible: false,
   top: 0,
   left: 0,
  }
  resetEditTag()
 }

 const handleReset = () => {
  editTag.value.visible = false
  tagInfo.value.visible = true
 }

 const handleSave = () => {
  console.log(\'标注的数据\', selectedTextList.value)
 }

 const handleSelectLabel = (t: TTag) => {
  const { tag_color, tag_name, tag_id } = t
  tagInfo.value.visible = false
  const marker = new Mark(document.getElementById(\'text-container\'))
  const markId = nanoid(10)
  const isReset = selectedTextList.value?.map((j) => j.mark_id).includes(editTag.value.mark_id)
   ? 1
   : 0 // 1:重选 0:新增
  if (isReset) {
   //如若重选,则删除后再新增标签
   const markEl = new Mark(document.getElementById(editTag.value.mark_id))
   markEl.unmark()
   selectedTextList.value.splice(
    selectedTextList.value?.findIndex((t) => t.mark_id == editTag.value.mark_id),
    1
   )
  }
  marker.markRanges(
   [
    {
     start: isReset ? editTag.value.start : selectedText.start,
     length: isReset ? editTag.value.content.length : selectedText.content.length,
    },
   ],
   {
    className: \'text-selected\',
    element: \'span\',
    each: (element: any) => {
     element.setAttribute(\'id\', markId)
     element.style.borderBottom = `2px solid ${t.tag_color}`
     element.style.color = t.tag_color
     element.style.userSelect = \'none\'
     element.style.paddingBottom = \'6px\'
     element.onclick = function (e: any) {
      e.preventDefault()
      if (!e.target.id) return
      const left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX - 300
      const item = selectedTextList.value?.find?.((t) => t.mark_id == e.target.id) as any
      const { mark_content, tag_id, start, end } = item || {}
      editTag.value = {
       visible: true,
       top: e.offsetY + 40,
       left: e.offsetX,
       mark_id: e.target.id,
       content: mark_content || \'\',
       tag_id: tag_id || \'\',
       start: start,
       end: end,
      }
      tagInfo.value = {
       visible: false,
       top: e.offsetY + 40,
       left: left,
      }
     }
    },
   }
  )
  selectedTextList.value.push({
   tag_color,
   tag_name,
   tag_id,
   start: isReset ? editTag.value.start : selectedText.start,
   end: isReset ? editTag.value.end : selectedText.end,
   mark_content: isReset ? editTag.value.content : selectedText.content,
   mark_id: markId,
  })
 }

 /**
 * 获取选取的文字数据
 */
 const getSelectedTextData = () => {
  const select = window?.getSelection() as any
  const nodeValue = select.focusNode?.nodeValue
  const anchorOffset = select.anchorOffset
  const focusOffset = select.focusOffset
  const nodeValueSatrtIndex = markContent.value?.indexOf(nodeValue)
  selectedText.content = select.toString()
  if (anchorOffset < focusOffset) {
   //从左到右标注
   selectedText.start = nodeValueSatrtIndex + anchorOffset
   selectedText.end = nodeValueSatrtIndex + focusOffset
  } else {
   //从右到左
   selectedText.start = nodeValueSatrtIndex + focusOffset
   selectedText.end = nodeValueSatrtIndex + anchorOffset
  }
 }

 const resetEditTag = () => {
  editTag.value = {
   visible: false,
   top: 0,
   left: 0,
   mark_id: \'\',
   content: \'\',
   tag_id: \'\',
   start: 0,
   end: 0,
  }
 }

 const drawMark = () => {
  //模拟后端返回的数据
  const res = [
   {
    start: 2, //必备
    end: 6,
    tag_color: \'#DE050CFF\',
    tag_id: \'tag_id1\',
    tag_name: \'标签一\',
    mark_content: \'标注的内容\',
    mark_id: \'mark_id1\',
   },
   {
    start: 39,
    end: 41,
    tag_color: \'#6ADE05FF\',
    tag_id: \'tag_id2\',
    tag_name: \'标签二\',
    mark_content: \'二极管\',
    mark_id: \'mark_id2\',
   },
   {
    start: 58,
    end: 61,
    tag_color: \'#DE058BFF\',
    tag_id: \'tag_id3\',
    tag_name: \'标签三\',
    mark_content: \'激活工具\',
    mark_id: \'mark_id3\',
   },
  ]
  selectedTextList.value = res?.map((t) => ({
   tag_id: t.tag_id,
   tag_name: t.tag_name,
   tag_color: t.tag_color,
   start: t.start,
   end: t.end,
   mark_content: t.mark_content,
   mark_id: t.mark_id,
  }))
  const markList =
   selectedTextList.value?.map((j) => ({
    ...j,
    start: j.start, //必备
    length: j.end - j.start + 1, //必备
   })) || []
  const marker = new Mark(document.getElementById(\'text-container\'))
  markList?.forEach?.(function (m: any) {
   marker.markRanges([m], {
    element: \'span\',
    className: \'text-selected\',
    each: (element: any) => {
     element.setAttribute(\'id\', m.mark_id)
     element.style.borderBottom = `2px solid ${m.tag_color}`
     element.style.color = m.tag_color
     element.style.userSelect = \'none\'
     element.style.paddingBottom = \'6px\'
     element.onclick = function (e: any) {
      console.log(\'cccccc\', m)
      const left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX - 300
      editTag.value = {
       visible: true,
       top: e.offsetY + 40,
       left: e.offsetX,
       mark_id: m.mark_id,
       content: m.mark_content,
       tag_id: m.tag_id,
       start: m.start,
       end: m.end,
      }
      tagInfo.value = {
       visible: false,
       top: e.offsetY + 40,
       left: left,
      }
     }
    },
   })
  })
 }

 //页面初始化
 onMounted(() => {
  const el = document.getElementById(\'text-container\')
  //鼠标抬起
  el?.addEventListener(\'mouseup\', (e) => {
   const text = window?.getSelection()?.toString() || \'\'
   if (text.length > 0) {
    const left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX - 300
    tagInfo.value = {
     visible: true,
     top: e.offsetY + 40,
     left: left,
    }
    getSelectedTextData()
   } else {
    tagInfo.value.visible = false
   }
   //清空重选/取消数据
   resetEditTag()
  })
  //从后端获取标注数据,进行初始化标注
  drawMark()
 })
</script>

<template>
 <header>
  <n-button
   type=\"primary\"
   :disabled=\"selectedTextList.length == 0 ? true : false\"
   ghost
   @click=\"handleAllDelete\"
  >
   清空标记
  </n-button>
  <n-button
   type=\"primary\"
   :disabled=\"selectedTextList.length == 0 ? true : false\"
   @click=\"handleSave\"
  >
   保存
  </n-button>
 </header>
 <main>
  <div id=\"text-container\" class=\"text\">
   {{ markContent }}
  </div>
  <!-- 标签选择 -->
  <div
   v-if=\"tagInfo.visible && tagList.length > 0\"
   :class=\"[\'tag-box p-4 \']\"
   :style=\"{ top: tagInfo.top + \'px\', left: tagInfo.left + \'px\' }\"
  >
   <div v-for=\"i in tagList\" :key=\"i.tag_id\" class=\"tag-name\" @click=\"handleSelectLabel(i)\">
    <n-space>
     <p>{{ i.tag_name }}</p>
     <n-button v-if=\"i.tag_id == editTag.tag_id\" text type=\"primary\">√</n-button>
    </n-space>
    <div
     :class=\"[\'w-4 h-4\']\"
     :style=\"{
      background: i.tag_color,
     }\"
    ></div>
   </div>
  </div>
  <!-- 重选/取消 -->
  <div
   v-if=\"editTag.visible\"
   class=\"edit-tag\"
   :style=\"{ top: editTag.top + \'px\', left: editTag.left + \'px\' }\"
  >
   <div class=\"py-1 bg-gray-100 text-center\" @click=\"handleCancel\">取 消</div>
   <div class=\"py-1 bg-gray-100 mt-2 text-center\" @click=\"handleReset\">重 选</div>
  </div>
 </main>
</template>

<style lang=\"less\" scoped>
 header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 24px;
  height: 80px;
  border-bottom: 1px solid #e5e7eb;
  user-select: none;
  background: #fff;
 }

 main {
  background: #fff;
  margin: 24px;
  height: 80vh;
  padding: 24px;
  overflow-y: auto;
  position: relative;
  box-shadow: 0 3px 8px 0 rgb(0 0 0 / 13%);
  .text {
   color: #333;
   font-weight: 500;
   font-size: 16px;
   line-height: 50px;
  }
  .tag-box {
   position: absolute;
   z-index: 10;
   width: 280px;
   max-height: 40vh;
   overflow-y: auto;
   background: #fff;
   border-radius: 4px;
   box-shadow: 0 9px 28px 8px rgb(0 0 0 / 3%), 0 6px 16px 4px rgb(0 0 0 / 9%),
    0 3px 6px -2px rgb(0 0 0 / 20%);
   user-select: none;
   .tag-name {
    width: 100%;
    background: rgba(243, 244, 246, var(--tw-bg-opacity));
    font-size: 14px;
    cursor: pointer;
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 4px 8px;
    margin-top: 8px;
   }
   .tag-name:nth-of-type(1) {
    margin-top: 0;
   }
  }
  .edit-tag {
   position: absolute;
   z-index: 20;
   padding: 16px;
   cursor: pointer;
   width: 100px;
   background: #fff;
   border-radius: 4px;
   box-shadow: 0 9px 28px 8px rgb(0 0 0 / 3%), 0 6px 16px 4px rgb(0 0 0 / 9%),
    0 3px 6px -2px rgb(0 0 0 / 20%);
   user-select: none;
  }
  ::selection {
   background: rgb(51 51 51 / 20%);
  }
 }
</style>
	


结束语

目前功能实现比较简单,还有很多发挥的空间,先小小的记录一下,最后~,预祝大家,双节快乐!!

markjs

来源:https://www.cnblogs.com/yangyukeke/p/17730681.html

收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

申明:本文由第三方发布,内容仅代表作者观点,与本网站无关。对本文以及其中全部或者部分内容的真实性、完整性、及时性本站不作任何保证或承诺,请读者仅作参考,并请自行核实相关内容。本网发布或转载文章出于传递更多信息之目的,并不意味着赞同其观点或证实其描述,也不代表本网对其真实性负责。

左子网 编程相关 vue3 + mark.js | 实现文字标注功能 https://www.zuozi.net/36460.html

常见问题
  • 1、自动:拍下后,点击(下载)链接即可下载;2、手动:拍下后,联系卖家发放即可或者联系官方找开发者发货。
查看详情
  • 1、源码默认交易周期:手动发货商品为1-3天,并且用户付款金额将会进入平台担保直到交易完成或者3-7天即可发放,如遇纠纷无限期延长收款金额直至纠纷解决或者退款!;
查看详情
  • 1、描述:源码描述(含标题)与实际源码不一致的(例:货不对板); 2、演示:有演示站时,与实际源码小于95%一致的(但描述中有”不保证完全一样、有变化的可能性”类似显著声明的除外); 3、发货:不发货可无理由退款; 4、安装:免费提供安装服务的源码但卖家不履行的; 5、收费:价格虚标,额外收取其他费用的(但描述中有显著声明或双方交易前有商定的除外); 6、其他:如质量方面的硬性常规问题BUG等。 注:经核实符合上述任一,均支持退款,但卖家予以积极解决问题则除外。
查看详情
  • 1、左子会对双方交易的过程及交易商品的快照进行永久存档,以确保交易的真实、有效、安全! 2、左子无法对如“永久包更新”、“永久技术支持”等类似交易之后的商家承诺做担保,请买家自行鉴别; 3、在源码同时有网站演示与图片演示,且站演与图演不一致时,默认按图演作为纠纷评判依据(特别声明或有商定除外); 4、在没有”无任何正当退款依据”的前提下,商品写有”一旦售出,概不支持退款”等类似的声明,视为无效声明; 5、在未拍下前,双方在QQ上所商定的交易内容,亦可成为纠纷评判依据(商定与描述冲突时,商定为准); 6、因聊天记录可作为纠纷评判依据,故双方联系时,只与对方在左子上所留的QQ、手机号沟通,以防对方不承认自我承诺。 7、虽然交易产生纠纷的几率很小,但一定要保留如聊天记录、手机短信等这样的重要信息,以防产生纠纷时便于左子介入快速处理。
查看详情

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务