页面效果

具体实现

新增

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

清空标记

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

vue3 + mark.js | 实现文字标注功能的更多相关文章

  1. js 实现文字滚动功能,可更改配置参数 带完整版解析代码。

    前言:         本人纯小白一个,有很多地方理解的没有各位大牛那么透彻,如有错误,请各位大牛指出斧正!小弟感激不尽.         本篇文章为您分析一下原生JS写文字滚动效果 需求分析: 需要 ...

  2. js实现文字截断

    先前用jq做了一个文字截断功能,但是不用jq的项目要实现此功能还要引如jq显得过于麻烦.这里写了一个js的文字截断功能.直接上代码. HTML(测试用的): <div>我是pox我是pox ...

  3. js实现文字逐个显示

    先把代码摆上了吧: <!DOCTYPE html> <html lang="en" xmlns="http://www.w3.org/1999/xhtm ...

  4. 【百度地图API】情人节求爱大作战——添加标注功能

    原文:[百度地图API]情人节求爱大作战--添加标注功能 任务描述: 2月2日是除夕,2月14立马来!即将到来的情人节,你想送TA一份什么礼物呢? 不如,在你们居住的地方,画个大大的桃心,表达你对TA ...

  5. 使用JS实现文字搬运工

    使用JS实现文字搬运工 效果图: 代码如下,复制即可使用: <!DOCTYPE html> <html><head><meta http-equiv=&quo ...

  6. openlayers学习笔记(十三)— 异步调用JSON数据画点、文字标注与连线

    使用Openlayers 3实现调用本地json数据在地图上添加点.文字标注以及连线. 生成底图地图 首先得有一个地图作为底图,代码如下: let vectorSource = new ol.sour ...

  7. 原生JS实现购物车结算功能代码+zepto版

    html <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3 ...

  8. 使用JS来实现验证码功能

    最近想为自己的Django博客添加验证码功能,本来想使用第三方库来实现的,不过考虑到添加第三方库对性能的影响,以及第三方库是否安全可靠的问题,还是用自己的代码来实现吧.反正用JS来实现验证码功能又不是 ...

  9. ASP.NET MVC 4 RC的JS/CSS打包压缩功能 (转载)

    ASP.NET MVC 4 RC的JS/CSS打包压缩功能 打包(Bundling)及压缩(Minification)指的是将多个js文件或css文件打包成单一文件并压缩的做法,如此可减少浏览器需下载 ...

  10. 小试Office OneNote 2010的图片文字识别功能(OCR)

    原文:小试Office OneNote 2010的图片文字识别功能(OCR) 自Office 2003以来,OneNote就成为了我电脑中必不可少的软件,它集各种创新功能于一身,可方便的记录下各种类型 ...

随机推荐

  1. 【python基础】复杂数据类型-字典(增删改查)

    1.初识字典 字典,是另外一种复杂的数据类型,相较于列表,字典可以将相关信息关联起来.比如说一个人的信息有名字.年龄.性别等,如果用列表存储的话,不能表示他们之间是相关联的,而字典可以,字典是一个或多 ...

  2. 记一次 .NET 某药材管理系统 卡死分析

    一:背景 1. 讲故事 前段时间有位朋友找到我,说他们在查询报表的时候发现程序的稳定性会受到影响,但服务器的内存,CPU都是正常的,让我帮忙看下怎么回事,问了下程序的稳定性指的是什么?指的是卡死,那既 ...

  3. 【python基础】函数-参数形式

    鉴于函数定义中可能包含多个形参变量,因此函数调用中也可能包含多个实参变量.向函数传递实参变量给形参变量的方式有很多,可使用位置参数,这要求实参变量的顺序与形参变量的顺序相同:也可使用关键字参数,都由变 ...

  4. Hugging News #0616: 有几项非常重要的合作快来围观、最新中文演讲视频回放发布!

    每一周,我们的同事都会向社区的成员们发布一些关于 Hugging Face 相关的更新,包括我们的产品和平台更新.社区活动.学习资源和内容更新.开源库和模型更新等,我们将其称之为「Hugging Ne ...

  5. 【Azure 应用服务】App Service for Container 无法拉取Docker Hub中的镜像替代方案

    问题描述 创建App Service Container服务,选择从Docker Hub中获取appsmith/appsmith-ce 镜像(https://www.appsmith.com/ &am ...

  6. 搭建自动化 Web 页面性能检测系统 —— 实现篇

    我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品.我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值.. 本文作者:琉易 liuxianyu.cn 前段时间分享了<搭 ...

  7. 前端学习C语言 - 初级指针

    初级指针 本篇主要介绍:指针和变量的关系.指针类型.指针的运算符.空指针和野指针.指针和数组.指针和字符串.const 和指针.以及gdb 调试段错误. 基础概念 指针是一种特殊的变量.存放地址的变量 ...

  8. 基于瑞芯微camera文章汇总pdf分享给大家

    前面一段时间基于瑞芯微3568平台编写了camera系列文章, 一共12篇,目前更新完毕. <瑞芯微文章合集> camera系列文章编写前后一共花费2个月时间, 期间查阅了很多资料,做了很 ...

  9. java根据配置文件读取值

    <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 --> <dependency> ...

  10. 使用docker构建可动态启动的FreeSWITCH实例

    操作系统 :CentOS 7.6_x64 FreeSWITCH版本 :1.10.9 Docker版本:23.0.6   使用docker打包FreeSWITCH可以极大简化部署环节,但针对高可用场景的 ...