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
This commit is contained in:
Moritz Klack
2023-09-28 16:19:03 +02:00
committed by GitHub
parent 2215b68438
commit ae33cba887
35 changed files with 351 additions and 95 deletions
+6
View File
@@ -4,6 +4,7 @@ import { BrowserRouter, Route, Routes, useLocation, useNavigate } from 'react-ro
import Basic from '../examples/Basic'; import Basic from '../examples/Basic';
import Backgrounds from '../examples/Backgrounds'; import Backgrounds from '../examples/Backgrounds';
import ControlledUncontrolled from '../examples/ControlledUncontrolled'; import ControlledUncontrolled from '../examples/ControlledUncontrolled';
import ControlledViewport from '../examples/ControlledViewport';
import CustomConnectionLine from '../examples/CustomConnectionLine'; import CustomConnectionLine from '../examples/CustomConnectionLine';
import CustomMiniMapNode from '../examples/CustomMiniMapNode'; import CustomMiniMapNode from '../examples/CustomMiniMapNode';
import CustomNode from '../examples/CustomNode'; import CustomNode from '../examples/CustomNode';
@@ -74,6 +75,11 @@ const routes: IRoute[] = [
path: '/controlled-uncontrolled', path: '/controlled-uncontrolled',
component: ControlledUncontrolled, component: ControlledUncontrolled,
}, },
{
name: 'Controlled Viewport',
path: '/controlled-viewport',
component: ControlledViewport,
},
{ {
name: 'Custom Connection Line', name: 'Custom Connection Line',
path: '/custom-connectionline', path: '/custom-connectionline',
@@ -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<Viewport>({ x: 0, y: 0, zoom: 1 });
const { fitView } = useReactFlow();
const onConnect = useCallback((params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)), [setEdges]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
viewport={viewport}
onViewportChange={setViewport}
>
<Panel position="top-left">
<button onClick={() => setViewport((vp) => ({ ...vp, y: vp.y + 10 }))}>update viewport</button>
<button onClick={() => fitView()}>fitView</button>
</Panel>
<MiniMap />
<Background />
<Controls />
</ReactFlow>
);
};
export default () => (
<ReactFlowProvider>
<Flow />
</ReactFlowProvider>
);
@@ -3,6 +3,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
const routes = [ const routes = [
'custom-connection-line',
'customnode', 'customnode',
'drag-n-drop', 'drag-n-drop',
'edges', 'edges',
@@ -11,10 +12,10 @@
'overview', 'overview',
'stress', 'stress',
'subflows', 'subflows',
'two-way-viewport',
'usesvelteflow', 'usesvelteflow',
'useupdatenodeinternals', 'useupdatenodeinternals',
'validation', 'validation'
'custom-connection-line'
]; ];
const onChange = (event: Event) => { const onChange = (event: Event) => {
@@ -0,0 +1,9 @@
<script lang="ts">
import { SvelteFlowProvider } from '@xyflow/svelte';
import Flow from './Flow.svelte';
</script>
<SvelteFlowProvider>
<Flow />
</SvelteFlowProvider>
@@ -0,0 +1,46 @@
<script lang="ts">
import { writable } from 'svelte/store';
import {
SvelteFlow,
Controls,
Background,
BackgroundVariant,
Panel,
useSvelteFlow,
type Viewport
} from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
const nodes = writable([
{
id: 'A',
position: { x: 0, y: 0 },
data: { label: 'A' }
},
{ id: 'B', position: { x: 0, y: 100 }, data: { label: 'B' } }
]);
const edges = writable([{ id: 'ab', source: 'A', target: 'B' }]);
const viewport = writable<Viewport>({ x: 0, y: 10, zoom: 1.25 });
const { fitView } = useSvelteFlow();
const updateViewport = () => {
$viewport.x += 10;
$viewport = $viewport;
};
viewport.subscribe((vp) => {
console.log('viewport update', vp);
});
</script>
<SvelteFlow {nodes} {edges} fitView {viewport}>
<Controls />
<Background variant={BackgroundVariant.Dots} />
<Panel>
<button on:click={updateViewport}>update viewport</button>
<button on:click={() => fitView()}>fitView</button>
</Panel>
</SvelteFlow>
@@ -24,6 +24,7 @@ export type FlowRendererProps = Omit<
| 'rfId' | 'rfId'
| 'nodeOrigin' | 'nodeOrigin'
> & { > & {
isControlledViewport: boolean;
children: ReactNode; children: ReactNode;
}; };
@@ -63,6 +64,8 @@ const FlowRenderer = ({
noWheelClassName, noWheelClassName,
noPanClassName, noPanClassName,
disableKeyboardA11y, disableKeyboardA11y,
onViewportChange,
isControlledViewport,
}: FlowRendererProps) => { }: FlowRendererProps) => {
const nodesSelectionActive = useStore(selector); const nodesSelectionActive = useStore(selector);
const selectionKeyPressed = useKeyPress(selectionKeyCode); const selectionKeyPressed = useKeyPress(selectionKeyCode);
@@ -92,6 +95,8 @@ const FlowRenderer = ({
preventScrolling={preventScrolling} preventScrolling={preventScrolling}
noWheelClassName={noWheelClassName} noWheelClassName={noWheelClassName}
noPanClassName={noPanClassName} noPanClassName={noPanClassName}
onViewportChange={onViewportChange}
isControlledViewport={isControlledViewport}
> >
<Pane <Pane
onSelectionStart={onSelectionStart} onSelectionStart={onSelectionStart}
@@ -5,8 +5,9 @@ import NodeRenderer from '../NodeRenderer';
import EdgeRenderer from '../EdgeRenderer'; import EdgeRenderer from '../EdgeRenderer';
import ViewportWrapper from '../Viewport'; import ViewportWrapper from '../Viewport';
import useOnInitHandler from '../../hooks/useOnInitHandler'; import useOnInitHandler from '../../hooks/useOnInitHandler';
import useViewportSync from '../../hooks/useViewportSync';
import ConnectionLine from '../../components/ConnectionLine'; import ConnectionLine from '../../components/ConnectionLine';
import type { NodeTypesWrapped, ReactFlowProps } from '../../types'; import type { ReactFlowProps } from '../../types';
import { createNodeTypes } from '../NodeRenderer/utils'; import { createNodeTypes } from '../NodeRenderer/utils';
import { createEdgeTypes } from '../EdgeRenderer/utils'; import { createEdgeTypes } from '../EdgeRenderer/utils';
import { useNodeOrEdgeTypes } from './utils'; import { useNodeOrEdgeTypes } from './utils';
@@ -104,11 +105,14 @@ const GraphView = ({
nodeOrigin, nodeOrigin,
nodeExtent, nodeExtent,
rfId, rfId,
viewport,
onViewportChange,
}: GraphViewProps) => { }: GraphViewProps) => {
const nodeTypesWrapped = useNodeOrEdgeTypes(nodeTypes, createNodeTypes); const nodeTypesWrapped = useNodeOrEdgeTypes(nodeTypes, createNodeTypes);
const edgeTypesWrapped = useNodeOrEdgeTypes(edgeTypes, createEdgeTypes); const edgeTypesWrapped = useNodeOrEdgeTypes(edgeTypes, createEdgeTypes);
useOnInitHandler(onInit); useOnInitHandler(onInit);
useViewportSync(viewport);
return ( return (
<FlowRenderer <FlowRenderer
@@ -145,6 +149,8 @@ const GraphView = ({
noWheelClassName={noWheelClassName} noWheelClassName={noWheelClassName}
noPanClassName={noPanClassName} noPanClassName={noPanClassName}
disableKeyboardA11y={disableKeyboardA11y} disableKeyboardA11y={disableKeyboardA11y}
onViewportChange={onViewportChange}
isControlledViewport={!!viewport}
> >
<ViewportWrapper> <ViewportWrapper>
<EdgeRenderer <EdgeRenderer
@@ -164,6 +164,8 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
style, style,
id, id,
nodeDragThreshold, nodeDragThreshold,
viewport,
onViewportChange,
...rest ...rest
}, },
ref ref
@@ -242,6 +244,8 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
disableKeyboardA11y={disableKeyboardA11y} disableKeyboardA11y={disableKeyboardA11y}
nodeOrigin={nodeOrigin} nodeOrigin={nodeOrigin}
nodeExtent={nodeExtent} nodeExtent={nodeExtent}
viewport={viewport}
onViewportChange={onViewportChange}
/> />
<StoreUpdater <StoreUpdater
nodes={nodes} nodes={nodes}
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { useStore } from '../../hooks/useStore'; import { useStore } from '../../hooks/useStore';
import type { ReactFlowState } from '../../types'; import type { ReactFlowState } from '../../types';
import { Viewport } from '@xyflow/system';
const selector = (s: ReactFlowState) => `translate(${s.transform[0]}px,${s.transform[1]}px) scale(${s.transform[2]})`; const selector = (s: ReactFlowState) => `translate(${s.transform[0]}px,${s.transform[1]}px) scale(${s.transform[2]})`;
@@ -18,7 +18,9 @@ type ZoomPaneProps = Omit<
| 'noDragClassName' | 'noDragClassName'
| 'disableKeyboardA11y' | 'disableKeyboardA11y'
| 'selectionOnDrag' | 'selectionOnDrag'
>; > & {
isControlledViewport: boolean;
};
const selector = (s: ReactFlowState) => ({ const selector = (s: ReactFlowState) => ({
userSelectionActive: s.userSelectionActive, userSelectionActive: s.userSelectionActive,
@@ -43,6 +45,8 @@ const ZoomPane = ({
children, children,
noWheelClassName, noWheelClassName,
noPanClassName, noPanClassName,
onViewportChange,
isControlledViewport,
}: ZoomPaneProps) => { }: ZoomPaneProps) => {
const store = useStoreApi(); const store = useStoreApi();
const zoomPane = useRef<HTMLDivElement>(null); const zoomPane = useRef<HTMLDivElement>(null);
@@ -60,7 +64,13 @@ const ZoomPane = ({
maxZoom, maxZoom,
translateExtent, translateExtent,
viewport: defaultViewport, 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 }), onDraggingChange: (paneDragging: boolean) => store.setState({ paneDragging }),
onPanZoomStart: (event, vp) => { onPanZoomStart: (event, vp) => {
const { onViewportChangeStart, onMoveStart } = store.getState(); const { onViewportChangeStart, onMoveStart } = store.getState();
@@ -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;
}
@@ -120,7 +120,9 @@ export type ReactFlowProps = HTMLAttributes<HTMLDivElement> & {
panOnDrag?: boolean | number[]; panOnDrag?: boolean | number[];
minZoom?: number; minZoom?: number;
maxZoom?: number; maxZoom?: number;
viewport?: Viewport;
defaultViewport?: Viewport; defaultViewport?: Viewport;
onViewportChange?: (viewport: Viewport) => void;
translateExtent?: CoordinateExtent; translateExtent?: CoordinateExtent;
preventScrolling?: boolean; preventScrolling?: boolean;
nodeExtent?: CoordinateExtent; nodeExtent?: CoordinateExtent;
@@ -24,6 +24,7 @@ export default function drag(domNode: Element, params: UseDragParams) {
onDragStop, onDragStop,
getStoreItems: () => { getStoreItems: () => {
const snapGrid = get(store.snapGrid); const snapGrid = get(store.snapGrid);
const vp = get(store.viewport);
return { return {
nodes: get(store.nodes), nodes: get(store.nodes),
@@ -34,7 +35,7 @@ export default function drag(domNode: Element, params: UseDragParams) {
nodeOrigin: [0, 0], nodeOrigin: [0, 0],
multiSelectionActive: false, multiSelectionActive: false,
domNode: get(store.domNode), domNode: get(store.domNode),
transform: get(store.transform), transform: [vp.x, vp.y, vp.zoom],
autoPanOnNodeDrag: get(store.autoPanOnNodeDrag), autoPanOnNodeDrag: get(store.autoPanOnNodeDrag),
nodesDraggable: get(store.nodesDraggable), nodesDraggable: get(store.nodesDraggable),
selectNodesOnDrag: get(store.selectNodesOnDrag), selectNodesOnDrag: get(store.selectNodesOnDrag),
@@ -5,15 +5,14 @@ import {
type CoordinateExtent, type CoordinateExtent,
type OnPanZoom, type OnPanZoom,
type PanZoomInstance, type PanZoomInstance,
type Transform,
type Viewport type Viewport
} from '@xyflow/system'; } from '@xyflow/system';
type ZoomParams = { type ZoomParams = {
transform: Writable<Transform>; viewport: Writable<Viewport>;
initialViewport: Viewport;
minZoom: number; minZoom: number;
maxZoom: number; maxZoom: number;
initialViewport: Viewport;
dragging: Writable<boolean>; dragging: Writable<boolean>;
onPanZoomStart?: OnPanZoom; onPanZoomStart?: OnPanZoom;
onPanZoom?: OnPanZoom; onPanZoom?: OnPanZoom;
@@ -39,7 +38,7 @@ type ZoomParams = {
}; };
export default function zoom(domNode: Element, params: 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; params;
const panZoomInstance = XYPanZoom({ const panZoomInstance = XYPanZoom({
@@ -48,11 +47,12 @@ export default function zoom(domNode: Element, params: ZoomParams) {
maxZoom, maxZoom,
translateExtent, translateExtent,
viewport: initialViewport, viewport: initialViewport,
onTransformChange: transform.set, onTransformChange: (transform) =>
viewport.set({ x: transform[0], y: transform[1], zoom: transform[2] }),
onDraggingChange: dragging.set onDraggingChange: dragging.set
}); });
const { x, y, zoom } = panZoomInstance.getViewport(); const currentViewport = panZoomInstance.getViewport();
transform.set([x, y, zoom]); viewport.set(currentViewport);
panZoom.set(panZoomInstance); panZoom.set(panZoomInstance);
panZoomInstance.update(params); panZoomInstance.update(params);
@@ -34,7 +34,7 @@
domNode, domNode,
nodes, nodes,
connectionRadius, connectionRadius,
transform, viewport,
isValidConnection, isValidConnection,
lib, lib,
addEdge, addEdge,
@@ -80,7 +80,7 @@
onConnectEnd: (event) => { onConnectEnd: (event) => {
dispatch('connectend', { event }); dispatch('connectend', { event });
}, },
getTransform: () => $transform getTransform: () => [$viewport.x, $viewport.y, $viewport.zoom]
}); });
} }
} }
@@ -45,7 +45,7 @@
const { const {
nodes, nodes,
edges, edges,
transform, viewport,
dragging, dragging,
elementsSelectable, elementsSelectable,
selectionRect, selectionRect,
@@ -120,7 +120,7 @@
selectedNodes = getNodesInside<Node>( selectedNodes = getNodesInside<Node>(
$nodes, $nodes,
nextUserSelectRect, nextUserSelectRect,
$transform, [$viewport.x, $viewport.y, $viewport.zoom],
$selectionMode === SelectionMode.Partial, $selectionMode === SelectionMode.Partial,
true true
); );
@@ -27,6 +27,7 @@
export let minZoom: $$Props['minZoom'] = undefined; export let minZoom: $$Props['minZoom'] = undefined;
export let maxZoom: $$Props['maxZoom'] = undefined; export let maxZoom: $$Props['maxZoom'] = undefined;
export let initialViewport: Viewport = { x: 0, y: 0, zoom: 1 }; export let initialViewport: Viewport = { x: 0, y: 0, zoom: 1 };
export let viewport: $$Props['viewport'] = undefined;
export let nodeTypes: $$Props['nodeTypes'] = undefined; export let nodeTypes: $$Props['nodeTypes'] = undefined;
export let edgeTypes: $$Props['edgeTypes'] = undefined; export let edgeTypes: $$Props['edgeTypes'] = undefined;
export let selectionKey: $$Props['selectionKey'] = undefined; export let selectionKey: $$Props['selectionKey'] = undefined;
@@ -81,6 +82,7 @@
store.syncNodeStores(nodes); store.syncNodeStores(nodes);
store.syncEdgeStores(edges); store.syncEdgeStores(edges);
store.syncViewport(viewport);
if (fitView !== undefined) { if (fitView !== undefined) {
store.fitViewOnInit.set(fitView); store.fitViewOnInit.set(fitView);
@@ -47,6 +47,7 @@ export type SvelteFlowProps = DOMAttributes<HTMLDivElement> & {
minZoom?: number; minZoom?: number;
maxZoom?: number; maxZoom?: number;
initialViewport?: Viewport; initialViewport?: Viewport;
viewport?: Writable<Viewport>;
connectionRadius?: number; connectionRadius?: number;
connectionMode?: ConnectionMode; connectionMode?: ConnectionMode;
connectionLineStyle?: string; connectionLineStyle?: string;
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { useStore } from '$lib/store'; import { useStore } from '$lib/store';
const { transform } = useStore(); const { viewport } = useStore();
</script> </script>
<div <div
class="svelte-flow__viewport xyflow__viewport" class="svelte-flow__viewport xyflow__viewport"
style="transform: translate({$transform[0]}px, {$transform[1]}px) scale({$transform[2]})" style="transform: translate({$viewport.x}px, {$viewport.y}px) scale({$viewport.zoom})"
> >
<slot /> <slot />
</div> </div>
@@ -20,7 +20,7 @@
export let panOnScroll: $$Props['panOnScroll']; export let panOnScroll: $$Props['panOnScroll'];
const { const {
transform, viewport,
panZoom, panZoom,
selectionKeyPressed, selectionKeyPressed,
minZoom, minZoom,
@@ -38,7 +38,7 @@
<div <div
class="svelte-flow__zoom" class="svelte-flow__zoom"
use:zoom={{ use:zoom={{
transform, viewport,
minZoom: $minZoom, minZoom: $minZoom,
maxZoom: $maxZoom, maxZoom: $maxZoom,
initialViewport: viewPort, initialViewport: viewPort,
+13 -34
View File
@@ -1,4 +1,4 @@
import { get, writable, type Writable } from 'svelte/store'; import { get, type Writable } from 'svelte/store';
import { import {
pointToRendererPoint, pointToRendererPoint,
type Project, type Project,
@@ -32,7 +32,7 @@ export function useSvelteFlow(): {
zoomOut, zoomOut,
fitView, fitView,
snapGrid, snapGrid,
transform, viewport,
width, width,
height, height,
maxZoom, maxZoom,
@@ -41,44 +41,26 @@ export function useSvelteFlow(): {
edges edges
} = useStore(); } = useStore();
const transformValues = get(transform);
const viewportWritable = writable({
x: transformValues[0],
y: transformValues[1],
zoom: transformValues[2]
});
transform.subscribe((ts) =>
viewportWritable.set({
x: ts[0],
y: ts[1],
zoom: ts[2]
})
);
return { return {
zoomIn, zoomIn,
zoomOut, zoomOut,
setZoom: (zoomLevel, options) => { setZoom: (zoomLevel, options) => {
get(panZoom)?.scaleTo(zoomLevel, { duration: options?.duration }); get(panZoom)?.scaleTo(zoomLevel, { duration: options?.duration });
}, },
getZoom: () => get(transform)[2], getZoom: () => get(viewport).zoom,
setViewport: (viewport, options) => { setViewport: (vieport, options) => {
const [x, y, zoom] = get(transform); const currentViewport = get(viewport);
get(panZoom)?.setViewport( get(panZoom)?.setViewport(
{ {
x: viewport.x ?? x, x: vieport.x ?? currentViewport.x,
y: viewport.y ?? y, y: vieport.y ?? currentViewport.y,
zoom: viewport.zoom ?? zoom zoom: vieport.zoom ?? currentViewport.zoom
}, },
{ duration: options?.duration } { duration: options?.duration }
); );
}, },
getViewport: () => { getViewport: () => get(viewport),
const [x, y, zoom] = get(transform);
return { x, y, zoom };
},
setCenter: (x, y, options) => { setCenter: (x, y, options) => {
const _width = get(width); const _width = get(width);
const _height = get(height); const _height = get(height);
@@ -98,15 +80,12 @@ export function useSvelteFlow(): {
fitView, fitView,
project: (position: XYPosition) => { project: (position: XYPosition) => {
const _snapGrid = get(snapGrid); const _snapGrid = get(snapGrid);
return pointToRendererPoint( const { x, y, zoom } = get(viewport);
position,
get(transform), return pointToRendererPoint(position, [x, y, zoom], _snapGrid !== null, _snapGrid || [1, 1]);
_snapGrid !== null,
_snapGrid || [1, 1]
);
}, },
nodes, nodes,
edges, edges,
viewport: viewportWritable viewport: viewport
}; };
} }
@@ -26,15 +26,15 @@
let className: $$Props['class'] = ''; let className: $$Props['class'] = '';
export { className as class }; export { className as class };
const { transform, flowId } = useStore(); const { viewport, flowId } = useStore();
const patternSize = size || defaultSize[variant!]; const patternSize = size || defaultSize[variant!];
const isDots = variant === BackgroundVariant.Dots; const isDots = variant === BackgroundVariant.Dots;
const isCross = variant === BackgroundVariant.Cross; const isCross = variant === BackgroundVariant.Cross;
const gapXY: number[] = Array.isArray(gap!) ? gap! : [gap!, gap!]; const gapXY: number[] = Array.isArray(gap!) ? gap! : [gap!, gap!];
$: patternId = `background-pattern-${$flowId}`; $: patternId = `background-pattern-${$flowId}`;
$: scaledGap = [gapXY[0] * $transform[2] || 1, gapXY[1] * $transform[2] || 1]; $: scaledGap = [gapXY[0] * $viewport.zoom || 1, gapXY[1] * $viewport.zoom || 1];
$: scaledSize = patternSize * $transform[2]; $: scaledSize = patternSize * $viewport.zoom;
$: patternDimensions = (isCross ? [scaledSize, scaledSize] : scaledGap) as [number, number]; $: patternDimensions = (isCross ? [scaledSize, scaledSize] : scaledGap) as [number, number];
$: patternOffset = isDots $: patternOffset = isDots
? [scaledSize / 2, scaledSize / 2] ? [scaledSize / 2, scaledSize / 2]
@@ -49,8 +49,8 @@
> >
<pattern <pattern
id={patternId} id={patternId}
x={$transform[0] % scaledGap[0]} x={$viewport.x % scaledGap[0]}
y={$transform[1] % scaledGap[1]} y={$viewport.y % scaledGap[1]}
width={scaledGap[0]} width={scaledGap[0]}
height={scaledGap[1]} height={scaledGap[1]}
patternUnits="userSpaceOnUse" patternUnits="userSpaceOnUse"
@@ -26,7 +26,7 @@
zoomIn, zoomIn,
zoomOut, zoomOut,
fitView, fitView,
transform, viewport,
minZoom, minZoom,
maxZoom, maxZoom,
nodesDraggable, nodesDraggable,
@@ -43,8 +43,8 @@
}; };
$: isInteractive = $nodesDraggable || $nodesConnectable || $elementsSelectable; $: isInteractive = $nodesDraggable || $nodesConnectable || $elementsSelectable;
$: minZoomReached = $transform[2] <= $minZoom; $: minZoomReached = $viewport.zoom <= $minZoom;
$: maxZoomReached = $transform[2] >= $maxZoom; $: maxZoomReached = $viewport.zoom >= $maxZoom;
const onZoomInHandler = () => { const onZoomInHandler = () => {
zoomIn(); zoomIn();
@@ -43,7 +43,7 @@
const defaultHeight = 150; const defaultHeight = 150;
const { const {
nodes, nodes,
transform, viewport,
width: containerWidth, width: containerWidth,
height: containerHeight, height: containerHeight,
flowId, flowId,
@@ -59,10 +59,10 @@
const labelledBy = `svelte-flow__minimap-desc-${$flowId}`; const labelledBy = `svelte-flow__minimap-desc-${$flowId}`;
$: viewBB = { $: viewBB = {
x: -$transform[0] / $transform[2], x: -$viewport.x / $viewport.zoom,
y: -$transform[1] / $transform[2], y: -$viewport.y / $viewport.zoom,
width: $containerWidth / $transform[2], width: $containerWidth / $viewport.zoom,
height: $containerHeight / $transform[2] height: $containerHeight / $viewport.zoom
}; };
$: boundingRect = $nodes.length > 0 ? getBoundsOfRects(getRectOfNodes($nodes), viewBB) : viewBB; $: boundingRect = $nodes.length > 0 ? getBoundsOfRects(getRectOfNodes($nodes), viewBB) : viewBB;
$: elementWidth = width ?? defaultWidth; $: elementWidth = width ?? defaultWidth;
@@ -100,7 +100,7 @@
style:--minimap-mask-stroke-width-props={maskStrokeWidth} style:--minimap-mask-stroke-width-props={maskStrokeWidth}
use:interactive={{ use:interactive={{
panZoom: $panZoom, panZoom: $panZoom,
transform, viewport,
getViewScale, getViewScale,
translateExtent: $translateExtent, translateExtent: $translateExtent,
width: $containerWidth, width: $containerWidth,
@@ -2,13 +2,13 @@ import { get, type Writable } from 'svelte/store';
import { import {
XYMinimap, XYMinimap,
type PanZoomInstance, type PanZoomInstance,
type Transform, type Viewport,
type XYMinimapUpdate type XYMinimapUpdate
} from '@xyflow/system'; } from '@xyflow/system';
export type UseInteractiveParams = { export type UseInteractiveParams = {
panZoom: PanZoomInstance; panZoom: PanZoomInstance;
transform: Writable<Transform>; viewport: Writable<Viewport>;
getViewScale: () => number; getViewScale: () => number;
} & XYMinimapUpdate; } & XYMinimapUpdate;
@@ -16,7 +16,10 @@ export default function interactive(domNode: Element, params: UseInteractivePara
const minimap = XYMinimap({ const minimap = XYMinimap({
domNode, domNode,
panZoom: params.panZoom, panZoom: params.panZoom,
getTransform: () => get(params.transform), getTransform: () => {
const viewport = get(params.viewport);
return [viewport.x, viewport.y, viewport.zoom];
},
getViewScale: params.getViewScale getViewScale: params.getViewScale
}); });
@@ -57,9 +57,9 @@ export function getDerivedConnectionProps(
store.connectionLineType, store.connectionLineType,
store.connectionMode, store.connectionMode,
store.nodes, store.nodes,
store.transform store.viewport
], ],
([connection, connectionLineType, connectionMode, nodes, transform]) => { ([connection, connectionLineType, connectionMode, nodes, viewport]) => {
if (!connection.connectionStartHandle?.nodeId) { if (!connection.connectionStartHandle?.nodeId) {
return initConnectionProps; return initConnectionProps;
} }
@@ -91,8 +91,8 @@ export function getDerivedConnectionProps(
sourceX: fromX, sourceX: fromX,
sourceY: fromY, sourceY: fromY,
sourcePosition: fromPosition, sourcePosition: fromPosition,
targetX: ((connection.connectionPosition?.x ?? 0) - transform[0]) / transform[2], targetX: ((connection.connectionPosition?.x ?? 0) - viewport.x) / viewport.zoom,
targetY: ((connection.connectionPosition?.y ?? 0) - transform[1]) / transform[2], targetY: ((connection.connectionPosition?.y ?? 0) - viewport.y) / viewport.zoom,
targetPosition: toPosition targetPosition: toPosition
}; };
+3 -3
View File
@@ -10,11 +10,11 @@ export function getEdgeTree(store: SvelteFlowStoreState) {
store.edges, store.edges,
store.nodes, store.nodes,
store.onlyRenderVisibleElements, store.onlyRenderVisibleElements,
store.transform, store.viewport,
store.width, store.width,
store.height store.height
], ],
([edges, nodes, onlyRenderVisibleElements, transform, width, height]) => { ([edges, nodes, onlyRenderVisibleElements, viewport, width, height]) => {
const visibleEdges = const visibleEdges =
onlyRenderVisibleElements && width && height onlyRenderVisibleElements && width && height
? edges.filter((edge) => { ? edges.filter((edge) => {
@@ -29,7 +29,7 @@ export function getEdgeTree(store: SvelteFlowStoreState) {
targetNode, targetNode,
width, width,
height, height,
transform transform: [viewport.x, viewport.y, viewport.zoom]
}) })
); );
}) })
+5 -5
View File
@@ -22,7 +22,7 @@ import { addEdge as addEdgeUtil } from '$lib/utils';
import type { EdgeTypes, NodeTypes, Node, Edge, FitViewOptions, ConnectionData } from '$lib/types'; import type { EdgeTypes, NodeTypes, Node, Edge, FitViewOptions, ConnectionData } from '$lib/types';
import { initialEdgeTypes, initialNodeTypes, getInitialStore } from './initial-store'; import { initialEdgeTypes, initialNodeTypes, getInitialStore } from './initial-store';
import type { SvelteFlowStore } from './types'; import type { SvelteFlowStore } from './types';
import { syncNodeStores, syncEdgeStores } from './utils'; import { syncNodeStores, syncEdgeStores, syncViewportStores } from './utils';
import { getEdgeTree } from './edge-tree'; import { getEdgeTree } from './edge-tree';
import { getVisibleNodes } from './visible-nodes'; import { getVisibleNodes } from './visible-nodes';
import { getDerivedConnectionProps } from './derived-connection-props'; import { getDerivedConnectionProps } from './derived-connection-props';
@@ -241,10 +241,11 @@ export function createStore(): SvelteFlowStore {
} }
function panBy(delta: XYPosition) { function panBy(delta: XYPosition) {
const viewport = get(store.viewport);
return panBySystem({ return panBySystem({
delta, delta,
panZoom: get(store.panZoom), panZoom: get(store.panZoom),
transform: get(store.transform), transform: [viewport.x, viewport.y, viewport.zoom],
translateExtent: get(store.translateExtent), translateExtent: get(store.translateExtent),
width: get(store.width), width: get(store.width),
height: get(store.height) height: get(store.height)
@@ -298,6 +299,7 @@ export function createStore(): SvelteFlowStore {
// actions // actions
syncNodeStores: (nodes) => syncNodeStores(store.nodes, nodes), syncNodeStores: (nodes) => syncNodeStores(store.nodes, nodes),
syncEdgeStores: (edges) => syncEdgeStores(store.edges, edges), syncEdgeStores: (edges) => syncEdgeStores(store.edges, edges),
syncViewport: (viewport) => syncViewportStores(store.panZoom, store.viewport, viewport),
setNodeTypes, setNodeTypes,
setEdgeTypes, setEdgeTypes,
addEdge, addEdge,
@@ -305,9 +307,7 @@ export function createStore(): SvelteFlowStore {
updateNodeDimensions, updateNodeDimensions,
zoomIn, zoomIn,
zoomOut, zoomOut,
fitView: (options?: FitViewOptions) => { fitView: (options?: FitViewOptions) => fitView(get(store.nodes), options),
return fitView(get(store.nodes), options);
},
setMinZoom, setMinZoom,
setMaxZoom, setMaxZoom,
setTranslateExtent, setTranslateExtent,
@@ -5,7 +5,6 @@ import {
ConnectionMode, ConnectionMode,
ConnectionLineType, ConnectionLineType,
type SelectionRect, type SelectionRect,
type Transform,
type SnapGrid, type SnapGrid,
type MarkerProps, type MarkerProps,
type PanZoomInstance, type PanZoomInstance,
@@ -14,7 +13,8 @@ import {
type GroupedEdges, type GroupedEdges,
type NodeOrigin, type NodeOrigin,
type OnError, type OnError,
devWarn devWarn,
type Viewport
} from '@xyflow/system'; } from '@xyflow/system';
import DefaultNode from '$lib/components/nodes/DefaultNode.svelte'; import DefaultNode from '$lib/components/nodes/DefaultNode.svelte';
@@ -72,7 +72,7 @@ export const getInitialStore = () => ({
selectionMode: writable<SelectionMode>(SelectionMode.Partial), selectionMode: writable<SelectionMode>(SelectionMode.Partial),
nodeTypes: writable<NodeTypes>(initialNodeTypes), nodeTypes: writable<NodeTypes>(initialNodeTypes),
edgeTypes: writable<EdgeTypes>(initialEdgeTypes), edgeTypes: writable<EdgeTypes>(initialEdgeTypes),
transform: writable<Transform>([0, 0, 1]), viewport: writable<Viewport>({ x: 0, y: 0, zoom: 1 }),
connectionMode: writable<ConnectionMode>(ConnectionMode.Strict), connectionMode: writable<ConnectionMode>(ConnectionMode.Strict),
domNode: writable<HTMLDivElement | null>(null), domNode: writable<HTMLDivElement | null>(null),
connection: readable<ConnectionProps>(initConnectionProps), connection: readable<ConnectionProps>(initConnectionProps),
+3 -1
View File
@@ -6,7 +6,8 @@ import type {
Connection, Connection,
UpdateNodePositions, UpdateNodePositions,
CoordinateExtent, CoordinateExtent,
UpdateConnection UpdateConnection,
Viewport
} from '@xyflow/system'; } from '@xyflow/system';
import type { getInitialStore } from './initial-store'; import type { getInitialStore } from './initial-store';
@@ -15,6 +16,7 @@ import type { Node, Edge, NodeTypes, EdgeTypes, FitViewOptions } from '$lib/type
export type SvelteFlowStoreActions = { export type SvelteFlowStoreActions = {
syncNodeStores: (nodesStore: Writable<Node[]>) => void; syncNodeStores: (nodesStore: Writable<Node[]>) => void;
syncEdgeStores: (edgeStore: Writable<Edge[]>) => void; syncEdgeStores: (edgeStore: Writable<Edge[]>) => void;
syncViewport: (viewportStore?: Writable<Viewport>) => void;
setNodeTypes: (nodeTypes: NodeTypes) => void; setNodeTypes: (nodeTypes: NodeTypes) => void;
setEdgeTypes: (edgeTypes: EdgeTypes) => void; setEdgeTypes: (edgeTypes: EdgeTypes) => void;
addEdge: (edge: Edge | Connection) => void; addEdge: (edge: Edge | Connection) => void;
+49 -1
View File
@@ -6,7 +6,7 @@ import {
type Writable, type Writable,
get get
} from 'svelte/store'; } 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'; 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)); 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<PanZoomInstance | null>,
viewportStore: Writable<Viewport>,
userViewportStore?: Writable<Viewport>
) => {
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 = { export type NodeStoreOptions = {
elevateNodesOnSelect?: boolean; elevateNodesOnSelect?: boolean;
}; };
@@ -1,13 +1,14 @@
import { derived } from 'svelte/store'; 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 { Node } from '$lib/types';
import type { SvelteFlowStoreState } from './types'; import type { SvelteFlowStoreState } from './types';
export function getVisibleNodes(store: SvelteFlowStoreState) { export function getVisibleNodes(store: SvelteFlowStoreState) {
return derived( return derived(
[store.nodes, store.onlyRenderVisibleElements, store.width, store.height, store.transform], [store.nodes, store.onlyRenderVisibleElements, store.width, store.height, store.viewport],
([nodes, onlyRenderVisibleElements, width, height, transform]) => { ([nodes, onlyRenderVisibleElements, width, height, viewport]) => {
const transform: Transform = [viewport.x, viewport.y, viewport.zoom];
return onlyRenderVisibleElements return onlyRenderVisibleElements
? getNodesInside<Node>(nodes, { x: 0, y: 0, width, height }, transform, true) ? getNodesInside<Node>(nodes, { x: 0, y: 0, width, height }, transform, true)
: nodes; : nodes;
+3 -2
View File
@@ -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 OnDraggingChange = (dragging: boolean) => void;
export type OnTransformChange = (transform: Transform) => void; export type OnTransformChange = (transform: Transform) => void;
@@ -55,4 +55,5 @@ export type PanZoomInstance = {
setTranslateExtent: (translateExtent: CoordinateExtent) => void; setTranslateExtent: (translateExtent: CoordinateExtent) => void;
scaleTo: (scale: number, options?: PanZoomTransformOptions) => void; scaleTo: (scale: number, options?: PanZoomTransformOptions) => void;
scaleBy: (factor: number, options?: PanZoomTransformOptions) => void; scaleBy: (factor: number, options?: PanZoomTransformOptions) => void;
syncViewport: (viewport: Viewport) => void;
}; };
@@ -194,6 +194,22 @@ export function XYPanZoom({
return nextTransform; 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 { function getViewport(): Viewport {
const transform = d3Selection ? zoomTransform(d3Selection.node() as Element) : { x: 0, y: 0, k: 1 }; const transform = d3Selection ? zoomTransform(d3Selection.node() as Element) : { x: 0, y: 0, k: 1 };
return { x: transform.x, y: transform.y, zoom: transform.k }; return { x: transform.x, y: transform.y, zoom: transform.k };
@@ -229,5 +245,6 @@ export function XYPanZoom({
scaleBy, scaleBy,
setScaleExtent, setScaleExtent,
setTranslateExtent, setTranslateExtent,
syncViewport,
}; };
} }
@@ -183,7 +183,9 @@ export function createPanZoomHandler({
onPaneContextMenu && isRightClickPan(panOnDrag, zoomPanValues.mouseButton ?? 0) 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) { if (onPanZoom && !event.sourceEvent?.internal) {
onPanZoom?.(event.sourceEvent as MouseEvent | TouchEvent, transformToViewport(event.transform)); onPanZoom?.(event.sourceEvent as MouseEvent | TouchEvent, transformToViewport(event.transform));