refactor: connector functionality

This commit is contained in:
Mark Mankarious
2023-08-15 16:24:39 +01:00
parent 07dc1d163c
commit b2bf329e84
22 changed files with 489 additions and 149 deletions

View File

@@ -27,7 +27,7 @@
"consistent-return": [0],
"react/no-unused-prop-types": ["warn"],
"react/require-default-props": [0],
"no-param-reassign": ["error", { "props": true, "ignorePropertyModificationsFor": ["draftState"] }],
"no-param-reassign": ["error", { "props": true, "ignorePropertyModificationsFor": ["draftState", "draft"] }],
"arrow-body-style": ["error", "always"]
},
"ignorePatterns": [

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { Coords } from 'src/types';
interface Props {
position: Coords;
radius?: number;
}
export const Circle = ({
position,
radius,
...rest
}: Props & React.SVGProps<SVGCircleElement>) => {
return <circle cx={position.x} cy={position.y} r={radius} {...rest} />;
};

View File

@@ -1,82 +1,80 @@
import React, { useMemo } from 'react';
import { Box } from '@mui/material';
import { Node, Coords, Connector as ConnectorI } from 'src/types';
import { Box, useTheme } from '@mui/material';
import { Connector as ConnectorI } from 'src/types';
import { UNPROJECTED_TILE_SIZE } from 'src/config';
import {
findPath,
getBoundingBox,
getBoundingBoxSize,
sortByPosition,
CoordsUtils
} from 'src/utils';
import { getAnchorPosition } from 'src/utils';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
import { Circle } from 'src/components/Circle/Circle';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
interface Props {
connector: ConnectorI;
fromNode: Node;
toNode: Node;
}
// The boundaries of the search area for the pathfinder algorithm
// is the grid that encompasses the two nodes + the offset below.
const BOUNDS_OFFSET: Coords = { x: 3, y: 3 };
export const Connector = ({ fromNode, toNode, connector }: Props) => {
export const Connector = ({ connector }: Props) => {
const theme = useTheme();
const zoom = useUiStateStore((state) => {
return state.zoom;
});
const nodes = useSceneStore((state) => {
return state.nodes;
});
const { getTilePosition } = useGetTilePosition();
const route = useMemo(() => {
const searchArea = getBoundingBox(
[fromNode.position, toNode.position],
BOUNDS_OFFSET
);
const searchAreaSize = getBoundingBoxSize(searchArea);
const sorted = sortByPosition(searchArea);
const topLeftTile = { x: sorted.highX, y: sorted.highY };
const positionsNormalisedFromSearchArea = {
from: CoordsUtils.subtract(topLeftTile, fromNode.position),
to: CoordsUtils.subtract(topLeftTile, toNode.position)
};
const connectorRoute = findPath({
from: positionsNormalisedFromSearchArea.from,
to: positionsNormalisedFromSearchArea.to,
gridSize: searchAreaSize
});
const pathString = useMemo(() => {
const unprojectedTileSize = UNPROJECTED_TILE_SIZE * zoom;
const path = connectorRoute.reduce((acc, tile) => {
return connector.path.tiles.reduce((acc, tile) => {
return `${acc} ${tile.x * unprojectedTileSize},${
tile.y * unprojectedTileSize
}`;
}, '');
}, [zoom, connector.path.tiles]);
const position = getTilePosition({
tile: topLeftTile
const pathOrigin = useMemo(() => {
return getTilePosition({ tile: connector.path.origin });
}, [getTilePosition, connector.path.origin]);
const anchorPositions = useMemo(() => {
const unprojectedTileSize = UNPROJECTED_TILE_SIZE * zoom;
return connector.anchors.map((anchor) => {
const position = getAnchorPosition({ anchor, nodes });
return {
x: (connector.path.origin.x - position.x) * unprojectedTileSize,
y: (connector.path.origin.y - position.y) * unprojectedTileSize
};
});
return { path, searchAreaSize, topLeftTile, position };
}, [fromNode.position, toNode.position, zoom, getTilePosition]);
}, [connector.path.origin, connector.anchors, zoom, nodes]);
return (
<Box
id="connector"
sx={{
position: 'absolute',
left: route.position.x,
top: route.position.y
left: pathOrigin.x,
top: pathOrigin.y
}}
>
<IsoTileArea tileArea={route.searchAreaSize} zoom={zoom} fill="none">
<IsoTileArea tileArea={connector.path.areaSize} zoom={zoom} fill="none">
<polyline
points={route.path}
points={pathString}
stroke={connector.color}
strokeWidth={10 * zoom}
fill="none"
/>
{anchorPositions.map((anchor) => {
return (
<Circle
position={anchor}
radius={10 * zoom}
stroke={theme.palette.common.black}
fill={theme.palette.common.white}
strokeWidth={4 * zoom}
/>
);
})}
</IsoTileArea>
</Box>
);

View File

@@ -40,6 +40,14 @@ export const DebugUtils = () => {
title="Mouse"
value={`${mouse.position.tile.x}, ${mouse.position.tile.y}`}
/>
<LineItem
title="Mouse down"
value={
mouse.mousedown
? `${mouse.mousedown.tile.x}, ${mouse.mousedown.tile.y}`
: 'null'
}
/>
<LineItem
title="Scroll"
value={`${scroll.position.x}, ${scroll.position.y}`}

View File

@@ -106,20 +106,11 @@ export const Renderer = () => {
</SceneLayer>
<SceneLayer>
{scene.connectors.map((connector) => {
const connectorNodes = getNodesFromIds([
connector.from,
connector.to
]);
return (
<Connector
key={connector.id}
connector={connector}
fromNode={connectorNodes[0]}
toNode={connectorNodes[1]}
/>
);
return <Connector key={connector.id} connector={connector} />;
})}
{mode.type === 'CONNECTOR' && mode.connector && (
<Connector connector={mode.connector} />
)}
</SceneLayer>
<SceneLayer>
{nodes.map((node) => {

View File

@@ -7,7 +7,8 @@ import {
ZoomOut as ZoomOutIcon,
NearMe as NearMeIcon,
CenterFocusStrong as CenterFocusStrongIcon,
Add as AddIcon
Add as AddIcon,
EastOutlined as ConnectorIcon
} from '@mui/icons-material';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useDiagramUtils } from 'src/hooks/useDiagramUtils';
@@ -52,6 +53,18 @@ export const ToolMenu = () => {
}}
size={theme.customVars.toolMenu.height}
/>
<IconButton
name="Connector"
Icon={<ConnectorIcon />}
onClick={() => {
uiStateStoreActions.setMode({
type: 'CONNECTOR',
connector: null,
showCursor: true
});
}}
size={theme.customVars.toolMenu.height}
/>
<IconButton
name="Select"
Icon={<NearMeIcon />}

View File

@@ -1,4 +1,4 @@
import { Size } from 'src/types';
import { Size, Coords } from 'src/types';
import { customVars } from './styles/theme';
export const UNPROJECTED_TILE_SIZE = 100;
@@ -7,8 +7,17 @@ export const TILE_PROJECTION_MULTIPLIERS: Size = {
height: 0.819
};
export const DEFAULT_COLOR = customVars.diagramPalette.blue;
export const CONNECTOR_DEFAULTS = {
width: 4
interface ConnectorDefaults {
width: number;
searchOffset: Coords;
}
export const CONNECTOR_DEFAULTS: ConnectorDefaults = {
width: 4,
// The boundaries of the search area for the pathfinder algorithm
// is the grid that encompasses the two nodes + the offset below.
searchOffset: { x: 3, y: 3 }
};
export const NODE_DEFAULTS = {
label: '',

View File

@@ -56,9 +56,14 @@ export const CustomNode = () => {
connectors: [
{
id: 'connector1',
from: 'database',
to: 'server',
label: 'connection'
anchors: [
{
nodeId: 'server'
},
{
nodeId: 'database'
}
]
}
],
groups: [

View File

@@ -7,14 +7,7 @@ export const DebugTools = () => {
<Isoflow
initialScene={{
icons,
connectors: [
{
id: 'connector1',
from: 'database',
to: 'server',
label: 'connection'
}
],
connectors: [],
groups: [
{
id: 'group1',
@@ -36,7 +29,7 @@ export const DebugTools = () => {
id: 'database',
label: 'Transactions',
labelHeight: 40,
iconId: 'server',
iconId: 'block',
position: {
x: 5,
y: 3

View File

@@ -0,0 +1,87 @@
import { v4 as uuid } from 'uuid';
import { InteractionReducer } from 'src/types';
import {
filterNodesByTile,
connectorInputToConnector,
getConnectorPath
} from 'src/utils';
export const Connector: InteractionReducer = {
type: 'CONNECTOR',
mousemove: (draftState) => {
if (
draftState.mode.type !== 'CONNECTOR' ||
!draftState.mode.connector?.anchors[0]
)
return;
const itemsAtTile = filterNodesByTile({
tile: draftState.mouse.position.tile,
nodes: draftState.scene.nodes
});
if (itemsAtTile.length > 0) {
const node = itemsAtTile[0];
draftState.mode.connector.anchors[1] = {
type: 'NODE',
id: node.id
};
} else {
draftState.mode.connector.anchors[1] = {
type: 'TILE',
coords: draftState.mouse.position.tile
};
}
draftState.mode.connector.path = getConnectorPath({
anchors: draftState.mode.connector.anchors,
nodes: draftState.scene.nodes
});
},
mousedown: (draftState) => {
if (draftState.mode.type !== 'CONNECTOR') return;
const itemsAtTile = filterNodesByTile({
tile: draftState.mouse.position.tile,
nodes: draftState.scene.nodes
});
if (itemsAtTile.length > 0) {
const node = itemsAtTile[0];
draftState.mode.connector = connectorInputToConnector(
{
id: uuid(),
anchors: [{ nodeId: node.id }, { nodeId: node.id }]
},
draftState.scene.nodes
);
return;
}
draftState.mode.connector = connectorInputToConnector(
{
id: uuid(),
anchors: [
{ tile: draftState.mouse.position.tile },
{ tile: draftState.mouse.position.tile }
]
},
draftState.scene.nodes
);
},
mouseup: (draftState) => {
if (draftState.mode.type !== 'CONNECTOR') return;
if (
draftState.mode.connector &&
draftState.mode.connector.anchors.length >= 2
) {
draftState.scene.connectors.push(draftState.mode.connector);
}
draftState.mode.connector = null;
}
};

View File

@@ -1,3 +1,4 @@
import { produce } from 'immer';
import { CoordsUtils } from 'src/utils';
import { InteractionReducer } from 'src/types';
@@ -17,18 +18,23 @@ export const DragItems: InteractionReducer = {
!CoordsUtils.isEqual(draftState.mouse.delta.tile, CoordsUtils.zero())
) {
// User has moved tile since the last mouse event
draftState.mode.items.forEach((node) => {
const nodeIndex = draftState.scene.nodes.findIndex((sceneNode) => {
return sceneNode.id === node.id;
const newScene = draftState.mode.items.reduce((acc, node) => {
return produce(acc, (draft) => {
const afterNodeUpdates = draftState.sceneActions.updateNode(
node.id,
{
position: draftState.mouse.position.tile
},
acc
);
draft.nodes = afterNodeUpdates.nodes;
draft.connectors = afterNodeUpdates.connectors;
});
}, draftState.scene);
if (nodeIndex === -1) return;
draftState.scene.nodes[nodeIndex].position =
draftState.mouse.position.tile;
draftState.contextMenu = null;
});
draftState.scene = newScene;
draftState.contextMenu = null;
}
},
mouseup: (draftState) => {

View File

@@ -9,13 +9,15 @@ import { Pan } from './reducers/Pan';
import { Cursor } from './reducers/Cursor';
import { Lasso } from './reducers/Lasso';
import { PlaceElement } from './reducers/PlaceElement';
import { Connector } from './reducers/Connector';
const reducers: { [k in string]: InteractionReducer } = {
CURSOR: Cursor,
DRAG_ITEMS: DragItems,
PAN: Pan,
LASSO: Lasso,
PLACE_ELEMENT: PlaceElement
PLACE_ELEMENT: PlaceElement,
CONNECTOR: Connector
};
export const useInteractionManager = () => {
@@ -47,9 +49,11 @@ export const useInteractionManager = () => {
const sceneActions = useSceneStore((state) => {
return state.actions;
});
const scene = useSceneStore(({ nodes, connectors, groups, icons }) => {
return { nodes, connectors, groups, icons };
});
const scene = useSceneStore(
({ nodes, connectors, groups, icons, actions }) => {
return { nodes, connectors, groups, icons, actions };
}
);
const rendererSize = useUiStateStore((state) => {
return state.rendererSize;
});

View File

@@ -3,7 +3,7 @@ import { createStore, useStore } from 'zustand';
import { produce } from 'immer';
import { Scene, SceneActions } from 'src/types';
import { sceneInput } from 'src/validation/scene';
import { sceneInputtoScene } from 'src/utils';
import { sceneInputtoScene, getItemById, getConnectorPath } from 'src/utils';
interface Actions {
actions: SceneActions;
@@ -29,21 +29,28 @@ const initialState = () => {
updateScene: (scene) => {
set(scene);
},
updateNode: (id, updates) => {
const { nodes } = get();
const nodeIndex = nodes.findIndex((node) => {
return node.id === id;
updateNode: (id, updates, scene) => {
return produce(scene ?? get(), (draftState) => {
const { item: node, index } = getItemById(draftState.nodes, id);
draftState.nodes[index] = {
...node,
...updates
};
draftState.connectors.forEach((connector, i) => {
const needsUpdate = connector.anchors.find((anchor) => {
return anchor.type === 'NODE' && anchor.id === id;
});
if (needsUpdate) {
draftState.connectors[i].path = getConnectorPath({
anchors: connector.anchors,
nodes: draftState.nodes
});
}
});
});
if (nodeIndex === -1) {
return;
}
const newNodes = produce(nodes, (draftState) => {
draftState[nodeIndex] = { ...draftState[nodeIndex], ...updates };
});
set({ nodes: newNodes });
}
}
};

View File

@@ -43,8 +43,14 @@ export const scene: SceneInput = {
}
],
connectors: [
{ id: 'connector1', label: 'Connector1', from: 'node1', to: 'node2' },
{ id: 'connector2', label: 'Connector2', from: 'node2', to: 'node3' }
{
id: 'connector1',
anchors: [{ nodeId: 'node1' }, { nodeId: 'node2' }]
},
{
id: 'connector2',
anchors: [{ nodeId: 'node2' }, { nodeId: 'node3' }]
}
],
groups: [{ id: 'group1', nodeIds: ['node1', 'node2'] }]
};

View File

@@ -2,6 +2,7 @@ import z from 'zod';
import {
iconInput,
nodeInput,
connectorAnchorInput,
connectorInput,
groupInput
} from 'src/validation/sceneItems';
@@ -9,6 +10,7 @@ import { sceneInput } from 'src/validation/scene';
export type IconInput = z.infer<typeof iconInput>;
export type NodeInput = z.infer<typeof nodeInput>;
export type ConnectorAnchorInput = z.infer<typeof connectorAnchorInput>;
export type ConnectorInput = z.infer<typeof connectorInput>;
export type GroupInput = z.infer<typeof groupInput>;
export type SceneInput = z.infer<typeof sceneInput>;

View File

@@ -1,4 +1,4 @@
import { Coords } from './common';
import { Coords, Size } from './common';
import { IconInput, SceneInput } from './inputs';
export enum TileOriginEnum {
@@ -26,13 +26,26 @@ export interface Node {
isSelected: boolean;
}
export type ConnectorAnchor =
| {
type: 'NODE';
id: string;
}
| {
type: 'TILE';
coords: Coords;
};
export interface Connector {
type: SceneItemTypeEnum.CONNECTOR;
id: string;
label: string;
color: string;
from: string;
to: string;
anchors: ConnectorAnchor[];
path: {
tiles: Coords[];
origin: Coords;
areaSize: Size;
};
}
export interface Group {
@@ -46,15 +59,15 @@ export type SceneItem = Node | Connector | Group;
export type Icon = IconInput;
export interface SceneActions {
setScene: (scene: SceneInput) => void;
updateScene: (scene: Scene) => void;
updateNode: (id: string, updates: Partial<Node>, scene?: Scene) => Scene;
}
export type Scene = {
nodes: Node[];
connectors: Connector[];
groups: Group[];
icons: IconInput[];
};
export interface SceneActions {
setScene: (scene: SceneInput) => void;
updateScene: (scene: Scene) => void;
updateNode: (id: string, updates: Partial<Node>) => void;
}

View File

@@ -1,5 +1,5 @@
import { Coords, Size } from './common';
import { SceneItem } from './scene';
import { SceneItem, Connector } from './scene';
import { IconInput } from './inputs';
export enum ItemControlsTypeEnum {
@@ -73,19 +73,26 @@ export interface DragItemsMode {
items: SceneItem[];
}
export interface PlaceElement {
export interface PlaceElementMode {
type: 'PLACE_ELEMENT';
showCursor: boolean;
icon: IconInput | null;
}
export interface ConnectorMode {
type: 'CONNECTOR';
showCursor: boolean;
connector: Connector | null;
}
export type Mode =
| InteractionsDisabled
| CursorMode
| PanMode
| DragItemsMode
| LassoMode
| PlaceElement;
| PlaceElementMode
| ConnectorMode;
// End mode types
export type ContextMenu =

View File

@@ -7,9 +7,13 @@ import {
Scene,
Node,
Connector,
Group
Group,
ConnectorAnchorInput,
ConnectorAnchor,
Coords
} from 'src/types';
import { NODE_DEFAULTS, DEFAULT_COLOR } from 'src/config';
import { getConnectorPath } from './renderer';
export const nodeInputToNode = (nodeInput: NodeInput): Node => {
return {
@@ -33,16 +37,40 @@ export const groupInputToGroup = (groupInput: GroupInput): Group => {
};
};
const connectorAnchorInputToConnectorAnchor = (
anchor: ConnectorAnchorInput
): ConnectorAnchor => {
if (anchor.nodeId) {
return {
type: 'NODE',
id: anchor.nodeId
};
}
return {
type: 'TILE',
coords: anchor.tile as Coords
};
};
export const connectorInputToConnector = (
connectorInput: ConnectorInput
connectorInput: ConnectorInput,
nodes: Node[]
): Connector => {
const anchors = connectorInput.anchors
.map((anchor) => {
return connectorAnchorInputToConnectorAnchor(anchor);
})
.filter((anchor) => {
return anchor !== null;
});
return {
type: SceneItemTypeEnum.CONNECTOR,
id: connectorInput.id,
label: connectorInput.label ?? '',
color: connectorInput.color ?? DEFAULT_COLOR,
from: connectorInput.from,
to: connectorInput.to
anchors,
path: getConnectorPath({ anchors, nodes })
};
};
@@ -56,7 +84,7 @@ export const sceneInputtoScene = (sceneInput: SceneInput): Scene => {
});
const connectors = sceneInput.connectors.map((connectorInput) => {
return connectorInputToConnector(connectorInput);
return connectorInputToConnector(connectorInput, nodes);
});
return {
@@ -79,15 +107,38 @@ export const nodeToNodeInput = (node: Node): NodeInput => {
};
};
export const connectorAnchorToConnectorAnchorInput = (
anchor: ConnectorAnchor
): ConnectorAnchorInput | null => {
switch (anchor.type) {
case 'NODE':
return {
nodeId: anchor.id
};
case 'TILE':
return {
tile: anchor.coords
};
default:
return null;
}
};
export const connectorToConnectorInput = (
connector: Connector
): ConnectorInput => {
const anchors = connector.anchors
.map((anchor) => {
return connectorAnchorToConnectorAnchorInput(anchor);
})
.filter((anchor): anchor is ConnectorAnchorInput => {
return !!anchor;
});
return {
id: connector.id,
label: connector.label,
from: connector.from,
to: connector.to,
color: connector.color
color: connector.color,
anchors
};
};
@@ -102,7 +153,8 @@ export const groupToGroupInput = (group: Group): GroupInput => {
export const sceneToSceneInput = (scene: Scene): SceneInput => {
const nodes: NodeInput[] = scene.nodes.map(nodeInputToNode);
const connectors: ConnectorInput[] = scene.connectors.map(
connectorToConnectorInput
connectorToConnectorInput,
nodes
);
const groups: GroupInput[] = scene.groups.map(groupToGroupInput);

View File

@@ -3,10 +3,24 @@ import {
UNPROJECTED_TILE_SIZE,
ZOOM_INCREMENT,
MAX_ZOOM,
MIN_ZOOM
MIN_ZOOM,
CONNECTOR_DEFAULTS
} from 'src/config';
import { Coords, TileOriginEnum, Node, Size, Scroll, Mouse } from 'src/types';
import { CoordsUtils, clamp, roundToOneDecimalPlace } from 'src/utils';
import {
Coords,
TileOriginEnum,
Node,
Size,
Scroll,
Mouse,
ConnectorAnchor
} from 'src/types';
import {
CoordsUtils,
clamp,
roundToOneDecimalPlace,
findPath
} from 'src/utils';
interface GetProjectedTileSize {
zoom: number;
@@ -272,3 +286,93 @@ export const getMouse = ({
return nextMouse;
};
export function getItemById<T extends { id: string }>(
items: T[],
id: string
): { item: T; index: number } {
const index = items.findIndex((item) => {
return item.id === id;
});
if (index === -1) {
throw new Error(`Item with id ${id} not found.`);
}
return { item: items[index], index };
}
interface GetAnchorPositions {
anchor: ConnectorAnchor;
nodes: Node[];
}
export const getAnchorPosition = ({
anchor,
nodes
}: GetAnchorPositions): Coords => {
if (anchor.type === 'NODE') {
const { item: node } = getItemById(nodes, anchor.id);
return node.position;
}
return anchor.coords;
};
interface NormalisePositionFromOrigin {
position: Coords;
origin: Coords;
}
export const normalisePositionFromOrigin = ({
position,
origin
}: NormalisePositionFromOrigin) => {
return CoordsUtils.subtract(origin, position);
};
interface GetConnectorPath {
anchors: ConnectorAnchor[];
nodes: Node[];
}
export const getConnectorPath = ({
anchors,
nodes
}: GetConnectorPath): { tiles: Coords[]; origin: Coords; areaSize: Size } => {
if (anchors.length < 2)
throw new Error(
`Connector needs at least two anchors (receieved: ${anchors.length})`
);
const anchorPositions = anchors.map((anchor) => {
return getAnchorPosition({ anchor, nodes });
});
const searchArea = getBoundingBox(
anchorPositions,
CONNECTOR_DEFAULTS.searchOffset
);
const searchAreaSize = getBoundingBoxSize(searchArea);
const sorted = sortByPosition(searchArea);
const origin = { x: sorted.highX, y: sorted.highY };
const positionsNormalisedFromSearchArea = anchorPositions.map((position) => {
return normalisePositionFromOrigin({ position, origin });
});
const tiles = positionsNormalisedFromSearchArea.reduce<Coords[]>(
(acc, position, i) => {
if (i === 0) return [position];
const prev = positionsNormalisedFromSearchArea[i - 1];
const path = findPath({
from: prev,
to: position,
gridSize: searchAreaSize
});
return [...acc, ...path];
},
[]
);
return { tiles, origin, areaSize: searchAreaSize };
};

View File

@@ -1,5 +1,10 @@
import z from 'zod';
const coords = z.object({
x: z.number(),
y: z.number()
});
export const iconInput = z.object({
id: z.string(),
name: z.string(),
@@ -13,18 +18,29 @@ export const nodeInput = z.object({
labelHeight: z.number().optional(),
color: z.string().optional(),
iconId: z.string(),
position: z.object({
x: z.number(),
y: z.number()
})
position: coords
});
export const connectorAnchorInput = z
.object({
nodeId: z.string(),
tile: coords
})
.partial()
.superRefine((data, ctx) => {
if (!data.nodeId && !data.tile) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['connectorAnchor'],
message: 'Connector anchor needs either a nodeId or tile coords.'
});
}
});
export const connectorInput = z.object({
id: z.string(),
label: z.string().nullable(),
color: z.string().optional(),
from: z.string(),
to: z.string()
anchors: z.array(connectorAnchorInput)
});
export const groupInput = z.object({

View File

@@ -36,9 +36,7 @@ describe('scene validation works correctly', () => {
const { nodes } = scene;
const invalidConnector = {
id: 'invalidConnector',
from: 'node1',
to: 'invalidNode',
label: null
anchors: [{ nodeId: 'node1' }, { nodeId: 'invalidNode' }]
};
const connectors: ConnectorInput[] = [
...scene.connectors,

View File

@@ -19,14 +19,20 @@ export const findInvalidConnector = (
nodes: NodeInput[]
) => {
return connectors.find((con) => {
const fromNode = nodes.find((node) => {
return con.from === node.id;
});
const toNode = nodes.find((node) => {
return con.to === node.id;
const invalidAnchor = con.anchors.find((anchor) => {
if (
anchor.nodeId &&
!nodes.find((node) => {
return node.id === anchor.nodeId;
})
) {
return true;
}
return false;
});
return Boolean(!fromNode || !toNode);
return Boolean(invalidAnchor);
});
};