vue3 + mark.js | 实现文字标注功能
页面效果
具体实现
新增
- 1、监听鼠标抬起事件,通过
window.getSelection()方法获取鼠标用户选择的文本范围或光标的当前位置。 - 2、通过
选中的文字长度是否大于0或window.getSelection().isCollapsed(返回一个布尔值用于描述选区的起始点和终止点是否位于一个位置,即是否框选了)来判断是否展示标签选择的弹窗。 - 3、标签选择的弹窗采用
子绝父相的定位方式,通过鼠标抬起的位置确认弹窗的top与left值。
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,
})
}
删除
点击已进行标记的文字————>重选/取消弹窗显示————>点击取消
如何判断点击的文字是否已标记,通过在创建的标记元素中绑定点击事件,触发则表示已标记。
- 在点击事件中记录该标记的相关内容,如颜色,文字,起始位置,以及唯一标识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,
}
}
},
}
)
- 点击取消后,获取在此前记录的id,根据id查询相关的标记元素
- 使用
markjs.unmark()方法即可删除此元素。 - 绑定的响应式数据,可使用
findIndex和splice()删除
- 编辑弹窗隐藏
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,
})
}
清空标记
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>
结束语
目前功能实现比较简单,还有很多发挥的空间,先小小的记录一下,最后~,预祝大家,双节快乐!!
vue3 + mark.js | 实现文字标注功能的更多相关文章
- js 实现文字滚动功能,可更改配置参数 带完整版解析代码。
前言: 本人纯小白一个,有很多地方理解的没有各位大牛那么透彻,如有错误,请各位大牛指出斧正!小弟感激不尽. 本篇文章为您分析一下原生JS写文字滚动效果 需求分析: 需要 ...
- js实现文字截断
先前用jq做了一个文字截断功能,但是不用jq的项目要实现此功能还要引如jq显得过于麻烦.这里写了一个js的文字截断功能.直接上代码. HTML(测试用的): <div>我是pox我是pox ...
- js实现文字逐个显示
先把代码摆上了吧: <!DOCTYPE html> <html lang="en" xmlns="http://www.w3.org/1999/xhtm ...
- 【百度地图API】情人节求爱大作战——添加标注功能
原文:[百度地图API]情人节求爱大作战--添加标注功能 任务描述: 2月2日是除夕,2月14立马来!即将到来的情人节,你想送TA一份什么礼物呢? 不如,在你们居住的地方,画个大大的桃心,表达你对TA ...
- 使用JS实现文字搬运工
使用JS实现文字搬运工 效果图: 代码如下,复制即可使用: <!DOCTYPE html> <html><head><meta http-equiv=&quo ...
- openlayers学习笔记(十三)— 异步调用JSON数据画点、文字标注与连线
使用Openlayers 3实现调用本地json数据在地图上添加点.文字标注以及连线. 生成底图地图 首先得有一个地图作为底图,代码如下: let vectorSource = new ol.sour ...
- 原生JS实现购物车结算功能代码+zepto版
html <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3 ...
- 使用JS来实现验证码功能
最近想为自己的Django博客添加验证码功能,本来想使用第三方库来实现的,不过考虑到添加第三方库对性能的影响,以及第三方库是否安全可靠的问题,还是用自己的代码来实现吧.反正用JS来实现验证码功能又不是 ...
- ASP.NET MVC 4 RC的JS/CSS打包压缩功能 (转载)
ASP.NET MVC 4 RC的JS/CSS打包压缩功能 打包(Bundling)及压缩(Minification)指的是将多个js文件或css文件打包成单一文件并压缩的做法,如此可减少浏览器需下载 ...
- 小试Office OneNote 2010的图片文字识别功能(OCR)
原文:小试Office OneNote 2010的图片文字识别功能(OCR) 自Office 2003以来,OneNote就成为了我电脑中必不可少的软件,它集各种创新功能于一身,可方便的记录下各种类型 ...
随机推荐
- mysql_三大范式
介绍 数据库的三大范式就是数据库的表应该如何设计,应该注意什么. 第一范式 要求每一张表都有一个主键,每一个字段都不可再分. 举例: id username address 1 张三 中国,北京 2 ...
- MySQL中都有哪些锁?
MySQL中都有哪些锁 为什么需要锁 在计算机系统中,锁(Lock)是一种同步机制,用于控制对共享资源的访问.它确保在任何给定时间内只有一个线程能够访问受保护的共享资源,从而避免了由并发访问导致的数据 ...
- 国标GB28181协议客户端开发(一)整体流程和技术选型
国标GB28181协议客户端开发(一)整体流程和技术选型 本系列文章将介绍国标GB28181协议设备端的开发过程.本文旨在探讨整体设计和技术选型方面的考虑,为开发人员提供指导和参考.文章将从设备端开发 ...
- 从源码级剖析Java类加载原理
相信大多数熟悉Java的研发工程师,都知道Java类加载原理:Java中的类是由类加载器采用双亲委派机制进行加载.其中,Java核心库中实现了三种类型的类加载器,它们分别是:引导类加载器Bootstr ...
- Terraform 系列-使用 for-each 对本地 json 进行迭代
系列文章 Terraform 系列文章 Grafana 系列文章 概述 前文 Grafana 系列 - Grafana Terraform Provider 基础 介绍了使用 Grafana Terr ...
- 获取邮箱(QQ、126、163)的客户端授权码
获取QQ邮箱的客户端授权码 1.首先登录QQ邮箱(https://mail.qq.com),然后,点击"设置"按钮. 2.点击"账户"按钮,进入"账户 ...
- macOS 系统 Kafka 快速入门
Kafka 的核心功能是高性能的消息发送与高性能的消息消费.以下是 Kafka 的快速入门教程. 下载并解压缩 Kafka 二进制代码压缩文件 打开 Kafka 官网的下载地址,可以看到不同版本的 K ...
- gowWeb之错误处理和返回响应
Go Web开发进阶实战(gin框架) 讲师:李文周老师 https://study.163.com/course/introduction.htm?courseId=1210171207&t ...
- CSS border(边框)
CSS 边框属性 CSS边框属性允许你指定一个元素边框的样式和颜色. 可以为上下左右每个框 定制不同的样式和颜色. 边框样式 边框样式属性指定要显示什么样的边界. border-style属性用来定义 ...
- 暗黑王者|ZEGO 低照度图像增强技术解析
在低光照的夜间,摄像头采集的画面通常是一片昏暗,画面清晰度要远远低于肉眼.而随着实时音视频应用技术的发展,我们已经看到了各种画质增强的视频增强技术,那么是否存在一种技术,可以使视频在低光照条件下看起来 ...