diff --git a/examples/react/cypress/components/reactflow/basic-props.cy.tsx b/examples/react/cypress/components/reactflow/basic-props.cy.tsx index c8ac818a..a0738eff 100644 --- a/examples/react/cypress/components/reactflow/basic-props.cy.tsx +++ b/examples/react/cypress/components/reactflow/basic-props.cy.tsx @@ -6,7 +6,7 @@ import * as simpleflow from '../../fixtures/simpleflow'; describe(': Basic Props', () => { describe('uses defaultNodes and defaultEdges', () => { beforeEach(() => { - cy.mount(); + cy.mount(); }); it('mounts nodes and edges', () => { diff --git a/examples/react/cypress/components/reactflow/on-nodes-change.cy.tsx b/examples/react/cypress/components/reactflow/on-nodes-change.cy.tsx new file mode 100644 index 00000000..a15fc67e --- /dev/null +++ b/examples/react/cypress/components/reactflow/on-nodes-change.cy.tsx @@ -0,0 +1,138 @@ +import { useCallback, useState } from 'react'; +import { OnNodesChange, Panel, ReactFlow, ReactFlowProps, applyNodeChanges, useReactFlow, Node } from '@xyflow/react'; + +const nodes = [ + { + id: '1', + data: { label: 'Node 1' }, + position: { x: 0, y: 0 }, + style: { width: 100, height: 40 }, + }, + { + id: '2', + data: { label: 'Node 2' }, + position: { x: 200, y: 200 }, + style: { width: 80, height: 50 }, + }, +]; + +describe(': onNodesChange', () => { + beforeEach(() => { + const onNodesChange = cy.spy().as('onNodesChange'); + cy.mount().wait(500); + }); + + it('receive initial dimension change', () => { + const expectedChanges = nodes.map((n) => ({ + type: 'dimensions', + id: n.id, + dimensions: n.style, + })); + + cy.get('@onNodesChange').should('have.callCount', 1); + cy.get('@onNodesChange').should('have.calledWith', expectedChanges); + }); + + it('receive replace and dimension change', () => { + const expectedDimensionChanges = [ + { + type: 'dimensions', + id: '1', + dimensions: { width: 200, height: 100 }, + }, + ]; + + const expectedReplaceChanges = [ + { + type: 'replace', + id: '1', + item: { + ...nodes[0], + computed: { positionAbsolute: nodes[0].position, width: 200, height: 100 }, + style: { width: 200, height: 100 }, + }, + }, + ]; + + cy.get('[data-id=update-btn]').click(); + + cy.get('@onNodesChange').should('have.callCount', 3); + cy.get('@onNodesChange').then((s) => { + // dimension change (already checked above) + // const firstCall = s.getCall(0); + // replace change + // @ts-ignore + const secondChangeCallArgs = s.getCall(1).args[0]; + // dimension change + // @ts-ignore + const thirdChangeCallArgs = s.getCall(2).args[0]; + + expect(secondChangeCallArgs).to.be.deep.eq(expectedReplaceChanges); + expect(thirdChangeCallArgs).to.be.deep.eq(expectedDimensionChanges); + }); + }); + + it('receive select and unselect change', () => { + const expectedSelectChanges = [{ id: '1', type: 'select', selected: true }]; + const expectedUnselectChanges = [{ id: '1', type: 'select', selected: false }]; + + cy.get('.react-flow__node').first().click(); + cy.get('@onNodesChange').should('have.calledWith', expectedSelectChanges); + + cy.get('.react-flow__pane').first().click({ force: true }); + cy.get('@onNodesChange').should('have.calledWith', expectedUnselectChanges); + }); + + it('receive position change', () => { + const endPosition = { x: 200, y: 25 }; + const expectedChanges = [ + { id: '1', type: 'position', dragging: false, position: endPosition, positionAbsolute: endPosition }, + ]; + + cy.drag('.react-flow__node:first', endPosition).then(() => { + cy.get('@onNodesChange').should('have.calledWith', expectedChanges); + }); + }); + + it('receive remove change', () => { + const expectedChanges = [{ id: '1', type: 'remove' }]; + + cy.get('.react-flow__node').first().click(); + cy.realPress('Backspace'); + + cy.get('@onNodesChange').should('have.calledWith', expectedChanges); + }); +}); + +// test specific helpers + +function UpdateButton() { + const { updateNode } = useReactFlow(); + const updateNodeDimensions = () => { + updateNode('1', { style: { width: 200, height: 100 } }); + }; + + return ( + + + + ); +} + +type CompProps = ReactFlowProps & { onNodesChange: (changes: any) => void }; + +function Comp(props: CompProps) { + const [nodes, setNodes] = useState(props.nodes || []); + const onNodesChange: OnNodesChange = useCallback((changes) => { + props.onNodesChange(changes); + setNodes((nds) => applyNodeChanges(changes, nds)); + }, []); + + return ( + + + + ); +} diff --git a/examples/react/cypress/e2e/basic.cy.ts b/examples/react/cypress/e2e/basic.cy.ts index 8328a307..10c0ce62 100644 --- a/examples/react/cypress/e2e/basic.cy.ts +++ b/examples/react/cypress/e2e/basic.cy.ts @@ -49,21 +49,16 @@ describe('Basic Flow Rendering', { testIsolation: false }, () => { it('selects one node with a selection', () => { cy.get('body') - .type('{shift}', { release: false }) - .wait(50) + .type('{Shift}', { release: false }) .get('.react-flow__pane') .trigger('mousedown', 1, 10, { button: 0, force: true }) - .trigger('mousemove', 1000, 450, { button: 0 }) - .wait(50) - .trigger('mouseup', 1000, 450, { button: 0, force: true }); + .trigger('mousemove', 1000, 200, { button: 0 }) + .trigger('mouseup', 1000, 200, { button: 0 }); - cy.wait(200); - - cy.get('.react-flow__node').eq(1).should('have.class', 'selected'); + cy.get('.react-flow__node').eq(0).should('have.class', 'selected'); cy.get('.react-flow__node').eq(3).should('have.not.class', 'selected'); cy.get('.react-flow__nodesselection-rect'); - cy.get('body').type('{shift}', { release: true, force: true }); }); @@ -97,19 +92,19 @@ describe('Basic Flow Rendering', { testIsolation: false }, () => { it('drags a node', () => { const styleBeforeDrag = Cypress.$('.react-flow__node:first').css('transform'); - cy.drag('.react-flow__node:first', { x: 500, y: 25 }).then(($el: any) => { + cy.drag('.react-flow__node:first', { x: 10, y: 10 }).then(($el: any) => { const styleAfterDrag = $el.css('transform'); expect(styleBeforeDrag).to.not.equal(styleAfterDrag); }); }); - // @TODO: why does this fail since react18? - // it('removes a node', () => { - // cy.get('.react-flow__node').contains('Node 1').click().should('have.class', 'selected'); - // cy.get('html').type('{backspace}').wait(100); - // cy.get('.react-flow__node').should('have.length', 3); - // cy.get('.react-flow__edge').should('have.length', 1); - // }); + it('removes a node', () => { + cy.get('.react-flow__node').contains('Node 1').click().should('have.class', 'selected'); + cy.get('html').realPress('Backspace'); + + cy.get('.react-flow__node').should('have.length', 3); + cy.get('.react-flow__edge').should('have.length', 0); + }); it('connects nodes', () => { cy.get('.react-flow__node') @@ -125,16 +120,15 @@ describe('Basic Flow Rendering', { testIsolation: false }, () => { .trigger('mouseup', { force: true, button: 0 }); cy.get('.react-flow__edge').as('edge'); - cy.get('@edge').should('have.length', 3); + cy.get('@edge').should('have.length', 1); }); - // @TODO: why does this fail since react18? - // it('removes an edge', () => { - // cy.get('.react-flow__edge:first').click(); - // cy.get('body').type('{backspace}'); + it('removes an edge', () => { + cy.get('.react-flow__edge:first').click(); + cy.get('html').realPress('Backspace'); - // cy.get('.react-flow__edge').should('have.length', 1); - // }); + cy.get('.react-flow__edge').should('have.length', 0); + }); it('drags the pane', () => { const styleBeforeDrag = Cypress.$('.react-flow__viewport').css('transform'); diff --git a/examples/react/cypress/support/ControlledFlow.tsx b/examples/react/cypress/support/ControlledFlow.tsx index eaa560bf..8474b81e 100644 --- a/examples/react/cypress/support/ControlledFlow.tsx +++ b/examples/react/cypress/support/ControlledFlow.tsx @@ -59,7 +59,7 @@ function ControlledFlow({ handlers.onConnect = onConnect; } - return ; + return ; } export default ControlledFlow; diff --git a/examples/react/cypress/support/commands.ts b/examples/react/cypress/support/commands.ts index e138e4cf..3a350dc5 100644 --- a/examples/react/cypress/support/commands.ts +++ b/examples/react/cypress/support/commands.ts @@ -9,7 +9,7 @@ Cypress.Commands.add('drag', (selector, { x, y }) => const nextY: number = centerY + y; return elementToDrag - .trigger('mousedown', { view: window }) + .trigger('mousedown', { view: window, force: true }) .trigger('mousemove', nextX, nextY, { force: true }) .wait(50) .trigger('mouseup', { view: window, force: true }); diff --git a/examples/react/cypress/support/e2e.ts b/examples/react/cypress/support/e2e.ts index d153dd3e..29be4a69 100644 --- a/examples/react/cypress/support/e2e.ts +++ b/examples/react/cypress/support/e2e.ts @@ -15,6 +15,7 @@ // Import commands.js using ES2015 syntax: import './commands'; +import 'cypress-real-events/support'; const resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/; diff --git a/examples/react/package.json b/examples/react/package.json index 36dd09a6..24bbee4a 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -30,8 +30,8 @@ "@types/react-dom": "^18.2.14", "@vitejs/plugin-react": "4.1.1", "@vitejs/plugin-react-swc": "^3.4.1", - "cypress": "12.17.3", - "cypress-real-events": "1.11.0", + "cypress": "13.6.6", + "cypress-real-events": "1.12.0", "start-server-and-test": "^2.0.2", "typescript": "5.2.2", "vite": "4.5.0" diff --git a/examples/react/src/examples/Basic/index.tsx b/examples/react/src/examples/Basic/index.tsx index a41f50db..cafa9fa8 100644 --- a/examples/react/src/examples/Basic/index.tsx +++ b/examples/react/src/examples/Basic/index.tsx @@ -45,7 +45,6 @@ const initialNodes: Node[] = [ data: { label: 'Node 4' }, position: { x: 400, y: 200 }, className: 'light', - connectable: false, }, ]; @@ -129,6 +128,7 @@ const BasicFlow = () => { selectNodesOnDrag={false} elevateEdgesOnSelect elevateNodesOnSelect={false} + nodeDragThreshold={0} > diff --git a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx index 72255e27..1265d408 100644 --- a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx +++ b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx @@ -1,4 +1,4 @@ -import React, { memo, FC, CSSProperties, useCallback } from 'react'; +import React, { memo, CSSProperties, useCallback } from 'react'; import { Handle, Position, NodeProps, Connection, Edge, useOnViewportChange, Viewport } from '@xyflow/react'; import type { ColorSelectorNode } from '.'; @@ -13,7 +13,7 @@ const sourceHandleStyleB: CSSProperties = { const onConnect = (params: Connection | Edge) => console.log('handle onConnect', params); -const ColorSelectorNode: FC> = ({ data, isConnectable }) => { +function ColorSelectorNode({ data, isConnectable }: NodeProps) { const onStart = useCallback((viewport: Viewport) => console.log('onStart', viewport), []); const onChange = useCallback((viewport: Viewport) => console.log('onChange', viewport), []); const onEnd = useCallback((viewport: Viewport) => console.log('onEnd', viewport), []); @@ -44,6 +44,6 @@ const ColorSelectorNode: FC> = ({ data, isC ); -}; +} export default memo(ColorSelectorNode); diff --git a/examples/react/src/examples/CustomNode/index.tsx b/examples/react/src/examples/CustomNode/index.tsx index 16a99803..03934e40 100644 --- a/examples/react/src/examples/CustomNode/index.tsx +++ b/examples/react/src/examples/CustomNode/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, MouseEvent, ChangeEvent, useCallback } from 'react'; +import { useState, useEffect, MouseEvent, ChangeEvent, useCallback, useRef } from 'react'; import { ReactFlow, MiniMap, @@ -17,6 +17,8 @@ import { OnBeforeDelete, BuiltInNode, BuiltInEdge, + NodeTypes, + ReactFlowProvider, } from '@xyflow/react'; import ColorSelectorNode from './ColorSelectorNode'; @@ -40,11 +42,12 @@ const initBgColor = '#1A192B'; const connectionLineStyle = { stroke: '#fff' }; const snapGrid: SnapGrid = [16, 16]; -const nodeTypes = { +const nodeTypes: NodeTypes = { selectorNode: ColorSelectorNode, }; const CustomNodeFlow = () => { + const ref = useRef(null); const [nodes, setNodes] = useState([]); const onNodesChange: OnNodesChange = useCallback( (changes) => @@ -165,6 +168,7 @@ const CustomNodeFlow = () => { minZoom={0.3} maxZoom={2} onBeforeDelete={onBeforeDelete} + ref={ref} > nodeStrokeColor={(n: MyNode): string => { @@ -186,4 +190,8 @@ const CustomNodeFlow = () => { ); }; -export default CustomNodeFlow; +export default () => ( + + + +); diff --git a/examples/react/src/examples/DragHandle/index.tsx b/examples/react/src/examples/DragHandle/index.tsx index a7a75fcf..2f7b9502 100644 --- a/examples/react/src/examples/DragHandle/index.tsx +++ b/examples/react/src/examples/DragHandle/index.tsx @@ -33,6 +33,7 @@ const DragHandleFlow = () => { edges={edges} nodeTypes={nodeTypes} onNodeClick={onNodeClick} + nodeDragThreshold={0} /> ); }; diff --git a/examples/react/src/examples/Interaction/index.tsx b/examples/react/src/examples/Interaction/index.tsx index 0dcee27b..ba3ecf03 100644 --- a/examples/react/src/examples/Interaction/index.tsx +++ b/examples/react/src/examples/Interaction/index.tsx @@ -36,7 +36,7 @@ const onNodeClick = (_: ReactMouseEvent, node: Node) => console.log('click', nod const onEdgeClick = (_: ReactMouseEvent, edge: Edge) => console.log('click', edge); const onPaneClick = (event: ReactMouseEvent) => console.log('onPaneClick', event); const onPaneScroll = (event?: WheelEvent) => console.log('onPaneScroll', event); -const onPaneContextMenu = (event: ReactMouseEvent) => console.log('onPaneContextMenu', event); +const onPaneContextMenu = (event: ReactMouseEvent | MouseEvent) => console.log('onPaneContextMenu', event); const onMoveEnd = (_: TouchEvent | MouseEvent | null, viewport: Viewport) => console.log('onMoveEnd', viewport); const InteractionFlow = () => { @@ -80,6 +80,7 @@ const InteractionFlow = () => { onPaneClick={captureZoomClick ? onPaneClick : undefined} onPaneScroll={captureZoomScroll ? onPaneScroll : undefined} onPaneContextMenu={captureZoomClick ? onPaneContextMenu : undefined} + nodeDragThreshold={0} onMoveEnd={onMoveEnd} > diff --git a/examples/react/src/examples/UseNodesData/index.tsx b/examples/react/src/examples/UseNodesData/index.tsx index 91cc1f94..dc0b43dd 100644 --- a/examples/react/src/examples/UseNodesData/index.tsx +++ b/examples/react/src/examples/UseNodesData/index.tsx @@ -20,8 +20,8 @@ export type ResultNode = Node<{}, 'result'>; export type UppercaseNode = Node<{ text: string }, 'uppercase'>; export type MyNode = TextNode | ResultNode | UppercaseNode; -export function isTextNode(node: any): node is TextNode { - return node.type === 'text'; +export function isTextNode(node: any): node is TextNode | UppercaseNode { + return node.type === 'text' || node.type === 'uppercase'; } const nodeTypes = { diff --git a/examples/react/src/examples/UseReactFlow/index.tsx b/examples/react/src/examples/UseReactFlow/index.tsx index bed127fb..2177d0cc 100644 --- a/examples/react/src/examples/UseReactFlow/index.tsx +++ b/examples/react/src/examples/UseReactFlow/index.tsx @@ -27,18 +27,21 @@ const initialNodes: Node[] = [ data: { label: 'Node 2' }, position: { x: 100, y: 100 }, className: 'light', + type: 'default', }, { id: '3', data: { label: 'Node 3' }, position: { x: 400, y: 100 }, className: 'light', + type: 'default', }, { id: '4', data: { label: 'Node 4' }, position: { x: 400, y: 200 }, className: 'light', + type: 'default', }, ]; @@ -85,6 +88,7 @@ const UseZoomPanHelperFlow = () => { data: { label: `${projectedPosition.x}-${projectedPosition.y}`, }, + type: 'default', }) ); }, @@ -106,6 +110,7 @@ const UseZoomPanHelperFlow = () => { data: { label: 'New Node', }, + type: 'default', }; addNodes(newNode); @@ -139,8 +144,8 @@ const UseZoomPanHelperFlow = () => { const onSetNodes = () => { setNodes([ - { id: 'a', position: { x: 0, y: 0 }, data: { label: 'Node a' } }, - { id: 'b', position: { x: 0, y: 150 }, data: { label: 'Node b' } }, + { id: 'a', type: 'default', position: { x: 0, y: 0 }, data: { label: 'Node a' } }, + { id: 'b', type: 'default', position: { x: 0, y: 150 }, data: { label: 'Node b' } }, ]); setEdges([{ id: 'a-b', source: 'a', target: 'b' }]); diff --git a/examples/react/src/generic-tests/edges/general.ts b/examples/react/src/generic-tests/edges/general.ts index bb768b09..23977657 100644 --- a/examples/react/src/generic-tests/edges/general.ts +++ b/examples/react/src/generic-tests/edges/general.ts @@ -3,6 +3,7 @@ import { MarkerType } from '@xyflow/react'; export default { flowProps: { fitView: true, + multiSelectionKeyCode: 's', nodes: [ { id: '1', diff --git a/examples/react/tsconfig.json b/examples/react/tsconfig.json index 7156a499..0519e0a8 100644 --- a/examples/react/tsconfig.json +++ b/examples/react/tsconfig.json @@ -15,7 +15,7 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "types": ["cypress", "cypress-real-events"] + "types": ["cypress", "node", "cypress-real-events"] }, "include": ["**/*.ts", "**/*.tsx"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/examples/svelte/package.json b/examples/svelte/package.json index 2679983a..edf69c6a 100644 --- a/examples/svelte/package.json +++ b/examples/svelte/package.json @@ -12,8 +12,8 @@ "format": "prettier --plugin-search-dir . --write ." }, "devDependencies": { - "@sveltejs/adapter-auto": "^2.1.1", - "@sveltejs/kit": "^1.27.3", + "@sveltejs/adapter-auto": "^3.1.1", + "@sveltejs/kit": "^2.5.2", "@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/parser": "^6.10.0", "eslint": "^8.53.0", @@ -21,8 +21,8 @@ "eslint-plugin-svelte": "^2.35.0", "prettier": "^3.0.3", "prettier-plugin-svelte": "^3.0.3", - "svelte": "^4.2.2", - "svelte-check": "^3.5.2", + "svelte": "^4.2.12", + "svelte-check": "^3.6.6", "tslib": "^2.6.2", "typescript": "^5.2.2", "vite": "^4.5.0" @@ -30,6 +30,7 @@ "type": "module", "dependencies": { "@dagrejs/dagre": "^1.0.4", + "@sveltejs/vite-plugin-svelte": "^3.0.2", "@xyflow/svelte": "workspace:^" } } diff --git a/examples/svelte/src/routes/examples/customnode/+page.svelte b/examples/svelte/src/routes/examples/customnode/+page.svelte index 525b0706..f89210c8 100644 --- a/examples/svelte/src/routes/examples/customnode/+page.svelte +++ b/examples/svelte/src/routes/examples/customnode/+page.svelte @@ -1,5 +1,4 @@ diff --git a/examples/svelte/src/routes/examples/usenodesdata/+page.svelte b/examples/svelte/src/routes/examples/usenodesdata/+page.svelte index fc85ee26..91fd8432 100644 --- a/examples/svelte/src/routes/examples/usenodesdata/+page.svelte +++ b/examples/svelte/src/routes/examples/usenodesdata/+page.svelte @@ -5,8 +5,8 @@ type UppercaseNodeType = Node<{ text: string }, 'uppercase'>; type ResultNodeType = Node<{}, 'result'>; - export function isTextNode(node: any): node is TextNodeType { - return node.type === 'text'; + export function isTextNode(node: any): node is TextNodeType | UppercaseNode { + return node.type === 'text' || node.type === 'uppercase'; } export type MyNode = TextNodeType | UppercaseNodeType | ResultNodeType; diff --git a/examples/svelte/svelte.config.js b/examples/svelte/svelte.config.js index 80719d79..56ed51a8 100644 --- a/examples/svelte/svelte.config.js +++ b/examples/svelte/svelte.config.js @@ -1,5 +1,5 @@ import adapter from '@sveltejs/adapter-auto'; -import { vitePreprocess } from '@sveltejs/kit/vite'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ const config = { diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 051fdbd9..0bc7f603 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,15 @@ # @xyflow/react +## 12.0.0-next.11 + +## Patch changes + +- fix `ref` prop for `ReactFlow` and `Handle` component +- unify `Edge` and `Node` type handling +- fix safari: prevent selection of viewport +- fix `useNodesData` hook to prevent re-renderings +- fix edges: allow start at 0,0 + ## 12.0.0-next.10 ## ⚠️ Breaking changes diff --git a/packages/react/package.json b/packages/react/package.json index d9105c4f..542a7071 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/react", - "version": "12.0.0-next.10", + "version": "12.0.0-next.11", "description": "React Flow - A highly customizable React library for building node-based editors and interactive flow charts.", "keywords": [ "react", diff --git a/packages/react/src/components/EdgeWrapper/index.tsx b/packages/react/src/components/EdgeWrapper/index.tsx index 3baea708..c114c1b4 100644 --- a/packages/react/src/components/EdgeWrapper/index.tsx +++ b/packages/react/src/components/EdgeWrapper/index.tsx @@ -111,7 +111,7 @@ export function EdgeWrapper({ [edge.markerEnd, rfId] ); - if (edge.hidden || !sourceX || !sourceY || !targetX || !targetY) { + if (edge.hidden || sourceX === null || sourceY === null || targetX === null || targetY === null) { return null; } @@ -212,6 +212,7 @@ export function EdgeWrapper({ id={id} source={edge.source} target={edge.target} + type={edge.type} selected={edge.selected} animated={edge.animated} label={edge.label} diff --git a/packages/react/src/components/EdgeWrapper/utils.ts b/packages/react/src/components/EdgeWrapper/utils.ts index 0ac2a2e8..afbccb13 100644 --- a/packages/react/src/components/EdgeWrapper/utils.ts +++ b/packages/react/src/components/EdgeWrapper/utils.ts @@ -1,5 +1,4 @@ -import type { ComponentType } from 'react'; -import type { EdgeProps, EdgeTypes } from '../../types'; +import type { EdgeTypes } from '../../types'; import { BezierEdgeInternal, StraightEdgeInternal, @@ -9,11 +8,11 @@ import { } from '../Edges'; export const builtinEdgeTypes: EdgeTypes = { - default: BezierEdgeInternal as ComponentType, - straight: StraightEdgeInternal as ComponentType, - step: StepEdgeInternal as ComponentType, - smoothstep: SmoothStepEdgeInternal as ComponentType, - simplebezier: SimpleBezierEdgeInternal as ComponentType, + default: BezierEdgeInternal, + straight: StraightEdgeInternal, + step: StepEdgeInternal, + smoothstep: SmoothStepEdgeInternal, + simplebezier: SimpleBezierEdgeInternal, }; export const nullPosition = { diff --git a/packages/react/src/components/Handle/index.tsx b/packages/react/src/components/Handle/index.tsx index 75c36b8c..f1f84cce 100644 --- a/packages/react/src/components/Handle/index.tsx +++ b/packages/react/src/components/Handle/index.tsx @@ -1,9 +1,11 @@ -/* - * The Handle component is used to connect nodes. When the user mousedowns a handle, we start the connection process. - * The user can then drag the connection to another handle or node. When the user releases the mouse, we check if the - * connection is valid and if so, we call the onConnect callback. - */ -import { memo, HTMLAttributes, forwardRef, MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent } from 'react'; +import { + type HTMLAttributes, + type MouseEvent as ReactMouseEvent, + type TouchEvent as ReactTouchEvent, + type ForwardedRef, + memo, + forwardRef, +} from 'react'; import cc from 'classcat'; import { shallow } from 'zustand/shallow'; import { @@ -58,190 +60,186 @@ const connectingSelector = }; }; -const HandleComponent = forwardRef( - ( - { - type = 'source', - position = Position.Top, - isValidConnection, - isConnectable = true, - isConnectableStart = true, - isConnectableEnd = true, - id, - onConnect, - children, - className, - onMouseDown, - onTouchStart, - ...rest - }, - ref - ) => { - const handleId = id || null; - const isTarget = type === 'target'; - const store = useStoreApi(); - const nodeId = useNodeId(); - const { connectOnClick, noPanClassName, rfId } = useStore(selector, shallow); - const { connectingFrom, connectingTo, clickConnecting, isPossibleEndHandle, connectionInProcess, valid } = useStore( - connectingSelector(nodeId, handleId, type), - shallow - ); +function HandleComponent( + { + type = 'source', + position = Position.Top, + isValidConnection, + isConnectable = true, + isConnectableStart = true, + isConnectableEnd = true, + id, + onConnect, + children, + className, + onMouseDown, + onTouchStart, + ...rest + }: HandleComponentProps, + ref: ForwardedRef +) { + const handleId = id || null; + const isTarget = type === 'target'; + const store = useStoreApi(); + const nodeId = useNodeId(); + const { connectOnClick, noPanClassName, rfId } = useStore(selector, shallow); + const { connectingFrom, connectingTo, clickConnecting, isPossibleEndHandle, connectionInProcess, valid } = useStore( + connectingSelector(nodeId, handleId, type), + shallow + ); - if (!nodeId) { - store.getState().onError?.('010', errorMessages['error010']()); + if (!nodeId) { + store.getState().onError?.('010', errorMessages['error010']()); + } + + const onConnectExtended = (params: Connection) => { + const { defaultEdgeOptions, onConnect: onConnectAction, hasDefaultEdges } = store.getState(); + + const edgeParams = { + ...defaultEdgeOptions, + ...params, + }; + if (hasDefaultEdges) { + const { edges, setEdges } = store.getState(); + setEdges(addEdge(edgeParams, edges)); } - const onConnectExtended = (params: Connection) => { - const { defaultEdgeOptions, onConnect: onConnectAction, hasDefaultEdges } = store.getState(); + onConnectAction?.(edgeParams); + onConnect?.(edgeParams); + }; - const edgeParams = { - ...defaultEdgeOptions, - ...params, - }; - if (hasDefaultEdges) { - const { edges, setEdges } = store.getState(); - setEdges(addEdge(edgeParams, edges)); - } + const onPointerDown = (event: ReactMouseEvent | ReactTouchEvent) => { + if (!nodeId) { + return; + } - onConnectAction?.(edgeParams); - onConnect?.(edgeParams); - }; + const isMouseTriggered = isMouseEvent(event.nativeEvent); - const onPointerDown = (event: ReactMouseEvent | ReactTouchEvent) => { - if (!nodeId) { - return; - } + if ( + isConnectableStart && + ((isMouseTriggered && (event as ReactMouseEvent).button === 0) || !isMouseTriggered) + ) { + const currentStore = store.getState(); - const isMouseTriggered = isMouseEvent(event.nativeEvent); - - if ( - isConnectableStart && - ((isMouseTriggered && (event as ReactMouseEvent).button === 0) || !isMouseTriggered) - ) { - const currentStore = store.getState(); - - XYHandle.onPointerDown(event.nativeEvent, { - autoPanOnConnect: currentStore.autoPanOnConnect, - connectionMode: currentStore.connectionMode, - connectionRadius: currentStore.connectionRadius, - domNode: currentStore.domNode, - nodes: currentStore.nodes, - lib: currentStore.lib, - isTarget, - handleId, - nodeId, - flowId: currentStore.rfId, - panBy: currentStore.panBy, - cancelConnection: currentStore.cancelConnection, - onConnectStart: currentStore.onConnectStart, - onConnectEnd: currentStore.onConnectEnd, - updateConnection: currentStore.updateConnection, - onConnect: onConnectExtended, - isValidConnection: isValidConnection || currentStore.isValidConnection, - getTransform: () => store.getState().transform, - }); - } - - if (isMouseTriggered) { - onMouseDown?.(event as ReactMouseEvent); - } else { - onTouchStart?.(event as ReactTouchEvent); - } - }; - - const onClick = (event: ReactMouseEvent) => { - const { - onClickConnectStart, - onClickConnectEnd, - connectionClickStartHandle, - connectionMode, - isValidConnection: isValidConnectionStore, - lib, - rfId: flowId, - } = store.getState(); - - if (!nodeId || (!connectionClickStartHandle && !isConnectableStart)) { - return; - } - - if (!connectionClickStartHandle) { - onClickConnectStart?.(event.nativeEvent, { nodeId, handleId, handleType: type }); - store.setState({ connectionClickStartHandle: { nodeId, type, handleId } }); - return; - } - - const doc = getHostForElement(event.target as HTMLElement); - const isValidConnectionHandler = isValidConnection || isValidConnectionStore; - const { connection, isValid } = XYHandle.isValid(event.nativeEvent, { - handle: { - nodeId, - id: handleId, - type, - }, - connectionMode, - fromNodeId: connectionClickStartHandle.nodeId, - fromHandleId: connectionClickStartHandle.handleId || null, - fromType: connectionClickStartHandle.type, - isValidConnection: isValidConnectionHandler, - flowId, - doc, - lib, + XYHandle.onPointerDown(event.nativeEvent, { + autoPanOnConnect: currentStore.autoPanOnConnect, + connectionMode: currentStore.connectionMode, + connectionRadius: currentStore.connectionRadius, + domNode: currentStore.domNode, + nodes: currentStore.nodes, + lib: currentStore.lib, + isTarget, + handleId, + nodeId, + flowId: currentStore.rfId, + panBy: currentStore.panBy, + cancelConnection: currentStore.cancelConnection, + onConnectStart: currentStore.onConnectStart, + onConnectEnd: currentStore.onConnectEnd, + updateConnection: currentStore.updateConnection, + onConnect: onConnectExtended, + isValidConnection: isValidConnection || currentStore.isValidConnection, + getTransform: () => store.getState().transform, }); + } - if (isValid && connection) { - onConnectExtended(connection); - } + if (isMouseTriggered) { + onMouseDown?.(event as ReactMouseEvent); + } else { + onTouchStart?.(event as ReactTouchEvent); + } + }; - onClickConnectEnd?.(event as unknown as MouseEvent); + const onClick = (event: ReactMouseEvent) => { + const { + onClickConnectStart, + onClickConnectEnd, + connectionClickStartHandle, + connectionMode, + isValidConnection: isValidConnectionStore, + lib, + rfId: flowId, + } = store.getState(); - store.setState({ connectionClickStartHandle: null }); - }; + if (!nodeId || (!connectionClickStartHandle && !isConnectableStart)) { + return; + } - return ( -
- {children} -
- ); - } -); + if (!connectionClickStartHandle) { + onClickConnectStart?.(event.nativeEvent, { nodeId, handleId, handleType: type }); + store.setState({ connectionClickStartHandle: { nodeId, type, handleId } }); + return; + } -HandleComponent.displayName = 'Handle'; + const doc = getHostForElement(event.target as HTMLElement); + const isValidConnectionHandler = isValidConnection || isValidConnectionStore; + const { connection, isValid } = XYHandle.isValid(event.nativeEvent, { + handle: { + nodeId, + id: handleId, + type, + }, + connectionMode, + fromNodeId: connectionClickStartHandle.nodeId, + fromHandleId: connectionClickStartHandle.handleId || null, + fromType: connectionClickStartHandle.type, + isValidConnection: isValidConnectionHandler, + flowId, + doc, + lib, + }); + + if (isValid && connection) { + onConnectExtended(connection); + } + + onClickConnectEnd?.(event as unknown as MouseEvent); + + store.setState({ connectionClickStartHandle: null }); + }; + + return ( +
+ {children} +
+ ); +} /** - * The Handle component is the part of a node that can be used to connect nodes. + * The Handle component is a UI element that is used to connect nodes. */ -export const Handle = memo(HandleComponent); +export const Handle = memo(forwardRef(HandleComponent)); diff --git a/packages/react/src/components/NodeWrapper/utils.tsx b/packages/react/src/components/NodeWrapper/utils.tsx index f98b6bb6..d6f7cc1a 100644 --- a/packages/react/src/components/NodeWrapper/utils.tsx +++ b/packages/react/src/components/NodeWrapper/utils.tsx @@ -1,5 +1,4 @@ -import type { ComponentType } from 'react'; -import type { NodeProps, XYPosition } from '@xyflow/system'; +import type { XYPosition } from '@xyflow/system'; import { InputNode } from '../Nodes/InputNode'; import { DefaultNode } from '../Nodes/DefaultNode'; @@ -15,10 +14,10 @@ export const arrowKeyDiffs: Record = { }; export const builtinNodeTypes: NodeTypes = { - input: InputNode as ComponentType, - default: DefaultNode as ComponentType, - output: OutputNode as ComponentType, - group: GroupNode as ComponentType, + input: InputNode, + default: DefaultNode, + output: OutputNode, + group: GroupNode, }; export function getNodeInlineStyleDimensions( diff --git a/packages/react/src/components/Nodes/DefaultNode.tsx b/packages/react/src/components/Nodes/DefaultNode.tsx index 132044c5..7bcbae08 100644 --- a/packages/react/src/components/Nodes/DefaultNode.tsx +++ b/packages/react/src/components/Nodes/DefaultNode.tsx @@ -1,13 +1,14 @@ -import { Position, type NodeProps } from '@xyflow/system'; +import { Position } from '@xyflow/system'; import { Handle } from '../../components/Handle'; +import type { BuiltInNode, NodeProps } from '../../types/nodes'; export function DefaultNode({ data, isConnectable, targetPosition = Position.Top, sourcePosition = Position.Bottom, -}: NodeProps) { +}: NodeProps) { return ( <> diff --git a/packages/react/src/components/Nodes/InputNode.tsx b/packages/react/src/components/Nodes/InputNode.tsx index e4b9da9b..15ded922 100644 --- a/packages/react/src/components/Nodes/InputNode.tsx +++ b/packages/react/src/components/Nodes/InputNode.tsx @@ -1,8 +1,9 @@ -import { Position, type NodeProps } from '@xyflow/system'; +import { Position } from '@xyflow/system'; import { Handle } from '../../components/Handle'; +import type { BuiltInNode, NodeProps } from '../../types/nodes'; -export function InputNode({ data, isConnectable, sourcePosition = Position.Bottom }: NodeProps) { +export function InputNode({ data, isConnectable, sourcePosition = Position.Bottom }: NodeProps) { return ( <> {data?.label} diff --git a/packages/react/src/components/Nodes/OutputNode.tsx b/packages/react/src/components/Nodes/OutputNode.tsx index 64af091c..ed61236c 100644 --- a/packages/react/src/components/Nodes/OutputNode.tsx +++ b/packages/react/src/components/Nodes/OutputNode.tsx @@ -1,8 +1,9 @@ -import { Position, type NodeProps } from '@xyflow/system'; +import { Position } from '@xyflow/system'; import { Handle } from '../../components/Handle'; +import type { BuiltInNode, NodeProps } from '../../types/nodes'; -export function OutputNode({ data, isConnectable, targetPosition = Position.Top }: NodeProps) { +export function OutputNode({ data, isConnectable, targetPosition = Position.Top }: NodeProps) { return ( <> diff --git a/packages/react/src/container/GraphView/index.tsx b/packages/react/src/container/GraphView/index.tsx index 80c25dea..3edc380a 100644 --- a/packages/react/src/container/GraphView/index.tsx +++ b/packages/react/src/container/GraphView/index.tsx @@ -172,7 +172,6 @@ function GraphViewComponent
-
nodeTypes={nodeTypes} onNodeClick={onNodeClick} @@ -189,6 +188,7 @@ function GraphViewComponent +
); diff --git a/packages/react/src/container/Pane/index.tsx b/packages/react/src/container/Pane/index.tsx index 414a4745..dfb240d0 100644 --- a/packages/react/src/container/Pane/index.tsx +++ b/packages/react/src/container/Pane/index.tsx @@ -128,7 +128,8 @@ export function Pane({ }; const onMouseMove = (event: ReactMouseEvent): void => { - const { userSelectionRect, edges, transform, nodeOrigin, nodes, onNodesChange, onEdgesChange } = store.getState(); + const { userSelectionRect, edges, transform, nodeOrigin, nodes, triggerNodeChanges, triggerEdgeChanges } = + store.getState(); if (!isSelecting || !containerBounds.current || !userSelectionRect) { return; } @@ -172,17 +173,13 @@ export function Pane({ if (prevSelectedNodesCount.current !== selectedNodeIds.size) { prevSelectedNodesCount.current = selectedNodeIds.size; const changes = getSelectionChanges(nodes, selectedNodeIds, true) as NodeChange[]; - if (changes.length) { - onNodesChange?.(changes); - } + triggerNodeChanges(changes); } if (prevSelectedEdgesCount.current !== selectedEdgeIds.size) { prevSelectedEdgesCount.current = selectedEdgeIds.size; const changes = getSelectionChanges(edges, selectedEdgeIds) as EdgeChange[]; - if (changes.length) { - onEdgesChange?.(changes); - } + triggerEdgeChanges(changes); } store.setState({ diff --git a/packages/react/src/container/ReactFlow/index.tsx b/packages/react/src/container/ReactFlow/index.tsx index 88d8843e..eb741684 100644 --- a/packages/react/src/container/ReactFlow/index.tsx +++ b/packages/react/src/container/ReactFlow/index.tsx @@ -9,7 +9,7 @@ import { StoreUpdater } from '../../components/StoreUpdater'; import { useColorModeClass } from '../../hooks/useColorModeClass'; import { GraphView } from '../GraphView'; import { Wrapper } from './Wrapper'; -import type { Edge, Node, ReactFlowProps, ReactFlowRefType } from '../../types'; +import type { Edge, Node, ReactFlowProps } from '../../types'; import { defaultViewport as initViewport, defaultNodeOrigin } from './init-values'; const wrapperStyle: CSSProperties = { @@ -142,7 +142,7 @@ function ReactFlow( debug, ...rest }: ReactFlowProps, - ref: ForwardedRef + ref: ForwardedRef ) { const rfId = id || '1'; const colorModeClassName = useColorModeClass(colorMode); @@ -286,4 +286,4 @@ function ReactFlow( ); } -export default forwardRef(ReactFlow) as typeof ReactFlow; +export default forwardRef(ReactFlow); diff --git a/packages/react/src/hooks/useNodesData.ts b/packages/react/src/hooks/useNodesData.ts index c43a60cc..7472cbf7 100644 --- a/packages/react/src/hooks/useNodesData.ts +++ b/packages/react/src/hooks/useNodesData.ts @@ -1,15 +1,9 @@ import { useCallback } from 'react'; -import { shallow } from 'zustand/shallow'; +import { shallowNodeData } from '@xyflow/system'; import { useStore } from '../hooks/useStore'; import type { Node } from '../types'; -export interface NodeDataReturn { - id: string; - type: NodeType['type']; - data: NodeType['data']; -} - /** * Hook for receiving data of one or multiple nodes * @@ -46,7 +40,7 @@ export function useNodesData(nodeIds: any): any { }, [nodeIds] ), - shallow + shallowNodeData ); return nodesData; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a757ec23..022f2626 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -64,7 +64,6 @@ export { SelectionMode, type SelectionRect, type OnError, - type NodeProps, type NodeOrigin, type OnSelectionDrag, Position, diff --git a/packages/react/src/types/component-props.ts b/packages/react/src/types/component-props.ts index e8d91ba4..6deca270 100644 --- a/packages/react/src/types/component-props.ts +++ b/packages/react/src/types/component-props.ts @@ -509,5 +509,3 @@ export interface ReactFlowProps & * Custom edge component props * @public */ -export type EdgeProps< - EdgeData extends Record = Record, - EdgeType extends string | undefined = string | undefined -> = Pick, 'id' | 'animated' | 'data' | 'style' | 'selected' | 'source' | 'target'> & +export type EdgeProps = Pick< + EdgeType, + 'id' | 'animated' | 'data' | 'style' | 'selected' | 'source' | 'target' +> & EdgePosition & EdgeLabelOptions & { sourceHandleId?: string | null; diff --git a/packages/react/src/types/general.ts b/packages/react/src/types/general.ts index 0e1e1dc4..6f723237 100644 --- a/packages/react/src/types/general.ts +++ b/packages/react/src/types/general.ts @@ -1,3 +1,4 @@ +import { ComponentType } from 'react'; import { FitViewParamsBase, FitViewOptionsBase, @@ -9,13 +10,19 @@ import { SetCenter, FitBounds, XYPosition, - NodeProps, OnBeforeDeleteBase, Connection, } from '@xyflow/system'; -import type { NodeChange, EdgeChange, Node, Edge, ReactFlowInstance, EdgeProps } from '.'; -import { ComponentType } from 'react'; +import type { NodeChange, EdgeChange, Node, Edge, ReactFlowInstance, EdgeProps, NodeProps } from '.'; + +// this is needed, to use generics + forwardRef +declare module 'react' { + // eslint-disable-next-line @typescript-eslint/ban-types + function forwardRef( + render: (props: P, ref: React.Ref) => React.ReactElement | null + ): (props: P & React.RefAttributes) => React.ReactElement | null; +} export type OnNodesChange = (changes: NodeChange[]) => void; export type OnEdgesChange = (changes: EdgeChange[]) => void; @@ -27,8 +34,28 @@ export type OnDelete edges: EdgeType[]; }) => void; -export type NodeTypes = { [key: string]: ComponentType }; -export type EdgeTypes = { [key: string]: ComponentType }; +export type NodeTypes = Record< + string, + ComponentType< + NodeProps & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: any; + } + > +>; +export type EdgeTypes = Record< + string, + ComponentType< + EdgeProps & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: any; + } + > +>; export type UnselectNodesAndEdgesParams = { nodes?: Node[]; diff --git a/packages/react/src/types/nodes.ts b/packages/react/src/types/nodes.ts index c3c0ea94..52f5a321 100644 --- a/packages/react/src/types/nodes.ts +++ b/packages/react/src/types/nodes.ts @@ -1,5 +1,5 @@ import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; -import type { CoordinateExtent, NodeBase, NodeOrigin, OnError } from '@xyflow/system'; +import type { CoordinateExtent, NodeBase, NodeOrigin, OnError, NodeProps as NodePropsBase } from '@xyflow/system'; import { NodeTypes } from './general'; @@ -9,7 +9,7 @@ import { NodeTypes } from './general'; */ export type Node< NodeData extends Record = Record, - NodeType extends string | undefined = string | undefined + NodeType extends string = string > = NodeBase & { style?: CSSProperties; className?: string; @@ -49,3 +49,5 @@ export type NodeWrapperProps = { }; export type BuiltInNode = Node<{ label: string }, 'input' | 'output' | 'default'>; + +export type NodeProps = NodePropsBase; diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 4635387f..f784ff70 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,16 @@ # @xyflow/svelte +## 0.0.38 + +## ⚠️ Breaking changes + +- `NodeProps` generic is a node and not only node data. `type $$Props = NodeProps` + +## Patch changes + +- unify `Edge` and `Node` type handling +- fix safari: prevent selection of viewport + ## 0.0.37 ## ⚠️ Breaking changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index f73908cb..02b23545 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/svelte", - "version": "0.0.37", + "version": "0.0.38", "description": "Svelte Flow - A highly customizable Svelte library for building node-based editors, workflow systems, diagrams and more.", "keywords": [ "svelte", @@ -46,9 +46,9 @@ "classcat": "^5.0.4" }, "devDependencies": { - "@sveltejs/adapter-auto": "^2.1.0", - "@sveltejs/kit": "^1.22.6", - "@sveltejs/package": "^2.2.1", + "@sveltejs/adapter-auto": "^3.1.1", + "@sveltejs/kit": "^2.5.2", + "@sveltejs/package": "^2.2.7", "@typescript-eslint/eslint-plugin": "^5.60.0", "@typescript-eslint/parser": "^5.60.0", "autoprefixer": "^10.4.15", @@ -65,8 +65,8 @@ "postcss-rename": "^0.6.1", "prettier": "^2.8.8", "prettier-plugin-svelte": "^2.10.1", - "svelte": "^4.2.0", - "svelte-check": "^3.5.0", + "svelte": "^4.2.12", + "svelte-check": "^3.6.6", "svelte-eslint-parser": "^0.32.2", "tslib": "^2.5.3", "typescript": "5.1.3" diff --git a/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte b/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte index 1dff49e7..0a229099 100644 --- a/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte +++ b/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte @@ -51,7 +51,8 @@ edgecontextmenu: { edge: Edge; event: MouseEvent }; }>(); - $: edgeComponent = $edgeTypes[type!] || BezierEdgeInternal; + const edgeType = type || 'default'; + $: edgeComponent = $edgeTypes[edgeType] || BezierEdgeInternal; $: markerStartUrl = markerStart ? `url(#${getMarkerId(markerStart, $flowId)})` : undefined; $: markerEndUrl = markerEnd ? `url(#${getMarkerId(markerEnd, $flowId)})` : undefined; $: isSelectable = selectable || ($elementsSelectable && typeof selectable === 'undefined'); @@ -113,6 +114,7 @@ {data} {style} {interactionWidth} + type={edgeType} sourceHandleId={sourceHandle} targetHandleId={targetHandle} markerStart={markerStartUrl} diff --git a/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte b/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte index 50dda4e7..04dcfc8c 100644 --- a/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte +++ b/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte @@ -1,16 +1,10 @@