在之前的系列文章中我们介绍了图形编辑器,基本的
移动
、缩放
、旋转
拖拽编辑能力,以及吸附、网格、辅助线等辅助编辑的能力。
本文会继续强化编辑能力:
- 锚点功能,如圆角矩形调整圆角大小的锚点、扇形调整扇形角度的锚点等
Demo体验链接:图形编辑器在线Demo
- 首先看图
对圆角矩形、线段、扇形、折线、自由多边形等等形状都需要这样的辅助编辑点的能力,因此我们接下来会实现这个功能
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:精灵自己渲染锚点
直接在精灵组件中调用这个组件,监听鼠标事件,然后设置锚点的位置状态,这样就可以实现拖拽了;
**优点:**自由度高,可以对锚点做非常多定制功能;
缺点:
- 用户需要关心锚点组件本身,且自由度太高也可能导致锚点实现五花八门显得很乱;
- 在精灵旋转一定角度后,每次都要转换坐标系才能让锚点操作符合预期,否则会产生偏差;
- 位置转换工作每个精灵中都要重复做,重复率高,不易用;
思路2:系统在精灵容器中渲染
系统中我们对锚点进行抽象,在精灵的 meta
中提供配置,让用户通过少量配置就可以渲染出锚点。
渲染出锚点后,进行操作移动时,通过事件系统向外发布锚点更新事件,并将操作精灵id、处理后的鼠标坐标、操作的锚点索引等信息作为参数发布出去,写精灵的时候监听此事件;经过计算后更新状态即可。
如果想对锚点位置进行定制(如圆角矩形希望锚点仅出现在矩形左上角边框上),可以允许用户配置锚点时传入一个函数,返回值为锚点数组,这样就可以实现特殊锚点定制。
优点:
- 使用简单,最简单使用只需配置一个数组即可;特殊定制逻辑也方便使用;
- 系统帮处理精灵旋转带来的位置偏差这个复杂的转换;
- 通用锚点逻辑集成在系统里,用户不用做重复工作;
**缺点:**自由度没有直接调用高;
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>
);
}
}
总结
至此,我们就实现了方便好用,且支持定制的锚点功能了,接下来我们看一看效果演示:
下面的文章我们将介绍 连接线、连接桩
功能。