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>
相关推荐
树叶会结冰7 分钟前
HTML语义化:当网页会说话
前端·html
冰万森13 分钟前
解决 React 项目初始化(npx create-react-app)速度慢的 7 个实用方案
前端·react.js·前端框架
牧羊人_myr26 分钟前
Ajax 技术详解
前端
浩男孩35 分钟前
🍀封装个 Button 组件,使用 vitest 来测试一下
前端
蓝银草同学39 分钟前
阿里 Iconfont 项目丢失?手把手教你将已引用的 SVG 图标下载到本地
前端·icon
布列瑟农的星空1 小时前
重学React —— React事件机制 vs 浏览器事件机制
前端
程序定小飞1 小时前
基于springboot的在线商城系统设计与开发
java·数据库·vue.js·spring boot·后端
一小池勺1 小时前
CommonJS
前端·面试
孙牛牛1 小时前
实战分享:一招解决嵌套依赖版本失控问题,以 undici 为例
前端