vue3??mark.js?實(shí)現(xiàn)文字標(biāo)注功能(案例代碼)
頁(yè)面效果

具體實(shí)現(xiàn)
新增
1、監(jiān)聽鼠標(biāo)抬起事件,通過 window.getSelection() 方法獲取鼠標(biāo)用戶選擇的文本范圍或光標(biāo)的當(dāng)前位置。
2、通過 選中的文字長(zhǎng)度是否大于0 或 window.getSelection().isCollapsed (返回一個(gè)布爾值用于描述選區(qū)的起始點(diǎn)和終止點(diǎn)是否位于一個(gè)位置,即是否框選了)來判斷是否展示標(biāo)簽選擇的彈窗。
3、標(biāo)簽選擇的彈窗采用 子絕父相 的定位方式,通過鼠標(biāo)抬起的位置確認(rèn)彈窗的 top 與 left 值。
const TAG_WIDTH = 280 //自定義最大范圍,以保證不超過內(nèi)容的最大寬度
const tagInfo = ref({
visible: false,
top: 0,
left: 0,
})
const el = document.getElementById('text-container')
//鼠標(biāo)抬起
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
}
//清空重選/取消數(shù)據(jù)
resetEditTag() const selectedText = reactive({
start: 0,
end: 0,
content: '',
})
//獲取選取的文字?jǐn)?shù)據(jù)
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) {
//從左到右標(biāo)注
selectedText.start = nodeValueSatrtIndex + anchorOffset
selectedText.end = nodeValueSatrtIndex + focusOffset
} else {
//從右到左
selectedText.start = nodeValueSatrtIndex + focusOffset
selectedText.end = nodeValueSatrtIndex + anchorOffset
}
}javascript操作光標(biāo)和選區(qū)詳情可參考文檔:http://m.fzitv.net/article/94012.htm
- 4、選中標(biāo)簽后,采用markjs的
markRanges()方式去創(chuàng)建一個(gè)選中的元素并為其添加樣式和綁定事件。 - 5、定義一個(gè)響應(yīng)式的文字列表,專門記錄標(biāo)記的內(nèi)容,添加完元素后可追加一條已標(biāo)記的數(shù)據(jù)。
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,
})
}刪除

點(diǎn)擊已進(jìn)行標(biāo)記的文字————>重選/取消彈窗顯示————>點(diǎn)擊取消
如何判斷點(diǎn)擊的文字是否已標(biāo)記,通過在創(chuàng)建的標(biāo)記元素中綁定點(diǎn)擊事件,觸發(fā)則表示已標(biāo)記。
在點(diǎn)擊事件中記錄該標(biāo)記的相關(guān)內(nèi)容,如顏色,文字,起始位置,以及唯一標(biāo)識(shí)id(新建時(shí)給元素添加一個(gè)id屬性,點(diǎn)擊時(shí)即可通過 e.target.id 獲?。?/p>
import { nanoid } from 'nanoid'
//選擇標(biāo)簽后
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,
}
}
},
}
)3.點(diǎn)擊取消后,獲取在此前記錄的id,根據(jù)id查詢相關(guān)的標(biāo)記元素
- 使用
markjs.unmark()方法即可刪除此元素。 - 綁定的響應(yīng)式數(shù)據(jù),可使用
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,
}
}重選

和取消的步驟一樣,只不過在點(diǎn)擊重選后,先彈出標(biāo)簽彈窗,選擇標(biāo)簽后,需要先刪除選中的元素,然后再新增一個(gè)標(biāo)記元素。由于在標(biāo)簽選擇,在標(biāo)簽選擇中判斷一下是否是重選,是重選的話就需刪除后再創(chuàng)建元素,不是的話就代表是新增,直接新增標(biāo)記元素(綜上所述)。
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) {
//如若重選,則刪除后再新增標(biāo)簽
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,
})
}清空標(biāo)記

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(
'這是標(biāo)注的內(nèi)容有業(yè)績(jī)還是我我很快就很快就開完如突然好幾個(gè)地方各級(jí)很大功夫數(shù)據(jù)庫(kù)二極管捍衛(wèi)國(guó)家和我回家很晚十九世紀(jì)俄國(guó)激活工具和丈母娘環(huán)境和顛覆國(guó)家的高房?jī)r(jià)奧蘇愛哦因?yàn)閕以太網(wǎng)圖的還是覺得好看啊空間函數(shù)調(diào)用加快速度還是饑渴的發(fā)貨可是磕碰日俄和那那么會(huì)就開始開會(huì)的數(shù)據(jù)庫(kù)和也會(huì)覺得講故事的而黃金九二額呵呵三角函數(shù)的吧合乎實(shí)際的和盡快核實(shí)當(dāng)升科技看交互的接口和送二ui為人開朗少女都被你們進(jìn)貨金額麥當(dāng)娜表面上的'
)
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: '標(biāo)簽一',
tag_color: `#DE050CFF`,
tag_id: 'tag_id1',
},
{
tag_name: '標(biāo)簽二',
tag_color: `#6ADE05FF`,
tag_id: 'tag_id2',
},
{
tag_name: '標(biāo)簽三',
tag_color: `#DE058BFF`,
tag_id: 'tag_id3',
},
{
tag_name: '標(biāo)簽四',
tag_color: `#9205DEFF`,
tag_id: 'tag_id4',
},
{
tag_name: '標(biāo)簽五',
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('標(biāo)注的數(shù)據(jù)', 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) {
//如若重選,則刪除后再新增標(biāo)簽
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,
})
}
/**
* 獲取選取的文字?jǐn)?shù)據(jù)
*/
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) {
//從左到右標(biāo)注
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 = () => {
//模擬后端返回的數(shù)據(jù)
const res = [
{
start: 2, //必備
end: 6,
tag_color: '#DE050CFF',
tag_id: 'tag_id1',
tag_name: '標(biāo)簽一',
mark_content: '標(biāo)注的內(nèi)容',
mark_id: 'mark_id1',
},
{
start: 39,
end: 41,
tag_color: '#6ADE05FF',
tag_id: 'tag_id2',
tag_name: '標(biāo)簽二',
mark_content: '二極管',
mark_id: 'mark_id2',
},
{
start: 58,
end: 61,
tag_color: '#DE058BFF',
tag_id: 'tag_id3',
tag_name: '標(biāo)簽三',
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,
}
}
},
})
})
}
//頁(yè)面初始化
onMounted(() => {
const el = document.getElementById('text-container')
//鼠標(biāo)抬起
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
}
//清空重選/取消數(shù)據(jù)
resetEditTag()
})
//從后端獲取標(biāo)注數(shù)據(jù),進(jìn)行初始化標(biāo)注
drawMark()
})
</script>
<template>
<header>
<n-button
type="primary"
:disabled="selectedTextList.length == 0 ? true : false"
ghost
@click="handleAllDelete"
>
清空標(biāo)記
</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>
<!-- 標(biāo)簽選擇 -->
<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>結(jié)束語
目前功能實(shí)現(xiàn)比較簡(jiǎn)單,還有很多發(fā)揮的空間,先小小的記錄一下,最后~,預(yù)祝大家,雙節(jié)快樂!!
到此這篇關(guān)于vue3 mark.js 實(shí)現(xiàn)文字標(biāo)注功能的文章就介紹到這了,更多相關(guān)vue3 mark.js文字標(biāo)注內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
vue在使用ECharts時(shí)的異步更新和數(shù)據(jù)加載詳解
這篇文章主要給大家介紹了關(guān)于vue在使用ECharts時(shí)的異步更新和數(shù)據(jù)加載的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧。2017-11-11
在vue中給后臺(tái)接口傳的值為數(shù)組的格式代碼
這篇文章主要介紹了在vue中給后臺(tái)接口傳的值為數(shù)組的格式代碼,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2020-11-11
vue element upload實(shí)現(xiàn)圖片本地預(yù)覽
這篇文章主要為大家詳細(xì)介紹了vue element upload實(shí)現(xiàn)圖片本地預(yù)覽,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-08-08
vue頁(yè)面使用阿里oss上傳功能的實(shí)例(一)
本篇文章主要介紹了vue頁(yè)面使用阿里oss上傳功能的實(shí)例(一),小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-08-08
vue使用axios實(shí)現(xiàn)動(dòng)態(tài)追加數(shù)據(jù)
在vuejs中使用axios時(shí),有時(shí)候需要追加數(shù)據(jù),比如,移動(dòng)端下拉觸底加載,分頁(yè)加載,滑動(dòng)滾動(dòng)條等,下面小編就來為大家介紹一下如何使用使用axios實(shí)現(xiàn)動(dòng)態(tài)追加數(shù)據(jù)吧2023-10-10
Vue中使用fetch讀取本地txt文件的技術(shù)實(shí)現(xiàn)
在Vue.js應(yīng)用開發(fā)中,有時(shí)我們需要從本地讀取文本文件(如 .txt 文件)并將其內(nèi)容展示在頁(yè)面上,這種需求在處理配置文件、日志文件或靜態(tài)數(shù)據(jù)時(shí)非常常見,本文將詳細(xì)介紹如何在Vue中使用 fetch API 讀取本地 .txt 文件,并提供多個(gè)示例和使用技巧2024-10-10
Vue3實(shí)現(xiàn)文本差異對(duì)比器的具體方案
本文將介紹本項(xiàng)目中 文本差異對(duì)比器 (Text Diff Checker) 工具的技術(shù)實(shí)現(xiàn)細(xì)節(jié),該工具基于 Vue 3 框架開發(fā),核心對(duì)比邏輯采用原生的 JavaScript 實(shí)現(xiàn),通過動(dòng)態(tài)加載的方式與 Vue 組件進(jìn)行交互,需要的朋友可以參考下2026-02-02
vue 使某個(gè)組件不被 keep-alive 緩存的方法
今天小編就為大家分享一篇vue 使某個(gè)組件不被 keep-alive 緩存的方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-09-09

