svg实现图形编辑器系列三:移动、缩放、旋转

3,711 阅读6分钟

theme: juejin

# 用svg实现图形编辑器系列二:精灵的开发和注册 文章中,我们实现了图形编辑器的最小demo并进行了重构,渲染能力已经基本完备了,接下来本文将介绍如何实现 移动缩放旋转 这三种基本的编辑能力。

Demo体验链接:图形编辑器在线Demo

以下是舞台画布的示意图

  • 渲染精灵组件
  • 编辑能力
    • 选中
    • 移动
    • 缩放
    • 旋转

image.png

  • 以下是实际选框的样式

image.png

矩形选框工具

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,具体每个事件里做的事情见下图:

UML 图-3.jpg

  • 移动拖拽效果

1move.gif

  • 移动实现代码
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角度是鼠标点与中心点连线和水平线组成的角度

UML 图-7.jpg

  • 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;
}

1rotate.gif

三、缩放 (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 不考虑旋转的情况下

UML 图-5.jpg

上图是分别操作右下角锚点和左上角锚点的示意图

  • 其中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旋转按照精灵中心点进行旋转的。

精灵位置坐标值是没有旋转时的坐标系,而鼠标点相对于精灵来说,是旋转后的坐标系下的点。

因此在精灵有旋转角的时候,拖动右下角锚点,依然仅改变高宽的话,矩形的中心点已经变化了,因此看起来左上角的点发生了位置偏移,所以要计算这个偏移手动修正回来。

  • 未修复偏差示意图
    • 其中灰色框是不旋转时的选框,原点是矩形中心点

1resize.gif

  • 修复偏差后的示意图

1resize-fix.gif

  • 处理缩放的最终计算代码

/**
 * 处理来自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
  };
};

四、总结

本文介绍了图形编辑器基础的 移动缩放旋转 等编辑能力,做到了三个操作代码隔离,并且在旋转后缩放修复了位置偏移问题。

完成基本的编辑能力后,我们的图形编辑器已经基本可用了,可以向舞台加入各种精灵,并可以选中,对它们进行移动、缩放、旋转等操作。

接下来会继续丰富编辑能力,例如:

  • 移动靠近其他精灵时吸附上去,并显示辅助线
  • 缩放靠近其他精灵时吸附上去,并显示辅助线
  • 画布上显示网格,精灵在画布上拖拽时可以吸附在网格上

系列文章汇总

  1. svg实现图形编辑器系列一:精灵系统
  2. svg实现图形编辑器系列二:精灵的开发和注册
  3. svg实现图形编辑器系列三:移动、缩放、旋转
  4. svg实现图形编辑器系列四:吸附&辅助线
  5. svg实现图形编辑器系列五:辅助编辑锚点
  6. svg实现图形编辑器系列六:链接线、连接桩
  7. svg实现图形编辑器系列七:右键菜单、快捷键、撤销回退
  8. svg实现图形编辑器系列八:多选、组合、解组
  9. svg实现图形编辑器系列九:精灵的编辑态&开发常用精灵
  10. svg实现图形编辑器系列十:工具栏&配置面板(最终篇)

awebp

🔥 demo演示源码

最后应大家要求,这里放上code sandbox的demo演示源码:

image.png