跳到主要内容

基于 SVG 无限画板的拖拽和缩放实现

· 阅读需 7 分钟
熊滔

SVG 可以看作是一个无限大小的画布,而 viewBox 属性就是决定将画布中的哪一部分展示出来,viewBox 指定的这个范围称之为视口。viewBox 通过四个属性来定义视口:

  • 左上角坐标 (minX,minY)(\text{minX}, \text{minY})
  • 视口大小 (width,height)(\text{width}, \text{height})

这里使用的坐标均是 SVG 中的坐标系统,SVG 坐标系统中的一个单位长度与一像素并不相等,二者之间的换算关系取决于视口的大小与 SVG 元素的 CSS 大小

<svg width="200" height="200" viewBox="0 0 100 100"></svg>

如上 SVG 的 CSS 大小为 200200 像素,而视口的大小为 100100 SVG 单位,也就是说 100100 SVG 单位等价于 200200 像素,所以一个单位等两个像素大小。

信息

实际上 xx 轴和 yy 轴方向可以有不同的换算比例,为了简单,只考虑二者的换算比例相同。

通过改变视口的左上角坐标即可实现画布拖拽,而改变 SVG 单位与像素单位的比例即可实现缩放。

拖拽

考虑点 (x,y)(x, y),要实现在视口上移动了 offsetX\text{offsetX} 像素和 offsetY\text{offsetY} 像素,求 newMinX\text{newMinX}newMinY\text{newMinY}

考虑简单的情况,没有缩放,那么一像素等与一个 SVG 单位,那么有

x2x1=(xnewMinX)(xminX)=offsetXy2y1=(ynewMinY)(yminY)=offsetY\begin{aligned} x_2 - x_1 &= (x - \text{newMinX}) - (x - \text{minX}) &= \text{offsetX} \\ y_2 - y_1 &= (y - \text{newMinY}) - (y - \text{minY}) &= \text{offsetY} \end{aligned}

得到

newMinX=minXoffsetXnewMinY=minYoffsetY\begin{aligned} \text{newMinX} &= \text{minX} - \text{offsetX} \\ \text{newMinY} &= \text{minY} - \text{offsetY} \end{aligned}

现在考虑缩放的情况,此时画布的缩放比例为 zoom\text{zoom},也就是说一个 SVG 单位等于 zoom\text{zoom} 像素,换句话说,一个像素大小为 1zoom\dfrac{1}{\text{zoom}} 个 SVG 单位,那么

x2x1=(xnewMinX)(xminX)=offsetXzoomy2y1=(ynewMinY)(yminY)=offsetYzoom\begin{aligned} x_2 - x_1 &= (x - \text{newMinX}) - (x - \text{minX}) &= \cfrac{\text{offsetX}}{\text{zoom}} \\ y_2 - y_1 &= (y - \text{newMinY}) - (y - \text{minY}) &= \cfrac{\text{offsetY}}{\text{zoom}} \end{aligned}

得到

newMinX=minXoffsetXzoomnewMinY=minYoffsetYzoom\begin{aligned} \text{newMinX} &= \text{minX} - \cfrac{\text{offsetX}}{\text{zoom}} \\ \text{newMinY} &= \text{minY} - \cfrac{\text{offsetY}}{\text{zoom}} \end{aligned}

缩放

要实现缩放,只需要改变视口大小与 CSS 大小的比例关系即可。设 SVG 的 CSS 宽高为(width,height)(\text{width}, \text{height}),要实现 zoom\text{zoom} 倍的缩放,那么只要将视口的宽度和高度设置为 (widthzoom,heightzoom)(\dfrac{\text{width}}{\text{zoom}}, \dfrac{\text{height}}{\text{zoom}})即可。

如果我们希望在进行缩放时,某个点相对于视口保持不变呢,考虑一个场景,在滚动滚动时我们缩放画布,但是希望此时鼠标所在的点相对于视口是不变的

假设以点 (x,y)(x, y) 进行缩放,在缩放完成后,点 (x,y)(x, y) 相对于画布应保持不变,即该点始终距离画布左侧为 offsetX\text{offsetX} 像素,距离画布顶部 offsetY\text{offsetY} 像素在缩放前后都是一样的。

设缩放前的缩放比例为 zoom\text{zoom},缩放后的缩放比例为 newZoom\text{newZoom},缩放前左上角的坐标为 (minX,minY)(\text{minX}, \text{minY}),求缩放后左上角的坐标 (newMinX,newMinY)(\text{newMinX}, \text{newMinY})。

考虑 xx 方向,可以得到两个等式

(xminX)zoom=offsetX(xnewMinX)newZoom=offsetX\begin{aligned} (x - \text{minX}) \cdot \text{zoom} &= \text{offsetX} \\ (x - \text{newMinX}) \cdot \text{newZoom} &= \text{offsetX} \\ \end{aligned}

解此方程可以得到

newMinX=offsetX(1zoom1newZoom)+minX\text{newMinX} = \text{offsetX} \cdot (\cfrac{1}{\text{zoom}} - \cfrac{1}{\text{newZoom}}) + \text{minX}

同理可以得到

newMinY=offsetY(1zoom1newZoom)+minY\text{newMinY} = \text{offsetY} \cdot (\cfrac{1}{\text{zoom}} - \cfrac{1}{\text{newZoom}}) + \text{minY}

所以为了在缩放的过程中保持点的位置不变,除了需要将视口的大小设置为 (widthnewZoom,heightnewZoom)(\cfrac{\text{width}}{\text{newZoom}}, \cfrac{\text{height}}{\text{newZoom}}),还需要将视口左上角坐标调整为 (offsetX(1zoom1newZoom)+minX,offsetY(1zoom1newZoom)+minY)(\text{offsetX} \cdot (\cfrac{1}{\text{zoom}} - \cfrac{1}{\text{newZoom}}) + \text{minX}, \text{offsetY} \cdot (\cfrac{1}{\text{zoom}} - \cfrac{1}{\text{newZoom}}) + \text{minY})

const { useRef, useEffect } = React;

let zoom = 1;
let minX = 0;
let minY = 0;
let startPosition = null;

const Component = () => {
const containerRef = useRef(null);
const svgRef = useRef(null);

useEffect(() => {
const boardContainer = containerRef.current;
const board = svgRef.current;
if (!boardContainer || !board) return;

const { width, height } = boardContainer.getBoundingClientRect();
board.setAttribute('viewBox', `${minX} ${minY} ${width / zoom} ${height /zoom}`);

const handlePointerDown = (e) => {
startPosition = {
x: e.clientX,
y: e.clientY,
}
}

const handlePointerMove = (e) => {
if (!startPosition || !boardContainer || !board) return;
const { width, height } = boardContainer.getBoundingClientRect();
const endPosition = {
x: e.clientX,
y: e.clientY
}
const offsetX = endPosition.x - startPosition.x;
const offsetY = endPosition.y - startPosition.y;
const newMinX = minX - offsetX / zoom;
const newMinY = minY - offsetY / zoom;
board.setAttribute('viewBox', `${newMinX} ${newMinY} ${width / zoom} ${height /zoom}`);
}

const handlePointerUp = (e) => {
if (!startPosition || !boardContainer || !board) return;
const { width, height } = boardContainer.getBoundingClientRect();
const endPosition = {
x: e.clientX,
y: e.clientY
}
const offsetX = endPosition.x - startPosition.x;
const offsetY = endPosition.y - startPosition.y;
const newMinX = minX - offsetX / zoom;
const newMinY = minY - offsetY / zoom;
board.setAttribute('viewBox', `${newMinX} ${newMinY} ${width / zoom} ${height /zoom}`);
minX = newMinX;
minY = newMinY;
startPosition = null;
}

const handleWheel = (e) => {
if (!boardContainer || !board) return;
e.preventDefault();
const { width, height, x: containerX, y: containerY } = boardContainer.getBoundingClientRect();

const x = e.clientX;
const y = e.clientY;

const offsetX = x - containerX;
const offsetY = y - containerY;
let newZoom;
if (e.deltaY < 0) {
newZoom = Math.min(zoom * 1.1, 10);
} else {
newZoom = Math.max(zoom * 0.9, 0.1);
}
const newMinX = minX + offsetX * (1 / zoom - 1 / newZoom);
const newMinY = minY + offsetY * (1 / zoom - 1 / newZoom);
board.setAttribute('viewBox', `${newMinX} ${newMinY} ${width / newZoom} ${height / newZoom}`);
zoom = newZoom;
minX = newMinX;
minY = newMinY;
}

boardContainer.addEventListener('pointerdown', handlePointerDown);
boardContainer.addEventListener('pointermove', handlePointerMove);
boardContainer.addEventListener('pointerup', handlePointerUp);
boardContainer.addEventListener('wheel', handleWheel);

return () => {
if (boardContainer) {
boardContainer.removeEventListener('pointerdown', handlePointerDown);
boardContainer.removeEventListener('pointermove', handlePointerMove);
boardContainer.removeEventListener('pointerup', handlePointerUp);
boardContainer.removeEventListener('wheel', handleWheel);
}
}
}, [])

return (
<div ref={containerRef} style={{ width: '100%', height: 500, border: '2px dashed #aaa' }}>
<svg ref={svgRef} width="100%" height="100%">
<rect x="100" y="100" width="200" height="100" fill="#e77c8e" fill-opacity="0.5" />
</svg>
</div>
)
}