theme: juejin
在 # 用svg实现图形编辑器系列二:精灵的开发和注册 文章中,我们实现了图形编辑器的最小demo并进行了重构,渲染能力已经基本完备了,接下来本文将介绍如何实现
移动
、缩放
、旋转
这三种基本的编辑能力。
Demo体验链接:图形编辑器在线Demo
以下是舞台画布的示意图
- 渲染精灵组件
- 编辑能力
- 选中
- 移动
- 缩放
- 旋转
- 以下是实际选框的样式
矩形选框工具
interface IProps {
activeSprite: ISprite;
registerSpriteMetaMap: Record<string, ISpriteMeta>;
stage: IStageApis;
}
interface IState {
auxiliaryLineList: Line[];
ready: boolean;
}
class ActiveSpriteContainer extends React.Component<IProps, IState> {
readonly state: IState = {
auxiliaryLineList: [],
ready: false,
};
componentDidMount() {
document.addEventListener("pointerdown", this.handleMouseDown, false);
this.setState({ ready: true });
}
componentWillUnmount() {
document.removeEventListener("pointerdown", this.handleSelect, false);
}
handleSelect = (e: any) => {
const { stage } = this.props;
const { activeSprite } = stage.store();
const spriteDom = findParentByClass(e.target, "sprite-container");
if (!spriteDom || isInputting()) {
return;
}
const id = spriteDom?.getAttribute("data-sprite-id");
stage.apis.updateActiveSprite(id);
};
// 处理精灵变更
handleSpriteChange = (sprite: ISprite) => {
const { stage } = this.props;
// 这个方法会自动根据id去精灵列表里找到目标精灵并更新
stage.apis.updateSprite(sprite);
};
render() {
const { initPosMap, initMousePos, initSizeMap } = this;
const { stage, activeSprite } = this.props;
const { ready, auxiliaryLineList } = this.state;
let activeRect: ISizeCoordinate = { width: 0, height: 0, x: 0, y: 0 };
const angle = activeSprite?.attrs?.angle || 0;
if (activeSprite) {
const { size, coordinate } = activeSprite.attrs;
activeRect = { ...size, ...coordinate };
}
return (
<>
<g
className="active-sprites-container"
transform={`rotate(${angle || 0}, ${info.x + info.width / 2} ${
info.y + info.height / 2
})`}
>
{/* 边框 */}
<rect
x={info.x}
y={info.y}
width={info.width}
height={info.height}
stroke="#0067ed"
fill="none"
className="active-sprites-content"
></rect>
{/* 移动 */}
{ready && (
<Move
activeRect={activeRect}
activeSprite={activeSprite}
onSpriteChange={this.handleSpriteChange}
/>
)}
{/* 缩放 */}
{ready && (
<Resize
activeRect={activeRect}
activeSprite={activeSprite}
onSpriteChange={this.handleSpriteChange}
/>
)}
{/* 旋转 */}
{ready && (
<Rotate
activeRect={activeRect}
activeSprite={activeSprite}
onSpriteChange={this.handleSpriteChange}
/>
)}
</g>
{/* 辅助线 */}
{auxiliaryLineList.map((line: Line) => (
<line
key={JSON.stringify(line)}
{...line}
stroke={"#0067ed"}
strokeDasharray="4 4"
></line>
))}
</>
);
}
}
一、移动
拖拽实现的底层原理主要是依靠三个鼠标事件:mousedown、mousemove、mouseup,具体每个事件里做的事情见下图:
- 移动拖拽效果
- 移动实现代码
import React from "react";
// 是否点击在精灵上
const isClickOnSprite = (e: MouseEvent) => { ... };
// 是否正在输入
export const isInputting = () => { ... };
// 计算鼠标位置
const getMousePoint = (e: MouseEvent) => ({ x: e.pageX, y: e.pageY });
interface IProps {
activeRect: ISizeCoordinate;
activeSprite: ISprite;
onSpriteChange: (sprite: ISprite) => void;
}
export class Move extends React.Component<IProps> {
// 鼠标按下时记录的初始状态
initData: {
initSize: ISize;
initCoordinate: ICoordinate;
initMousePos: ICoordinate;
} = {};
componentDidMount() {
// 加载时监听鼠标按下事件
document.addEventListener("pointerdown", this.handleMouseDown, false);
}
componentWillUnmount() {
// 即将销毁时取消事件监听
document.removeEventListener("pointerdown", this.handleMouseDown, false);
document.removeEventListener("pointermove", this.handleMouseMove, false);
document.removeEventListener("pointerup", this.handleMouseUp, false);
}
handleMouseDown = (e: MouseEvent) => {
// 记录初始状态
this.getInitData(e);
// 没点在精灵上或正在输入就返回
if (!isClickOnSprite(e) || isInputting()) {
return;
}
// 添加监听事件
document.addEventListener("pointermove", this.handleMouseMove, false);
document.addEventListener("pointerup", this.handleMouseUp, false);
};
handleMouseUp = () => {
// 移除监听事件
document.removeEventListener("pointermove", this.handleMouseMove, false);
document.removeEventListener("pointerup", this.handleMouseUp, false);
};
handleMouseMove = (e: MouseEvent) => {
const { activeSprite, onSpriteChange } = this.props;
const { initMousePos, initCoordinate } = this.initData;
const mousePoint = getMousePoint(e);
// 计算鼠标变化的相对位置
const dx = mousePoint.x - initMousePos.x;
const dy = mousePoint.y - initMousePos.y;
const newSprite: ISprite = cloneDeep(activeSprite);
// 修改精灵的定位
newSprite.attrs.coordinate = {
x: initCoordinate.x + dx,
y: initCoordinate.x + dy,
};
// 通过接口修改精灵的定位
onSpriteChange?.(newSprite);
};
// 记录初始状态
getInitData = (e: MouseEvent) => {
const { activeSprite } = this.props;
const initData = this.initData;
initData.initSize = { ...activeSprite.attrs.size };
initData.initCoordinate = { ...activeSprite.attrs.coordinate };
initData.initMousePos = getMousePoint(e);
};
render() {
const { activeRect } = this.props;
return (
<rect
x={activeRect.x}
y={activeRect.y}
width={activeRect.width}
height={activeRect.height}
stroke="#0566e5"
fill="none"
className="active-sprites-rect"
></rect>
);
}
}
二、旋转
实现旋转的代码结构和移动基本相同,不一样的地方仅有2处:
- 渲染一个旋转icon,鼠标按下事件绑定在旋转icon上
- 鼠标移动时计算旋转角的逻辑 angle = A + B
- 其中其中A角度取决于矩形的长宽比
- B角度是鼠标点与中心点连线和水平线组成的角度
- svg实现旋转需要手动指定
旋转中心
,否则就默认按照坐标原点旋转
,因此需要计算精灵的中心点 - 旋转平移的svg transform设置为
- 旋转:
rotate(${angle}, ${center.x} ${center.y})
- 平移:
translate(${coordinate.x}, ${coordinate.y})
- 旋转:
- 精灵的容器 g 标签修改为以下代码:
import React from "react";
import { ICoordinate, ISize, ISprite } from "./type";
// 精灵
export const Sprite = ({
sprite,
children
}: {
sprite: ISprite;
children?: React.ReactNode;
}) => {
const { id, attrs } = sprite;
const { size = {}, coordinate = {}, angle = 0 } = attrs;
const { width = 0, height = 0 } = size as ISize;
const { x = 0, y = 0 } = coordinate;
const center = {
x: x + width / 2,
y: y + height / 2
};
// 旋转
const rotateStr = `rotate(${angle}, ${center.x} ${center.y})`;
// 平移定位
const translateStr = `translate(${x}, ${y})`;
const transform = `${angle === 0 ? "" : rotateStr} ${translateStr}`;
return (
<>
<g className="sprite-container" data-sprite-id={id} transform={transform}>
{children}
</g>
</>
);
};
- 计算旋转角
// 计算旋转角
const getAngle = (
initSize: ISize,
initCoordinate: ICoordinate,
initMousePos: ICoordinate,
mousePos: ICoordinate,
) => {
// 矩形中心点
const center = {
x: initCoordinate.x + initSize.width / 2,
y: initCoordinate.y + initSize.height / 2,
};
// B角度是鼠标点与中心点连线和水平线组成的角度
const B = lineAngle(center, mousePos);
// 初始鼠标点和中心的点组成角度的补偿处理
const A += radianToAngle(Math.atan(info.height / info.width));
let angle = A + B;
// 限制溢出
angle += angle < 0 ? 360 : 0;
angle = Math.max(angle, 360);
return angle;
}
三、缩放 (resize)
缩放的实现原理也是借助 鼠标按下、移动、抬起三个事件来做的,只不过鼠标按下事件绑定在了
缩放锚点
上
1. 缩放锚点渲染
对缩放锚点进行数据抽象:
- 定位: 以百分比的形式存储各个锚点的定位,例如右下角的锚点位置为:{ x: 100, y: 100 }, 正下方的锚点位置为: { x: 50, y: 100 }
- 角度: 为了计算鼠标resize样式而用,记录锚点相对于精灵中心点的角度,在精灵发生旋转时自动计算鼠标resize样式
const getCursor = (angle: number) => {
let a = angle;
if (a < 0) {
a += 360;
}
if (a >= 360) {
a -= 360;
}
if (a >= 338 || a < 23 || (a > 157 && a <= 202)) {
return "ew-resize";
} else if ((a >= 23 && a < 68) || (a > 202 && a <= 247)) {
return "nwse-resize";
} else if ((a >= 68 && a < 113) || (a > 247 && a <= 292)) {
return "ns-resize";
} else {
return "nesw-resize";
}
};
// 这里仅放出了四个角的锚点,其余位置可按此思路补齐
const resizePoints = [
{ name: "right-top", position: { x: 100, y: 0 }, angle: -45 },
{ name: "right-bottom", position: { x: 100, y: 100 }, angle: 45 },
{ name: "left-top", position: { x: 0, y: 0 }, angle: 225 },
{ name: "left-bottom", position: { x: 0, y: 100 }, angle: 135 },
];
export const ResizePoints = ({ angle, activeRect, onResizeDown }) => {
const { x, y, width, height } = activeRect;
return (
<g>
{resizePoints.map((e: any, i: number) => {
return (
<rect
key={e.name}
x={x + (width / 100) * e.position.x - 4}
y={y + (height / 100) * e.position.y - 4}
width={8}
height={8}
fill="#fff"
strokeWidth="1"
stroke="#999"
className={`operate-point-container operate-point-${e.name}`}
style={{ cursor: getCursor(angle + e.angle) }}
onMouseDown={(event: any) => onResizeDown?.(event, e.name)}
></rect>
);
})}
</g>
);
};
2. 缩放实现
-
以下是实现缩放的代码,事件交互和移动基本类似
- 区别:是鼠标按下事件的来源是缩放锚点
-
其中
computeResizeRect
方法是计算缩放后的新大小和位置,这个逻辑比较复杂,放在后面专门结合
import React from "react";
interface IProps {
activeRect: ISizeCoordinate;
activeSprite: ISprite;
onSpriteChange: (sprite: ISprite) => void;
}
export class Resize extends React.Component<IProps> {
// 正在操作的缩放锚点方向,例如右上:right-top
resizePos = '';
initData = {};
componentDidMount() {
document.addEventListener("pointerdown", this.handleMouseDown, false);
document.addEventListener("pointerup", this.handleMouseUp, false);
}
componentWillUnmount() {
document.removeEventListener("pointermove", this.handleMouseMove, false);
document.removeEventListener("pointerup", this.handleMouseUp, false);
}
handleMouseDown = (e: MouseEvent, resizePos: string) => {
this.resizePos = resizePos;
// 记录初始状态
this.getInitData(e);
// 没点在精灵上或正在输入就返回
if (!isClickOnSprite(e) || isInputting()) {
return;
}
// 添加监听事件
document.addEventListener("pointermove", this.handleMouseMove, false);
document.addEventListener("pointerup", this.handleMouseUp, false);
};
handleMouseUp = () => {
// 移除监听事件
document.removeEventListener("pointermove", this.handleMouseMove, false);
document.removeEventListener("pointerup", this.handleMouseUp, false);
};
handleMouseMove = (e: MouseEvent) => {
const { resizePos } = this;
const { activeSprite, onSpriteChange } = this.props;
const { initMousePos, initSize, initCoordinate } = this.initData;
const mousePoint = getMousePoint(e);
// 计算鼠标变化的相对位置
const dx = mousePoint.x - initMousePos.x;
const dy = mousePoint.y - initMousePos.y;
const newSprite: ISprite = cloneDeep(activeSprite);
// 修改精灵的定位
newSprite.coordinate = {
x: initCoordinate.x + dx,
y: initCoordinate.x + dy,
};
// 计算缩放后的矩形,这个计算方式比较复杂,后面单独说明
const newRect = computeResizeRect(
e,
resizePos,
initSize,
initCoordinate,
newSprite.attrs.angle,
);
const { width, height, x, y } = newRect;
newSprite.attrs.size = { width, height };
newSprite.attrs.coordinate = { x, y };
// 通过接口修改精灵的定位
onSpriteChange?.(newSprite);
};
getInitData = (e: MouseEvent) => {
const { activeSprite } = this.props;
const initData = this.initData;
initData.initSize = { ...activeSprite.size };
initData.initCoordinate = { ...activeSprite.coordinate };
initData.initMousePos = getMousePoint(e);
};
render() {
const { activeSprite, activeRect } = this.props;
const angle = activeSprite?.attrs?.angle || 0;
return (
<g>
<ResizePoints
angle={angle}
activeRect={activeRect}
onResizeDown={this.handleMouseDown}
/>
</g>
);
}
}
3. 计算缩放后的矩形
computeResizeRect
方法的输入为:
- 操作的锚点是哪一个
- 开始缩放时精灵的大小和位置
- 开始缩放时鼠标的位置
- 鼠标现在的最新位置
- 精灵的旋转角
输出为:
- 精灵新的大小和位置
3.1 不考虑旋转的情况下
上图是分别操作右下角锚点和左上角锚点的示意图
- 其中
dx
dy
是鼠标相对位置差
- 如果锚点位置在右侧(例如: right、right-top、right-bottom等) ,则移动仅影响宽度
- x1 = x0
- w1 = w0 + dx
- 如果锚点位置在左侧(例如: left、left-top、left-bottom等) ,则移动会影响x方向定位和宽度
- x1 = x0 + dx
- w1 = w0 - dx
换算到高度方向上同理。
const getNewRect = (
resizePos: string,
initSize: ISize,
initCoordinate: ICoordinate,
initMousePos: ICoordinate,
mousePos: ICoordinate,
) => {
const xReverse = resizePos.includes('left');
const yReverse = resizePos.includes('top');
const { width: w0, height: h0 } = initSize;
const { x: x0, y: y0 } = initCoordinate;
const dx = mousePos.x - initMousePos.x;
const dy = mousePos.y - initMousePos.y;
const x = xReverse ? x0 + dx : x0;
const y = yReverse ? y0 + dy : y0;
const width = xReverse ? w0 - dx : w0 + dx;
const height = yReverse ? h0 - dy : h0 + dy;
return { x, y, width, height } as ISizeCoordinate;
}
3.2 考虑旋转的情况下
此情况下相对会复杂很多
上面旋转的实现里介绍了svg旋转按照精灵中心点进行旋转的。
精灵位置坐标值是没有旋转时的坐标系,而鼠标点相对于精灵来说,是旋转后的坐标系下的点。
因此在精灵有旋转角的时候,拖动右下角锚点,依然仅改变高宽的话,矩形的中心点已经变化了,因此看起来左上角的点发生了位置偏移,所以要计算这个偏移手动修正回来。
- 未修复偏差示意图
- 其中灰色框是不旋转时的选框,原点是矩形中心点
- 修复偏差后的示意图
- 处理缩放的最终计算代码
/**
* 处理来自8个方向上的size变动
* @param param
* @returns
*/
export const handlePositionResize = ({
pos,
angle,
mousePoint,
initPos,
initSize,
initMousePos,
info,
resizeLock,
}: // logPoints,
any) => {
let { width, height, x, y } = info;
let offsetPoint: Point = { x: 0, y: 0 };
const initCenter = {
x: initPos.x + initSize.width / 2,
y: initPos.y + initSize.height / 2,
};
// 宽高方向上各自是否发生了反转,如右侧边的锚点是否拖拽到了矩形的左边
// 把鼠标点转换到未旋转的坐标系下,方便判断是否翻转
const originMousePoint = rotate(mousePoint, -angle, initCenter);
// 重要:在移动右下角锚点的情况下,只影响高宽,但是由于旋转中心变了,左上角也会偏移的
// 所以要计算这个偏移手动修正回来
const getOffsetPoint = (width = 0, height = 0, angle = 0) => {
const newCenter = {
x: initPos.x + (width + initSize.width) / 2,
y: initPos.y + (height + initSize.height) / 2,
};
const p1 = rotate(initPos, angle, initCenter);
const p2 = rotate(initPos, angle, newCenter);
const offsetPoint = {
x: p2.x - p1.x,
y: p2.y - p1.y,
};
return offsetPoint;
};
const hasLeft = pos.includes('left');
const hasRight = pos.includes('right');
const hasTop = pos.includes('top');
const hasBottom = pos.includes('bottom');
const reverseX = hasLeft
? originMousePoint.x > initPos.x + initSize.width
: originMousePoint.x < initPos.x;
const reverseY = hasTop
? originMousePoint.y > initPos.y + initSize.height
: originMousePoint.y < initPos.y;
const offsetAngle = angle * (hasLeft ? -1 : 1) * (hasTop ? -1 : 1);
if (hasLeft || hasRight) {
width = getIncreaseSize(
initMousePos,
mousePoint,
angle + (hasLeft ? 180 : 0),
).width;
}
if (hasTop || hasBottom) {
height = getIncreaseSize(
initMousePos,
mousePoint,
angle + (hasTop ? 180 : 0),
).height;
}
offsetPoint = getOffsetPoint(width, height, offsetAngle);
x = -offsetPoint.x;
y = -offsetPoint.y;
if (hasRight) {
x = -offsetPoint.x + (reverseX ? initSize.width + width : 0);
}
if (hasLeft) {
x = offsetPoint.x + (reverseX ? initSize.width : -width);
}
if (hasBottom) {
y = -offsetPoint.y + (reverseY ? initSize.height + height : 0);
}
if (hasTop) {
y = offsetPoint.y + (reverseY ? initSize.height : -height);
}
return {
width: Math.abs(initSize.width + width),
height: Math.abs(initSize.height + height),
x: initPos.x + x,
y: initPos.y + y
};
};
四、总结
本文介绍了图形编辑器基础的 移动
、缩放
、旋转
等编辑能力,做到了三个操作代码隔离,并且在旋转后缩放修复了位置偏移问题。
完成基本的编辑能力后,我们的图形编辑器已经基本可用了,可以向舞台加入各种精灵,并可以选中,对它们进行移动、缩放、旋转等操作。
接下来会继续丰富编辑能力,例如:
- 移动靠近其他精灵时吸附上去,并显示辅助线
- 缩放靠近其他精灵时吸附上去,并显示辅助线
- 画布上显示网格,精灵在画布上拖拽时可以吸附在网格上
系列文章汇总
- svg实现图形编辑器系列一:精灵系统
- svg实现图形编辑器系列二:精灵的开发和注册
- svg实现图形编辑器系列三:移动、缩放、旋转
- svg实现图形编辑器系列四:吸附&辅助线
- svg实现图形编辑器系列五:辅助编辑锚点
- svg实现图形编辑器系列六:链接线、连接桩
- svg实现图形编辑器系列七:右键菜单、快捷键、撤销回退
- svg实现图形编辑器系列八:多选、组合、解组
- svg实现图形编辑器系列九:精灵的编辑态&开发常用精灵
- svg实现图形编辑器系列十:工具栏&配置面板(最终篇)
🔥 demo演示源码
最后应大家要求,这里放上code sandbox的demo演示源码
: