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


    页面效果

    具体实现

    新增

    • 1、监听鼠标抬起事件,通过window.getSelection()方法获取鼠标用户选择的文本范围或光标的当前位置。
    • 2、通过 选中的文字长度是否大于0window.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,
        })
    }
    
    

    删除

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

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

    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()方法即可删除此元素。
    • 绑定的响应式数据,可使用findIndexsplice()删除
    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,
        }
      }
    

    重选

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

      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,
        })
      }
    

    清空标记

    image.png

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

    完整代码

    
    
    <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

  • 相关阅读:
    Unity API详解——GameObject类
    SpringBoot - Identify and stop the process that‘s listening on port 8080解决方案
    【最佳实践】瀚高数据库安全版v4.5.8非root用户运行的安装配置
    23 种设计模式之工厂模式
    计算机毕业设计Java网上汽车售票系统(源码+系统+mysql数据库+Lw文档)
    【Linux】虚拟地址空间
    STM32屏幕计时器
    Java:自定义实现SpringBoot Starter
    mysql order by 原理及优化详解
    教你挑选成品短视频App源码的5大关键要素
  • 原文地址:https://www.cnblogs.com/yangyukeke/p/17730681.html