From ae33cba887f5b71633b88e1e9b6b11d90ffa73d0 Mon Sep 17 00:00:00 2001 From: Moritz Klack Date: Thu, 28 Sep 2023 16:19:03 +0200 Subject: [PATCH] feat(react/svelte): controlled viewport (#3461) * feat(react): controlled viewport * refactor(react): cleanup viewport * feat(svelte): two way data binding for viewport, cleanup transform <> viewport stuff * refactor(svelte): viewport stuff --- examples/react/src/App/index.tsx | 6 ++ .../src/examples/ControlledViewport/index.tsx | 88 +++++++++++++++++++ .../src/components/Header/Header.svelte | 5 +- .../src/routes/two-way-viewport/+page.svelte | 9 ++ .../src/routes/two-way-viewport/Flow.svelte | 46 ++++++++++ .../src/container/FlowRenderer/index.tsx | 5 ++ .../react/src/container/GraphView/index.tsx | 8 +- .../react/src/container/ReactFlow/index.tsx | 4 + .../react/src/container/Viewport/index.tsx | 1 + .../react/src/container/ZoomPane/index.tsx | 14 ++- packages/react/src/hooks/useViewportSync.ts | 21 +++++ packages/react/src/types/component-props.ts | 2 + packages/svelte/src/lib/actions/drag/index.ts | 3 +- packages/svelte/src/lib/actions/zoom/index.ts | 14 +-- .../src/lib/components/Handle/Handle.svelte | 4 +- .../svelte/src/lib/container/Pane/Pane.svelte | 4 +- .../container/SvelteFlow/SvelteFlow.svelte | 2 + .../src/lib/container/SvelteFlow/types.ts | 1 + .../lib/container/Viewport/Viewport.svelte | 4 +- .../svelte/src/lib/container/Zoom/Zoom.svelte | 4 +- .../svelte/src/lib/hooks/useSvelteFlow.ts | 47 +++------- .../lib/plugins/Background/Background.svelte | 10 +-- .../src/lib/plugins/Controls/Controls.svelte | 6 +- .../src/lib/plugins/Minimap/Minimap.svelte | 12 +-- .../src/lib/plugins/Minimap/interactive.ts | 9 +- .../src/lib/store/derived-connection-props.ts | 8 +- packages/svelte/src/lib/store/edge-tree.ts | 6 +- packages/svelte/src/lib/store/index.ts | 10 +-- .../svelte/src/lib/store/initial-store.ts | 6 +- packages/svelte/src/lib/store/types.ts | 4 +- packages/svelte/src/lib/store/utils.ts | 50 ++++++++++- .../svelte/src/lib/store/visible-nodes.ts | 7 +- packages/system/src/types/panzoom.ts | 5 +- packages/system/src/xypanzoom/XYPanZoom.ts | 17 ++++ packages/system/src/xypanzoom/eventhandler.ts | 4 +- 35 files changed, 351 insertions(+), 95 deletions(-) create mode 100644 examples/react/src/examples/ControlledViewport/index.tsx create mode 100644 examples/svelte/src/routes/two-way-viewport/+page.svelte create mode 100644 examples/svelte/src/routes/two-way-viewport/Flow.svelte create mode 100644 packages/react/src/hooks/useViewportSync.ts diff --git a/examples/react/src/App/index.tsx b/examples/react/src/App/index.tsx index ce5b4899..b6bc5be7 100644 --- a/examples/react/src/App/index.tsx +++ b/examples/react/src/App/index.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Route, Routes, useLocation, useNavigate } from 'react-ro import Basic from '../examples/Basic'; import Backgrounds from '../examples/Backgrounds'; import ControlledUncontrolled from '../examples/ControlledUncontrolled'; +import ControlledViewport from '../examples/ControlledViewport'; import CustomConnectionLine from '../examples/CustomConnectionLine'; import CustomMiniMapNode from '../examples/CustomMiniMapNode'; import CustomNode from '../examples/CustomNode'; @@ -74,6 +75,11 @@ const routes: IRoute[] = [ path: '/controlled-uncontrolled', component: ControlledUncontrolled, }, + { + name: 'Controlled Viewport', + path: '/controlled-viewport', + component: ControlledViewport, + }, { name: 'Custom Connection Line', path: '/custom-connectionline', diff --git a/examples/react/src/examples/ControlledViewport/index.tsx b/examples/react/src/examples/ControlledViewport/index.tsx new file mode 100644 index 00000000..ebf4d029 --- /dev/null +++ b/examples/react/src/examples/ControlledViewport/index.tsx @@ -0,0 +1,88 @@ +import { MouseEvent, useCallback, useState } from 'react'; +import { + ReactFlow, + addEdge, + Node, + Connection, + Edge, + useNodesState, + useEdgesState, + Viewport, + Panel, + MiniMap, + Background, + ReactFlowProvider, + useReactFlow, + Controls, +} from '@xyflow/react'; + +const initNodes: Node[] = [ + { + id: '1a', + type: 'input', + data: { label: 'Node 1' }, + position: { x: 250, y: 5 }, + className: 'light', + ariaLabel: 'Input Node 1', + }, + { + id: '2a', + data: { label: 'Node 2' }, + position: { x: 100, y: 100 }, + className: 'light', + ariaLabel: 'Default Node 2', + }, + { + id: '3a', + data: { label: 'Node 3' }, + position: { x: 400, y: 100 }, + className: 'light', + }, + { + id: '4a', + data: { label: 'Node 4' }, + position: { x: 400, y: 200 }, + className: 'light', + }, +]; + +const initEdges: Edge[] = [ + { id: 'e1-2', source: '1a', target: '2a', ariaLabel: undefined }, + { id: 'e1-3', source: '1a', target: '3a' }, +]; + +const Flow = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 }); + const { fitView } = useReactFlow(); + + const onConnect = useCallback((params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)), [setEdges]); + + return ( + + + + + + + + + + + ); +}; + +export default () => ( + + + +); diff --git a/examples/svelte/src/components/Header/Header.svelte b/examples/svelte/src/components/Header/Header.svelte index 3d0c9ff5..6755fc2d 100644 --- a/examples/svelte/src/components/Header/Header.svelte +++ b/examples/svelte/src/components/Header/Header.svelte @@ -3,6 +3,7 @@ import { page } from '$app/stores'; const routes = [ + 'custom-connection-line', 'customnode', 'drag-n-drop', 'edges', @@ -11,10 +12,10 @@ 'overview', 'stress', 'subflows', + 'two-way-viewport', 'usesvelteflow', 'useupdatenodeinternals', - 'validation', - 'custom-connection-line' + 'validation' ]; const onChange = (event: Event) => { diff --git a/examples/svelte/src/routes/two-way-viewport/+page.svelte b/examples/svelte/src/routes/two-way-viewport/+page.svelte new file mode 100644 index 00000000..12dec33c --- /dev/null +++ b/examples/svelte/src/routes/two-way-viewport/+page.svelte @@ -0,0 +1,9 @@ + + + + + diff --git a/examples/svelte/src/routes/two-way-viewport/Flow.svelte b/examples/svelte/src/routes/two-way-viewport/Flow.svelte new file mode 100644 index 00000000..7aa5a9b4 --- /dev/null +++ b/examples/svelte/src/routes/two-way-viewport/Flow.svelte @@ -0,0 +1,46 @@ + + + + + + + + + + + diff --git a/packages/react/src/container/FlowRenderer/index.tsx b/packages/react/src/container/FlowRenderer/index.tsx index 1267eba9..442cc2bf 100644 --- a/packages/react/src/container/FlowRenderer/index.tsx +++ b/packages/react/src/container/FlowRenderer/index.tsx @@ -24,6 +24,7 @@ export type FlowRendererProps = Omit< | 'rfId' | 'nodeOrigin' > & { + isControlledViewport: boolean; children: ReactNode; }; @@ -63,6 +64,8 @@ const FlowRenderer = ({ noWheelClassName, noPanClassName, disableKeyboardA11y, + onViewportChange, + isControlledViewport, }: FlowRendererProps) => { const nodesSelectionActive = useStore(selector); const selectionKeyPressed = useKeyPress(selectionKeyCode); @@ -92,6 +95,8 @@ const FlowRenderer = ({ preventScrolling={preventScrolling} noWheelClassName={noWheelClassName} noPanClassName={noPanClassName} + onViewportChange={onViewportChange} + isControlledViewport={isControlledViewport} > { const nodeTypesWrapped = useNodeOrEdgeTypes(nodeTypes, createNodeTypes); const edgeTypesWrapped = useNodeOrEdgeTypes(edgeTypes, createEdgeTypes); useOnInitHandler(onInit); + useViewportSync(viewport); return ( ( style, id, nodeDragThreshold, + viewport, + onViewportChange, ...rest }, ref @@ -242,6 +244,8 @@ const ReactFlow = forwardRef( disableKeyboardA11y={disableKeyboardA11y} nodeOrigin={nodeOrigin} nodeExtent={nodeExtent} + viewport={viewport} + onViewportChange={onViewportChange} /> `translate(${s.transform[0]}px,${s.transform[1]}px) scale(${s.transform[2]})`; diff --git a/packages/react/src/container/ZoomPane/index.tsx b/packages/react/src/container/ZoomPane/index.tsx index e24056cf..aee6ac87 100644 --- a/packages/react/src/container/ZoomPane/index.tsx +++ b/packages/react/src/container/ZoomPane/index.tsx @@ -18,7 +18,9 @@ type ZoomPaneProps = Omit< | 'noDragClassName' | 'disableKeyboardA11y' | 'selectionOnDrag' ->; +> & { + isControlledViewport: boolean; +}; const selector = (s: ReactFlowState) => ({ userSelectionActive: s.userSelectionActive, @@ -43,6 +45,8 @@ const ZoomPane = ({ children, noWheelClassName, noPanClassName, + onViewportChange, + isControlledViewport, }: ZoomPaneProps) => { const store = useStoreApi(); const zoomPane = useRef(null); @@ -60,7 +64,13 @@ const ZoomPane = ({ maxZoom, translateExtent, viewport: defaultViewport, - onTransformChange: (transform: Transform) => store.setState({ transform }), + onTransformChange: (transform: Transform) => { + onViewportChange?.({ x: transform[0], y: transform[1], zoom: transform[2] }); + + if (!isControlledViewport) { + store.setState({ transform }); + } + }, onDraggingChange: (paneDragging: boolean) => store.setState({ paneDragging }), onPanZoomStart: (event, vp) => { const { onViewportChangeStart, onMoveStart } = store.getState(); diff --git a/packages/react/src/hooks/useViewportSync.ts b/packages/react/src/hooks/useViewportSync.ts new file mode 100644 index 00000000..a34cdcd5 --- /dev/null +++ b/packages/react/src/hooks/useViewportSync.ts @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import type { Viewport } from '@xyflow/system'; + +import { useStore, useStoreApi } from './useStore'; +import type { ReactFlowState } from '../types'; + +const selector = (state: ReactFlowState) => state.panZoom?.syncViewport; + +export default function useViewportSync(viewport?: Viewport) { + const syncViewport = useStore(selector); + const store = useStoreApi(); + + useEffect(() => { + if (viewport) { + syncViewport?.(viewport); + store.setState({ transform: [viewport.x, viewport.y, viewport.zoom] }); + } + }, [viewport, syncViewport]); + + return null; +} diff --git a/packages/react/src/types/component-props.ts b/packages/react/src/types/component-props.ts index b1078e7b..3fe97f07 100644 --- a/packages/react/src/types/component-props.ts +++ b/packages/react/src/types/component-props.ts @@ -120,7 +120,9 @@ export type ReactFlowProps = HTMLAttributes & { panOnDrag?: boolean | number[]; minZoom?: number; maxZoom?: number; + viewport?: Viewport; defaultViewport?: Viewport; + onViewportChange?: (viewport: Viewport) => void; translateExtent?: CoordinateExtent; preventScrolling?: boolean; nodeExtent?: CoordinateExtent; diff --git a/packages/svelte/src/lib/actions/drag/index.ts b/packages/svelte/src/lib/actions/drag/index.ts index 65f7a1e7..bf93b041 100644 --- a/packages/svelte/src/lib/actions/drag/index.ts +++ b/packages/svelte/src/lib/actions/drag/index.ts @@ -24,6 +24,7 @@ export default function drag(domNode: Element, params: UseDragParams) { onDragStop, getStoreItems: () => { const snapGrid = get(store.snapGrid); + const vp = get(store.viewport); return { nodes: get(store.nodes), @@ -34,7 +35,7 @@ export default function drag(domNode: Element, params: UseDragParams) { nodeOrigin: [0, 0], multiSelectionActive: false, domNode: get(store.domNode), - transform: get(store.transform), + transform: [vp.x, vp.y, vp.zoom], autoPanOnNodeDrag: get(store.autoPanOnNodeDrag), nodesDraggable: get(store.nodesDraggable), selectNodesOnDrag: get(store.selectNodesOnDrag), diff --git a/packages/svelte/src/lib/actions/zoom/index.ts b/packages/svelte/src/lib/actions/zoom/index.ts index d85bc101..b96e012f 100644 --- a/packages/svelte/src/lib/actions/zoom/index.ts +++ b/packages/svelte/src/lib/actions/zoom/index.ts @@ -5,15 +5,14 @@ import { type CoordinateExtent, type OnPanZoom, type PanZoomInstance, - type Transform, type Viewport } from '@xyflow/system'; type ZoomParams = { - transform: Writable; + viewport: Writable; + initialViewport: Viewport; minZoom: number; maxZoom: number; - initialViewport: Viewport; dragging: Writable; onPanZoomStart?: OnPanZoom; onPanZoom?: OnPanZoom; @@ -39,7 +38,7 @@ type ZoomParams = { }; export default function zoom(domNode: Element, params: ZoomParams) { - const { panZoom, minZoom, maxZoom, initialViewport, transform, dragging, translateExtent } = + const { panZoom, minZoom, maxZoom, initialViewport, viewport, dragging, translateExtent } = params; const panZoomInstance = XYPanZoom({ @@ -48,11 +47,12 @@ export default function zoom(domNode: Element, params: ZoomParams) { maxZoom, translateExtent, viewport: initialViewport, - onTransformChange: transform.set, + onTransformChange: (transform) => + viewport.set({ x: transform[0], y: transform[1], zoom: transform[2] }), onDraggingChange: dragging.set }); - const { x, y, zoom } = panZoomInstance.getViewport(); - transform.set([x, y, zoom]); + const currentViewport = panZoomInstance.getViewport(); + viewport.set(currentViewport); panZoom.set(panZoomInstance); panZoomInstance.update(params); diff --git a/packages/svelte/src/lib/components/Handle/Handle.svelte b/packages/svelte/src/lib/components/Handle/Handle.svelte index 0108357b..58d5ad02 100644 --- a/packages/svelte/src/lib/components/Handle/Handle.svelte +++ b/packages/svelte/src/lib/components/Handle/Handle.svelte @@ -34,7 +34,7 @@ domNode, nodes, connectionRadius, - transform, + viewport, isValidConnection, lib, addEdge, @@ -80,7 +80,7 @@ onConnectEnd: (event) => { dispatch('connectend', { event }); }, - getTransform: () => $transform + getTransform: () => [$viewport.x, $viewport.y, $viewport.zoom] }); } } diff --git a/packages/svelte/src/lib/container/Pane/Pane.svelte b/packages/svelte/src/lib/container/Pane/Pane.svelte index 1305ccdc..6727fc44 100644 --- a/packages/svelte/src/lib/container/Pane/Pane.svelte +++ b/packages/svelte/src/lib/container/Pane/Pane.svelte @@ -45,7 +45,7 @@ const { nodes, edges, - transform, + viewport, dragging, elementsSelectable, selectionRect, @@ -120,7 +120,7 @@ selectedNodes = getNodesInside( $nodes, nextUserSelectRect, - $transform, + [$viewport.x, $viewport.y, $viewport.zoom], $selectionMode === SelectionMode.Partial, true ); diff --git a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte index aaf51063..ef5d547b 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte +++ b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte @@ -27,6 +27,7 @@ export let minZoom: $$Props['minZoom'] = undefined; export let maxZoom: $$Props['maxZoom'] = undefined; export let initialViewport: Viewport = { x: 0, y: 0, zoom: 1 }; + export let viewport: $$Props['viewport'] = undefined; export let nodeTypes: $$Props['nodeTypes'] = undefined; export let edgeTypes: $$Props['edgeTypes'] = undefined; export let selectionKey: $$Props['selectionKey'] = undefined; @@ -81,6 +82,7 @@ store.syncNodeStores(nodes); store.syncEdgeStores(edges); + store.syncViewport(viewport); if (fitView !== undefined) { store.fitViewOnInit.set(fitView); diff --git a/packages/svelte/src/lib/container/SvelteFlow/types.ts b/packages/svelte/src/lib/container/SvelteFlow/types.ts index f0214d4a..91f3bb70 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/types.ts +++ b/packages/svelte/src/lib/container/SvelteFlow/types.ts @@ -47,6 +47,7 @@ export type SvelteFlowProps = DOMAttributes & { minZoom?: number; maxZoom?: number; initialViewport?: Viewport; + viewport?: Writable; connectionRadius?: number; connectionMode?: ConnectionMode; connectionLineStyle?: string; diff --git a/packages/svelte/src/lib/container/Viewport/Viewport.svelte b/packages/svelte/src/lib/container/Viewport/Viewport.svelte index 2d331ba1..a6578a87 100644 --- a/packages/svelte/src/lib/container/Viewport/Viewport.svelte +++ b/packages/svelte/src/lib/container/Viewport/Viewport.svelte @@ -1,12 +1,12 @@
diff --git a/packages/svelte/src/lib/container/Zoom/Zoom.svelte b/packages/svelte/src/lib/container/Zoom/Zoom.svelte index 1a4a7008..d86a8505 100644 --- a/packages/svelte/src/lib/container/Zoom/Zoom.svelte +++ b/packages/svelte/src/lib/container/Zoom/Zoom.svelte @@ -20,7 +20,7 @@ export let panOnScroll: $$Props['panOnScroll']; const { - transform, + viewport, panZoom, selectionKeyPressed, minZoom, @@ -38,7 +38,7 @@
- viewportWritable.set({ - x: ts[0], - y: ts[1], - zoom: ts[2] - }) - ); - return { zoomIn, zoomOut, setZoom: (zoomLevel, options) => { get(panZoom)?.scaleTo(zoomLevel, { duration: options?.duration }); }, - getZoom: () => get(transform)[2], - setViewport: (viewport, options) => { - const [x, y, zoom] = get(transform); + getZoom: () => get(viewport).zoom, + setViewport: (vieport, options) => { + const currentViewport = get(viewport); get(panZoom)?.setViewport( { - x: viewport.x ?? x, - y: viewport.y ?? y, - zoom: viewport.zoom ?? zoom + x: vieport.x ?? currentViewport.x, + y: vieport.y ?? currentViewport.y, + zoom: vieport.zoom ?? currentViewport.zoom }, { duration: options?.duration } ); }, - getViewport: () => { - const [x, y, zoom] = get(transform); - return { x, y, zoom }; - }, + getViewport: () => get(viewport), setCenter: (x, y, options) => { const _width = get(width); const _height = get(height); @@ -98,15 +80,12 @@ export function useSvelteFlow(): { fitView, project: (position: XYPosition) => { const _snapGrid = get(snapGrid); - return pointToRendererPoint( - position, - get(transform), - _snapGrid !== null, - _snapGrid || [1, 1] - ); + const { x, y, zoom } = get(viewport); + + return pointToRendererPoint(position, [x, y, zoom], _snapGrid !== null, _snapGrid || [1, 1]); }, nodes, edges, - viewport: viewportWritable + viewport: viewport }; } diff --git a/packages/svelte/src/lib/plugins/Background/Background.svelte b/packages/svelte/src/lib/plugins/Background/Background.svelte index 5086c210..7e6a9bff 100644 --- a/packages/svelte/src/lib/plugins/Background/Background.svelte +++ b/packages/svelte/src/lib/plugins/Background/Background.svelte @@ -26,15 +26,15 @@ let className: $$Props['class'] = ''; export { className as class }; - const { transform, flowId } = useStore(); + const { viewport, flowId } = useStore(); const patternSize = size || defaultSize[variant!]; const isDots = variant === BackgroundVariant.Dots; const isCross = variant === BackgroundVariant.Cross; const gapXY: number[] = Array.isArray(gap!) ? gap! : [gap!, gap!]; $: patternId = `background-pattern-${$flowId}`; - $: scaledGap = [gapXY[0] * $transform[2] || 1, gapXY[1] * $transform[2] || 1]; - $: scaledSize = patternSize * $transform[2]; + $: scaledGap = [gapXY[0] * $viewport.zoom || 1, gapXY[1] * $viewport.zoom || 1]; + $: scaledSize = patternSize * $viewport.zoom; $: patternDimensions = (isCross ? [scaledSize, scaledSize] : scaledGap) as [number, number]; $: patternOffset = isDots ? [scaledSize / 2, scaledSize / 2] @@ -49,8 +49,8 @@ > = $maxZoom; + $: minZoomReached = $viewport.zoom <= $minZoom; + $: maxZoomReached = $viewport.zoom >= $maxZoom; const onZoomInHandler = () => { zoomIn(); diff --git a/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte b/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte index 07ffde00..79b5e1ad 100644 --- a/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte +++ b/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte @@ -43,7 +43,7 @@ const defaultHeight = 150; const { nodes, - transform, + viewport, width: containerWidth, height: containerHeight, flowId, @@ -59,10 +59,10 @@ const labelledBy = `svelte-flow__minimap-desc-${$flowId}`; $: viewBB = { - x: -$transform[0] / $transform[2], - y: -$transform[1] / $transform[2], - width: $containerWidth / $transform[2], - height: $containerHeight / $transform[2] + x: -$viewport.x / $viewport.zoom, + y: -$viewport.y / $viewport.zoom, + width: $containerWidth / $viewport.zoom, + height: $containerHeight / $viewport.zoom }; $: boundingRect = $nodes.length > 0 ? getBoundsOfRects(getRectOfNodes($nodes), viewBB) : viewBB; $: elementWidth = width ?? defaultWidth; @@ -100,7 +100,7 @@ style:--minimap-mask-stroke-width-props={maskStrokeWidth} use:interactive={{ panZoom: $panZoom, - transform, + viewport, getViewScale, translateExtent: $translateExtent, width: $containerWidth, diff --git a/packages/svelte/src/lib/plugins/Minimap/interactive.ts b/packages/svelte/src/lib/plugins/Minimap/interactive.ts index 9bdbea13..064f0d27 100644 --- a/packages/svelte/src/lib/plugins/Minimap/interactive.ts +++ b/packages/svelte/src/lib/plugins/Minimap/interactive.ts @@ -2,13 +2,13 @@ import { get, type Writable } from 'svelte/store'; import { XYMinimap, type PanZoomInstance, - type Transform, + type Viewport, type XYMinimapUpdate } from '@xyflow/system'; export type UseInteractiveParams = { panZoom: PanZoomInstance; - transform: Writable; + viewport: Writable; getViewScale: () => number; } & XYMinimapUpdate; @@ -16,7 +16,10 @@ export default function interactive(domNode: Element, params: UseInteractivePara const minimap = XYMinimap({ domNode, panZoom: params.panZoom, - getTransform: () => get(params.transform), + getTransform: () => { + const viewport = get(params.viewport); + return [viewport.x, viewport.y, viewport.zoom]; + }, getViewScale: params.getViewScale }); diff --git a/packages/svelte/src/lib/store/derived-connection-props.ts b/packages/svelte/src/lib/store/derived-connection-props.ts index f9586885..5be1598c 100644 --- a/packages/svelte/src/lib/store/derived-connection-props.ts +++ b/packages/svelte/src/lib/store/derived-connection-props.ts @@ -57,9 +57,9 @@ export function getDerivedConnectionProps( store.connectionLineType, store.connectionMode, store.nodes, - store.transform + store.viewport ], - ([connection, connectionLineType, connectionMode, nodes, transform]) => { + ([connection, connectionLineType, connectionMode, nodes, viewport]) => { if (!connection.connectionStartHandle?.nodeId) { return initConnectionProps; } @@ -91,8 +91,8 @@ export function getDerivedConnectionProps( sourceX: fromX, sourceY: fromY, sourcePosition: fromPosition, - targetX: ((connection.connectionPosition?.x ?? 0) - transform[0]) / transform[2], - targetY: ((connection.connectionPosition?.y ?? 0) - transform[1]) / transform[2], + targetX: ((connection.connectionPosition?.x ?? 0) - viewport.x) / viewport.zoom, + targetY: ((connection.connectionPosition?.y ?? 0) - viewport.y) / viewport.zoom, targetPosition: toPosition }; diff --git a/packages/svelte/src/lib/store/edge-tree.ts b/packages/svelte/src/lib/store/edge-tree.ts index f316fee5..ff099868 100644 --- a/packages/svelte/src/lib/store/edge-tree.ts +++ b/packages/svelte/src/lib/store/edge-tree.ts @@ -10,11 +10,11 @@ export function getEdgeTree(store: SvelteFlowStoreState) { store.edges, store.nodes, store.onlyRenderVisibleElements, - store.transform, + store.viewport, store.width, store.height ], - ([edges, nodes, onlyRenderVisibleElements, transform, width, height]) => { + ([edges, nodes, onlyRenderVisibleElements, viewport, width, height]) => { const visibleEdges = onlyRenderVisibleElements && width && height ? edges.filter((edge) => { @@ -29,7 +29,7 @@ export function getEdgeTree(store: SvelteFlowStoreState) { targetNode, width, height, - transform + transform: [viewport.x, viewport.y, viewport.zoom] }) ); }) diff --git a/packages/svelte/src/lib/store/index.ts b/packages/svelte/src/lib/store/index.ts index 4d411746..22d49ac3 100644 --- a/packages/svelte/src/lib/store/index.ts +++ b/packages/svelte/src/lib/store/index.ts @@ -22,7 +22,7 @@ import { addEdge as addEdgeUtil } from '$lib/utils'; import type { EdgeTypes, NodeTypes, Node, Edge, FitViewOptions, ConnectionData } from '$lib/types'; import { initialEdgeTypes, initialNodeTypes, getInitialStore } from './initial-store'; import type { SvelteFlowStore } from './types'; -import { syncNodeStores, syncEdgeStores } from './utils'; +import { syncNodeStores, syncEdgeStores, syncViewportStores } from './utils'; import { getEdgeTree } from './edge-tree'; import { getVisibleNodes } from './visible-nodes'; import { getDerivedConnectionProps } from './derived-connection-props'; @@ -241,10 +241,11 @@ export function createStore(): SvelteFlowStore { } function panBy(delta: XYPosition) { + const viewport = get(store.viewport); return panBySystem({ delta, panZoom: get(store.panZoom), - transform: get(store.transform), + transform: [viewport.x, viewport.y, viewport.zoom], translateExtent: get(store.translateExtent), width: get(store.width), height: get(store.height) @@ -298,6 +299,7 @@ export function createStore(): SvelteFlowStore { // actions syncNodeStores: (nodes) => syncNodeStores(store.nodes, nodes), syncEdgeStores: (edges) => syncEdgeStores(store.edges, edges), + syncViewport: (viewport) => syncViewportStores(store.panZoom, store.viewport, viewport), setNodeTypes, setEdgeTypes, addEdge, @@ -305,9 +307,7 @@ export function createStore(): SvelteFlowStore { updateNodeDimensions, zoomIn, zoomOut, - fitView: (options?: FitViewOptions) => { - return fitView(get(store.nodes), options); - }, + fitView: (options?: FitViewOptions) => fitView(get(store.nodes), options), setMinZoom, setMaxZoom, setTranslateExtent, diff --git a/packages/svelte/src/lib/store/initial-store.ts b/packages/svelte/src/lib/store/initial-store.ts index a6bee57b..c9deb622 100644 --- a/packages/svelte/src/lib/store/initial-store.ts +++ b/packages/svelte/src/lib/store/initial-store.ts @@ -5,7 +5,6 @@ import { ConnectionMode, ConnectionLineType, type SelectionRect, - type Transform, type SnapGrid, type MarkerProps, type PanZoomInstance, @@ -14,7 +13,8 @@ import { type GroupedEdges, type NodeOrigin, type OnError, - devWarn + devWarn, + type Viewport } from '@xyflow/system'; import DefaultNode from '$lib/components/nodes/DefaultNode.svelte'; @@ -72,7 +72,7 @@ export const getInitialStore = () => ({ selectionMode: writable(SelectionMode.Partial), nodeTypes: writable(initialNodeTypes), edgeTypes: writable(initialEdgeTypes), - transform: writable([0, 0, 1]), + viewport: writable({ x: 0, y: 0, zoom: 1 }), connectionMode: writable(ConnectionMode.Strict), domNode: writable(null), connection: readable(initConnectionProps), diff --git a/packages/svelte/src/lib/store/types.ts b/packages/svelte/src/lib/store/types.ts index 9156f6f0..49213f7a 100644 --- a/packages/svelte/src/lib/store/types.ts +++ b/packages/svelte/src/lib/store/types.ts @@ -6,7 +6,8 @@ import type { Connection, UpdateNodePositions, CoordinateExtent, - UpdateConnection + UpdateConnection, + Viewport } from '@xyflow/system'; import type { getInitialStore } from './initial-store'; @@ -15,6 +16,7 @@ import type { Node, Edge, NodeTypes, EdgeTypes, FitViewOptions } from '$lib/type export type SvelteFlowStoreActions = { syncNodeStores: (nodesStore: Writable) => void; syncEdgeStores: (edgeStore: Writable) => void; + syncViewport: (viewportStore?: Writable) => void; setNodeTypes: (nodeTypes: NodeTypes) => void; setEdgeTypes: (edgeTypes: EdgeTypes) => void; addEdge: (edge: Edge | Connection) => void; diff --git a/packages/svelte/src/lib/store/utils.ts b/packages/svelte/src/lib/store/utils.ts index 949e2272..adf64c0f 100644 --- a/packages/svelte/src/lib/store/utils.ts +++ b/packages/svelte/src/lib/store/utils.ts @@ -6,7 +6,7 @@ import { type Writable, get } from 'svelte/store'; -import { updateNodes } from '@xyflow/system'; +import { updateNodes, type Transform, type Viewport, type PanZoomInstance } from '@xyflow/system'; import type { DefaultEdgeOptions, DefaultNodeOptions, Edge, Node } from '$lib/types'; @@ -56,6 +56,54 @@ export function syncEdgeStores( edgesStore.update = userEdgesStore.update = (fn: (nds: Edge[]) => Edge[]) => _set(fn(val)); } +// it is possible to pass a viewport store to SvelteFlow for having more control +// if that's the case we need to sync the internal viewport with the user viewport +export const syncViewportStores = ( + panZoomStore: Writable, + viewportStore: Writable, + userViewportStore?: Writable +) => { + if (!userViewportStore) { + return; + } + + const panZoom = get(panZoomStore); + + const viewportStoreSetter = viewportStore.set; + const userViewportStoreSetter = userViewportStore.set; + + let val = userViewportStore ? get(userViewportStore) : { x: 0, y: 0, zoom: 1 }; + viewportStore.set(val); + + viewportStore.set = (vp: Viewport) => { + viewportStoreSetter(vp); + userViewportStoreSetter(vp); + + val = vp; + + return vp; + }; + + userViewportStore.set = (vp: Viewport) => { + panZoom?.syncViewport(vp); + + viewportStoreSetter(vp); + userViewportStoreSetter(vp); + + val = vp; + + return vp; + }; + + viewportStore.update = (fn: (vp: Viewport) => Viewport) => { + viewportStore.set(fn(val)); + }; + + userViewportStore.update = (fn: (vp: Viewport) => Viewport) => { + userViewportStore.set(fn(val)); + }; +}; + export type NodeStoreOptions = { elevateNodesOnSelect?: boolean; }; diff --git a/packages/svelte/src/lib/store/visible-nodes.ts b/packages/svelte/src/lib/store/visible-nodes.ts index 5fff7fd6..73ed53a4 100644 --- a/packages/svelte/src/lib/store/visible-nodes.ts +++ b/packages/svelte/src/lib/store/visible-nodes.ts @@ -1,13 +1,14 @@ import { derived } from 'svelte/store'; -import { getNodesInside } from '@xyflow/system'; +import { getNodesInside, type Transform } from '@xyflow/system'; import type { Node } from '$lib/types'; import type { SvelteFlowStoreState } from './types'; export function getVisibleNodes(store: SvelteFlowStoreState) { return derived( - [store.nodes, store.onlyRenderVisibleElements, store.width, store.height, store.transform], - ([nodes, onlyRenderVisibleElements, width, height, transform]) => { + [store.nodes, store.onlyRenderVisibleElements, store.width, store.height, store.viewport], + ([nodes, onlyRenderVisibleElements, width, height, viewport]) => { + const transform: Transform = [viewport.x, viewport.y, viewport.zoom]; return onlyRenderVisibleElements ? getNodesInside(nodes, { x: 0, y: 0, width, height }, transform, true) : nodes; diff --git a/packages/system/src/types/panzoom.ts b/packages/system/src/types/panzoom.ts index 3280ef78..81654bdb 100644 --- a/packages/system/src/types/panzoom.ts +++ b/packages/system/src/types/panzoom.ts @@ -1,6 +1,6 @@ -import { ZoomTransform } from 'd3-zoom'; +import type { ZoomTransform } from 'd3-zoom'; -import { CoordinateExtent, PanOnScrollMode, Transform, Viewport } from './'; +import { PanOnScrollMode, type CoordinateExtent, type Transform, type Viewport } from './'; export type OnDraggingChange = (dragging: boolean) => void; export type OnTransformChange = (transform: Transform) => void; @@ -55,4 +55,5 @@ export type PanZoomInstance = { setTranslateExtent: (translateExtent: CoordinateExtent) => void; scaleTo: (scale: number, options?: PanZoomTransformOptions) => void; scaleBy: (factor: number, options?: PanZoomTransformOptions) => void; + syncViewport: (viewport: Viewport) => void; }; diff --git a/packages/system/src/xypanzoom/XYPanZoom.ts b/packages/system/src/xypanzoom/XYPanZoom.ts index f5a3e5fe..372e6134 100644 --- a/packages/system/src/xypanzoom/XYPanZoom.ts +++ b/packages/system/src/xypanzoom/XYPanZoom.ts @@ -194,6 +194,22 @@ export function XYPanZoom({ return nextTransform; } + function syncViewport(viewport: Viewport) { + if (d3Selection) { + const nextTransform = viewportToTransform(viewport); + const currentTransform = d3Selection.property('__zoom'); + + if ( + currentTransform.k !== viewport.zoom || + currentTransform.x !== viewport.x || + currentTransform.y !== viewport.y + ) { + // @ts-ignore + d3ZoomInstance?.transform(d3Selection, nextTransform, null, { sync: true }); + } + } + } + function getViewport(): Viewport { const transform = d3Selection ? zoomTransform(d3Selection.node() as Element) : { x: 0, y: 0, k: 1 }; return { x: transform.x, y: transform.y, zoom: transform.k }; @@ -229,5 +245,6 @@ export function XYPanZoom({ scaleBy, setScaleExtent, setTranslateExtent, + syncViewport, }; } diff --git a/packages/system/src/xypanzoom/eventhandler.ts b/packages/system/src/xypanzoom/eventhandler.ts index 456e2332..0c2c1d5f 100644 --- a/packages/system/src/xypanzoom/eventhandler.ts +++ b/packages/system/src/xypanzoom/eventhandler.ts @@ -183,7 +183,9 @@ export function createPanZoomHandler({ onPaneContextMenu && isRightClickPan(panOnDrag, zoomPanValues.mouseButton ?? 0) ); - onTransformChange([event.transform.x, event.transform.y, event.transform.k]); + if (!event.sourceEvent?.sync) { + onTransformChange([event.transform.x, event.transform.y, event.transform.k]); + } if (onPanZoom && !event.sourceEvent?.internal) { onPanZoom?.(event.sourceEvent as MouseEvent | TouchEvent, transformToViewport(event.transform));