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

效果图

安装依赖

java 复制代码
npm install mark.js --save-dev
npm i nanoid

代码块

java 复制代码
<template>
  <!-- 文档标注 -->
  <header>
    <el-button
      type="primary"
      :disabled="selectedTextList.length == 0 ? true : false"
      ghost
      @click="handleAllDelete"
    >
      清空标记
    </el-button>
    <el-button
      type="primary"
      :disabled="selectedTextList.length == 0 ? true : false"
      @click="handleSave"
    >
      保存
    </el-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)"
      >
        <div>
          <p>{{ i.tag_name }}</p>
          <el-button
            v-if="i.tag_id == editTag.tag_id"
            text
            type="primary"
          ></el-button>
        </div>
        <div
          :class="['w-4 h-4']"
          style="width: 30px; height: 30px"
          :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"
        style="margin-bottom: 10px;"
        @click="handleCancel"
      >
        取 消
      </div>
      <div class="py-1 bg-gray-100 mt-2 text-center" @click="handleReset">
        重 选
      </div>
    </div>
  </main>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import Mark from 'mark.js' //清空标记
import { nanoid } from 'nanoid' //一个小巧、安全、URL友好、唯一的 JavaScript 字符串 ID 生成器。

const TAG_WIDTH = 1000

const selectedTextList = ref([])

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

const markContent = ref(
  '作文是经过人的思想考虑和语言组织,通过文字来表达一个主题意义的记叙方法。作文体裁包括:记叙文、说明文、应用文、议论文。作文分为小学作文、中学作文、大学作文(论文)。'
)

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 = [
  {
    tag_name: '1级',
    tag_color: `#DE050CFF`,
    tag_id: 'tag_id1',
  },
  {
    tag_name: '2级',
    tag_color: `#6ADE05FF`,
    tag_id: 'tag_id2',
  },
  {
    tag_name: '3级',
    tag_color: `#DE058BFF`,
    tag_id: 'tag_id3',
  },
  {
    tag_name: '4级',
    tag_color: `#9205DEFF`,
    tag_id: 'tag_id4',
  },
  {
    tag_name: '5级',
    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 => {
  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 => {
        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) {
          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
          )
          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()
  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: 0, //必备
      end: 1,
      tag_color: '#DE050CFF',
      tag_id: 'tag_id1',
      tag_name: '1级',
      mark_content: '作文',
      mark_id: 'mark_id1',
    },
  ]
  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) {
    marker.markRanges([m], {
      element: 'span',
      className: 'text-selected',
      each: element => {
        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) {
          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 < 500 ? e.offsetX - 20 : 500
      tagInfo.value = {
        visible: true,
        top: e.offsetY + 40,
        left: left,
      }
      getSelectedTextData()
    } else {
      tagInfo.value.visible = false
    }
    //清空重选/取消数据
    resetEditTag()
  })
  //从后端获取标注数据,进行初始化标注
  drawMark()
})
</script>

<style lang="scss" 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: 150px;
    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: 40px;
    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>
相关推荐
aPurpleBerry7 分钟前
JS常用数组方法 reduce filter find forEach
javascript
GIS程序媛—椰子36 分钟前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_00142 分钟前
前端八股文(一)HTML 持续更新中。。。
前端·html
ZL不懂前端1 小时前
Content Security Policy (CSP)
前端·javascript·面试
乐闻x1 小时前
ESLint 使用教程(一):从零配置 ESLint
javascript·eslint
木舟10091 小时前
ffmpeg重复回听音频流,时长叠加问题
前端
王大锤43911 小时前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
我血条子呢1 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js
黎金安1 小时前
前端第二次作业
前端·css·css3
啦啦右一1 小时前
前端 | MYTED单篇TED词汇学习功能优化
前端·学习