Skip to content

Instantly share code, notes, and snippets.

@rolangom
Last active November 11, 2021 21:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rolangom/8427b9560d6299aeb3d6a7bf0d32260e to your computer and use it in GitHub Desktop.
Save rolangom/8427b9560d6299aeb3d6a7bf0d32260e to your computer and use it in GitHub Desktop.
GTFlowEngine
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import createStore from "./GTState";
const DUMMY_FUNC = (...args: any[]) => {};
const RESIZER_SIZE = 10;
const PORT_RADIUS = 6;
const PADDING_RIGHT = 50;
const PORT_LABEL_FONT_SIZE = 7;
const portCircleWidth = 2;
const RESIZER_BORDER_WIDTH = 1;
const BORDER_WIDTH = 5;
const HALF = 0.5;
const OPACITY_50 = HALF;
const OPACITY_75 = 0.75
const OPACITY_25 = 0.25
const DEFAULT_XY: [number, number] = [0,0];
const X = 0;
const Y = 1;
const W = 0;
const H = 1;
const emptyObj = {};
const useSVGStore = createStore<SVGSVGElement|undefined>(undefined);
interface IMaybeProps {
visible: boolean,
children: React.ReactNode,
}
function Maybe(props: IMaybeProps) {
return props.visible ? <>{props.children}</> : null;
}
function getMouseEventPosition(event: React.MouseEvent<any, MouseEvent>|MouseEvent, svg: SVGSVGElement|undefined, maybePt: DOMPoint|undefined): [number, number, DOMPoint|undefined] {
if (!svg) return [event.clientX, event.clientY, undefined];
const pt = maybePt || svg.createSVGPoint();
pt.x = event.clientX; pt.y = event.clientY;
const newPt = pt.matrixTransform(svg.getScreenCTM()?.inverse());
return [newPt.x, newPt.y, newPt];
}
function startDrag(
event: React.MouseEvent<any, MouseEvent>,
initialPosition: [number, number],
setPosition: React.Dispatch<React.SetStateAction<[number, number]>>,
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>,
svg: SVGSVGElement|undefined,
) {
event.preventDefault();
setIsDragging(true);
const [clientX, clientY, pt] = getMouseEventPosition(event, svg, undefined);
const [x, y] = initialPosition;
const offsetX = clientX - x ;
const offsetY = clientY - y ;
function move(x: number, y: number) {
setPosition([x - offsetX, y - offsetY]);
}
function mousemove(event: MouseEvent) {
const [clientX, clientY, _pt] = getMouseEventPosition(event, svg, pt);
move(clientX, clientY)
};
function mouseup(lEvent: MouseEvent) {
const [clientX, clientY, _pt] = getMouseEventPosition(lEvent, svg, pt);
move(clientX, clientY);
document.removeEventListener('pointermove', mousemove);
document.removeEventListener('pointerup', mouseup);
setIsDragging(false);
};
setPosition([x, y])
document.addEventListener("pointermove", mousemove);
document.addEventListener("pointerup", mouseup);
}
interface INodePortProps {
id: string,
label?: string,
type: 'input' | 'output',
position: [number, number]
}
interface IPortAddress {
node: string,
port: string,
}
interface IPortAddressExtra {
node: string,
port: INodePortProps,
}
interface INodeConnector {
id: string,
from: IPortAddress,
to: IPortAddress,
}
interface INodeProps extends Record<string, any> {
id: string,
position: [number, number],
size: [number, number],
// width: number,
// height: number,
text: string,
resizable?: boolean,
ports: INodePortProps[]
}
type IDir = 'top' | 'right' | 'bottom' | 'left';
type DraggingPortPos = [nodeId: string, portId: string, at: number]; // , event: PIXI.InteractionEvent
type DraggingPortPosFromTo = {
from: DraggingPortPos|undefined,
to: DraggingPortPos|undefined
} | undefined;
function getDir(position: [number, number]): IDir {
const [x, y] = position;
if (x === 0) {
return 'left';
} else if (y === 0) {
return 'top';
} else if (y === 1) {
return 'bottom';
} else {
return 'right';
}
}
function getConnectorPortPosDir(item: INodeProps, portId: string): [x:number, y:number, dir: IDir] {
const port = item.ports.find(it => it.id === portId)!;
const
sx = item.position[X],
sy = item.position[Y];
const x = sx + item.size[W] * port.position[X];
const y = sy + item.size[H] * port.position[Y];
const dir = getDir(port.position);
return [x, y, dir];
}
interface IPortCircle {
item: INodePortProps,
nodeId: string,
x: number, y: number,
currentDrawingPortAddress?: IPortAddressExtra,
setDraggingFromTo: React.Dispatch<React.SetStateAction<DraggingPortPosFromTo>>,
setDragginPos: React.Dispatch<React.SetStateAction<[number, number]>>,
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>,
}
const PortCircle = React.memo((props: IPortCircle) => {
const { item, nodeId, setDraggingFromTo, setDragginPos, setIsDragging, x, y, currentDrawingPortAddress } = props;
const [isHoverWhileDragging, setIsHoverWhileDragging] = useState(false);
const [svg] = useSVGStore();
const circleRef = useRef<SVGCircleElement| undefined>(undefined);
function setCurrentPoint(ev: boolean) {
const val = ev ? [nodeId, item.id, Date.now()] as DraggingPortPos : undefined;
setIsHoverWhileDragging(ev);
setDraggingFromTo((fromTo: DraggingPortPosFromTo) => ({
from: item.type === 'input' ? val : fromTo?.from,
to: item.type === 'output' ? val : fromTo?.to
}));
}
const onMouseDown = useCallback((ev: React.MouseEvent) => {
setCurrentPoint(true);
function localSetIsDragging(pIsDragging: boolean) {
setIsDragging(pIsDragging);
if (!pIsDragging) {
setDraggingFromTo(undefined);
}
}
startDrag(
ev,
[x, y],
setDragginPos,
// @ts-ignore
localSetIsDragging,
svg,
);
}, [nodeId, x, y, svg]);
const onMouseEnterRef = useRef((ev: MouseEvent) => setCurrentPoint(true));
const onMouseLeaveRef = useRef((ev: MouseEvent) => setCurrentPoint(false));
useEffect(() => {
if (
currentDrawingPortAddress &&
circleRef.current &&
currentDrawingPortAddress.node !== nodeId &&
currentDrawingPortAddress.port.id !== item.id &&
currentDrawingPortAddress.port.type !== item.type
) {
// console.log('addingListeners', nodeId, item.id);
circleRef.current.addEventListener('pointerover', onMouseEnterRef.current);
circleRef.current.addEventListener('pointerout', onMouseLeaveRef.current);
} else {
if (circleRef.current) {
circleRef.current.removeEventListener('pointerover', onMouseEnterRef.current);
circleRef.current.removeEventListener('pointerout', onMouseLeaveRef.current);
setIsHoverWhileDragging(false);
}
}
}, [currentDrawingPortAddress, circleRef, nodeId, item]);
return (
<circle
// @ts-ignore
ref={circleRef}
cx={x}
cy={y}
r={isHoverWhileDragging ? PORT_RADIUS * 1.15 : PORT_RADIUS}
fill="white"
stroke="gray"
strokeWidth={2}
onPointerDown={onMouseDown}
name={item.id}
/>
);
});
interface IPortProps {
item: INodePortProps,
nodeId: string,
nodePos: [number, number],
width: number, height: number,
currentDrawingPortAddress?: IPortAddressExtra,
setDraggingFromTo: React.Dispatch<React.SetStateAction<DraggingPortPosFromTo>>,
}
function Port(props: IPortProps) {
const { item, width, height, nodeId, nodePos, setDraggingFromTo, currentDrawingPortAddress } = props;
const x = nodePos[X] + width * item.position[X];
const y = nodePos[Y] + height * item.position[Y];
const dir = getDir(item.position)
const [isDragging, setIsDragging] = useState(false);
const [draggingPos, setDragginPos] = useState(DEFAULT_XY);
const origin: [number, number] = [x, y];
const textX = dir === 'right'
? x - portCircleWidth - PORT_RADIUS
: dir == 'left'
? x + portCircleWidth + PORT_RADIUS : x;
const textAlign = dir === 'right'
? 'end' : dir === 'left'
? 'start' : 'center';
const textAnchor = dir === 'right'
? 'end' : dir === 'left'
? 'start' : 'middle';
const textY = (dir === 'top' || dir === 'bottom')
? y - portCircleWidth - PORT_RADIUS - PORT_LABEL_FONT_SIZE * HALF
: y + PORT_LABEL_FONT_SIZE * HALF * HALF
return (
<>
<PortCircle
x={x} y={y}
item={item}
nodeId={nodeId}
setDragginPos={setDragginPos}
setIsDragging={setIsDragging}
setDraggingFromTo={setDraggingFromTo}
currentDrawingPortAddress={currentDrawingPortAddress}
/>
<Maybe visible={!!item.label}>
<text x={textX} y={textY} style={{ fontSize: PORT_LABEL_FONT_SIZE, textAlign, textAnchor }} fill="gray" pointerEvents="none">{item.label}</text>
</Maybe>
<Maybe visible={isDragging}>
<MaybeDrawDraggingLine origin={origin} draggingPos={draggingPos} />
</Maybe>
</>
);
}
interface INodeElementProps extends INodeProps {
onPositionChange(p: [number, number]): void,
onSizeChange(width: number, height: number): void,
isShadow?: boolean,
setDraggingFromTo: React.Dispatch<React.SetStateAction<DraggingPortPosFromTo>>,
children?: React.ReactNode,
currentDrawingPortAddress?: IPortAddressExtra,
setIsInteracting: React.Dispatch<React.SetStateAction<boolean>>,
}
function Node(props: INodeElementProps) {
const { setIsInteracting } = props;
const [position, setPosition] = useState(props.position);
const [size, setSize] = useState(props.size);
const
width = size[W],
height = size[H],
x = position[X],
y = position[Y];
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [draggingStartPosition, setDraggingStartPosition] = useState<[number, number]|null>(null);
const [svg] = useSVGStore();
const onMouseSizeDown: React.MouseEventHandler<SVGRectElement> = useCallback((ev) => {
setDraggingStartPosition(position);
function handlePositionToSize([rsx, rsy]: [number, number]) {
const newWidth = rsx - x + RESIZER_SIZE + RESIZER_BORDER_WIDTH
const newHeight = rsy - y + RESIZER_SIZE + RESIZER_BORDER_WIDTH;
setSize([newWidth, newHeight]);
}
const resizerX = x + width - RESIZER_SIZE - RESIZER_BORDER_WIDTH;
const resizerY = y + height - RESIZER_SIZE - RESIZER_BORDER_WIDTH;
// @ts-ignore
startDrag(ev, [resizerX, resizerY], handlePositionToSize, setIsResizing, svg);
}, [position, width, height, svg]);
const onMousePositionDown: React.MouseEventHandler<SVGRectElement> = useCallback((ev) => {
if (!isResizing) {
setDraggingStartPosition(position);
// @ts-ignore
startDrag(ev, position, setPosition, setIsDragging, svg);
}
}, [position, isResizing, svg]);
useEffect(() => {
if (!isDragging) {
props.onPositionChange(position);
}
}, [isDragging, position]);
useEffect(() => {
if (!isResizing) {
props.onSizeChange(width, height);
}
}, [isResizing, width, height]);
useEffect(() => {
if (isResizing || isDragging) {
setIsInteracting(true);
} else {
setIsInteracting(false);
}
}, [isDragging, isResizing])
const opacity = (isDragging || isResizing) ? 0.5 : props.isShadow ? 0.25 : 1;
const portElements = useMemo(() => props.ports.map(it => (
<Port
key={it.id}
item={it}
width={width}
height={height}
nodeId={props.id}
nodePos={position}
setDraggingFromTo={props.setDraggingFromTo}
currentDrawingPortAddress={props.currentDrawingPortAddress}
/>
)), [width, height, position, props.ports, props.id, props.setDraggingFromTo, props.currentDrawingPortAddress]);
// const
return (
<>
<g
// x={x} y={y}
// width={portCircleWidth + portRadius + width + resizerSize + resizerStrokeWidth + paddingRight}
// height={portCircleWidth + portRadius + height + resizerSize + resizerStrokeWidth}
opacity={opacity}
>
<rect
// x={portCircleWidth + portRadius} y={portCircleWidth + portRadius}
x={x} y={y}
width={width} height={height}
rx={10} ry={10}
fill="white"
opacity={0.65}
stroke="lightgray" strokeWidth={5}
onPointerDown={onMousePositionDown}
// onMouseDown={onMousePositionDown}
/>
<text x={x + width * .5} y={y + height * .5} textAnchor="middle" fill="gray" pointerEvents="none">{props.text}</text>
{portElements}
{/* {props.children} */}
<Maybe visible={!!props.resizable}>
<rect
x={x + width} y={y + height}
width={RESIZER_SIZE} height={RESIZER_SIZE}
fill="white" stroke="black" strokeWidth={RESIZER_BORDER_WIDTH}
opacity={0.75}
onPointerDown={onMouseSizeDown}
// onMouseDown={onMouseSizeDown}
/>
</Maybe>
</g>
{(isDragging || isResizing) && draggingStartPosition && (
<Node
id={props.id+'-shadow'}
position={draggingStartPosition}
size={size}
resizable={props.resizable}
text={props.text}
onPositionChange={DUMMY_FUNC}
onSizeChange={DUMMY_FUNC}
isShadow={true}
ports={props.ports}
setDraggingFromTo={DUMMY_FUNC}
setIsInteracting={DUMMY_FUNC}
/>
)}
</>
);
}
const defaultWidth = 200;
const defaultHeight = 50;
const defaultElements: INodeProps[] = [
{
id: '0',
position: [50, 50],
size: [defaultWidth, defaultHeight],
text: 'Content 0',
resizable: true,
ports: [
{
id: '0pi0',
type: 'input',
position: [1/3, 0],
label: 'input_label',
}, {
id: '0pi1',
type: 'input',
position: [2/3, 0],
label: 'input_label',
}, {
id: '0po0',
type: 'output',
position: [0.5, 1],
label: 'output_label',
},
],
},
{
id: '1',
position: [150, 150],
size: [defaultWidth, defaultHeight],
text: 'Content 1',
ports: [
{
id: '1pi0',
type: 'input',
position: [0, 1/3],
label: 'input_label',
}, {
id: '1pi1',
type: 'input',
position: [0, 2/3],
label: 'input_label',
}, {
id: '1po0',
type: 'output',
position: [1, 0.5],
label: 'output_label',
},
],
},
{
id: '2',
position: [250, 250],
size: [defaultWidth, defaultHeight],
text: 'Content 2',
ports: [
{
id: '2pi0',
type: 'input',
position: [0, 1/3],
label: 'input_label',
}, {
id: '2pi1',
type: 'input',
position: [0, 2/3],
label: 'input_label',
}, {
id: '2po0',
type: 'output',
position: [1, 0.5],
label: 'output_label',
},
],
},
];
const defaultConnectors: INodeConnector[] = [
{
id: 'conn0',
from: {
node: '0',
port: '0po0',
},
to: {
node: '1',
port: '1pi0',
}
}
];
const emptyArr = [] as unknown[];
function arrayToRecords<T extends Record<string, any>>(list: T[], key: string): Record<string, T> {
return Object.values(list)
.reduce(
(acc, it) => ({ ...acc, [it[key]]: it }),
{} as Record<string, T>
);
}
interface ConnectorsDrawerProps {
connectors: INodeConnector[],
elements: Record<string, INodeProps>,
}
const connTensionLen = 50;
function buildPortPath(pos: ReturnType<typeof getConnectorPortPosDir>): [number, number, number, number] {
const [x1, y1, dir]= pos;
const x2 = dir === 'left'
? x1 - connTensionLen
: dir === 'right'
? x1 + connTensionLen
: x1;
const y2 = dir === 'top'
? y1 - connTensionLen
: dir === 'bottom'
? y1 + connTensionLen
: y1;
return [x1, y1, x2, y2];
}
function ConnectorPath(props: { conn: INodeConnector, elements: Record<string, INodeProps> }) {
const { conn, elements } = props;
const { from: { node: fromNode, port: fromPort }, to: { node: toNode, port: toPort } } = conn;
const fromNodeElem = elements[fromNode];
const toNodeElem = elements[toNode];
const posDirFromPort = getConnectorPortPosDir(fromNodeElem, fromPort)
const posDirToPort = getConnectorPortPosDir(toNodeElem, toPort)
const [p1x1, p1y1, p1x2, p1y2] = buildPortPath(posDirFromPort);
const [p2x1, p2y1, p2x2, p2y2] = buildPortPath(posDirToPort);
const data = `M ${p1x1} ${p1y1} C ${p1x2} ${p1y2}, ${p2x2} ${p2y2}, ${p2x1} ${p2y1}`;
function onClick() {
// alert('klk');
console.log('connector cliked');
}
return (
<path
d={data} stroke="orange"
fill="transparent" strokeWidth={5}
strokeLinecap="round"
opacity={0.75} onClick={onClick}
/>
);
}
function ConnectorsDrawer(props: ConnectorsDrawerProps) {
const { connectors, elements } = props;
const toRender = useMemo(
() => connectors.map(it => <ConnectorPath key={it.id} conn={it} elements={elements} />),
[connectors, elements]
);
return (
<>
{toRender}
</>
);
}
interface IMaybeDrawDragginLineProps {
// origin: DraggingPortPos | undefined,
origin: [number, number],
draggingPos: [number, number],
// resetDrawing(): void,
}
const arrowSize = 10;
function MaybeDrawDraggingLine(props: IMaybeDrawDragginLineProps) {
const { origin, draggingPos } = props;
if (!origin) {
return null;
}
return (
<line
x1={origin[X]} x2={draggingPos[X]}
y1={origin[Y]} y2={draggingPos[Y]}
stroke="orange"
strokeWidth="5"
opacity={0.5}
strokeLinecap="round"
/>
);
}
function getDraggingOrigin(it: DraggingPortPosFromTo) {
// @ts-ignore
const sortedAsc = Object.values(it ?? emptyObj as DraggingPortPosFromTo)
.filter(Boolean)
.sort((a, b) => (a?.[2] ?? Number.MAX_VALUE) - (b?.[2] ?? Number.MAX_VALUE));
return sortedAsc?.[0];
}
interface ISVGContainerProps {
width: string|number,
height: string|number,
isInteracting: boolean,
}
interface IViewBox {x: number, y: number, w: number, h: number}
function SVGContainer(props: React.PropsWithChildren<ISVGContainerProps>) {
const { width, height, children, isInteracting } = props;
const svgImageRef = useRef<SVGSVGElement|undefined>(undefined);
const svgContainerRef = useRef<HTMLDivElement|undefined>(undefined);
const [_, setSvgValue] = useSVGStore();
const [sViewBox, setViewBox] = useState<IViewBox|undefined>(undefined);
useEffect(() => {
setSvgValue(svgImageRef.current);
return () => setSvgValue(undefined);
}, [svgImageRef.current]);
useEffect(() => {
// console.log('SVGContainer isInteracting', isInteracting);
const svgImage = svgImageRef.current;
const svgContainer = svgContainerRef.current;
let lViewBox: IViewBox|undefined = undefined;
let onwheel: (((ev: WheelEvent) => any) | null) = null;
let onmousedown: (((ev: MouseEvent) => any) | null) = null;
let onmouseup: (((ev: MouseEvent) => any) | null) = null;
let onmousemove: (((ev: MouseEvent) => any) | null) = null;
let onmouseleave: (((ev: MouseEvent) => any) | null) = null;
function clean2ndListeners() {
if (svgContainer) {
svgContainer.removeEventListener('mousemove', onmousemove!);
svgContainer.removeEventListener('mouseup', onmouseup!)
svgContainer.removeEventListener('mouseleave', onmouseleave!);
}
}
function cleanup(viewBox: IViewBox|undefined) {
if (viewBox) {
setViewBox(viewBox);
}
if (svgContainer) {
console.log('cleanup');
svgContainer.removeEventListener('wheel', onwheel!);
svgContainer.removeEventListener('mousedown', onmousedown!);
clean2ndListeners();
}
}
/**static */
function newMovedViewBox(e: MouseEvent, viewBox: IViewBox, startPoint: { x: number, y: number }, scale: number): IViewBox {
const dx = (startPoint.x - e.x)/scale;
const dy = (startPoint.y - e.y)/scale;
return { x: viewBox.x + dx, y: viewBox.y + dy, w: viewBox.w, h: viewBox.h };
}
const viewBoxStr = (viewbox: IViewBox) => `${viewbox.x} ${viewbox.y} ${viewbox.w} ${viewbox.h}`;
// console.log('SVGContainer isInteracting', isInteracting);
if (svgImage && svgContainer && !isInteracting) {
// console.log('SVGContainer adding listeners')
// https://stackoverflow.com/questions/52576376/how-to-zoom-in-on-a-complex-svg-structure
const svgSize = {w:svgImage.clientWidth,h:svgImage.clientHeight};
lViewBox = sViewBox || {
x: 0,
y: 0,
w: svgImage.clientWidth,
h: svgImage.clientHeight,
} as IViewBox;
let viewBox = lViewBox!
// setViewBox(viewBox);
let startPoint = {x:0,y:0};
let scale = svgSize.w/viewBox.w;
console.log('SVGContainer viewBox', viewBox)
onwheel = function(e) {
e.preventDefault();
var w = viewBox.w;
var h = viewBox.h;
var mx = e.offsetX;//mouse x
var my = e.offsetY;
var dw = w*Math.sign(e.deltaY)*0.05;
var dh = h*Math.sign(e.deltaY)*0.05;
var dx = dw*mx/svgSize.w;
var dy = dh*my/svgSize.h;
viewBox = {x:viewBox.x+dx,y:viewBox.y+dy,w:viewBox.w-dw,h:viewBox.h-dh};
scale = svgSize.w/viewBox.w;
svgImage.setAttribute('viewBox', viewBoxStr(viewBox));
lViewBox = viewBox;
}
svgContainer.addEventListener('wheel', onwheel);
onmousemove = (e) => {
const newViewBox = newMovedViewBox(e, viewBox, startPoint, scale);
svgImage.setAttribute('viewBox', viewBoxStr(newViewBox));
}
onmouseup = (e) => {
const newViewBox = newMovedViewBox(e, viewBox, startPoint, scale);
viewBox = newViewBox;
svgImage.setAttribute('viewBox', viewBoxStr(newViewBox));
lViewBox = viewBox;
clean2ndListeners();
}
onmouseleave = (e) => {
const newViewBox = newMovedViewBox(e, viewBox, startPoint, scale);
viewBox = newViewBox;
svgImage.setAttribute('viewBox', viewBoxStr(newViewBox));
lViewBox = viewBox;
clean2ndListeners();
}
onmousedown = (e) => {
startPoint = {x:e.x,y:e.y};
svgContainer.addEventListener('mousemove', onmousemove!);
svgContainer.addEventListener('mouseup', onmouseup!)
svgContainer.addEventListener('mouseleave', onmouseleave!);
}
svgContainer.addEventListener('mousedown', onmousedown);
}
return () => cleanup(lViewBox);
}, [svgImageRef.current, svgContainerRef.current, width, height, isInteracting])
return (
// @ts-ignore
<div ref={svgContainerRef}>
<svg
// @ts-ignore
ref={svgImageRef}
height={height}
style={{
border: '1px solid green',
width: width,
backgroundColor: '#fffff8',
}}
// onClick={() => console.log('bg clicked')}
>
{children}
</svg>
</div>
);
}
interface ISingleNodeProps {
it: INodeProps,
currentDrawingPortAddress: IPortAddressExtra|undefined,
setElements: React.Dispatch<React.SetStateAction<Record<string, INodeProps>>>,
setDraggingFromTo: React.Dispatch<React.SetStateAction<DraggingPortPosFromTo>>,
setIsInteracting: React.Dispatch<React.SetStateAction<boolean>>,
}
function SingleNode(props: ISingleNodeProps) {
const { it, setElements, currentDrawingPortAddress, setDraggingFromTo, setIsInteracting } = props;
const onPositionChange = useCallback(
(position: [number, number]) => setElements(els => ({ ...els, [it.id]: { ...els[it.id], position }})),
[it.id, setElements]
);
const onSizeChange = useCallback(
(width: number, height: number) => setElements(els => ({ ...els, [it.id]: { ...els[it.id], size: [width, height] }})),
[it.id, setElements]
);
return (
<Node
id={it.id}
key={it.id}
position={it.position}
size={it.size}
resizable={it.resizable}
text={it.text}
onPositionChange={onPositionChange}
onSizeChange={onSizeChange}
ports={it.ports}
setDraggingFromTo={setDraggingFromTo}
currentDrawingPortAddress={currentDrawingPortAddress}
setIsInteracting={setIsInteracting}
/>
);
}
interface INodeListProps {
currentDrawingPortAddress: IPortAddressExtra|undefined,
elements: Record<string, INodeProps>,
setElements: React.Dispatch<React.SetStateAction<Record<string, INodeProps>>>,
setDraggingFromTo: React.Dispatch<React.SetStateAction<DraggingPortPosFromTo>>,
setIsInteracting: React.Dispatch<React.SetStateAction<boolean>>,
}
function NodeList(props: INodeListProps) {
const { currentDrawingPortAddress, elements, setElements, setDraggingFromTo, setIsInteracting } = props;
const nodes = useMemo(() => Object.values(elements).map((it) => (
<SingleNode
it={it}
key={it.id}
setElements={setElements}
setDraggingFromTo={setDraggingFromTo}
currentDrawingPortAddress={currentDrawingPortAddress}
setIsInteracting={setIsInteracting}
/>
)), [setDraggingFromTo, setElements, elements, currentDrawingPortAddress]);
return <>{nodes}</>;
}
function GTFlowEngine() {
const [currentDrawingPortAddress, setCurrentDrawingPortAddress] = useState<IPortAddressExtra|undefined>(undefined);
const [elements, setElements] = useState<Record<string, INodeProps>>(() => arrayToRecords(defaultElements, 'id'));
const [connectors, setConnectors] = useState(defaultConnectors);
const [draggingFromTo, setDraggingFromTo] = useState<DraggingPortPosFromTo>(undefined);
const [prevDraggingFromTo, setPrevDraggingFromTo] = useState<DraggingPortPosFromTo>(draggingFromTo);
const [isInteracting, setIsInteracting] = useState(false);
useEffect(() => {
const origin = getDraggingOrigin(draggingFromTo);
if (origin) {
const [node, portId] = origin;
const port = elements[node].ports.find(it => it.id === portId)!;
setCurrentDrawingPortAddress({ node, port });
} else {
setCurrentDrawingPortAddress(undefined);
}
// console.log('origin, draggingFromTo; prevDraggingFromTo', JSON.stringify(origin), JSON.stringify(draggingFromTo), JSON.stringify(prevDraggingFromTo));
if (draggingFromTo === undefined && prevDraggingFromTo?.from && prevDraggingFromTo.to) {
const { from: draggingFrom, to: draggingTo } = prevDraggingFromTo;
const newId = Date.now().toString(36);
const [fromNode, fromPort] = draggingFrom!;
const [toNode, toPort] = draggingTo!;
const connector = {
id: newId,
from: {
node: fromNode,
port: fromPort,
},
to: {
node: toNode,
port: toPort,
},
} as INodeConnector;
setConnectors((connectors) => [...connectors, connector]);
}
setPrevDraggingFromTo(draggingFromTo);
}, [draggingFromTo, elements]);
return (
<SVGContainer width="100%" height={600} isInteracting={isInteracting || !!currentDrawingPortAddress}>
{/* <rect x={0} y={0} width={window.innerWidth} height="100%" fill="#fee" onClick={() => console.log('bg clicked')} /> */}
<ConnectorsDrawer connectors={connectors} elements={elements} />
<NodeList
elements={elements}
currentDrawingPortAddress={currentDrawingPortAddress}
setDraggingFromTo={setDraggingFromTo}
setElements={setElements}
setIsInteracting={setIsInteracting}
/>
</SVGContainer>
);
}
export default GTFlowEngine;
import React, { useCallback, useState, useEffect, useMemo, useRef, useContext } from 'react';
import { Stage, Sprite, Graphics, Container, Text } from '@inlet/react-pixi';
import * as ReactPixi from '@inlet/react-pixi';
import * as PIXI from 'pixi.js';
import { Viewport as PixiViewport } from "pixi-viewport";
import Viewport from './Viewport';
const RECT_RADIUS = 10;
const RESIZER_SIZE = 10;
const RESIZER_BORDER_WIDTH = 1;
const WHITE = 0xffffff;
const RECT_COLOR = WHITE;
const LIGHT_GRAY = 0xdddddd;
const GRAY = 0x888888;
const BORDER_COLOR = LIGHT_GRAY;
const BORDER_WIDTH = 5;
const HALF = 0.5;
const OPACITY_50 = HALF;
const OPACITY_75 = 0.75
const OPACITY_25 = 0.25
const X = 0;
const Y = 1;
const W = 0;
const H = 1;
const PORT_RADIUS = 6;
const PADDING_RIGHT = 50;
const PORT_LABEL_FONT_SIZE = 7;
const PORT_STROKE_WIDTH = 2;
const DEFAULT_XY: [number, number] = [0,0];
const DUMMY_FUNC = (...args: any[]) => {};
const defaultTextStyle = new PIXI.TextStyle({
fontSize: 14,
stroke: 'gray',
});
const portTextStyle = new PIXI.TextStyle({
fontSize: 7,
stroke: 'gray',
});
interface INodePortProps {
id: string,
label?: string,
type: 'input' | 'output',
pos: [number, number]
}
type PointerEventHandler = (event: PIXI.InteractionEvent) => void;
interface IPortAddress {
node: string,
port: string,
}
interface IPortAddressExtra {
node: string,
port: INodePortProps,
}
interface INodeConnector {
id: string,
from: IPortAddress,
to: IPortAddress,
}
interface INodeProps extends Record<string, any> {
id: string,
pos: [x: number, y: number],
size: [w: number, h: number],
text: string,
resizable?: boolean,
ports: INodePortProps[]
}
type IDir = 'top' | 'right' | 'bottom' | 'left'
interface IMaybeProps {
visible: boolean,
children: React.ReactNode,
}
type DraggingPortPos = [nodeId: string, portId: string, at: number]; // , event: PIXI.InteractionEvent
type DraggingPortPosFromTo = {
from: DraggingPortPos|undefined,
to: DraggingPortPos|undefined
} | undefined;
function Maybe(props: IMaybeProps) {
return props.visible ? <>{props.children}</> : null;
}
type IRectProps = {
x: number, y: number,
w: number, h: number,
bgColor?: number,
r?: number, stroke?: number, strokeWidth?: number,
scale?: number,
opacity?: number, strokeOpacity?: number,
} & Pick<ReactPixi._ReactPixi.IGraphics, 'interactive'|'pointerdown'|'pointermove'|'pointerup'|'buttonMode'|'hitArea'|'anchor'>
function Rect(props: IRectProps) {
const {
x, y, w, h, r = RECT_RADIUS, bgColor = WHITE, opacity = OPACITY_75,
stroke = BORDER_COLOR, strokeWidth = BORDER_WIDTH, strokeOpacity = OPACITY_75,
interactive, pointerdown, pointerup, pointermove, buttonMode, hitArea, scale, anchor
} = props;
const draw = useCallback((g: PIXI.Graphics) => {
g.clear();
g.lineStyle(strokeWidth, stroke, strokeOpacity);
g.beginFill(bgColor, opacity);
g.drawRoundedRect(x, y, w, h, r);
g.endFill();
}, [x, y, w, h, bgColor, opacity, r, strokeWidth, stroke, strokeOpacity]);
return (
<Graphics
draw={draw}
interactive={interactive}
pointerdown={pointerdown}
pointerup={pointerup}
pointermove={pointermove}
buttonMode={buttonMode}
// hitArea={hitArea}
anchor={anchor}
scale={scale}
/>
);
}
type ICircleProps = {
x: number, y: number, r: number,
bgColor?: number, scale?: number, opacity: number, strokeWidth: number, stroke: number, strokeOpacity: number,
} & Pick<
ReactPixi._ReactPixi.IGraphics,
'interactive'|'pointerdown'|'pointermove'|'pointerup'|'buttonMode'|'hitArea'|'pointerover'|'pointerout'|'anchor'
>;
// @ts-ignore
const Circle = React.forwardRef<PIXI.Graphics, ICircleProps>((props: ICircleProps, ref) => {
const {
x, y, r, opacity, stroke, strokeWidth, strokeOpacity, bgColor = WHITE,
interactive, scale, pointerdown, pointerup, pointermove, pointerout, pointerover,
buttonMode, hitArea, anchor
} = props;
const draw = useCallback((g: PIXI.Graphics) => {
g.clear();
g.lineStyle(strokeWidth, stroke, strokeOpacity);
g.beginFill(bgColor, opacity);
g.drawCircle(x, y, r);
g.endFill()
}, [x, y, r, bgColor, strokeWidth, stroke, strokeOpacity, opacity]);
return (
<Graphics
ref={ref}
draw={draw}
interactive={interactive}
pointerdown={pointerdown}
pointerup={pointerup}
pointermove={pointermove}
pointerout={pointerout}
pointerover={pointerover}
buttonMode={buttonMode}
// hitArea={hitArea}
scale={scale}
anchor={anchor}
/>
);
});
type IBezierProps = {
x: number, y: number,
cpX: number, cpY: number, cpX2: number, cpY2: number, toX: number, toY: number,
lineWidth: number, color?: number, opacity?: number,
} & Pick<
ReactPixi._ReactPixi.IGraphics,
'pointertap'|'click'|'interactive'|'hitArea'
>
function Bezier(props: IBezierProps) {
const { x, y, cpX, cpY, cpX2, cpY2, toX, toY, lineWidth, opacity, color, pointertap, click, interactive } = props;
// const poligonArea = new PIXI.Polygon(x, y, cpX, cpY, cpX2, cpY2, toX, toY);
const graphRef = useRef<ReactPixi._ReactPixi.IGraphics|undefined>(undefined);
const draw = useCallback((g: PIXI.Graphics) => {
g.clear();
const lineStyle = {
color,
width: lineWidth,
alpha: opacity,
cap: PIXI.LINE_CAP.ROUND,
} as PIXI.ILineStyleOptions;
g.lineStyle(lineStyle);
g.moveTo(x, y);
g.bezierCurveTo(cpX, cpY, cpX2, cpY2, toX, toY);
g.drawCircle(x + cpX2 - cpY, y + cpY2 - cpY, 3)
// g.lineTo(cpX, cpY); g.lineTo(cpX2, cpY2); g.lineTo(toX, toY);
g.endFill();
// g.lineStyle(2, 0x0000ff, 1)
// g.beginFill(0xff700b, 1);
// g.drawPolygon(poligonArea)
// g.endFill();
// if (graphRef) {
// console.log('Bezier hitArea', graphRef.current?.hitArea)
// PIXI.Simp
// }
}, [x, y, cpX, cpY, cpX2, cpY2, toX, toY, lineWidth, color, opacity]);
// useRef(() => {
// }, [graphRef])
return (
<Graphics
draw={draw}
pointertap={pointertap}
click={click}
interactive
/>
);
}
type ILineProps = {
x1: number, y1: number,
x2: number, y2: number,
lineWidth: number, color?: number, opacity?: number,
};
function Line(props: ILineProps) {
const { x1, y1, x2, y2, lineWidth, opacity, color } = props;
const draw = useCallback((g: PIXI.Graphics) => {
g.clear();
const lineStyle = {
color,
width: lineWidth,
alpha: opacity,
cap: PIXI.LINE_CAP.ROUND,
} as PIXI.ILineStyleOptions;
g.lineStyle(lineStyle);
g.moveTo(x1, y1);
g.lineTo(x2, y2);
g.endFill();
}, [x1, y1, x2, y2, color, lineWidth, opacity]);
return (
<Graphics draw={draw} />
);
}
interface IPortCircle {
item: INodePortProps,
nodeId: string,
x: number, y: number,
currentDrawingPortAddress?: IPortAddressExtra,
setDraggingFromTo: React.Dispatch<React.SetStateAction<DraggingPortPosFromTo>>,
setDragginPos: React.Dispatch<React.SetStateAction<[number, number]>>,
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>,
}
const PortCircle = React.memo((props: IPortCircle) => {
const { item, nodeId, setDraggingFromTo, setDragginPos, setIsDragging, x, y, currentDrawingPortAddress } = props;
const [isHoverWhileDragging, setIsHoverWhileDragging] = useState(false);
const circleRef = useRef<PIXI.Graphics| undefined>(undefined);
// const viewport = useMemo(() => circleRef.current?.parent.parent as PixiViewport|undefined, [circleRef.current]);
function setCurrentPoint(ev: boolean) {
const val = ev ? [nodeId, item.id, Date.now()] as DraggingPortPos : undefined;
setIsHoverWhileDragging(ev);
setDraggingFromTo((fromTo: DraggingPortPosFromTo) => ({
from: item.type === 'input' ? val : fromTo?.from,
to: item.type === 'output' ? val : fromTo?.to
}));
}
const onMouseDown = useCallback((ev: PIXI.InteractionEvent) => {
console.log('mousedown',nodeId, item.id, circleRef);
setCurrentPoint(true);
function localSetIsDragging(pIsDragging: boolean) {
setIsDragging(pIsDragging);
if (!pIsDragging) {
setDraggingFromTo(undefined);
}
}
const viewport = circleRef.current?.parent.parent as PixiViewport|undefined;
startDrag(
ev,
[x, y],
setDragginPos,
// @ts-ignore
localSetIsDragging,
viewport
);
}, [nodeId, x, y, item.id, circleRef]);
useEffect(() => {
const onMouseEnter: PointerEventHandler = (ev) => {
// console.log('onMouseEnter', nodeId, item.id);
setCurrentPoint(true);
// setIsHoverWhileDragging(true);
};
const onMouseLeave: PointerEventHandler = (ev) => {
// console.log('onMouseLeave', nodeId, item.id);
setCurrentPoint(false);
// setIsHoverWhileDragging(false);
};
if (
currentDrawingPortAddress &&
circleRef.current &&
currentDrawingPortAddress.node !== nodeId &&
currentDrawingPortAddress.port.id !== item.id &&
currentDrawingPortAddress.port.type !== item.type
) {
// console.log('addingListeners', nodeId, item.id);
circleRef.current.addListener('pointerover', onMouseEnter);
circleRef.current.addListener('pointerout', onMouseLeave);
} else {
if (circleRef.current) {
// console.log('cleaning up', nodeId, item.id);
circleRef.current.removeListener('pointerover');
circleRef.current.removeListener('pointerout');
setIsHoverWhileDragging(false);
}
}
}, [currentDrawingPortAddress, circleRef, nodeId, item]);
return (
<Circle
// @ts-ignore
ref={circleRef}
x={x}
y={y}
r={isHoverWhileDragging ? PORT_RADIUS * 1.15 : PORT_RADIUS}
bgColor={WHITE}
stroke={GRAY}
strokeWidth={PORT_STROKE_WIDTH}
opacity={1}
strokeOpacity={OPACITY_75}
interactive
pointerdown={onMouseDown}
/>
);
});
interface IPortProps {
item: INodePortProps,
nodeId: string,
nodeSize: [number, number],
currentDrawingPortAddress?: IPortAddressExtra,
setDraggingFromTo: React.Dispatch<React.SetStateAction<DraggingPortPosFromTo>>,
}
function Port(props: IPortProps) {
const { item, nodeSize, nodeId, setDraggingFromTo, currentDrawingPortAddress } = props;
const x = nodeSize[W] * item.pos[X];
const y = nodeSize[H] * item.pos[Y];
const [isDragging, setIsDragging] = useState(false);
const [draggingPos, setDragginPos] = useState(DEFAULT_XY);
const origin: [number, number] = [x, y]
return (
<>
<PortCircle
x={x} y={y}
item={item}
nodeId={nodeId}
setDragginPos={setDragginPos}
setIsDragging={setIsDragging}
setDraggingFromTo={setDraggingFromTo}
currentDrawingPortAddress={currentDrawingPortAddress}
/>
<Maybe visible={!!item.label}>
<Text x={x + PORT_STROKE_WIDTH + PORT_RADIUS} y={y - PORT_RADIUS} style={portTextStyle} text={item.label} />
</Maybe>
<Maybe visible={isDragging}>
<MaybeDrawDraggingLine origin={origin} draggingPos={draggingPos} />
</Maybe>
</>
);
}
function startDrag(
event: PIXI.InteractionEvent,
initialPosition: [number, number],
setPosition: React.Dispatch<React.SetStateAction<[number, number]>>,
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>,
viewport?: PixiViewport,
) {
setIsDragging(true);
const [x, y] = initialPosition;
const realEventPos = viewport?.toWorld(event.data.global) ?? event.data.global;
const offsetX = realEventPos.x - x;
const offsetY = realEventPos.y - y;
const target = event.target;
console.log('startDrag event.data.global, offsetX, offsetY, viewport', event.data.global, offsetX, offsetY, viewport);
function move(x: number, y: number) {
setPosition([x - offsetX, y - offsetY]);
}
function onPointerMove(ev: PIXI.InteractionEvent) {
const pos = ev.data.global;
const realEventPos = viewport?.toWorld(pos) ?? pos;
// move(pos.x, pos.y);
move(realEventPos.x, realEventPos.y);
}
function onPointerUp(ev: PIXI.InteractionEvent) {
console.log('onPointerUp offsetX, offsetY', offsetX, offsetY);
if (target) {
target.removeListener('pointerup');
target.removeListener('pointermove');
target.removeListener('pointerupoutside');
}
setIsDragging(false);
viewport?.drag();
}
setPosition([x, y]);
target.addListener('pointerup', onPointerUp);
target.addListener('pointerupoutside', onPointerUp);
target.addListener('pointermove', onPointerMove);
viewport?.drag({ pressDrag: false });
}
interface INodeElementProps extends INodeProps {
onPositionChange(p: [number, number]): void,
onSizeChange(size: [number, number]): void,
isShadow?: boolean,
setDraggingFromTo: React.Dispatch<React.SetStateAction<DraggingPortPosFromTo>>,
children?: React.ReactNode,
currentDrawingPortAddress?: IPortAddressExtra,
}
function Node(props: INodeElementProps) {
const { text } = props;
const [pos, setPosition] = useState<[number, number]>(props.pos);
const [size, setSize] = useState<[number, number]>(props.size);
const w = size[W], h = size[H];
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [draggingStartPosition, setDraggingStartPosition] = useState<[number, number]|null>(null);
const x = pos[X], y = pos[Y];
const { theme } = useGlobalParams();
const containerRef = useRef<PIXI.DisplayObject|undefined>(undefined);
const viewport = useMemo(() => containerRef.current?.parent as PixiViewport|undefined, [containerRef.current]);
const pointerdown = useCallback((event: PIXI.InteractionEvent) => {
console.log('containerRef, viewport', containerRef, viewport)
if (!isResizing && viewport) {
const point: [number, number] = [containerRef.current!.x, containerRef.current!.y]
setDraggingStartPosition(pos);
// startDrag(event, pos, setPosition, setIsDragging, viewport);
startDrag(event, point, setPosition, setIsDragging, viewport);
}
}, [pos, isResizing, viewport]);
const onMouseSizeDown = useCallback((ev: PIXI.InteractionEvent) => {
setDraggingStartPosition(pos);
function handlePositionToSize([rsx, rsy]: [number, number]) {
const newWidth = rsx - x + RESIZER_SIZE + BORDER_WIDTH;
const newHeight = rsy - y + RESIZER_SIZE + BORDER_WIDTH;
setSize([newWidth, newHeight]);
}
const resizerX = x + w - RESIZER_SIZE - BORDER_WIDTH;
const resizerY = y + h - RESIZER_SIZE - BORDER_WIDTH;
// @ts-ignore
startDrag(ev, [resizerX, resizerY], handlePositionToSize, setIsResizing, viewport);
}, [pos, size, viewport]);
useEffect(() => {
if (!isDragging) {
props.onPositionChange(pos);
}
}, [isDragging, pos]);
useEffect(() => {
if (!isResizing) {
props.onSizeChange(size);
}
}, [isResizing, size]);
const portElements = useMemo(() => props.ports.map(it => (
<Port
key={it.id}
item={it}
nodeSize={size}
nodeId={props.id}
setDraggingFromTo={props.setDraggingFromTo}
currentDrawingPortAddress={props.currentDrawingPortAddress}
/>
)), [size, props.ports, props.id, props.setDraggingFromTo, props.currentDrawingPortAddress]);
const opacity = (isDragging || isResizing) ? OPACITY_50 : props.isShadow ? OPACITY_25 : OPACITY_75;
const scale = isDragging ? 0.98 : 1;
return (
<>
{(isDragging || isResizing) && draggingStartPosition && (
<Node
id={props.id+'-shadow'}
pos={draggingStartPosition}
size={size}
resizable={props.resizable}
text={props.text}
onPositionChange={DUMMY_FUNC}
onSizeChange={DUMMY_FUNC}
isShadow
ports={props.ports}
setDraggingFromTo={DUMMY_FUNC}
/>
)}
<Container
x={x} y={y}
// @ts-ignore
ref={containerRef}
>
<Rect
x={0} y={0}
// x={w*HALF} y={h*HALF}
w={w} h={h}
anchor={[w*HALF, h*HALF]}
opacity={opacity}
strokeOpacity={opacity}
interactive
pointerdown={pointerdown}
buttonMode
scale={scale}
/>
<Text x={w * HALF} y={h * HALF} anchor={HALF} text={text} alpha={opacity} style={defaultTextStyle} />
{portElements}
<Maybe visible={!!props.resizable}>
<Rect
x={w} y={h} r={0}
w={RESIZER_SIZE} h={RESIZER_SIZE}
stroke={GRAY} strokeWidth={RESIZER_BORDER_WIDTH}
interactive buttonMode
pointerdown={onMouseSizeDown}
opacity={opacity}
/>
</Maybe>
</Container>
</>
);
}
const defaultWidth = 200;
const defaultHeight = 50;
const defaultElements: INodeProps[] = [
{
id: '0',
pos: [50, 50],
size: [defaultWidth, defaultHeight],
text: 'Content 0',
resizable: true,
ports: [
{
id: '0pi0',
type: 'input',
pos: [1/3, 0],
label: 'input_label',
}, {
id: '0pi1',
type: 'input',
pos: [2/3, 0],
label: 'input_label',
}, {
id: '0po0',
type: 'output',
pos: [0.5, 1],
label: 'output_label',
},
],
},
{
id: '1',
pos: [150, 150],
size: [defaultWidth, defaultHeight],
text: 'Content 1',
ports: [
{
id: '1pi0',
type: 'input',
pos: [0, 1/3],
label: 'input_label',
}, {
id: '1pi1',
type: 'input',
pos: [0, 2/3],
label: 'input_label',
}, {
id: '1po0',
type: 'output',
pos: [1, 0.5],
label: 'output_label',
},
],
},
{
id: '2',
pos: [250, 250],
size: [defaultWidth, defaultHeight],
text: 'Content 2',
resizable: true,
ports: [
{
id: '2pi0',
type: 'input',
pos: [0, 1/3],
label: 'input_label',
}, {
id: '2pi1',
type: 'input',
pos: [0, 2/3],
label: 'input_label',
}, {
id: '2po0',
type: 'output',
pos: [1, 0.5],
label: 'output_label',
},
],
},
];
const defaultConnectors: INodeConnector[] = [
{
id: 'conn0',
from: {
node: '0',
port: '0po0',
},
to: {
node: '1',
port: '1pi0',
}
}
];
const emptyArr = [] as unknown[];
const emptyObj = {} as unknown;
function arrayToRecords<T extends Record<string, any>>(list: T[], key: string): Record<string, T> {
return Object.values(list)
.reduce(
(acc, it) => ({ ...acc, [it[key]]: it }),
{} as Record<string, T>
);
}
function getDir(position: [number, number]): IDir {
const [x, y] = position;
if (x === 0) {
return 'left';
} else if (y === 0) {
return 'top';
} else if (y === 1) {
return 'bottom';
} else {
return 'right';
}
}
function getConnectorPortPosDir(item: INodeProps, portId: string): [x:number, y:number, dir: IDir] {
const port = item.ports.find(it => it.id === portId)!;
const [sx, sy] = item.pos
const x = sx + item.size[W] * port.pos[X];
const y = sy + item.size[H] * port.pos[Y];
const dir = getDir(port.pos);
return [x, y, dir];
}
interface ConnectorsDrawerProps {
connectors: INodeConnector[],
elements: Record<string, INodeProps>,
}
const CONN_TENSION_LEN = 50;
function buildPortPath(pos: ReturnType<typeof getConnectorPortPosDir>): [number, number, number, number] {
const [x1, y1, dir]= pos;
const x2 = dir === 'left'
? x1 - CONN_TENSION_LEN
: dir === 'right'
? x1 + CONN_TENSION_LEN
: x1;
const y2 = dir === 'top'
? y1 - CONN_TENSION_LEN
: dir === 'bottom'
? y1 + CONN_TENSION_LEN
: y1;
return [x1, y1, x2, y2];
}
function ConnectorPath(props: { conn: INodeConnector, elements: Record<string, INodeProps> }) {
const { conn, elements } = props;
const { from: { node: fromNode, port: fromPort }, to: { node: toNode, port: toPort } } = conn;
const fromNodeElem = elements[fromNode];
const toNodeElem = elements[toNode];
const posDirFromPort = getConnectorPortPosDir(fromNodeElem, fromPort)
const posDirToPort = getConnectorPortPosDir(toNodeElem, toPort)
const [p1x1, p1y1, p1x2, p1y2] = buildPortPath(posDirFromPort);
const [p2x1, p2y1, p2x2, p2y2] = buildPortPath(posDirToPort);
function onClick() {
console.log('connector click', fromNode, fromPort, toNode, toPort);
}
return (
<Bezier
lineWidth={5}
color={GRAY}
opacity={OPACITY_75}
x={p1x1} y={p1y1}
cpX={p1x2} cpY={p1y2}
cpX2={p2x2} cpY2={p2y2}
toX={p2x1} toY={p2y1}
click={onClick}
pointertap={onClick}
interactive
/>
);
}
function ConnectorsDrawer(props: ConnectorsDrawerProps) {
const { connectors, elements } = props;
return (
<>
{connectors.map(it => <ConnectorPath key={it.id} conn={it} elements={elements} />)}
</>
);
}
interface IMaybeDrawDragginLineProps {
origin: [number, number],
draggingPos: [number, number],
}
function MaybeDrawDraggingLine(props: IMaybeDrawDragginLineProps) {
const { origin, draggingPos } = props;
if (!origin) {
return null;
}
return (
<Line
x1={origin[X]} x2={draggingPos[X]}
y1={origin[Y]} y2={draggingPos[Y]}
color={GRAY}
lineWidth={5}
opacity={0.5}
/>
);
}
function getDraggingOrigin(it: DraggingPortPosFromTo) {
// @ts-ignore
const sortedAsc = Object.values(it ?? emptyObj as DraggingPortPosFromTo)
.filter(Boolean)
.sort((a, b) => (a?.[2] ?? Number.MAX_VALUE) - (b?.[2] ?? Number.MAX_VALUE));
return sortedAsc?.[0];
}
interface IPixiFlowEngineCtx {
theme?: {
bgColor?: number,
nodeBgColor?: number,
nodeAlpha?: number,
nodeBorderWidth?: number,
nodeBorderColor?: number,
},
viewport?: PixiViewport,
}
const PixiFlowEngineCtx = React.createContext({} as IPixiFlowEngineCtx);
function PixiFlowEngineProvider(props: React.PropsWithChildren<IPixiFlowEngineCtx>) {
const { children, ...rest } = props;
const memoValue = useMemo(() => rest, [rest]);
return (
<PixiFlowEngineCtx.Provider value={memoValue}>
{children}
</PixiFlowEngineCtx.Provider>
);
}
function useGlobalParams() {
const val = useContext(PixiFlowEngineCtx);
return val;
}
function PixiFlowEngine() {
const [currentDrawingPortAddress, setCurrentDrawingPortAddress] = useState<IPortAddressExtra|undefined>(undefined);
const [elements, setElements] = useState<Record<string, INodeProps>>(() => arrayToRecords(defaultElements, 'id'));
const [connectors, setConnectors] = useState(defaultConnectors);
const [draggingFromTo, setDraggingFromTo] = useState<DraggingPortPosFromTo>(undefined);
const [prevDraggingFromTo, setPrevDraggingFromTo] = useState<DraggingPortPosFromTo>(draggingFromTo);
const onPositionChange = useCallback(
(id: string) => (pos: [number, number]) => setElements(els => ({ ...els, [id]: { ...els[id], pos }})),
emptyArr
);
const onSizeChange = useCallback(
(id: string) => (size: [number, number]) => setElements(els => ({ ...els, [id]: { ...els[id], size }})),
emptyArr
);
useEffect(() => {
const origin = getDraggingOrigin(draggingFromTo);
if (origin) {
const [node, portId] = origin;
// ignore when no changes
if (!(currentDrawingPortAddress?.node === node && currentDrawingPortAddress.port.id === portId)) {
const port = elements[node].ports.find(it => it.id === portId)!;
setCurrentDrawingPortAddress({ node, port });
}
} else {
setCurrentDrawingPortAddress(undefined);
}
// console.log('origin, draggingFromTo; prevDraggingFromTo', JSON.stringify(origin), JSON.stringify(draggingFromTo), JSON.stringify(prevDraggingFromTo));
if (draggingFromTo === undefined && prevDraggingFromTo?.from && prevDraggingFromTo.to) {
const { from: draggingFrom, to: draggingTo } = prevDraggingFromTo;
const newId = Date.now().toString(36);
const [fromNode, fromPort] = draggingFrom!;
const [toNode, toPort] = draggingTo!;
const connector = {
id: newId,
from: {
node: fromNode,
port: fromPort,
},
to: {
node: toNode,
port: toPort,
},
} as INodeConnector;
setConnectors((connectors) => [...connectors, connector]);
}
setPrevDraggingFromTo(draggingFromTo);
}, [draggingFromTo, elements]);
const theme = {};
const viewportRef = useRef<PixiViewport|undefined>(undefined);
// console.log('viewportRef', viewportRef.current);
// const context: IPixiFlowEngineCtx = React.useMemo(() => ({ theme, viewport: viewportRef.current }), [theme, viewportRef]);
return (
<PixiFlowEngineProvider theme={theme}>
<Stage width={window.innerWidth} height={600} options={{ backgroundColor: 0xffffff }}>
<Viewport
width={window.innerWidth}
height={600}
>
{Object.values(elements).map((it) => (
<Node
id={it.id}
key={it.id}
pos={it.pos}
size={it.size}
resizable={it.resizable}
text={it.text}
onPositionChange={onPositionChange(it.id)}
onSizeChange={onSizeChange(it.id)}
ports={it.ports}
setDraggingFromTo={setDraggingFromTo}
currentDrawingPortAddress={currentDrawingPortAddress}
/>
))}
<ConnectorsDrawer connectors={connectors} elements={elements} />
</Viewport>
</Stage>
</PixiFlowEngineProvider>
);
}
export default PixiFlowEngine;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment