在 # 用svg实现图形编辑器系列二:精灵的开发和注册 文章中,我们介绍了图形编辑器基础的
移动
、缩放
、旋转
等编辑能力,做到了三个操作代码隔离,并且在旋转后缩放修复了位置偏移问题。
本文会继续丰富以下编辑能力:
- 移动靠近其他精灵时吸附上去,并显示辅助线
- 缩放靠近其他精灵时吸附上去,并显示辅助线
- 画布上显示网格,精灵在画布上拖拽时可以吸附在网格上
Demo体验链接:图形编辑器在线Demo
系列文章汇总
一、矩形之间靠近吸附
1. 原理介绍
我们把画布上的一个个精灵想像成一个个大小位置不同的矩形,当 矩形1 靠近 矩形2 时,计算他们上下左右方向各自的间距当间距小于设置的阈值时,就记录下来显示这些辅助线,并将矩形位置移动到间距最小的一个位置上。
- 矩形中参与吸附计算的位置示意图
- 水平方向上的部分吸附演示
- 吸附效果
- 参与计算的矩形位置线(水平方向)
源矩形 | 目标矩形 |
---|---|
left | left |
left | centerX |
left | right |
centerX | left |
centerX | centerX |
centerX | right |
right | left |
right | centerX |
right | right |
- 垂直方向同理:
源矩形 | 目标矩形 |
---|---|
top | top |
top | centerY |
top | bottom |
centerY | top |
centerY | centerY |
centerY | bottom |
bottom | top |
bottom | centerY |
bottom | bottom |
2. 计算元素之间靠近时的对其辅助线,以及吸附的修正距离
/**
* 计算元素之间靠近时的对其辅助线,以及吸附的修正距离
* @param rect 选中矩形区域
* @param spriteList 未选中的与元素列表
* @param activeSpriteList 选中的元素
* @returns 辅助线数组和吸附定位
*/
export const getAuxiliaryLine = (
adsorbLine: IAdsorbLine,
spriteRect: ISizeCoordinate,
rectList: ISizeCoordinate[],
canvasSize: ISize,
// 四个方向上是否禁止计算吸附线,例如正在拖动右侧,则左侧就不用计算了
disableAdsorbSide: Record<string, boolean>,
adsorbCanvas = true,
) => {
// 正在拖拽中的矩形的各个边信息
const rectLeft = spriteRect.x;
const rectRight = spriteRect.x + spriteRect.width;
const rectTop = spriteRect.y;
const rectBottom = spriteRect.y + spriteRect.height;
const rectCenterX = (rectLeft + rectRight) / 2;
const rectCenterY = (rectTop + rectBottom) / 2;
const dis = adsorbLine.distance || 5;
// 判断接近
const closeTo = (a: number, b: number, d = dis) => Math.abs(a - b) < d;
const rectList = [...rectList];
// 增加一个和舞台同样大小的虚拟元素,用来和舞台对齐
if (adsorbCanvas) {
const canvasBackground: ISizeCoordinate = { x: 0, y: 0, ...canvasSize };
rectList.push(canvasBackground);
}
let dx = Infinity;
let dy = Infinity;
const sourcePosSpaceMap: Record<string, any> = {};
for (const rect of rectList) {
// 矩形的各个边信息
const left = rect.x;
const right = rect.x + rect.width;
const top = rect.y;
const bottom = rect.y + rect.height;
const centerX = (left + right) / 2;
const centerY = (top + bottom) / 2;
// x和y方向各自取开始、中间、结束三个位置,枚举出共18种情况
const array = [
{ pos: 'x', sourcePos: 'left', source: rectLeft, target: left },
{ pos: 'x', sourcePos: 'left', source: rectLeft, target: centerX },
{ pos: 'x', sourcePos: 'left', source: rectLeft, target: right },
{ pos: 'x', sourcePos: 'centerX', source: rectCenterX, target: left },
{ pos: 'x', sourcePos: 'centerX', source: rectCenterX, target: centerX },
{ pos: 'x', sourcePos: 'centerX', source: rectCenterX, target: right },
{ pos: 'x', sourcePos: 'right', source: rectRight, target: left },
{ pos: 'x', sourcePos: 'right', source: rectRight, target: centerX },
{ pos: 'x', sourcePos: 'right', source: rectRight, target: right },
{ pos: 'y', sourcePos: 'top', source: rectTop, target: top },
{ pos: 'y', sourcePos: 'top', source: rectTop, target: centerY },
{ pos: 'y', sourcePos: 'top', source: rectTop, target: bottom },
{ pos: 'y', sourcePos: 'centerY', source: rectCenterY, target: top },
{ pos: 'y', sourcePos: 'centerY', source: rectCenterY, target: centerY },
{ pos: 'y', sourcePos: 'centerY', source: rectCenterY, target: bottom },
{ pos: 'y', sourcePos: 'bottom', source: rectBottom, target: top },
{ pos: 'y', sourcePos: 'bottom', source: rectBottom, target: centerY },
{ pos: 'y', sourcePos: 'bottom', source: rectBottom, target: bottom },
];
const minX = Math.min(left, rectLeft);
const maxX = Math.max(right, rectRight);
const minY = Math.min(top, rectTop);
const maxY = Math.max(bottom, rectBottom);
// 对正在拖拽的矩形来说,每个方向上选出一个最近的辅助线即可
array.forEach((e: any) => {
if (closeTo(e.source, e.target)) {
const space = e.target - e.source;
// 选出距离更小的
if (
!sourcePosSpaceMap[e.sourcePos] ||
Math.abs(sourcePosSpaceMap[e.sourcePos].space) < Math.abs(space)
) {
if (e.pos === 'x') {
dx = space;
} else {
dy = space;
}
sourcePosSpaceMap[e.sourcePos] = {
space,
line: {
x1: e.pos === 'x' ? e.target : minX,
x2: e.pos === 'x' ? e.target : maxX,
y1: e.pos === 'y' ? e.target : minY,
y2: e.pos === 'y' ? e.target : maxY,
},
};
}
}
});
}
return {
lines: Object.values(sourcePosSpaceMap).map(e => e.line),
dx,
dy,
};
};
3. 处理吸附的修正距离作用与矩形上
将计算出来的吸附修正距离应用在矩形的位置和高宽上
注意: 平移和缩放都可以用下面的函数,平移和缩放同时生效时,可以将各自计算出的dx dy修正距离对比,选出绝对值更小的进行计算即可。
// 处理吸附的修正距离作用与矩形上
export const handleAdsorb = ({
// 正在编辑的矩形
rect,
// 吸附计算出来的x和y方向的变更
dx,
dy,
// 移动还是缩放
mode,
// 正在缩放的锚点名
resizePos = '',
// 缩放是否移动到了反向,例如把右侧缩放锚点移动到矩形左侧
reverse = {},
}: {
rect: ISizeCoordinate;
dx: number;
dy: number;
mode: IChangeMode;
resizePos?: string;
reverse?: any;
}) => {
const spriteRect = { ...rect };
const {
leftReverse = false,
rightReverse = false,
bottomReverse = false,
topReverse = false,
} = reverse;
if (mode === 'move') {
spriteRect.x += dx;
spriteRect.y += dy;
} else if (mode === 'resize') {
if (resizePos.includes('right')) {
if (rightReverse) {
spriteRect.x += dx;
spriteRect.width -= dx;
} else {
spriteRect.width += dx;
}
}
if (resizePos.includes('left')) {
if (leftReverse) {
spriteRect.width += dx;
} else {
spriteRect.x += dx;
spriteRect.width -= dx;
}
}
if (resizePos.includes('top')) {
if (topReverse) {
spriteRect.height += dy;
} else {
spriteRect.y += dy;
spriteRect.height -= dy;
}
}
if (resizePos.includes('bottom')) {
if (bottomReverse) {
spriteRect.y += dy;
spriteRect.height -= dy;
} else {
spriteRect.height += dy;
}
}
}
return spriteRect;
};
4. 吸附线渲染
export const AuxiliaryLine = ({ lineList = [] }: { lineList: Line[] }) => {
return (
<g>
{/* 辅助线 */}
{lineList.map((line: Line) => (
<line
key={JSON.stringify(line)}
{...line}
stroke={'#0068ee'}
strokeDasharray="4 4"></line>
))}
</g>
);
}
二、网格
网格能力会在很多图形编辑产品里出现,例如流程图、UML图等等,本章节结合如何实现体验较好的网格能力
网格线吸附
除了矩形之间相互靠近时的吸附功能,有时我们也希望按照画布的网格来进行吸附约束
1. 参数解释
我们通过一下几个参数控制吸附的行为,可以设置网格单元格的高宽
,以及在高宽方向上的吸附距离阈值,通过这几个参数就可以针对不同场景下的不同要求进行灵活定制,实现出体验较好的网格功能。
参数 | 含义 |
---|---|
gridCellWidth | 网格单元格的宽度 |
gridCellHeight | 网格单元格的高度 |
adsorbWidth | 水平方向的吸附距离阈值 |
adsorbHeight | 垂直方向的吸附距离阈值 |
2. 网格吸附效果案例
2.1 单元格宽度大于吸附距离
例如单元格为 50 * 50 ,吸附距离均为5,效果如下:
- 此时适用于对宽高细节调整要求较高的情况
2.2 吸附距离大于等于单元格宽度
例如单元格为 50 * 50 ,吸附距离均为50,效果如下:
- 此时适用于对宽高细节调整要求不高,希望严格吸附在网格上
- 也可以将单元格调小一些提升网格的精度来做细节调整
2.3 在严格吸附网格情况下,使拖拽更加平滑
- 增加一个虚线框,显示放手时的位置,矩形跟着鼠标实时位置
3. 计算网格吸附的源码
这里和矩形之间吸附的原理类似,可以把网格线类比理解为矩形的边线。主要区别是可能有需要根据单元格大小进行四舍五入计算的情况。
缩放
时以移动右边为例说明
- 当吸附距离小于宽度的一半时,比较 右边 的 x 和网格的 x 的差,如果小于吸附阈值 并且绝对值小于已经记录的边距,就更新;
- 当吸附距离大于等于宽度的一半时,根据网格宽度进行四舍五入,使边一直落在网格上即可;
移动
时以水平方向为例:
- 同时计算左边、右边与网格的边距,如果小于吸附阈值并且绝对值小于已经记录的边距,就更新;
// 网格吸附
export const handleGridAdsorb = (
rect: IRect,
gridCellWidth: number,
gridCellHeight: number,
adsorbWidth = defaultGridAdsorbWidth,
adsorbHeight = defaultGridAdsorbHeight,
// 移动还是缩放
changeMode: string,
// 缩放时需要计算吸附的边
adsorbSides: Record<string, boolean> = {},
) => {
const { x, y, width, height } = rect;
const spriteRect = { ...rect };
// 组件左或下方向被激活
let leftActivated = true;
let topActivated = true;
if (changeMode === 'resize') {
// resize的场景下,正在操作哪个方向的锚点就激活哪个方向
leftActivated = adsorbSides.left;
topActivated = adsorbSides.top;
} else {
// move的场景下,距离那哪边近就激活哪个方向
leftActivated =
minDisWithGrid(x, gridCellWidth) <
minDisWithGrid(x + width, gridCellWidth);
topActivated =
minDisWithGrid(y, gridCellHeight) <
minDisWithGrid(y + height, gridCellHeight);
}
if (leftActivated) {
spriteRect.x = roundingUnitize(x, gridCellWidth, adsorbWidth);
} else {
spriteRect.x =
roundingUnitize(x + width, gridCellWidth, adsorbWidth) - width;
}
if (topActivated) {
spriteRect.y = roundingUnitize(y, gridCellHeight, adsorbHeight);
} else {
spriteRect.y =
roundingUnitize(y + height, gridCellHeight, adsorbHeight) - height;
}
return {
dx: spriteRect.x - rect.x || Infinity,
dy: spriteRect.y - rect.y || Infinity,
};
};
// 距离网格的最小距离
export const minDisWithGrid = (n: number, unit: number) =>
Math.min(n % unit, unit - (n % unit));
// 四舍五入网格取整
export const roundingUnitize = (n: number, unit: number, adsorbDis = 4) => {
// 余数
const remainder = Math.abs(n % unit);
const closeToStart = remainder <= adsorbDis;
const closeToEnd = Math.abs(unit - (n % unit)) <= adsorbDis;
if (closeToStart || closeToEnd) {
const m = Math.floor(n / unit); // 是单位长度的几倍
if (remainder <= unit / 2) {
// 靠近单元格开始位置
return m * unit;
} else {
// 靠近单元格结束位置
return (m + 1) * unit;
}
}
// 都不靠近,直接返回原本位置
return n;
};
网格线渲染
interface IProps {
width: number;
height: number;
spacing?: number;
}
// 计算网格线
const getLines = (width: number, height: number, spacing: number) => {
const getLineList = (size: number, spacing: number) => {
const length = Math.floor(size / spacing);
return new Array(length)
.fill(1)
.map((_: any, index: number) => [0, size, index * spacing]);
};
const xLines = getLineList(height, spacing).map((arr: number[]) => ({
x1: 0,
y1: arr[2],
x2: width,
y2: arr[2],
}));
const yLines = getLineList(width, spacing).map((arr: number[]) => ({
x1: arr[2],
y1: 0,
x2: arr[2],
y2: height,
}));
return { xLines, yLines };
};
// 把网格线转化成path路径
const getPath = (line: Line) => `M${line.x1},${line.y1} L${line.x2},${line.y2}`;
export default (props: IProps) => {
const {
width = 0,
height = 0,
spacing = 50,
} = props;
const [d, setD] = useState('');
const style = {
stroke: '#f8f8f8',
strokeWidth: 1,
strokeDasharray: 'none',
};
// 计算网格线
useEffect(() => {
const { xLines, yLines } = getLines(width, height, spacing);
const lines = [...xLines, ...yLines];
const path = lines.map((line: Line) => getPath(line)).join(' ');
setD(path);
}, [width, height, spacing]);
return (
<path d={d} {...style} />
);
};
总结
本文介绍了两种吸附功能:矩形之间靠近吸附
和 网格吸附
,可以针对不同的场景下提升用户体验。
接下来我们会继续强化编辑能力,如:
- 锚点功能,如圆角矩形调整圆角大小的锚点、扇形调整扇形角度的锚点等
- 连接线功能:在精灵上定义端口,可以用连接线把精灵彼此相连,当其中一个精灵发生变化时,连接线也会持续变化保持连接。