在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 方法是以 id 为 svgroot 的 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]);
/**......省略部分代码 */
};
此处是对 mousedown 和 mousemove 通过重写监听,进行了功能扩展。
键盘操作:复制、粘贴、删除
在执行粘贴操作时,若画布存在缩放或平移,其位置也需要进行修正。
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="se-cmenu_canvas" 属性。
水平翻转,垂直翻转
翻转功能的核心是 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="matrix(a, b, c, d, e, f)"是 SVG 变换的底层数学表示。接受六个参数,它们共同定义了一个 3x3 的变换矩阵(为了便于仿射变换,实际使用齐次坐标):
css
[ a c e ]
[ b d f ]
[ 0 0 1 ]
通常我们关注左上角的 2x2 线性变换矩阵 (a, b, c, d) 和右侧列的平移向量 (e, f) 来描述元素坐标系的所有基础变换的底层数学表示,功能非常强大但也相对抽象。
对于简单变换,优先使用 translate, scale, rotate, skewX, skewY 等函数以提高代码可读性。
- 平移 (Translation)
- e: 沿 X 轴的平移量。
- f: 沿 Y 轴的平移量。
示例:
ts
matrix(1, 0, 0, 1, 100, 50) === translate(100px, 50px)
- 缩放 (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)
- 旋转 (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)
- 倾斜/错切 (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)
- 组合变换
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)更灵活,尤其适用于已有旋转、倾斜等复杂变换的元素。