svg实现图形编辑器系列五:辅助编辑锚点

1,514 阅读7分钟

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

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

  • 锚点功能,如圆角矩形调整圆角大小的锚点、扇形调整扇形角度的锚点等

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

  • 首先看图

1anchor1.gif

对圆角矩形、线段、扇形、折线、自由多边形等等形状都需要这样的辅助编辑点的能力,因此我们接下来会实现这个功能

1. 实现可拖动锚点组件

首先我们实现一个受控的坐标点组件,鼠标拖动的时候就会跟着鼠标移动。

看过之前系列文章的话,这里应该非常好理解,点的拖动也是通过mousedown、mousemove、mouseup三个鼠标事件控制,这里直接给出源码:

import React, { useState, useEffect, useRef } from 'react';

const defaultFun = () => '';

export interface Point {
  x: number;
  y: number;
}

interface IProps {
  x?: number;
  y?: number;
  radius?: number;
  stroke?: string;
  strokeWidth?: number;
  fill?: string;
  id?: string;
  className?: string;
  style?: Record<string, any>;
  onChange?: (point: Point) => void;
  onMouseDown?: (point: Point, e: React.MouseEvent) => void;
  onMouseMove?: (point: Point, e: MouseEvent) => void;
  onMouseUp?: (point: Point, e: MouseEvent) => void;
}
const initPoint: Point = { x: -1, y: -1 };

export default (props: IProps) => {
  const {
    x = 0,
    y = 0,
    radius = 4,
    stroke = '#fff',
    strokeWidth = '1',
    fill = '#1e7fff',
    className = '',
    style = {},
    onChange = defaultFun,
    onMouseDown: mouseDown = defaultFun,
    onMouseMove: mouseMove = defaultFun,
    onMouseUp: mouseUp = defaultFun,
    ...rest
  } = props;
  const [startPoint, setStartPoint] = useState(initPoint);
  const [startAnchorPoint, setStartAnchorPoint] = useState(initPoint);
  const [currentPoint, setCurrentPoint] = useState(initPoint);
  const [moving, setMoving] = useState(false);
  const pointRef = useRef<any>();

  const getStatePoint = (e: MouseEvent) => {
    const { pageX, pageY } = e;
    // 计算在鼠标相对锚点的坐标
    const point = {
      x: pageX - startPoint.x + startAnchorPoint.x,
      y: pageY - startPoint.y + startAnchorPoint.y,
    };
    return point;
  };
  // 鼠标拖动锚点
  const onMouseMove = (e: MouseEvent) => {
    const newPoint = getStatePoint(e);
    setCurrentPoint(newPoint);
    onChange(newPoint);
    mouseMove(newPoint, e);
  };

  // 鼠标抬起锚点
  const onMouseUp = (e: MouseEvent) => {
    document.removeEventListener('mousemove', onMouseMove, true);
    document.removeEventListener('mouseup', onMouseUp, true);
    setStartPoint(initPoint);
    setMoving(false);
    mouseUp(currentPoint, e);
  };
  // 鼠标按下锚点
  const onMouseDown = (e: React.MouseEvent) => {
    const { pageX, pageY } = e;
    setStartPoint({ x: pageX, y: pageY });
    setStartAnchorPoint({ x, y });
    setMoving(true);
    mouseDown({ x, y }, e);
  };

  useEffect(() => {
    if (moving) {
      document.addEventListener('mousemove', onMouseMove, true);
      document.addEventListener('mouseup', onMouseUp, true);
    }
    return () => {
      document.removeEventListener('mousemove', onMouseMove, true);
      document.removeEventListener('mouseup', onMouseUp, true);
    };
  }, [moving]);

  return (
    <circle
      ref={pointRef}
      style={{ cursor: 'pointer', ...style }}
      filter="drop-shadow(rgba(0, 0, 0, 0.4) 0 0 5)"
      {...rest}
      className={className}
      stroke={stroke}
      strokeWidth={strokeWidth}
      fill={fill}
      cx={x}
      cy={y}
      r={radius}
      onMouseDown={onMouseDown}
    />
  );
};


2. 精灵锚点设计

这里我们有两种实现思路:

思路1:精灵自己渲染锚点

直接在精灵组件中调用这个组件,监听鼠标事件,然后设置锚点的位置状态,这样就可以实现拖拽了;

**优点:**自由度高,可以对锚点做非常多定制功能;

缺点:

  1. 用户需要关心锚点组件本身,且自由度太高也可能导致锚点实现五花八门显得很乱;
  2. 在精灵旋转一定角度后,每次都要转换坐标系才能让锚点操作符合预期,否则会产生偏差;
  3. 位置转换工作每个精灵中都要重复做,重复率高,不易用;

思路2:系统在精灵容器中渲染

系统中我们对锚点进行抽象,在精灵的 meta 中提供配置,让用户通过少量配置就可以渲染出锚点。

渲染出锚点后,进行操作移动时,通过事件系统向外发布锚点更新事件,并将操作精灵id、处理后的鼠标坐标、操作的锚点索引等信息作为参数发布出去,写精灵的时候监听此事件;经过计算后更新状态即可。

如果想对锚点位置进行定制(如圆角矩形希望锚点仅出现在矩形左上角边框上),可以允许用户配置锚点时传入一个函数,返回值为锚点数组,这样就可以实现特殊锚点定制。

优点:

  1. 使用简单,最简单使用只需配置一个数组即可;特殊定制逻辑也方便使用;
  2. 系统帮处理精灵旋转带来的位置偏差这个复杂的转换;
  3. 通用锚点逻辑集成在系统里,用户不用做重复工作;

**缺点:**自由度没有直接调用高;

3. 锚点配置方法

  • 下面是几种形状的锚点配置方法
  • 扇形的锚点展示了定制锚点样式的能力
// 圆角矩形,锚点位置是根据圆角大小计算来的
export const RectRoundSpriteMeta: ISpriteMeta<IProps> = {
  type: 'RectRoundSprite',
  anchors: {
    getPoints: ({ sprite }) => {
      const { attrs } = sprite;
      const { width, height } = attrs.size;
      const { borderRadius = 0 } = sprite.props as IProps;
      const r = (borderRadius / 100) * Math.min(width, height);
      return [{ x: r, y: 0 }];
    },
  },
};


// 线段,锚点位置是根据起始点计算来的
export const LineSpriteMeta: ISpriteMeta<IProps> = {
  type: 'LineSprite',
  anchors: {
    getPoints: ({ sprite }: IContext) => {
      const { start, end } = sprite.props as IProps;
      return [{ ...start }, { ...end }];
    },
  },
};

// 扇形,锚点位置是根据扇形起始角度和半径计算来的
export const FanShapedSpriteMeta: ISpriteMeta<IProps> = {
  type: 'FanShapedSprite',
  anchors: {
    getPoints: ({ sprite }) => {
      const { attrs } = sprite;
      const { width, height } = attrs.size;
      const { startAngle, endAngle } = sprite.props as IProps;
      const rx = width / 2;
      const ry = height / 2;
      const startPoint = getPointByAngle(startAngle, rx, ry);
      const endPoint = getPointByAngle(endAngle, rx, ry);
      return [startPoint, endPoint];
    },
    // 定制锚点的样式
    pointRender: () => {
      const r = 4;
      return (
        <path
          fill={'orange'}
          stroke={'#fff'}
          transform="rotate(45)"
          filter={'drop-shadow(rgba(0, 0, 0, 0.32) 0 2.38213 7.1464'}
          d={`M${-r},${-r}  L${r},${-r} L${r},${r}  L${-r},${r}  Z`}
        />
      );
    },
  },
};


4. 精灵内监听锚点位置变化,更新组件状态

在舞台api中我们集成了事件监听和移除的方法

  • stage.apis.$event.on
  • stage.apis.$event.off

interface IProps extends IDefaultGraphicProps {
  borderRadius?: number;
}

export class RectRoundSprite extends BaseSprite<IProps> {
  componentDidMount() {
    // 添加事件监听
    this.props.stage.apis.$event.on(
      EventTypeEnum.SpriteAnchorPointChange,
      this.handleAnchorChange,
    );
  }

  componentWillUnmount() {
    // 移除事件监听
    this.props.stage.apis.$event.off(
      EventTypeEnum.SpriteAnchorPointChange,
      this.handleAnchorChange,
    );
  }

  // 处理锚点移动事件, 更新状态(圆角大小)
  handleAnchorChange = ({ point, sprite: changeSprite }: any) => {
    const { sprite, stage } = this.props;
    // 判断现在操作的锚点是否属于当前精灵
    if (changeSprite.id !== this.props.sprite.id) {
      return;
    }
    // 根据锚点最新位置计算圆角大小
    const { attrs } = sprite;
    const { width, height } = attrs.size;
    const len = Math.min(width, height);
    const x = Math.max(0, Math.min(len / 2, point.x));
    const borderRadius = (100 * x) / len;

    // 设置组件的属性
    stage.apis.updateSpriteProps(sprite, { borderRadius });
  };

  render() {
    return ...;
  }
}

关于发布订阅模式,不太熟悉的同学可以通过我写的另一篇文章了解:

5. 舞台侧精灵锚点放置的位置

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

6. 精灵锚点的实现:

  • 我们在这个组件中调用了AnchorPoint锚点组件渲染出精灵配置的锚点,这里包含多种配置方式和样式定制方式
  • 监听AnchorPoint锚点组件的鼠标事件,然后通过getOriginMousePointInSprite方法处理原始鼠标点的坐标,计算出鼠标点在旋转前坐标系下的坐标,用户直接取用即可;
  • 通过事件系统发布锚点位置更新事件,并把鼠标坐标等信息放入参数中,供用户调用
import React from 'react';
import { AnchorPoint } from 'base-components';
import type { ISprite, IStageApis, IContext, Point } from '../interface';
import { PortReferEnum, EventTypeEnum } from '../interface';

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

interface IState {
  anchorMoving: boolean;
}

const defaultPointAttrs = {
  stroke: '#fff',
  strokeWidth: 1,
  fill: '#1e7fff',
  path: 'M10,10 a5 5 0 1 0 0.00000001 0',
};
/**
 * 计算鼠标在转回为0度的坐标系下的坐标
 * @param attrs 精灵的属性
 * @returns
 */
export const getOriginMousePointInSprite = (
  e: MouseEvent | React.MouseEvent,
  attrs: ISpriteAttrs,
  stage: IStageApis,
) => {
  const { pageX, pageY } = e;
  const { coordinate, scale = 1 } = stage.store();
  const { x, y } = coordinate;
  const mousePointInStage = { x: (pageX - x) / scale, y: (pageY - y) / scale };
  if (!attrs.angle) {
    return mousePointInStage;
  }
  const center = getSpriteCenter(attrs);
  return rotate(mousePointInStage, -attrs.angle, center);
};


export default class AnchorPointsRender extends React.Component<
  IProps,
  IState
> {
  state: Readonly<IState> = {
    anchorMoving: false,
  };

  pointChangeHandle = (mousePoint: Point, e: MouseEvent, i: number) => {
    if (!this.state.anchorMoving) {
      this.setState({ anchorMoving: true });
    }
    this.publishEvent(mousePoint, e, i, EventTypeEnum.SpriteAnchorPointChange);
  };

  mousePointInStage = (point: Point, stage: IStageApis) => {
    const { size, scale = 1 } = stage.store();
    console.log('sprite:', scale);
    const center = { x: size.width / 2, y: size.height / 2 };
    const mousePoint = { ...point };
    mousePoint.x = center.x + (mousePoint.x - center.x) / scale;
    mousePoint.y = center.y + (mousePoint.y - center.y) / scale;
    return mousePoint;
  };

  publishEvent = (
    mousePoint: Point,
    e: React.MouseEvent | MouseEvent,
    index: number,
    eventType: EventTypeEnum,
  ) => {
    const { sprite, stage } = this.props;
    const { x, y } = sprite.attrs.coordinate;
    // 计算在不旋转的情况下的坐标点
    const point = getOriginMousePointInSprite(e, sprite.attrs, stage);
    // 派发锚点变化事件
    stage.apis.$event.emit(eventType, {
      point: {
        ...mousePoint,
        x: point.x - x,
        y: point.y - y,
      },
      mousePoint,
      index,
      e,
      sprite,
      stage,
    });
  };

  handleMouseDown = (mousePoint: Point, e: React.MouseEvent, i: number) => {
    e.stopPropagation();
    this.publishEvent(
      mousePoint,
      e,
      i,
      EventTypeEnum.SpriteAnchorPointMouseDown,
    );
  };

  handleMouseUp = (mousePoint: Point, e: MouseEvent, i: number) => {
    this.setState({ anchorMoving: false });
    this.publishEvent(mousePoint, e, i, EventTypeEnum.SpriteAnchorPointMouseUp);
  };

  render() {
    const { sprite, stage, active } = this.props;
    const { registerSpriteMetaMap } = stage.store();
    const { id, type, attrs } = sprite;
    const meta = registerSpriteMetaMap[type];
    if (!meta || !meta.anchors) {
      return null;
    }
    const { anchors } = meta;
    const {
      getPoints,
      refer = PortReferEnum.sprite,
      pointAttrs = {},
      pointRender,
      moveHide = false,
    } = anchors;
    const { anchorMoving } = this.state;
    if (!active) {
      return null;
    }
    let { points = [] } = anchors;
    if (getPoints) {
      points = getPoints({ sprite, stage } as IContext);
    }
    const anchorPointAttrs = {
      filter: 'drop-shadow(rgba(0, 0, 0, 0.4) 0 0 5)',
      ...defaultPointAttrs,
      ...pointAttrs,
    };
    // 自定义渲染锚点
    let pointTsx: React.ReactNode = null;
    if (pointRender) {
      pointTsx = pointRender?.({ sprite, stage });
    }
    return (
      <g
        style={{
          display: anchorMoving && moveHide ? 'none' : '',
          pointerEvents: anchorMoving && moveHide ? 'none' : undefined,
        }}>
        {points.map((point: any, i: number) => {
          const { x, y } = point;
          return (
            <g key={i}>
              {pointTsx && (
                <g
                  style={{ cursor: 'pointer' }}
                  {...anchorPointAttrs}
                  transform={`translate(${x},${y})`}>
                  {pointTsx}
                </g>
              )}
              <AnchorPoint
                {...anchorPointAttrs}
                x={x}
                y={y}
                className={`anchor-point-container anchor-point__${type} ${anchorPointAttrs.className || ''}`}
                style={{ opacity: pointTsx ? 0 : undefined }}
                data-sprite-id={id}
                data-index={i}
                onMouseDown={(p: Point, e: React.MouseEvent) => this.handleMouseDown(p, e, i)}
                onMouseUp={(p: Point, e: MouseEvent) => this.handleMouseUp(p, e, i)}
                onMouseMove={(p: Point, e: MouseEvent) => this.pointChangeHandle(p, e, i)}
              />
            </g>
          );
        })}
      </g>
    );
  }
}


总结

至此,我们就实现了方便好用,且支持定制的锚点功能了,接下来我们看一看效果演示:

1anchor2.gif

下面的文章我们将介绍 连接线、连接桩 功能。

系列文章汇总

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