svg实现图形编辑器系列六:连接线、连接桩

2,514 阅读3分钟

在之前的系列文章中我们介绍了图形编辑器,基本的移动缩放旋转拖拽编辑能力,以及吸附、网格、辅助线等辅助编辑的能力。

本文会继续强化编辑能力:

  • 连接线功能:在精灵上定义端口,可以用连接线把精灵彼此相连,当其中一个精灵发生变化时,连接线也会持续变化保持连接。

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

  • 首先看效果图

demo3.gif

一、精灵侧

1. 精灵锚点配置

先看配置:

首先还是圆角矩形(这个形状实在太经典了,什么功能都有,配置也容易理解)


// 圆角矩形,连接桩位置是固定的上下左右,单位是百分比
export const RectRoundSpriteMeta: ISpriteMeta<IProps> = {
  type: 'RectRoundSprite',
  // 连接桩配置
  ports: {
    // 单位为百分比
    unit: PortUnitEnum.percent,
    points: [
      { x: 50, y: 0, arcAngle: 270 },
      { x: 50, y: 100, arcAngle: 90 },
      { x: 0, y: 50, arcAngle: 180 },
      { x: 100, y: 50, arcAngle: 0 },
    ],
  },
};

// 三角形,连接桩位置是根据三角形的顶点动态变化
export const TriangleSpriteMeta: ISpriteMeta<IProps> = {
  type: ' TriangleSprite',
  // 连接桩配置
  ports: {
    // 连接桩点坐标配置,三角形的连接桩是顶点,根据属性动态变化
    getPoints: ({ sprite }) => {
      const { props } = sprite as any;
      const { anchorPointX = 0 } = props;
      const { width, height } = sprite.attrs.size;
      return [
        { x: (anchorPointX * width) / 100, y: 0, arcAngle: 270 },
        { x: 0, y: height, arcAngle: 180 },
        { x: width, y: height, arcAngle: 0 },
      ];
    },
  },

2. 精灵连接线实现方式

锚点实现逻辑略有不同的是,精灵设置连接桩后自己是无需消费的,只用来给连接线读取。

例如现在我们用线段连接线连接上了一个圆角矩形,那么连接线一个的端点属性配置就变为


export interface IPort {
  // 连接桩所属的精灵id
  spriteId: string;
  // 连接桩在所属的精灵中的索引
  index: number;
}

// 连接线精灵的属性
const LinkLineProps = {
  start: {
    // 点坐标
    x: 100,
    y: 100,
    // 起点连接上了圆角矩形的第二个连接桩
    port: {
      spriteId: 'RectRoundSprite1',
      index: 1,
    } as IPort,
  }

  // 其他属性...
};

// 连接线精灵组件
export class LinkLineSprite extends BaseSprite<IProps> {
  componentDidMount() {
    // 监听目标精灵变化
    this.props.stage.apis.$event.on(
      EventTypeEnum.UpdateSpriteList,
      this.handleSpriteUpdate,
    );
    // 监听精灵锚点变化
    this.props.stage.apis.$event.on(
      EventTypeEnum.SpriteAnchorPointMouseUp,
      this.handleAnchorMouseUp,
    );
  }

  componentWillUnmount() {
    // 监听目标精灵变化
    this.props.stage.apis.$event.off(
      EventTypeEnum.UpdateSpriteList,
      this.handleSpriteUpdate,
    );
  }

  // 处理精灵变化事件,(updateSprite为正在更新的精灵)
  handleSpriteUpdate = ({ updateSprite }: { updateSprite: ISprite }) => {
    const { sprite } = this.props;
    const { start, end } = sprite.props;
    const startMove = start.port && updateSprite.id === start.port.id;
    if (startMove) {
      // 计算目标精灵连接桩的坐标
      const p = this.getSpritePortCoordinate(sprite.props.start);
      // 更新起始点坐标
      this.pointChangeHandle(p, 'start');
    }
  };

  // 计算目标精灵连接桩的坐标
  getSpritePortCoordinate = (point: IPortPointOption) => {
    if (!point.port || !point.port.spriteId) {
      return point;
    }
    const { spriteId, index } = point.port;
    const { stage, sprite } = this.props;
    const { x, y } = sprite.attrs.coordinate;
    const newPoint = stage.apis.getPortCoordinate(spriteId, index);
    if (newPoint) {
      newPoint.x -= x;
      newPoint.y -= y;
    }
    return newPoint || point;
  };

  // 更新起始点坐标
  pointChangeHandle = (point: Point, prop: 'start' | 'end') => {
    const { sprite, stage } = this.props;
    const { props } = sprite;
    const newProps = {
      ...props,
      [prop]: { ...props[prop], ...point },
    };
    stage.apis.updateSpriteProps(sprite, newProps);
  };

  // 锚点移动放手时判断是否落在锚点上,记录在连接线精灵的端点属性里
  handleAnchorMouseUp = ({ sprite, index, e }) => {
    const { sprite } = this.props;
    const { props } = sprite;

    const newProps = { ...props };
    const port = getPortInfo(e);
    if (port) {
      const prop = index === 0 ? 'start' : 'end';
      newProps[prop] = { ...newProps[prop], port };
    }
    this.updateSprite(newProps);
  };

  • 其中连接桩位置计算的详细方法如下:

/**
 * 计算锚点真实坐标
 * @param params
 * @returns
 */
export const getPortCoordinate = (params: {
  stage: IStageApis;
  sprite: ISprite;
  meta: ISpriteMeta;
  pointIndex: number;
}) => {
  const { stage, sprite, meta, pointIndex } = params;
  const { ports } = meta;
  const { attrs } = sprite;
  if (ports) {
    const {
      getPoints,
      unit = PortUnitEnum.px,
      refer = PortReferEnum.sprite,
    } = ports;
    let { points = [] } = ports;
    if (getPoints) {
      points = getPoints({ sprite, stage } as any);
    }
    const info = { ...attrs.coordinate, ...attrs.size };
    let { x, y } = points[pointIndex];
    if (unit === PortUnitEnum.percent) {
      x = info.x + (info.width / 100) * x;
      y = info.y + (info.height / 100) * y;
    } else if (refer === PortReferEnum.sprite) {
      x = info.x + x;
      y = info.y + y;
    }
    let point: Point = { x, y };
    if (typeof attrs.angle === 'number' && attrs.angle !== 0) {
      const center = {
        x: info.x + info.width / 2,
        y: info.y + info.height / 2,
      };
      point = rotate(point, attrs.angle, center);
    }

    return point;
  }
  return null;
};


二、舞台侧

1. 在精灵容器中渲染精灵连接桩

(
  <Sprite
    key={sprite.id}
    x={attrs.coordinate.x}
    y={attrs.coordinate.y}
  >
    <SpriteComponent sprite={sprite} />
    <!-- 在精灵容器中加入锚点渲染器 -->
    <AnchorPoints sprite={sprite} stage={stage} active={active} />
    <!-- 在精灵容器中加入连接桩渲染器 -->
    <LinkPorts sprite={sprite} stage={stage} />
  </Sprite>
)

2. 连接桩渲染器

interface IProps {
  sprite: ISprite;
  stage: IStageApis;
}

export default class LinkPointsRender extends React.Component<IProps> {
  render() {
    const { sprite, stage } = this.props;
    const { registerSpriteMetaMap } = stage.store();
    const { id, type, attrs } = sprite;
    const meta = registerSpriteMetaMap[type];
    if (!meta || !meta.ports) {
      return null;
    }
    const info = {
      ...attrs.coordinate,
      ...attrs.size,
    };
    const { ports } = meta;
    const {
      getPoints,
      render,
      unit = PortUnitEnum.px,
      refer = PortReferEnum.sprite,
    } = ports;
    const radius = 6;
    let { points = [] } = ports;
    if (getPoints) {
      points = getPoints({ sprite, stage } as IContext);
    }
    if (render) {
      return render({ sprite, stage });
    }
    if (points.length === 0) {
      return null;
    }
    const jsonData = {
      spriteId: id,
      id: '',
      index: 0,
    };
    return (
      <>
        {points.map((point: any, i: number) => {
          let { x, y } = point;
          if (unit === PortUnitEnum.percent) {
            x = (info.width / 100) * x;
            y = (info.height / 100) * y;
          }
          return (
            <circle
              key={i}
              r={radius}
              cx={x}
              cy={y}
              fill="#fff"
              stroke="#000"
              strokeWidth="1"
              className={`link-port-point-container link-port-point__${type}`}
              style={{ cursor: 'pointer' }}
              id={`sprite-port__${id}__${i}`}
              data-port-json={JSON.stringify({
                ...jsonData,
                ...point,
                index: i,
                id: `sprite-port__${id}__${i}`,
              })}
            />
          );
        })}
      </>
    );
  }
}


3. 舞台在精灵发生变化时抛出事件(移动、缩放、旋转等)

const emitSpriteUpdateEvent = (updateSprite: ISprite) => {
  // 派发事件
  stage.apis.$event.emit(EventTypeEnum.UpdateSpriteList, { updateSprite });
};

const handleMove = (sprite) => {
  emitSpriteUpdateEvent(sprite);
};

const handleResize = (sprite) => {
  emitSpriteUpdateEvent(sprite);
};

const handleRotate = (sprite) => {
  emitSpriteUpdateEvent(sprite);
};

三、总结

本文介绍了连接线、连接桩的实现思路,不过此思路使用了发布订阅事件系统来通知连接线坐标更新,在同一个连接桩被非常多个连接线连接上时可能会发生一些卡顿。如果你有更好的设计思路,欢迎在评论区讨论~

系列文章汇总

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