svg实现图形编辑器系列八:多选、组合、解组

1,567 阅读4分钟

在之前的系列文章中,我们介绍了图形编辑器基础的 移动缩放旋转 等基础编辑能力,以及很多辅助编辑的能力。本文将继续介绍提升编辑效率的功能:多选、组合、解组,以方便批量操作精灵,提升效率。

一、多选

  • 多选效果演示 1multi-select.gif

1. 选中精灵变为选中精灵列表

  • activeSprite: ISprite 变为 activeSpriteList: ISprite[]

  • 对应的数据操作api也要变化


interface IState {
  spriteList: ISprite[];
  activeSpriteList: ISprite[];
}
export class GraphicEditorCore extends React.Component<IProps, IState> {
  readonly state: IState = {
    spriteList: [],
    activeSpriteList: []
  };
  // 设置选中精灵列表
  updateActiveSpriteList = (activeSpriteList: ISprite[]) => {
    this.setState({ activeSpriteList });
  };
}

2. 选框矩形大小和位置由精灵列表计算而来

class ActiveSpriteContainer extends React.Component<IProps, IState> {
  render() {
    const { activeSpriteList } = this.props;
    let activeRect: ISizeCoordinate = { width: 0, height: 0, x: 0, y: 0 };
    const angle = activeSprite?.attrs?.angle || 0;
    // 选框矩形大小和位置由精灵列表计算而来
    activeRect = getActiveSpriteRect(activeSpriteList);
    return (
      <>
        <g
          className="active-sprites-container"
          transform={`rotate(${angle || 0}, ${activeRect.x + activeRect.width / 2} ${
            activeRect.y + activeRect.height / 2
          })`}
        >
          // 省略...
        </g>
    )
  }
}

/**
 * 计算选中所有精灵的矩形区域
 * @param activeSpriteList 精灵列表
 * @param registerSpriteMetaMap 注册的精灵映射
 * @returns
 */
export const getActiveSpriteRect = (activeSpriteList: ISprite[]) => {
  const posMap = {
    minX: Infinity,
    minY: Infinity,
    maxX: 0,
    maxY: 0
  };
  activeSpriteList.forEach((sprite: ISprite) => {
    const { size, coordinate } = sprite.attrs;
    const { width = 0, height = 0 } = size;
    const { x = 0, y = 0 } = coordinate;
    if (x < posMap.minX) {
      posMap.minX = x;
    }
    if (y < posMap.minY) {
      posMap.minY = y;
    }
    if (x + width > posMap.maxX) {
      posMap.maxX = x + width;
    }
    if (y + height > posMap.maxY) {
      posMap.maxY = y + height;
    }
  });
  return {
    width: posMap.maxX - posMap.minX,
    height: posMap.maxY - posMap.minY,
    x: posMap.minX,
    y: posMap.minY
  } as ISizeCoordinate;
};

3. 点击根据事件判断选中哪个精灵

  • 点击要选中最顶层组合,dom遍历要一直向上找到直属于舞台的dom对应的精灵
/**
 * 根据类名寻找精灵
 * @param dom dom元素
 * @param className css类名
 * @return dom | null
 */
export function findSpriteDomByClass(dom: any, className: string): any {
  const domList = findParentListByClass(dom, className);
  return domList.pop();
}

/**
 * 根据类名寻找所有满足条件的父元素
 * @param dom dom元素
 * @param className css类名
 * @return dom | null
 */
export function findParentListByClass(_dom: any, _className: string): any {
  const domList: any[] = [];
  const dfs = (dom: any, className: string): any => {
    if (!dom || dom.tagName === "BODY") {
      return;
    }
    if (dom.classList.contains(className)) {
      domList.push(dom);
    }
    return dfs(dom.parentNode, className);
  };

  dfs(_dom, _className);
  return domList;
}

4. 鼠标框选

  • 框选示意图

image.png


import React from 'react';
import type { ICoordinate, ISprite, IStageApis } from '../interface';
import { getStageMousePoint } from '../utils/tools';

interface IProps {
  stage: IStageApis;
}
interface IState {
  initMousePos: ICoordinate;
  currentMousePos: ICoordinate;
}

class SelectRect extends React.Component<IProps, IState> {
  readonly state = {
    initMousePos: { x: 0, y: 0 },
    currentMousePos: { x: 0, y: 0 },
  };

  private readonly onStageMouseDown = (e: any) => {
    const { stage } = this.props;
    if (e.target.classList.contains('lego-stage-container')) {
      const { coordinate, scale = 1 } = stage.store();
      const currentMousePos = getStageMousePoint(e, coordinate, scale);
      this.setState({
        initMousePos: { ...currentMousePos },
        currentMousePos: { ...currentMousePos },
      });
      document.addEventListener('mousemove', this.onStageMouseMove, false);
      document.addEventListener('mouseup', this.onStageMouseUp, false);
    }
  };

  private readonly onStageMouseUp = () => {
    this.setState({
      initMousePos: { x: 0, y: 0 },
      currentMousePos: { x: 0, y: 0 },
    });
    document.removeEventListener('mousemove', this.onStageMouseMove, false);
    document.removeEventListener('mouseup', this.onStageMouseUp, false);
  };

  private readonly onStageMouseMove = (e: any) => {
    const { stage } = this.props;
    const { coordinate, scale = 1 } = stage.store();
    const currentMousePos = getStageMousePoint(e, coordinate, scale);
    this.setState({ currentMousePos });
    this.handleSelectSprites();
  };

  // 计算框选范围内包含的所有精灵
  private readonly handleSelectSprites = () => {
    const { stage } = this.props;
    const { spriteList } = stage.store();
    const { initMousePos, currentMousePos } = this.state;
    const minX = Math.min(initMousePos.x, currentMousePos.x);
    const maxX = Math.max(initMousePos.x, currentMousePos.x);
    const minY = Math.min(initMousePos.y, currentMousePos.y);
    const maxY = Math.max(initMousePos.y, currentMousePos.y);
    const activeSpriteList: ISprite[] = [];
    spriteList.forEach((sprite: ISprite) => {
      const { x, y } = sprite.attrs.coordinate;
      const { width, height } = sprite.attrs.size;
      if (x >= minX && x + width <= maxX && y >= minY && y + height <= maxY) {
        activeSpriteList.push(sprite);
      }
    });
    stage.apis.setActiveSpriteList(activeSpriteList);
  };

  componentDidMount() {
    document.addEventListener('mousedown', this.onStageMouseDown);
  }

  componentWillUnmount() {
    document.removeEventListener('mousedown', this.onStageMouseDown);
    document.removeEventListener('mousemove', this.onStageMouseMove);
  }

  render() {
    const { initMousePos, currentMousePos } = this.state;
    return (
      <rect
        x={Math.min(currentMousePos.x, initMousePos.x)}
        y={Math.min(currentMousePos.y, initMousePos.y)}
        width={Math.abs(currentMousePos.x - initMousePos.x)}
        height={Math.abs(currentMousePos.y - initMousePos.y)}
        stroke="#0067ed"
        strokeWidth="1"
        fill="#e6f6ff"
        opacity=".5"></rect>
    );
  }
}

export default SelectRect;

二、组合、解组

  • 组合解组效果图

1group.gif

1. 精灵列表变为精灵树

const spriteList = [
  {
    id: "Group1",
    type: "GroupSprite",
    props: {},
    attrs: {
      coordinate: { x: 100, y: 240 },
      size: { width: 300, height: 260 },
      angle: 0
    },
    children: [
      {
        id: "RectSprite2",
        type: "RectSprite",
        attrs: {
          coordinate: { x: 0, y: 0 },
          size: { width: 160, height: 100 },
          angle: 0
        }
      },
      {
        id: "RectSprite3",
        type: "RectSprite",
        attrs: {
          coordinate: { x: 200, y: 100 },
          size: { width: 100, height: 160 },
          angle: 0
        }
      }
    ]
  }
]

2. 支持递归渲染


export class GraphicEditorCore extends React.Component<IProps, IState> {
  // 新增递归渲染精灵的方法
  renderSprite = (sprite: ISprite) => {
    const { registerSpriteMetaMap } = this;
    // 从注册好的精灵映射里拿到meta和精灵组件
    const spriteMeta = registerSpriteMetaMap[sprite.type];
    const SpriteComponent =
      (spriteMeta?.spriteComponent as any) ||
      (() => <text fill="red">Undefined Sprite: {sprite.type}</text>);
    // 如果是组,就递归渲染
    if (isGroupSprite(sprite)) {
      const { children } = sprite;
      return (
        <Sprite key={sprite.id} sprite={sprite}>
          <rect
            x="0"
            y="0"
            width="100%"
            height="100%"
            fill="transparent"
          ></rect>
          {children?.map((childSprite) => this.renderSprite(childSprite))}
        </Sprite>
      );
    }
    return (
      <Sprite key={sprite.id} sprite={sprite}>
        <SpriteComponent sprite={sprite} />
      </Sprite>
    );
  };

  render() {
    const { registerSpriteMetaMap, stage } = this;
    const { width, height } = this.props;
    const { spriteList, activeSpriteList } = this.state;
    return (
      <Stage id="graphic-editor-stage" width={width} height={height}>
        {/* 精灵列表 */}
        {spriteList.map((sprite) => this.renderSprite(sprite))}
        <Drag
          scale={1}
          stage={stage}
          pressShift={false}
          activeSpriteList={activeSpriteList}
          registerSpriteMetaMap={registerSpriteMetaMap}
        />
      </Stage>
    );
}

3. 组合和解组的精灵计算


export const isGroupSprite = (sprite?: ISprite) =>
  Boolean(sprite?.type?.toLocaleLowerCase()?.includes("group"));

// 多个精灵组合为一个【组合精灵】
export const makeSpriteGroup = (activeSpriteList: ISprite[]) => {
  const { x, y, width, height } = getActiveSpriteRect(activeSpriteList);
  const groupSprite: ISprite = {
    type: "GroupSprite",
    id: `Group_${Math.floor(Math.random() * 10000)}`,
    attrs: {
      size: { width, height },
      coordinate: { x, y },
      angle: 0
    },
    children: activeSpriteList.map((sprite) => {
      const { coordinate } = sprite.attrs;
      return {
        ...sprite,
        attrs: {
          ...sprite.attrs,
          coordinate: {
            x: coordinate.x - x,
            y: coordinate.y - y
          }
        }
      };
    })
  };
  return groupSprite;
};

// 【组合精灵】拆分为多个精灵
export const splitSpriteGroup = (group: ISprite) => {
  const { x, y } = getActiveSpriteRect([group]);
  if (!sprite?.children || sprite?.children?.length < 1) {
    return [];
  }
  const getAngle = (n: any) => Number(n) || 0;
  const spriteList = group.children.map((child: ISprite) => {
    const { coordinate, angle } = child.attrs;
        // 处理角度,拆分后的精灵角度等于组合角度和自己本身的旋转角度同时作用生效,但不是简单的角度相加
    const { dx, dy, angle } = getSplitSpriteAngleMove(child, sprite);

    return {
      ...child,
      attrs: {
        ...child.attrs,
        // 处理角度,拆分后的精灵角度等于组合角度加自己的角度
        //(PS: 这个直接加的算法不准确,应该关注精灵的旋转点,这里只简写这样写)
        angle: angle + group.attrs.angle,
        // 偏移和上方同样的影响,新的位置也不再只受精灵本身位置和组合位置的影响了
        coordinate: {
          x: coordinate.x + x,
          y: coordinate.y + y,
        }
      }
    };
  });
  return spriteList;
};

4. 修复【组合后旋转再取消组合】精灵位置的偏移

  • 我们发现直接将角度相加会产生如下的偏移抖动

1group-error.gif

  • 组合后旋转,再取消组合,重新计算组合内精灵的旋转角度,我要使视觉上保持不变

位置偏移的原因是:

  • 拆分后的精灵角度并不等于组合角度加自己的角度, 应该关注精灵的旋转点
  • 旋转后的角度不等于直接相加的原因是两次旋转的中心点不同,一次是精灵本身,一个是组合精灵

计算平移距离:

  • 组合后旋转,再取消组合,平移距离就是精灵中心点组合旋转角影响后的新中心点与原中心点的相对位移
  • 计算公式为
    • 新中心点 = rotate(精灵中心点, 组合的角度, 组合中心点)
    • dx = 新中心点.x - 精灵中心点.x
    • dy = 新中心点.y - 精灵中心点.y

计算新的旋转角:

  • 组合后旋转,再取消组合,则精灵的旋转角首先受自身旋转影响,围绕自身中点旋转一次
  • 再围绕组合的中心旋转组合精灵旋转角度
  • 直接计算两次旋转角的影响比较复杂,因此我们直接计算旋转后精灵的实际位置,重新计算旋转角就可以
  • 用右边中点,经精灵自身旋转,再经过组旋转,再与自身中心点一起计算出精灵实际旋转角

image.png

1group2.gif

// 【组合精灵】拆分为多个精灵
export const splitSpriteGroup = (group: ISprite) => {
  const { x, y } = getActiveSpriteRect([group]);
  if (!sprite?.children || sprite?.children?.length < 1) {
    return [];
  }
  const getAngle = (n: any) => Number(n) || 0;
  const spriteList = group.children.map((child: ISprite) => {
    const { coordinate } = child.attrs;
    // 计算解组后内部精灵的旋转和平移,修复视觉上产生的偏移
    const { dx, dy, angle } = getSplitSpriteAngleMove(child, sprite);

    return {
      ...child,
      attrs: {
        ...child.attrs,
        angle,
        coordinate: {
          x: coordinate.x + x + dx,
          y: coordinate.y + y + dy,
        }
      }
    };
  });
  return spriteList;
};

// 计算解组后内部精灵的旋转和平移,修复视觉上产生的偏移
export const getSplitSpriteAngleMove = (sprite: ISprite, groupSprite: ISprite) => {
  const getAngle = (n: any) => Number(n) || 0;
  const { coordinate, size } = sprite.attrs;
  const { coordinate: groupCoordinate, size: groupSize } = groupSprite.attrs;
  const spriteAngle = getAngle(sprite.attrs.angle);
  const groupAngle = getAngle(groupSprite.attrs.angle);
  const groupCenterPoint = {
    x: groupCoordinate.x + groupSize.width / 2,
    y: groupCoordinate.y + groupSize.height / 2,
  };
  const originCenterPoint = {
    x: coordinate.x + groupCoordinate.x + size.width / 2,
    y: coordinate.y + groupCoordinate.y + size.height / 2,
  };
  const rotateCenterPoint = rotate(originCenterPoint, groupAngle, groupCenterPoint);
  const dx = rotateCenterPoint.x - originCenterPoint.x;
  const dy = rotateCenterPoint.y - originCenterPoint.y;

  // 计算精灵旋转角,用右边中点,经精灵自身旋转,再经过组旋转,再与自身中心点一起计算出精灵实际旋转角
  const originRightPoint  = { x: originCenterPoint.x + size.width / 2, y: originCenterPoint.y };
  let rotateRightPoint = rotate(originRightPoint, spriteAngle, originCenterPoint);
  rotateRightPoint = rotate(rotateRightPoint, groupAngle, groupCenterPoint);
  const angle = lineAngle(rotateCenterPoint, rotateRightPoint);
  return { dx, dy, angle };
};

系列文章汇总

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