在之前的系列文章中我们介绍了图形编辑器,基本的
移动
、缩放
、旋转
拖拽编辑能力,以及吸附、网格、辅助线等辅助编辑的能力。
本文会继续强化编辑能力:
- 连接线功能:在精灵上定义端口,可以用连接线把精灵彼此相连,当其中一个精灵发生变化时,连接线也会持续变化保持连接。
Demo体验链接:图形编辑器在线Demo
- 首先看效果图
一、精灵侧
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);
};
三、总结
本文介绍了连接线、连接桩的实现思路,不过此思路使用了发布订阅事件系统来通知连接线坐标更新,在同一个连接桩被非常多个连接线连接上时可能会发生一些卡顿。如果你有更好的设计思路,欢迎在评论区讨论~