diff --git a/.eslintrc.js b/.eslintrc.js index d755a537..68a0668e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,7 +5,7 @@ const baseRules = { 'no-use-before-define': 0, 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': ['error'], - indent: ['error', 2, { SwitchCase: 1, flatTernaryExpressions: true }], + indent: ['warn', 2, { SwitchCase: 1, flatTernaryExpressions: true }], quotes: ['error', 'single', { avoidEscape: true }], semi: ['error', 'always'], 'import/no-mutable-exports': 0, diff --git a/package.json b/package.json index d059d8cd..b8edf532 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "vue": "^3.0.5" }, "devDependencies": { + "@types/d3": "^7.0.0", "@typescript-eslint/eslint-plugin": "^4.28.1", "@typescript-eslint/parser": "^4.28.1", "@vitejs/plugin-vue": "^1.2.3", diff --git a/src/App.vue b/src/App.vue index 5627368f..5b2e681c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,16 +1,24 @@ diff --git a/src/components/ConnectionLine/index.tsx b/src/components/ConnectionLine/index.tsx new file mode 100644 index 00000000..30496846 --- /dev/null +++ b/src/components/ConnectionLine/index.tsx @@ -0,0 +1,169 @@ +import { ref, defineComponent, CSSProperties, PropType } from 'vue'; + +import { getBezierPath } from '../Edges/BezierEdge'; +import { getSmoothStepPath } from '../Edges/SmoothStepEdge'; +import { + ElementId, + Node, + Transform, + HandleElement, + Position, + ConnectionLineType, + ConnectionLineComponent, + HandleType +} from '../../types'; + +interface ConnectionLineProps { + connectionNodeId: ElementId; + connectionHandleId: ElementId | null; + connectionHandleType: HandleType; + connectionPositionX: number; + connectionPositionY: number; + connectionLineType: ConnectionLineType; + nodes: Node[]; + transform: Transform; + isConnectable: boolean; + connectionLineStyle?: CSSProperties; + CustomConnectionLineComponent?: ConnectionLineComponent; +} + +const ConnectionLine = defineComponent({ + props: { + connectionNodeId: { + type: String as PropType, + required: true + }, + connectionHandleId: { + type: String as PropType, + required: true + }, + connectionHandleType: { + type: String as PropType, + required: true + }, + connectionLineStyle: { + type: Object as PropType, + required: false, + default: () => ({}) + }, + connectionPositionX: { + type: Number as PropType, + required: false, + default: 0 + }, + connectionPositionY: { + type: Number as PropType, + required: false, + default: 0 + }, + connectionLineType: { + type: String as PropType, + required: false, + default: ConnectionLineType.Bezier + }, + nodes: { + type: Array as PropType, + required: true, + default: () => [] + }, + transform: { + type: Object as PropType, + required: false, + default: () => ({}) + }, + isConnectable: { + type: Boolean as PropType, + required: false, + default: false + }, + CustomConnectionLineComponent: { + type: Object as PropType, + required: false, + default: () => ({}) + } + }, + setup(props) { + console.log('connectionline'); + const sourceNode = ref(props.nodes.find((n) => n.id === props.connectionNodeId) || null); + + if (!sourceNode.value || !props.isConnectable) { + return null; + } + + const sourceHandle = props.connectionHandleId + ? sourceNode.value.__rf.handleBounds[props.connectionHandleType].find( + (d: HandleElement) => d.id === props.connectionHandleId + ) + : sourceNode.value.__rf.handleBounds[props.connectionHandleType][0]; + const sourceHandleX = sourceHandle ? sourceHandle.x + sourceHandle.width / 2 : sourceNode.value.__rf.width / 2; + const sourceHandleY = sourceHandle ? sourceHandle.y + sourceHandle.height / 2 : sourceNode.value.__rf.height; + const sourceX = sourceNode.value.__rf.position.x + sourceHandleX; + const sourceY = sourceNode.value.__rf.position.y + sourceHandleY; + + const targetX = (props.connectionPositionX - props.transform[0]) / props.transform[2]; + const targetY = (props.connectionPositionY - props.transform[1]) / props.transform[2]; + + const isRightOrLeft = sourceHandle?.position === Position.Left || sourceHandle?.position === Position.Right; + const targetPosition = isRightOrLeft ? Position.Left : Position.Top; + + if (props.CustomConnectionLineComponent) { + return () => ( + + + + ); + } + + let dAttr = ''; + + if (props.connectionLineType === ConnectionLineType.Bezier) { + dAttr = getBezierPath({ + sourceX, + sourceY, + sourcePosition: sourceHandle?.position, + targetX, + targetY, + targetPosition + }); + } else if (props.connectionLineType === ConnectionLineType.Step) { + dAttr = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition: sourceHandle?.position, + targetX, + targetY, + targetPosition, + borderRadius: 0 + }); + } else if (props.connectionLineType === ConnectionLineType.SmoothStep) { + dAttr = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition: sourceHandle?.position, + targetX, + targetY, + targetPosition + }); + } else { + dAttr = `M${sourceX},${sourceY} ${targetX},${targetY}`; + } + + return () => ( + + + + ); + } +}); + +export default ConnectionLine; diff --git a/src/components/Edges/BezierEdge.tsx b/src/components/Edges/BezierEdge.tsx new file mode 100644 index 00000000..2213ad60 --- /dev/null +++ b/src/components/Edges/BezierEdge.tsx @@ -0,0 +1,163 @@ +import { CSSProperties, defineComponent, PropType } from 'vue'; + +import EdgeText from './EdgeText'; + +import { getMarkerEnd, getCenter } from './utils'; +import { ArrowHeadType, Position } from '../../types'; + +interface GetBezierPathParams { + sourceX: number; + sourceY: number; + sourcePosition?: Position; + targetX: number; + targetY: number; + targetPosition?: Position; + centerX?: number; + centerY?: number; +} + +export function getBezierPath({ + sourceX, + sourceY, + sourcePosition = Position.Bottom, + targetX, + targetY, + targetPosition = Position.Top, + centerX, + centerY +}: GetBezierPathParams): string { + const [_centerX, _centerY] = getCenter({ sourceX, sourceY, targetX, targetY }); + const leftAndRight = [Position.Left, Position.Right]; + + const cX = typeof centerX !== 'undefined' ? centerX : _centerX; + const cY = typeof centerY !== 'undefined' ? centerY : _centerY; + + let path = `M${sourceX},${sourceY} C${sourceX},${cY} ${targetX},${cY} ${targetX},${targetY}`; + + if (leftAndRight.includes(sourcePosition) && leftAndRight.includes(targetPosition)) { + path = `M${sourceX},${sourceY} C${cX},${sourceY} ${cX},${targetY} ${targetX},${targetY}`; + } else if (leftAndRight.includes(targetPosition)) { + path = `M${sourceX},${sourceY} C${sourceX},${targetY} ${sourceX},${targetY} ${targetX},${targetY}`; + } else if (leftAndRight.includes(sourcePosition)) { + path = `M${sourceX},${sourceY} C${targetX},${sourceY} ${targetX},${sourceY} ${targetX},${targetY}`; + } + + return path; +} + +const BezierEdge = defineComponent({ + props: { + sourceX: { + type: Number, + required: true, + default: 0 + }, + sourceY: { + type: Number, + required: true, + default: 0 + }, + targetX: { + type: Number, + required: true, + default: 0 + }, + targetY: { + type: Number, + required: true, + default: 0 + }, + sourcePosition: { + type: String as PropType, + required: true, + default: Position.Bottom + }, + targetPosition: { + type: String as PropType, + required: true, + default: Position.Top + }, + label: { + type: String, + required: true + }, + labelStyle: { + type: Object as PropType, + required: true, + default: () => ({}) + }, + labelShowBg: { + type: Boolean, + required: false, + default: false + }, + labelBgStyle: { + type: String, + required: true + }, + labelBgPadding: { + type: ([0, 0] as any) as PropType<[number, number]>, + required: false, + default: () => [0, 0] as [number, number] + }, + labelBgBorderRadius: { + type: Number, + required: false, + default: 0 + }, + arrowHeadType: { + type: Object as PropType, + required: true + }, + markerEndId: { + type: String, + required: true + }, + style: { + type: Object as PropType, + required: false, + default: () => ({}) + } + }, + setup(props) { + const [centerX, centerY] = getCenter({ + sourceX: props.sourceX, + sourceY: props.sourceY, + targetX: props.targetX, + targetY: props.targetY, + sourcePosition: props.sourcePosition, + targetPosition: props.targetPosition + }); + const path = getBezierPath({ + sourceX: props.sourceX, + sourceY: props.sourceY, + targetX: props.targetX, + targetY: props.targetY, + targetPosition: props.targetPosition + }); + + const text = props.label ? ( + + ) : null; + + const markerEnd = getMarkerEnd(props.arrowHeadType, props.markerEndId); + + return ( + <> + + {text} + + ); + } +}); + +export default BezierEdge; diff --git a/src/components/Edges/EdgeAnchor.tsx b/src/components/Edges/EdgeAnchor.tsx new file mode 100644 index 00000000..ef115c03 --- /dev/null +++ b/src/components/Edges/EdgeAnchor.tsx @@ -0,0 +1,56 @@ +import { Position } from '../../types'; +import { defineComponent, HTMLAttributes, PropType } from 'vue'; + +const shiftX = (x: number, shift: number, position: Position): number => { + if (position === Position.Left) return x - shift; + if (position === Position.Right) return x + shift; + return x; +}; + +const shiftY = (y: number, shift: number, position: Position): number => { + if (position === Position.Top) return y - shift; + if (position === Position.Bottom) return y + shift; + return y; +}; + +export interface EdgeAnchorProps extends HTMLAttributes { + position: Position; + centerX: number; + centerY: number; + radius?: number; +} + +export const EdgeAnchor = defineComponent({ + props: { + position: { + type: String as PropType, + required: true + }, + centerX: { + type: Number as PropType, + required: true + }, + centerY: { + type: Number as PropType, + required: true + }, + radius: { + type: Number as PropType, + required: false, + default: 10 + } + }, + setup(props) { + const radius = props.radius || 10; + return () => ( + + ); + } +}); diff --git a/src/components/Edges/EdgeText.tsx b/src/components/Edges/EdgeText.tsx new file mode 100644 index 00000000..dbe77585 --- /dev/null +++ b/src/components/Edges/EdgeText.tsx @@ -0,0 +1,88 @@ +import { EdgeTextProps, Rect } from '../../types'; +import { defineComponent, PropType, ref, watch } from 'vue'; + +const EdgeText = defineComponent({ + props: { + x: { + type: Number as PropType, + required: true + }, + y: { + type: Number as PropType, + required: true + }, + label: { + type: String as PropType, + required: true + }, + labelStyle: { + type: Object as PropType, + default: () => ({}) + }, + labelShowBg: { + type: Boolean as PropType, + default: true + }, + labelBgStyle: { + type: Object as PropType, + default: () => ({}) + }, + labelBgPadding: { + /* @ts-ignore */ + type: Array as PropType<[number, number]>, + default: () => [2, 4] + }, + labelBgBorderRadius: { + type: Number as PropType, + default: 2 + } + }, + setup(props, { slots }) { + const edgeRef = ref(null); + const edgeTextBox = ref({ x: 0, y: 0, width: 0, height: 0 }); + const label = ref(props.label); + + watch(label, () => { + if (edgeRef.value) { + const textBbox = edgeRef.value.getBBox(); + + edgeRef.value = { + x: textBbox.x, + y: textBbox.y, + width: textBbox.width, + height: textBbox.height + } as any; + } + }); + + if (typeof label.value === 'undefined' || !label.value) { + return null; + } + + return ( + + {props.labelShowBg && ( + + )} + + {label} + + {slots.default ? slots.default() : ''} + + ); + } +}); + +export default EdgeText; diff --git a/src/components/Edges/SmoothStepEdge.tsx b/src/components/Edges/SmoothStepEdge.tsx new file mode 100644 index 00000000..c3c2d806 --- /dev/null +++ b/src/components/Edges/SmoothStepEdge.tsx @@ -0,0 +1,141 @@ +import { defineComponent } from 'vue'; + +import EdgeText from './EdgeText'; +import { getMarkerEnd, getCenter } from './utils'; +import { EdgeSmoothStepProps, Position } from '../../types'; + +// These are some helper methods for drawing the round corners +// The name indicates the direction of the path. "bottomLeftCorner" goes +// from bottom to the left and "leftBottomCorner" goes from left to the bottom. +// We have to consider the direction of the paths because of the animated lines. +const bottomLeftCorner = (x: number, y: number, size: number): string => `L ${x},${y - size}Q ${x},${y} ${x + size},${y}`; +const leftBottomCorner = (x: number, y: number, size: number): string => `L ${x + size},${y}Q ${x},${y} ${x},${y - size}`; +const bottomRightCorner = (x: number, y: number, size: number): string => `L ${x},${y - size}Q ${x},${y} ${x - size},${y}`; +const rightBottomCorner = (x: number, y: number, size: number): string => `L ${x - size},${y}Q ${x},${y} ${x},${y - size}`; +const leftTopCorner = (x: number, y: number, size: number): string => `L ${x + size},${y}Q ${x},${y} ${x},${y + size}`; +const topLeftCorner = (x: number, y: number, size: number): string => `L ${x},${y + size}Q ${x},${y} ${x + size},${y}`; +const topRightCorner = (x: number, y: number, size: number): string => `L ${x},${y + size}Q ${x},${y} ${x - size},${y}`; +const rightTopCorner = (x: number, y: number, size: number): string => `L ${x - size},${y}Q ${x},${y} ${x},${y + size}`; + +interface GetSmoothStepPathParams { + sourceX: number; + sourceY: number; + sourcePosition?: Position; + targetX: number; + targetY: number; + targetPosition?: Position; + borderRadius?: number; + centerX?: number; + centerY?: number; +} + +export function getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition = Position.Bottom, + targetX, + targetY, + targetPosition = Position.Top, + borderRadius = 5, + centerX, + centerY +}: GetSmoothStepPathParams): string { + const [_centerX, _centerY, offsetX, offsetY] = getCenter({ sourceX, sourceY, targetX, targetY }); + const cornerWidth = Math.min(borderRadius, Math.abs(targetX - sourceX)); + const cornerHeight = Math.min(borderRadius, Math.abs(targetY - sourceY)); + const cornerSize = Math.min(cornerWidth, cornerHeight, offsetX, offsetY); + const leftAndRight = [Position.Left, Position.Right]; + const cX = typeof centerX !== 'undefined' ? centerX : _centerX; + const cY = typeof centerY !== 'undefined' ? centerY : _centerY; + + let firstCornerPath; + let secondCornerPath; + + if (sourceX <= targetX) { + firstCornerPath = sourceY <= targetY ? bottomLeftCorner(sourceX, cY, cornerSize) : topLeftCorner(sourceX, cY, cornerSize); + secondCornerPath = sourceY <= targetY ? rightTopCorner(targetX, cY, cornerSize) : rightBottomCorner(targetX, cY, cornerSize); + } else { + firstCornerPath = sourceY < targetY ? bottomRightCorner(sourceX, cY, cornerSize) : topRightCorner(sourceX, cY, cornerSize); + secondCornerPath = sourceY < targetY ? leftTopCorner(targetX, cY, cornerSize) : leftBottomCorner(targetX, cY, cornerSize); + } + + if (leftAndRight.includes(sourcePosition) && leftAndRight.includes(targetPosition)) { + if (sourceX <= targetX) { + firstCornerPath = sourceY <= targetY ? rightTopCorner(cX, sourceY, cornerSize) : rightBottomCorner(cX, sourceY, cornerSize); + secondCornerPath = sourceY <= targetY ? bottomLeftCorner(cX, targetY, cornerSize) : topLeftCorner(cX, targetY, cornerSize); + } else if (sourcePosition === Position.Right && targetPosition === Position.Left) { + // and sourceX > targetX + firstCornerPath = sourceY <= targetY ? leftTopCorner(cX, sourceY, cornerSize) : leftBottomCorner(cX, sourceY, cornerSize); + secondCornerPath = + sourceY <= targetY ? bottomRightCorner(cX, targetY, cornerSize) : topRightCorner(cX, targetY, cornerSize); + } + } else if (leftAndRight.includes(sourcePosition) && !leftAndRight.includes(targetPosition)) { + if (sourceX <= targetX) { + firstCornerPath = + sourceY <= targetY ? rightTopCorner(targetX, sourceY, cornerSize) : rightBottomCorner(targetX, sourceY, cornerSize); + } else { + firstCornerPath = + sourceY <= targetY ? leftTopCorner(targetX, sourceY, cornerSize) : leftBottomCorner(targetX, sourceY, cornerSize); + } + secondCornerPath = ''; + } else if (!leftAndRight.includes(sourcePosition) && leftAndRight.includes(targetPosition)) { + if (sourceX <= targetX) { + firstCornerPath = + sourceY <= targetY ? bottomLeftCorner(sourceX, targetY, cornerSize) : topLeftCorner(sourceX, targetY, cornerSize); + } else { + firstCornerPath = + sourceY <= targetY ? bottomRightCorner(sourceX, targetY, cornerSize) : topRightCorner(sourceX, targetY, cornerSize); + } + secondCornerPath = ''; + } + + return `M ${sourceX},${sourceY}${firstCornerPath}${secondCornerPath}L ${targetX},${targetY}`; +} + +const SmoothStepEdge = defineComponent({ + props: EdgeSmoothStepProps, + setup(props) { + const [centerX, centerY] = getCenter({ + sourceX: props.sourceX, + sourceY: props.sourceY, + targetX: props.targetX, + targetY: props.targetY, + sourcePosition: props.sourcePosition, + targetPosition: props.targetPosition + }); + + const path = getSmoothStepPath({ + sourceX: props.sourceX, + sourceY: props.sourceY, + targetX: props.targetX, + targetY: props.targetY, + sourcePosition: props.sourcePosition, + targetPosition: props.targetPosition, + borderRadius: props.borderRadius + }); + + const markerEnd = getMarkerEnd(props.arrowHeadType, props.markerEndId); + + const text = props.label ? ( + + ) : null; + + return ( + <> + + {text} + + ); + } +}); + +export default SmoothStepEdge; diff --git a/src/components/Edges/StepEdge.tsx b/src/components/Edges/StepEdge.tsx new file mode 100644 index 00000000..19466a41 --- /dev/null +++ b/src/components/Edges/StepEdge.tsx @@ -0,0 +1,14 @@ +import { defineComponent } from 'vue'; + +import { EdgeSmoothStepProps } from '../../types'; +import SmoothStepEdge from './SmoothStepEdge'; + +const StepEdge = defineComponent({ + components: { SmoothStepEdge }, + props: EdgeSmoothStepProps, + setup(props) { + return () => ; + } +}); + +export default StepEdge; diff --git a/src/components/Edges/StraightEdge.tsx b/src/components/Edges/StraightEdge.tsx new file mode 100644 index 00000000..e0a1f910 --- /dev/null +++ b/src/components/Edges/StraightEdge.tsx @@ -0,0 +1,44 @@ +import { defineComponent } from 'vue'; + +import EdgeText from './EdgeText'; +import { getMarkerEnd } from './utils'; +import { EdgeProps } from '../../types'; + +const StraightEdge = defineComponent({ + props: EdgeProps, + setup(props) { + const yOffset = Math.abs(props.targetY - props.sourceY) / 2; + const centerY = props.targetY < props.sourceY ? props.targetY + yOffset : props.targetY - yOffset; + + const xOffset = Math.abs(props.targetX - props.sourceX) / 2; + const centerX = props.targetX < props.sourceX ? props.targetX + xOffset : props.targetX - xOffset; + const markerEnd = getMarkerEnd(props.arrowHeadType, props.markerEndId); + + const text = props.label ? ( + + ) : null; + + return ( + <> + + {text} + + ); + } +}); + +export default StraightEdge; diff --git a/src/components/Edges/index.ts b/src/components/Edges/index.ts new file mode 100644 index 00000000..65beb017 --- /dev/null +++ b/src/components/Edges/index.ts @@ -0,0 +1,4 @@ +export { default as BezierEdge } from './BezierEdge'; +export { default as StepEdge } from './StepEdge'; +export { default as SmoothStepEdge } from './SmoothStepEdge'; +export { default as StraightEdge } from './StraightEdge'; diff --git a/src/components/Edges/utils.ts b/src/components/Edges/utils.ts new file mode 100644 index 00000000..88db3847 --- /dev/null +++ b/src/components/Edges/utils.ts @@ -0,0 +1,54 @@ +import { ArrowHeadType, Position } from '../../types'; + +export const getMarkerEnd = (arrowHeadType?: ArrowHeadType, markerEndId?: string): string => { + if (typeof markerEndId !== 'undefined' && markerEndId) { + return `url(#${markerEndId})`; + } + + return typeof arrowHeadType !== 'undefined' ? `url(#react-flow__${arrowHeadType})` : 'none'; +}; + +export interface GetCenterParams { + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + sourcePosition?: Position; + targetPosition?: Position; +} + +const LeftOrRight = [Position.Left, Position.Right]; + +export const getCenter = ({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition = Position.Bottom, + targetPosition = Position.Top +}: GetCenterParams): [number, number, number, number] => { + const sourceIsLeftOrRight = LeftOrRight.includes(sourcePosition); + const targetIsLeftOrRight = LeftOrRight.includes(targetPosition); + + // we expect flows to be horizontal or vertical (all handles left or right respectively top or bottom) + // a mixed edge is when one the source is on the left and the target is on the top for example. + const mixedEdge = (sourceIsLeftOrRight && !targetIsLeftOrRight) || (targetIsLeftOrRight && !sourceIsLeftOrRight); + + if (mixedEdge) { + const xOffset = sourceIsLeftOrRight ? Math.abs(targetX - sourceX) : 0; + const centerX = sourceX > targetX ? sourceX - xOffset : sourceX + xOffset; + + const yOffset = sourceIsLeftOrRight ? 0 : Math.abs(targetY - sourceY); + const centerY = sourceY < targetY ? sourceY + yOffset : sourceY - yOffset; + + return [centerX, centerY, xOffset, yOffset]; + } + + const xOffset = Math.abs(targetX - sourceX) / 2; + const centerX = targetX < sourceX ? targetX + xOffset : targetX - xOffset; + + const yOffset = Math.abs(targetY - sourceY) / 2; + const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset; + + return [centerX, centerY, xOffset, yOffset]; +}; diff --git a/src/components/Edges/wrapEdge.tsx b/src/components/Edges/wrapEdge.tsx new file mode 100644 index 00000000..f2dad75e --- /dev/null +++ b/src/components/Edges/wrapEdge.tsx @@ -0,0 +1,183 @@ +import { Component, computed, defineComponent, ref } from 'vue'; + +import store from '../../store'; +import { Edge, EdgeProps, Position, WrapEdgeProps } from '../../types'; +import { onMouseDown } from '../Handle/handler'; +import { EdgeAnchor } from './EdgeAnchor'; + +export default (EdgeComponent: any): Component => { + return defineComponent({ + components: { EdgeComponent }, + props: WrapEdgeProps, + setup(props) { + console.log('wrap'); + const pinia = store(); + + const updating = ref(false); + + const inactive = !props.elementsSelectable && !props.onClick; + const edgeClasses = [ + 'react-flow__edge', + `react-flow__edge-${props.type}`, + { selected: props.selected, animated: props.animated, inactive, updating } + ]; + + const edgeElement = computed(() => { + const el: Edge = { + id: props.id || '', + source: props.source || '', + target: props.target || '', + type: props.type + }; + + if (props.sourceHandleId) { + el.sourceHandle = props.sourceHandleId; + } + + if (props.targetHandleId) { + el.targetHandle = props.targetHandleId; + } + + if (typeof props.data !== 'undefined') { + el.data = props.data; + } + + return el; + }); + + const onEdgeClick = (event: MouseEvent) => { + if (props.elementsSelectable) { + pinia.unsetNodesSelection(); + pinia.addSelectedElements(edgeElement.value as any); + } + + props.onClick?.(event, edgeElement.value); + }; + + const onEdgeContextMenu = (event: MouseEvent) => { + props.onContextMenu?.(event, edgeElement.value); + }; + + const onEdgeMouseEnter = (event: MouseEvent) => { + props.onMouseEnter?.(event, edgeElement.value); + }; + + const onEdgeMouseMove = (event: MouseEvent) => { + props.onMouseMove?.(event, edgeElement.value); + }; + + const onEdgeMouseLeave = (event: MouseEvent) => { + props.onMouseLeave?.(event, edgeElement.value); + }; + + const handleEdgeUpdater = (event: MouseEvent, isSourceHandle: boolean) => { + const nodeId = isSourceHandle ? props.target : props.source; + const handleId = isSourceHandle ? props.targetHandleId : props.sourceHandleId; + const isValidConnection = () => true; + const isTarget = isSourceHandle; + + props.onEdgeUpdateStart?.(event, edgeElement.value); + + const _onEdgeUpdate = props.onEdgeUpdateEnd + ? (evt: MouseEvent) => { + if (props.onEdgeUpdateEnd) props.onEdgeUpdateEnd(evt, edgeElement.value); + } + : undefined; + + onMouseDown( + event, + handleId, + nodeId || '', + pinia.setConnectionNodeId, + pinia.setConnectionPosition, + props.onConnectEdge, + isTarget, + isValidConnection, + pinia.connectionMode, + isSourceHandle ? 'target' : 'source', + _onEdgeUpdate + ); + }; + + const onEdgeUpdaterSourceMouseDown = (event: MouseEvent) => { + handleEdgeUpdater(event, true); + }; + + const onEdgeUpdaterTargetMouseDown = (event: MouseEvent) => { + handleEdgeUpdater(event, false); + }; + + const onEdgeUpdaterMouseEnter = () => (updating.value = true); + const onEdgeUpdaterMouseOut = () => (updating.value = false); + + if (props.isHidden) { + return null; + } + + return () => ( + + + {props.handleEdgeUpdate && ( + + + + )} + {props.handleEdgeUpdate && ( + + + + )} + + ); + } + }); +}; diff --git a/src/components/Handle/handler.ts b/src/components/Handle/handler.ts new file mode 100644 index 00000000..1ee7105c --- /dev/null +++ b/src/components/Handle/handler.ts @@ -0,0 +1,193 @@ +import { getHostForElement } from '../../utils'; + +import { + ElementId, + XYPosition, + OnConnectFunc, + OnConnectStartFunc, + OnConnectStopFunc, + OnConnectEndFunc, + ConnectionMode, + SetConnectionId, + Connection, + HandleType +} from '../../types'; + +export type ValidConnectionFunc = (connection: Connection) => boolean; +export type SetSourceIdFunc = (params: SetConnectionId) => void; + +export type SetPosition = (pos: XYPosition) => void; + +type Result = { + elementBelow: Element | null; + isValid: boolean; + connection: Connection; + isHoveringHandle: boolean; +}; + +// checks if element below mouse is a handle and returns connection in form of an object { source: 123, target: 312 } +function checkElementBelowIsValid( + event: MouseEvent, + connectionMode: ConnectionMode, + isTarget: boolean, + nodeId: ElementId, + handleId: ElementId | null, + isValidConnection: ValidConnectionFunc, + doc: Document | ShadowRoot +) { + const elementBelow = doc.elementFromPoint(event.clientX, event.clientY); + const elementBelowIsTarget = elementBelow?.classList.contains('target') || false; + const elementBelowIsSource = elementBelow?.classList.contains('source') || false; + + const result: Result = { + elementBelow, + isValid: false, + connection: { source: null, target: null, sourceHandle: null, targetHandle: null }, + isHoveringHandle: false + }; + + if (elementBelow && (elementBelowIsTarget || elementBelowIsSource)) { + result.isHoveringHandle = true; + + // in strict mode we don't allow target to target or source to source connections + const isValid = + connectionMode === ConnectionMode.Strict ? (isTarget && elementBelowIsSource) || (!isTarget && elementBelowIsTarget) : true; + + if (isValid) { + const elementBelowNodeId = elementBelow.getAttribute('data-nodeid'); + const elementBelowHandleId = elementBelow.getAttribute('data-handleid'); + const connection: Connection = isTarget + ? { + source: elementBelowNodeId, + sourceHandle: elementBelowHandleId, + target: nodeId, + targetHandle: handleId + } + : { + source: nodeId, + sourceHandle: handleId, + target: elementBelowNodeId, + targetHandle: elementBelowHandleId + }; + + result.connection = connection; + result.isValid = isValidConnection(connection); + } + } + + return result; +} + +function resetRecentHandle(hoveredHandle: Element): void { + hoveredHandle?.classList.remove('react-flow__handle-valid'); + hoveredHandle?.classList.remove('react-flow__handle-connecting'); +} + +export function onMouseDown( + event: MouseEvent, + handleId: ElementId | null, + nodeId: ElementId, + setConnectionNodeId: SetSourceIdFunc, + setPosition: SetPosition, + onConnect: OnConnectFunc, + isTarget: boolean, + isValidConnection: ValidConnectionFunc, + connectionMode: ConnectionMode, + elementEdgeUpdaterType?: HandleType, + onEdgeUpdateEnd?: (evt: MouseEvent) => void, + onConnectStart?: OnConnectStartFunc, + onConnectStop?: OnConnectStopFunc, + onConnectEnd?: OnConnectEndFunc +): void { + const reactFlowNode = (event.target as Element).closest('.react-flow'); + // when react-flow is used inside a shadow root we can't use document + const doc = getHostForElement(event.target as HTMLElement); + + console.log(doc); + + if (!doc) { + return; + } + + const elementBelow = doc.elementFromPoint(event.clientX, event.clientY); + const elementBelowIsTarget = elementBelow?.classList.contains('target'); + const elementBelowIsSource = elementBelow?.classList.contains('source'); + + if (!reactFlowNode || (!elementBelowIsTarget && !elementBelowIsSource && !elementEdgeUpdaterType)) { + return; + } + + const handleType = elementEdgeUpdaterType ? elementEdgeUpdaterType : elementBelowIsTarget ? 'target' : 'source'; + const containerBounds = reactFlowNode.getBoundingClientRect(); + let recentHoveredHandle: Element; + + setPosition({ + x: event.clientX - containerBounds.left, + y: event.clientY - containerBounds.top + }); + + setConnectionNodeId({ connectionNodeId: nodeId, connectionHandleId: handleId, connectionHandleType: handleType }); + onConnectStart?.(event, { nodeId, handleId, handleType }); + + function onMouseMove(event: MouseEvent) { + setPosition({ + x: event.clientX - containerBounds.left, + y: event.clientY - containerBounds.top + }); + + const { connection, elementBelow, isValid, isHoveringHandle } = checkElementBelowIsValid( + event, + connectionMode, + isTarget, + nodeId, + handleId, + isValidConnection, + doc + ); + + if (!isHoveringHandle) { + return resetRecentHandle(recentHoveredHandle); + } + + const isOwnHandle = connection.source === connection.target; + + if (!isOwnHandle && elementBelow) { + recentHoveredHandle = elementBelow; + elementBelow.classList.add('react-flow__handle-connecting'); + elementBelow.classList.toggle('react-flow__handle-valid', isValid); + } + } + + function onMouseUp(event: MouseEvent) { + const { connection, isValid } = checkElementBelowIsValid( + event, + connectionMode, + isTarget, + nodeId, + handleId, + isValidConnection, + doc + ); + + onConnectStop?.(event); + + if (isValid) { + onConnect?.(connection); + } + + onConnectEnd?.(event); + + if (elementEdgeUpdaterType && onEdgeUpdateEnd) { + onEdgeUpdateEnd(event); + } + + resetRecentHandle(recentHoveredHandle); + setConnectionNodeId({ connectionNodeId: null, connectionHandleId: null, connectionHandleType: null }); + + doc.removeEventListener('mousemove', onMouseMove as EventListenerOrEventListenerObject); + doc.removeEventListener('mouseup', onMouseUp as EventListenerOrEventListenerObject); + } + + doc.addEventListener('mousemove', onMouseMove as EventListenerOrEventListenerObject); + doc.addEventListener('mouseup', onMouseUp as EventListenerOrEventListenerObject); +} diff --git a/src/components/Handle/index.tsx b/src/components/Handle/index.tsx new file mode 100644 index 00000000..26e9ea00 --- /dev/null +++ b/src/components/Handle/index.tsx @@ -0,0 +1,97 @@ +import { Connection, ElementId, Position } from '../../types'; + +import { onMouseDown, ValidConnectionFunc } from './handler'; +import { defineComponent, inject, PropType } from 'vue'; +import store from '../../store'; + +const alwaysValid = () => true; + +const Handle = defineComponent({ + props: { + type: { + type: String, + required: false, + default: 'source' + }, + position: { + type: String as PropType, + required: false, + default: Position.Top + }, + isValidConnection: { + type: Function as PropType, + required: false, + default: alwaysValid + }, + isConnectable: { + type: Boolean, + required: false, + default: true + }, + id: { + type: String, + required: false, + default: undefined + }, + onConnect: { + type: Function, + required: false, + default: undefined + } + }, + setup(props, { slots }) { + const pinia = store(); + const nodeId = inject('NodeIdContext') as ElementId; + const handleId = props.id || null; + const isTarget = props.type === 'target'; + + const onConnectExtended = (params: Connection) => { + pinia.onConnect?.(params); + props.onConnect?.(params); + }; + + const onMouseDownHandler = (event: MouseEvent) => { + onMouseDown( + event, + handleId, + nodeId, + pinia.setConnectionNodeId, + pinia.setConnectionPosition, + onConnectExtended, + isTarget, + props.isValidConnection, + pinia.connectionMode, + undefined, + undefined, + pinia.onConnectStart, + pinia.onConnectStop, + pinia.onConnectEnd + ); + }; + + const handleClasses = [ + 'react-flow__handle', + `react-flow__handle-${props.position}`, + 'nodrag', + { + source: !isTarget, + target: isTarget, + connectable: props.isConnectable + } + ]; + + return () => ( +
+ {slots.default ? slots.default() : ''} +
+ ); + } +}); + +export default Handle; diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue deleted file mode 100644 index 975da936..00000000 --- a/src/components/HelloWorld.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - - - diff --git a/src/components/Nodes/DefaultNode.tsx b/src/components/Nodes/DefaultNode.tsx new file mode 100644 index 00000000..0f541422 --- /dev/null +++ b/src/components/Nodes/DefaultNode.tsx @@ -0,0 +1,41 @@ +import Handle from '../../components/Handle'; +import { NodeProps, Position } from '../../types'; +import { defineComponent, PropType } from 'vue'; + +const DefaultNode = defineComponent({ + name: 'DefaultNode', + components: { Handle }, + props: { + data: { + type: Object as PropType, + required: false, + default: undefined as any + }, + isConnectable: { + type: Boolean as PropType, + required: false, + default: undefined + }, + targetPosition: { + type: String as PropType, + required: false, + default: Position.Top + }, + sourcePosition: { + type: String as PropType, + required: false, + default: Position.Bottom + } + }, + setup(props) { + return () => ( + <> + + {props.data?.label} + + + ); + } +}); + +export default DefaultNode; diff --git a/src/components/Nodes/InputNode.tsx b/src/components/Nodes/InputNode.tsx new file mode 100644 index 00000000..548e68eb --- /dev/null +++ b/src/components/Nodes/InputNode.tsx @@ -0,0 +1,35 @@ +import Handle from '../../components/Handle'; +import { NodeProps, Position } from '../../types'; +import { defineComponent, PropType } from 'vue'; + +const InputNode = defineComponent({ + name: 'InputNode', + components: { Handle }, + props: { + data: { + type: Object as PropType, + required: false, + default: undefined as any + }, + isConnectable: { + type: Boolean as PropType, + required: false, + default: false + }, + sourcePosition: { + type: String as PropType, + required: false, + default: Position.Bottom + } + }, + setup(props) { + return () => ( + <> + {props.data?.label} + + + ); + } +}); + +export default InputNode; diff --git a/src/components/Nodes/OutputNode.tsx b/src/components/Nodes/OutputNode.tsx new file mode 100644 index 00000000..1a0fb900 --- /dev/null +++ b/src/components/Nodes/OutputNode.tsx @@ -0,0 +1,35 @@ +import Handle from '../../components/Handle'; +import { NodeProps, Position } from '../../types'; +import { defineComponent, PropType } from 'vue'; + +const OutputNode = defineComponent({ + name: 'OutputNode', + components: { Handle }, + props: { + data: { + type: Object as PropType, + required: false, + default: undefined as any + }, + isConnectable: { + type: Boolean as PropType, + required: false, + default: false + }, + targetPosition: { + type: String as PropType, + required: false, + default: Position.Bottom + } + }, + setup(props) { + return () => ( + <> + {props.data?.label} + + + ); + } +}); + +export default OutputNode; diff --git a/src/components/Nodes/utils.ts b/src/components/Nodes/utils.ts new file mode 100644 index 00000000..6bcf2b28 --- /dev/null +++ b/src/components/Nodes/utils.ts @@ -0,0 +1,41 @@ +import { HandleElement, Position } from '../../types'; +import { getDimensions } from '../../utils'; + +export const getHandleBounds = (nodeElement: HTMLDivElement, scale: number) => { + const bounds = nodeElement.getBoundingClientRect(); + + return { + source: getHandleBoundsByHandleType('.source', nodeElement, bounds, scale), + target: getHandleBoundsByHandleType('.target', nodeElement, bounds, scale) + }; +}; + +export const getHandleBoundsByHandleType = ( + selector: string, + nodeElement: HTMLDivElement, + parentBounds: ClientRect | DOMRect, + k: number +): HandleElement[] | null => { + const handles = nodeElement.querySelectorAll(selector); + + if (!handles || !handles.length) { + return null; + } + + const handlesArray = Array.from(handles) as HTMLDivElement[]; + + return handlesArray.map((handle): HandleElement => { + const bounds = handle.getBoundingClientRect(); + const dimensions = getDimensions(handle); + const handleId = handle.getAttribute('data-handleid'); + const handlePosition = handle.getAttribute('data-handlepos') as unknown as Position; + + return { + id: handleId, + position: handlePosition, + x: (bounds.left - parentBounds.left) / k, + y: (bounds.top - parentBounds.top) / k, + ...dimensions + }; + }); +}; diff --git a/src/components/Nodes/wrapNode.tsx b/src/components/Nodes/wrapNode.tsx new file mode 100644 index 00000000..155233b4 --- /dev/null +++ b/src/components/Nodes/wrapNode.tsx @@ -0,0 +1,201 @@ +import { Node, WrapNodeProps } from '../../types'; +import { computed, CSSProperties, defineComponent, onMounted, provide, ref } from 'vue'; +import store from '../../store'; + +export default (NodeComponent: any) => { + return defineComponent({ + props: WrapNodeProps, + setup(props) { + const pinia = store(); + provide('NodeIdContext', props.id); + + const nodeElement = ref(null); + + const node = computed(() => ({ + id: props.id, + type: props.type, + position: { x: props.xPos, y: props.yPos }, + data: props.data + })); + // const grid = computed(() => (props.snapToGrid ? props.snapGrid : [1, 1])! as [number, number]); + + const nodeStyle = computed(() => ({ + zIndex: props.selected ? 10 : 3, + transform: `translate(${props.xPos}px,${props.yPos}px)`, + pointerEvents: + props.isSelectable || + props.isDraggable || + props.onClick || + props.onMouseEnter || + props.onMouseMove || + props.onMouseLeave + ? 'all' + : 'none', + // prevents jumping of nodes on start + opacity: props.isInitialized ? 1 : 0, + ...props.style + })); + const onMouseEnterHandler = () => { + if (!props.onMouseEnter || props.isDragging) { + return; + } + + return (event: MouseEvent) => props.onMouseEnter && props.onMouseEnter(event, node.value as Node); + }; + + const onMouseMoveHandler = () => { + if (!props.onMouseMove || props.isDragging) { + return; + } + + return (event: MouseEvent) => props.onMouseMove && props.onMouseMove(event, node.value as Node); + }; + + const onMouseLeaveHandler = () => { + if (!props.onMouseLeave || props.isDragging) { + return; + } + + return (event: MouseEvent) => props.onMouseLeave && props.onMouseLeave(event, node.value as Node); + }; + + const onContextMenuHandler = () => { + if (!props.onContextMenu) { + return; + } + + return (event: MouseEvent) => props.onContextMenu && props.onContextMenu(event, node.value as Node); + }; + + const onSelectNodeHandler = (event: MouseEvent) => { + if (!props.isDraggable) { + if (props.isSelectable) { + pinia.unsetNodesSelection(); + + if (!props.selected) { + pinia.addSelectedElements([node.value as Node]); + } + } + + props.onClick?.(event, node.value as Node); + } + }; + + /** + const onDragStart = (event: DraggableEvent) => { + onNodeDragStart?.(event as MouseEvent, node); + + if (selectNodesOnDrag && isSelectable) { + unsetNodesSelection(); + + if (!selected) { + addSelectedElements(node); + } + } else if (!selectNodesOnDrag && !selected && isSelectable) { + unsetNodesSelection(); + addSelectedElements([]); + } + }; + + const onDrag = useCallback( + (event: DraggableEvent, draggableData: DraggableData) => { + if (onNodeDrag) { + node.position.x += draggableData.deltaX; + node.position.y += draggableData.deltaY; + onNodeDrag(event as MouseEvent, node); + } + + updateNodePosDiff({ + id, + diff: { + x: draggableData.deltaX, + y: draggableData.deltaY + }, + isDragging: true + }); + }, + [id, node, onNodeDrag] + ); + + const onDragStop = useCallback( + (event: DraggableEvent) => { + // onDragStop also gets called when user just clicks on a node. + // Because of that we set dragging to true inside the onDrag handler and handle the click here + if (!isDragging) { + if (isSelectable && !selectNodesOnDrag && !selected) { + addSelectedElements(node); + } + + onClick?.(event as MouseEvent, node); + + return; + } + + updateNodePosDiff({ + id: node.id, + isDragging: false + }); + + onNodeDragStop?.(event as MouseEvent, node); + }, + [node, isSelectable, selectNodesOnDrag, onClick, onNodeDragStop, isDragging, selected] + ); + */ + + onMounted(() => { + if (nodeElement.value && !props.isHidden) { + pinia.updateNodeDimensions([{ id: props.id || '', nodeElement: nodeElement.value, forceUpdate: true }]); + } + }); + + onMounted(() => { + if (nodeElement.value && typeof props.resizeObserver?.observe === 'function') { + const currNode = nodeElement.value; + props.resizeObserver?.observe(currNode); + + return () => props.resizeObserver?.unobserve(currNode); + } + }); + + if (props.isHidden) { + return null; + } + + const nodeClasses = [ + 'react-flow__node', + `react-flow__node-${props.type}`, + { + selected: props.selected, + selectable: props.isSelectable + } + ]; + + return () => ( +
+ +
+ ); + } + }); +}; diff --git a/src/components/NodesSelection/index.tsx b/src/components/NodesSelection/index.tsx new file mode 100644 index 00000000..20d54deb --- /dev/null +++ b/src/components/NodesSelection/index.tsx @@ -0,0 +1,123 @@ +import { isNode } from '../../utils/graph'; +import { Node } from '../../types'; + +export interface NodesSelectionProps { + onSelectionDragStart?: (event: MouseEvent, nodes: Node[]) => void; + onSelectionDrag?: (event: MouseEvent, nodes: Node[]) => void; + onSelectionDragStop?: (event: MouseEvent, nodes: Node[]) => void; + onSelectionContextMenu?: (event: MouseEvent, nodes: Node[]) => void; +} + +export default ({ onSelectionDragStart, onSelectionDrag, onSelectionDragStop, onSelectionContextMenu }: NodesSelectionProps) => { + const [tX, tY, tScale] = useStoreState((state) => state.transform); + const selectedNodesBbox = useStoreState((state) => state.selectedNodesBbox); + const selectionActive = useStoreState((state) => state.selectionActive); + const selectedElements = useStoreState((state) => state.selectedElements); + const snapToGrid = useStoreState((state) => state.snapToGrid); + const snapGrid = useStoreState((state) => state.snapGrid); + const nodes = useStoreState((state) => state.nodes); + + const updateNodePosDiff = useStoreActions((actions) => actions.updateNodePosDiff); + + const nodeRef = useRef(null); + + const grid = useMemo(() => (snapToGrid ? snapGrid : [1, 1])! as [number, number], [snapToGrid, snapGrid]); + + const selectedNodes = useMemo( + () => + selectedElements + ? selectedElements.filter(isNode).map((selectedNode) => { + const matchingNode = nodes.find((node) => node.id === selectedNode.id); + + return { + ...matchingNode, + position: matchingNode?.__rf.position + } as Node; + }) + : [], + [selectedElements, nodes] + ); + + const style = useMemo( + () => ({ + transform: `translate(${tX}px,${tY}px) scale(${tScale})` + }), + [tX, tY, tScale] + ); + + const innerStyle = useMemo( + () => ({ + width: selectedNodesBbox.width, + height: selectedNodesBbox.height, + top: selectedNodesBbox.y, + left: selectedNodesBbox.x + }), + [selectedNodesBbox] + ); + + const onStart = useCallback( + (event: MouseEvent) => { + onSelectionDragStart?.(event, selectedNodes); + }, + [onSelectionDragStart, selectedNodes] + ); + + const onDrag = useCallback( + (event: MouseEvent, data: DraggableData) => { + if (onSelectionDrag) { + onSelectionDrag(event, selectedNodes); + } + + updateNodePosDiff({ + diff: { + x: data.deltaX, + y: data.deltaY + }, + isDragging: true + }); + }, + [onSelectionDrag, selectedNodes, updateNodePosDiff] + ); + + const onStop = useCallback( + (event: MouseEvent) => { + updateNodePosDiff({ + isDragging: false + }); + + onSelectionDragStop?.(event, selectedNodes); + }, + [selectedNodes, onSelectionDragStop] + ); + + const onContextMenu = useCallback( + (event: MouseEvent) => { + const selectedNodes = selectedElements + ? selectedElements.filter(isNode).map((selectedNode) => nodes.find((node) => node.id === selectedNode.id)!) + : []; + + onSelectionContextMenu?.(event, selectedNodes); + }, + [onSelectionContextMenu] + ); + + if (!selectedElements || selectionActive) { + return null; + } + + return ( +
+ onStart(event as MouseEvent)} + onDrag={(event, data) => onDrag(event as MouseEvent, data)} + onStop={(event) => onStop(event as MouseEvent)} + nodeRef={nodeRef} + enableUserSelectHack={false} + > +
+ +
+ ); +}; diff --git a/src/components/UserSelection/index.tsx b/src/components/UserSelection/index.tsx new file mode 100644 index 00000000..3d7de3ae --- /dev/null +++ b/src/components/UserSelection/index.tsx @@ -0,0 +1,104 @@ +/** + * The user selection rectangle gets displayed when a user drags the mouse while pressing shift + */ +import { XYPosition } from '../../types'; +import { defineComponent, PropType } from 'vue'; +import store from '../../store'; + +type UserSelectionProps = { + selectionKeyPressed: boolean; +}; + +function getMousePosition(event: MouseEvent): XYPosition | void { + const reactFlowNode = (event.target as Element).closest('.react-flow'); + if (!reactFlowNode) { + return; + } + + const containerBounds = reactFlowNode.getBoundingClientRect(); + + return { + x: event.clientX - containerBounds.left, + y: event.clientY - containerBounds.top + }; +} + +const SelectionRect = defineComponent({ + setup() { + const pinia = store(); + + if (!pinia.userSelectionRect.draw) { + return null; + } + + return () => ( +
+ ); + } +}); + +export default defineComponent({ + components: { SelectionRect }, + props: { + selectionKeyPressed: { + type: Boolean as PropType, + required: true + } + }, + setup(props) { + const pinia = store(); + const renderUserSelectionPane = pinia.selectionActive || props.selectionKeyPressed; + + if (!pinia.elementsSelectable || !renderUserSelectionPane) { + return null; + } + + const onMouseDown = (event: MouseEvent): void => { + const mousePos = getMousePosition(event); + if (!mousePos) { + return; + } + + pinia.setUserSelection(mousePos); + }; + + const onMouseMove = (event: MouseEvent): void => { + if (!props.selectionKeyPressed || !pinia.selectionActive) { + return; + } + const mousePos = getMousePosition(event); + + if (!mousePos) { + return; + } + + pinia.updateUserSelection(mousePos); + }; + + const onMouseUp = () => pinia.unsetUserSelection(); + + const onMouseLeave = () => { + pinia.unsetUserSelection(); + pinia.unsetNodesSelection(); + }; + + return () => ( +
+ +
+ ); + } +}); diff --git a/src/container/EdgeRenderer/MarkerDefinitions.tsx b/src/container/EdgeRenderer/MarkerDefinitions.tsx new file mode 100644 index 00000000..cd8a8b01 --- /dev/null +++ b/src/container/EdgeRenderer/MarkerDefinitions.tsx @@ -0,0 +1,72 @@ +import { defineComponent, PropType } from 'vue'; + +interface MarkerProps { + id: string; +} + +const Marker = defineComponent({ + props: { + id: { + type: String as PropType, + required: true + } + }, + setup(props, { slots }) { + return () => ( + + {slots.default ? slots.default() : ''} + + ); + } +}); + +interface MarkerDefinitionsProps { + color: string; +} + +const MarkerDefinitions = defineComponent({ + name: 'MarkerDefinitions', + props: { + color: { + type: String as PropType, + required: true + } + }, + setup(props) { + return () => ( + + + + + + + + + ); + } +}); + +export default MarkerDefinitions; diff --git a/src/container/EdgeRenderer/index.tsx b/src/container/EdgeRenderer/index.tsx new file mode 100644 index 00000000..7b9521f7 --- /dev/null +++ b/src/container/EdgeRenderer/index.tsx @@ -0,0 +1,368 @@ +import ConnectionLine from '../../components/ConnectionLine/index'; +import { isEdge } from '../../utils/graph'; +import MarkerDefinitions from './MarkerDefinitions'; +import { getEdgePositions, getHandle, isEdgeVisible, getSourceTargetNodes } from './utils'; +import { + Position, + Edge, + Node, + Elements, + Connection, + ConnectionLineType, + ConnectionLineComponent, + ConnectionMode, + Transform, + OnEdgeUpdateFunc +} from '../../types'; +import { computed, CSSProperties, defineComponent, PropType, ref } from 'vue'; +import store from '../../store'; + +interface EdgeRendererProps { + edgeTypes: any; + connectionLineType: ConnectionLineType; + connectionLineStyle?: CSSProperties; + connectionLineComponent?: ConnectionLineComponent; + connectionMode?: ConnectionMode; + onElementClick?: (event: MouseEvent, element: Node | Edge) => void; + onEdgeDoubleClick?: (event: MouseEvent, edge: Edge) => void; + arrowHeadColor: string; + markerEndId?: string; + onlyRenderVisibleElements: boolean; + onEdgeUpdate?: OnEdgeUpdateFunc; + onEdgeContextMenu?: (event: MouseEvent, edge: Edge) => void; + onEdgeMouseEnter?: (event: MouseEvent, edge: Edge) => void; + onEdgeMouseMove?: (event: MouseEvent, edge: Edge) => void; + onEdgeMouseLeave?: (event: MouseEvent, edge: Edge) => void; + onEdgeUpdateStart?: (event: MouseEvent, edge: Edge) => void; + onEdgeUpdateEnd?: (event: MouseEvent, edge: Edge) => void; + edgeUpdaterRadius?: number; +} + +interface EdgeWrapperProps { + edge: Edge; + props: EdgeRendererProps; + nodes: Node[]; + selectedElements: Elements | null; + elementsSelectable: boolean; + transform: Transform; + width: number; + height: number; + onlyRenderVisibleElements: boolean; + connectionMode?: ConnectionMode; +} + +const EdgeCmp = defineComponent({ + props: { + edge: { + type: Object as PropType, + required: true + }, + props: { + type: Object as PropType, + required: true + }, + nodes: { + type: Array as unknown as PropType, + required: true + }, + selectedElements: { + type: Array as unknown as PropType, + required: false, + default: undefined + }, + elementsSelectable: { + type: Boolean as PropType, + required: false, + default: false + }, + transform: { + type: Array as unknown as PropType, + required: false, + default: () => [] + }, + width: { + type: Number as PropType, + required: false, + default: undefined + }, + height: { + type: Number as PropType, + required: false, + default: undefined + }, + onlyRenderVisibleElements: { + type: Boolean as PropType, + required: false, + default: false + }, + connectionMode: { + type: String as PropType, + required: false, + default: undefined + } + }, + setup(props) { + const sourceHandleId = props.edge.sourceHandle || null; + const targetHandleId = props.edge.targetHandle || null; + const nodes = computed(() => { + console.log('foo'); + return getSourceTargetNodes(props.edge, props.nodes); + }); + const { sourceNode, targetNode } = nodes.value; + console.log(nodes.value); + + const onConnectEdge = (connection: Connection) => { + props.props.onEdgeUpdate?.(props.edge, connection); + }; + + if (!sourceNode) { + console.log(`couldn't create edge for source id: ${props.edge.source}; edge id: ${props.edge.id}`); + return null; + } + + if (!targetNode) { + console.log(`couldn't create edge for target id: ${props.edge.target}; edge id: ${props.edge.id}`); + return null; + } + + // source and target node need to be initialized + if (!sourceNode.__rf.width || !targetNode.__rf.width) { + return null; + } + + const edgeType = props.edge.type || 'default'; + const EdgeComponent: any = props.props.edgeTypes[edgeType] || props.props.edgeTypes.default; + const targetNodeBounds = targetNode.__rf.handleBounds; + // when connection type is loose we can define all handles as sources + const targetNodeHandles = + props.connectionMode === ConnectionMode.Strict + ? targetNodeBounds.target + : targetNodeBounds.target || targetNodeBounds.source; + const sourceHandle = getHandle(sourceNode.__rf.handleBounds.source, sourceHandleId); + const targetHandle = getHandle(targetNodeHandles, targetHandleId); + const sourcePosition = sourceHandle ? sourceHandle.position : Position.Bottom; + const targetPosition = targetHandle ? targetHandle.position : Position.Top; + + if (!sourceHandle) { + console.log(`couldn't create edge for source handle id: ${sourceHandleId}; edge id: ${props.edge.id}`); + return null; + } + + if (!targetHandle) { + console.log(`couldn't create edge for target handle id: ${targetHandleId}; edge id: ${props.edge.id}`); + return null; + } + + const { sourceX, sourceY, targetX, targetY } = getEdgePositions( + sourceNode, + sourceHandle, + sourcePosition, + targetNode, + targetHandle, + targetPosition + ); + + const isVisible = computed(() => { + return props.onlyRenderVisibleElements + ? isEdgeVisible({ + sourcePos: { x: sourceX, y: sourceY }, + targetPos: { x: targetX, y: targetY }, + width: props.width || 0, + height: props.height || 0, + transform: props.transform + }) + : true; + }); + + if (!isVisible.value) { + return null; + } + + const isSelected = computed(() => props.selectedElements?.some((elm) => isEdge(elm) && elm.id === props.edge.id) || false); + + return () => ( + + ); + } +}); + +const EdgeRenderer = defineComponent({ + name: 'EdgeRenderer', + components: { EdgeCmp, ConnectionLine }, + props: { + edgeTypes: { + type: Object, + required: false, + default: undefined + }, + connectionLineType: { + type: String as PropType, + required: false, + default: undefined + }, + connectionLineStyle: { + type: Object as PropType, + required: false, + default: undefined + }, + connectionLineComponent: { + type: Object as PropType, + required: false, + default: undefined + }, + connectionMode: { + type: String as PropType, + required: false, + default: undefined + }, + onElementClick: { + type: Function() as PropType, + required: false, + default: undefined + }, + onEdgeDoubleClick: { + type: Function() as PropType, + required: false, + default: undefined + }, + arrowHeadColor: { + type: String as PropType, + required: false, + default: undefined + }, + markerEndId: { + type: String as PropType, + required: false, + default: undefined + }, + onlyRenderVisibleElements: { + type: Boolean as PropType, + required: false, + default: undefined + }, + onEdgeUpdate: { + type: Function() as PropType, + required: false, + default: undefined + }, + onEdgeContextMenu: { + type: Function() as PropType, + required: false, + default: undefined + }, + onEdgeMouseEnter: { + type: Function() as PropType, + required: false, + default: undefined + }, + onEdgeMouseMove: { + type: Function() as PropType, + required: false, + default: undefined + }, + onEdgeMouseLeave: { + type: Function() as PropType, + required: false, + default: undefined + }, + onEdgeUpdateStart: { + type: Function() as PropType, + required: false, + default: undefined + }, + onEdgeUpdateEnd: { + type: Function() as PropType, + required: false, + default: undefined + }, + edgeUpdaterRadius: { + type: Number as PropType, + required: false, + default: undefined + } + }, + setup(props) { + const pinia = store(); + + const transformStyle = `translate(${pinia.transform[0]},${pinia.transform[1]}) scale(${pinia.transform[2]})`; + const renderConnectionLine = pinia.connectionNodeId && pinia.connectionHandleType; + + return () => ( + + + {pinia.edges.map((edge: Edge) => ( + + ))} + {renderConnectionLine && ( + + )} + + + ); + } +}); + +export default EdgeRenderer; diff --git a/src/container/EdgeRenderer/utils.ts b/src/container/EdgeRenderer/utils.ts new file mode 100644 index 00000000..bfa193a8 --- /dev/null +++ b/src/container/EdgeRenderer/utils.ts @@ -0,0 +1,161 @@ +import { BezierEdge, StepEdge, SmoothStepEdge, StraightEdge } from '../../components/Edges'; +import wrapEdge from '../../components/Edges/wrapEdge'; +import { rectToBox } from '../../utils/graph'; + +import { EdgeTypesType, EdgeProps, Position, Node, XYPosition, ElementId, HandleElement, Transform, Edge } from '../../types'; +import { Component } from 'vue'; + +export function createEdgeTypes(edgeTypes: EdgeTypesType): EdgeTypesType { + const standardTypes: EdgeTypesType = { + default: wrapEdge(edgeTypes.default || BezierEdge), + straight: wrapEdge(edgeTypes.bezier || StraightEdge), + step: wrapEdge(edgeTypes.step || StepEdge), + smoothstep: wrapEdge(edgeTypes.step || SmoothStepEdge) + }; + + const wrappedTypes = {} as EdgeTypesType; + const specialTypes: EdgeTypesType = Object.keys(edgeTypes) + .filter((k) => !['default', 'bezier'].includes(k)) + .reduce((res, key) => { + res[key] = wrapEdge((edgeTypes[key] || BezierEdge) as Component); + + return res; + }, wrappedTypes); + + return { + ...standardTypes, + ...specialTypes + }; +} + +export function getHandlePosition(position: Position, node: Node, handle: any | null = null): XYPosition { + const x = (handle?.x || 0) + node.__rf.position.x; + const y = (handle?.y || 0) + node.__rf.position.y; + const width = handle?.width || node.__rf.width; + const height = handle?.height || node.__rf.height; + + switch (position) { + case Position.Top: + return { + x: x + width / 2, + y + }; + case Position.Right: + return { + x: x + width, + y: y + height / 2 + }; + case Position.Bottom: + return { + x: x + width / 2, + y: y + height + }; + case Position.Left: + return { + x, + y: y + height / 2 + }; + } +} + +export function getHandle(bounds: HandleElement[], handleId: ElementId | null): HandleElement | null { + console.log('handle', bounds); + if (!bounds) { + return null; + } + + // there is no handleId when there are no multiple handles/ handles with ids + // so we just pick the first one + let handle = null; + if (bounds.length === 1 || !handleId) { + handle = bounds[0]; + } else if (handleId) { + handle = bounds.find((d) => d.id === handleId); + } + + return typeof handle === 'undefined' ? null : handle; +} + +interface EdgePositions { + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; +} + +export const getEdgePositions = ( + sourceNode: Node, + sourceHandle: HandleElement | unknown, + sourcePosition: Position, + targetNode: Node, + targetHandle: HandleElement | unknown, + targetPosition: Position +): EdgePositions => { + const sourceHandlePos = getHandlePosition(sourcePosition, sourceNode, sourceHandle); + const targetHandlePos = getHandlePosition(targetPosition, targetNode, targetHandle); + + return { + sourceX: sourceHandlePos.x, + sourceY: sourceHandlePos.y, + targetX: targetHandlePos.x, + targetY: targetHandlePos.y + }; +}; + +interface IsEdgeVisibleParams { + sourcePos: XYPosition; + targetPos: XYPosition; + width: number; + height: number; + transform: Transform; +} + +export function isEdgeVisible({ sourcePos, targetPos, width, height, transform }: IsEdgeVisibleParams): boolean { + const edgeBox = { + x: Math.min(sourcePos.x, targetPos.x), + y: Math.min(sourcePos.y, targetPos.y), + x2: Math.max(sourcePos.x, targetPos.x), + y2: Math.max(sourcePos.y, targetPos.y) + }; + + if (edgeBox.x === edgeBox.x2) { + edgeBox.x2 += 1; + } + + if (edgeBox.y === edgeBox.y2) { + edgeBox.y2 += 1; + } + + const viewBox = rectToBox({ + x: (0 - transform[0]) / transform[2], + y: (0 - transform[1]) / transform[2], + width: width / transform[2], + height: height / transform[2] + }); + + const xOverlap = Math.max(0, Math.min(viewBox.x2, edgeBox.x2) - Math.max(viewBox.x, edgeBox.x)); + const yOverlap = Math.max(0, Math.min(viewBox.y2, edgeBox.y2) - Math.max(viewBox.y, edgeBox.y)); + const overlappingArea = Math.ceil(xOverlap * yOverlap); + + return overlappingArea > 0; +} + +type SourceTargetNode = { + sourceNode: Node | null; + targetNode: Node | null; +}; + +export const getSourceTargetNodes = (edge: Edge, nodes: Node[]): SourceTargetNode => { + return nodes.reduce( + (res, node) => { + if (node.id === edge.source) { + res.sourceNode = node; + } + if (node.id === edge.target) { + res.targetNode = node; + } + return res; + }, + { sourceNode: null, targetNode: null } as SourceTargetNode + ); +}; diff --git a/src/container/FlowRenderer/index.tsx b/src/container/FlowRenderer/index.tsx new file mode 100644 index 00000000..3ff38b10 --- /dev/null +++ b/src/container/FlowRenderer/index.tsx @@ -0,0 +1,198 @@ +import { GraphViewProps } from '../GraphView'; +import ZoomPane from '../ZoomPane'; +import UserSelection from '../../components/UserSelection'; +import { defineComponent, PropType } from 'vue'; +import store from '../../store'; + +type FlowRendererProps = Omit< + GraphViewProps, + | 'elements' + | 'snapToGrid' + | 'nodeTypes' + | 'edgeTypes' + | 'snapGrid' + | 'connectionLineType' + | 'arrowHeadColor' + | 'onlyRenderVisibleElements' + | 'selectNodesOnDrag' +>; + +const FlowRenderer = defineComponent({ + name: 'FlowRenderer', + components: { UserSelection, ZoomPane }, + props: { + onPaneClick: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onPaneContextMenu: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onPaneScroll: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onElementsRemove: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onMove: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onMoveStart: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onMoveEnd: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onSelectionDragStart: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onSelectionDrag: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onSelectionDragStop: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onSelectionContextMenu: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + deleteKeyCode: { + type: (Number || String) as PropType, + required: false, + default: undefined + }, + selectionKeyCode: { + type: (Number || String) as PropType, + required: false, + default: undefined + }, + multiSelectionKeyCode: { + type: (Number || String) as PropType, + required: false, + default: undefined + }, + zoomActivationKeyCode: { + type: (Number || String) as PropType, + required: false, + default: undefined + }, + elementsSelectable: { + type: Boolean as PropType, + required: false, + default: false + }, + zoomOnScroll: { + type: Boolean as PropType, + required: false, + default: false + }, + zoomOnPinch: { + type: Boolean as PropType, + required: false, + default: false + }, + panOnScroll: { + type: Boolean as PropType, + required: false, + default: false + }, + panOnScrollSpeed: { + type: Number as PropType, + required: false, + default: undefined + }, + panOnScrollMode: { + type: String as PropType, + required: false, + default: undefined + }, + zoomOnDoubleClick: { + type: Boolean as PropType, + required: false, + default: false + }, + paneMoveable: { + type: Boolean as PropType, + required: false, + default: false + }, + defaultPosition: { + type: Array as unknown as PropType, + required: false, + default: undefined + }, + defaultZoom: { + type: Number as PropType, + required: false, + default: undefined + }, + translateExtent: { + type: Array as unknown as PropType, + required: false, + default: undefined + } + }, + setup(props, { slots }) { + const pinia = store(); + + const onClick = (event: MouseEvent) => { + props.onPaneClick?.(event); + pinia.unsetNodesSelection(); + pinia.resetSelectedElements(); + }; + + const onContextMenu = (event: MouseEvent) => { + props.onPaneContextMenu?.(event); + }; + + const onWheel = (event: WheelEvent) => { + props.onPaneScroll?.(event); + }; + + return () => ( + + {slots.default ? slots.default() : ''} +
+ + ); + } +}); + +export default FlowRenderer; diff --git a/src/container/GraphView/index.tsx b/src/container/GraphView/index.tsx new file mode 100644 index 00000000..b579f5a5 --- /dev/null +++ b/src/container/GraphView/index.tsx @@ -0,0 +1,530 @@ +import FlowRenderer from '../FlowRenderer'; +import NodeRenderer from '../NodeRenderer'; +import EdgeRenderer from '../EdgeRenderer'; +import { onLoadProject, onLoadGetElements, onLoadToObject } from '../../utils/graph'; + +import { ReactFlowProps } from '../RevueFlow'; + +import { NodeTypesType, EdgeTypesType, ConnectionLineType, KeyCode } from '../../types'; +import { CSSProperties, defineComponent, onMounted, PropType, ref } from 'vue'; +import store from '../../store'; +import useZoomPanHelper from '../../hooks/useZoomPanHelper'; + +export interface GraphViewProps extends Omit { + nodeTypes: NodeTypesType; + edgeTypes: EdgeTypesType; + selectionKeyCode: KeyCode; + deleteKeyCode: KeyCode; + multiSelectionKeyCode: KeyCode; + connectionLineType: ConnectionLineType; + snapToGrid: boolean; + snapGrid: [number, number]; + onlyRenderVisibleElements: boolean; + defaultZoom: number; + defaultPosition: [number, number]; + arrowHeadColor: string; + selectNodesOnDrag: boolean; +} + +const GraphView = defineComponent({ + name: 'GraphView', + components: { NodeRenderer, EdgeRenderer }, + props: { + nodeTypes: { + type: Object as PropType, + required: false, + default: undefined + }, + edgeTypes: { + type: Object as PropType, + required: false, + default: undefined + }, + onMove: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onMoveStart: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onMoveEnd: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onLoad: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onElementClick: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeDoubleClick: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeDoubleClick: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeMouseEnter: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeMouseMove: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeMouseLeave: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeContextMenu: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeDragStart: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeDrag: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeDragStop: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onSelectionContextMenu: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onElementsRemove: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onConnect: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onConnectStart: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onConnectStop: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onConnectEnd: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + connectionMode: { + type: String as PropType, + required: false, + default: undefined + }, + connectionLineType: { + type: String as PropType, + required: false, + default: undefined + }, + connectionLineStyle: { + type: Object as PropType, + required: false, + default: () => ({} as CSSProperties) + }, + connectionLineComponent: { + type: Object as PropType, + required: false, + default: undefined as any + }, + selectionKeyCode: { + type: (Number || String) as PropType, + required: false, + default: undefined + }, + multiSelectionKeyCode: { + type: (Number || String) as PropType, + required: false, + default: undefined + }, + zoomActivationKeyCode: { + type: (Number || String) as PropType, + required: false, + default: undefined + }, + deleteKeyCode: { + type: (Number || String) as PropType, + required: false, + default: undefined + }, + snapToGrid: { + type: Boolean as PropType, + required: false, + default: undefined + }, + snapGrid: { + type: Array as unknown as PropType, + required: false, + default: undefined + }, + onlyRenderVisibleElements: { + type: Boolean as PropType, + required: false, + default: undefined + }, + nodesDraggable: { + type: Boolean as PropType, + required: false, + default: undefined + }, + nodesConnectable: { + type: Boolean as PropType, + required: false, + default: undefined + }, + elementsSelectable: { + type: Boolean as PropType, + required: false, + default: undefined + }, + selectNodesOnDrag: { + type: Boolean as PropType, + required: false, + default: undefined + }, + minZoom: { + type: Number as PropType, + required: false, + default: undefined + }, + maxZoom: { + type: Number as PropType, + required: false, + default: undefined + }, + defaultZoom: { + type: Number as PropType, + required: false, + default: undefined + }, + defaultPosition: { + type: Array as unknown as PropType, + required: false, + default: undefined + }, + translateExtent: { + type: Array as unknown as PropType, + required: false, + default: undefined + }, + nodeExtent: { + type: Array as unknown as PropType, + required: false, + default: undefined + }, + arrowHeadColor: { + type: String as PropType, + required: false, + default: undefined + }, + markerEndId: { + type: String as PropType, + required: false, + default: undefined + }, + zoomOnScroll: { + type: Boolean as PropType, + required: false, + default: undefined + }, + zoomOnPinch: { + type: Boolean as PropType, + required: false, + default: undefined + }, + panOnScroll: { + type: Boolean as PropType, + required: false, + default: undefined + }, + panOnScrollSpeed: { + type: Number as PropType, + required: false, + default: undefined + }, + panOnScrollMode: { + type: String as PropType, + required: false, + default: undefined + }, + zoomOnDoubleClick: { + type: Boolean as PropType, + required: false, + default: undefined + }, + paneMoveable: { + type: Boolean as PropType, + required: false, + default: undefined + }, + onPaneClick: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onPaneScroll: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onPaneContextMenu: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeUpdate: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeMouseEnter: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeContextMenu: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeMouseMove: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeMouseLeave: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeUpdateEnd: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeUpdateStart: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + edgeUpdaterRadius: { + type: Number as PropType, + required: false, + default: undefined + } + }, + setup(props) { + const pinia = store(); + const isInitialized = ref(false); + + const { zoomIn, zoomOut, zoomTo, transform, fitView, initialized } = useZoomPanHelper(); + + onMounted(() => { + if (!isInitialized.value && initialized) { + if (props.onLoad) { + props.onLoad({ + fitView: (params = { padding: 0.1 }) => fitView(params), + zoomIn, + zoomOut, + zoomTo, + setTransform: transform, + project: onLoadProject(pinia), + getElements: onLoadGetElements(pinia), + toObject: onLoadToObject(pinia) + } as any); + } + + isInitialized.value = true; + } + }); + + onMounted(() => { + if (props.onConnect) { + pinia.setOnConnect(props.onConnect); + } + }); + + onMounted(() => { + if (props.onConnectStart) { + pinia.setOnConnectStart(props.onConnectStart); + } + }); + + onMounted(() => { + if (props.onConnectStop) { + pinia.setOnConnectStop(props.onConnectStop); + } + }); + + onMounted(() => { + if (props.onConnectEnd) { + pinia.setOnConnectEnd(props.onConnectEnd); + } + }); + + onMounted(() => { + if (typeof props.snapToGrid !== 'undefined') { + pinia.setSnapToGrid(props.snapToGrid); + } + }); + + onMounted(() => { + if (typeof props.snapGrid !== 'undefined') { + pinia.setSnapGrid(props.snapGrid); + } + }); + + onMounted(() => { + if (typeof props.nodesDraggable !== 'undefined') { + pinia.setNodesDraggable(!!props.nodesDraggable); + } + }); + + onMounted(() => { + if (typeof props.nodesConnectable !== 'undefined') { + pinia.setNodesConnectable(!!props.nodesConnectable); + } + }); + + onMounted(() => { + if (typeof props.elementsSelectable !== 'undefined') { + pinia.setElementsSelectable(!!props.elementsSelectable); + } + }); + + onMounted(() => { + if (typeof props.minZoom !== 'undefined') { + pinia.setMinZoom(props.minZoom as any); + } + }); + + onMounted(() => { + if (typeof props.maxZoom !== 'undefined') { + pinia.setMaxZoom(props.maxZoom as any); + } + }); + + onMounted(() => { + if (typeof props.translateExtent !== 'undefined') { + pinia.setTranslateExtent(props.translateExtent as any); + } + }); + + onMounted(() => { + if (typeof props.nodeExtent !== 'undefined') { + pinia.setNodeExtent(props.nodeExtent as any); + } + }); + + onMounted(() => { + if (typeof props.connectionMode !== 'undefined') { + pinia.setConnectionMode(props.connectionMode as any); + } + }); + + return () => ( + + + + + ); + } +}); + +export default GraphView; diff --git a/src/container/NodeRenderer/index.tsx b/src/container/NodeRenderer/index.tsx new file mode 100644 index 00000000..23ba4919 --- /dev/null +++ b/src/container/NodeRenderer/index.tsx @@ -0,0 +1,191 @@ +import { getNodesInside } from '../../utils/graph'; +import { Node, NodeTypesType, Edge } from '../../types'; +import { computed, defineComponent, PropType } from 'vue'; +import store from '../../store'; + +interface NodeRendererProps { + nodeTypes: NodeTypesType; + selectNodesOnDrag: boolean; + onElementClick?: (event: MouseEvent, element: Node | Edge) => void; + onNodeDoubleClick?: (event: MouseEvent, element: Node) => void; + onNodeMouseEnter?: (event: MouseEvent, node: Node) => void; + onNodeMouseMove?: (event: MouseEvent, node: Node) => void; + onNodeMouseLeave?: (event: MouseEvent, node: Node) => void; + onNodeContextMenu?: (event: MouseEvent, node: Node) => void; + onNodeDragStart?: (event: MouseEvent, node: Node) => void; + onNodeDrag?: (event: MouseEvent, node: Node) => void; + onNodeDragStop?: (event: MouseEvent, node: Node) => void; + snapToGrid: boolean; + snapGrid: [number, number]; + onlyRenderVisibleElements: boolean; +} + +const NodeRenderer = defineComponent({ + name: 'NodeRenderer', + props: { + nodeTypes: { + type: Object as PropType, + required: false, + default: undefined + }, + selectNodesOnDrag: { + type: Boolean as PropType, + required: false, + default: undefined + }, + snapToGrid: { + type: Boolean as PropType, + required: false, + default: undefined + }, + snapGrid: { + type: Array as unknown as PropType, + required: false, + default: undefined + }, + onlyRenderVisibleElements: { + type: Boolean as PropType, + required: false, + default: undefined + }, + onElementClick: { + type: Function() as PropType, + required: false, + default: undefined + }, + onNodeDoubleClick: { + type: Function() as PropType, + required: false, + default: undefined + }, + onNodeMouseEnter: { + type: Function() as PropType, + required: false, + default: undefined + }, + onNodeMouseMove: { + type: Function() as PropType, + required: false, + default: undefined + }, + onNodeMouseLeave: { + type: Function() as PropType, + required: false, + default: undefined + }, + onNodeContextMenu: { + type: Function() as PropType, + required: false, + default: undefined + }, + onNodeDrag: { + type: Function() as PropType, + required: false, + default: undefined + }, + onNodeDragStart: { + type: Function() as PropType, + required: false, + default: undefined + }, + onNodeDragStop: { + type: Function() as PropType, + required: false, + default: undefined + } + }, + setup(props) { + const pinia = store(); + + const visibleNodes = computed(() => { + return props.onlyRenderVisibleElements + ? getNodesInside( + pinia.nodes, + { + x: 0, + y: 0, + width: pinia.width, + height: pinia.height + }, + pinia.transform, + true + ) + : pinia.nodes; + }); + + const transformStyle = computed(() => ({ + transform: `translate(${pinia.transform[0]}px,${pinia.transform[1]}px) scale(${pinia.transform[2]})` + })); + + const resizeObserver = computed(() => { + if (typeof ResizeObserver === 'undefined') { + return null; + } + + return new ResizeObserver((entries: ResizeObserverEntry[]) => { + const updates = entries.map((entry: ResizeObserverEntry) => ({ + id: entry.target.getAttribute('data-id') as string, + nodeElement: entry.target as HTMLDivElement + })); + + pinia.updateNodeDimensions(updates); + }); + }); + + return () => ( +
+ {visibleNodes.value.map((node) => { + const nodeType = node.type || 'default'; + if (props.nodeTypes) { + const NodeComponent: any = (props.nodeTypes[nodeType] || props.nodeTypes.default); + if (!props.nodeTypes[nodeType]) { + console.warn(`Node type "${nodeType}" not found. Using fallback type "default".`); + } + + const isDraggable = !!(node.draggable || (pinia.nodesDraggable && typeof node.draggable === 'undefined')); + const isSelectable = !!(node.selectable || (pinia.elementsSelectable && typeof node.selectable === 'undefined')); + const isConnectable = !!(node.connectable || (pinia.nodesConnectable && typeof node.connectable === 'undefined')); + + return ( + id === node.id) || false} + isDraggable={isDraggable} + isSelectable={isSelectable} + isConnectable={isConnectable} + resizeObserver={resizeObserver.value} + /> + ); + } + })} +
+ ); + } +}); + +export default NodeRenderer; diff --git a/src/container/NodeRenderer/utils.ts b/src/container/NodeRenderer/utils.ts new file mode 100644 index 00000000..e633c6dd --- /dev/null +++ b/src/container/NodeRenderer/utils.ts @@ -0,0 +1,27 @@ +import DefaultNode from '../../components/Nodes/DefaultNode'; +import InputNode from '../../components/Nodes/InputNode'; +import OutputNode from '../../components/Nodes/OutputNode'; +import wrapNode from '../../components/Nodes/wrapNode'; +import { NodeTypesType } from '../../types'; + +export function createNodeTypes(nodeTypes: NodeTypesType): NodeTypesType { + const standardTypes: NodeTypesType = { + input: wrapNode(nodeTypes.input || InputNode), + default: wrapNode(nodeTypes.default || DefaultNode), + output: wrapNode(nodeTypes.output || OutputNode) + }; + + const wrappedTypes = {} as NodeTypesType; + const specialTypes: NodeTypesType = Object.keys(nodeTypes) + .filter((k) => !['input', 'default', 'output'].includes(k)) + .reduce((res, key) => { + res[key] = wrapNode(nodeTypes[key] || DefaultNode); + + return res; + }, wrappedTypes); + + return { + ...standardTypes, + ...specialTypes + }; +} diff --git a/src/container/RevueFlow/index.tsx b/src/container/RevueFlow/index.tsx index 69bd8b23..1001590f 100644 --- a/src/container/RevueFlow/index.tsx +++ b/src/container/RevueFlow/index.tsx @@ -1,28 +1,538 @@ +import { computed, CSSProperties, defineComponent, HTMLAttributes, onMounted, PropType, provide } from 'vue'; +import GraphView from '../GraphView'; +import DefaultNode from '../../components/Nodes/DefaultNode'; +import InputNode from '../../components/Nodes/InputNode'; +import OutputNode from '../../components/Nodes/OutputNode'; +import { createNodeTypes } from '../NodeRenderer/utils'; +import { BezierEdge, StepEdge, SmoothStepEdge, StraightEdge } from '../../components/Edges'; +import { createEdgeTypes } from '../EdgeRenderer/utils'; +import { + Elements, + NodeTypesType, + EdgeTypesType, + OnLoadFunc, + Node, + Edge, + Connection, + ConnectionMode, + ConnectionLineType, + ConnectionLineComponent, + FlowTransform, + OnConnectStartFunc, + OnConnectStopFunc, + OnConnectEndFunc, + TranslateExtent, + KeyCode, + PanOnScrollMode, + OnEdgeUpdateFunc, + NodeExtent +} from '../../types'; + import '../../style.css'; import '../../theme-default.css'; -import { HTMLAttributes, defineComponent } from 'vue'; +import store from '../../store'; const defaultNodeTypes = { - input: 'InputNode', - default: 'DefaultNode', - output: 'OutputNode' + input: InputNode, + default: DefaultNode, + output: OutputNode }; const defaultEdgeTypes = { - default: 'BezierEdge', - straight: 'StraightEdge', - step: 'StepEdge', - smoothstep: 'SmoothStepEdge' + default: BezierEdge, + straight: StraightEdge, + step: StepEdge, + smoothstep: SmoothStepEdge }; -export interface ReactFlowProps extends Omit {} - -export type ReactFlowRefType = HTMLDivElement; +export interface ReactFlowProps extends Omit { + elements: Elements; + onElementClick?: (event: MouseEvent, element: Node | Edge) => void; + onElementsRemove?: (elements: Elements) => void; + onNodeDoubleClick?: (event: MouseEvent, node: Node) => void; + onNodeMouseEnter?: (event: MouseEvent, node: Node) => void; + onNodeMouseMove?: (event: MouseEvent, node: Node) => void; + onNodeMouseLeave?: (event: MouseEvent, node: Node) => void; + onNodeContextMenu?: (event: MouseEvent, node: Node) => void; + onNodeDragStart?: (event: MouseEvent, node: Node) => void; + onNodeDrag?: (event: MouseEvent, node: Node) => void; + onNodeDragStop?: (event: MouseEvent, node: Node) => void; + onConnect?: (connection: Edge | Connection) => void; + onConnectStart?: OnConnectStartFunc; + onConnectStop?: OnConnectStopFunc; + onConnectEnd?: OnConnectEndFunc; + onLoad?: OnLoadFunc; + onMove?: (flowTransform?: FlowTransform) => void; + onMoveStart?: (flowTransform?: FlowTransform) => void; + onMoveEnd?: (flowTransform?: FlowTransform) => void; + onSelectionChange?: (elements: Elements | null) => void; + onSelectionDragStart?: (event: MouseEvent, nodes: Node[]) => void; + onSelectionDrag?: (event: MouseEvent, nodes: Node[]) => void; + onSelectionDragStop?: (event: MouseEvent, nodes: Node[]) => void; + onSelectionContextMenu?: (event: MouseEvent, nodes: Node[]) => void; + onPaneScroll?: (event?: WheelEvent) => void; + onPaneClick?: (event: MouseEvent) => void; + onPaneContextMenu?: (event: MouseEvent) => void; + nodeTypes?: NodeTypesType; + edgeTypes?: EdgeTypesType; + connectionMode?: ConnectionMode; + connectionLineType?: ConnectionLineType; + connectionLineStyle?: CSSProperties; + connectionLineComponent?: ConnectionLineComponent; + deleteKeyCode?: KeyCode; + selectionKeyCode?: KeyCode; + multiSelectionKeyCode?: KeyCode; + zoomActivationKeyCode?: KeyCode; + snapToGrid?: boolean; + snapGrid?: [number, number]; + onlyRenderVisibleElements?: boolean; + nodesDraggable?: boolean; + nodesConnectable?: boolean; + elementsSelectable?: boolean; + selectNodesOnDrag?: boolean; + paneMoveable?: boolean; + minZoom?: number; + maxZoom?: number; + defaultZoom?: number; + defaultPosition?: [number, number]; + translateExtent?: TranslateExtent; + nodeExtent?: NodeExtent; + arrowHeadColor?: string; + markerEndId?: string; + zoomOnScroll?: boolean; + zoomOnPinch?: boolean; + panOnScroll?: boolean; + panOnScrollSpeed?: number; + panOnScrollMode?: PanOnScrollMode; + zoomOnDoubleClick?: boolean; + onEdgeUpdate?: OnEdgeUpdateFunc; + onEdgeContextMenu?: (event: MouseEvent, edge: Edge) => void; + onEdgeMouseEnter?: (event: MouseEvent, edge: Edge) => void; + onEdgeMouseMove?: (event: MouseEvent, edge: Edge) => void; + onEdgeMouseLeave?: (event: MouseEvent, edge: Edge) => void; + onEdgeDoubleClick?: (event: MouseEvent, edge: Edge) => void; + onEdgeUpdateStart?: (event: MouseEvent, edge: Edge) => void; + onEdgeUpdateEnd?: (event: MouseEvent, edge: Edge) => void; + edgeUpdaterRadius?: number; + nodeTypesId?: string; + edgeTypesId?: string; +} const RevueFlow = defineComponent({ name: 'RevueFlow', - setup() { - return () =>
Revue Flow!
; + components: { GraphView }, + props: { + elements: { + type: Object as PropType, + required: true + }, + nodeTypesId: { + type: String as PropType, + required: false, + default: '1' + }, + edgeTypesId: { + type: String as PropType, + required: false, + default: '1' + }, + nodeTypes: { + type: Object as PropType, + required: false, + default: () => defaultNodeTypes + }, + edgeTypes: { + type: Object as PropType, + required: false, + default: () => defaultEdgeTypes + }, + onMove: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onMoveStart: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onMoveEnd: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onLoad: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onElementClick: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeDoubleClick: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeDoubleClick: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeMouseEnter: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeMouseMove: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeMouseLeave: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeContextMenu: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeDragStart: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeDrag: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onNodeDragStop: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onSelectionContextMenu: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onElementsRemove: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onConnect: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onConnectStart: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onConnectStop: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onConnectEnd: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + connectionMode: { + type: String as PropType, + required: false, + default: ConnectionMode.Strict + }, + connectionLineType: { + type: String as PropType, + required: false, + default: ConnectionLineType.Bezier + }, + connectionLineStyle: { + type: Object as PropType, + required: false, + default: () => ({} as CSSProperties) + }, + connectionLineComponent: { + type: Object as PropType, + required: false, + default: undefined as any + }, + selectionKeyCode: { + type: (Number || String) as PropType, + required: false, + default: 'Shift' + }, + multiSelectionKeyCode: { + type: (Number || String) as PropType, + required: false, + default: 'Meta' + }, + zoomActivationKeyCode: { + type: (Number || String) as PropType, + required: false, + default: 'Meta' + }, + deleteKeyCode: { + type: (Number || String) as PropType, + required: false, + default: 'Backspace' + }, + snapToGrid: { + type: Boolean as PropType, + required: false, + default: false + }, + snapGrid: { + type: Array as unknown as PropType, + required: false, + default: () => [15, 15] + }, + onlyRenderVisibleElements: { + type: Boolean as PropType, + required: false, + default: false + }, + nodesDraggable: { + type: Boolean as PropType, + required: false, + default: undefined + }, + nodesConnectable: { + type: Boolean as PropType, + required: false, + default: undefined + }, + elementsSelectable: { + type: Boolean as PropType, + required: false, + default: undefined + }, + selectNodesOnDrag: { + type: Boolean as PropType, + required: false, + default: true + }, + minZoom: { + type: Number as PropType, + required: false, + default: undefined + }, + maxZoom: { + type: Number as PropType, + required: false, + default: undefined + }, + defaultZoom: { + type: Number as PropType, + required: false, + default: 1 + }, + defaultPosition: { + type: Array as unknown as PropType, + required: false, + default: () => [0, 0] + }, + translateExtent: { + type: Array as unknown as PropType, + required: false, + default: undefined + }, + nodeExtent: { + type: Array as unknown as PropType, + required: false, + default: undefined + }, + arrowHeadColor: { + type: String as PropType, + required: false, + default: '#b1b1b7' + }, + markerEndId: { + type: String as PropType, + required: false, + default: undefined + }, + zoomOnScroll: { + type: Boolean as PropType, + required: false, + default: true + }, + zoomOnPinch: { + type: Boolean as PropType, + required: false, + default: true + }, + panOnScroll: { + type: Boolean as PropType, + required: false, + default: false + }, + panOnScrollSpeed: { + type: Number as PropType, + required: false, + default: 0.5 + }, + panOnScrollMode: { + type: String as PropType, + required: false, + default: PanOnScrollMode.Free + }, + zoomOnDoubleClick: { + type: Boolean as PropType, + required: false, + default: true + }, + paneMoveable: { + type: Boolean as PropType, + required: false, + default: true + }, + onPaneClick: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onPaneScroll: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onPaneContextMenu: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeUpdate: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeMouseEnter: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeContextMenu: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeMouseMove: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeMouseLeave: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeUpdateEnd: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onEdgeUpdateStart: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + edgeUpdaterRadius: { + type: Number as PropType, + required: false, + default: 10 + } + }, + setup(props, { slots }) { + const pinia = store(); + const nodeTypesParsed = computed(() => createNodeTypes(props.nodeTypes as any)); + const edgeTypesParsed = computed(() => createEdgeTypes(props.edgeTypes as any)); + provide('store', pinia); + const reactFlowClasses = ['react-flow']; + + onMounted(() => { + pinia.setElements(props.elements); + }); + + return () => ( +
+ + {slots.default ? slots.default() : ''} +
+ ); } }); diff --git a/src/container/ZoomPane/index.tsx b/src/container/ZoomPane/index.tsx new file mode 100644 index 00000000..49915e7f --- /dev/null +++ b/src/container/ZoomPane/index.tsx @@ -0,0 +1,313 @@ +import { zoom, zoomIdentity } from 'd3-zoom'; +import { select, pointer } from 'd3-selection'; + +import { clamp } from '../../utils'; +import { FlowTransform, TranslateExtent, PanOnScrollMode, KeyCode } from '../../types'; +import { defineComponent, onMounted, PropType, ref, watch } from 'vue'; +import store from '../../store'; +import useKeyPress from '../../hooks/useKeyPress'; +import useResizeHandler from '../../hooks/useResizeHandler'; + +interface ZoomPaneProps { + selectionKeyPressed: boolean; + elementsSelectable?: boolean; + zoomOnScroll?: boolean; + zoomOnPinch?: boolean; + panOnScroll?: boolean; + panOnScrollSpeed?: number; + panOnScrollMode?: PanOnScrollMode; + zoomOnDoubleClick?: boolean; + paneMoveable?: boolean; + defaultPosition?: [number, number]; + defaultZoom?: number; + translateExtent?: TranslateExtent; + onMove?: (flowTransform?: FlowTransform) => void; + onMoveStart?: (flowTransform?: FlowTransform) => void; + onMoveEnd?: (flowTransform?: FlowTransform) => void; + zoomActivationKeyCode?: KeyCode; +} + +const viewChanged = (prevTransform: FlowTransform, eventTransform: any): boolean => + prevTransform.x !== eventTransform.x || prevTransform.y !== eventTransform.y || prevTransform.zoom !== eventTransform.k; + +const eventToFlowTransform = (eventTransform: any): FlowTransform => ({ + x: eventTransform.x, + y: eventTransform.y, + zoom: eventTransform.k +}); + +const ZoomPane = defineComponent({ + props: { + onMove: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onMoveStart: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + onMoveEnd: { + type: Function() as PropType, + required: false, + default: undefined as any + }, + zoomOnScroll: { + type: Boolean as PropType, + required: false, + default: true + }, + zoomOnPinch: { + type: Boolean as PropType, + required: false, + default: true + }, + panOnScroll: { + type: Boolean as PropType, + required: false, + default: false + }, + panOnScrollSpeed: { + type: Number as PropType, + required: false, + default: 0.5 + }, + panOnScrollMode: { + type: String as PropType, + required: false, + default: PanOnScrollMode.Free + }, + zoomOnDoubleClick: { + type: Boolean as PropType, + required: false, + default: true + }, + selectionKeyPressed: { + type: Boolean as PropType, + required: false, + default: undefined + }, + elementsSelectable: { + type: Boolean as PropType, + required: false, + default: undefined + }, + paneMoveable: { + type: Boolean as PropType, + required: false, + default: true + }, + defaultPosition: { + type: Array as unknown as PropType, + required: false, + default: () => [0, 0] + }, + defaultZoom: { + type: Number as PropType, + required: false, + default: 1 + }, + translateExtent: { + type: Array as unknown as PropType, + required: false, + default: undefined + }, + zoomActivationKeyCode: { + type: (Number || String) as PropType, + required: false, + default: undefined + } + }, + setup(props, { slots }) { + const pinia = store(); + const zoomPane = ref(null); + const prevTransform = ref({ x: 0, y: 0, zoom: 0 }); + const zoomActivationKeyPressed = useKeyPress(props.zoomActivationKeyCode); + + watch(zoomPane, () => { + if (zoomPane.value) useResizeHandler(zoomPane.value); + }); + + onMounted(() => { + if (zoomPane.value && props.defaultPosition && props.defaultZoom) { + const currentTranslateExtent = + typeof props.translateExtent !== 'undefined' ? props.translateExtent : pinia.translateExtent; + const d3ZoomInstance = zoom().scaleExtent([pinia.minZoom, pinia.maxZoom]).translateExtent(currentTranslateExtent); + const selection = select(zoomPane.value as Element).call(d3ZoomInstance); + + const clampedX = clamp(props.defaultPosition[0], currentTranslateExtent[0][0], currentTranslateExtent[1][0]); + const clampedY = clamp(props.defaultPosition[1], currentTranslateExtent[0][1], currentTranslateExtent[1][1]); + const clampedZoom = clamp(props.defaultZoom, pinia.minZoom, pinia.maxZoom); + const updatedTransform = zoomIdentity.translate(clampedX, clampedY).scale(clampedZoom); + + d3ZoomInstance.transform(selection, updatedTransform); + + pinia.initD3Zoom({ + d3Zoom: d3ZoomInstance, + d3Selection: selection, + d3ZoomHandler: selection.on('wheel.zoom'), + // we need to pass transform because zoom handler is not registered when we set the initial transform + transform: [clampedX, clampedY, clampedZoom] + }); + } + }); + + onMounted(() => { + if (pinia.d3Selection && pinia.d3Zoom) { + if (props.panOnScroll && zoomActivationKeyPressed) { + pinia.d3Selection + .on('wheel', (event: any) => { + event.preventDefault(); + event.stopImmediatePropagation(); + + const currentZoom = pinia.d3Selection?.property('__zoom').k || 1; + + if (event.ctrlKey && props.zoomOnPinch) { + const point = pointer(event); + // taken from https://github.com/d3/d3-zoom/blob/master/src/zoom.js + const pinchDelta = -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * 10; + const zoom = currentZoom * Math.pow(2, pinchDelta); + if (pinia.d3Selection) pinia.d3Zoom?.scaleTo(pinia.d3Selection, zoom, point); + + return; + } + + // increase scroll speed in firefox + // firefox: deltaMode === 1; chrome: deltaMode === 0 + const deltaNormalize = event.deltaMode === 1 ? 20 : 1; + const deltaX = props.panOnScrollMode === PanOnScrollMode.Vertical ? 0 : event.deltaX * deltaNormalize; + const deltaY = props.panOnScrollMode === PanOnScrollMode.Horizontal ? 0 : event.deltaY * deltaNormalize; + + if (pinia.d3Selection && props.panOnScrollSpeed) + pinia.d3Zoom?.translateBy( + pinia.d3Selection, + -(deltaX / currentZoom) * props.panOnScrollSpeed, + -(deltaY / currentZoom) * props.panOnScrollSpeed + ); + }) + .on('wheel.zoom', null); + } else if (typeof pinia.d3ZoomHandler !== 'undefined') { + pinia.d3Selection.on('wheel', null).on('wheel.zoom', pinia.d3ZoomHandler); + } + } + }); + + onMounted(() => { + if (pinia.d3Zoom) { + if (props.selectionKeyPressed) { + pinia.d3Zoom.on('zoom', null); + } else { + pinia.d3Zoom.on('zoom', (event: any) => { + pinia.updateTransform([event.transform.x, event.transform.y, event.transform.k]); + + if (props.onMove) { + const flowTransform = eventToFlowTransform(event.transform); + props.onMove(flowTransform); + } + }); + } + } + }); + + onMounted(() => { + if (pinia.d3Zoom) { + if (props.onMoveStart) { + pinia.d3Zoom.on('start', (event: any) => { + if (viewChanged(prevTransform.value, event.transform)) { + const flowTransform = eventToFlowTransform(event.transform); + prevTransform.value = flowTransform; + + if (props.onMoveStart) props.onMoveStart(flowTransform); + } + }); + } else { + pinia.d3Zoom.on('start', null); + } + } + }); + + onMounted(() => { + if (pinia.d3Zoom) { + if (props.onMoveEnd) { + pinia.d3Zoom.on('end', (event: any) => { + if (viewChanged(prevTransform.value, event.transform)) { + const flowTransform = eventToFlowTransform(event.transform); + prevTransform.value = flowTransform; + + if (props.onMoveEnd) props.onMoveEnd(flowTransform); + } + }); + } else { + pinia.d3Zoom.on('end', null); + } + } + }); + + onMounted(() => { + if (pinia.d3Zoom) { + pinia.d3Zoom.filter((event: any) => { + const zoomScroll = props.zoomOnScroll; + const pinchZoom = props.zoomOnPinch && event.ctrlKey; + + // if all interactions are disabled, we prevent all zoom events + if (!props.paneMoveable && !zoomScroll && !props.panOnScroll && !props.zoomOnDoubleClick && !props.zoomOnPinch) { + return false; + } + + // during a selection we prevent all other interactions + if (props.selectionKeyPressed) { + return false; + } + + // if zoom on double click is disabled, we prevent the double click event + if (!props.zoomOnDoubleClick && event.type === 'dblclick') { + return false; + } + + if (event.target.closest('.nowheel') && event.type === 'wheel') { + return false; + } + + // when the target element is a node, we still allow zooming + if ( + (event.target.closest('.react-flow__node') || event.target.closest('.react-flow__edge')) && + event.type !== 'wheel' + ) { + return false; + } + + // when the target element is a node selection, we still allow zooming + if (event.target.closest('.react-flow__nodesselection') && event.type !== 'wheel') { + return false; + } + + if (!props.zoomOnPinch && event.ctrlKey && event.type === 'wheel') { + return false; + } + + // when there is no scroll handling enabled, we prevent all wheel events + if (!zoomScroll && !props.panOnScroll && !pinchZoom && event.type === 'wheel') { + return false; + } + + // if the pane is not movable, we prevent dragging it with mousestart or touchstart + if (!props.paneMoveable && (event.type === 'mousedown' || event.type === 'touchstart')) { + return false; + } + + // default filter for d3-zoom + return (!event.ctrlKey || event.type === 'wheel') && !event.button; + }); + } + }); + + return () => ( +
+ {slots.default ? slots.default() : ''} +
+ ); + } +}); + +export default ZoomPane; diff --git a/src/custom.d.ts b/src/custom.d.ts new file mode 100644 index 00000000..68bb83c7 --- /dev/null +++ b/src/custom.d.ts @@ -0,0 +1,2 @@ +declare const __REACT_FLOW_VERSION__: string; +declare const __ENV__: string; diff --git a/src/hooks/useGlobalKeyHandler.ts b/src/hooks/useGlobalKeyHandler.ts new file mode 100644 index 00000000..5f027a54 --- /dev/null +++ b/src/hooks/useGlobalKeyHandler.ts @@ -0,0 +1,39 @@ +import useKeyPress from './useKeyPress'; +import { isNode, getConnectedEdges } from '../utils/graph'; +import { Elements, KeyCode, ElementId, FlowElement } from '../types'; +import store from '../store'; +import { onMounted } from 'vue'; + +interface HookParams { + deleteKeyCode: KeyCode; + multiSelectionKeyCode: KeyCode; + onElementsRemove?: (elements: Elements) => void; +} + +export default ({ deleteKeyCode, multiSelectionKeyCode, onElementsRemove }: HookParams): void => { + const pinia = store(); + + const deleteKeyPressed = useKeyPress(deleteKeyCode); + const multiSelectionKeyPressed = useKeyPress(multiSelectionKeyCode); + + onMounted(() => { + const { edges, selectedElements } = pinia; + + if (onElementsRemove && deleteKeyPressed && selectedElements) { + const selectedNodes = selectedElements.filter(isNode); + const connectedEdges = getConnectedEdges(selectedNodes, edges); + const elementsToRemove = [...selectedElements, ...connectedEdges].reduce( + (res, item) => res.set(item.id, item), + new Map() + ); + + onElementsRemove(Array.from(elementsToRemove.values())); + pinia.unsetNodesSelection(); + pinia.resetSelectedElements(); + } + }); + + onMounted(() => { + pinia.setMultiSelectionActive(multiSelectionKeyPressed); + }); +}; diff --git a/src/hooks/useKeyPress.ts b/src/hooks/useKeyPress.ts new file mode 100644 index 00000000..571784b1 --- /dev/null +++ b/src/hooks/useKeyPress.ts @@ -0,0 +1,38 @@ +import { isInputDOMNode } from '../utils'; +import { KeyCode } from '../types'; +import { onMounted, ref } from 'vue'; + +export default (keyCode?: KeyCode): boolean => { + const keyPressed = ref(false); + + onMounted(() => { + if (typeof keyCode !== 'undefined') { + const downHandler = (event: KeyboardEvent) => { + if (!isInputDOMNode(event) && (event.key === keyCode || event.keyCode === keyCode)) { + event.preventDefault(); + keyPressed.value = true; + } + }; + + const upHandler = (event: KeyboardEvent) => { + if (!isInputDOMNode(event) && (event.key === keyCode || event.keyCode === keyCode)) { + keyPressed.value = false; + } + }; + + const resetHandler = () => (keyPressed.value = false); + + window.addEventListener('keydown', downHandler); + window.addEventListener('keyup', upHandler); + window.addEventListener('blur', resetHandler); + + return () => { + window.removeEventListener('keydown', downHandler); + window.removeEventListener('keyup', upHandler); + window.removeEventListener('blur', resetHandler); + }; + } + }); + + return keyPressed.value; +}; diff --git a/src/hooks/useResizeHandler.ts b/src/hooks/useResizeHandler.ts new file mode 100644 index 00000000..d7cc0aed --- /dev/null +++ b/src/hooks/useResizeHandler.ts @@ -0,0 +1,40 @@ +import { getDimensions } from '../utils'; +import store from '../store'; + +export default (rendererNode: HTMLDivElement) => { + const pinia = store(); + + let resizeObserver: ResizeObserver; + + const updateDimensions = () => { + if (!rendererNode) { + return; + } + + const size = getDimensions(rendererNode); + + if (size.height === 0 || size.width === 0) { + console.warn('The React Flow parent container needs a width and a height to render the graph.'); + } + + pinia.updateSize(size); + }; + + updateDimensions(); + window.onresize = updateDimensions; + + if (rendererNode) { + resizeObserver = new ResizeObserver(() => updateDimensions()); + resizeObserver.observe(rendererNode); + } + /* + return () => { + window.onresize = null; + + if (resizeObserver && rendererNode) { + resizeObserver.unobserve(rendererNode); + } + }; + + */ +}; diff --git a/src/hooks/useUpdateNodeInternals.ts b/src/hooks/useUpdateNodeInternals.ts new file mode 100644 index 00000000..3550c58d --- /dev/null +++ b/src/hooks/useUpdateNodeInternals.ts @@ -0,0 +1,16 @@ +import { ElementId, UpdateNodeInternals } from '../types'; +import store from '../store'; + +function useUpdateNodeInternals(): UpdateNodeInternals { + const pinia = store(); + + return (id: ElementId) => { + const nodeElement: HTMLDivElement | null = document.querySelector(`.react-flow__node[data-id="${id}"]`); + + if (nodeElement) { + pinia.updateNodeDimensions([{ id, nodeElement, forceUpdate: true }]); + } + }; +} + +export default useUpdateNodeInternals; diff --git a/src/hooks/useZoomPanHelper.ts b/src/hooks/useZoomPanHelper.ts new file mode 100644 index 00000000..710032d9 --- /dev/null +++ b/src/hooks/useZoomPanHelper.ts @@ -0,0 +1,85 @@ +import { zoomIdentity } from 'd3-zoom'; +import { getRectOfNodes, pointToRendererPoint, getTransformForBounds } from '../utils/graph'; +import { FitViewParams, FlowTransform, ZoomPanHelperFunctions, Rect, XYPosition } from '../types'; +import { computed } from 'vue'; +import store from '../store'; + +const DEFAULT_PADDING = 0.1; + +const initialZoomPanHelper: ZoomPanHelperFunctions = { + zoomIn: () => {}, + zoomOut: () => {}, + zoomTo: (_: number) => {}, + transform: (_: FlowTransform) => {}, + fitView: (_: FitViewParams = { padding: DEFAULT_PADDING, includeHiddenNodes: false }) => {}, + setCenter: (_: number, __: number) => {}, + fitBounds: (_: Rect) => {}, + project: (position: XYPosition) => position, + initialized: false +}; + +const useZoomPanHelper = (): ZoomPanHelperFunctions => { + const pinia = store(); + const zoomPanHelperFunctions = computed(() => { + if (pinia.d3Selection && pinia.d3Zoom) { + return { + zoomIn: () => pinia.d3Zoom?.scaleBy(pinia.d3Selection as any, 1.2), + zoomOut: () => pinia.d3Zoom?.scaleBy(pinia.d3Selection as any, 1 / 1.2), + zoomTo: (zoomLevel: number) => pinia.d3Zoom?.scaleTo(pinia.d3Selection as any, zoomLevel), + transform: (transform: FlowTransform) => { + const nextTransform = zoomIdentity.translate(transform.x, transform.y).scale(transform.zoom); + + pinia.d3Zoom?.transform(pinia.d3Selection as any, nextTransform); + }, + fitView: (options: FitViewParams = { padding: DEFAULT_PADDING, includeHiddenNodes: false }) => { + const { nodes, width, height, minZoom, maxZoom } = pinia; + + if (!nodes.length) { + return; + } + + const bounds = getRectOfNodes(options.includeHiddenNodes ? nodes : nodes.filter((node) => !node.isHidden)); + const [x, y, zoom] = getTransformForBounds( + bounds, + width, + height, + options.minZoom ?? minZoom, + options.maxZoom ?? maxZoom, + options.padding ?? DEFAULT_PADDING + ); + const transform = zoomIdentity.translate(x, y).scale(zoom); + + pinia.d3Zoom?.transform(pinia.d3Selection as any, transform); + }, + setCenter: (x: number, y: number, zoom?: number) => { + const { width, height, maxZoom } = pinia; + + const nextZoom = typeof zoom !== 'undefined' ? zoom : maxZoom; + const centerX = width / 2 - x * nextZoom; + const centerY = height / 2 - y * nextZoom; + const transform = zoomIdentity.translate(centerX, centerY).scale(nextZoom); + + pinia.d3Zoom?.transform(pinia.d3Selection as any, transform); + }, + fitBounds: (bounds: Rect, padding = DEFAULT_PADDING) => { + const { width, height, minZoom, maxZoom } = pinia; + const [x, y, zoom] = getTransformForBounds(bounds, width, height, minZoom, maxZoom, padding); + const transform = zoomIdentity.translate(x, y).scale(zoom); + + pinia.d3Zoom?.transform(pinia.d3Selection as any, transform); + }, + project: (position: XYPosition) => { + const { transform, snapToGrid, snapGrid } = pinia; + return pointToRendererPoint(position, transform, snapToGrid, snapGrid); + }, + initialized: true + }; + } + + return initialZoomPanHelper; + }); + + return zoomPanHelperFunctions.value; +}; + +export default useZoomPanHelper; diff --git a/src/main.ts b/src/main.ts index 80921711..28042c77 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,5 +3,6 @@ import App from './App.vue'; import { createPinia } from 'pinia'; const app = createApp(App); +app.config.performance = true; app.use(createPinia()); app.mount('#app'); diff --git a/src/store/configure-store.ts b/src/store/configure-store.ts new file mode 100644 index 00000000..0cbde1df --- /dev/null +++ b/src/store/configure-store.ts @@ -0,0 +1,302 @@ +import { defineStore, StoreDefinition } from 'pinia'; +import isEqual from 'fast-deep-equal'; +import { Edge, Node, NodeDiffUpdate, ReactFlowState, RevueFlowActionsTree, XYPosition } from '../types'; +import { getConnectedEdges, getNodesInside, getRectOfNodes, isEdge, isNode, parseEdge, parseNode } from '../utils/graph'; +import { clampPosition, getDimensions } from '../utils'; +import { getHandleBounds } from '../components/Nodes/utils'; + +type NextElements = { + nextNodes: Node[]; + nextEdges: Edge[]; +}; + +export default function configureStore( + preloadedState: ReactFlowState +): StoreDefinition<'revue-flow', ReactFlowState, any, RevueFlowActionsTree> { + return defineStore({ + id: 'revue-flow', + state: () => preloadedState, + getters: {}, + actions: { + setOnConnect(onConnect) { + this.onConnect = onConnect; + }, + setOnConnectStart(onConnectStart) { + this.onConnectStart = onConnectStart; + }, + setOnConnectEnd(onConnectEnd) { + this.onConnectEnd = onConnectEnd; + }, + setOnConnectStop(onConnectStop) { + this.onConnectStop = onConnectStop; + }, + setElements(elements) { + const propElements = elements; + const nextElements: NextElements = { + nextNodes: [], + nextEdges: [] + }; + const { nextNodes, nextEdges } = propElements.reduce((res, propElement): NextElements => { + if (isNode(propElement)) { + const storeNode = this.nodes.find((node) => node.id === propElement.id); + + if (storeNode) { + const updatedNode: Node = { + ...storeNode, + ...propElement + }; + + if (storeNode.position.x !== propElement.position.x || storeNode.position.y !== propElement.position.y) { + updatedNode.__rf.position = propElement.position; + } + + if (typeof propElement.type !== 'undefined' && propElement.type !== storeNode.type) { + // we reset the elements dimensions here in order to force a re-calculation of the bounds. + // When the type of a node changes it is possible that the number or positions of handles changes too. + updatedNode.__rf.width = null; + } + + res.nextNodes.push(updatedNode); + } else { + res.nextNodes.push(parseNode(propElement, this.nodeExtent)); + } + } else if (isEdge(propElement)) { + const storeEdge = this.edges.find((se) => se.id === propElement.id); + + if (storeEdge) { + res.nextEdges.push({ + ...storeEdge, + ...propElement + }); + } else { + res.nextEdges.push(parseEdge(propElement)); + } + } + + return res; + }, nextElements); + + this.nodes = nextNodes; + this.edges = nextEdges; + }, + updateNodeDimensions(updates) { + this.nodes = this.nodes.map((node) => { + const update = updates.find((u) => u.id === node.id); + if (update) { + const dimensions = getDimensions(update.nodeElement); + const doUpdate = + dimensions.width && + dimensions.height && + (node.__rf.width !== dimensions.width || node.__rf.height !== dimensions.height || update.forceUpdate); + + if (doUpdate) { + const handleBounds = getHandleBounds(update.nodeElement, this.transform[2]); + + return { + ...node, + __rf: { + ...node.__rf, + ...dimensions, + handleBounds + } + }; + } + } + + return node; + }); + }, + updateNodePos(payload) { + const { id, pos } = payload; + let position: XYPosition = pos; + + if (this.snapToGrid) { + const [gridSizeX, gridSizeY] = this.snapGrid; + position = { + x: gridSizeX * Math.round(pos.x / gridSizeX), + y: gridSizeY * Math.round(pos.y / gridSizeY) + }; + } + + this.nodes = this.nodes.map((node) => { + if (node.id === id) { + return { + ...node, + __rf: { + ...node.__rf, + position + } + }; + } + + return node; + }); + }, + updateNodePosDiff(payload: NodeDiffUpdate) { + const { id, diff, isDragging } = payload; + + this.nodes = this.nodes.map((node) => { + if (id === node.id || this.selectedElements?.find((sNode) => sNode.id === node.id)) { + const updatedNode = { + ...node, + __rf: { + ...node.__rf, + isDragging + } + }; + + if (diff) { + updatedNode.__rf.position = { + x: node.__rf.position.x + diff.x, + y: node.__rf.position.y + diff.y + }; + } + + return updatedNode; + } + + return node; + }); + }, + setUserSelection(mousePos) { + this.selectionActive = true; + this.userSelectionRect = { + width: 0, + height: 0, + startX: mousePos.x, + startY: mousePos.y, + x: mousePos.x, + y: mousePos.y, + draw: true + }; + }, + updateUserSelection(mousePos) { + const startX = this.userSelectionRect.startX ?? 0; + const startY = this.userSelectionRect.startY ?? 0; + + const nextUserSelectRect: ReactFlowState['userSelectionRect'] = { + ...this.userSelectionRect, + x: mousePos.x < startX ? mousePos.x : this.userSelectionRect.x, + y: mousePos.y < startY ? mousePos.y : this.userSelectionRect.y, + width: Math.abs(mousePos.x - startX), + height: Math.abs(mousePos.y - startY) + }; + + const selectedNodes = getNodesInside(this.nodes, nextUserSelectRect, this.transform); + const selectedEdges = getConnectedEdges(selectedNodes, this.edges); + + const nextSelectedElements = [...selectedNodes, ...selectedEdges]; + const selectedElementsChanged = !isEqual(nextSelectedElements, this.selectedElements); + const selectedElementsUpdate = selectedElementsChanged + ? { + selectedElements: nextSelectedElements.length > 0 ? nextSelectedElements : null + } + : {}; + + this.userSelectionRect = nextUserSelectRect; + this.selectedElements = selectedElementsUpdate.selectedElements as any; + }, + unsetUserSelection() { + const selectedNodes = this.selectedElements?.filter((node) => isNode(node) && node.__rf) as Node[]; + + this.selectionActive = false; + this.userSelectionRect.draw = false; + + if (!selectedNodes || selectedNodes.length === 0) { + this.selectedElements = null; + this.nodesSelectionActive = false; + } else { + this.selectedNodesBbox = getRectOfNodes(selectedNodes); + this.nodesSelectionActive = true; + } + }, + addSelectedElements(elements) { + const selectedElementsArr = Array.isArray(elements) ? elements : [elements]; + const selectedElementsUpdated = !isEqual(selectedElementsArr, this.selectedElements); + this.selectedElements = selectedElementsUpdated ? selectedElementsArr : this.selectedElements; + }, + initD3Zoom(payload) { + const { d3Zoom, d3Selection, d3ZoomHandler, transform } = payload; + + this.d3Zoom = d3Zoom; + this.d3Selection = d3Selection; + this.d3ZoomHandler = d3ZoomHandler; + this.transform = transform; + }, + setMinZoom(minZoom) { + this.d3Zoom?.scaleExtent([minZoom, this.maxZoom]); + + this.minZoom = minZoom; + }, + setMaxZoom(maxZoom) { + this.d3Zoom?.scaleExtent([this.minZoom, maxZoom]); + + this.maxZoom = maxZoom; + }, + setTranslateExtent(translateExtent) { + this.d3Zoom?.translateExtent(translateExtent); + + this.translateExtent = translateExtent; + }, + setNodeExtent(nodeExtent) { + this.nodeExtent = nodeExtent; + this.nodes = this.nodes.map((node) => { + return { + ...node, + __rf: { + ...node.__rf, + position: clampPosition(node.__rf.position, nodeExtent) + } + }; + }); + }, + resetSelectedElements() { + this.selectedElements = null; + }, + unsetNodesSelection() { + this.nodesSelectionActive = false; + }, + updateTransform(transform) { + this.transform = transform; + }, + updateSize(size) { + this.height = size.height; + this.width = size.width; + }, + setConnectionPosition(connectionPosition) { + this.connectionPosition = connectionPosition; + }, + setConnectionNodeId(payload) { + this.connectionNodeId = payload.connectionNodeId; + this.connectionHandleId = payload.connectionHandleId; + this.connectionHandleType = payload.connectionHandleType; + }, + setSnapToGrid(snapToGrid) { + this.snapToGrid = snapToGrid; + }, + setSnapGrid(snapGrid) { + this.snapGrid = snapGrid; + }, + setInteractive(isInteractive) { + this.nodesDraggable = isInteractive; + this.nodesConnectable = isInteractive; + this.elementsSelectable = isInteractive; + }, + setNodesDraggable(nodesDraggable) { + this.nodesDraggable = nodesDraggable; + }, + setNodesConnectable(nodesConnectable) { + this.nodesConnectable = nodesConnectable; + }, + setElementsSelectable(elementsSelectable) { + this.elementsSelectable = elementsSelectable; + }, + setMultiSelectionActive(multiSelectionActive) { + this.multiSelectionActive = multiSelectionActive; + }, + setConnectionMode(connectionMode) { + this.connectionMode = connectionMode; + } + } + }); +} diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 00000000..766f835e --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,63 @@ +import configureStore from './configure-store'; + +import { ReactFlowState, ConnectionMode, RevueFlowActionsTree } from '../types'; + +export const initialState: ReactFlowState = { + width: 0, + height: 0, + transform: [0, 0, 1], + nodes: [], + edges: [], + selectedElements: null, + selectedNodesBbox: { x: 0, y: 0, width: 0, height: 0 }, + + d3Zoom: null, + d3Selection: null, + d3ZoomHandler: undefined, + minZoom: 0.5, + maxZoom: 2, + translateExtent: [ + [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY], + [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY] + ], + + nodeExtent: [ + [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY], + [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY] + ], + + nodesSelectionActive: false, + selectionActive: false, + + userSelectionRect: { + startX: 0, + startY: 0, + x: 0, + y: 0, + width: 0, + height: 0, + draw: false + }, + connectionNodeId: null, + connectionHandleId: null, + connectionHandleType: 'source', + connectionPosition: { x: 0, y: 0 }, + connectionMode: ConnectionMode.Strict, + + snapGrid: [15, 15], + snapToGrid: false, + + nodesDraggable: true, + nodesConnectable: true, + elementsSelectable: true, + + multiSelectionActive: false, + + reactFlowVersion: typeof __REACT_FLOW_VERSION__ !== 'undefined' ? __REACT_FLOW_VERSION__ : '-' +}; + +const store = configureStore(initialState); + +export type ReactFlowDispatch = RevueFlowActionsTree; + +export default store; diff --git a/src/store/utils.ts b/src/store/utils.ts new file mode 100644 index 00000000..0766c73d --- /dev/null +++ b/src/store/utils.ts @@ -0,0 +1,5 @@ +export function createAction(type: T): { type: T }; +export function createAction(type: T, payload: P): { type: T; payload: P }; +export function createAction(type: string, payload?: unknown): Record { + return { type, payload }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 638c3c1c..e6eb1957 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ import { Selection as D3Selection, ZoomBehavior } from 'd3'; -import { Component, HTMLAttributes, VNode } from 'vue'; +import { Component, HTMLAttributes, PropType, VNode } from 'vue'; export type ElementId = string; @@ -84,7 +84,7 @@ export enum BackgroundVariant { export type HandleType = 'source' | 'target'; -export type NodeTypesType = { [key: string]: VNode }; +export type NodeTypesType = { [key: string]: Component }; export type EdgeTypesType = NodeTypesType; @@ -135,6 +135,185 @@ export interface WrapEdgeProps { onEdgeUpdateEnd?: (event: MouseEvent, edge: Edge) => void; } +export const WrapEdgeProps = { + id: { + type: String as PropType, + required: false, + default: undefined + }, + type: { + type: String as PropType, + required: true + }, + data: { + type: String as PropType, + required: false, + default: undefined + }, + onClick: { + type: Function('event', 'edge') as PropType, + required: false, + default: () => ({}) + }, + onEdgeDoubleClick: { + type: Function('event', 'edge') as PropType, + required: false, + default: () => ({}) + }, + selected: { + type: Boolean as PropType, + required: true, + default: false + }, + animated: { + type: Boolean as PropType, + required: false, + default: false + }, + label: { + type: Object as PropType, + required: false, + default: undefined + }, + labelStyle: { + type: Object as PropType, + required: false, + default: undefined + }, + labelShowBg: { + type: Boolean as PropType, + required: false, + default: false + }, + labelBgStyle: { + type: Object as PropType, + required: false, + default: undefined + }, + labelBgPadding: { + type: Array as unknown as PropType, + required: false, + default: undefined + }, + labelBgBorderRadius: { + type: Number as PropType, + required: false, + default: undefined + }, + style: { + type: Object as PropType, + required: false, + default: undefined + }, + arrowHeadType: { + type: String as PropType, + required: false, + default: undefined + }, + source: { + type: String as PropType, + required: true + }, + target: { + type: String as PropType, + required: true + }, + sourceHandleId: { + type: String as PropType, + required: true, + default: null + }, + targetHandleId: { + type: String as PropType, + required: true, + default: null + }, + sourceX: { + type: Number as PropType, + required: true + }, + sourceY: { + type: Number as PropType, + required: true + }, + targetX: { + type: Number as PropType, + required: true + }, + targetY: { + type: Number as PropType, + required: true + }, + sourcePosition: { + type: String as PropType, + required: true + }, + targetPosition: { + type: String as PropType, + required: true + }, + elementsSelectable: { + type: Boolean as PropType, + required: false, + default: false + }, + markerEndId: { + type: String as PropType, + required: false, + default: undefined + }, + isHidden: { + type: Boolean as PropType, + required: false, + default: false + }, + handleEdgeUpdate: { + type: Boolean as PropType, + required: false, + default: false + }, + onConnectEdge: { + type: Function() as PropType, + required: false, + default: () => ({}) + }, + onContextMenu: { + type: Function() as PropType, + required: false, + default: () => ({}) + }, + onMouseEnter: { + type: Function() as PropType, + required: false, + default: () => ({}) + }, + onMouseMove: { + type: Function() as PropType, + required: false, + default: () => ({}) + }, + onMouseLeave: { + type: Function() as PropType, + required: false, + default: () => ({}) + }, + edgeUpdaterRadius: { + type: Number as PropType, + required: false, + default: undefined + }, + onEdgeUpdateStart: { + type: Function() as PropType, + required: false, + default: () => ({}) + }, + onEdgeUpdateEnd: { + type: Function() as PropType, + required: false, + default: () => ({}) + } +}; + export interface EdgeProps { id: ElementId; source: ElementId; @@ -165,6 +344,112 @@ export interface EdgeSmoothStepProps extends EdgeProps { borderRadius?: number; } +export const EdgeProps = { + id: { + type: String as PropType, + required: true + }, + sourceX: { + type: Number as PropType, + required: true, + default: 0 + }, + sourceY: { + type: Number as PropType, + required: true, + default: 0 + }, + targetX: { + type: Number as PropType, + required: true, + default: 0 + }, + targetY: { + type: Number as PropType, + required: true, + default: 0 + }, + sourcePosition: { + type: String as PropType, + required: true, + default: Position.Bottom + }, + targetPosition: { + type: String as PropType, + required: true, + default: Position.Top + }, + label: { + type: String as PropType, + required: false, + default: undefined + }, + labelStyle: { + type: Object as PropType, + required: true, + default: undefined + }, + labelShowBg: { + type: Boolean as PropType, + required: false, + default: false + }, + labelBgStyle: { + type: String as PropType, + required: false, + default: undefined + }, + labelBgPadding: { + type: [0, 0] as any as PropType<[number, number]>, + required: false, + default: undefined + }, + labelBgBorderRadius: { + type: Number as PropType, + required: false, + default: undefined + }, + arrowHeadType: { + type: Object as PropType, + required: false, + default: undefined + }, + markerEndId: { + type: String as PropType, + required: false, + default: undefined + }, + style: { + type: Object as PropType, + required: false, + default: undefined + }, + data: { + type: Object as PropType, + required: false, + default: undefined + }, + sourceHandleId: { + type: Object as PropType, + required: false, + default: undefined + }, + targetHandleId: { + type: Object as PropType, + required: false, + default: undefined + } +}; + +export const EdgeSmoothStepProps = { + ...EdgeProps, + borderRadius: { + type: Number as PropType, + required: false, + default: 5 + } +}; + export interface EdgeTextProps extends HTMLAttributes { x: number; y: number; @@ -213,6 +498,152 @@ export interface NodeComponentProps { isDragging?: boolean; } +export const WrapNodeProps = { + id: { + type: String as PropType, + required: true + }, + type: { + type: String as PropType, + required: true + }, + data: { + type: Object as PropType, + required: false, + default: undefined as any + }, + selected: { + type: Boolean as PropType, + required: false, + default: false + }, + scale: { + type: Number as PropType, + required: false, + default: undefined + }, + xPos: { + type: Number as PropType, + required: false, + default: undefined + }, + yPos: { + type: Number as PropType, + required: false, + default: undefined + }, + isSelectable: { + type: Boolean as PropType, + required: false, + default: undefined + }, + isDraggable: { + type: Boolean as PropType, + required: false, + default: undefined + }, + isConnectable: { + type: Boolean as PropType, + required: false, + default: undefined + }, + selectNodesOnDrag: { + type: Boolean as PropType, + required: false, + default: undefined + }, + onClick: { + type: Function() as PropType, + required: false, + default: () => {} + }, + onNodeDoubleClick: { + type: Function() as PropType, + required: false, + default: () => {} + }, + onMouseEnter: { + type: Function() as PropType, + required: false, + default: () => {} + }, + onMouseMove: { + type: Function() as PropType, + required: false, + default: () => {} + }, + onMouseLeave: { + type: Function() as PropType, + required: false, + default: () => {} + }, + onContextMenu: { + type: Function() as PropType, + required: false, + default: () => {} + }, + onNodeDragStart: { + type: Function() as PropType, + required: false, + default: () => {} + }, + onNodeDrag: { + type: Function() as PropType, + required: false, + default: () => {} + }, + onNodeDragStop: { + type: Function() as PropType, + required: false, + default: () => {} + }, + style: { + type: Object as PropType, + required: false, + default: undefined as any + }, + sourcePosition: { + type: String as PropType, + required: false, + default: undefined + }, + targetPosition: { + type: String as PropType, + required: false, + default: undefined + }, + isHidden: { + type: Boolean as PropType, + required: false, + default: undefined + }, + isInitialized: { + type: Boolean as PropType, + required: false, + default: undefined + }, + snapToGrid: { + type: Boolean as PropType, + required: false, + default: undefined + }, + snapGrid: { + type: Object as PropType, + required: false, + default: undefined + }, + isDragging: { + type: Boolean as PropType, + required: false, + default: undefined + }, + resizeObserver: { + type: Object as PropType, + required: false, + default: undefined + } +}; + export interface WrapNodeProps { id: ElementId; type: string; @@ -438,4 +869,38 @@ export interface ReactFlowState { onConnectEnd?: OnConnectEndFunc; } +export type RevueFlowActionsTree = { + setOnConnect: (onConnect: OnConnectFunc) => void; + setOnConnectStart: (onConnectStart: OnConnectStartFunc) => void; + setOnConnectStop: (onConnectStop: OnConnectStopFunc) => void; + setOnConnectEnd: (onConnectEnd: OnConnectEndFunc) => void; + setElements: (elements: Elements) => void; + updateNodeDimensions: (updates: NodeDimensionUpdate[]) => void; + updateNodePos: (payload: NodePosUpdate) => void; + updateNodePosDiff: (payload: NodeDiffUpdate) => void; + setUserSelection: (mousePos: XYPosition) => void; + updateUserSelection: (mousePos: XYPosition) => void; + unsetUserSelection: () => void; + addSelectedElements: (elements: Elements) => void; + initD3Zoom: (payload: InitD3ZoomPayload) => void; + setMinZoom: (zoom: number) => void; + setMaxZoom: (zoom: number) => void; + setTranslateExtent: (translateExtent: TranslateExtent) => void; + setNodeExtent: (nodeExtent: NodeExtent) => void; + resetSelectedElements: () => void; + unsetNodesSelection: () => void; + updateTransform: (transform: Transform) => void; + updateSize: (size: Dimensions) => void; + setConnectionPosition: (connectionPosition: XYPosition) => void; + setConnectionNodeId: (payload: SetConnectionId) => void; + setSnapToGrid: (snapToGrid: boolean) => void; + setSnapGrid: (snapGrid: SnapGrid) => void; + setInteractive: (isInteractive: boolean) => void; + setNodesDraggable: (nodesDraggable: boolean) => void; + setNodesConnectable: (nodesConnectable: boolean) => void; + setElementsSelectable: (elementsSelectable: boolean) => void; + setMultiSelectionActive: (multiSelectionActive: boolean) => void; + setConnectionMode: (connectionMode: ConnectionMode) => void; +}; + export type UpdateNodeInternals = (nodeId: ElementId) => void; diff --git a/src/utils/index.ts b/src/utils/index.ts index 77d1eef5..5bb3c531 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,26 +1,21 @@ -import { DraggableEvent } from 'react-draggable'; -import { MouseEvent as ReactMouseEvent } from 'react'; - import { Dimensions, XYPosition, NodeExtent } from '../types'; -export const isInputDOMNode = (e: ReactMouseEvent | DraggableEvent | KeyboardEvent) => { +export const isInputDOMNode = (e: MouseEvent | /* DraggableEvent | */ KeyboardEvent): boolean => { const target = e?.target as HTMLElement; - return ( - ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(target?.nodeName) || target?.hasAttribute('contenteditable') - ); + return ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(target?.nodeName) || target?.hasAttribute('contenteditable'); }; export const getDimensions = (node: HTMLDivElement): Dimensions => ({ width: node.offsetWidth, - height: node.offsetHeight, + height: node.offsetHeight }); -export const clamp = (val: number, min: number = 0, max: number = 1): number => Math.min(Math.max(val, min), max); +export const clamp = (val: number, min = 0, max = 1): number => Math.min(Math.max(val, min), max); export const clampPosition = (position: XYPosition, extent: NodeExtent) => ({ x: clamp(position.x, extent[0][0], extent[1][0]), - y: clamp(position.y, extent[0][1], extent[1][1]), + y: clamp(position.y, extent[0][1], extent[1][1]) }); export const getHostForElement = (element: HTMLElement): Document | ShadowRoot => diff --git a/vite.config.ts b/vite.config.ts index 1d95aaa7..db26d5ba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,8 @@ import vueJsx from '@vitejs/plugin-vue-jsx'; // https://vitejs.dev/config/ export default defineConfig({ + build: { sourcemap: 'inline' }, + esbuild: { sourcemap: 'both' }, plugins: [ vue(), vueJsx({ diff --git a/yarn.lock b/yarn.lock index 30201420..00a2f44a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -306,11 +306,226 @@ estree-walker "^2.0.1" picomatch "^2.2.2" +"@types/d3-array@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.1.tgz#0ff28ee7052a2e504cb951d17df7437c33d442d6" + integrity sha512-D/G7oG0czeszALrkdUiV68CDiHDxXf+M2mLVqAyKktGd12VKQQljj1sHJGBKjcK4jRH1biBd6ZPQPHpJ0mNa0w== + +"@types/d3-axis@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-axis/-/d3-axis-3.0.1.tgz#6afc20744fa5cc0cbc3e2bd367b140a79ed3e7a8" + integrity sha512-zji/iIbdd49g9WN0aIsGcwcTBUkgLsCSwB+uH+LPVDAiKWENMtI3cJEWt+7/YYwelMoZmbBfzA3qCdrZ2XFNnw== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-brush@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-brush/-/d3-brush-3.0.1.tgz#ae5f17ce391935ca88b29000e60ee20452c6357c" + integrity sha512-B532DozsiTuQMHu2YChdZU0qsFJSio3Q6jmBYGYNp3gMDzBmuFFgPt9qKA4VYuLZMp4qc6eX7IUFUEsvHiXZAw== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-chord@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-chord/-/d3-chord-3.0.1.tgz#54c8856c19c8e4ab36a53f73ba737de4768ad248" + integrity sha512-eQfcxIHrg7V++W8Qxn6QkqBNBokyhdWSAS73AbkbMzvLQmVVBviknoz2SRS/ZJdIOmhcmmdCRE/NFOm28Z1AMw== + +"@types/d3-color@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.0.2.tgz#53f2d6325f66ee79afd707c05ac849e8ae0edbb0" + integrity sha512-WVx6zBiz4sWlboCy7TCgjeyHpNjMsoF36yaagny1uXfbadc9f+5BeBf7U+lRmQqY3EHbGQpP8UdW8AC+cywSwQ== + +"@types/d3-contour@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-contour/-/d3-contour-3.0.1.tgz#9ff4e2fd2a3910de9c5097270a7da8a6ef240017" + integrity sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ== + dependencies: + "@types/d3-array" "*" + "@types/geojson" "*" + +"@types/d3-delaunay@*": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.0.tgz#c09953ac7e5460997f693d2d7bf3522e0d4a88e6" + integrity sha512-iGm7ZaGLq11RK3e69VeMM6Oqj2SjKUB9Qhcyd1zIcqn2uE8w9GFB445yCY46NOQO3ByaNyktX1DK+Etz7ZaX+w== + +"@types/d3-dispatch@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-dispatch/-/d3-dispatch-3.0.1.tgz#a1b18ae5fa055a6734cb3bd3cbc6260ef19676e3" + integrity sha512-NhxMn3bAkqhjoxabVJWKryhnZXXYYVQxaBnbANu0O94+O/nX9qSjrA1P1jbAQJxJf+VC72TxDX/YJcKue5bRqw== + +"@types/d3-drag@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.1.tgz#fb1e3d5cceeee4d913caa59dedf55c94cb66e80f" + integrity sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-dsv@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-dsv/-/d3-dsv-3.0.0.tgz#f3c61fb117bd493ec0e814856feb804a14cfc311" + integrity sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A== + +"@types/d3-ease@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.0.tgz#c29926f8b596f9dadaeca062a32a45365681eae0" + integrity sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA== + +"@types/d3-fetch@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-fetch/-/d3-fetch-3.0.1.tgz#f9fa88b81aa2eea5814f11aec82ecfddbd0b8fe0" + integrity sha512-toZJNOwrOIqz7Oh6Q7l2zkaNfXkfR7mFSJvGvlD/Ciq/+SQ39d5gynHJZ/0fjt83ec3WL7+u3ssqIijQtBISsw== + dependencies: + "@types/d3-dsv" "*" + +"@types/d3-force@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-3.0.2.tgz#b186a008f0b32b88736a82059e8164120cba6f0b" + integrity sha512-dZ9w40EA7NhhbuXPkNCLbrq5Vz6aq0k1Y0Z9irGO3JE4pGaJN+m26BX916l4jnOk56elBUlM1usTuIYXDW0rug== + +"@types/d3-format@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.1.tgz#194f1317a499edd7e58766f96735bdc0216bb89d" + integrity sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg== + +"@types/d3-geo@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-geo/-/d3-geo-3.0.2.tgz#e7ec5f484c159b2c404c42d260e6d99d99f45d9a" + integrity sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ== + dependencies: + "@types/geojson" "*" + +"@types/d3-hierarchy@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-3.0.2.tgz#ca63f2f4da15b8f129c5b7dffd71d904cba6aca2" + integrity sha512-+krnrWOZ+aQB6v+E+jEkmkAx9HvsNAD+1LCD0vlBY3t+HwjKnsBFbpVLx6WWzDzCIuiTWdAxXMEnGnVXpB09qQ== + +"@types/d3-interpolate@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz#e7d17fa4a5830ad56fe22ce3b4fac8541a9572dc" + integrity sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.0.0.tgz#939e3a784ae4f80b1fde8098b91af1776ff1312b" + integrity sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg== + +"@types/d3-polygon@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-polygon/-/d3-polygon-3.0.0.tgz#5200a3fa793d7736fa104285fa19b0dbc2424b93" + integrity sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw== + +"@types/d3-quadtree@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz#433112a178eb7df123aab2ce11c67f51cafe8ff5" + integrity sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw== + +"@types/d3-random@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-random/-/d3-random-3.0.1.tgz#5c8d42b36cd4c80b92e5626a252f994ca6bfc953" + integrity sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ== + +"@types/d3-scale-chromatic@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#103124777e8cdec85b20b51fd3397c682ee1e954" + integrity sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw== + +"@types/d3-scale@*": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.1.tgz#fbe8238e2eff27af577d2b7d0b933ae50a546970" + integrity sha512-GDuXcRcR6mKcpUVMhPNttpOzHi2dP6YcDqLZYSZHgwTZ+sfCa8e9q0VEBwZomblAPNMYpVqxojnSyIEb4s/Pwg== + dependencies: + "@types/d3-time" "*" + +"@types/d3-selection@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.0.tgz#2c6333b4e20ff3dc72f1cf5ffaca3a21c36a40f0" + integrity sha512-SZsDWFG1dLV9ivX2wGDPFWgLUf71tF4aupfEMTYMrbArHwEMLhmv3TS1CZECLQgoTttdpM1g5Z6JwRP7zoInWg== + +"@types/d3-shape@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.0.1.tgz#8cebf5f3d56dd81674e1822279db58a38848a250" + integrity sha512-HnpwE2zl45cOaXLLo0zR4OfRQM9u9sI/ESb0w41PrrsQfkibj8pVIS8VYMSem2Wf5RuWxcgy3/8Kw2/FcIqAEQ== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time-format@*": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-4.0.0.tgz#ee7b6e798f8deb2d9640675f8811d0253aaa1946" + integrity sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw== + +"@types/d3-time@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819" + integrity sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg== + +"@types/d3-timer@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.0.tgz#e2505f1c21ec08bda8915238e397fb71d2fc54ce" + integrity sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g== + +"@types/d3-transition@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.1.tgz#c9a96125567173d6163a6985b874f79154f4cc3d" + integrity sha512-Sv4qEI9uq3bnZwlOANvYK853zvpdKEm1yz9rcc8ZTsxvRklcs9Fx4YFuGA3gXoQN/c/1T6QkVNjhaRO/cWj94g== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.1.tgz#4bfc7e29625c4f79df38e2c36de52ec3e9faf826" + integrity sha512-7s5L9TjfqIYQmQQEUcpMAcBOahem7TRoSO/+Gkz02GbMVuULiZzjF2BOdw291dbO2aNon4m2OdFsRGaCq2caLQ== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + +"@types/d3@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@types/d3/-/d3-7.0.0.tgz#d102ec6ea5741e51a1ff7b8228850db0665ccd27" + integrity sha512-7rMMuS5unvbvFCJXAkQXIxWTo2OUlmVXN5q7sfQFesuVICY55PSP6hhbUhWjTTNpfTTB3iLALsIYDFe7KUNABw== + dependencies: + "@types/d3-array" "*" + "@types/d3-axis" "*" + "@types/d3-brush" "*" + "@types/d3-chord" "*" + "@types/d3-color" "*" + "@types/d3-contour" "*" + "@types/d3-delaunay" "*" + "@types/d3-dispatch" "*" + "@types/d3-drag" "*" + "@types/d3-dsv" "*" + "@types/d3-ease" "*" + "@types/d3-fetch" "*" + "@types/d3-force" "*" + "@types/d3-format" "*" + "@types/d3-geo" "*" + "@types/d3-hierarchy" "*" + "@types/d3-interpolate" "*" + "@types/d3-path" "*" + "@types/d3-polygon" "*" + "@types/d3-quadtree" "*" + "@types/d3-random" "*" + "@types/d3-scale" "*" + "@types/d3-scale-chromatic" "*" + "@types/d3-selection" "*" + "@types/d3-shape" "*" + "@types/d3-time" "*" + "@types/d3-time-format" "*" + "@types/d3-timer" "*" + "@types/d3-transition" "*" + "@types/d3-zoom" "*" + "@types/estree@^0.0.48": version "0.0.48" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.48.tgz#18dc8091b285df90db2f25aa7d906cfc394b7f74" integrity sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew== +"@types/geojson@*": + version "7946.0.8" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.8.tgz#30744afdb385e2945e22f3b033f897f76b1f12ca" + integrity sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA== + "@types/json-schema@^7.0.7": version "7.0.7" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad"