零开始构建一个功能完善的文本批注组件

346 阅读8分钟

引言

在现代Web应用中,为用户提供丰富的交互体验至关重要。文本批注功能在许多场景下都非常有用,例如在线教育、文档审阅、协作编辑等。本文将详细介绍如何使用Vue 3、Element Plus以及SVG技术,从零开始构建一个功能完善的文本批注组件,实现文本高亮、批注添加、编辑、删除以及文本与批注之间的连线等核心功能。

效果图

在这里插入图片描述

核心功能概览

我们实现的批注功能具备以下核心特性:

  1. 文本选择与动态按钮:用户选择文本后,会自动在选中区域附近显示"添加批注"按钮。
  2. 文本高亮:添加批注后,对应的原文文本会被高亮显示。
  3. 侧边批注面板:所有批注以卡片形式展示在页面右侧的侧边栏中。
  4. 批注卡片样式:批注卡片包含用户头像、用户名、时间以及批注内容,并支持编辑和删除操作。
  5. L型连线:选中文本与对应的批注卡片之间通过一条L型虚线连接,清晰指示关联关系。
  6. 响应式布局:批注按钮和连线会根据滚动和窗口大小变化进行调整。

技术栈

  • 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,显示输入框。
    • confirmEditediting 状态设为 false,保存内容,并调用 drawAllAnnotationLines 重新绘制连线(因为内容高度可能变化影响连线)。
    • deleteAnnotationannotations 数组中移除批注数据,并找到对应的 <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中。

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-inputtextarea 样式,确保其背景和边框与自定义设计保持一致。

希望这篇博客能帮助您理解和实现类似的文本批注功能!如果您有任何问题或建议,欢迎在评论区留言。