diff --git a/examples/react/src/examples/DragNDrop/index.tsx b/examples/react/src/examples/DragNDrop/index.tsx index 16e9737e..a3e09d8d 100644 --- a/examples/react/src/examples/DragNDrop/index.tsx +++ b/examples/react/src/examples/DragNDrop/index.tsx @@ -49,9 +49,9 @@ const DnDFlow = () => { if (reactFlowInstance) { const type = event.dataTransfer.getData('application/reactflow'); - const position = reactFlowInstance.project({ + const position = reactFlowInstance.screenToFlowCoordinate({ x: event.clientX, - y: event.clientY - 40, + y: event.clientY, }); const newNode: Node = { id: getId(), diff --git a/examples/svelte/src/components/Header/Header.svelte b/examples/svelte/src/components/Header/Header.svelte index 6755fc2d..e842824e 100644 --- a/examples/svelte/src/components/Header/Header.svelte +++ b/examples/svelte/src/components/Header/Header.svelte @@ -15,7 +15,9 @@ 'two-way-viewport', 'usesvelteflow', 'useupdatenodeinternals', - 'validation' + 'validation', + 'intersections', + 'add-node-on-drop' ]; const onChange = (event: Event) => { diff --git a/examples/svelte/src/routes/add-node-on-drop/+page.svelte b/examples/svelte/src/routes/add-node-on-drop/+page.svelte new file mode 100644 index 00000000..338f964f --- /dev/null +++ b/examples/svelte/src/routes/add-node-on-drop/+page.svelte @@ -0,0 +1,9 @@ + + + + + + diff --git a/examples/svelte/src/routes/add-node-on-drop/Flow.svelte b/examples/svelte/src/routes/add-node-on-drop/Flow.svelte new file mode 100644 index 00000000..cd12c1a4 --- /dev/null +++ b/examples/svelte/src/routes/add-node-on-drop/Flow.svelte @@ -0,0 +1,115 @@ + + + + +
+ { + // Memorize the nodeId you start draggin a connection line from a node + connectingNodeId = nodeId; + }} + on:connectend={handleConnectEnd} + /> +
+ + diff --git a/examples/svelte/src/routes/intersections/+page.svelte b/examples/svelte/src/routes/intersections/+page.svelte new file mode 100644 index 00000000..8937b2c0 --- /dev/null +++ b/examples/svelte/src/routes/intersections/+page.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/examples/svelte/src/routes/intersections/+server.ts b/examples/svelte/src/routes/intersections/+server.ts new file mode 100644 index 00000000..ceca3ead --- /dev/null +++ b/examples/svelte/src/routes/intersections/+server.ts @@ -0,0 +1,29 @@ +// Template for ALL endpoints serving the code snippets +// No need to edit these inside /src/routes/** +// Will be overwritten by "pnpm run create:endpoints" + +import { json } from '@sveltejs/kit'; + +export function POST() { + const files = import.meta.glob(['./*.js', './*.ts', './*.svelte', './*css', '!**/+server.ts'], { + as: 'raw', + eager: true + }); + + // Loose ./ for each filename + // +page.svelte becomes App.svelte for correct display in Sandpack + const filesClean: { [key: string]: string } = Object.entries(files).reduce( + (filesCleanAcc: { [key: string]: string }, [filename, file]) => { + if (filename === './+page.svelte') { + filesCleanAcc['App.svelte'] = file; + } else { + filesCleanAcc[filename.replace('./', '')] = file; + } + + return filesCleanAcc; + }, + {} + ); + + return json(filesClean); +} diff --git a/examples/svelte/src/routes/intersections/Flow.svelte b/examples/svelte/src/routes/intersections/Flow.svelte new file mode 100644 index 00000000..f3f80335 --- /dev/null +++ b/examples/svelte/src/routes/intersections/Flow.svelte @@ -0,0 +1,60 @@ + + +
+ + + + +
+ + diff --git a/examples/svelte/src/routes/intersections/nodes-and-edges.ts b/examples/svelte/src/routes/intersections/nodes-and-edges.ts new file mode 100644 index 00000000..fffb59b8 --- /dev/null +++ b/examples/svelte/src/routes/intersections/nodes-and-edges.ts @@ -0,0 +1,28 @@ +import type { Node, Edge } from '@xyflow/svelte'; + +export const initialNodes: Node[] = [ + { + id: '1', + data: { label: 'Node 1' }, + position: { x: 0, y: 0 }, + style: 'width: 200px; height: 100px;' + }, + { + id: '2', + data: { label: 'Node 2' }, + position: { x: 0, y: 150 } + }, + { + id: '3', + data: { label: 'Node 3' }, + position: { x: 250, y: 0 } + }, + { + id: '4', + data: { label: 'Node' }, + position: { x: 350, y: 150 }, + style: 'width: 50px; height: 50px;' + } +]; + +export const initialEdges: Edge[] = []; diff --git a/packages/react/src/hooks/useReactFlow.ts b/packages/react/src/hooks/useReactFlow.ts index 8e94954a..da65a4e3 100644 --- a/packages/react/src/hooks/useReactFlow.ts +++ b/packages/react/src/hooks/useReactFlow.ts @@ -172,6 +172,8 @@ export default function useReactFlow(): ReactFlo } } } + + return { deletedNodes: matchingNodes, deletedEdges: matchingEdges }; }, []); const getNodeRect = useCallback( diff --git a/packages/react/src/hooks/useViewportHelper.ts b/packages/react/src/hooks/useViewportHelper.ts index aded1f17..1872d414 100644 --- a/packages/react/src/hooks/useViewportHelper.ts +++ b/packages/react/src/hooks/useViewportHelper.ts @@ -1,5 +1,11 @@ import { useMemo } from 'react'; -import { pointToRendererPoint, getTransformForBounds, fitView, type XYPosition } from '@xyflow/system'; +import { + pointToRendererPoint, + getTransformForBounds, + fitView, + type XYPosition, + rendererPointToPoint, +} from '@xyflow/system'; import { useStoreApi, useStore } from '../hooks/useStore'; import type { ViewportHelperFunctions, ReactFlowState } from '../types'; @@ -85,6 +91,36 @@ const useViewportHelper = (): ViewportHelperFunctions => { const { transform, snapToGrid, snapGrid } = store.getState(); return pointToRendererPoint(position, transform, snapToGrid, snapGrid); }, + screenToFlowCoordinate: (position: XYPosition) => { + const { transform, snapToGrid, snapGrid, domNode } = store.getState(); + if (domNode) { + const { x: domX, y: domY } = domNode.getBoundingClientRect(); + + const correctedPosition = { + x: position.x - domX, + y: position.y - domY, + }; + + return pointToRendererPoint(correctedPosition, transform, snapToGrid, snapGrid || [1, 1]); + } + + return { x: 0, y: 0 }; + }, + flowToScreenCoordinate: (position: XYPosition) => { + const { transform, domNode } = store.getState(); + if (domNode) { + const { x: domX, y: domY } = domNode.getBoundingClientRect(); + + const rendererPosition = rendererPointToPoint(position, transform); + + return { + x: rendererPosition.x + domX, + y: rendererPosition.y + domY, + }; + } + + return { x: 0, y: 0 }; + }, viewportInitialized: panZoomInitialized, }; }, [panZoomInitialized]); diff --git a/packages/react/src/types/general.ts b/packages/react/src/types/general.ts index 8108c3dd..2b2945a6 100644 --- a/packages/react/src/types/general.ts +++ b/packages/react/src/types/general.ts @@ -55,5 +55,7 @@ export type ViewportHelperFunctions = { setCenter: SetCenter; fitBounds: FitBounds; project: Project; + screenToFlowCoordinate: Project; + flowToScreenCoordinate: Project; viewportInitialized: boolean; }; diff --git a/packages/react/src/types/instance.ts b/packages/react/src/types/instance.ts index 694329fb..3d123c1a 100644 --- a/packages/react/src/types/instance.ts +++ b/packages/react/src/types/instance.ts @@ -28,7 +28,10 @@ export namespace Instance { export type GetEdge = (id: string) => Edge | undefined; export type AddEdges = (payload: Edge[] | Edge) => void; export type ToObject = () => ReactFlowJsonObject; - export type DeleteElements = ({ nodes, edges }: DeleteElementsOptions) => void; + export type DeleteElements = ({ nodes, edges }: DeleteElementsOptions) => { + deletedNodes: Node[]; + deletedEdges: Edge[]; + }; export type GetIntersectingNodes = ( node: (Partial> & { id: Node['id'] }) | Rect, partially?: boolean, diff --git a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte index ef5d547b..d9eb867d 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte +++ b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte @@ -71,13 +71,14 @@ export { className as class }; let domNode: HTMLDivElement; + let clientWidth: number; + let clientHeight: number; const store = hasContext(key) ? useStore() : createStoreContext(); onMount(() => { - const { width, height } = domNode.getBoundingClientRect(); - store.width.set(width); - store.height.set(height); + store.width.set(clientWidth); + store.height.set(clientHeight); store.domNode.set(domNode); store.syncNodeStores(nodes); @@ -141,6 +142,8 @@
void; getViewport: () => Viewport; fitView: (options?: FitViewOptions) => void; - project: Project; + getIntersectingNodes: ( + nodeOrRect: (Partial & { id: Node['id'] }) | Rect, + partially?: boolean, + nodesToIntersect?: Node[] + ) => Node[]; + isNodeIntersecting: ( + nodeOrRect: (Partial & { id: Node['id'] }) | Rect, + area: Rect, + partially?: boolean + ) => boolean; + fitBounds: (bounds: Rect, options?: FitBoundsOptions) => void; + deleteElements: ( + nodesToRemove?: Partial & { id: string }[], + edgesToRemove?: Partial & { id: string }[] + ) => { deletedNodes: Node[]; deletedEdges: Edge[] }; + screenToFlowCoordinate: (position: XYPosition) => XYPosition; + flowToScreenCoordinate: (position: XYPosition) => XYPosition; viewport: Writable; - nodes: SvelteFlowStore['nodes']; - edges: SvelteFlowStore['edges']; getConnectedEdges: (id: string | (Partial & { id: Node['id'] })[]) => Edge[]; getIncomers: (node: string | (Partial & { id: Node['id'] })) => Node[]; getOutgoers: (node: string | (Partial & { id: Node['id'] })) => Node[]; @@ -40,12 +60,29 @@ export function useSvelteFlow(): { viewport, width, height, + minZoom, maxZoom, panZoom, nodes, - edges + edges, + domNode } = useStore(); + const getNodeRect = ( + nodeOrRect: (Partial & { id: Node['id'] }) | Rect + ): [Rect | null, Node | null | undefined, boolean] => { + const isRect = isRectObject(nodeOrRect); + const node = isRect ? null : get(nodes).find((n) => n.id === nodeOrRect.id); + + if (!isRect && !node) { + return [null, null, isRect]; + } + + const nodeRect = isRect ? nodeOrRect : nodeToRect(node!); + + return [nodeRect, node, isRect]; + }; + return { zoomIn, zoomOut, @@ -67,34 +104,145 @@ export function useSvelteFlow(): { }, getViewport: () => get(viewport), setCenter: (x, y, options) => { - const _width = get(width); - const _height = get(height); - const _maxZoom = get(maxZoom); - - const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : _maxZoom; + const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : get(maxZoom); get(panZoom)?.setViewport( { - x: _width / 2 - x * nextZoom, - y: _height / 2 - y * nextZoom, + x: get(width) / 2 - x * nextZoom, + y: get(height) / 2 - y * nextZoom, zoom: nextZoom }, { duration: options?.duration } ); }, fitView, - project: (position: XYPosition) => { - const _snapGrid = get(snapGrid); - const { x, y, zoom } = get(viewport); + fitBounds: (bounds: Rect, options?: FitBoundsOptions) => { + const [x, y, zoom] = getTransformForBounds( + bounds, + get(width), + get(height), + get(minZoom), + get(maxZoom), + options?.padding ?? 0.1 + ); - return pointToRendererPoint(position, [x, y, zoom], _snapGrid !== null, _snapGrid || [1, 1]); + get(panZoom)?.setViewport( + { + x, + y, + zoom + }, + { duration: options?.duration } + ); }, - nodes, - edges, - getConnectedEdges: (node) => { - const _edges = get(edges); + getIntersectingNodes: ( + nodeOrRect: (Partial & { id: Node['id'] }) | Rect, + partially = true, + nodesToIntersect?: Node[] + ) => { + const [nodeRect, node, isRect] = getNodeRect(nodeOrRect); + if (!nodeRect || !node) { + return []; + } + + return (nodesToIntersect || get(nodes)).filter((n) => { + if (!isRect && (n.id === node.id || !n.positionAbsolute)) { + return false; + } + + const currNodeRect = nodeToRect(n); + const overlappingArea = getOverlappingArea(currNodeRect, nodeRect); + const partiallyVisible = partially && overlappingArea > 0; + + return partiallyVisible || overlappingArea >= nodeOrRect.width! * nodeOrRect.height!; + }); + }, + isNodeIntersecting: ( + nodeOrRect: (Partial & { id: Node['id'] }) | Rect, + area: Rect, + partially = true + ) => { + const [nodeRect] = getNodeRect(nodeOrRect); + + if (!nodeRect) { + return false; + } + + const overlappingArea = getOverlappingArea(nodeRect, area); + const partiallyVisible = partially && overlappingArea > 0; + + return partiallyVisible || overlappingArea >= nodeOrRect.width! * nodeOrRect.height!; + }, + deleteElements: ( + nodesToRemove: Partial & { id: string }[] = [], + edgesToRemove: Partial & { id: string }[] = [] + ) => { + const _nodes = get(nodes); + const _edges = get(edges); + const { matchingNodes, matchingEdges } = getElementsToRemove({ + nodesToRemove, + edgesToRemove, + nodes: _nodes, + edges: _edges + }); + + if (matchingNodes) { + nodes.set(_nodes.filter((node) => !matchingNodes.some(({ id }) => id === node.id))); + } + + if (matchingEdges) { + edges.set(_edges.filter((edge) => !matchingEdges.some(({ id }) => id === edge.id))); + } + + return { + deletedNodes: matchingNodes, + deletedEdges: matchingEdges + }; + }, + screenToFlowCoordinate: (position: XYPosition) => { + const _domNode = get(domNode); + + if (_domNode) { + const _snapGrid = get(snapGrid); + const { x, y, zoom } = get(viewport); + const { x: domX, y: domY } = _domNode.getBoundingClientRect(); + + const correctedPosition = { + x: position.x - domX, + y: position.y - domY + }; + + return pointToRendererPoint( + correctedPosition, + [x, y, zoom], + _snapGrid !== null, + _snapGrid || [1, 1] + ); + } + + return { x: 0, y: 0 }; + }, + flowToScreenCoordinate: (position: XYPosition) => { + const _domNode = get(domNode); + + if (_domNode) { + const { x, y, zoom } = get(viewport); + const { x: domX, y: domY } = _domNode.getBoundingClientRect(); + + const rendererPosition = rendererPointToPoint(position, [x, y, zoom]); + + return { + x: rendererPosition.x + domX, + y: rendererPosition.y + domY + }; + } + + return { x: 0, y: 0 }; + }, + getConnectedEdges: (node) => { const nodeIds = new Set(); + if (typeof node === 'string') { nodeIds.add(node); } else if (node.length >= 1) { @@ -103,28 +251,18 @@ export function useSvelteFlow(): { }); } - return _edges.filter((edge) => nodeIds.has(edge.source) || nodeIds.has(edge.target)); + return get(edges).filter((edge) => nodeIds.has(edge.source) || nodeIds.has(edge.target)); }, getIncomers: (node) => { - const _edges = get(edges); - const _nodes = get(nodes); + const _node = typeof node === 'string' ? { id: node } : node; - if (typeof node === 'string') { - return getIncomersBase({ id: node }, _nodes, _edges); - } - - return getIncomersBase(node, _nodes, _edges); + return getIncomersBase(_node, get(nodes), get(edges)); }, getOutgoers: (node) => { - const _edges = get(edges); - const _nodes = get(nodes); + const _node = typeof node === 'string' ? { id: node } : node; - if (typeof node == 'string') { - return getOutgoersBase({ id: node }, _nodes, _edges); - } - - return getOutgoersBase(node, _nodes, _edges); + return getOutgoersBase(_node, get(nodes), get(edges)); }, - viewport: viewport + viewport }; } diff --git a/packages/svelte/src/lib/index.ts b/packages/svelte/src/lib/index.ts index f1af3791..74a0ec7f 100644 --- a/packages/svelte/src/lib/index.ts +++ b/packages/svelte/src/lib/index.ts @@ -19,9 +19,12 @@ export { useStore } from '$lib/store'; // utils export * from '$lib/utils'; + +//hooks export * from '$lib/hooks/useSvelteFlow'; export * from '$lib/hooks/useUpdateNodeInternals'; export * from '$lib/hooks/useConnection'; +export * from '$lib/hooks/useNodesEdges'; // types export type { Edge, EdgeProps, EdgeTypes, DefaultEdgeOptions } from '$lib/types/edges'; diff --git a/packages/svelte/src/styles/style.css b/packages/svelte/src/styles/style.css index b3f57e6f..8d45911a 100644 --- a/packages/svelte/src/styles/style.css +++ b/packages/svelte/src/styles/style.css @@ -5,4 +5,5 @@ .svelte-flow__edge-label { text-align: center; position: absolute; + font-size: 10px; }