diff --git a/examples/react/src/examples/Edges/index.tsx b/examples/react/src/examples/Edges/index.tsx index d6870621..bbba30fb 100644 --- a/examples/react/src/examples/Edges/index.tsx +++ b/examples/react/src/examples/Edges/index.tsx @@ -175,6 +175,15 @@ const edgeTypes: EdgeTypes = { custom2: CustomEdge2, }; +const defaultEdgeOptions = { + markerEnd: { + type: MarkerType.ArrowClosed, + color: 'red', + width: 20, + height: 20, + }, +}; + const EdgesFlow = () => { const [nodes, , onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -197,6 +206,7 @@ const EdgesFlow = () => { onEdgeMouseMove={onEdgeMouseMove} onEdgeMouseLeave={onEdgeMouseLeave} onDelete={console.log} + defaultEdgeOptions={defaultEdgeOptions} > diff --git a/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte b/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte index 809ae82c..e767e4ac 100644 --- a/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte +++ b/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte @@ -34,7 +34,7 @@ // See of connection landed inside the flow pane const targetIsPane = (event.target as HTMLDivElement)?.classList.contains('svelte-flow__pane'); - if (targetIsPane) { + if (targetIsPane && 'clientX' in event && 'clientY' in event) { const id = getId(); const position = { x: event.clientX, diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index f47f7ef4..6931ea5b 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -6,7 +6,10 @@ - selection box is not interrupted by selectionKey being let go - fix `OnNodeDrag` type -- refactor(handles): do not use fallback handle if an id is being used #3409 +- do not use fallback handle if a specific id is being used +- fix `defaultEdgeOptions` markers not being applied +- fix `getNodesBounds` and add second param for passing options +- fix `expandParent` for child nodes ## 12.0.0-next.7 diff --git a/packages/react/package.json b/packages/react/package.json index a7eb71c3..097cba0d 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/react", - "version": "12.0.0-next.7", + "version": "12.0.0-next.8", "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/additional-components/MiniMap/MiniMap.tsx b/packages/react/src/additional-components/MiniMap/MiniMap.tsx index 34a66fa1..cd3f64ae 100644 --- a/packages/react/src/additional-components/MiniMap/MiniMap.tsx +++ b/packages/react/src/additional-components/MiniMap/MiniMap.tsx @@ -25,7 +25,8 @@ const selector = (s: ReactFlowState) => { return { viewBB, - boundingRect: s.nodes.length > 0 ? getBoundsOfRects(getNodesBounds(s.nodes, s.nodeOrigin), viewBB) : viewBB, + boundingRect: + s.nodes.length > 0 ? getBoundsOfRects(getNodesBounds(s.nodes, { nodeOrigin: s.nodeOrigin }), viewBB) : viewBB, rfId: s.rfId, nodeOrigin: s.nodeOrigin, panZoom: s.panZoom, diff --git a/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx b/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx index a681d6f3..0ecc8185 100644 --- a/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx +++ b/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx @@ -73,7 +73,7 @@ export function NodeToolbar({ return null; } - const nodeRect: Rect = getNodesBounds(nodes, nodeOrigin); + const nodeRect: Rect = getNodesBounds(nodes, { nodeOrigin }); const zIndex: number = Math.max(...nodes.map((node) => (node[internalsSymbol]?.z || 1) + 1)); const wrapperStyle: CSSProperties = { diff --git a/packages/react/src/components/NodesSelection/index.tsx b/packages/react/src/components/NodesSelection/index.tsx index 8ba01979..9ded77b8 100644 --- a/packages/react/src/components/NodesSelection/index.tsx +++ b/packages/react/src/components/NodesSelection/index.tsx @@ -22,7 +22,7 @@ export type NodesSelectionProps = { const selector = (s: ReactFlowState) => { const selectedNodes = s.nodes.filter((n) => n.selected); - const { width, height, x, y } = getNodesBounds(selectedNodes, s.nodeOrigin); + const { width, height, x, y } = getNodesBounds(selectedNodes, { nodeOrigin: s.nodeOrigin }); return { width, diff --git a/packages/react/src/container/EdgeRenderer/MarkerDefinitions.tsx b/packages/react/src/container/EdgeRenderer/MarkerDefinitions.tsx index a4696e5b..5b6194c5 100644 --- a/packages/react/src/container/EdgeRenderer/MarkerDefinitions.tsx +++ b/packages/react/src/container/EdgeRenderer/MarkerDefinitions.tsx @@ -1,9 +1,8 @@ -import { memo, useCallback } from 'react'; +import { memo, useMemo } from 'react'; import { type MarkerProps, createMarkerIds } from '@xyflow/system'; import { useStore } from '../../hooks/useStore'; import { useMarkerSymbol } from './MarkerSymbols'; -import type { ReactFlowState } from '../../types'; type MarkerDefinitionsProps = { defaultColor: string; @@ -43,23 +42,23 @@ const Marker = ({ ); }; -const markerSelector = - ({ defaultColor, rfId }: { defaultColor: string; rfId?: string }) => - (s: ReactFlowState) => { - const markers = createMarkerIds(s.edges, { id: rfId, defaultColor }); - - return markers; - }; - -const markersEqual = (a: MarkerProps[], b: MarkerProps[]) => - // the id includes all marker options, so we just need to look at that part of the marker - !(a.length !== b.length || a.some((m, i) => m.id !== b[i].id)); - // when you have multiple flows on a page and you hide the first one, the other ones have no markers anymore // when they do have markers with the same ids. To prevent this the user can pass a unique id to the react flow wrapper // that we can then use for creating our unique marker ids const MarkerDefinitions = ({ defaultColor, rfId }: MarkerDefinitionsProps) => { - const markers = useStore(useCallback(markerSelector({ defaultColor, rfId }), [defaultColor, rfId]), markersEqual); + const edges = useStore((s) => s.edges); + const defaultEdgeOptions = useStore((s) => s.defaultEdgeOptions); + + const markers = useMemo(() => { + const markers = createMarkerIds(edges, { + id: rfId, + defaultColor, + defaultMarkerStart: defaultEdgeOptions?.markerStart, + defaultMarkerEnd: defaultEdgeOptions?.markerEnd, + }); + + return markers; + }, [edges, defaultEdgeOptions, rfId, defaultColor]); if (!markers.length) { return null; diff --git a/packages/react/src/hooks/useConnection.ts b/packages/react/src/hooks/useConnection.ts index 3544f31f..9b458e87 100644 --- a/packages/react/src/hooks/useConnection.ts +++ b/packages/react/src/hooks/useConnection.ts @@ -10,18 +10,24 @@ const selector = (s: ReactFlowStore) => ({ position: s.connectionStartHandle ? s.connectionPosition : null, }); +type UseConnectionResult = { + /** The start handle where the user interaction started or null */ + startHandle: ReactFlowStore['connectionStartHandle']; + /** The target handle that's inside the connection radius or null */ + endHandle: ReactFlowStore['connectionEndHandle']; + /** The current connection status 'valid', 'invalid' or null*/ + status: ReactFlowStore['connectionStatus']; + /** The current connection position or null */ + position: ReactFlowStore['connectionPosition'] | null; +}; + /** * Hook for accessing the ongoing connection. * * @public - * @returns ongoing connection: startHandle, endHandle, status, position + * @returns ongoing connection */ -export function useConnection(): { - startHandle: ReactFlowStore['connectionStartHandle']; - endHandle: ReactFlowStore['connectionEndHandle']; - status: ReactFlowStore['connectionStatus']; - position: ReactFlowStore['connectionPosition'] | null; -} { +export function useConnection(): UseConnectionResult { const ongoingConnection = useStore(selector, shallow); return ongoingConnection; diff --git a/packages/react/src/hooks/useHandleConnections.ts b/packages/react/src/hooks/useHandleConnections.ts index 7232cce4..4f62615e 100644 --- a/packages/react/src/hooks/useHandleConnections.ts +++ b/packages/react/src/hooks/useHandleConnections.ts @@ -13,7 +13,7 @@ type useHandleConnectionsParams = { }; /** - * Hook to check if a is connected to another and get the connections. + * Hook to check if a is connected to another and get the connections. * * @public * @param param.type - handle type 'source' or 'target' diff --git a/packages/react/src/hooks/useOnSelectionChange.ts b/packages/react/src/hooks/useOnSelectionChange.ts index 53c48891..8e08191b 100644 --- a/packages/react/src/hooks/useOnSelectionChange.ts +++ b/packages/react/src/hooks/useOnSelectionChange.ts @@ -11,7 +11,7 @@ export type UseOnSelectionChangeOptions = { * Hook for registering an onSelectionChange handler. * * @public - * @params params.onChange - The handler to register + * @param params.onChange - The handler to register */ export function useOnSelectionChange({ onChange }: UseOnSelectionChangeOptions) { const store = useStoreApi(); diff --git a/packages/react/src/hooks/useViewportHelper.ts b/packages/react/src/hooks/useViewportHelper.ts index 39320459..0e27d933 100644 --- a/packages/react/src/hooks/useViewportHelper.ts +++ b/packages/react/src/hooks/useViewportHelper.ts @@ -86,31 +86,31 @@ const useViewportHelper = (): ViewportHelperFunctions => { panZoom?.setViewport(viewport, { duration: options?.duration }); }, - screenToFlowPosition: (position: XYPosition, options: { snapToGrid: boolean } = { snapToGrid: true }) => { + screenToFlowPosition: (clientPosition: XYPosition, options: { snapToGrid: boolean } = { snapToGrid: true }) => { const { transform, snapGrid, domNode } = store.getState(); if (!domNode) { - return position; + return clientPosition; } const { x: domX, y: domY } = domNode.getBoundingClientRect(); const correctedPosition = { - x: position.x - domX, - y: position.y - domY, + x: clientPosition.x - domX, + y: clientPosition.y - domY, }; return pointToRendererPoint(correctedPosition, transform, options.snapToGrid, snapGrid); }, - flowToScreenPosition: (position: XYPosition) => { + flowToScreenPosition: (flowPosition: XYPosition) => { const { transform, domNode } = store.getState(); if (!domNode) { - return position; + return flowPosition; } const { x: domX, y: domY } = domNode.getBoundingClientRect(); - const rendererPosition = rendererPointToPoint(position, transform); + const rendererPosition = rendererPointToPoint(flowPosition, transform); return { x: rendererPosition.x + domX, diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index 109899d6..aace0bb4 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -38,7 +38,8 @@ const getInitialState = ({ if (fitView && width && height) { const nodesWithDimensions = nextNodes.filter((node) => node.width && node.height); - const bounds = getNodesBounds(nodesWithDimensions, [0, 0]); + // @todo users nodeOrigin should be used here + const bounds = getNodesBounds(nodesWithDimensions, { nodeOrigin: [0, 0] }); const { x, y, zoom } = getViewportForBounds(bounds, width, height, 0.5, 2, 0.1); transform = [x, y, zoom]; } diff --git a/packages/react/src/types/general.ts b/packages/react/src/types/general.ts index 53d95d68..4f3f127a 100644 --- a/packages/react/src/types/general.ts +++ b/packages/react/src/types/general.ts @@ -47,17 +47,91 @@ export type OnInit = ) => void; export type ViewportHelperFunctions = { + /** + * Zooms viewport in by 1.2. + * + * @param options.duration - optional duration. If set, a transition will be applied + */ zoomIn: ZoomInOut; + /** + * Zooms viewport out by 1 / 1.2. + * + * @param options.duration - optional duration. If set, a transition will be applied + */ zoomOut: ZoomInOut; + /** + * Sets the current zoom level. + * + * @param zoomLevel - the zoom level to set + * @param options.duration - optional duration. If set, a transition will be applied + */ zoomTo: ZoomTo; + /** + * Returns the current zoom level. + * + * @returns current zoom as a number + */ getZoom: GetZoom; + /** + * Sets the current viewport. + * + * @param viewport - the viewport to set + * @param options.duration - optional duration. If set, a transition will be applied + */ setViewport: SetViewport; + /** + * Returns the current viewport. + * + * @returns Viewport + */ getViewport: GetViewport; + /** + * Fits the view. + * + * @param options.padding - optional padding + * @param options.includeHiddenNodes - optional includeHiddenNodes + * @param options.minZoom - optional minZoom + * @param options.maxZoom - optional maxZoom + * @param options.duration - optional duration. If set, a transition will be applied + * @param options.nodes - optional nodes to fit the view to + */ fitView: FitView; + /** + * Sets the center of the view to the given position. + * + * @param x - x position + * @param y - y position + * @param options.zoom - optional zoom + */ setCenter: SetCenter; + /** + * Fits the view to the given bounds . + * + * @param bounds - the bounds ({ x: number, y: number, width: number, height: number }) to fit the view to + * @param options.padding - optional padding + */ fitBounds: FitBounds; - screenToFlowPosition: (position: XYPosition, options?: { snapToGrid: boolean }) => XYPosition; - flowToScreenPosition: (position: XYPosition) => XYPosition; + /** + * Converts a screen / client position to a flow position. + * + * @param clientPosition - the screen / client position. When you are working with events you can use event.clientX and event.clientY + * @param options.snapToGrid - if true, the converted position will be snapped to the grid + * @returns position as { x: number, y: number } + * + * @example + * const flowPosition = screenToFlowPosition({ x: event.clientX, y: event.clientY }) + */ + screenToFlowPosition: (clientPosition: XYPosition, options?: { snapToGrid: boolean }) => XYPosition; + /** + * Converts a flow position to a screen / client position. + * + * @param flowPosition - the screen / client position. When you are working with events you can use event.clientX and event.clientY + * @returns position as { x: number, y: number } + * + * @example + * const clientPosition = flowToScreenPosition({ x: node.position.x, y: node.position.y }) + */ + flowToScreenPosition: (flowPosition: XYPosition) => XYPosition; viewportInitialized: boolean; }; diff --git a/packages/react/src/types/instance.ts b/packages/react/src/types/instance.ts index 6b37fd14..d34c23dc 100644 --- a/packages/react/src/types/instance.ts +++ b/packages/react/src/types/instance.ts @@ -59,19 +59,112 @@ export namespace Instance { } export type ReactFlowInstance = { + /** + * Returns nodes. + * + * @returns nodes array + */ getNodes: Instance.GetNodes; + /** + * Sets nodes. + * + * @param payload - the nodes to set or a function that receives the current nodes and returns the new nodes + */ setNodes: Instance.SetNodes; + /** + * Adds nodes. + * + * @param payload - the nodes to add + */ addNodes: Instance.AddNodes; + /** + * Returns a node by id. + * + * @param id - the node id + * @returns the node or undefined if no node was found + */ getNode: Instance.GetNode; + /** + * Returns edges. + * + * @returns edges array + */ getEdges: Instance.GetEdges; + /** + * Sets edges. + * + * @param payload - the edges to set or a function that receives the current edges and returns the new edges + */ setEdges: Instance.SetEdges; + /** + * Adds edges. + * + * @param payload - the edges to add + */ addEdges: Instance.AddEdges; + /** + * Returns an edge by id. + * + * @param id - the edge id + * @returns the edge or undefined if no edge was found + */ getEdge: Instance.GetEdge; + /** + * Returns the nodes, edges and the viewport as a JSON object. + * + * @returns the nodes, edges and the viewport as a JSON object + */ toObject: Instance.ToObject; + /** + * Deletes nodes and edges. + * + * @param params.nodes - optional nodes array to delete + * @param params.edges - optional edges array to delete + * + * @returns a promise that resolves with the deleted nodes and edges + */ deleteElements: Instance.DeleteElements; + /** + * Returns all nodes that intersect with the given node or rect. + * + * @param node - the node or rect to check for intersections + * @param partially - if true, the node is considered to be intersecting if it partially overlaps with the passed node or rect + * @param nodes - optional nodes array to check for intersections + * + * @returns an array of intersecting nodes + */ getIntersectingNodes: Instance.GetIntersectingNodes; + /** + * Checks if the given node or rect intersects with the passed rect. + * + * @param node - the node or rect to check for intersections + * @param area - the rect to check for intersections + * @param partially - if true, the node is considered to be intersecting if it partially overlaps with the passed react + * + * @returns true if the node or rect intersects with the given area + */ isNodeIntersecting: Instance.IsNodeIntersecting; + /** + * Updates a node. + * + * @param id - id of the node to update + * @param nodeUpdate - the node update as an object or a function that receives the current node and returns the node update + * @param options.replace - if true, the node is replaced with the node update, otherwise the changes get merged + * + * @example + * updateNode('node-1', (node) => ({ position: { x: node.position.x + 10, y: node.position.y } })); + */ updateNode: Instance.UpdateNode; + /** + * Updates the data attribute of a node. + * + * @param id - id of the node to update + * @param dataUpdate - the data update as an object or a function that receives the current data and returns the data update + * @param options.replace - if true, the data is replaced with the data update, otherwise the changes get merged + * + * @example + * updateNodeData('node-1', { label: 'A new label' }); + */ updateNodeData: Instance.UpdateNodeData; viewportInitialized: boolean; } & Omit; diff --git a/packages/react/src/utils/changes.ts b/packages/react/src/utils/changes.ts index 6bcddabd..310b784c 100644 --- a/packages/react/src/utils/changes.ts +++ b/packages/react/src/utils/changes.ts @@ -6,10 +6,7 @@ export function handleParentExpand(updatedElements: any[], updateItem: any) { for (const [index, item] of updatedElements.entries()) { if (item.id === updateItem.parentNode) { const parent = { ...item }; - - if (!parent.computed) { - parent.computed = {}; - } + parent.computed ??= {}; const extendWidth = updateItem.position.x + updateItem.computed.width - parent.computed.width; const extendHeight = updateItem.position.y + updateItem.computed.height - parent.computed.height; @@ -106,7 +103,7 @@ function applyChanges(changes: any[], elements: any[]): any[] { const updatedElement = { ...element }; for (const change of changes) { - applyChange(change, updatedElement, elements); + applyChange(change, updatedElement, updatedElements); } updatedElements.push(updatedElement); diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 6ca5483e..b534df65 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -11,7 +11,9 @@ - selection box is not interrupted by selectionKey being let go - Edge label has a default background and is clickable -- refactor(handles): do not use fallback handle if an id is being used #3409 +- do not use fallback handle if a specific id is being used +- use correct id for `` data-id attribute +- fix `getNodesBounds` and add second param for passing options ## 0.0.34 diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 5095441e..ac688280 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/svelte", - "version": "0.0.34", + "version": "0.0.35", "description": "Svelte Flow - A highly customizable Svelte library for building node-based editors, workflow systems, diagrams and more.", "keywords": [ "svelte", diff --git a/packages/svelte/src/lib/components/Handle/Handle.svelte b/packages/svelte/src/lib/components/Handle/Handle.svelte index 87b6c9d2..f00ccc44 100644 --- a/packages/svelte/src/lib/components/Handle/Handle.svelte +++ b/packages/svelte/src/lib/components/Handle/Handle.svelte @@ -134,7 +134,7 @@ The Handle component is the part of a node that can be used to connect nodes. data-handleid={handleId} data-nodeid={nodeId} data-handlepos={position} - data-id="{flowId}-{nodeId}-{id || null}-{type}" + data-id="{$flowId}-{nodeId}-{id || null}-{type}" class={cc([ 'svelte-flow__handle', `svelte-flow__handle-${position}`, diff --git a/packages/svelte/src/lib/hooks/useSvelteFlow.ts b/packages/svelte/src/lib/hooks/useSvelteFlow.ts index b6bb941e..c035a52a 100644 --- a/packages/svelte/src/lib/hooks/useSvelteFlow.ts +++ b/packages/svelte/src/lib/hooks/useSvelteFlow.ts @@ -24,32 +24,136 @@ import { isNode } from '$lib/utils'; * Hook for accessing the ReactFlow instance. * * @public + * * @returns helper functions */ export function useSvelteFlow(): { + /** + * Zooms viewport in by 1.2. + * + * @param options.duration - optional duration. If set, a transition will be applied + */ zoomIn: ZoomInOut; + /** + * Zooms viewport out by 1 / 1.2. + * + * @param options.duration - optional duration. If set, a transition will be applied + */ zoomOut: ZoomInOut; + /** + * Returns a node by id. + * + * @param id - the node id + * @returns the node or undefined if no node was found + */ getNode: (id: string) => Node | undefined; + /** + * Returns nodes. + * + * @returns nodes array + */ getNodes: (ids?: string[]) => Node[]; + /** + * Returns an edge by id. + * + * @param id - the edge id + * @returns the edge or undefined if no edge was found + */ getEdge: (id: string) => Edge | undefined; + /** + * Returns edges. + * + * @returns edges array + */ getEdges: (ids?: string[]) => Edge[]; + /** + * Sets the current zoom level. + * + * @param zoomLevel - the zoom level to set + * @param options.duration - optional duration. If set, a transition will be applied + */ setZoom: (zoomLevel: number, options?: ViewportHelperFunctionOptions) => void; + /** + * Returns the current zoom level. + * + * @returns current zoom as a number + */ getZoom: () => number; + /** + * Sets the center of the view to the given position. + * + * @param x - x position + * @param y - y position + * @param options.zoom - optional zoom + */ setCenter: (x: number, y: number, options?: SetCenterOptions) => void; + /** + * Sets the current viewport. + * + * @param viewport - the viewport to set + * @param options.duration - optional duration. If set, a transition will be applied + */ setViewport: (viewport: Viewport, options?: ViewportHelperFunctionOptions) => void; + /** + * Returns the current viewport. + * + * @returns Viewport + */ getViewport: () => Viewport; + /** + * Fits the view. + * + * @param options.padding - optional padding + * @param options.includeHiddenNodes - optional includeHiddenNodes + * @param options.minZoom - optional minZoom + * @param options.maxZoom - optional maxZoom + * @param options.duration - optional duration. If set, a transition will be applied + * @param options.nodes - optional nodes to fit the view to + */ fitView: (options?: FitViewOptions) => void; + /** + * Returns all nodes that intersect with the given node or rect. + * + * @param node - the node or rect to check for intersections + * @param partially - if true, the node is considered to be intersecting if it partially overlaps with the passed node or rect + * @param nodes - optional nodes array to check for intersections + * + * @returns an array of intersecting nodes + */ getIntersectingNodes: ( nodeOrRect: Node | { id: Node['id'] } | Rect, partially?: boolean, nodesToIntersect?: Node[] ) => Node[]; + /** + * Checks if the given node or rect intersects with the passed rect. + * + * @param node - the node or rect to check for intersections + * @param area - the rect to check for intersections + * @param partially - if true, the node is considered to be intersecting if it partially overlaps with the passed react + * + * @returns true if the node or rect intersects with the given area + */ isNodeIntersecting: ( nodeOrRect: Node | { id: Node['id'] } | Rect, area: Rect, partially?: boolean ) => boolean; + /** + * Fits the view to the given bounds . + * + * @param bounds - the bounds ({ x: number, y: number, width: number, height: number }) to fit the view to + * @param options.padding - optional padding + */ fitBounds: (bounds: Rect, options?: FitBoundsOptions) => void; + /** + * Deletes nodes and edges. + * + * @param params.nodes - optional nodes array to delete + * @param params.edges - optional edges array to delete + * + * @returns a promise that resolves with the deleted nodes and edges + */ deleteElements: ({ nodes, edges @@ -57,19 +161,66 @@ export function useSvelteFlow(): { nodes?: (Node | { id: Node['id'] })[]; edges?: (Edge | { id: Edge['id'] })[]; }) => Promise<{ deletedNodes: Node[]; deletedEdges: Edge[] }>; - screenToFlowPosition: (position: XYPosition, options?: { snapToGrid: boolean }) => XYPosition; - flowToScreenPosition: (position: XYPosition) => XYPosition; + /** + * Converts a screen / client position to a flow position. + * + * @param clientPosition - the screen / client position. When you are working with events you can use event.clientX and event.clientY + * @param options.snapToGrid - if true, the converted position will be snapped to the grid + * @returns position as { x: number, y: number } + * + * @example + * const flowPosition = screenToFlowPosition({ x: event.clientX, y: event.clientY }) + */ + screenToFlowPosition: ( + clientPosition: XYPosition, + options?: { snapToGrid: boolean } + ) => XYPosition; + /** + * Converts a flow position to a screen / client position. + * + * @param flowPosition - the screen / client position. When you are working with events you can use event.clientX and event.clientY + * @returns position as { x: number, y: number } + * + * @example + * const clientPosition = flowToScreenPosition({ x: node.position.x, y: node.position.y }) + */ + flowToScreenPosition: (flowPosition: XYPosition) => XYPosition; viewport: Writable; + /** + * Updates a node. + * + * @param id - id of the node to update + * @param nodeUpdate - the node update as an object or a function that receives the current node and returns the node update + * @param options.replace - if true, the node is replaced with the node update, otherwise the changes get merged + * + * @example + * updateNode('node-1', (node) => ({ position: { x: node.position.x + 10, y: node.position.y } })); + */ updateNode: ( id: string, nodeUpdate: Partial | ((node: Node) => Partial), options?: { replace: boolean } ) => void; + /** + * Updates the data attribute of a node. + * + * @param id - id of the node to update + * @param dataUpdate - the data update as an object or a function that receives the current data and returns the data update + * @param options.replace - if true, the data is replaced with the data update, otherwise the changes get merged + * + * @example + * updateNodeData('node-1', { label: 'A new label' }); + */ updateNodeData: ( id: string, dataUpdate: object | ((node: Node) => object), options?: { replace: boolean } ) => void; + /** + * Returns the nodes, edges and the viewport as a JSON object. + * + * @returns the nodes, edges and the viewport as a JSON object + */ toObject: () => { nodes: Node[]; edges: Edge[]; viewport: Viewport }; } { const { @@ -263,6 +414,11 @@ export function useSvelteFlow(): { _snapGrid || [1, 1] ); }, + /** + * + * @param position + * @returns + */ flowToScreenPosition: (position: XYPosition) => { const _domNode = get(domNode); @@ -279,6 +435,7 @@ export function useSvelteFlow(): { y: rendererPosition.y + domY }; }, + toObject: () => { return { nodes: get(nodes).map((node) => ({ diff --git a/packages/svelte/src/lib/plugins/NodeToolbar/NodeToolbar.svelte b/packages/svelte/src/lib/plugins/NodeToolbar/NodeToolbar.svelte index fab6ce2d..ca6dba1f 100644 --- a/packages/svelte/src/lib/plugins/NodeToolbar/NodeToolbar.svelte +++ b/packages/svelte/src/lib/plugins/NodeToolbar/NodeToolbar.svelte @@ -58,7 +58,7 @@ height: toolbarNode.computed?.height ?? toolbarNode.height ?? 0 }; } else if (toolbarNodes.length > 1) { - nodeRect = getNodesBounds(toolbarNodes, $nodeOrigin); + nodeRect = getNodesBounds(toolbarNodes, { nodeOrigin: $nodeOrigin }); } if (nodeRect) { diff --git a/packages/svelte/src/lib/store/initial-store.ts b/packages/svelte/src/lib/store/initial-store.ts index fedaa364..6c18c877 100644 --- a/packages/svelte/src/lib/store/initial-store.ts +++ b/packages/svelte/src/lib/store/initial-store.ts @@ -92,7 +92,8 @@ export const getInitialStore = ({ if (fitView && width && height) { const nodesWithDimensions = nextNodes.filter((node) => node.width && node.height); - const bounds = getNodesBounds(nodesWithDimensions, [0, 0]); + // @todo users nodeOrigin should be used here + const bounds = getNodesBounds(nodesWithDimensions, { nodeOrigin: [0, 0] }); viewport = getViewportForBounds(bounds, width, height, 0.5, 2, 0.1); } diff --git a/packages/system/package.json b/packages/system/package.json index 4eecdb57..e4fc6875 100644 --- a/packages/system/package.json +++ b/packages/system/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/system", - "version": "0.0.15", + "version": "0.0.16", "description": "xyflow core system that powers React Flow and Svelte Flow.", "keywords": [ "node-based UI", diff --git a/packages/system/src/utils/graph.ts b/packages/system/src/utils/graph.ts index 9dd23a1c..6407d1a1 100644 --- a/packages/system/src/utils/graph.ts +++ b/packages/system/src/utils/graph.ts @@ -102,11 +102,13 @@ export const getIncomers = { +): { position: XYPosition; positionAbsolute: XYPosition } => { if (!node) { return { - x: 0, - y: 0, + position: { + x: 0, + y: 0, + }, positionAbsolute: { x: 0, y: 0, @@ -123,7 +125,7 @@ export const getNodePositionWithOrigin = ( }; return { - ...position, + position, positionAbsolute: node.computed?.positionAbsolute ? { x: node.computed.positionAbsolute.x - offsetX, @@ -133,27 +135,35 @@ export const getNodePositionWithOrigin = ( }; }; +export type GetNodesBoundsParams = { + nodeOrigin?: NodeOrigin; + useRelativePosition?: boolean; +}; + /** * Determines a bounding box that contains all given nodes in an array * @public * @remarks Useful when combined with {@link getViewportForBounds} to calculate the correct transform to fit the given nodes in a viewport. * @param nodes - Nodes to calculate the bounds for - * @param nodeOrigin - Origin of the nodes: [0, 0] - top left, [0.5, 0.5] - center + * @param params.nodeOrigin - Origin of the nodes: [0, 0] - top left, [0.5, 0.5] - center + * @param params.useRelativePosition - Whether to use the relative or absolute node positions * @returns Bounding box enclosing all nodes */ -export const getNodesBounds = (nodes: NodeBase[], nodeOrigin: NodeOrigin = [0, 0]): Rect => { +export const getNodesBounds = ( + nodes: NodeBase[], + params: GetNodesBoundsParams = { nodeOrigin: [0, 0], useRelativePosition: false } +): Rect => { if (nodes.length === 0) { return { x: 0, y: 0, width: 0, height: 0 }; } const box = nodes.reduce( (currBox, node) => { - const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin); + const nodePos = getNodePositionWithOrigin(node, node.origin || params.nodeOrigin); return getBoundsOfBoxes( currBox, rectToBox({ - x, - y, + ...nodePos[params.useRelativePosition ? 'position' : 'positionAbsolute'], width: node.computed?.width ?? node.width ?? 0, height: node.computed?.height ?? node.height ?? 0, }) @@ -239,7 +249,7 @@ export function fitView, Options exte }); if (filteredNodes.length > 0) { - const bounds = getNodesBounds(filteredNodes, nodeOrigin); + const bounds = getNodesBounds(filteredNodes, { nodeOrigin }); const viewport = getViewportForBounds( bounds, diff --git a/packages/system/src/utils/marker.ts b/packages/system/src/utils/marker.ts index 0a191276..834f17ef 100644 --- a/packages/system/src/utils/marker.ts +++ b/packages/system/src/utils/marker.ts @@ -19,21 +19,32 @@ export function getMarkerId(marker: EdgeMarkerType | undefined, id?: string | nu export function createMarkerIds( edges: EdgeBase[], - { id, defaultColor }: { id?: string | null; defaultColor?: string } + { + id, + defaultColor, + defaultMarkerStart, + defaultMarkerEnd, + }: { + id?: string | null; + defaultColor?: string; + defaultMarkerStart?: EdgeMarkerType; + defaultMarkerEnd?: EdgeMarkerType; + } ) { - const ids: string[] = []; + const ids = new Set(); return edges .reduce((markers, edge) => { - [edge.markerStart, edge.markerEnd].forEach((marker) => { + [edge.markerStart || defaultMarkerStart, edge.markerEnd || defaultMarkerEnd].forEach((marker) => { if (marker && typeof marker === 'object') { const markerId = getMarkerId(marker, id); - if (!ids.includes(markerId)) { + if (!ids.has(markerId)) { markers.push({ id: markerId, color: marker.color || defaultColor, ...marker }); - ids.push(markerId); + ids.add(markerId); } } }); + return markers; }, []) .sort((a, b) => a.id.localeCompare(b.id)); diff --git a/packages/system/src/utils/store.ts b/packages/system/src/utils/store.ts index 8efb84c2..51cb1329 100644 --- a/packages/system/src/utils/store.ts +++ b/packages/system/src/utils/store.ts @@ -136,7 +136,7 @@ function calculateXYZPosition( } const parentNode = nodeLookup.get(node.parentNode)!; - const parentNodePosition = getNodePositionWithOrigin(parentNode, parentNode?.origin || nodeOrigin); + const { position: parentNodePosition } = getNodePositionWithOrigin(parentNode, parentNode?.origin || nodeOrigin); return calculateXYZPosition( parentNode, diff --git a/packages/system/src/xydrag/XYDrag.ts b/packages/system/src/xydrag/XYDrag.ts index b2e8af9f..ffd19f37 100644 --- a/packages/system/src/xydrag/XYDrag.ts +++ b/packages/system/src/xydrag/XYDrag.ts @@ -120,7 +120,7 @@ export function XYDrag voi let nodesBox: Box = { x: 0, y: 0, x2: 0, y2: 0 }; if (dragItems.length > 1 && nodeExtent) { - const rect = getNodesBounds(dragItems as unknown as NodeBase[], nodeOrigin); + const rect = getNodesBounds(dragItems as unknown as NodeBase[], { nodeOrigin }); nodesBox = rectToBox(rect); } diff --git a/packages/system/src/xyhandle/XYHandle.ts b/packages/system/src/xyhandle/XYHandle.ts index 239e6356..4ddb5313 100644 --- a/packages/system/src/xyhandle/XYHandle.ts +++ b/packages/system/src/xyhandle/XYHandle.ts @@ -260,9 +260,10 @@ function isValidHandle( }: IsValidParams ) { const isTarget = fromType === 'target'; - const handleDomNode = doc.querySelector( - `.${lib}-flow__handle[data-id="${flowId}-${handle?.nodeId}-${handle?.id}-${handle?.type}"]` - ); + const handleDomNode = handle + ? doc.querySelector(`.${lib}-flow__handle[data-id="${flowId}-${handle?.nodeId}-${handle?.id}-${handle?.type}"]`) + : null; + const { x, y } = getEventPosition(event); const handleBelow = doc.elementFromPoint(x, y); // we always want to prioritize the handle below the mouse cursor over the closest distance handle,