基于svgjs的批注实现方案

1,501 阅读33分钟

最近在做一个在视频/图片上添加批注的需求,做了调研,最终选择svgjs作为基础工具实现批注功能。

在线体验

在线体验备用(githubPages访问速度较慢)

查看源码

先来看看效果图:

image.png

下面将从技术选型开始,把遇到的一些实际问题和解决方案做一个详细阐述,作为一次技术总结。

一、技术选型

批注的元素里包含矢量图,因此一开始的方向相对来说就比较明确。

1.1 矢量图

目前主流的方式是通过canvas或者svg来实现矢量图形类的渲染,他们都可以实现多边形、线段、圆形的渲染。

都是以左上角顶点为起点,x轴方向从左到右,y轴方向和笛卡尔坐标系不一样是从上到下,可视区域的坐标值都是正数方便计算。

最终选用svg的决定性因素是:批注元素中还有输入框、头像等dom元素,svg可以通过foreignObject标签插入任意dom元素。而canvas没有提供直接的方式,只能额外添加,不方便管理和后续的定位计算。

矢量图的实现选定了基本方向,紧接着便是实现方式的问题。批注元素是通过实时交互动态添加和删除的,因此不适合预先在模版内内置svg元素来实现,这样缺乏灵动性、实现难度也较大。通过原生js的方式来实现元素的添加、删除、移动等操作更适合实际情况,但是原生的js创建和操作svg元素,较为繁琐和复杂。

为了进一步降低难度、减少工作量,这时考虑能否找到有类似JQuery一样方便svg操作的插件,通过在github掘金上资源的收集最终确定使用svgjs作为基础开发工具。

svgjs的优点

  1. 功能丰富,包含元素创建、元素操作、动画、数学计算、定位。
  2. 事件类型丰富,支持自定义事件,对于有复杂业务需求的开发者有极大的便利。
  3. 基于原生js编写使用无限制,类似于JQuery减少大量重复性作业、操作简化,方便开发者实现自己需要的各种各样功能。
  4. 文档清晰,分类合理能快速找到需要的api,开发体验好。

原生js

// Vanilla js
var ns = 'http://www.w3.org/2000/svg'
var div = document.getElementById('drawing') 
var svg = document.createElementNS(ns, 'svg')
svg.setAttributeNS(null, 'width', '100%')
svg.setAttributeNS(null, 'height', '100%')
div.appendChild(svg)
var rect = document.createElementNS(ns, 'rect')
rect.setAttributeNS(null, 'width', 100)
rect.setAttributeNS(null, 'height', 100)
rect.setAttributeNS(null, 'fill', '#f06')
svg.appendChild(rect)

使用svgjs创建图形非常便利,支持链式调用

import { SVG } from '@svgdotjs/svg.js'

// 将svg添加到body(支持配置)设置宽高(也可以设置百分比)
const draw = SVG().addTo('body').size(300, 300) 
// 创建矩形
const rect = draw.rect(100, 100).attr({ fill: '#f06' })

1.2 dom元素插入

插入的dom元素有不少的交互和业务逻辑,如果使用原生的html和js来实现工作量较大,加上需要动态插入实现起来较为复杂和困难。因此考虑把组件插入到svgforeignObject中,这里以vue为例:

import { createVNode, render } from "vue";
import { SVG } from "@svgdotjs/svg.js";
// vue组件
import ConfirmInput from "./components/confirm-input.vue";

const draw = SVG().addTo('body').size('100%', '100%') 
// 创建一个foreignObject,并控制尺寸
const foreignObject = draw.foreignObject(width, height);
// vue组件生成VNode
const vm = createVNode(ConfirmInput, { ...props });
// 将VNode渲染到foreignObject元素内
render(vm, foreignObject.node);

二、整体设计

主要包括整体的结构设置,和批注元素的设计。

2.1 批注区域设计

image.png

这是批注区域的基本的结构图,批注区域把内容区域包裹,在批注区域内都可以批注。

<!-- 空间容器 -->
<div class="box">
    <!-- svg容器-批注区域 -->
    <div class="svg-container"></div>
    <!--内容区域-视频、图片等 -->
    <div class="content"></div>
</div>
import { SVG } from "@svgdotjs/svg.js";

const draw = SVG().addTo('.svg-container').size('100%', '100%') 

结合图形和代码,简单说明一下

  1. 外层.box设置为position: relative;
  2. svg容器.svg-container设置为绝对定位,通过svgjs的size设置达到占满.box容器的效果。
  3. 窗口尺寸变化导致容器空间变化或者内容区域尺寸变化时,批注元素会发生偏移,为了方便尺寸变化时计算元素新的定位(后续详细说明计算方式),需要保证内容区域居中。

2.2 节点设计

通过svg生成的元素,提供了不少直接操作元素的方法,也可以存储开发者自定义的data。

为了记录元素的信息,创建元素时同步存储和元素一对一关联的数据,这里称为节点数据,后续变动时需要保证元素和节点的同步。

每个节点的数据结构如下,记录的节点数据,通过节点标识shapesubShape节点数据data可以渲染出对应元素。

export type Shape =
  | null
  | undefined
  | "text-input"
  | "confirm-input"
  | "area-confirm-input"
  | "painting";

export type SubShape = null | undefined | "rect" | "confirm-input";

// svg节点
export interface Node {
  // 节点唯一标识
  key: string;
  // 关联节点key
  sourceKey?: string;
  // 关联节点
  sourceNode?: Node;
  // 节点类型
  shape: Shape;
  // 节点子类型(组合元素)
  subShape?: SubShape;
  // 区域宽度
  containerWidth: number;
  // 区域高度
  containerHeight: number;
  // 有效区域宽度
  effectiveAreaWidth: number;
  // 有效区域高度
  effectiveAreaHeight: number;
  // 节点宽度,单位为 px。
  width?: number;
  // 节点高度,单位为 px。
  height?: number;
  // 路径数据
  path?: string;
  // 节点位置 x 坐标,单位为 px。
  x: number;
  // 节点位置 y 坐标,单位为 px。
  y: number;
  // 节点相关数据
  data: NodeData;
  // 视频版式
  plate?: string;
  strokeColor?: string;
  strokeWidth?: string;
  radius?: number;
  _resize?: { [key: string]: any };
  // 编辑时content暂存
  _content?: string;
  // 保存标记
  _saved: boolean;
}

2.3 代码设计

class SvgDraw {
  // 实例
  draw: any;
  // 配置项
  options: DrawOptions;
  // 节点类型
  shape: Shape = null;
  // 节点数据
  nodeData: Node[] = [];
  // 节点对应svg元素暂存
  elementData: any[] = [];
  
  constructor(container: string | HTMLElement, options?: DrawOptions) {
    if (!options) options = {};
    this.options = {
      // 默认配置项
      ...defaultOptions(),
      ...options,
    };
    this.createDraw(container);
  }

  createDraw(container: string | HTMLElement) {
    const el =
      typeof container === "string"
        ? document.querySelector(container)
        : container;
    const width = this.options.width;
    const height = this.options.height;
    this.draw = SVG()
      .addTo(el as HTMLElement)
      .size(width, height);
  }

}

SvgDraw通过传入svg需要绑定的dom和一些开发者额外需要的配置项,可以实现在任意指定dom内批注。

三、功能实现

这里主要说明框选批注和画笔的实现,包含了大多数批注场景会遇到的问题。

3.1 框选批注

操作步骤

  1. 鼠标点击操作区域,然后按住鼠标不放移动,移动时根据起点和鼠标当前位置实时渲染出矩形边框。
  2. 鼠标松手后,通过计算在矩形边框四周添加输入框,可输入批注内容。
  3. 输入完内容后,点击确认输入框消失,并显示头像鼠标悬停展示相关信息。

首先要做的就是确定起点,当鼠标点击批注区域时,通过点击事件计算出在区域对应的坐标。 svgjs提供了计算点坐标的方法。

  class SvgPosition {
    // SvgDraw实例
    svgDraw: any;
     
    // 计算点在svg中位置
    calcPointPosition(shape: Shape, e: any) {
      const position = {
        x: 0,
        y: 0,
      };
      // 通过创建一个`path`元素计算节点位置(待优化)
      const path = this.svgDraw.draw.path();
      // 通过`svgjs`的`path.point`计算出坐标值
      const point = path.point(e.pageX, e.pageY);
      if (shape) {
        position.x = point.x;
        position.y = point.y;
      }
      path.remove();

      return position;
    }
  }

通过svgjs提供的path.point传入事件的e可以计算出esvg区域内对应的坐标值,不需要开发者去计算非常便捷。

3.1.1 开始框选

鼠标点击是框选批注的起始动作,先来看看点击时做了哪些处理。

class SvgDraw {
  // svgjs实例
  draw: any;
  // 节点类型
  shape: Shape = null;
  // SvgPosition实例
  svgPosition: any;
  
  // 框选批注
  addAreaEvent() {
    // 标记拖拽开始
    let isStart = false;
    // 存储矩形元素
    let rectEle: any;
    // 起点坐标
    const startPosition = {
      x: 0,
      y: 0,
    };
    
    // 批注区域添加点击事件
    this.draw.mousedown((e: any) => {
      // 如果当前shape不是批注框选
      if (this.shape !== svgShapeConfig.areaConfirmInput) return;
      
      // 标记拖拽开始
      isStart = true;
      // 使用SvgPosition实例提供的节点坐标计算方法,获取起点的坐标位置
      const position = this.svgPosition.calcPointPosition(this.shape, e);

      startPosition.x = position.x;
      startPosition.y = position.y;
    });
  }
}

鼠标点击时如果shape是框选批注,则标记isStart,并计算出起点坐标。若后续鼠标不松开,且移动鼠标说明将要进行框选批注操作,接下来说明点击后鼠标移动时所做的处理。

3.1.2 框选时移动

class SvgDraw {
  // svgjs实例
  draw: any;
  // 节点类型
  shape: Shape = null;
  // SvgPosition实例
  svgPosition: any;

  // 框选批注
  addAreaEvent() {
    // 标记拖拽
    let isStart = false;
    // 存储矩形元素
    let rectEle: any;
    // 起点坐标
    const startPosition = {
      x: 0,
      y: 0,
    };

    // ...

    // 鼠标点击后移动时,动态计算矩形的起点和宽高
    const handleContainerMove = (e: any) => {
      if (!isStart) return;
      // 移动时计算鼠标悬停位置的坐标
      const position = this.svgPosition.calcPointPosition(this.shape, e);
      
      // 悬停位置和起点位置的x,y轴距离就是矩形的宽高
      const dx = Math.abs(startPosition.x - position.x);
      const dy = Math.abs(startPosition.y - position.y);

      // 超过设定的阈值(防止误触)时生成矩形
      if (
        !rectEle &&
        dx > this.options.areaDragCritical &&
        dy > this.options.areaDragCritical
      ) {
        // 查找未保存的框选批注对应的输入框
        const unSavedNode = this.nodeData.find(
          (i: any) =>
            // 框选批注
            i.shape === svgShapeConfig.areaConfirmInput &&
            // 未保存标记
            !i._saved &&
            // 有sourceKey表明是输入框,指向对应的矩形
            i.sourceKey
        );
        // 有未保存的框选批注又重新框选,则将未保存的元素和对应的节点删除
        if (unSavedNode)
          this.deleteNodeAndElementByKeys([
            // 输入框的key
            unSavedNode[keyField],
            // 对应的矩形的key
            unSavedNode.sourceKey as string,
          ]);

        // 通过svgjs的`rect`方法生成框选矩形
        rectEle = this.draw
          .rect()
          .stroke({
            color: this.options.strokeColor,
            width: this.options.strokeWidth,
          })
          .radius(this.options.radius)
          // 矩形不填充
          .fill("none");
      }

      if (rectEle) {
        // 鼠标移动时实时计算起点坐标
        const { x, y } = this.getAreaConfirmStartPosition(
          startPosition,
          position
        );
        // 通过svgjs的`size`和`move`方法动态变更矩形的宽高和起点
        rectEle.size(dx, dy).move(x, y);
      }
    };
    // 节流控制,减少触发,提升性能
    const moveFun = throttle(handleContainerMove, 30);
    // 批注区域添加鼠标移动事件
    this.draw.mousemove(moveFun);
  }

  // 框选批注起点坐标
  getAreaConfirmStartPosition(
    // 起点坐标
    startPosition: { x: number; y: number },
    // 当前鼠标悬停坐标
    position: { x: number; y: number }
  ) {
    
    return {
      // x坐标取较小值
      x: startPosition.x <= position.x ? startPosition.x : position.x,
      // y坐标取较小值
      y: startPosition.y <= position.y ? startPosition.y : position.y,
    };
  }
}

移动过程中主要做两件事:

  1. 动态计算矩形的起点和宽高并实时渲染,实现框选的效果。
  2. 如果有未保存的框选元素,找到对应矩形和输入框的key删除对应的元素和节点。

3.1.3 框选结束

确定了框选区域松开鼠标,给矩形插入一个输入框。先来看看输入框元素如何插入这里以vue为例:

import { createVNode, render } from "vue";

class SvgDraw {
  // svgjs实例
  draw: any;
  // 节点类型
  shape: Shape = null;
  // SvgPosition实例
  svgPosition: any;

  // 生成foreignObject元素
  genForeignObject(
    width: number,
    height: number,
    comp: Component,
    props?: { [key: string]: any }
  ) {
    if (!this.draw) return {};
    if (!props) props = {};
    // 创建一个foreignObject,并控制尺寸
    const foreignObject = this.draw.foreignObject(width, height);
    // vue组件生成VNode,并将VNode渲染到foreignObject元素内
    const vm = createVNode(comp, { ...props, element: foreignObject }) as any;
    render(vm, foreignObject.node as any);

    return { foreignObject, vm };
  }

  // 创建dom元素
  genElement(shape: Shape, key?: string, props?: { [key: string]: any }) {
    let ele: any;
    if (!props) props = {};
    if (shape === svgShapeConfig.areaConfirmInput) {
      // 计算输入框的位置和占位(下文详细说明)
      const { translateX, translateY, placement } =
        this.svgPosition.calcAreaInputPosition(
          props.sourceNode,
          confirmInputConfig.height,
          confirmInputConfig.width
        );
      // 插入输入框
      const { foreignObject } = this.genForeignObject(
        confirmInputConfig.width,
        confirmInputConfig.height,
        ConfirmInput,
        {
          placement,
          ...props,
        }
      );
      ele = foreignObject;
      // 输入框平移(超出边界时完整显示,下文详细说明)
      ele.transform({
        translateX,
        translateY,
      });
    }

    return ele;
  }
}

dom的插入使用了createVNode生成VNode,用render把VNode渲染到svgjs生成的foreignObject元素内。vue组件内js部分可以正常执行,可以应对复杂的业务场景和交互。

接下来看看框选批注结束,做了哪些处理:

class SvgDraw {
  // svgjs实例
  draw: any;
  // 节点类型
  shape: Shape = null;
  // SvgPosition实例
  svgPosition: any;

  // 框选批注
  addAreaEvent() {
    // 标记拖拽
    let isStart = false;
    // 存储矩形元素
    let rectEle: any;
    // 起点坐标
    const startPosition = {
      x: 0,
      y: 0,
    };

    // ...
    
    // 批注区域添加mouseup事件
    this.draw.mouseup(() => {
      // 鼠标松开时如果有矩形元素,说明是框选操作
      if (rectEle) {
        // 矩形元素的key
        const key = getUUID();

        // 通过`svgjs`提供的`data`方法设置矩形元素的key,用于后续查找、操作
        rectEle.data({
          [keyField]: key,
        });
        // 存储矩形元素
        this.elementData.push(rectEle);
        // 生成矩形元素节点数据
        const nodeData = this.genNode({
          shape: this.shape,
          subShape: svgSubShapeConfig.rect,
          [keyField]: key,
          x: rectEle.x(),
          y: rectEle.y(),
          width: rectEle.width(),
          height: rectEle.height(),
          strokeColor: this.options.strokeColor,
          strokeWidth: this.options.strokeWidth,
          radius: this.options.radius,
        });
        // 存储矩形元素节点数据
        this.nodeData.push(nodeData);
        
        // 输入框元素的key
        const eleKey = getUUID();
        // 输入框元素节点数据
        const eleNodeDataParams = this.genNode({
          shape: this.shape,
          subShape: svgSubShapeConfig.confirmInput,
          [keyField]: eleKey,
          sourceKey: key,
        });
        // 生成输入框元素
        const ele = this.genElement(this.shape, undefined, {
          // 对应矩形节点
          sourceNode: nodeData,
          node: eleNodeDataParams,
          svgPosition: this.svgPosition,
        });
        // 通过`svgjs`提供的`data`方法设置输入框元素的key,用于后续查找、操作
        ele.data({
          [keyField]: eleKey,
          sourceKey: key,
        });
        /**
         * rectEle.x()获取矩形元素的起点x轴坐标,rectEle.y()获取矩形元素的起点y轴坐标
         * 通过`move`方设置输入框元素的起点和对应矩形元素一致
         * 再通过计算平移的距离,让输入框元素在一个合适的位置(下文详细说明)
         */ 
        ele.move(rectEle.x(), rectEle.y());
        // 存储输入框元素节点数据
        this.elementData.push(ele);

        // 更新输入框节点坐标和宽高
        Object.assign(eleNodeDataParams, {
          x: ele.x(),
          y: ele.y(),
          width: ele.width(),
          height: ele.height(),
        });
        // 生成输入框节点数据
        const eleNodeData = this.genNode(eleNodeDataParams);
        // 存储输入框节点数据
        this.nodeData.push(eleNodeData);
      }
      // 重置框选标记
      isStart = false;
      // 重置矩形元素
      rectEle = null;
    });
  }
}

鼠标松开后关键操作:

  1. 如果有未保存的框选批注,删除对应的元素和节点数据。
  2. 存储矩形元素数据,同时生成对应节点数据并保存。
  3. 生成对应输入框元素,并保存元素数据,同时生成输入框元素和对应节点数据并保存。
  4. 重置框选标记、元素。

以上就是框选批注的主流程,主要分为起始、移动、松手三个阶段,其中包含了批注会碰到的绝大多数问题。此外还有一些细节方面的问题需要补充,下面针对上文提到的输入框元素定位做一个详细的说明。

3.1.4 元素定位

在实际的场景中通常会遇到一个常见的问题,那就是批注区域的变化。比如手动拖拽改变浏览器尺寸,如果批注区域设置的是百分比宽高或者是flex布局,那么批注区域的宽高将会不固定。而svg内的坐标和尺寸是固定的,它并不会随着svg的宽高改变而动态改变,为了保证批注元素(矩形元素等)和批注内容(视频、图片的内容)的相对位置不发生偏移,批注元素的坐标和尺寸都需要实时更新。

此外,由于svg内的元素只能在设置的区域内显示,为了完整展示输入框需要根据对应矩形的坐标起始坐标和输入框的宽高以及区域的宽高计算出输入框的合适位置。

主要问题归结如下:

  1. 起点位置需要随着输入区域宽高的变化同步更新。
  2. 矩形的宽高需要随着批注区域宽高动态变化,保证框选区域的准确性(非常重要)。
  3. 浏览器宽高改变时输入框的宽高不会改变,输入框的期待呢需要随着矩形元素的起点同步变化;批注区域变化时为了展示完全输入框的位置需要动态调整。

先来看看批注区域尺寸变化时,坐标位置如何更新,这里以x轴的坐标为例:

image.png

如上图所示,将x轴方向区域分为三块,内容左侧、内容区域(x、y轴方向居中)、内容区域右侧。

  1. 位于内容区域左侧时

    计算在左侧区域所占比例,尺寸变化时x坐标为:当前左侧宽度*比例。

  2. 位于内容区域时

    计算(当前位置-左侧区域宽度)后,占内容区域比例,尺寸变化时x坐标为:当前左侧宽度+当前内容区域宽度*比例。

  3. 位于内容区域右侧时

    计算(当前位置-(左侧区域+内容区域))宽度后,剩余宽度占内容区域右侧比例,尺寸变化时x坐标为:当前左侧宽度+当前内容区域宽度+内容区域右侧宽度*比例。

计算细节如下:

import { createVNode, render } from "vue";

class SvgPosition {
  svgDraw: any;

  // 内容区域变化时动态计算x轴坐标
  calcXPositionOnResize(node: Node) {
    const {
      x, // x轴坐标
      containerWidth: nodeContainerWidth, // 批注区域宽度
      effectiveAreaWidth: nodeEffectiveAreaWidth, // 内容区域宽度
    } = node; // 原始节点数据
    // 当前批注区域宽度、内容区域宽度(变化时,实时更新到svgDraw内)
    const { containerWidth, effectiveAreaWidth } = this.svgDraw;
    let positionX = 0;

    // 节点存储
    // 内容区域左侧宽度
    const nodeLeftAreaWidth =
      nodeContainerWidth / 2 - nodeEffectiveAreaWidth / 2;
    // 内容区域左侧宽度+内容区域宽度
    const nodeLeftAreaAndEffectWidth =
      nodeContainerWidth / 2 + nodeEffectiveAreaWidth / 2;

    // 当前
    // 内容区域左侧宽度
    const leftAreaWidth = containerWidth / 2 - effectiveAreaWidth / 2;
    // 内容区域左侧宽度+内容区域宽度
    const leftAreaAndEffectWidth = containerWidth / 2 + effectiveAreaWidth / 2;
    // 位于内容区域左侧
    if (x < nodeLeftAreaWidth) {
      const percent = x / nodeLeftAreaWidth;

      positionX = leftAreaWidth * percent;
    } else if (x >= nodeLeftAreaWidth && x <= nodeLeftAreaAndEffectWidth) {
      // 位于内容区域
      const percent = (x - nodeLeftAreaWidth) / nodeEffectiveAreaWidth;

      positionX = leftAreaWidth + effectiveAreaWidth * percent;
    } else {
      // 位于内容区域右侧
      const percent =
        leftAreaWidth === 0
          ? 0
          : (x - nodeLeftAreaAndEffectWidth) / leftAreaWidth;

      positionX = leftAreaAndEffectWidth + leftAreaWidth * percent;
    }

    return positionX;
  }
}

node节点数据中存储了元素创建时批注区域的宽高、内容区域的宽高、元素的x,y轴坐标位置等信息。通过这些数据可以计算出节点在批注区域内各个区域的占比,通过这些比例结合当前批注区域宽高、当前内容区域宽高可以计算出最新的坐标位置,从而保证位置不发生偏移。

除了节点坐标批注区域宽高变化时,元素的宽高也需要实时同步变化,以保证框选区域的准确性。以矩形元素的宽高相对于内容区域的宽高占比为基准,结合当前内容区域宽高计算实时的数值,这种计算方式可以保证在内容区域内框选的准确性。

// 宽度占比
const percentW = node.width / node.effectiveAreaWidth;
// 高度占比
const percentH = node.height / node.effectiveAreaHeight;
// 当前宽度
const width = effectiveAreaWidth * percentW;
// 当前高度
const height = effectiveAreaHeight * percentH;
// 通过`svgjs`的`size`方法重新设置矩形尺寸
element.size(
  width,
  height
);

和矩形元素不同的是,这里输入框的宽度是固定的,高度限制为最小2行最大6行超过则滚动条显示。为了让输入框完整显示,需要计算输入框合适的摆放位置。下面通过代码说明去和确定输入框的位置。

class SvgPosition {
  svgDraw: any;

  /**
   * 计算框选输入框的位置
   * @param {object} sourceNode 输入框对应的矩形节点
   * @param {number} height  输入框区域高度
   * @param {number} width  输入框区域宽度
   * @return {number} translateX 输入框X轴平移数值
   * @return {number} translateY 输入框Y轴平移数值
   */
  calcAreaInputPosition(sourceNode: Node, height: number, width: number) {
    const {
      x,
      y,
      height: sourceNodeHeight = 0,
      width: sourceNodeWidth = 0,
      strokeWidth = svgConfig.strokeWidth,
      _resize,
    } = sourceNode; // 输入框对应的矩形节点
    // 输入框高度+stroke高度(矩形的边框尺寸)
    const distanceY = height + (strokeWidth as number);
    // 输入框对应的矩形节点y坐标
    let yVal = y;
    // 输入框对应的矩形节点x坐标
    let xVal = x;
    // 输入框对应的矩形节点宽度数值
    let sourceNodeWidthVal = sourceNodeWidth;
    let sourceNodeHeightVal = sourceNodeHeight;
    // 输入框位置默认在矩形元素上方左对齐
    let placement = "topLeft";
    // 矩形节点坐标和宽高改变时
    if (_resize) {
      yVal = _resize.y;
      xVal = _resize.x;
      sourceNodeHeightVal = _resize.height;
      sourceNodeWidthVal = _resize.width;
    }
    // 判断输入框高度未超过矩形顶部到批注区域的距离
    const yTopUnOver = yVal > distanceY;
    // 输入框Y轴平移数值
    let translateY = yTopUnOver
      ? distanceY * -1 // 没有超出批注区域高度时平移到矩形元素左上角
      : sourceNodeHeightVal + (strokeWidth as number); // 超出时平移到矩形元素左下角
    // 判断矩形底部y轴坐标数值+输入框高度超过了批注区域高度
    const yOverContainer =
      yVal + sourceNodeHeightVal + height + (strokeWidth as number) * 2 >
      this.svgDraw.containerHeight;

    // 判断矩形左上角起点的(x轴坐标数值+输入框元素宽度)超过批注区域宽度
    const xOver = xVal + width > this.svgDraw.containerWidth;
    // 超过则平移数值为(矩形元素宽度-输入框宽度),没有超过则x轴不平移
    let translateX = xOver ? sourceNodeWidthVal - width : 0;
    
    // 矩形元素的上方和下方都无法完全放置输入框时
    if (!yTopUnOver && yOverContainer) {
      translateY = strokeWidth as number;
      translateX = strokeWidth as number;
      // 标记输入框起点位置为矩形内部左上角
      placement = "insideTopLeft";
    } else if (xOver && yTopUnOver) {
      // x轴方向右侧超出批注区域,y轴方向未超出矩形元素上方区域
      placement = "topRight";
    } else if (!yTopUnOver && !xOver) {
      // y轴方向超出矩形元素上方区域,x轴方向右侧未超出批注区域
      placement = "bottomLeft";
    } else if (!yTopUnOver && xOver) {
      // y轴方向超出矩形元素上方区域,x轴方向右侧超出批注区域
      placement = "bottomRight";
    }
    return {
      translateX,
      translateY,
      placement,
    };
  }
}

image.png

image.png

上图展示了5种定位方式,批注区域宽高变化时实时计算输入框的位置,此外输入框的高度因输入文字而变化时也会重新计算定位,始终保证输入框能够完整显示,定位的优先级为:

insideTopLeft > topLeft > topRight > bottomLeft > bottomRight

框选批注的矩形元素和输入框元素的坐标定位和宽高变化以及输入框的展示定位问题是重点和难点,也包含了大部分定位会碰到的问题。

3.1.5 元素图层

svg中的图层顺序是由元素在顺序决定的,后面的图层越高,且不能通过z-index属性控制,这就会导致前面插入的元素被后面插入的元素遮挡的问题。硬刺只能通过改变元素顺序来改变图层顺序,先来看个批注后元素重新排序的效果图:

image.png

从上图中可以看出,通过元素的重新排序,头像的图层都高于矩形元素,下面来看看如何快速根据自己的需求对元素重新进行排序。

class SvgPosition {
  svgDraw: any;

  // 通过改变渲染顺序改变图层
  changeElementOrder() {
    // 矢量图节点
    const graphicList: Node[] = [];
    // dom元素节点
    const foreignList: Node[] = [];
    this.svgDraw.nodeData.forEach((node: any) => {
      if (node.shape === svgShapeConfig.areaConfirmInput) {
        // 矩形元素
        if (node.subShape === svgSubShapeConfig.rect) {
          graphicList.push(node);
        } else if (node.subShape === svgSubShapeConfig.confirmInput) {
          // 输入框
          foreignList.push(node);
        }
      }
    });

    if (graphicList.length) {
      // 最后一个矩形节点数据
      const lastNode = graphicList[graphicList.length - 1];
      // 最后一个矩形节点对应的矩形元素
      const lastElement = this.svgDraw.elementData.find(
        (ele: any) => ele.data(keyField) === lastNode[keyField]
      );
      // 如果有矩形元素
      if (lastElement) {
        foreignList.forEach((node: any) => {
          // 找到输入框节点对应的元素
          const element = this.svgDraw.elementData.find(
            (ele: any) => ele.data(keyField) === node[keyField]
          );

          if (element) {
            // 通过`svgjs`的`index`方法获取最后一个矩形元素的排序index
            const lastElementIndex = this.svgDraw.draw.index(lastElement);
            // 输入框节点的排序index
            const elementIndex = this.svgDraw.draw.index(element);
            // 如果输入框元素在最后一个矩形元素前面
            if (
              lastElementIndex > -1 &&
              elementIndex > -1 &&
              lastElementIndex > elementIndex
            ) {
              // 通过`svgjs`的`insertAfter`方法把输入框元素插入到最后一个矩形元素后面
              element.insertAfter(lastElement);
            }
          }
        });
      }
    }
  }
}

通过对比输入框和元素和最后一个矩形元素的排序,如果有输入框元素在最后一个矩形元素前面,则把该输入框插入到最后一个矩形元素后面,最终达到输入框图层都高于矩形元素图层的效果。

总结:

框选批注涉及到的细节问题较多,大致可以归为以下几个要点:

  1. 通过鼠标事件完成矩形元素和输入框元素的插入。
  2. 元素坐标的定位、位置更新、元素宽高的更新。
  3. 通过计算确认输入框位置。
  4. 元素的顺序决定图层,根据需要重新设置顺序改变元素图层。

3.2 画笔

相对于框选批注来说,路径的绘制较为简单,但是路径的更新计算量较大。

操作步骤

  1. 鼠标点击时记录起点,并标记绘制开始。
  2. 鼠标不松开在批注区域移动过程中记录坐标位置,并通过path元素实时绘制。
  3. 鼠标松开时结束绘制,标记绘制结束。

image.png

3.2.1 路径生成

用户可以根据自己的需要圈出不同的区域,也可以书写文字,是比较灵活的一种批注方式。下面来看看具体的实现方式:

class SvgDraw {
  // svgjs实例
  draw: any;
  // 节点类型
  shape: Shape = null;
  // SvgPosition实例
  svgPosition: any;

  // 画笔功能
  addPaintingEvent() {
    // 开始标记
    let isStart = false;
    // path元素
    let pathEle: any;
    // 路径点str
    let pathStr = "";
    // 存储路径点坐标`{ x:number; y:number; }`
    let pathArr: any[] = [];
    // 鼠标按下时
    this.draw.mousedown((e: any) => {
      // 如果当前批注类型不是画笔
      if (this.shape !== svgShapeConfig.painting) return;
      // 开始标记
      isStart = true;
      // 通过鼠标事件`e`计算出起点坐标
      const position = this.svgPosition.calcPointPosition(this.shape, e);
      // 路径添加起点用`M`标记
      pathStr = `M${position.x} ${position.y}`;
      // 存储起点坐标数据
      pathArr.push(position);
      // 通过`svgjs`的`path`方法生产`path`元素
      pathEle = this.draw
        .path(pathStr)
        // 设置宽度和颜色
        .stroke({
          color: this.options.strokeColor,
          width: this.options.strokeWidth,
        })
        // 不填充
        .fill("none");
      // 移除`svgjs`默认的动画效果
      this.moveAnimation(pathEle);
    });
    // 按下鼠标后移动
    const handleContainerMove = (e: any) => {
      // 如果没有开始标记
      if (!isStart) return;
      // 移动时实时计算鼠标停留位置对应坐标
      const position = this.svgPosition.calcPointPosition(this.shape, e);
      // 添加路径点用`L`标记
      pathStr += `L${position.x} ${position.y}`;
      // 存储路径坐标数据
      pathArr.push(position);
      // 通过`svgjs`的`plot`方法实时更新路径点,渲染完整路径
      pathEle.plot(pathStr).animate(0, 0);
    };

    const moveFun = throttle(handleContainerMove, 30);

    this.draw.mousemove(moveFun);
    // 鼠标松开时
    this.draw.mouseup(() => {
      if (pathEle) {
        // 节点key
        const key = getUUID();

        pathEle.data({
          [keyField]: key,
        });
        // 存储路径元素
        this.elementData.push(pathEle);
        // 生成路径节点数据
        const nodeData = this.genNode({
          shape: this.shape,
          [keyField]: key,
          path: pathStr,
          pathArr,
        });
        // 存储路径节点数据
        this.nodeData.push(nodeData);
      }
      // 重置标记相关数据
      isStart = false;
      pathEle = null;
      pathStr = "";
      pathArr = [];
    });
  }
}

相对于框选批注来说,画笔批注的过程较为简单可以概括为:

  1. 鼠标按下记录标记开始,并记录起始点生成path
  2. 鼠标按下后移动实时更新path实时渲染,并记录节点数据。
  3. 鼠标松开时,存储元素和节点数据,重置标记相关数据。

3.2.2 路径更新

这里的更新是指批注区域宽高变化时,路径点坐标的同步更新。为了保证画笔批注的准确性,批注区域或者内容区域的宽高变化时,所有相关路径点都需要同步实时更新,适应新的尺寸。

下面来看看具体是如何实现的:

import { keyField, svgShapeConfig } from "./config";

class SvgPosition {
  svgDraw: any;

  // 更新节点坐标、图形尺寸
  updateElementPosition() {
    const { nodeData, elementData } = this.svgDraw;

    nodeData.forEach((node: any) => {
      const element = elementData.find(
        (ele: any) => ele.data(keyField) === node[keyField]
      );
      // 画笔元素
      if (node.shape === svgShapeConfig.painting) {
        // 根据所有路径点数据,计算出当前path
        const pathStr = this.calcPaintingPosition(node);
        // 通过`svgjs`的`plot`方法更新路径点
        element.plot(pathStr);
        // 移除动画效果
        this.svgDraw.moveAnimation(element);
      }
    });
  }

  /**
   * 计算画笔元素路径点path
   * @return {string} pathStr 新的路径数据
   */
  calcPaintingPosition(node: Node) {
    // 如果不是画笔批注
    if (node.shape !== svgShapeConfig.painting) return;
    let pathStr = "";
    const {
      containerWidth,
      containerHeight,
      effectiveAreaWidth,
      effectiveAreaHeight,
    } = node;
    // 遍历存储的路径点坐标
    node?.pathArr?.forEach((position: any) => {
      // 计算x轴新坐标
      const x = this.calcXPositionOnResize({
        x: position.x,
        containerWidth,
        effectiveAreaWidth,
      });
      // 计算y轴新坐标
      const y = this.calcYPositionOnResize({
        y: position.y,
        containerHeight,
        effectiveAreaHeight,
      });
      // 起始点
      if (!pathStr) {
        pathStr = `M${x} ${y}`;
      } else {
       // 路径点
        pathStr += `L${x} ${y}`;
      }
    });

    return pathStr;
  }
}

遍历存储的所有路径点,计算每个点对应的最新坐标组合成最新的路径数据,最后通过svgjsplot方法实时更新路径点,保证画笔批注的准确性。

3.3 批注区域宽高变化

这里以vue为例,通过插件监听dom元素宽高变化,同步更新元素和节点相关数据。

dom布局示例

<!-- 空间容器 -->
<div ref="containerRef" class="box">
    <!-- svg容器-批注区域 -->
    <div ref="svgRef" class="svg-container"></div>
    <!--内容区域-视频、图片等 -->
    <div ref="effectRef" class="content"></div>
</div>

通用的逻辑,封装useSvg

import { useElementSize } from "@vueuse/core";
import type { DrawOptions } from "./type";

export default (
  {
    svgRef,
    containerRef,
    effectRef,
    autoCreate = true,
  }: {
    // svg绑定的dom
    svgRef: Ref<HTMLElement>;
    // svg操作范围绑定的dom
    containerRef: Ref<HTMLElement>;
    // svg有效区域绑定的dom
    effectRef: Ref<HTMLElement>;
    // 配置组件渲染完成后是否自动创建draw
    autoCreate?: boolean;
  },
  options?: DrawOptions
) => {
  // draw实例存储
  const draw = shallowRef();

  // 创建svgDraw
  function createDraw() {
    draw.value = new SvgDraw(svgRef.value as HTMLElement, options);

    return draw;
  }

  onMounted(() => {
    // 默认组件渲染完成后创建draw
    if (autoCreate) createDraw();
  });

  // 批注区域宽到变化
  const { width, height } = useElementSize(containerRef);
  // 内容区域宽高变化
  const { width: videoWidth, height: videoHeight } = useElementSize(effectRef);
  // 宽高变化时,更新svg内元素的定位、尺寸
  watch(
    [width, height, videoWidth, videoHeight],
    ([val1, val2, val3, val4]) => {
      // 更新
      draw.value && draw.value.updateContainer(val1, val2, val3, val4);
    }
  );
};

下面是更新逻辑,上文有提到画笔批注更新的部分。

import { keyField, svgShapeConfig } from "./config";

class SvgPosition {
  svgDraw: any;

  // 更新节点坐标、图形尺寸
  updateElementPosition() {
    const { nodeData, elementData, effectiveAreaWidth, effectiveAreaHeight } =
      this.svgDraw;

    nodeData.forEach((node: any) => {
      // 计算节点x轴坐标
      const x = this.calcXPositionOnResize(node);
      // 计算节点y轴坐标
      const y = this.calcYPositionOnResize(node);
      // 通过节点key找到对应元素
      const element = elementData.find(
        (ele: any) => ele.data(keyField) === node[keyField]
      );
      // 画笔批注
      if (node.shape === svgShapeConfig.painting) {
        // 计算出的路径数据
        const pathStr = this.calcPaintingPosition(node);
        element.plot(pathStr);
        this.svgDraw.moveAnimation(element);
      } else {
        // 其他类型将元素起点移动到最新计算的位置
        element.move(x, y);
      }

      // 框选批注,重新设置框的尺寸
      if (node.shape === svgShapeConfig.areaConfirmInput) {
        // 输入框
        if (node.sourceKey) {
          const sourceNode = this.svgDraw.nodeData.find(
            (i: any) => i[keyField] === node.sourceKey
          );

          if (sourceNode?._resize) {
            // 尺寸变化时,为了不被遮挡,动态计算定位并重新设置平移
            const { translateX, translateY, placement } =
              this.calcAreaInputPosition(
                sourceNode,
                element.height(),
                element.width()
              );
            // 输入框平移到合适的位置
            element.transform({
              translateX,
              translateY,
            });
          }
        } else {
          // 矩形
          const percentW = node.width / node.effectiveAreaWidth;
          const percentH = node.height / node.effectiveAreaHeight;
          const width = effectiveAreaWidth * percentW;
          const height = effectiveAreaHeight * percentH;
          // 重新设置尺寸
          element.size(
            effectiveAreaWidth * percentW,
            effectiveAreaHeight * percentH
          );
          // 记录当前尺寸、坐标
          node._resize = {
            x,
            y,
            width,
            height,
          };
        }
      }
    });
  }
}

这里的更新逻辑包含了框选批注和画笔批注,基本围绕坐标起点、宽高、定位的调整,包含了大部分批注场景会遇到的更新相关场景。

到此,框选批注和画笔批注的功能实现介绍完毕了,总的来说细节问题不少。有些只有在实际的开发的过程中碰到,才会去想办法解决。其他类型的批注肯定会有不一样的细节,后续有兴趣可以开发新的批注工具。

四、批注支持@

image.png

在资源管理平台我们的资源比如视频、图片制作上传后需要专门的人员或者领导审核,填写批注意见时支持@系统内用户并发送对应消息通知,可以及时告知对应人员快速做处理,比如蓝湖就支持@项目内成员。

有不少的支持批注的平台都有做@功能,但是大部分都是在批注区域外有一个专门的@操作和操作区域内批注内容做了分离,或者有些平台的批注内容是在批注区域外编辑的。可以考虑到的是,不少平台这样做的原因是这种功能的实现成本比较高、难度较大。@用户在输入区域内需要作为一个整体添加和删除,本人有做过社区类内容编辑的开发发现input类普通文本编辑没有办法做到这种整体添加和删除的效果,且在svg或者canvas内更难处理。

目前我的解决方案是使用富文本编辑器支持@,现代的富文本编辑器除了支持插入图片、链接等内容也支持自定义元素,这得益于现在高度开放的开源环境,不少高复杂度的功能都可以通过开源社区提供的工具实现。

支持自定义元素的富文本编辑器

wangEditor 5,一款基于slatejs开发的富文本编辑器,不依赖于vue或者react类的第三方框架灵活度高。

该富文本编辑器支持以插件的形式添加自定义元素,需要根据插件的结构设计添加开发者自己需要的自定义元素,最后通过编辑器提供的insertNode插入自定义元素,自由度非常高。

富文本编辑器作者开发了一个mention插件,支持输入@时弹出人员选择框,选择后通过编辑器提供的api插入对应人员、删除多余@符号。

4.1 mention插件设计解析

精简的mention插件使用示例,这里以vue框架内使用为例

<template>
  <!-- 编辑器 -->
  <Editor
    style="height: 400px"
    v-model="html"
    :defaultConfig="editorConfig"
    @onCreated="onCreated"
  />
  <!-- @用户弹出框,开发者自定义 -->
  <mention-modal
    v-if="isShowModal"
    @hideMentionModal="hideMentionModal"
    @insertMention="insertMention"
  ></mention-modal>
</template>

<script setup lang="ts">
import { Boot } from "@wangeditor/editor";
import { Editor } from "@wangeditor/editor-for-vue";
import mentionModule from "@wangeditor/plugin-mention";
import MentionModal from "./MentionModal";
// 注册mention插件
Boot.registerModule(mentionModule);

const html = ref();

// 编辑器配置项
const editorConfig = {
  // mention插件检测到输入的内容是`@`是调用此处的`showModal`方法
  EXTEND_CONF: {
    mentionConfig: {
      showModal: showMentionModal,
      hideModal: hideMentionModal,
    },
  },
};

const isShowModal = ref(false);
// 打开mention用户弹出框
function showMentionModal() {
  isShowModal.value = true;
}
// 关闭mention用户弹出框
function hideMentionModal() {
  isShowModal.value = false;
}

// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef();
function onCreated(editor: any) {
  editorRef.value = editor;
}

// 插入@用户
function insertMention(id: string, name: string) {
  // 按规则配置自定义节点元素
  const mentionNode = {
    type: "mention", // 必须和 'mention'插件内定义的节点类型一致
    value: name,
    info: { id },
    children: [{ text: "" }], // 必须有一个空 text 作为 children
  };

  editorRef.value.restoreSelection(); // 恢复选区
  editorRef.value.deleteBackward("character"); // 删除 '@'
  editorRef.value.insertNode(mentionNode); // 插入 mention
  editorRef.value.move(1); // 移动光标
}
</script>

  1. 引入mention插件并注册。
  2. 编辑器编辑内容时mention插件检测到输入的内容是@时会调用开发者自定义的mentionConfig内配置的showModal方法,开发者在此方法内根据自己的需求打开用户选择框,根据需求在不同场景下选择了@内容后,开发者通过insertNode把自定义的mention节点添加到编辑器内。mention插件内将节点元素contentEditable设置为false所以@内容将作为一个整体不可编辑。
  3. mention插件内添加了fullScreenunFullScreenscrollmodalOrPanelShowmodalOrPanelHide事件监听,这些事件触发时会调用开发者自定义的mentionConfig内配置的hideModal方法,开发者可以在此方法内关闭mention内容弹出框。

弹出框根据光标输入@的位置定位,下面来看看MentionModal组件是如何定位的。

<template>
  <!-- 弹窗设置为绝对定位 -->
  <div class=".mention-modal" :style="{ top: top, left: left }">
    <input ref="mentionModalRef" v-model="searchVal" />
    <ul class=".mention-list">
      <!-- 点击时触发插入、关闭弹窗事件 -->
      <li
        v-for="item in searchedList"
        :key="item.id"
        @click="insertMentionHandler(item.id, item.name)"
      >
        {{ item.name }}
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
const emit = defineEmits(["insertMention", "hideMentionModal"]);
const list = [
  { id: "a", name: "A张三" },
  { id: "b", name: "B李四" },
  { id: "c", name: "C小明" },
  { id: "d", name: "D小李" },
  { id: "e", name: "E小红" },
];

const top = ref("");
const left = ref("");
const searchVal = ref("");

const searchedList = computed(() => {
  const val = searchVal.value.trim().toLowerCase();
  return list.filter((item) => {
    const name = item.name.toLowerCase();
    if (name.indexOf(val) >= 0) {
      return true;
    }
    return false;
  });
});

function insertMentionHandler(id: string, name: string) {
  emit("insertMention", id, name);
  emit("hideMentionModal"); // 隐藏 modal
}

const mentionModalRef = shallowRef();
onMounted(() => {
  // 获取光标位置
  const domSelection = document.getSelection();
  const domRange = domSelection?.getRangeAt(0);
  if (domRange == null) return;
  const rect = domRange.getBoundingClientRect();

  // 弹窗为绝对定位,设置弹窗位置
  top.value = `${rect.top + 20}px`;
  left.value = `${rect.left + 5}px`;

  // focus input
  mentionModalRef.value.focus();
});
</script>

<style scoped>
.mention-modal {
  position: fixed;
}
</style>

  1. 将弹窗设置为绝对定位。
  2. 弹窗dom生成后,根据光标所处的位置设置topleft,使弹窗在编辑器内输入@的位置附近弹出。
  3. 选择插入的内容后触发insertMentionhideMentionModal事件,在父级组件内通过编辑器api插入内容并销毁弹窗。

综合来说该插件对开发来说自由度高,可以根据自己的需求做调整,完成不同的需求。

4.2 基于mention插件在svg的foreignObject元素内支持@

mention插件的示例代码大部分代码都能直接使用,结合svg的需求场景有几个问题需要处理。

4.2.1 样式的定制

先来看看mention插件内样式是如何定制的

/**
 * @description render elem
 * @author wangfupeng
 */

import { h, VNode } from 'snabbdom'
import { DomEditor, IDomEditor, SlateElement } from '@wangeditor/editor'
import { MentionElement } from './custom-types'

function renderMention(elem: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode {
  // 当前节点是否选中
  const selected = DomEditor.isNodeSelected(editor, elem)
  const { value = '' } = elem as MentionElement

  // 构建 vnode
  const vnode = h(
    'span',
    {
      props: {
        contentEditable: false, // 设置节点不可编辑
      },
      style: {
        marginLeft: '3px',
        marginRight: '3px',
        backgroundColor: 'var(--w-e-textarea-slight-bg-color)',
        border: selected // 选中/不选中,样式不一样
          ? '2px solid #1DA57A' // wangEditor 提供了 css var https://www.wangeditor.com/v5/theme.html
          : '2px solid transparent',
        borderRadius: '3px',
        padding: '0 3px',
      },
    },
    `@${value}` // 如 `@张三`
  )

  return vnode
}

const conf = {
  type: 'mention', // 节点 type ,重要!!!
  renderElem: renderMention,
}

export default conf

mention插件代码中通过设置style内的backgroundColor设置背景色,通过selected属性判断节点是否被选中设置,通过border设置边框颜色,所以可以通过修改默认的css变量值实现样式的定制。还有一种更直接的方式就是把插件添加的开发者项目内,根据开发者需求做定制。为了方便定制我选择了添加插件到项目内再做修改的方式。

4.2.2 modal元素的定位

svg内元素只在设置的可视区域内可见,所以如果按照实例代码内那样设置MentionModal为绝对定位,那么超出编辑区域的部分将不可见,为了解决这个问题可以将MentionModal元素添加到更高的层级比如直接添加到body层级。这里以vue为例,实现方案如下:

<template>
  <Editor
    class="rich-editor-wrap"
    v-model="html"
    :defaultConfig="editorConfig"
    @onChange="onChange"
    @onCreated="onCreated"
  />
  <!-- @弹出框渲染到body -->
  <Teleport v-if="isShowModal" to="body">
    <mention-modal
      @hideMentionModal="hideMentionModal"
      @insertMention="insertMention"
    />
  </Teleport>
</template>

通过Teleport组件将MentionModal渲染到body层,快速解决svg内元素超出可视区域不可见问题,同时将MentionModal设置为fixed定位。

此外还需考虑一个边界问题,如下图所示:

image.png

如图所示因为是fixed定位所以在右侧和底部边间附近会出现超出浏览器可视区域被遮挡的问题,因此需要添加编辑处理:

async function calcModalPosition() {
  // 获取光标位置
  const domSelection = document.getSelection();
  const domRange = domSelection?.getRangeAt(0);
  if (domRange == null) return;
  const rect = domRange.getBoundingClientRect();
  await nextTick();
  // 定位 modal
  if (rect.left + mentionModalRef.value.offsetWidth > window.innerWidth) {
    // 超出浏览器右侧显示区域
    left.value = `${rect.left - mentionModalRef.value.offsetWidth - 5}px`;
  } else {
    left.value = `${rect.left}px`;
  }

  if (
    rect.top + mentionModalRef.value.offsetHeight + lineHeight >
    window.innerHeight
  ) {
    // 超出浏览器下侧显示区域
    top.value = `${rect.top - mentionModalRef.value.offsetHeight}px`;
  } else {
    top.value = `${rect.top + lineHeight}px`;
  }
}

如代码所示,超出浏览器右侧区域时将弹出框定位到@符号左侧,超出浏览器下侧显示区域时将弹出框定位到@符号顶部,实际的效果如下:

image.png

4.2.3 modal元素的移除

因为MentionModal通过Teleport组件渲染到了body层,所以通过js的api将批注元素删除时MentionModal仍然在body层没有被销毁,因此需要将其同步销毁。销毁方式如下:

首先在MentionModal最外层class添加rich-editor-mention-modal-wrap特殊标记

<template>
  <div
    ref="mentionModalRef"
    class="mention-modal rich-editor-mention-modal-wrap"
    :style="{ top: top, left: left }"
  >
    <a-input
      class="mention-input"
      v-model:value="searchVal"
      ref="inputRef"
      @keyup="inputKeyupHandler"
    />
    <ul class="mention-list">
      <li
        v-for="item in searchedList"
        :key="item.id"
        @click="insertMentionHandler(item.id, item.name)"
      >
        {{ item.name }}
      </li>
    </ul>
  </div>
</template>

再通过js的方式将MentionModal删除,只需要在需要的时候调用即可。

function removeMentionModals() {
  const modalElements = document.querySelectorAll(`.rich-editor-mention-modal-wrap`)
  for (let i = 0; i < modalElements.length; i++) {
    const modalEle = modalElements[i]
    modalEle.remove()
  }
}

这个方案利用了现有的开源插件,并在此基础上根据需求做了相应的改造,实现了需要的功能整体的效果也不错。有一个可以改进的方向就是,为了便捷的在富文本插入自定义元素直接使用的wangEditor相当于每一个框选批注都插入了一个富文本组件有点杀鸡用牛刀的感觉。如果能用slatejs封装一个支持自定义元素的简易版富文本编辑器,或者除了富文本有没有更好的实现方案是一个优化的方向。

五、总结

从需求到技术选型再到功能实现,这次做了一个较为完整的回顾。实际的开发流程是花了一周左右时间做技术调研和写demo,方案评审时也有提出一些先见问题,比如尺寸问题,但总体来说整个过程是比较顺利的。感觉上来说现在开发一个新功能可选的东西较多,而且不少插件、组件、框架也做的很好,日常开发也会用到ai辅助写一些代码。

时代真的变了,时常也会感叹和自己日常开发不同,不少开源的东西做的真的很好,相对来说自身的热情和技术能力都还远远不及。但是也就是感叹,日常确实没有花费太多的精力往深度发展。上班的同学应该感同身受,不少人日常已经被日常的业务开发填满了,有时候也是完成度优先能用就行,如果加上天天加班那就更没有什么心思去做什么细活。更直接的原因是,代码写的越好并不意味着薪资就越高,因为大部分公司还是业务导向的,很多时候其实根本没有到需要谈技术深度的地步。所以,自身的感受就是还想学的时候就学,还想写的时候就写,不掺杂额外的东西,当然我也没有多少想提笔的时候。

这次写这篇除了算作是一次技术总结,也是想看看类似较为复杂的一个需求需要注意一些什么问题,后续碰到类似的没接触过的实现较为复杂的东西该怎么样去入手,有没有一些经验上的东西是可以提炼的。

此外,写技术类文章可以锻炼一下写作能力。一路写下来一直有一个感受就是,怎么样组织文字才能更准确的表达出自己原本的意思,同时也让读者能够尽可能低成本的理解。文章的结构要怎么划分,才能显得条理更加清晰,文字描述和代码要怎么要分配。通篇下来自我的一个感受是,相对来说代码的占比较多,当然代码注释是非常详细的,但是直觉上还是会觉得给读者阅读和理解添加一些障碍。

就像写论文一样,技术类文章的文字也是需要不断打磨的,这次算是给自己一次锻炼的机会。当然,如果这篇文章能给需要的人带来一些帮助和启发那就更好了。