页面效果
具体实现
新增
- 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 - 300tagInfo.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 anyconsole.log('selectselectselectselect', select)const nodeValue = select.focusNode?.nodeValueconst anchorOffset = select.anchorOffsetconst focusOffset = select.focusOffsetconst nodeValueSatrtIndex = markContent.value?.indexOf(nodeValue)selectedText.content = select.toString()if (anchorOffset < focusOffset) {//从左到右标注selectedText.start = nodeValueSatrtIndex + anchorOffsetselectedText.end = nodeValueSatrtIndex + focusOffset} else {//从右到左selectedText.start = nodeValueSatrtIndex + focusOffsetselectedText.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 } = tconst 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) returnconst left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX - 300const item = selectedTextList.value?.find?.((t) => t.mark_id == e.target.id) as anyconst { 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) returnconst 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 = falseconst { tag_color, tag_name, tag_id } = tconst 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_colorelement.style.userSelect = 'none'element.style.paddingBottom = '6px'element.onclick = function (e: any) {e.preventDefault()if (!e.target.id) returnconst left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX - 300const item = selectedTextList.value?.find?.((t) => t.mark_id == e.target.id) as anyconst { 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: stringtag_id: stringtag_color: string}type TSelectText = {tag_id: stringtag_name: stringtag_color: stringstart: numberend: numbermark_content: stringmark_id: string}const TAG_WIDTH = 280const 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) returnconst 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 = falsetagInfo.value.visible = true}const handleSave = () => {console.log('标注的数据', selectedTextList.value)}const handleSelectLabel = (t: TTag) => {const { tag_color, tag_name, tag_id } = ttagInfo.value.visible = falseconst 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_colorelement.style.userSelect = 'none'element.style.paddingBottom = '6px'element.onclick = function (e: any) {e.preventDefault()if (!e.target.id) returnconst left = e.offsetX < TAG_WIDTH ? 0 : e.offsetX - 300const item = selectedTextList.value?.find?.((t) => t.mark_id == e.target.id) as anyconst { 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 anyconst nodeValue = select.focusNode?.nodeValueconst anchorOffset = select.anchorOffsetconst focusOffset = select.focusOffsetconst nodeValueSatrtIndex = markContent.value?.indexOf(nodeValue)selectedText.content = select.toString()if (anchorOffset < focusOffset) {//从左到右标注selectedText.start = nodeValueSatrtIndex + anchorOffsetselectedText.end = nodeValueSatrtIndex + focusOffset} else {//从右到左selectedText.start = nodeValueSatrtIndex + focusOffsetselectedText.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_colorelement.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 - 300editTag.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 - 300tagInfo.value = {visible: true,top: e.offsetY + 40,left: left,}getSelectedTextData()} else {tagInfo.value.visible = false}//清空重选/取消数据resetEditTag()})//从后端获取标注数据,进行初始化标注drawMark()})
</script><template><header><n-buttontype="primary":disabled="selectedTextList.length == 0 ? true : false"ghost@click="handleAllDelete">清空标记</n-button><n-buttontype="primary":disabled="selectedTextList.length == 0 ? true : false"@click="handleSave">保存</n-button></header><main><div id="text-container" class="text">{{ markContent }}</div><!-- 标签选择 --><divv-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><!-- 重选/取消 --><divv-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