引言
在现代Web应用中,为用户提供丰富的交互体验至关重要。文本批注功能在许多场景下都非常有用,例如在线教育、文档审阅、协作编辑等。本文将详细介绍如何使用Vue 3、Element Plus以及SVG技术,从零开始构建一个功能完善的文本批注组件,实现文本高亮、批注添加、编辑、删除以及文本与批注之间的连线等核心功能。
效果图
核心功能概览
我们实现的批注功能具备以下核心特性:
- 文本选择与动态按钮:用户选择文本后,会自动在选中区域附近显示"添加批注"按钮。
- 文本高亮:添加批注后,对应的原文文本会被高亮显示。
- 侧边批注面板:所有批注以卡片形式展示在页面右侧的侧边栏中。
- 批注卡片样式:批注卡片包含用户头像、用户名、时间以及批注内容,并支持编辑和删除操作。
- L型连线:选中文本与对应的批注卡片之间通过一条L型虚线连接,清晰指示关联关系。
- 响应式布局:批注按钮和连线会根据滚动和窗口大小变化进行调整。
技术栈
- Vue 3
- Element Plus
- SCSS
- SVG
4.1 模板结构 (<template>)
模板主要由三部分组成:批注容器、主内容区域和批注侧边栏。
<template>
<div class="annotation-container" ref="annotationContainerRef">
<svg id="annotation-lines" ref="annotationLines"></svg>
<!-- 文章内容 -->
<main id="main-content" ref="mainContent">
<!-- 这里是您的文章内容 -->
<el-button v-if="showAnnotationBtn" class="annotation-btn" @click="hideAnnotationBtn"
:style="{ top: btnTop + 'px', left: btnLeft + 'px' }" type="primary">
添加批注
</el-button>
</main>
<!-- 批注面板 -->
<div class="annotation-sidebar" v-if="annotations.length > 0">
<div class="annotation-list" ref="annotationListRef">
<div v-for="annotation in annotations" :key="annotation.id" class="annotation-box"
:data-annotation-id="annotation.id" :style="{ top: annotation.position.top + 'px' }"
@dblclick="editAnnotation(annotation)">
<div class="annotation-header">
<img :src="annotation.avatar" alt="Avatar" class="annotation-avatar" />
<span class="annotation-username">{{ annotation.username }}</span>
<span class="annotation-time">{{ annotation.time }}</span>
<el-dropdown trigger="click" class="annotation-actions">
<span class="el-dropdown-link">
<el-icon class="el-icon--right">
<MoreFilled />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="deleteAnnotation(annotation)">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="annotation-content">
<div v-if="annotation.editing" class="annotation-edit-container">
<el-input v-model="annotation.text" class="annotation-input" type="textarea"
@keyup.enter="confirmEdit(annotation)" @blur="confirmEdit(annotation)" />
</div>
<p v-else class="annotation-text">
{{ annotation.text }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<div class="annotation-container">:整个批注功能的根容器,用于包含内容区和批注侧边栏,并处理滚动。<svg id="annotation-lines">:用于绘制批注连线的SVG画布,通过绝对定位覆盖在内容区之上,pointer-events: none确保不影响文本选择。<main id="main-content">:文章内容的显示区域,用户在此选择文本。<el-button v-if="showAnnotationBtn">:动态显示的"添加批注"按钮,使用Element Plus的按钮组件。<div class="annotation-sidebar" v-if="annotations.length > 0">:批注侧边栏,只有当有批注时才显示。它包含一个批注列表。<div class="annotation-box">:每个批注卡片的结构,包含头部(头像、用户名、时间、操作菜单)和内容区(可编辑的输入框或只读文本)。
4.2 脚本逻辑 (<script setup>)
脚本部分使用Vue 3的Composition API,逻辑清晰地组织在 setup 函数中。
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import {
ElButton, ElInput, ElDropdown, ElDropdownMenu, ElDropdownItem, ElIcon
} from 'element-plus';
import { MoreFilled } from '@element-plus/icons-vue';
// 批注按钮状态和位置
const showAnnotationBtn = ref(false);
const btnTop = ref(0);
const btnLeft = ref(0);
const mainContent = ref(null);
const annotations = ref([
// 预设批注数据...
]);
const annotationListRef = ref(null);
const annotationLines = ref(null);
const annotationContainerRef = ref(null);
// 编辑批注
function editAnnotation(annotation) {
annotation.editing = true;
}
// 确认编辑
function confirmEdit(annotation) {
annotation.editing = false;
nextTick(() => {
drawAllAnnotationLines();
});
}
// 删除批注
function deleteAnnotation(annotation) {
const index = annotations.value.findIndex(a => a.id === annotation.id);
if (index !== -1) {
// 移除对应的.pizhu span标签
const highlightedSpan = mainContent.value.querySelector(`span.pizhu[data-annotation-id="${annotation.id}"]`);
if (highlightedSpan) {
const parent = highlightedSpan.parentNode;
parent.replaceChild(document.createTextNode(highlightedSpan.textContent), highlightedSpan);
}
annotations.value.splice(index, 1);
nextTick(() => {
drawAllAnnotationLines(); // 重新绘制所有连线
});
}
}
// 处理文本选择事件
function handleSelectionChange() {
const selection = window.getSelection();
if (selection.rangeCount > 0 && !selection.isCollapsed) {
const range = selection.getRangeAt(0);
const contentRect = mainContent.value.getBoundingClientRect();
const isSelectionInMainContent = mainContent.value.contains(range.commonAncestorContainer);
if (isSelectionInMainContent) {
const rect = range.getBoundingClientRect();
btnTop.value = rect.bottom - contentRect.top;
btnLeft.value = rect.left - contentRect.left;
showAnnotationBtn.value = true;
} else {
showAnnotationBtn.value = false;
}
}
else {
showAnnotationBtn.value = false;
}
}
// 隐藏批注按钮(并添加批注)
function hideAnnotationBtn() {
if (window.getSelection().toString().trim()) {
const selectedText = window.getSelection().toString().trim();
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const id = Date.now();
// 创建span标签用于高亮和关联
const span = document.createElement('span');
span.textContent = selectedText;
span.className = 'pizhu'; // 添加高亮样式
span.setAttribute('data-annotation-id', id);
try {
range.deleteContents();
range.insertNode(span);
} catch (e) {
console.warn('Error replacing selected text with span:', e);
// Fallback for multi-line or complex selections
const clonedContents = range.cloneContents();
const tempDiv = document.createElement('div');
tempDiv.appendChild(clonedContents);
const originalHtml = tempDiv.innerHTML;
span.innerHTML = originalHtml; // Preserve original HTML structure
range.deleteContents();
range.insertNode(span);
}
const rect = span.getBoundingClientRect();
const annotationTop = (rect.top + annotationContainerRef.value.scrollTop) - annotationContainerRef.value.getBoundingClientRect().top;
annotations.value.push({
id: id,
text: selectedText,
editing: true, // 初始为编辑状态
position: {
top: annotationTop,
left: 0,
width: '100%',
height: 'auto'
},
time: new Date().toLocaleTimeString(),
username: '雷越',
avatar: 'https://cube.elemecdn.com/0/88/03b0dff35048270dfda05a3dff99fpng.png'
});
nextTick(() => {
const newAnnotation = annotations.value.find(anno => anno.id === id);
if (newAnnotation) {
editAnnotation(newAnnotation); // 进入编辑模式
}
drawAllAnnotationLines();
});
}
showAnnotationBtn.value = false;
window.getSelection().removeAllRanges();
}
// 画所有批注的连线
function drawAllAnnotationLines() {
const svg = annotationLines.value;
if (!svg) {
console.error('SVG element not found for drawing lines.');
return;
}
if (annotationContainerRef.value) {
svg.style.height = `${annotationContainerRef.value.scrollHeight}px`;
svg.style.width = `${annotationContainerRef.value.scrollWidth}px`;
}
svg.innerHTML = ''; // 清空之前的连线
const containerRect = annotationContainerRef.value.getBoundingClientRect();
const containerScrollTop = annotationContainerRef.value.scrollTop;
const containerScrollLeft = annotationRect.value.scrollLeft;
annotations.value.forEach(annotation => {
const span = mainContent.value.querySelector(`span.pizhu[data-annotation-id='${annotation.id}']`);
const box = annotationListRef.value.querySelector(`[data-annotation-id='${annotation.id}']`);
if (span && box) {
const spanRect = span.getBoundingClientRect();
const boxRect = box.getBoundingClientRect();
// 计算main-content的右边缘(相对于视口)
const mainContentRightEdge_raw = mainContent.value.getBoundingClientRect().right;
// 1. 起点:高亮文本下方中心
const startX_raw = spanRect.left + spanRect.width / 2;
const startY_raw = spanRect.bottom;
// 2. 第一个折点:距离main-content右边缘15px,Y与起点Y相同
const bendX_raw = mainContentRightEdge_raw - 15;
const bendY_raw = startY_raw;
// 3. 第二个折点:X与第一个折点X相同,Y与批注卡片中心Y相同
const bend2X_raw = bendX_raw;
const endY_raw = boxRect.top + boxRect.height / 2;
// 4. 终点:批注卡片左侧中心
const endX_raw = boxRect.left;
// 转换为相对于 SVG 容器(滚动区域)的坐标
const startX = (startX_raw - containerRect.left) + containerScrollLeft;
const startY = (startY_raw - containerRect.top) + containerScrollTop;
const bendX = (bendX_raw - containerRect.left) + containerScrollLeft;
const bendY = (bendY_raw - containerRect.top) + containerScrollTop;
const bend2X = (bend2X_raw - containerRect.left) + containerScrollLeft;
const bend2Y = (endY_raw - containerRect.top) + containerScrollTop;
const endX = (endX_raw - containerRect.left) + containerScrollLeft;
const endY = (endY_raw - containerRect.top) + containerScrollTop;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', `M${startX},${startY} L${bendX},${bendY} L${bend2X},${bend2Y} L${endX},${endY}`);
path.setAttribute('stroke', '#e15b56');
path.setAttribute('stroke-width', '1.5');
path.setAttribute('stroke-dasharray', '5,5');
path.setAttribute('fill', 'none');
svg.appendChild(path);
} else {
console.warn(`Could not find span or box for annotation ID: ${annotation.id}`);
}
});
}
// 生命周期钩子
onMounted(() => {
document.addEventListener('selectionchange', handleSelectionChange);
if (annotationContainerRef.value) {
annotationContainerRef.value.addEventListener('scroll', drawAllAnnotationLines);
}
window.addEventListener('resize', drawAllAnnotationLines);
nextTick(() => {
if (mainContent.value) {
const mainContentDom = mainContent.value;
const containerRect = annotationContainerRef.value.getBoundingClientRect();
const containerScrollTop = annotationContainerRef.value.scrollTop;
annotations.value.forEach(annotation => {
// 检查这个批注的文本是否已经高亮
if (mainContentDom.querySelector(`span.pizhu[data-annotation-id="${annotation.id}"]`)) {
const existingSpan = mainContentDom.querySelector(`span.pizhu[data-annotation-id="${annotation.id}"]`);
const rect = existingSpan.getBoundingClientRect();
annotation.position.top = (rect.top + containerScrollTop) - containerRect.top;
return;
}
// 查找并高亮文本
const walk = document.createTreeWalker(
mainContentDom,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
let highlighted = false;
while ((node = walk.nextNode()) && !highlighted) {
const textContent = node.nodeValue;
const startIndex = textContent.indexOf(annotation.text);
if (startIndex !== -1) {
const range = document.createRange();
range.setStart(node, startIndex);
range.setEnd(node, startIndex + annotation.text.length);
const span = document.createElement('span');
span.textContent = annotation.text;
span.className = 'pizhu';
span.setAttribute('data-annotation-id', annotation.id);
try {
range.surroundContents(span);
highlighted = true;
const rect = span.getBoundingClientRect();
annotation.position.top = (rect.top + containerScrollTop) - containerRect.top;
} catch (e) {
console.warn(`Could not surround contents for annotation ID ${annotation.id}:`, e);
// 尝试更安全的插入方法,可能导致部分高亮不准确,但避免报错
const clonedContents = range.cloneContents();
span.appendChild(clonedContents);
range.deleteContents();
range.insertNode(span);
highlighted = true; // 标记为已处理,即使不完美
const rect = span.getBoundingClientRect();
annotation.position.top = (rect.top + containerScrollTop) - containerRect.top;
}
}
}
});
setTimeout(() => {
drawAllAnnotationLines();
}, 100);
}
});
});
onUnmounted(() => {
document.removeEventListener('selectionchange', handleSelectionChange);
if (annotationContainerRef.value) {
annotationContainerRef.value.removeEventListener('scroll', drawAllAnnotationLines);
}
window.removeEventListener('resize', drawAllAnnotationLines);
});
- 数据管理:
showAnnotationBtn:控制"添加批注"按钮的显示/隐藏。btnTop,btnLeft:控制按钮的定位。annotations:响应式数组,存储所有批注的数据,每个批注对象包含id,text,editing状态,position(用于侧边栏定位),time,username,avatar。mainContent,annotationListRef,annotationLines,annotationContainerRef:通过ref获取DOM元素的引用。
- 文本选择与批注添加 (
handleSelectionChange,hideAnnotationBtn):handleSelectionChange监听selectionchange事件,当用户选中非空文本且在main-content区域内时,计算并显示"添加批注"按钮。hideAnnotationBtn(这个函数名有点误导,它实际是添加批注的逻辑)在按钮点击后执行:- 获取选中的文本内容和范围。
- 创建新的
<span>元素,添加pizhu类用于高亮,并设置data-annotation-id用于关联批注。 - 尝试使用
range.surroundContents(span)将选中内容包裹在高亮<span>中。考虑到多行选择的复杂性,增加了try-catch块和回退逻辑,以确保即使无法直接包裹也能插入高亮。 - 计算批注卡片在侧边栏的
top位置,并添加到annotations数组中。 nextTick确保DOM更新后,立即进入编辑模式并重新绘制所有连线。
- 批注的编辑与删除 (
editAnnotation,confirmEdit,deleteAnnotation):editAnnotation将批注的editing状态设为true,显示输入框。confirmEdit将editing状态设为false,保存内容,并调用drawAllAnnotationLines重新绘制连线(因为内容高度可能变化影响连线)。deleteAnnotation从annotations数组中移除批注数据,并找到对应的<span>元素,将其替换回纯文本,从而移除高亮。最后重新绘制连线。
- 批注连线绘制 (
drawAllAnnotationLines):- 这是核心的视觉连接部分,使用SVG的
path元素绘制L型虚线。 - 动态设置SVG的宽高以适应容器滚动,并清空旧的连线。
- 遍历所有批注:
- 获取高亮
<span>和批注box的DOM元素及其位置信息。 - 计算连线的四个关键点:
- 起点:高亮文本的底部中心。
- 第一个折点:与起点Y坐标相同,X坐标位于
main-content右边缘向左偏移15px处。 - 第二个折点:与第一个折点X坐标相同,Y坐标位于批注卡片的高度中心。
- 终点:批注卡片的左侧中心。
- 将这些原始视口坐标转换为相对于SVG容器的滚动区域坐标,以确保在滚动时连线位置正确。
- 创建
path元素,设置d属性定义路径,并设置stroke(颜色),stroke-width(宽度),stroke-dasharray(虚线样式)和fill(填充)属性。最后添加到SVG中。
- 获取高亮
- 这是核心的视觉连接部分,使用SVG的
4.3 样式设计 (<style lang="scss">)
样式部分采用SCSS编写,定义了组件的布局和视觉效果。这里只展示关键部分。
<style lang="scss">
.annotation-container {
display: flex;
min-height: 100vh;
overflow: auto;
position: relative;
}
#annotation-lines {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none; // 允许点击穿透
z-index: 999;
}
#main-content {
flex: 1;
padding: 20px;
min-height: 100vh;
position: relative;
}
.annotation-sidebar {
width: 300px;
background: transparent;
border-left: 1px solid #241d42;
padding: 20px;
min-height: 100vh;
}
.annotation-btn {
position: absolute;
z-index: 200;
margin-top: 5px;
white-space: nowrap;
}
.annotation-box {
width: 260px;
min-height: 60px;
border: 1px solid #e15b56; /* 红色边框 */
border-radius: 4px;
margin-bottom: 15px;
padding: 10px;
position: absolute; // 相对定位,方便js调整top
background-color: #fff;
box-sizing: border-box;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.annotation-header {
display: flex;
align-items: center;
margin-bottom: 8px;
position: relative;
}
.annotation-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 8px;
}
.annotation-username {
font-weight: bold;
color: #e15b56; /* 红色用户名 */
margin-right: 10px;
font-size: 14px;
}
.annotation-time {
font-size: 12px;
color: #909399; /* 灰色时间 */
flex-grow: 1;
}
.annotation-content {
background-color: #f7f7f7; /* 灰色背景 */
padding: 8px;
border-radius: 4px;
}
.annotation-text {
user-select: text; /* 确保批注内容可以被选中 */
// ... 其他样式
}
.pizhu {
background-color: #e15b56; /* 高亮文本的背景色 */
}
// 对于Element Plus组件的深度选择器
:deep(.el-textarea__inner) {
box-shadow: none !important;
resize: none;
padding: 0;
width: 100%;
box-sizing: border-box;
min-width: 0;
white-space: normal;
word-wrap: break-word;
background-color: #f7f7f7; // 继承内容区背景
color: #303133;
font-size: 14px;
line-height: 1.5;
border: none;
}
:deep(.highlighted-text) {
background-color: #e15b56; // 高亮文本颜色
}
</style>
.annotation-container:使用Flexbox布局,实现内容区和侧边栏的并排显示。#annotation-lines:SVG层,pointer-events: none是关键,它确保用户可以点击下层的文本内容而不是SVG线。.annotation-sidebar:定义侧边栏的宽度、背景和边框。.annotation-box:批注卡片的样式,包括红色边框、圆角、阴影和绝对定位,方便JavaScript动态调整其垂直位置。.annotation-header和.annotation-content:定义批注卡片内部的布局和颜色,特别注意用户头像、用户名(红色)、时间和内容区(灰色背景)的样式。.pizhu:高亮文本的样式,这里设置为与批注卡片边框相呼应的红色。:deep()深度选择器:用于修改 Element Plus 内部组件的样式,例如el-input的textarea样式,确保其背景和边框与自定义设计保持一致。
希望这篇博客能帮助您理解和实现类似的文本批注功能!如果您有任何问题或建议,欢迎在评论区留言。