Last active
November 11, 2021 21:15
-
-
Save rolangom/8427b9560d6299aeb3d6a7bf0d32260e to your computer and use it in GitHub Desktop.
GTFlowEngine
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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