feat(utils): add vanilla drag and panzoom (#3108)

* refactor(panzoom): create vanilla helper

* feat(svelte): add interaction example

* refactor(panzoom): cleanup

* Update function-runner.ts

* refactor(panzoom): cleanup

* refactor(panzoom): rename functions

* feat(utils): add vanilla drag helper (#3107)

* feat(utils): add vanilla drag helper

* refactor(drag): cleanup

* refactor(drag): cleanup

* chore(packages): cleanup

* refactor(panzoom): cleanup and simplify
This commit is contained in:
Moritz Klack
2023-05-31 15:55:36 +02:00
committed by GitHub
parent d8ef041737
commit 1dab08f36e
59 changed files with 3258 additions and 4313 deletions
@@ -5,6 +5,7 @@
'customnode',
'drag-n-drop',
'edges',
'interaction',
'overview',
'stress',
'subflows',
+53 -119
View File
@@ -1,136 +1,70 @@
import { get } from 'svelte/store';
import { drag as d3Drag, type D3DragEvent, type SubjectPosition } from 'd3-drag';
import { select } from 'd3-selection';
import type { XYPosition, CoordinateExtent } from '@reactflow/system';
import { XYDrag } from '@reactflow/utils';
import { getDragItems, hasSelector, calcNextPosition } from './utils';
import type { SvelteFlowStore } from '$lib/store/types';
export type UseDragData = { dx: number; dy: number };
export type UseDragEvent = D3DragEvent<HTMLDivElement, null, SubjectPosition>;
export type NodeDragItem = {
id: string;
position: XYPosition;
positionAbsolute: XYPosition;
// distance from the mouse cursor to the node when start dragging
distance: XYPosition;
width?: number | null;
height?: number | null;
extent?: 'parent' | CoordinateExtent;
parentNode?: string;
dragging?: boolean;
};
type UseDragParams = {
store: SvelteFlowStore;
disabled?: boolean;
noDragClassName?: string;
handleSelector?: string;
nodeId?: string;
updateNodePositions: (dragItems: NodeDragItem[], d: boolean, p: boolean) => void;
nodes: SvelteFlowStore['nodes'];
transform: SvelteFlowStore['transform'];
snapGrid: SvelteFlowStore['snapGrid'];
isSelectable?: boolean;
};
export default function drag(
nodeRef: Element,
{
handleSelector,
nodeId,
updateNodePositions,
nodes,
transform: transformStore,
snapGrid: snapGridStore
}: UseDragParams
) {
let dragging = false;
let dragItems: NodeDragItem[] = [];
let lastPos: { x: number | null; y: number | null } = { x: null, y: null };
export default function drag(domNode: Element, params: UseDragParams) {
const dragInstance = XYDrag({
domNode,
getStoreItems: () => {
const { store } = params;
const snapGrid = get(store.snapGrid);
const selection = select(nodeRef);
return {
nodes: get(store.nodes),
edges: get(store.edges),
nodeExtent: get(store.nodeExtent),
snapGrid: snapGrid ? snapGrid : [0, 0],
snapToGrid: !!snapGrid,
nodeOrigin: [0, 0],
multiSelectionActive: false,
domNode: get(store.domNode),
transform: get(store.transform),
autoPanOnNodeDrag: get(store.autoPanOnNodeDrag),
nodesDraggable: get(store.nodesDraggable),
selectNodesOnDrag: get(store.selectNodesOnDrag),
unselectNodesAndEdges: store.unselectNodesAndEdges,
updateNodePositions: store.updateNodePositions,
panBy: store.panBy
};
},
onNodeClick: () => {
console.log('node click');
}
});
const getPointerPosition = ({ sourceEvent }: UseDragEvent) => {
const x = sourceEvent.touches ? sourceEvent.touches[0].clientX : sourceEvent.clientX;
const y = sourceEvent.touches ? sourceEvent.touches[0].clientY : sourceEvent.clientY;
const transform = get(transformStore);
const snapGrid = get(snapGridStore);
const pointerPos = {
x: (x - transform[0]) / transform[2],
y: (y - transform[1]) / transform[2]
};
// we need the snapped position in order to be able to skip unnecessary drag events
return {
xSnapped: snapGrid ? snapGrid[0] * Math.round(pointerPos.x / snapGrid[0]) : pointerPos.x,
ySnapped: snapGrid ? snapGrid[1] * Math.round(pointerPos.y / snapGrid[1]) : pointerPos.y,
...pointerPos
};
};
const updateNodes = ({ x, y }: XYPosition) => {
let hasChange = false;
const snapGrid = get(snapGridStore);
dragItems = dragItems.map((n) => {
const nextPosition = { x: x - n.distance.x, y: y - n.distance.y };
if (snapGrid) {
nextPosition.x = snapGrid[0] * Math.round(nextPosition.x / snapGrid[0]);
nextPosition.y = snapGrid[1] * Math.round(nextPosition.y / snapGrid[1]);
}
const updatedPos = calcNextPosition(n, nextPosition, get(nodes));
// we want to make sure that we only fire a change event when there is a changes
hasChange =
hasChange ||
n.position.x !== updatedPos.position.x ||
n.position.y !== updatedPos.position.y;
n.position = updatedPos.position;
n.positionAbsolute = updatedPos.positionAbsolute;
return n;
});
if (!hasChange) {
function updateDrag(domNode: Element, params: UseDragParams) {
if (params.disabled) {
dragInstance.destroy();
return;
}
updateNodePositions(dragItems, true, true);
dragging = true;
};
const dragHandler = d3Drag()
.on('start', (event: UseDragEvent) => {
const pointerPos = getPointerPosition(event);
lastPos = pointerPos;
dragItems = getDragItems(get(nodes), pointerPos, nodeId);
})
.on('drag', (event: UseDragEvent) => {
const pointerPos = getPointerPosition(event);
// skip events without movement
if ((lastPos.x !== pointerPos.xSnapped || lastPos.y !== pointerPos.ySnapped) && dragItems) {
lastPos = pointerPos;
updateNodes(pointerPos);
}
})
.on('end', (event: UseDragEvent) => {
dragging = false;
if (dragItems) {
updateNodePositions(dragItems, false, false);
}
})
.filter((event: MouseEvent) => {
const target = event.target as HTMLDivElement;
const isDraggable =
!event.button &&
!hasSelector(target, '.nodrag', nodeRef) &&
(!handleSelector || hasSelector(target, handleSelector, nodeRef));
return isDraggable;
dragInstance.update({
domNode,
noDragClassName: params.noDragClassName,
handleSelector: params.handleSelector,
nodeId: params.nodeId,
isSelectable: params.isSelectable
});
}
selection.call(dragHandler);
updateDrag(domNode, params);
return {
update(params: UseDragParams) {
updateDrag(domNode, params);
},
destroy() {
dragInstance.destroy();
}
};
}
@@ -1,144 +0,0 @@
import {
errorMessages,
type CoordinateExtent,
type NodeDragItem,
type NodeOrigin,
type XYPosition
} from '@reactflow/system';
import { clampPosition, devWarn, getNodePositionWithOrigin, isNumeric } from '@reactflow/utils';
import type { Node } from '$lib/types';
export function isParentSelected(node: Node, nodes: Node[]): boolean {
if (!node.parentNode) {
return false;
}
const parentNode = nodes.find((n) => n.id === node.parentNode);
if (!parentNode) {
return false;
}
if (parentNode.selected) {
return true;
}
return isParentSelected(parentNode, nodes);
}
export function hasSelector(target: Element, selector: string, domNode: Element): boolean {
let current = target;
do {
if (current?.matches(selector)) return true;
if (current === domNode) return false;
current = current.parentElement as Element;
} while (current);
return false;
}
// looks for all selected nodes and created a NodeDragItem for each of them
export function getDragItems(nodes: Node[], mousePos: XYPosition, nodeId?: string): NodeDragItem[] {
return nodes
.filter(
(n) =>
(n.selected || n.id === nodeId) &&
(n.draggable || n.draggable === undefined) &&
(!n.parentNode || !isParentSelected(n, nodes))
)
.map((n) => ({
id: n.id,
position: n.position ? { ...n.position } : { x: 0, y: 0 },
positionAbsolute: n.positionAbsolute ? { ...n.positionAbsolute } : { x: 0, y: 0 },
distance: {
x: mousePos.x - (n.positionAbsolute?.x ?? 0),
y: mousePos.y - (n.positionAbsolute?.y ?? 0)
},
delta: {
x: 0,
y: 0
},
extent: n.extent,
parentNode: n.parentNode,
width: n.width,
height: n.height
}));
}
export function calcNextPosition(
node: NodeDragItem | Node,
nextPosition: XYPosition,
nodes: Node[],
nodeExtent?: CoordinateExtent
): { position: XYPosition; positionAbsolute: XYPosition } {
let currentExtent = node.extent || nodeExtent;
if (node.extent === 'parent') {
if (node.parentNode && node.width && node.height) {
const parent = nodes.find((n) => n.id === node.parentNode);
const parentOrigin = parent?.origin || [0, 0];
const nodeOrigin = node.origin || [0, 0];
const { x: parentX, y: parentY } = getNodePositionWithOrigin(
parent,
parentOrigin
).positionAbsolute;
console.log({
parentX,
parentY,
parentW: parent?.width,
parentH: parent?.height,
nodeW: node.width,
nodeH: node.height,
parentOrigin: parentOrigin[0]
});
currentExtent =
parent &&
isNumeric(parentX) &&
isNumeric(parentY) &&
isNumeric(parent.width) &&
isNumeric(parent.height)
? [
[parentX + node.width * nodeOrigin[0], parentY + node.height * nodeOrigin[1]],
[
parentX + parent.width - node.width + node.width * nodeOrigin[0],
parentY + parent.height - node.height + node.height * nodeOrigin[1]
]
]
: currentExtent;
} else {
devWarn('005', errorMessages['error005']());
currentExtent = nodeExtent;
}
} else if (node.extent && node.parentNode) {
const parent = nodes.find((n) => n.id === node.parentNode);
const { x: parentX, y: parentY } = getNodePositionWithOrigin(
parent,
parent?.origin || [0, 0]
).positionAbsolute;
currentExtent = [
[node.extent[0][0] + parentX, node.extent[0][1] + parentY],
[node.extent[1][0] + parentX, node.extent[1][1] + parentY]
];
}
let parentPosition = { x: 0, y: 0 };
if (node.parentNode) {
const parent = nodes.find((n) => n.id === node.parentNode);
parentPosition = getNodePositionWithOrigin(parent, parent?.origin || [0, 0]).positionAbsolute;
}
const positionAbsolute = currentExtent
? clampPosition(nextPosition, currentExtent as CoordinateExtent)
: nextPosition;
return {
position: {
x: positionAbsolute.x - parentPosition.x,
y: positionAbsolute.y - parentPosition.y
},
positionAbsolute
};
}
+39 -194
View File
@@ -1,217 +1,62 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Writable } from 'svelte/store';
import { select } from 'd3-selection';
import { zoom as d3Zoom, zoomIdentity, type D3ZoomEvent } from 'd3-zoom';
import type {
D3SelectionInstance,
D3ZoomInstance,
OnMove,
OnMoveEnd,
OnMoveStart,
CoordinateExtent,
OnPanZoom,
PanOnScrollMode,
PanZoomInstance,
Transform,
Viewport
} from '@reactflow/system';
import { clamp } from '@reactflow/utils';
const isWrappedWithClass = (event: any, className: string | undefined) =>
event.target.closest(`.${className}`);
const eventToFlowTransform = (eventViewport: any): Viewport => ({
x: eventViewport.x,
y: eventViewport.y,
zoom: eventViewport.k
});
const isRightClickPan = (panOnDrag: FlowRendererProps['panOnDrag'], usedButton: number) =>
usedButton === 2 && Array.isArray(panOnDrag) && panOnDrag.includes(2);
const viewChanged = (prevViewport: Viewport, eventViewport: any): boolean =>
prevViewport.x !== eventViewport.x ||
prevViewport.y !== eventViewport.y ||
prevViewport.zoom !== eventViewport.k;
function filter(event: any, params: ZoomParams): boolean {
const zoomScroll = true;
const pinchZoom = true;
if (
event.button === 1 &&
event.type === 'mousedown' &&
(isWrappedWithClass(event, 'svelte-flow__node') ||
isWrappedWithClass(event, 'svelte-flow__edge'))
) {
return true;
}
// if all interactions are disabled, we prevent all zoom events
// if (!panOnDrag && !zoomScroll && !panOnScroll && !zoomOnDoubleClick && !zoomOnPinch) {
// return false;
// }
// // during a selection we prevent all other interactions
if (params.selecting) {
return false;
}
// // if zoom on double click is disabled, we prevent the double click event
// if (!zoomOnDoubleClick && event.type === 'dblclick') {
// return false;
// }
// // if the target element is inside an element with the nowheel class, we prevent zooming
// if (isWrappedWithClass(event, noWheelClassName) && event.type === 'wheel') {
// return false;
// }
// // if the target element is inside an element with the nopan class, we prevent panning
if (isWrappedWithClass(event, 'nopan') && event.type !== 'wheel') {
return false;
}
// if (!zoomOnPinch && event.ctrlKey && event.type === 'wheel') {
// return false;
// }
// // when there is no scroll handling enabled, we prevent all wheel events
// if (!zoomScroll && !panOnScroll && !pinchZoom && event.type === 'wheel') {
// return false;
// }
// // if the pane is not movable, we prevent dragging it with mousestart or touchstart
// if (!panOnDrag && (event.type === 'mousedown' || event.type === 'touchstart')) {
// return false;
// }
// // if the pane is only movable using allowed clicks
// if (
// Array.isArray(panOnDrag) &&
// !panOnDrag.includes(event.button) &&
// (event.type === 'mousedown' || event.type === 'touchstart')
// ) {
// return false;
// }
// // We only allow right clicks if pan on drag is set to right click
// const buttonAllowed =
// (Array.isArray(panOnDrag) && panOnDrag.includes(event.button)) ||
// !event.button ||
// event.button <= 1;
// default filter for d3-zoom
return ((!event.ctrlKey || event.type === 'wheel') && !event.button) || event.button <= 1;
}
import { XYPanZoom } from '@reactflow/utils';
type ZoomParams = {
transform: Writable<Transform>;
selecting: boolean;
d3: Writable<{ zoom: D3ZoomInstance | null; selection: D3SelectionInstance | null }>;
minZoom: number;
maxZoom: number;
initialViewport: Viewport;
dragging: Writable<boolean>;
onMoveStart?: OnMoveStart;
onMove?: OnMove;
onMoveEnd?: OnMoveEnd;
onPanZoomStart?: OnPanZoom;
onPanZoom?: OnPanZoom;
onPanZoomEnd?: OnPanZoom;
onPaneContextMenu?: (event: MouseEvent) => void;
translateExtent: CoordinateExtent;
panZoom: Writable<PanZoomInstance | null>;
zoomOnScroll: boolean;
zoomOnPinch: boolean;
zoomOnDoubleClick: boolean;
panOnScroll: boolean;
panOnDrag: boolean | number[];
panOnScrollSpeed: number;
panOnScrollMode: PanOnScrollMode;
zoomActivationKeyPressed: boolean;
preventScrolling: boolean;
noPanClassName: string;
noWheelClassName: string;
userSelectionActive: boolean;
};
export default function zoom(domNode: Element, params: ZoomParams) {
const { d3, minZoom, maxZoom, initialViewport } = params;
const { panZoom, minZoom, maxZoom, initialViewport, transform, dragging, translateExtent } =
params;
const d3ZoomInstance = d3Zoom().scaleExtent([minZoom, maxZoom]);
const selection = select(domNode).call(d3ZoomInstance);
const updatedTransform = zoomIdentity
.translate(initialViewport.x, initialViewport.y)
.scale(clamp(initialViewport.zoom, minZoom, maxZoom));
const d3ZoomHandler = selection.on('wheel.zoom');
let mouseButton = 0;
let isZoomingOrPanning = false;
let prevTransform: Viewport = { x: 0, y: 0, zoom: 0 };
let zoomedWithRightMouseButton = false;
let timerId: any;
function updateZoomHandling(_params: ZoomParams) {
const { transform, dragging, onMoveStart, onMove, onMoveEnd } = _params;
d3ZoomInstance.on('start', (event: D3ZoomEvent<HTMLDivElement, any>) => {
// we need to remember it here, because it's always 0 in the "zoom" event
mouseButton = event.sourceEvent?.button || 0;
isZoomingOrPanning = true;
if (event.sourceEvent?.type === 'mousedown') {
dragging.set(true);
}
if (onMoveStart) {
const flowTransform = eventToFlowTransform(event.transform);
prevTransform = flowTransform;
onMoveStart?.((event.sourceEvent as MouseEvent | TouchEvent) || null, flowTransform);
}
});
d3ZoomInstance.on('zoom', (event: D3ZoomEvent<HTMLDivElement, any>) => {
transform.set([event.transform.x, event.transform.y, event.transform.k]);
if (onMove) {
const flowTransform = eventToFlowTransform(event.transform);
onMove?.((event.sourceEvent as MouseEvent | TouchEvent) || null, flowTransform);
}
});
d3ZoomInstance.on('end', (event: D3ZoomEvent<HTMLDivElement, any>) => {
isZoomingOrPanning = false;
dragging.set(false);
if (
//onPaneContextMenu &&
isRightClickPan(panOnDrag, mouseButton ?? 0) &&
!zoomedWithRightMouseButton
) {
// onPaneContextMenu(event.sourceEvent);
}
zoomedWithRightMouseButton = false;
if (onMoveEnd && viewChanged(prevTransform, event.transform)) {
const flowTransform = eventToFlowTransform(event.transform);
prevTransform = flowTransform;
clearTimeout(timerId);
timerId = setTimeout(
() => {
onMoveEnd?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform);
},
panOnScroll ? 150 : 0
);
}
});
selection.on('wheel.zoom', function (event: any, d: any) {
if (isWrappedWithClass(event, 'nowheel')) {
return null;
}
event.preventDefault();
d3ZoomHandler!.call(this, event, d);
});
d3ZoomInstance.filter((event: any) => filter(event, params));
}
d3ZoomInstance.transform(selection, updatedTransform);
updateZoomHandling(params);
d3.set({
zoom: d3ZoomInstance,
selection
const panZoomInstance = XYPanZoom({
domNode,
minZoom,
maxZoom,
translateExtent,
viewport: initialViewport,
onTransformChange: transform.set,
onDraggingChange: dragging.set
});
const { x, y, zoom } = panZoomInstance.getViewport();
transform.set([x, y, zoom]);
panZoom.set(panZoomInstance);
panZoomInstance.update(params);
return {
update(params: ZoomParams) {
updateZoomHandling(params);
panZoomInstance.update(params);
}
};
}
@@ -5,7 +5,8 @@
import { Selection } from '$lib/components/Selection';
import drag from '$lib/actions/drag';
const { selectionRectMode, nodes, transform, snapGrid, updateNodePositions } = useStore();
const store = useStore();
const { selectionRectMode, nodes } = store;
$: selectedNodes = $nodes.filter((n) => n.selected);
$: rect = getRectOfNodes(selectedNodes);
@@ -15,7 +16,7 @@
<div
class="selection-wrapper nopan"
style={`width: ${rect.width}px; height: ${rect.height}px; transform: translate(${rect.x}px, ${rect.y}px)`}
use:drag={{ nodes, snapGrid, transform, updateNodePositions }}
use:drag={{ disabled: false, store }}
/>
<Selection
isVisible={$selectionRectMode === 'nodes'}
@@ -31,7 +31,8 @@
let className: string = '';
export { className as class };
const { nodes, transform, nodeTypes, snapGrid, updateNodePositions, addSelectedNodes } = useStore();
const store = useStore();
const { nodes, nodeTypes, addSelectedNodes } = store;
let nodeRef: HTMLDivElement;
const nodeTypeValid = !!$nodeTypes[type!];
@@ -74,7 +75,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
use:drag={{ nodeId: id, nodes, snapGrid, transform, updateNodePositions }}
use:drag={{ nodeId: id, isSelectable: selectable, disabled: false, handleSelector: undefined, noDragClassName: 'nodrag', store }}
bind:this={nodeRef}
data-id={id}
class={cc(['svelte-flow__node', `svelte-flow__node-${type || 'default'}`, className])}
@@ -48,7 +48,7 @@
selectionRectMode,
selectionKeyPressed,
selectionMode,
resetSelectedElements,
unselectNodesAndEdges,
} = useStore();
let container: HTMLDivElement;
@@ -61,7 +61,7 @@
function onClick(event: MouseEvent) {
dispatch('pane:click', event);
resetSelectedElements();
unselectNodesAndEdges();
selectionRectMode.set(null);
}
@@ -80,7 +80,7 @@
const { x, y } = getEventPosition(event, containerBounds);
resetSelectedElements();
unselectNodesAndEdges();
selectionRect.set({
width: 0,
@@ -1,10 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import cc from 'classcat';
import { PanOnScrollMode, type CoordinateExtent, type Viewport } from '@reactflow/system';
import { Zoom } from '$lib/container/Zoom';
import { Pane } from '$lib/container/Pane';
import { Viewport } from '$lib/container/Viewport';
import { Viewport as ViewportComponent } from '$lib/container/Viewport';
import { NodeRenderer } from '$lib/container/NodeRenderer';
import { EdgeRenderer } from '$lib/container/EdgeRenderer';
import { UserSelection } from '$lib/components/UserSelection';
@@ -14,6 +15,7 @@
import { Attribution } from '$lib/components/Attribution';
import { useStore } from '$lib/store';
import type { SvelteFlowProps } from './types';
import type { EdgeTypes, NodeTypes } from '$lib/types';
type $$Props = SvelteFlowProps;
@@ -21,7 +23,7 @@
export let fitView: $$Props['fitView'] = undefined;
export let minZoom: $$Props['minZoom'] = undefined;
export let maxZoom: $$Props['maxZoom'] = undefined;
export let initialViewport: $$Props['initialViewport'] = undefined;
export let initialViewport: Viewport = { x:0, y: 0, zoom: 1 };
export let nodeTypes: $$Props['nodeTypes'] = undefined;
export let edgeTypes: $$Props['edgeTypes'] = undefined;
export let selectionKey: $$Props['selectionKey'] = undefined;
@@ -37,6 +39,15 @@
export let onMove: $$Props['onMove'] = undefined;
export let onMoveEnd: $$Props['onMoveEnd'] = undefined;
export let isValidConnection: $$Props['isValidConnection'] = undefined;
export let translateExtent: $$Props['translateExtent'] = undefined;
export let panOnScrollMode: PanOnScrollMode = PanOnScrollMode.Free;
export let preventScrolling: boolean = true;
export let zoomOnScroll: boolean = true;
export let zoomOnDoubleClick: boolean = true;
export let zoomOnPinch: boolean = true;
export let panOnScroll: boolean = false;
export let panOnDrag: boolean | number[] = true;
export let defaultMarkerColor = '#b1b1b7';
export let style: $$Props['style'] = undefined;
@@ -53,6 +64,15 @@
store.height.set(height);
store.domNode.set(domNode);
updateStore({
nodeTypes,
edgeTypes,
minZoom,
maxZoom,
translateExtent,
fitView
});
return () => {
store.reset();
}
@@ -81,7 +101,14 @@
})
}
$: {
function updateStore({ nodeTypes, edgeTypes, minZoom, maxZoom, translateExtent, fitView }: {
nodeTypes?: NodeTypes,
edgeTypes?: EdgeTypes,
minZoom?: number,
maxZoom?: number,
translateExtent?: CoordinateExtent,
fitView?: boolean
}) {
if (nodeTypes !== undefined) {
store.setNodeTypes(nodeTypes);
}
@@ -98,10 +125,23 @@
store.setMaxZoom(maxZoom);
}
if (translateExtent !== undefined) {
store.setTranslateExtent(translateExtent)
}
if (fitView !== undefined) {
store.fitViewOnInit.set(fitView);
}
}
$: updateStore({
nodeTypes,
edgeTypes,
minZoom,
maxZoom,
translateExtent,
fitView
})
</script>
<div
@@ -114,9 +154,21 @@
{...$$restProps}
>
<KeyHandler {selectionKey} {deleteKey} />
<Zoom {initialViewport} {onMoveStart} {onMove} {onMoveEnd}>
<Zoom
{initialViewport}
{onMoveStart}
{onMove}
{onMoveEnd}
{panOnScrollMode}
{preventScrolling}
{zoomOnScroll}
{zoomOnDoubleClick}
{zoomOnPinch}
{panOnScroll}
{panOnDrag}
>
<Pane on:pane:click>
<Viewport>
<ViewportComponent>
<EdgeRenderer on:edge:click />
<ConnectionLine />
<div class="svelte-flow__edgelabel-renderer" />
@@ -130,7 +182,7 @@
on:connect:end
/>
<NodeSelection />
</Viewport>
</ViewportComponent>
<UserSelection />
</Pane>
</Zoom>
@@ -9,7 +9,9 @@ import type {
SnapGrid,
OnMoveStart,
OnMove,
OnMoveEnd
OnMoveEnd,
CoordinateExtent,
PanOnScrollMode
} from '@reactflow/system';
import type {
@@ -39,6 +41,14 @@ export type SvelteFlowProps = DOMAttributes<HTMLDivElement> & {
nodesDraggable?: boolean;
nodesConnectable?: boolean;
elementsSelectable?: boolean;
translateExtent?: CoordinateExtent;
panOnScrollMode?: PanOnScrollMode;
preventScrolling?: boolean;
zoomOnScroll?: boolean;
zoomOnDoubleClick?: boolean;
zoomOnPinch?: boolean;
panOnScroll?: boolean;
panOnDrag?: boolean | number[];
class?: string;
style?: string;
@@ -1,32 +1,60 @@
<script lang="ts">
import { PanOnScrollMode } from '@reactflow/system';
import { useStore } from '$lib/store';
import zoom from '$lib/actions/zoom';
import type { ZoomProps } from './types';
type $$Props = ZoomProps;
export let initialViewport: $$Props['initialViewport'] = undefined;
export let initialViewport: $$Props['initialViewport'];
export let onMoveStart: $$Props['onMoveStart'] = undefined;
export let onMove: $$Props['onMove'] = undefined;
export let onMoveEnd: $$Props['onMoveEnd'] = undefined;
export let panOnScrollMode: $$Props['panOnScrollMode'];
export let preventScrolling: $$Props['preventScrolling'];;
export let zoomOnScroll: $$Props['zoomOnScroll'];;
export let zoomOnDoubleClick: $$Props['zoomOnDoubleClick'];;
export let zoomOnPinch: $$Props['zoomOnPinch'];;
export let panOnDrag: $$Props['panOnDrag'];
export let panOnScroll: $$Props['panOnScroll'];;
const { transform, d3, selectionKeyPressed, selectionRectMode, minZoom, maxZoom, dragging } = useStore();
const {
transform,
panZoom,
selectionKeyPressed,
minZoom,
maxZoom,
dragging,
translateExtent
} = useStore();
$: viewPort = initialViewport || { x: 0, y: 0, zoom: 1 };
$: selecting = $selectionKeyPressed || $selectionRectMode === 'user';
</script>
<div class="svelte-flow__zoom" use:zoom={{
transform,
d3,
selecting,
minZoom: $minZoom,
maxZoom: $maxZoom,
initialViewport: viewPort,
dragging,
onMoveStart,
onMove,
onMoveEnd
panZoom,
onPanZoomStart: onMoveStart,
onPanZoom: onMove,
onPanZoomEnd: onMoveEnd,
zoomOnScroll,
zoomOnDoubleClick,
zoomOnPinch,
panOnScroll,
panOnDrag,
panOnScrollSpeed: 0.5,
panOnScrollMode: panOnScrollMode || PanOnScrollMode.Free,
zoomActivationKeyPressed: false,
preventScrolling: typeof preventScrolling === 'boolean' ? preventScrolling : true,
noPanClassName: 'nopan',
noWheelClassName: 'nowheel',
userSelectionActive: $selectionKeyPressed,
translateExtent: $translateExtent
}}>
<slot />
</div>
@@ -1,8 +1,15 @@
import type { OnMove, OnMoveStart, OnMoveEnd, Viewport } from '@reactflow/system';
import type { OnMoveStart, OnMove, OnMoveEnd, PanOnScrollMode, Viewport } from '@reactflow/system';
export type ZoomProps = {
initialViewport?: Viewport;
initialViewport: Viewport;
panOnScrollMode: PanOnScrollMode;
onMove?: OnMove;
onMoveStart?: OnMoveStart;
onMoveEnd?: OnMoveEnd;
preventScrolling: boolean;
zoomOnScroll: boolean;
zoomOnDoubleClick: boolean;
zoomOnPinch: boolean;
panOnScroll: boolean;
panOnDrag: boolean | number[];
};
+20 -23
View File
@@ -7,12 +7,11 @@ import type {
XYPosition,
ZoomInOut
} from '@reactflow/system';
import { zoomIdentity } from 'd3-zoom';
import { pointToRendererPoint } from '@reactflow/utils';
import { useStore } from '$lib/store';
import type { FitViewOptions } from '$lib/types';
import type { SvelteFlowStore } from '$lib/store/types';
import { getD3Transition, pointToRendererPoint } from '@reactflow/utils';
export function useSvelteFlow(): {
zoomIn: ZoomInOut;
@@ -38,7 +37,7 @@ export function useSvelteFlow(): {
width,
height,
maxZoom,
d3,
panZoom,
nodes,
edges
} = useStore();
@@ -62,23 +61,20 @@ export function useSvelteFlow(): {
zoomIn,
zoomOut,
setZoom: (zoomLevel, options) => {
const { zoom, selection } = get(d3);
if (zoom && selection) {
zoom.scaleTo(getD3Transition(selection, options?.duration), zoomLevel);
}
get(panZoom)?.scaleTo(zoomLevel, { duration: options?.duration });
},
getZoom: () => get(transform)[2],
setViewport: (viewport, options) => {
const [x, y, zoom] = get(transform);
const { zoom: d3Zoom, selection } = get(d3);
if (d3Zoom && selection) {
const nextTransform = zoomIdentity
.translate(viewport.x ?? x, viewport.y ?? y)
.scale(viewport.zoom ?? zoom);
d3Zoom.transform(getD3Transition(selection, options?.duration), nextTransform);
}
get(panZoom)?.setViewport(
{
x: viewport.x ?? x,
y: viewport.y ?? y,
zoom: viewport.zoom ?? zoom
},
{ duration: options?.duration }
);
},
getViewport: () => {
const [x, y, zoom] = get(transform);
@@ -88,16 +84,17 @@ export function useSvelteFlow(): {
const _width = get(width);
const _height = get(height);
const _maxZoom = get(maxZoom);
const { zoom, selection } = get(d3);
if (zoom && selection) {
const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : _maxZoom;
const centerX = _width / 2 - x * nextZoom;
const centerY = _height / 2 - y * nextZoom;
const transform = zoomIdentity.translate(centerX, centerY).scale(nextZoom);
const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : _maxZoom;
zoom.transform(getD3Transition(selection, options?.duration), transform);
}
get(panZoom)?.setViewport(
{
x: _width / 2 - x * nextZoom,
y: _height / 2 - y * nextZoom,
zoom: nextZoom
},
{ duration: options?.duration }
);
},
fitView,
project: (position: XYPosition) => {
+54 -41
View File
@@ -1,8 +1,7 @@
import { getContext } from 'svelte';
import { derived, get } from 'svelte/store';
import { zoomIdentity } from 'd3-zoom';
import {
type NodeDragItem,
type UpdateNodePositions,
type NodeDimensionUpdate,
internalsSymbol,
type ViewportHelperFunctionOptions,
@@ -13,10 +12,10 @@ import {
import {
createMarkerIds,
fitView as fitViewUtil,
getD3Transition,
getDimensions,
getElementsToRemove,
getHandleBounds
getHandleBounds,
infiniteExtent
} from '@reactflow/utils';
import { addEdge as addEdgeUtil } from '$lib/utils';
@@ -60,7 +59,7 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
store.edges.set(addEdgeUtil(edgeParams, edges));
}
function updateNodePositions(nodeDragItems: NodeDragItem[], dragging = false) {
const updateNodePositions: UpdateNodePositions = (nodeDragItems, dragging = false) => {
store.nodes.update((nds) => {
return nds.map((n) => {
const nodeDragItem = nodeDragItems.find((ndi) => ndi.id === n.id);
@@ -77,7 +76,7 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
return n;
});
});
}
};
function updateNodeDimensions(updates: NodeDimensionUpdate[]) {
const viewportNode = document?.querySelector('.svelte-flow__viewport');
@@ -123,21 +122,21 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
return node;
});
const { zoom: d3Zoom, selection: d3Selection } = get(store.d3);
const panZoom = get(store.panZoom);
const fitViewOnInitDone =
get(store.fitViewOnInitDone) ||
(get(store.fitViewOnInit) && !!d3Zoom && !!d3Selection && fitView({ nodes: nextNodes }));
(get(store.fitViewOnInit) && !!panZoom && fitView({ nodes: nextNodes }));
store.fitViewOnInitDone.set(fitViewOnInitDone);
store.nodes.set(nextNodes);
}
function zoomBy(factor: number, options?: ViewportHelperFunctionOptions) {
const { zoom: d3Zoom, selection: d3Selection } = get(store.d3);
const panZoom = get(store.panZoom);
if (d3Zoom && d3Selection) {
d3Zoom.scaleBy(getD3Transition(d3Selection, options?.duration), factor);
if (panZoom) {
panZoom.scaleBy(factor, options);
}
}
@@ -150,28 +149,36 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
}
function setMinZoom(minZoom: number) {
const d3Zoom = get(store.d3).zoom;
if (d3Zoom) {
d3Zoom?.scaleExtent([minZoom, get(store.maxZoom)]);
const panZoom = get(store.panZoom);
if (panZoom) {
panZoom.setScaleExtent([minZoom, get(store.maxZoom)]);
store.minZoom.set(minZoom);
}
}
function setMaxZoom(maxZoom: number) {
const d3Zoom = get(store.d3).zoom;
if (d3Zoom) {
d3Zoom?.scaleExtent([get(store.minZoom), maxZoom]);
const panZoom = get(store.panZoom);
if (panZoom) {
panZoom.setScaleExtent([get(store.minZoom), maxZoom]);
store.maxZoom.set(maxZoom);
}
}
function fitView(options?: FitViewOptions) {
const { zoom: d3Zoom, selection: d3Selection } = get(store.d3);
function setTranslateExtent(extent: CoordinateExtent) {
const panZoom = get(store.panZoom);
if (!d3Zoom || !d3Selection) {
if (panZoom) {
panZoom.setTranslateExtent(extent);
store.translateExtent.set(extent);
}
}
function fitView(options?: FitViewOptions) {
const panZoom = get(store.panZoom);
if (!panZoom) {
return false;
}
@@ -184,8 +191,7 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
height: get(store.height),
minZoom: 0.2,
maxZoom: 2,
d3Selection,
d3Zoom,
panZoom,
nodeOrigin: [0, 0]
},
{}
@@ -203,7 +209,7 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
return item;
}
function resetSelectedElements() {
function unselectNodesAndEdges() {
store.nodes.update((ns) => ns.map(resetSelectedItem));
store.edges.update((es) => es.map(resetSelectedItem));
}
@@ -267,29 +273,35 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
}
function panBy(delta: XYPosition) {
const { zoom: d3Zoom, selection: d3Selection } = get(store.d3);
const panZoom = get(store.panZoom);
const transform = get(store.transform);
const width = get(store.width);
const height = get(store.height);
if (!d3Zoom || !d3Selection || (!delta.x && !delta.y)) {
return;
if (!panZoom || (!delta.x && !delta.y)) {
return false;
}
const nextTransform = zoomIdentity
.translate(transform[0] + delta.x, transform[1] + delta.y)
.scale(transform[2]);
const nextViewport = panZoom.setViewportConstrained(
{
x: transform[0] + delta.x,
y: transform[1] + delta.y,
zoom: transform[2]
},
[
[0, 0],
[width, height]
],
infiniteExtent
);
const extent: CoordinateExtent = [
[0, 0],
[width, height]
];
const transformChanged =
!!nextViewport &&
(nextViewport.x !== transform[0] ||
nextViewport.y !== transform[1] ||
nextViewport.k !== transform[2]);
const constrainedTransform = d3Zoom?.constrain()(nextTransform, extent, [
[Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY],
[Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]
]);
d3Zoom.transform(d3Selection, constrainedTransform);
return transformChanged;
}
function updateConnection(connectionUpdate: Partial<ConnectionData> | null) {
@@ -319,7 +331,7 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
store.snapGrid.set(null);
store.isValidConnection.set(() => true);
resetSelectedElements();
unselectNodesAndEdges();
cancelConnection();
}
@@ -346,7 +358,8 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
fitView,
setMinZoom,
setMaxZoom,
resetSelectedElements,
setTranslateExtent,
unselectNodesAndEdges,
addSelectedNodes,
addSelectedEdges,
panBy,
@@ -1,14 +1,14 @@
import { readable, writable } from 'svelte/store';
import {
SelectionMode,
type D3ZoomInstance,
type D3SelectionInstance,
ConnectionMode,
ConnectionLineType,
type SelectionRect,
type Transform,
type SnapGrid,
type MarkerProps
type MarkerProps,
type PanZoomInstance,
type CoordinateExtent
} from '@reactflow/system';
import DefaultNode from '$lib/components/nodes/DefaultNode.svelte';
@@ -27,6 +27,7 @@ import type {
Node,
IsValidConnection
} from '$lib/types';
import { infiniteExtent } from '@reactflow/utils';
export const initConnectionData = {
nodeId: null,
@@ -58,12 +59,12 @@ export const initialStoreState = {
width: writable<number>(500),
minZoom: writable<number>(0.5),
maxZoom: writable<number>(2),
nodeExtent: writable<CoordinateExtent>(infiniteExtent),
translateExtent: writable<CoordinateExtent>(infiniteExtent),
autoPanOnNodeDrag: writable<boolean>(true),
fitViewOnInit: writable<boolean>(false),
fitViewOnInitDone: writable<boolean>(false),
d3: writable<{ zoom: D3ZoomInstance | null; selection: D3SelectionInstance | null }>({
zoom: null,
selection: null
}),
panZoom: writable<PanZoomInstance | null>(null),
snapGrid: writable<SnapGrid | null>(null),
dragging: writable<boolean>(false),
selectionRect: writable<SelectionRect | null>(null),
@@ -85,6 +86,7 @@ export const initialStoreState = {
nodesDraggable: writable<boolean>(true),
nodesConnectable: writable<boolean>(true),
elementsSelectable: writable<boolean>(true),
selectNodesOnDrag: writable<boolean>(true),
markers: readable<MarkerProps[]>([]),
defaultMarkerColor: writable<string>('#b1b1b7')
};
+6 -8
View File
@@ -3,7 +3,8 @@ import type {
XYPosition,
ViewportHelperFunctionOptions,
Connection,
NodeDragItem
UpdateNodePositions,
CoordinateExtent
} from '@reactflow/system';
import type { initialStoreState } from './initial-store';
@@ -18,17 +19,14 @@ export type SvelteFlowStoreActions = {
zoomOut: (options?: ViewportHelperFunctionOptions) => void;
setMinZoom: (minZoom: number) => void;
setMaxZoom: (maxZoom: number) => void;
setTranslateExtent: (extent: CoordinateExtent) => void;
fitView: (options?: FitViewOptions) => boolean;
updateNodePositions: (
nodeDragItems: NodeDragItem[],
positionChanged?: boolean,
dragging?: boolean
) => void;
updateNodePositions: UpdateNodePositions;
updateNodeDimensions: (updates: NodeDimensionUpdate[]) => void;
resetSelectedElements: () => void;
unselectNodesAndEdges: () => void;
addSelectedNodes: (ids: string[]) => void;
addSelectedEdges: (ids: string[]) => void;
panBy: (delta: XYPosition) => void;
panBy: (delta: XYPosition) => boolean;
updateConnection: (connection: Partial<ConnectionData>) => void;
cancelConnection: () => void;
reset(): void;
@@ -0,0 +1,215 @@
<script lang="ts">
import SvelteFlow, {
SvelteFlowProvider,
Controls,
Panel,
createNodes,
createEdges,
PanOnScrollMode,
Minimap,
type OnMoveEnd,
type Node,
type Edge,
} from '../../lib/index';
const nodes = createNodes([
{
id: '1',
type: 'input',
data: { label: 'Node 1' },
position: { x: 250, y: 5 },
},
{ id: '2', data: { label: 'Node 2' }, position: { x: 100, y: 100 } },
{ id: '3', data: { label: 'Node 3' }, position: { x: 400, y: 100 } },
{ id: '4', data: { label: 'Node 4' }, position: { x: 400, y: 200 } },
]);
const edges = createEdges([
{ id: 'e1-2', source: '1', target: '2', animated: true },
{ id: 'e1-3', source: '1', target: '3' },
]);
const onNodeDragStart = (_: MouseEvent, node: Node) => console.log('drag start', node);
const onNodeDragStop = (_: MouseEvent, node: Node) => console.log('drag stop', node);
const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node);
const onEdgeClick = (_: MouseEvent, edge: Edge) => console.log('click', edge);
const onPaneClick = (event: MouseEvent) => console.log('onPaneClick', event);
const onPaneScroll = (event?: WheelEvent) => console.log('onPaneScroll', event);
const onPaneContextMenu = (event: MouseEvent) => console.log('onPaneContextMenu', event);
const onMoveEnd: OnMoveEnd = (_, viewport) => console.log('onMoveEnd', viewport);
let isSelectable = false;
let isDraggable = false;
let isConnectable = false;
let zoomOnScroll = false;
let zoomOnPinch = false;
let panOnScroll = false;
let panOnScrollMode = PanOnScrollMode.Free;
let zoomOnDoubleClick = false;
let panOnDrag = true;
let captureZoomClick = false;
let captureZoomScroll = false;
let captureElementClick = false;
</script>
<SvelteFlowProvider
{nodes}
{edges}
>
<SvelteFlow
elementsSelectable={isSelectable}
nodesConnectable={isConnectable}
nodesDraggable={isDraggable}
zoomOnScroll={zoomOnScroll}
zoomOnPinch={zoomOnPinch}
panOnScroll={panOnScroll}
panOnScrollMode={panOnScrollMode}
zoomOnDoubleClick={zoomOnDoubleClick}
panOnDrag={panOnDrag}
onMoveEnd={onMoveEnd}
>
<Minimap />
<Controls />
<Panel position="top-right">
<div>
<label for="draggable">
nodesDraggable
<input
id="draggable"
type="checkbox"
bind:checked={isDraggable}
class="react-flow__draggable"
/>
</label>
</div>
<div>
<label for="connectable">
nodesConnectable
<input
id="connectable"
type="checkbox"
bind:checked={isConnectable}
class="react-flow__connectable"
/>
</label>
</div>
<div>
<label for="selectable">
elementsSelectable
<input
id="selectable"
type="checkbox"
bind:checked={isSelectable}
class="react-flow__selectable"
/>
</label>
</div>
<div>
<label for="zoomonscroll">
zoomOnScroll
<input
id="zoomonscroll"
type="checkbox"
bind:checked={zoomOnScroll}
class="react-flow__zoomonscroll"
/>
</label>
</div>
<div>
<label for="zoomonpinch">
zoomOnPinch
<input
id="zoomonpinch"
type="checkbox"
bind:checked={zoomOnPinch}
class="react-flow__zoomonpinch"
/>
</label>
</div>
<div>
<label for="panonscroll">
panOnScroll
<input
id="panonscroll"
type="checkbox"
bind:checked={panOnScroll}
class="react-flow__panonscroll"
/>
</label>
</div>
<div>
<label for="panonscrollmode">
panOnScrollMode
<select
id="panonscrollmode"
bind:value={panOnScrollMode}
on:change={(event) => { panOnScrollMode = PanOnScrollMode.Free }}
class="react-flow__panonscrollmode"
>
<option value="free">free</option>
<option value="horizontal">horizontal</option>
<option value="vertical">vertical</option>
</select>
</label>
</div>
<div>
<label for="zoomondbl">
zoomOnDoubleClick
<input
id="zoomondbl"
type="checkbox"
bind:checked={zoomOnDoubleClick}
class="react-flow__zoomondbl"
/>
</label>
</div>
<div>
<label for="panondrag">
panOnDrag
<input
id="panondrag"
type="checkbox"
bind:checked={panOnDrag}
class="react-flow__panondrag"
/>
</label>
</div>
<div>
<label for="capturezoompaneclick">
capture onPaneClick
<input
id="capturezoompaneclick"
type="checkbox"
bind:checked={captureZoomClick}
class="react-flow__capturezoompaneclick"
/>
</label>
</div>
<div>
<label for="capturezoompanescroll">
capture onPaneScroll
<input
id="capturezoompanescroll"
type="checkbox"
bind:checked={captureZoomScroll}
class="react-flow__capturezoompanescroll"
/>
</label>
</div>
<div>
<label for="captureelementclick">
capture onElementClick
<input
id="captureelementclick"
type="checkbox"
bind:checked={captureElementClick}
class="react-flow__captureelementclick"
/>
</label>
</div>
</Panel>
</SvelteFlow>
</SvelteFlowProvider>
@@ -11,8 +11,8 @@
type Edge
} from '../../lib/index';
const yNodes = 10;
const xNodes = 10;
const yNodes = 20;
const xNodes = 20;
const nodeItems: Node[] = [];
const edgeItems: Edge[] = [];
@@ -55,6 +55,7 @@
>
<SvelteFlow
fitView
minZoom={0.2}
>
<Controls />
<Background variant={BackgroundVariant.Dots} />