不从零开始构建专属 SVG 编辑器的实战指南

在Web开发领域,重复造轮子并非明智之举。当我们需要定制一个 SVG 编辑器时,与其完全从零开始,不如基于成熟的基础进行构建。

@svgedit/svgcanvas 简介

@svgedit/svgcanvas 是开源项目 SVGEdit 的核心组件,提供底层的 SVG 操作能力。将界面和交互逻辑交给开发者自定义,使我们可快速构建自己的编辑器,同时保持高度可定制性

  • 优点: 经过了超过十年的发展和实际项目的考验,提供了一个功能高度完备的起点

实现了从基本的图形绘制(如矩形、圆形、路径)到复杂的编辑操作(如图层管理、历史记录)等一套完整的 SVG 画布操作能力。

这意味着无需从零开始实现这些复杂逻辑,可以专注于定制编辑器的界面、交互和特定业务功能,从而显著提升开发效率

  • 缺点: 缺乏 TypeScript 支持和详细的 API 文档。

没有 ts 就失去了类型检查、代码智能提示、以及编译时错误检测,开发体验会受到影响

缺乏详细 API 文档,有时需要直接阅读源码,虽然一开始可能有难度,但这是理解库的核心工作机制和最可靠的途径。

快速搭建

为了避免 new SvgCanvas 后得到的 canvas 实例在组件间传递的复杂性,通常建议使用状态管理库将其提升为全局状态。

但出于简化示例的目的,本文将暂不引入相关库,而是聚焦于核心逻辑。

tsx 复制代码
import React, { useEffect, useRef, useState } from 'react';
import SvgCanvas from '@svgedit/svgcanvas';

//默认配置
const config = {
  canvas_expansion: 0,
  initFill: {
    color: 'fff',
    opacity: 1,
  },
  initStroke: {
    width: 5,
    color: '000000',
    opacity: 1,
  },
  text: {
    stroke_width: 0,
    font_size: 12,
  },
  gridSnapping: false,
  baseUnit: 'px',
};

const App: React.FC = () => {
  const svgcanvasRef = useRef(null);
  const textRef = useRef(null);

  const [canvas, setCanvas] = useState(null);

  function updateCanvas(canvas: SvgCanvas, editorDom: HTMLDivElement) {
    const workarea = editorDom.parentNode as HTMLElement;
    let { width, height } = workarea.getBoundingClientRect();
    editorDom.style.width = `${width}px`;
    editorDom.style.height = `${height}px`;
    canvas.updateCanvas(width, height);
  }
  
  useEffect(() => {
    const editorDom = svgcanvasRef.current;
    if (editorDom) {
      // 创建 SvgCanvas 实例
      const canvas = new SvgCanvas(editorDom, config);
      setCanvas(canvas);
      updateCanvas(canvas, editorDom);
      if (textRef.current) {
        // 设置文本输入框元素
        canvas.textActions.setInputElem(textRef.current);
      }
    }
  },[]);

  return (
    <>
      <div>
        {['select', 'ellipse', 'rect', 'path', 'line', 'text'].map((item) => (
           canvas?.setMode(item)}
          >
            {item}
          
        ))}
      </div>
      <div>
        <div/>
      </div>
       {
          canvas?.setTextContent((e.target as HTMLInputElement).value);
        }}
        onFocus={() => {
          const selectedElement = canvas?.getSelectedElements()[0];
          if (textRef.current && selectedElement) {
            textRef.current.value = selectedElement.textContent;
          }
        }}
        onBlur={() => {
          if (textRef.current) textRef.current.value = '';
        }}
        style={{ position: 'absolute', left: '-9999px' }}
      />
    
  );
};

export default App;

功能实现

下面提供一些你可能需要的功能实现。

绘制网格背景

源码中的 setBackgroundMethod 方法(位于 elem-get-set.js 文件内)用于设置编辑器背景,但功能较为基础,仅支持配置填充色和背景图片。

为保持结构不变,在生成网格后,将 #canvasBackground元素内的 ``进行替换。

tsx 复制代码
import SvgCanvas from '@svgedit/svgcanvas';

function useGridBg(props: { gridSize: number }) {
  const { gridSize } = props;

  function drawGridBg(canvas: SvgCanvas | null) {
    const editorDom = canvas?.getSvgRoot().parentNode as HTMLElement;

    if (!editorDom || !canvas) return;

    const zoom = canvas.getZoom();
    const scaledGridSize = gridSize * zoom;
    const gridSvg = document.createElementNS(
      'http://www.w3.org/2000/svg',
      'svg',
    );
    gridSvg.setAttribute('width', '100%');
    gridSvg.setAttribute('height', '100%');
    gridSvg.setAttribute('overflow', 'visible');
    gridSvg.setAttribute('pointer-events', 'none');
    // 获取画布的位移
    const viewBox = canvas.getSvgRoot().getAttribute('viewBox')?.split(' ');
    const translateX = viewBox ? parseFloat(viewBox[0]) : 0,
      translateY = viewBox ? parseFloat(viewBox[1]) : 0;
    const bg = document.getElementById('canvasBackground');

    if (bg) {
      const visibleWidth = Number(editorDom.style.width.replace('px', '')),
        visibleHeight = Number(editorDom.style.height.replace('px', ''));
      const bgWidth = Number(bg.getAttribute('width')?.replace('px', '')),
        bgHeight = Number(bg.getAttribute('height')?.replace('px', ''));
      const width = Math.max(visibleWidth, bgWidth),
        height = Math.max(visibleHeight, bgHeight);

      // 计算可视区域的边界
      const redundancy = Math.ceil(width / 2 / scaledGridSize) * scaledGridSize;

      const startX =
        -redundancy + Math.floor(translateX / scaledGridSize) * scaledGridSize;
      const startY =
        -redundancy + Math.floor(translateY / scaledGridSize) * scaledGridSize;
      const endX = width + redundancy + translateX;
      const endY = height + redundancy + translateY;
      // 绘制横线
      for (let y = startY; y < endY; y += scaledGridSize) {
        const line = document.createElementNS(
          'http://www.w3.org/2000/svg',
          'line',
        );
        line.setAttribute('x1', startX.toString());
        line.setAttribute('y1', y.toString());
        line.setAttribute('x2', endX.toString());
        line.setAttribute('y2', y.toString());
        line.setAttribute('stroke', '#e2deded5');
        line.setAttribute('stroke-width', '1');
        gridSvg.appendChild(line);
      }

      // 绘制竖线
      for (let x = startX; x < endX; x += scaledGridSize) {
        const line = document.createElementNS(
          'http://www.w3.org/2000/svg',
          'line',
        );
        line.setAttribute('x1', x.toString());
        line.setAttribute('y1', startY.toString());
        line.setAttribute('x2', x.toString());
        line.setAttribute('y2', endY.toString());
        line.setAttribute('stroke', '#e2deded5');
        line.setAttribute('stroke-width', '1');
        gridSvg.appendChild(line);
      }

      bg.innerHTML = '';
      bg.appendChild(gridSvg);
    }
  }

  return { drawGridBg };
}
export default useGridBg;
less 复制代码
:global {
    #canvasBackground {
      overflow: visible;
    }
  }

new SvgCanvas 时,就绘制背景。

tsx 复制代码
const App: React.FC = () => {
  /**......省略部分代码 */
  

  /** 这部分是新增的代码*/
  const { drawGridBg } = useGridBg({ gridSize: 20 });
  /**------------------------------------*/
  
  useEffect(() => {
    const editorDom = svgcanvasRef.current;
    if (editorDom) {
      const canvas = new SvgCanvas(editorDom, config);
      setCanvas(canvas);
      updateCanvas(canvas, editorDom);
      /** 这部分是新增的代码 */
      // 背景网格绘制
      drawGridBg(canvas);
      /**------------------------------------*/
      if (textRef.current) {
        canvas.textActions.setInputElem(textRef.current);
      }
    }
  }, []);
  
  /**......省略部分代码 */
};

按住鼠标滚轮拖拽画布

拖拽功能!这么实用的功能,是的,没有 API。

我是通过更改 viewBox 来实现的。

tsx 复制代码
const App: React.FC = () => {
  /**......省略部分代码 */
  
  useEffect(() => {
    const editorDom = svgcanvasRef.current;
    let isDragging = false;
    // 存储鼠标按下时的坐标和画布初始位置
    let startX: number = 0,
      startY: number = 0,
      initialTranslateX: number = 0,
      initialTranslateY: number = 0;

    function handleMouseDown(e: MouseEvent) {
      // 仅当按下 鼠标滚轮 时开始拖拽
      if (e.button === 1) {
        const selectGroup = document.getElementById('selectorParentGroup');
        if (selectGroup) {
          selectGroup.style.display = 'none';
        }
        document.body.style.cursor = 'grab';
        canvas?.clearSelection();
        e.preventDefault();
        isDragging = true;
        startX = e.clientX;
        startY = e.clientY;

        //获取当前画布移动的距离
        [initialTranslateX, initialTranslateX] = canvas
          ?.getSvgRoot()
          .getAttribute('viewBox')
          ?.split(' ')
          .map(Number) ?? [0, 0, 0, 0];
      }
    }

    function handleMouseMove(e: MouseEvent) {
      if (isDragging) {
        const dx = e.clientX - startX;
        const dy = e.clientY - startY;
        const newTranslateX = initialTranslateX - dx;
        const newTranslateY = initialTranslateY - dy;
        // 设置画布的位置
        const viewBox = canvas?.getSvgRoot(),
          width = viewBox?.getAttribute('width'),
          height = viewBox?.getAttribute('height');

        viewBox?.setAttribute(
          'viewBox',
          `${newTranslateX} ${newTranslateY} ${width} ${height}`,
        );
        document.body.style.cursor = 'grabbing';
        // 重新绘制网格背景
        drawGridBg(canvas);
      }
    }

    function handleMouseUp() {
      isDragging = false;
      document.body.style.cursor = 'default';
      const selectGroup = document.getElementById('selectorParentGroup');
      if (selectGroup) {
        selectGroup.style.display = 'block';
      }
    }

    editorDom?.addEventListener('mousedown', handleMouseDown);
    editorDom?.addEventListener('mousemove', handleMouseMove);
    editorDom?.addEventListener('mouseup', handleMouseUp);
    return () => {
      editorDom?.removeEventListener('mousedown', handleMouseDown);
      editorDom?.removeEventListener('mousemove', handleMouseMove);
      editorDom?.removeEventListener('mouseup', handleMouseUp);
    };
  }, [canvas]);

  /**......省略部分代码 */
};

缩放功能

如果没有使用拖拽功能的话,使用库提供的 API ,这部分代码就够了。

tsx 复制代码
const App: React.FC = () => {
  /**......省略部分代码 */
  
  useEffect(() => {
    const editorDom = svgcanvasRef.current;
    let zoom = 1;

    function handleWheel(e: WheelEvent) {
      if (canvas && editorDom) {
        e.preventDefault();
        canvas.clearSelection();
        // 缩放的比例
        const zoomStep = 0.1;
        const delta = e.deltaY > 0 ? -zoomStep : zoomStep;
        const newZoom = zoom + delta;
        if (newZoom > 0.3 && newZoom < 3) {
          canvas.setZoom(newZoom);
          updateCanvas(canvas, editorDom);
          // 重新绘制网格背景
          drawGridBg(canvas);
          zoom = newZoom;
        }
      }
    }
    editorDom?.addEventListener('wheel', handleWheel);
    return () => {
      editorDom?.removeEventListener('wheel', handleWheel);
    };
  }, [canvas]);
  
  /**......省略部分代码 */ 
};
居中缩放

但如果进行拖拽了,会发现缩放时不是在当前可视区的中心缩放。

因为我们的拖拽是通过 viewBox,所以还需要增加代码,修正下这部分问题。

tsx 复制代码
const App: React.FC = () => {
  /**......省略部分代码 */
  
  useEffect(() => {
    const editorDom = svgcanvasRef.current;
    let zoom = 1;

    function handleWheel(e: WheelEvent) {
      if (canvas && editorDom) {
        e.preventDefault();
        canvas.clearSelection();
        // 缩放的比例
        const zoomStep = 0.1;
        const delta = e.deltaY > 0 ? -zoomStep : zoomStep;
        const newZoom = zoom + delta;
        if (newZoom > 0.3 && newZoom < 3) {
          canvas.setZoom(newZoom);
          updateCanvas(canvas, editorDom);
          drawGridBg(canvas);
          
          /** 这部分是新增的代码*/
          const scale = newZoom / zoom;

          const svg = canvas?.getSvgRoot(),
            width = svg?.getAttribute('width'),
            height = svg?.getAttribute('height'),
            [translateX, translateY] = svg
              .getAttribute('viewBox')
              ?.split(' ')
              .map(Number) ?? [0, 0, 0, 0];

          canvas
            .getSvgRoot()
            .setAttribute(
              'viewBox',
              `${translateX * scale} ${translateY * scale} ${width} ${height}`,
            );
          /**------------------------------------*/

          zoom = newZoom;
        }
      }
    }
    editorDom?.addEventListener('wheel', handleWheel);
    return () => {
      editorDom?.removeEventListener('wheel', handleWheel);
    };
  }, [canvas]);
  
  /**......省略部分代码 */ 
};
鼠标为中心缩放

库中提供的 setZoom 方法是以 idsvgroot 的 svg 的中心点为缩放中心。

所以,要以缩放时要以鼠标为中心的话,我们需要进行修正。

tsx 复制代码
  useEffect(() => {
    const editorDom = svgcanvasRef.current;
    let zoom = 1;

    function handleWheel(e: WheelEvent) {
      if (canvas && editorDom) {
        e.preventDefault();
        canvas.clearSelection();
        // 缩放的比例
        const zoomStep = 0.1;
        const delta = e.deltaY > 0 ? -zoomStep : zoomStep;
        const newZoom = zoom + delta;
        if (newZoom > 0.3 && newZoom < 3) {
          canvas.setZoom(newZoom);
          updateCanvas(canvas, editorDom);
          drawGridBg(canvas);
          
          /** 这部分是新增的代码*/
          const svg = canvas?.getSvgRoot(),
            width = svg?.getAttribute('width'),
            height = svg?.getAttribute('height'),
            [translateX, translateY] = svg
              .getAttribute('viewBox')
              ?.split(' ')
              .map(Number) ?? [0, 0, 0, 0];

          const rect = svgcanvasRef.current!.getBoundingClientRect();
          // svg 中心点的距离
          let offsetCenterX = e.clientX - rect.left - rect.width / 2,
            offsetCenterY = e.clientY - rect.top - rect.height / 2;

          // 缩放中心点的距离
          const offsetSvgCenterX = (offsetCenterX + translateX) / zoom,
            offsetSvgCenterY = (offsetCenterY + translateY) / zoom;

          // 缩放的偏移值
          const offsetX = offsetSvgCenterX * delta,
            offsetY = offsetSvgCenterY * delta;

          canvas
            .getSvgRoot()
            .setAttribute(
              'viewBox',
              `${translateX + offsetX} ${
                translateY + offsetY
              } ${width} ${height}`,
            );
          /**------------------------------------*/
          zoom = newZoom;
        }
      }
    }
    editorDom?.addEventListener('wheel', handleWheel);
    return () => {
      editorDom?.removeEventListener('wheel', handleWheel);
    };
  }, [canvas]);

键盘移动元素

tsx 复制代码
const App: React.FC = () => { 
  /**......省略部分代码 */

 useEffect(() => {
    const uDLRMove = (e: KeyboardEvent) => {
      const moveStep = 1,
        bigMoveStep = 10;

      const selectedElements = canvas?.getSelectedElements() || [];
      if (selectedElements.length === 0) return;

      let dx = 0,
        dy = 0;
      const step = e.shiftKey ? bigMoveStep : moveStep;

      switch (e.key) {
        case 'ArrowUp':
          dy = -step;
          break;
        case 'ArrowDown':
          dy = step;
          break;
        case 'ArrowLeft':
          dx = -step;
          break;
        case 'ArrowRight':
          dx = step;
          break;
        default:
          return;
      }
      e.preventDefault();
      canvas?.moveSelectedElements(dx, dy);
    };

    document.addEventListener('keydown', uDLRMove);
    return () => document.removeEventListener('keydown', uDLRMove);
  }, []);
  
  /**......省略部分代码 */ 
};

按住 ctrl 键框选多选

库已实现按住 Shift + 点击元素进行多选(或取消选中)的功能,但尚未实现按住 Ctrl + 框选多选。

tsx 复制代码
const App: React.FC = () => {
  /**......省略部分代码 */
  
  useEffect(() => {
    const editorDom = svgcanvasRef.current;

    if (canvas && editorDom) {
      editorDom.removeEventListener('mousedown', canvas?.mouseDownEvent);
      editorDom.removeEventListener('mousemove', canvas?.mouseMoveEvent);
      
      // 储存之前选中的元素
      let agoSelectedElements: SVGElement[] = [];

      function mousedown(e: MouseEvent) {
        if (e.button === 1) return;

        if (e.ctrlKey || e.metaKey) {
          agoSelectedElements = canvas?.getSelectedElements() ?? [];
        } else {
          agoSelectedElements = [];
        }
        canvas?.mouseDownEvent(e);
        if (agoSelectedElements.length > 0) {
          canvas?.addToSelection(agoSelectedElements);
        }
      }

      function mousemove(e: MouseEvent) {
        if (!canvas?.started || e.button === 1) return;

        canvas?.mouseMoveEvent(e);
        if (agoSelectedElements.length > 0) {
          canvas.addToSelection(agoSelectedElements);
        }
      }
      editorDom.addEventListener('mousedown', mousedown);
      editorDom.addEventListener('mousemove', mousemove);
      
      return () => {
        editorDom.removeEventListener('mousedown', canvas.mouseDownEvent);
        editorDom.removeEventListener('mousemove', canvas.mouseMoveEvent);
      };
    }
  }, [canvas]);
  
  /**......省略部分代码 */ 
};

此处是对 mousedownmousemove 通过重写监听,进行了功能扩展。

键盘操作:复制、粘贴、删除

在执行粘贴操作时,若画布存在缩放或平移,其位置也需要进行修正。

tsx 复制代码
const App: React.FC = () => {
  /**......省略部分代码 */
  
  // 记录鼠标在屏幕上的位置
  const clientXYRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });

  useEffect(() => {
    function handleMouseMove(e: MouseEvent) {
      clientXYRef.current = { x: e.clientX, y: e.clientY };
    }
    svgcanvasRef.current?.addEventListener('mousemove', handleMouseMove);
    return () => {
      svgcanvasRef.current?.removeEventListener('mousemove', handleMouseMove);
    };
  }, []);
  useEffect(() => {
    function onKeyDown(e: KeyboardEvent) {
      //删除功能
      if (
        (e.key === 'Delete' || e.key === 'Backspace') &&
        (e.target as HTMLElement).tagName !== 'INPUT'
      ) {
        e.preventDefault();
        canvas?.deleteSelectedElements();
      }
      //复制功能 ctrl+c
      if ((e.key === 'c' || e.key === 'C') && (e.ctrlKey || e.metaKey)) {
        e.preventDefault();
        try {
          canvas?.copySelectedElements();
        } catch (error) {}
      }
      //粘贴功能 ctrl+v
      if ((e.key === 'v' || e.key === 'V') && (e.ctrlKey || e.metaKey)) {
        e.preventDefault();
        const rect = svgcanvasRef.current!.getBoundingClientRect();

        const svg = canvas!.getSvgRoot(),
          zoom = canvas?.getZoom() ?? 1,
          [translateX, translateY] = svg
            .getAttribute('viewBox')
            ?.split(' ')
            .map(Number) ?? [0, 0];

        let svgX = (clientXYRef.current.x - rect.left + translateX) / zoom,
          svgY = (clientXYRef.current.y - rect.top + translateY) / zoom;

        // 修正偏移量
        const svgcontent = document.getElementById('svgcontent')!;
        svgX -= Number(svgcontent.getAttribute('x')) / zoom;
        svgY -= Number(svgcontent.getAttribute('y')) / zoom;
        
        canvas?.pasteElements('point', svgX, svgY);
      }
    }
    document.addEventListener('keydown', onKeyDown);
    return () => {
      document.removeEventListener('keydown', onKeyDown);
    };
  }, [canvas]);

  /**......省略部分代码 */ 
};

这里为什么要在复制功能那里增加 try/catch 错误捕获呢?

因为在 copySelectedElements 源码内执行了 document.getElementById('se-cmenu_canvas'),我们这里没有添加这个 div,所以进行报错的拦截。

se-cmenu_canvas 元素主要用在右键菜单功能,当有复制内容被暂存时,该 div 会被赋予一个属性,以便后续判断是否已存在复制内容。因此,你有右键菜单功能,记得在 div 对应位置增加 id=&#34;se-cmenu_canvas&#34; 属性。

水平翻转,垂直翻转

翻转功能的核心是 matrix,通过矩阵来实现的。

简单说下矩阵运算中会用到的 2 个 API:

  • multiplySelf:将当前矩阵右乘 另一个矩阵:当前矩阵 = 当前矩阵 × 参数矩阵。累积多个变换,按"自然"顺序(如先缩放,再旋转,后平移)。
  • preMultiplySelf:将当前矩阵左乘 另一个矩阵:当前矩阵 = 参数矩阵 × 当前矩阵。累积多个变换,按与 multiplySelf相反的顺序。
tsx 复制代码
const App: React.FC = () => {
  /**......省略部分代码 */

 // 解析transform属性,提取现有的变换矩阵
  function parseTransformToMatrix(element: SVGElement): DOMMatrix {
    const transform = element.getAttribute('transform') || '';
    // 提取矩阵参数
    const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
    if (!matrixMatch) {
      return new DOMMatrix();
    }
    const matrixValues = matrixMatch[1].split(',').map(Number);
    return new DOMMatrix(matrixValues);
  }

  function overturn(type: 'horizontal' | 'vertical') {
    const selectedElements = canvas?.getSelectedElements();
    if (selectedElements?.length === 0) return;

    selectedElements?.forEach((element) => {
      const visualBbox = canvas?.getBBox(element);
      if (!visualBbox) return;

      // 获取位置的中心点
      const centerX = visualBbox.x + visualBbox.width / 2;
      const centerY = visualBbox.y + visualBbox.height / 2;

      // 创建一个新的 DOM 变换矩阵
      const flipMatrix = new DOMMatrix();
      // 将元素平移,使其中点与原点重合
      flipMatrix.translateSelf(centerX, centerY);
      //进行翻转
      switch (type) {
        case 'horizontal':
          flipMatrix.scaleSelf(-1, 1);
          break;
        case 'vertical':
          flipMatrix.scaleSelf(1, -1);
          break;
      }
      // 将元素平移回原位置
      flipMatrix.translateSelf(-centerX, -centerY);

      // 获取现有的变换矩阵
      const existingMatrix = parseTransformToMatrix(element);
      // 合并现有变换
      flipMatrix.preMultiplySelf(existingMatrix);

      const matrixString = `matrix(${flipMatrix.a}, ${flipMatrix.b}, ${flipMatrix.c}, ${flipMatrix.d}, ${flipMatrix.e}, ${flipMatrix.f})`;
      element.setAttribute('transform', matrixString);
    });
  }
  
  /**......省略部分代码 */ 
};

自定义图形

有时候基础图形往往无法满足所有需求,此时便需要实现自定义图形的能力。

存储

保存的逻辑很简单,把 svg 转成 string 进行存储。

tsx 复制代码
function addCustom() {
    const selectedElements = canvas?.getSelectedElements();
    if (selectedElements && selectedElements?.length > 0) {
      const tempSvg = document.createElementNS(
        'http://www.w3.org/2000/svg',
        'svg',
      );

      selectedElements.forEach((elem) => {
        tempSvg.appendChild(elem);
      });
      state.canvas?.svgToString(tempSvg, 0);
    }
  }

为什么svgcanvas 提供了 svgToString 方法,比直接使用 XMLSerializer 优势在哪呢?

XMLSerializer 只是简单地将 DOM 树转换为字符串。而 svgToString 是对 SVG 序列化过程的全面控制:优化输出大小、增强可读性以及支持特殊功能(如图像嵌入),使得生成的 SVG 代码更加精简,输出的文件质量更高。

展示

这里是使用了 React 的 dangerouslySetInnerHTML来实现元素的渲染的。

tsx 复制代码
const App: React.FC = () => {
  /**......省略部分代码 */
  
const svg1 = `


 
 
`;

  // 获取边界框
  function getSVGCoordinateRange(svgString: string): {
    minX: number;
    maxX: number;
    minY: number;
    maxY: number;
    svgElement: SVGSVGElement;
  } {
    const parser = new DOMParser();
    const doc = parser.parseFromString(svgString, 'image/svg+xml');
    const svgElement = doc.documentElement as unknown as SVGSVGElement;

    let minX = Infinity,
      minY = Infinity,
      maxX = -Infinity,
      maxY = -Infinity;

    const updateBounds = (x: number, y: number): void => {
      minX = Math.min(minX, x);
      minY = Math.min(minY, y);
      maxX = Math.max(maxX, x);
      maxY = Math.max(maxY, y);
    };

    const traverse = (element: Element): void => {
      const tagName = element.tagName.toLowerCase();

      switch (tagName) {
        case 'polyline': {
          const points =
            element
              .getAttribute('points')
              ?.split(/[\s,]+/)
              .map(Number) || [];
          for (let i = 0; i < points.length; i += 2) {
            updateBounds(points[i], points[i + 1]);
          }
          break;
        }
        case 'rect': {
          const x = parseFloat(element.getAttribute('x') || '0');
          const y = parseFloat(element.getAttribute('y') || '0');
          const width = parseFloat(element.getAttribute('width') || '0');
          const height = parseFloat(element.getAttribute('height') || '0');
          // 处理矩形四角
          updateBounds(x, y);
          updateBounds(x + width, y);
          updateBounds(x, y + height);
          updateBounds(x + width, y + height);
          break;
        }
        case 'text':
        case 'image':
          updateBounds(
            parseFloat(element.getAttribute('x') || '0'),
            parseFloat(element.getAttribute('y') || '0'),
          );
          break;
        /** ......省略其他元素逻辑  */
      }
      Array.from(element.children).forEach((child) => traverse(child));
    };

    traverse(svgElement);

    return {
      minX: minX === Infinity ? 0 : minX,
      maxX: maxX === -Infinity ? 0 : maxX,
      minY: minY === Infinity ? 0 : minY,
      maxY: maxY === -Infinity ? 0 : maxY,
      svgElement,
    };
  }
  
  function templateSvg(svgString: string) {
    const { minX, minY, maxX, maxY, svgElement } =
      getSVGCoordinateRange(svgString);
      
    svgElement.setAttribute(
      'viewBox',
      `${minX} ${minY} ${maxX - minX} ${maxY - minY}`,
    );
    return { __html: svgElement.outerHTML };
  }
  
  return (
    <div> {
        const svgString = e.currentTarget!.innerHTML;
        e.dataTransfer.setData('text/plain', svgString);
      }}
      dangerouslySetInnerHTML={templateSvg(svg1)}
    />
  );
  
  /**......省略部分代码 */ 
};
拖拽放入

生成元素时,必须为其分配唯一的 id。如果出现重复,将导致元素缺失。

若进行过拖拽和缩放,自然也需要进行修正。

tsx 复制代码
const App: React.FC = () => {
  /**......省略部分代码 */
  
function uuid() {
  const s: Array = [];
  const hexDigits = '0123456789abcdef';
  for (let i = 0; i < 36; i++) {
    s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1);
  }
  s[14] = '4'; // bits 12-15 of the time_hi_and_version field to 0010
  s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1); // bits 6-7 of the clock_seq_hi_and_reserved to 01
  s[8] = s[13] = s[18] = s[23] = '-';

  const uuid = s.join('');
  return uuid;
}

  useEffect(() => {
    // 监听拖放事件
    const dropHandler = (e: DragEvent) => {
      e.preventDefault();

      let data = e.dataTransfer?.getData('text/plain');
      if (data && canvas) {
        const rect = svgcanvasRef.current!.getBoundingClientRect();

        // 转换为 SVG 坐标(考虑平移和缩放)
        const svg = canvas.getSvgRoot(),
          zoom = canvas.getZoom() ?? 1,
          [translateX, translateY] = svg
            .getAttribute('viewBox')
            ?.split(' ')
            .map(Number) ?? [0, 0];
          
        let svgX = (e.clientX - rect.left + translateX) / zoom,
          svgY = (e.clientY - rect.top + translateY) / zoom;
        // 修正偏移量
        const svgcontent = document.getElementById('svgcontent')!;
        svgX -= Number(svgcontent.getAttribute('x')) / zoom;
        svgY -= Number(svgcontent.getAttribute('y')) / zoom;

        if (data) {
          const { minX, minY, maxX, maxY, svgElement } =
            getSVGCoordinateRange(data);

          const offsetX = svgX - (minX + (maxX - minX) / 2),
            offsetY = svgY - (minY + (maxY - minY) / 2);

          Array.from(svgElement.children).forEach((ele) => {
            switch (ele.tagName) {
              case 'polyline':
                {
                  const points = ele.getAttribute('points');
                  const amendPoints = points!
                    .split(' ')
                    .map((item) => {
                      const [x, y] = item.split(',').map(Number);
                      return `${x + offsetX},${y + offsetY}`;
                    })
                    .join(' ');

                  canvas?.createSVGElement({
                    element: 'polyline',
                    attr: {
                      points: amendPoints,
                      id: 'polyline-' + uuid(),
                      stroke: ele.getAttribute('stroke'),
                      'stroke-width': ele.getAttribute('stroke-width'),
                    },
                  });
                }
                break;
              case 'rect':
                {
                  canvas?.createSVGElement({
                    element: 'rect',
                    attr: {
                      x: Number(ele.getAttribute('x')) + offsetX,
                      y: Number(ele.getAttribute('y')) + offsetY,
                      width: ele.getAttribute('width'),
                      height: ele.getAttribute('height'),
                      stroke: ele.getAttribute('stroke'),
                      fill: ele.getAttribute('fill'),
                      'stroke-width': ele.getAttribute('stroke-width'),
                      id: 'rect-' + uuid(),
                    },
                  });
                }
                break;
              case 'text': {
                const text = canvas?.createSVGElement({
                  element: 'text',
                  attr: {
                    x: Number(ele.getAttribute('x')) + offsetX,
                    y: Number(ele.getAttribute('y')) + offsetY,
                    'font-size': ele.getAttribute('font-size'),
                    fill: ele.getAttribute('fill'),
                    id: 'text-' + uuid(),
                  },
                });
                text.textContent = ele.textContent;
                break;
              }
              case 'image':
                canvas?.createSVGElement({
                  element: 'image',
                  attr: {
                    x: Number(ele.getAttribute('x')) + offsetX,
                    y: Number(ele.getAttribute('y')) + offsetY,
                    width: ele.getAttribute('width'),
                    height: ele.getAttribute('height'),
                    href: ele.getAttribute('href'),
                    id: 'image-' + uuid(),
                  },
                });
                break;
              /** ......省略其他元素逻辑  */
            }
          });
        }
      }
    };

    function preventDefault(e: DragEvent) {
      e.preventDefault();
    }
    svgcanvasRef.current?.addEventListener('dragover', preventDefault);
    svgcanvasRef.current?.addEventListener('drop', dropHandler);

    return () => {
      svgcanvasRef.current?.removeEventListener('drop', dropHandler);
      svgcanvasRef.current?.removeEventListener('dragover', preventDefault);
    };
  }, [canvas]);
  
  /**......省略部分代码 */ 
};

撤销/重做功能

介绍

这部分功能的主要代码是在源码里的 history.js 文件中,且采用了经典的命令模式的设计模式。

支持多种类型的操作命令,都继承自基础的 Command 类:

  • MoveElementCommand:元素移动操作
  • InsertElementCommand:元素插入操作
  • RemoveElementCommand:元素删除操作
  • ChangeElementCommand:元素属性修改操作
  • BatchCommand:批量操作命令组合

UndoManager:负责维护命令历史堆栈,提供撤销与重做功能的核心控制器。

示例

下面以拖拽自定义图形为示例

tsx 复制代码
const App: React.FC = () => {
  /**......省略部分代码 */
  
// 创建批处理命令
const batchCmd = new canvas.history.BatchCommand('添加多个图形');

Array.from(svgElement.children).forEach((ele) => {
  let svgElementNode: SVGElement | null = null;
  
  switch (ele.tagName) {
    case 'polyline':
      {
        const points = ele.getAttribute('points');
        const amendPoints = points!
          .split(' ')
          .map((item) => {
            const [x, y] = item.split(',').map(Number);
            return `${x + offsetX},${y + offsetY}`;
          })
          .join(' ');

        svgElementNode = canvas?.createSVGElement({
          element: 'polyline',
          attr: {
            points: amendPoints,
            id: 'polyline-' + uuid(),
            stroke: ele.getAttribute('stroke'),
            'stroke-width': ele.getAttribute('stroke-width'),
          },
        });
      }
      break;
    /**......省略部分代码 */
  }
  if (svgElementNode) {
    // 添加子命令
    batchCmd.addSubCommand(
      new canvas.history.InsertElementCommand(svgElementNode, `自定义图形元素`),
    );
  }
});

// 将批处理命令添加到历史记录
canvas.addCommandToHistory(batchCmd);

  /**......省略部分代码 */ 
};

BatchCommand的作用是将多个一系列操作视为一个整体,而不是零散的状态变更,使得整个拖拽过程被视为一个完整的步骤。

最后调用 addCommandToHistory 方法,将对应的 BatchCommand 添加到 UndoManager 的历史栈中。

这一步是关键,因为只有被记录到历史栈中的命令,才能在 撤销/重做 中处理。

tsx 复制代码
    canvas?.undoMgr.undo()}>
     后退
   
    canvas?.undoMgr.redo()}>
     前进
   

执行 ChangeElementCommand 命令时,有个顺序细节需要注意,必须是先完成对元素属性修改后,再执行命令的提交。

tsx 复制代码
   // 记录修改前的值
    const oldValue = element.getAttribute(attributeName);
    // 修改属性值
    element.setAttribute(attributeName, newValue);
    // 执行实际的属性修改
    element.setAttribute(attributeName, newValue);
    // 创建 ChangeElementCommand 并添加到历史记录
    canvas.addCommandToHistory(new ChangeElementCommand(element, changes));

Q&A

关于文中的修正偏移

为什么上面会有修正偏移的操作呢?

由于所有图形元素都绘制在 svgroot 下的子容器 svgcontent 内,此操作正是为了校正这个实际容器本身的偏移。

所以在粘贴和拖拽自定义元素放入时,需要进行修正。

关于 matrix 函数

transform=&#34;matrix(a, b, c, d, e, f)&#34;是 SVG 变换的底层数学表示。接受六个参数,它们共同定义了一个 3x3 的变换矩阵(为了便于仿射变换,实际使用齐次坐标):

css 复制代码
[ a c e ]
[ b d f ]
[ 0 0 1 ]

通常我们关注左上角的 2x2 线性变换矩阵 (a, b, c, d) 和右侧列的平移向量 (e, f) 来描述元素坐标系的所有基础变换的底层数学表示,功能非常强大但也相对抽象。

对于简单变换,优先使用 translate, scale, rotate, skewX, skewY 等函数以提高代码可读性。

  1. 平移 (Translation)
  • e: 沿 X 轴的平移量。
  • f: 沿 Y 轴的平移量。

示例:

ts 复制代码
matrix(1, 0, 0, 1, 100, 50) === translate(100px, 50px)
  1. 缩放 (Scaling)
  • a: X 轴方向的缩放因子。
  • d: Y 轴方向的缩放因子。

示例:

ts 复制代码
matrix(2, 0, 0, 3, 0, 0) === scale(2, 3)

matrix(0.5, 0, 0, 0.5, 0, 0) === scale(0.5)
  1. 旋转 (Rotation)

旋转需要 a, b, c, d 共同作用。旋转角度 θ(通常以弧度表示) 时:

  • a = cosθ
  • b = sinθ
  • c = -sinθ
  • d = cosθ。

示例: 旋转 30 度 (≈ 0.5236 弧度),cos(30°) ≈ 0.866, sin(30°) = 0.5。

ts 复制代码
matrix(0.866, 0.5, -0.5, 0.866, 0, 0) === rotate(30deg)
  1. 倾斜/错切 (Skewing/Shearing)
  • 沿 X 轴倾斜 (SkewX):由 c控制。c = tan(α),其中 α是倾斜角度。
  • 沿 Y 轴倾斜 (SkewY):由 b控制。b = tan(β),其中 β是倾斜角度。

示例: tan(26.565deg) ≈ 0.5。

ts 复制代码
matrix(1, 0, 0.5, 1, 0, 0) === skewX(26.565deg) 

matrix(1, 0.5, 0, 1, 0, 0) === skewY(26.565deg)
  1. 组合变换

matrix的真正威力在于它能用一个函数表示 ​​任意顺序、任意组合​​ 的平移、旋转、缩放、倾斜。这是通过将各个基础变换的矩阵 ​​相乘​​ 得到的。

顺序很重要: 矩阵乘法不满足交换律。旋转 * 平移和 平移 * 旋转的结果通常是不同的。

  • 旋转 * 平移:先围绕原点旋转,然后将旋转后的图形平移。

  • 平移 * 旋转:先将图形平移到新位置,然后围绕当前坐标系的原点(即平移后的点)旋转。

如何组合: 如果你有一系列基础变换,要得到等效的 matrix,你需要将它们对应的矩阵按 ​​从右到左​​ 的顺序相乘(在 SVG 中,transform 列表的书写顺序也是从右到左应用的)。最终得到的 6 个参数就是 matrix(a, b, c, d, e, f)的参数。

关于 createSVGTransform 函数

在阅读源码时,你会看到在对元素进行变换(例如移动、旋转、缩放)操作的函数中, createSVGTransform 被频繁的调用。

createSVGTransform 是 SVG DOM 提供的方法,用于创建 SVG 变换对象(SVGTransform),该对象可描述平移、缩放、旋转等几何变换。

核心作用是:生成临时变换规则,用于动态调整元素在拖拽缩放过程中的位置和尺寸,确保变换围绕正确的锚点(如边角、中心点)进行。

为什么需要通过 createSVGTransform 创建?

  • 临时变换隔离: 直接修改元素的 width/height 可能与现有变换(如旋转)冲突,而通过 SVGTransform 可在变换列表中插入临时规则,避免破坏原始属性。
  • 动态调整支持: 拖拽过程中需要实时更新变换参数(如缩放比例),SVGTransform 对象允许随时修改 setTranslate/setScale 的参数并重新应用。
  • 变换组合能力: 多个 SVGTransform 可按顺序组合(如先平移再缩放),实现复杂的几何调整逻辑(这也是为什么需要创建三个对象的核心原因)。

createSVGTransform 的作用是: 生成可配置的变换规则,通过临时修改元素的变换列表,实现拖拽缩放过程中的精准位置控制。这比直接修改元素属性(如 width/height)更灵活,尤其适用于已有旋转、倾斜等复杂变换的元素。

相关推荐
小白冲鸭11 小时前
苍穹外卖-前端环境搭建-nginx双击后网页打不开
运维·前端·nginx
wulijuan88866611 小时前
Web Worker
前端·javascript
深念Y11 小时前
仿B站项目 前端 3 首页 整体结构
前端·ai·vue·agent·bilibili·首页
IT_陈寒11 小时前
React 18实战:这5个新特性让我的开发效率提升了40%
前端·人工智能·后端
深念Y12 小时前
仿B站项目 前端 5 首页 标签栏
前端·vue·ai编程·bilibili·标签栏·trae·滚动栏
老朋友此林12 小时前
React Hook原理速通笔记1(useEffect 原理、使用踩坑、渲染周期、依赖项)
javascript·笔记·react.js
克里斯蒂亚诺更新12 小时前
vue3使用pinia替代vuex举例
前端·javascript·vue.js
Benny的老巢12 小时前
用 Playwright 启动指定 Chrome 账号的本地浏览器, 复用原账号下的cookie信息
前端·chrome
冰暮流星12 小时前
javascript赋值运算符
开发语言·javascript·ecmascript
2501_9418053112 小时前
从微服务网关到统一安全治理的互联网工程语法实践与多语言探索
前端·python·算法