feat(utils): vanilla interactive minimap

This commit is contained in:
moklick
2023-06-05 17:32:40 +02:00
parent 1b4f4a034e
commit e0ccc0f980
22 changed files with 584 additions and 475 deletions

View File

@@ -7,7 +7,7 @@
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "turbo run dev",
"dev": "turbo run dev --parallel",
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint",

View File

@@ -3,14 +3,13 @@
import { memo, useEffect, useRef, type MouseEvent } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import { type D3ZoomEvent, zoom } from 'd3-zoom';
import { select, pointer } from 'd3-selection';
import {
getRectOfNodes,
getBoundsOfRects,
getNodePositionWithOrigin,
CoordinateExtent,
XYMinimap,
type Rect,
type XYMinimapInstance,
} from '@xyflow/system';
import { useStore, useStoreApi } from '../../hooks/useStore';
@@ -40,12 +39,17 @@ const selector = (s: ReactFlowState) => {
boundingRect: nodes.length > 0 ? getBoundsOfRects(getRectOfNodes(nodes, s.nodeOrigin), viewBB) : viewBB,
rfId: s.rfId,
nodeOrigin: s.nodeOrigin,
panZoom: s.panZoom,
translateExtent: s.translateExtent,
flowWidth: s.width,
flowHeight: s.height,
};
};
const getAttrFunction = (func: any): GetMiniMapNodeAttribute => (func instanceof Function ? func : () => func);
const ARIA_LABEL_KEY = 'react-flow__minimap-desc';
function MiniMap({
style,
className,
@@ -66,12 +70,15 @@ function MiniMap({
pannable = false,
zoomable = false,
ariaLabel = 'React Flow mini map',
inversePan = false,
zoomStep = 10,
inversePan,
zoomStep,
}: MiniMapProps) {
const store = useStoreApi();
const svg = useRef<SVGSVGElement>(null);
const { boundingRect, viewBB, nodes, rfId, nodeOrigin } = useStore(selector, shallow);
const { boundingRect, viewBB, nodes, rfId, nodeOrigin, panZoom, translateExtent, flowWidth, flowHeight } = useStore(
selector,
shallow
);
const elementWidth = (style?.width as number) ?? defaultWidth;
const elementHeight = (style?.height as number) ?? defaultHeight;
const nodeColorFunc = getAttrFunction(nodeColor);
@@ -90,76 +97,41 @@ function MiniMap({
const shapeRendering = typeof window === 'undefined' || !!window.chrome ? 'crispEdges' : 'geometricPrecision';
const labelledBy = `${ARIA_LABEL_KEY}-${rfId}`;
const viewScaleRef = useRef(0);
const minimapInstance = useRef<XYMinimapInstance>();
viewScaleRef.current = viewScale;
useEffect(() => {
if (svg.current) {
const selection = select(svg.current as Element);
const zoomHandler = (event: D3ZoomEvent<SVGSVGElement, any>) => {
const { transform, panZoom } = store.getState();
if (event.sourceEvent.type !== 'wheel' || !panZoom) {
return;
}
const pinchDelta =
-event.sourceEvent.deltaY *
(event.sourceEvent.deltaMode === 1 ? 0.05 : event.sourceEvent.deltaMode ? 1 : 0.002) *
zoomStep;
const nextZoom = transform[2] * Math.pow(2, pinchDelta);
panZoom.scaleTo(nextZoom);
};
const panHandler = (event: D3ZoomEvent<HTMLDivElement, any>) => {
const { transform, panZoom, translateExtent, width, height } = store.getState();
if (event.sourceEvent.type !== 'mousemove' || !panZoom) {
return;
}
// @TODO: how to calculate the correct next position? Math.max(1, transform[2]) is a workaround.
const moveScale = viewScaleRef.current * Math.max(1, transform[2]) * (inversePan ? -1 : 1);
const position = {
x: transform[0] - event.sourceEvent.movementX * moveScale,
y: transform[1] - event.sourceEvent.movementY * moveScale,
};
const extent: CoordinateExtent = [
[0, 0],
[width, height],
];
panZoom.setViewportConstrained(
{
x: position.x,
y: position.y,
zoom: transform[2],
},
extent,
translateExtent
);
};
const zoomAndPanHandler = zoom()
// @ts-ignore
.on('zoom', pannable ? panHandler : null)
// @ts-ignore
.on('zoom.wheel', zoomable ? zoomHandler : null);
selection.call(zoomAndPanHandler, {});
if (svg.current && panZoom) {
minimapInstance.current = XYMinimap({
domNode: svg.current,
panZoom,
getTransform: () => store.getState().transform,
getViewScale: () => viewScaleRef.current,
});
return () => {
selection.on('zoom', null);
minimapInstance.current?.destroy();
};
}
}, [pannable, zoomable, inversePan, zoomStep]);
}, [panZoom]);
useEffect(() => {
minimapInstance.current?.update({
translateExtent,
width: flowWidth,
height: flowHeight,
inversePan,
pannable,
zoomStep,
zoomable,
});
}, [pannable, zoomable, inversePan, zoomStep, translateExtent, flowWidth, flowHeight]);
const onSvgClick = onClick
? (event: MouseEvent) => {
const rfCoord = pointer(event);
onClick(event, { x: rfCoord[0], y: rfCoord[1] });
const rfCoord = minimapInstance.current?.pointer(event);
onClick(event, { x: rfCoord?.[0] ?? 0, y: rfCoord?.[1] ?? 0 });
}
: undefined;

View File

@@ -98,10 +98,9 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
autoPanOnConnect,
domNode,
edges,
isValidConnection: isValidConnectionStore,
isValidConnection,
connectionMode,
connectionRadius,
transform,
lib,
onConnectStart,
onConnectEnd,
@@ -113,7 +112,6 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
const nodeId = isSourceHandle ? target : source;
const handleId = (isSourceHandle ? targetHandleId : sourceHandleId) || null;
const handleType = isSourceHandle ? 'target' : 'source';
const isValidConnection = isValidConnectionStore || alwaysValidConnection;
const isTarget = isSourceHandle;
const edge = edges.find((e) => e.id === id)!;
@@ -139,7 +137,6 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
nodes,
isTarget,
edgeUpdaterType: handleType,
transform,
lib,
cancelConnection,
panBy,
@@ -149,6 +146,7 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
onConnectEnd,
onEdgeUpdateEnd: _onEdgeUpdateEnd,
updateConnection,
getTransform: () => store.getState().transform,
});
};

View File

@@ -23,7 +23,7 @@ A basic flow looks like this:
Controls,
Background,
BackgroundVariant,
Minimap,
MiniMap,
Panel,
createNodes,
createEdges,
@@ -73,7 +73,7 @@ A basic flow looks like this:
>
<Controls />
<Background variant={BackgroundVariant.Dots} />
<Minimap />
<MiniMap />
</SvelteFlow>
</SvelteFlowProvider>
```

View File

@@ -12,6 +12,7 @@
import { useStore } from '$lib/store';
import { Panel } from '$lib/container/Panel';
import MinimapNode from './MinimapNode.svelte';
import interactive from './interactive'
import type { GetMiniMapNodeAttribute, MiniMapProps } from './types';
type $$Props = MiniMapProps;
@@ -30,13 +31,17 @@
let maskStrokeWidth: $$Props['maskStrokeWidth'] = 1;
let width: $$Props['width'] = undefined;
let height: $$Props['height'] = undefined;
let pannable: $$Props['pannable'] = true;
let zoomable: $$Props['zoomable'] = true;
let inversePan: $$Props['inversePan'] = undefined;
let zoomStep: $$Props['zoomStep'] = undefined;
let className: $$Props['class'] = '';
export { className as class };
const defaultWidth = 200;
const defaultHeight = 150;
const { nodes, transform, width: containerWidth, height: containerHeight, flowId } = useStore();
const { nodes, transform, width: containerWidth, height: containerHeight, flowId, panZoom, translateExtent } = useStore();
const nodeColorFunc = getAttrFunction(nodeColor);
const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor);
@@ -65,6 +70,8 @@
$: y = boundingRect.y - (viewHeight - boundingRect.height) / 2 - offset;
$: viewboxWidth = viewWidth + offset * 2;
$: viewboxHeight = viewHeight + offset * 2;
const getViewScale = () => viewScale;
</script>
<Panel
@@ -73,44 +80,58 @@
style={`background-color: ${bgColor}; ${style}`}
data-testid="svelte-flow__minimap"
>
<svg
width={elementWidth}
height={elementHeight}
viewBox={`${x} ${y} ${viewboxWidth} ${viewboxHeight}`}
role="img"
aria-labelledby={labelledBy}
>
{#if ariaLabel}<title id={labelledBy}>{ariaLabel}</title>{/if}
{#if $panZoom}
<svg
width={elementWidth}
height={elementHeight}
viewBox={`${x} ${y} ${viewboxWidth} ${viewboxHeight}`}
role="img"
aria-labelledby={labelledBy}
use:interactive={{
panZoom: $panZoom,
transform,
getViewScale,
translateExtent: $translateExtent,
width: $containerWidth,
height: $containerHeight,
inversePan,
zoomStep,
pannable,
zoomable,
}}
>
{#if ariaLabel}<title id={labelledBy}>{ariaLabel}</title>{/if}
{#each $nodes as node (node.id)}
{#if node.width && node.height}
{@const pos = getNodePositionWithOrigin(node).positionAbsolute}
<MinimapNode
x={pos.x}
y={pos.y}
width={node.width}
height={node.height}
color={nodeColorFunc(node)}
borderRadius={nodeBorderRadius}
strokeColor={nodeStrokeColorFunc(node)}
strokeWidth={nodeStrokeWidth}
{shapeRendering}
class={nodeClassNameFunc(node)}
style={{}}
/>
{/if}
{/each}
<path
class="svelte-flow__minimap-mask"
d={`M${x - offset},${y - offset}h${viewboxWidth + offset * 2}v${viewboxHeight + offset * 2}h${
-viewboxWidth - offset * 2
}z
M${viewBB.x},${viewBB.y}h${viewBB.width}v${viewBB.height}h${-viewBB.width}z`}
fill={maskColor}
fill-rule="evenodd"
stroke={maskStrokeColor}
stroke-width={maskStrokeWidth}
pointer-events="none"
/>
</svg>
{#each $nodes as node (node.id)}
{#if node.width && node.height}
{@const pos = getNodePositionWithOrigin(node).positionAbsolute}
<MinimapNode
x={pos.x}
y={pos.y}
width={node.width}
height={node.height}
color={nodeColorFunc(node)}
borderRadius={nodeBorderRadius}
strokeColor={nodeStrokeColorFunc(node)}
strokeWidth={nodeStrokeWidth}
{shapeRendering}
class={nodeClassNameFunc(node)}
style={{}}
/>
{/if}
{/each}
<path
class="svelte-flow__minimap-mask"
d={`M${x - offset},${y - offset}h${viewboxWidth + offset * 2}v${viewboxHeight + offset * 2}h${
-viewboxWidth - offset * 2
}z
M${viewBB.x},${viewBB.y}h${viewBB.width}v${viewBB.height}h${-viewBB.width}z`}
fill={maskColor}
fill-rule="evenodd"
stroke={maskStrokeColor}
stroke-width={maskStrokeWidth}
pointer-events="none"
/>
</svg>
{/if}
</Panel>

View File

@@ -1 +1 @@
export { default as Minimap } from './Minimap.svelte';
export { default as MiniMap } from './Minimap.svelte';

View File

@@ -0,0 +1,41 @@
import { get, type Writable } from 'svelte/store';
import {
XYMinimap,
type PanZoomInstance,
type Transform,
type XYMinimapUpdate
} from '@xyflow/system';
export type UseInteractiveParams = {
panZoom: PanZoomInstance;
transform: Writable<Transform>;
getViewScale: () => number;
} & XYMinimapUpdate;
export default function interactive(domNode: Element, params: UseInteractiveParams) {
const minimap = XYMinimap({
domNode,
panZoom: params.panZoom,
getTransform: () => get(params.transform),
getViewScale: params.getViewScale
});
function update(params: UseInteractiveParams) {
minimap.update({
translateExtent: params.translateExtent,
width: params.width,
height: params.height,
inversePan: params.inversePan,
zoomStep: params.zoomStep,
pannable: params.pannable,
zoomable: params.zoomable
});
}
return {
update,
destroy() {
minimap.destroy();
}
};
}

View File

@@ -22,6 +22,8 @@ export type MiniMapProps = {
height?: number;
// onClick?: (event: MouseEvent, position: XYPosition) => void;
// onNodeClick?: (event: MouseEvent, node: Node) => void;
// pannable?: boolean;
// zoomable?: boolean;
pannable?: boolean;
zoomable?: boolean;
inversePan?: boolean;
zoomStep?: number;
};

View File

@@ -6,7 +6,7 @@
Controls,
Background,
BackgroundVariant,
Minimap,
MiniMap,
createNodes,
createEdges,
type NodeTypes,
@@ -108,7 +108,7 @@
>
<Controls />
<Background variant={BackgroundVariant.Dots} />
<Minimap />
<MiniMap />
</SvelteFlow>
</SvelteFlowProvider>

View File

@@ -3,7 +3,7 @@
Controls,
Background,
BackgroundVariant,
Minimap,
MiniMap,
useSvelteFlow,
type Node
} from '../../lib/index';
@@ -52,7 +52,7 @@
>
<Controls />
<Background variant={BackgroundVariant.Dots} />
<Minimap />
<MiniMap />
</SvelteFlow>
<Sidebar />
</main>

View File

@@ -4,7 +4,7 @@
Controls,
Background,
BackgroundVariant,
Minimap,
MiniMap,
createNodes,
createEdges,
type NodeTypes,
@@ -154,7 +154,7 @@
>
<Controls />
<Background variant={BackgroundVariant.Dots} />
<Minimap />
<MiniMap />
</SvelteFlow>
</SvelteFlowProvider>

View File

@@ -6,7 +6,7 @@
createNodes,
createEdges,
PanOnScrollMode,
Minimap,
MiniMap,
type OnMoveEnd,
type Node,
type Edge,
@@ -70,7 +70,7 @@
panOnDrag={panOnDrag}
onMoveEnd={onMoveEnd}
>
<Minimap />
<MiniMap />
<Controls />
<Panel position="top-right">

View File

@@ -4,7 +4,7 @@
Controls,
Background,
BackgroundVariant,
Minimap,
MiniMap,
Panel,
createNodes,
createEdges,
@@ -138,7 +138,7 @@
>
<Controls />
<Background variant={BackgroundVariant.Dots} />
<Minimap />
<MiniMap />
<Panel position="top-right">
<button on:click={updateNode}>update node pos</button>
</Panel>

View File

@@ -4,7 +4,7 @@
Controls,
Background,
BackgroundVariant,
Minimap,
MiniMap,
createNodes,
createEdges,
type Node,
@@ -59,6 +59,6 @@
>
<Controls />
<Background variant={BackgroundVariant.Dots} />
<Minimap />
<MiniMap />
</SvelteFlow>
</SvelteFlowProvider>

View File

@@ -4,7 +4,7 @@
Controls,
Background,
BackgroundVariant,
Minimap,
MiniMap,
createNodes,
createEdges,
type NodeTypes,
@@ -130,7 +130,7 @@
>
<Controls />
<Background variant={BackgroundVariant.Dots} />
<Minimap />
<MiniMap />
</SvelteFlow>
</SvelteFlowProvider>

View File

@@ -3,7 +3,7 @@
Controls,
Background,
BackgroundVariant,
Minimap,
MiniMap,
} from '../../lib/index';
import Sidebar from './Sidebar.svelte';
</script>
@@ -14,7 +14,7 @@
>
<Controls />
<Background variant={BackgroundVariant.Dots} />
<Minimap />
<MiniMap />
</SvelteFlow>
<Sidebar />
</main>

View File

@@ -3,4 +3,5 @@ export * from './types';
export * from './utils';
export * from './xydrag';
export * from './xyhandle';
export * from './xyminimap';
export * from './xypanzoom';

View File

@@ -0,0 +1,311 @@
import { pointToRendererPoint, rendererPointToPoint, getHostForElement, calcAutoPan, getEventPosition } from '../utils';
import {
ConnectionMode,
type OnConnect,
type OnConnectStart,
type HandleType,
type Connection,
type PanBy,
type BaseNode,
type Transform,
type ConnectingHandle,
type OnConnectEnd,
type UpdateConnection,
type IsValidConnection,
type ConnectionHandle,
} from '../types';
import { getClosestHandle, getConnectionStatus, getHandleLookup, getHandleType, resetRecentHandle } from './utils';
export type OnPointerDownParams = {
autoPanOnConnect: boolean;
connectionMode: ConnectionMode;
connectionRadius: number;
domNode: HTMLDivElement | null;
handleId: string | null;
nodeId: string;
isTarget: boolean;
nodes: BaseNode[];
lib: string;
edgeUpdaterType?: HandleType;
updateConnection: UpdateConnection;
panBy: PanBy;
cancelConnection: () => void;
onConnectStart?: OnConnectStart;
onConnect?: OnConnect;
onConnectEnd?: OnConnectEnd;
isValidConnection?: IsValidConnection;
onEdgeUpdateEnd?: (evt: MouseEvent | TouchEvent) => void;
getTransform: () => Transform;
};
export type IsValidParams = {
handle: Pick<ConnectionHandle, 'nodeId' | 'id' | 'type'> | null;
connectionMode: ConnectionMode;
fromNodeId: string;
fromHandleId: string | null;
fromType: HandleType;
isValidConnection?: IsValidConnection;
doc: Document | ShadowRoot;
lib: string;
};
export type XYHandleInstance = {
onPointerDown: (event: MouseEvent | TouchEvent, params: OnPointerDownParams) => void;
isValid: (event: MouseEvent | TouchEvent, params: IsValidParams) => Result;
};
type Result = {
handleDomNode: Element | null;
isValid: boolean;
connection: Connection;
endHandle: ConnectingHandle | null;
};
const nullConnection: Connection = { source: null, target: null, sourceHandle: null, targetHandle: null };
const alwaysValid = () => true;
function onPointerDown(
event: MouseEvent | TouchEvent,
{
connectionMode,
connectionRadius,
handleId,
nodeId,
edgeUpdaterType,
isTarget,
domNode,
nodes,
lib,
autoPanOnConnect,
panBy,
cancelConnection,
onConnectStart,
onConnect,
onConnectEnd,
isValidConnection = alwaysValid,
onEdgeUpdateEnd,
updateConnection,
getTransform,
}: OnPointerDownParams
) {
// when react-flow is used inside a shadow root we can't use document
const doc = getHostForElement(event.target as HTMLElement);
let autoPanId = 0;
let closestHandle: ConnectionHandle | null;
const { x, y } = getEventPosition(event);
const clickedHandle = doc?.elementFromPoint(x, y);
const handleType = getHandleType(edgeUpdaterType, clickedHandle);
const containerBounds = domNode?.getBoundingClientRect();
if (!containerBounds || !handleType) {
return;
}
let prevActiveHandle: Element;
let connectionPosition = getEventPosition(event, containerBounds);
let autoPanStarted = false;
let connection: Connection | null = null;
let isValid = false;
let handleDomNode: Element | null = null;
const handleLookup = getHandleLookup({
nodes,
nodeId,
handleId,
handleType,
});
// when the user is moving the mouse close to the edge of the canvas while connecting we move the canvas
function autoPan(): void {
if (!autoPanOnConnect || !containerBounds) {
return;
}
const [x, y] = calcAutoPan(connectionPosition, containerBounds);
panBy({ x, y });
autoPanId = requestAnimationFrame(autoPan);
}
updateConnection({
connectionPosition,
connectionStatus: null,
// connectionNodeId etc will be removed in the next major in favor of connectionStartHandle
connectionStartHandle: {
nodeId,
handleId,
type: handleType,
},
connectionEndHandle: null,
});
onConnectStart?.(event, { nodeId, handleId, handleType });
function onPointerMove(event: MouseEvent | TouchEvent) {
const transform = getTransform();
connectionPosition = getEventPosition(event, containerBounds);
closestHandle = getClosestHandle(
pointToRendererPoint(connectionPosition, transform, false, [1, 1]),
connectionRadius,
handleLookup
);
if (!autoPanStarted) {
autoPan();
autoPanStarted = true;
}
const result = isValidHandle(event, {
handle: closestHandle,
connectionMode,
fromNodeId: nodeId,
fromHandleId: handleId,
fromType: isTarget ? 'target' : 'source',
isValidConnection,
doc,
lib,
});
handleDomNode = result.handleDomNode;
connection = result.connection;
isValid = result.isValid;
updateConnection({
connectionPosition:
closestHandle && isValid
? rendererPointToPoint(
{
x: closestHandle.x,
y: closestHandle.y,
},
transform
)
: connectionPosition,
connectionStatus: getConnectionStatus(!!closestHandle, isValid),
connectionEndHandle: result.endHandle,
});
if (!closestHandle && !isValid && !handleDomNode) {
return resetRecentHandle(prevActiveHandle, lib);
}
if (connection.source !== connection.target && handleDomNode) {
resetRecentHandle(prevActiveHandle, lib);
prevActiveHandle = handleDomNode;
// @todo: remove the old class names "react-flow__handle-" in the next major version
handleDomNode.classList.add('connecting', `${lib}-flow__handle-connecting`);
handleDomNode.classList.toggle('valid', isValid);
handleDomNode.classList.toggle(`${lib}-flow__handle-valid`, isValid);
}
}
function onPointerUp(event: MouseEvent | TouchEvent) {
if ((closestHandle || handleDomNode) && connection && isValid) {
onConnect?.(connection);
}
// it's important to get a fresh reference from the store here
// in order to get the latest state of onConnectEnd
onConnectEnd?.(event);
if (edgeUpdaterType) {
onEdgeUpdateEnd?.(event);
}
resetRecentHandle(prevActiveHandle, lib);
cancelConnection();
cancelAnimationFrame(autoPanId);
autoPanStarted = false;
isValid = false;
connection = null;
handleDomNode = null;
doc.removeEventListener('mousemove', onPointerMove as EventListener);
doc.removeEventListener('mouseup', onPointerUp as EventListener);
doc.removeEventListener('touchmove', onPointerMove as EventListener);
doc.removeEventListener('touchend', onPointerUp as EventListener);
}
doc.addEventListener('mousemove', onPointerMove as EventListener);
doc.addEventListener('mouseup', onPointerUp as EventListener);
doc.addEventListener('touchmove', onPointerMove as EventListener);
doc.addEventListener('touchend', onPointerUp as EventListener);
}
// checks if and returns connection in fom of an object { source: 123, target: 312 }
function isValidHandle(
event: MouseEvent | TouchEvent,
{
handle,
connectionMode,
fromNodeId,
fromHandleId,
fromType,
doc,
lib,
isValidConnection = alwaysValid,
}: IsValidParams
) {
const isTarget = fromType === 'target';
const handleDomNode = doc.querySelector(
`.${lib}-flow__handle[data-id="${handle?.nodeId}-${handle?.id}-${handle?.type}"]`
);
const { x, y } = getEventPosition(event);
const handleBelow = doc.elementFromPoint(x, y);
// we always want to prioritize the handle below the mouse cursor over the closest distance handle,
// because it could be that the center of another handle is closer to the mouse pointer than the handle below the cursor
const handleToCheck = handleBelow?.classList.contains(`${lib}-flow__handle`) ? handleBelow : handleDomNode;
const result: Result = {
handleDomNode: handleToCheck,
isValid: false,
connection: nullConnection,
endHandle: null,
};
if (handleToCheck) {
const handleType = getHandleType(undefined, handleToCheck);
const handleNodeId = handleToCheck.getAttribute('data-nodeid');
const handleId = handleToCheck.getAttribute('data-handleid');
const connectable = handleToCheck.classList.contains('connectable');
const connectableEnd = handleToCheck.classList.contains('connectableend');
const connection: Connection = {
source: isTarget ? handleNodeId : fromNodeId,
sourceHandle: isTarget ? handleId : fromHandleId,
target: isTarget ? fromNodeId : handleNodeId,
targetHandle: isTarget ? fromHandleId : handleId,
};
result.connection = connection;
const isConnectable = connectable && connectableEnd;
// in strict mode we don't allow target to target or source to source connections
const isValid =
isConnectable &&
(connectionMode === ConnectionMode.Strict
? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target')
: handleNodeId !== fromNodeId || handleId !== fromHandleId);
if (isValid) {
result.endHandle = {
nodeId: handleNodeId as string,
handleId,
type: handleType as HandleType,
};
result.isValid = isValidConnection(connection);
}
}
return result;
}
export const XYHandle: XYHandleInstance = {
onPointerDown,
isValid: isValidHandle,
};

View File

@@ -1,311 +1 @@
import { pointToRendererPoint, rendererPointToPoint, getHostForElement, calcAutoPan, getEventPosition } from '../utils';
import {
ConnectionMode,
type OnConnect,
type OnConnectStart,
type HandleType,
type Connection,
type PanBy,
type BaseNode,
type Transform,
type ConnectingHandle,
type OnConnectEnd,
type UpdateConnection,
type IsValidConnection,
type ConnectionHandle,
} from '../types';
import { getClosestHandle, getConnectionStatus, getHandleLookup, getHandleType, resetRecentHandle } from './utils';
export type OnPointerDownParams = {
autoPanOnConnect: boolean;
connectionMode: ConnectionMode;
connectionRadius: number;
domNode: HTMLDivElement | null;
handleId: string | null;
nodeId: string;
isTarget: boolean;
nodes: BaseNode[];
lib: string;
edgeUpdaterType?: HandleType;
updateConnection: UpdateConnection;
panBy: PanBy;
cancelConnection: () => void;
onConnectStart?: OnConnectStart;
onConnect?: OnConnect;
onConnectEnd?: OnConnectEnd;
isValidConnection?: IsValidConnection;
onEdgeUpdateEnd?: (evt: MouseEvent | TouchEvent) => void;
getTransform: () => Transform;
};
export type IsValidParams = {
handle: Pick<ConnectionHandle, 'nodeId' | 'id' | 'type'> | null;
connectionMode: ConnectionMode;
fromNodeId: string;
fromHandleId: string | null;
fromType: HandleType;
isValidConnection?: IsValidConnection;
doc: Document | ShadowRoot;
lib: string;
};
export type XYHandleInstance = {
onPointerDown: (event: MouseEvent | TouchEvent, params: OnPointerDownParams) => void;
isValid: (event: MouseEvent | TouchEvent, params: IsValidParams) => Result;
};
type Result = {
handleDomNode: Element | null;
isValid: boolean;
connection: Connection;
endHandle: ConnectingHandle | null;
};
const nullConnection: Connection = { source: null, target: null, sourceHandle: null, targetHandle: null };
const alwaysValid = () => true;
function onPointerDown(
event: MouseEvent | TouchEvent,
{
connectionMode,
connectionRadius,
handleId,
nodeId,
edgeUpdaterType,
isTarget,
domNode,
nodes,
lib,
autoPanOnConnect,
panBy,
cancelConnection,
onConnectStart,
onConnect,
onConnectEnd,
isValidConnection = alwaysValid,
onEdgeUpdateEnd,
updateConnection,
getTransform,
}: OnPointerDownParams
) {
// when react-flow is used inside a shadow root we can't use document
const doc = getHostForElement(event.target as HTMLElement);
let autoPanId = 0;
let closestHandle: ConnectionHandle | null;
const { x, y } = getEventPosition(event);
const clickedHandle = doc?.elementFromPoint(x, y);
const handleType = getHandleType(edgeUpdaterType, clickedHandle);
const containerBounds = domNode?.getBoundingClientRect();
if (!containerBounds || !handleType) {
return;
}
let prevActiveHandle: Element;
let connectionPosition = getEventPosition(event, containerBounds);
let autoPanStarted = false;
let connection: Connection | null = null;
let isValid = false;
let handleDomNode: Element | null = null;
const handleLookup = getHandleLookup({
nodes,
nodeId,
handleId,
handleType,
});
// when the user is moving the mouse close to the edge of the canvas while connecting we move the canvas
function autoPan(): void {
if (!autoPanOnConnect || !containerBounds) {
return;
}
const [x, y] = calcAutoPan(connectionPosition, containerBounds);
panBy({ x, y });
autoPanId = requestAnimationFrame(autoPan);
}
updateConnection({
connectionPosition,
connectionStatus: null,
// connectionNodeId etc will be removed in the next major in favor of connectionStartHandle
connectionStartHandle: {
nodeId,
handleId,
type: handleType,
},
connectionEndHandle: null,
});
onConnectStart?.(event, { nodeId, handleId, handleType });
function onPointerMove(event: MouseEvent | TouchEvent) {
const transform = getTransform();
connectionPosition = getEventPosition(event, containerBounds);
closestHandle = getClosestHandle(
pointToRendererPoint(connectionPosition, transform, false, [1, 1]),
connectionRadius,
handleLookup
);
if (!autoPanStarted) {
autoPan();
autoPanStarted = true;
}
const result = isValidHandle(event, {
handle: closestHandle,
connectionMode,
fromNodeId: nodeId,
fromHandleId: handleId,
fromType: isTarget ? 'target' : 'source',
isValidConnection,
doc,
lib,
});
handleDomNode = result.handleDomNode;
connection = result.connection;
isValid = result.isValid;
updateConnection({
connectionPosition:
closestHandle && isValid
? rendererPointToPoint(
{
x: closestHandle.x,
y: closestHandle.y,
},
transform
)
: connectionPosition,
connectionStatus: getConnectionStatus(!!closestHandle, isValid),
connectionEndHandle: result.endHandle,
});
if (!closestHandle && !isValid && !handleDomNode) {
return resetRecentHandle(prevActiveHandle, lib);
}
if (connection.source !== connection.target && handleDomNode) {
resetRecentHandle(prevActiveHandle, lib);
prevActiveHandle = handleDomNode;
// @todo: remove the old class names "react-flow__handle-" in the next major version
handleDomNode.classList.add('connecting', `${lib}-flow__handle-connecting`);
handleDomNode.classList.toggle('valid', isValid);
handleDomNode.classList.toggle(`${lib}-flow__handle-valid`, isValid);
}
}
function onPointerUp(event: MouseEvent | TouchEvent) {
if ((closestHandle || handleDomNode) && connection && isValid) {
onConnect?.(connection);
}
// it's important to get a fresh reference from the store here
// in order to get the latest state of onConnectEnd
onConnectEnd?.(event);
if (edgeUpdaterType) {
onEdgeUpdateEnd?.(event);
}
resetRecentHandle(prevActiveHandle, lib);
cancelConnection();
cancelAnimationFrame(autoPanId);
autoPanStarted = false;
isValid = false;
connection = null;
handleDomNode = null;
doc.removeEventListener('mousemove', onPointerMove as EventListener);
doc.removeEventListener('mouseup', onPointerUp as EventListener);
doc.removeEventListener('touchmove', onPointerMove as EventListener);
doc.removeEventListener('touchend', onPointerUp as EventListener);
}
doc.addEventListener('mousemove', onPointerMove as EventListener);
doc.addEventListener('mouseup', onPointerUp as EventListener);
doc.addEventListener('touchmove', onPointerMove as EventListener);
doc.addEventListener('touchend', onPointerUp as EventListener);
}
// checks if and returns connection in fom of an object { source: 123, target: 312 }
function isValidHandle(
event: MouseEvent | TouchEvent,
{
handle,
connectionMode,
fromNodeId,
fromHandleId,
fromType,
doc,
lib,
isValidConnection = alwaysValid,
}: IsValidParams
) {
const isTarget = fromType === 'target';
const handleDomNode = doc.querySelector(
`.${lib}-flow__handle[data-id="${handle?.nodeId}-${handle?.id}-${handle?.type}"]`
);
const { x, y } = getEventPosition(event);
const handleBelow = doc.elementFromPoint(x, y);
// we always want to prioritize the handle below the mouse cursor over the closest distance handle,
// because it could be that the center of another handle is closer to the mouse pointer than the handle below the cursor
const handleToCheck = handleBelow?.classList.contains(`${lib}-flow__handle`) ? handleBelow : handleDomNode;
const result: Result = {
handleDomNode: handleToCheck,
isValid: false,
connection: nullConnection,
endHandle: null,
};
if (handleToCheck) {
const handleType = getHandleType(undefined, handleToCheck);
const handleNodeId = handleToCheck.getAttribute('data-nodeid');
const handleId = handleToCheck.getAttribute('data-handleid');
const connectable = handleToCheck.classList.contains('connectable');
const connectableEnd = handleToCheck.classList.contains('connectableend');
const connection: Connection = {
source: isTarget ? handleNodeId : fromNodeId,
sourceHandle: isTarget ? handleId : fromHandleId,
target: isTarget ? fromNodeId : handleNodeId,
targetHandle: isTarget ? fromHandleId : handleId,
};
result.connection = connection;
const isConnectable = connectable && connectableEnd;
// in strict mode we don't allow target to target or source to source connections
const isValid =
isConnectable &&
(connectionMode === ConnectionMode.Strict
? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target')
: handleNodeId !== fromNodeId || handleId !== fromHandleId);
if (isValid) {
result.endHandle = {
nodeId: handleNodeId as string,
handleId,
type: handleType as HandleType,
};
result.isValid = isValidConnection(connection);
}
}
return result;
}
export const XYHandle: XYHandleInstance = {
onPointerDown,
isValid: isValidHandle,
};
export * from './XYHandle';

View File

@@ -0,0 +1,104 @@
import { type D3ZoomEvent, zoom } from 'd3-zoom';
import { select, pointer } from 'd3-selection';
import type { CoordinateExtent, PanZoomInstance, Transform } from '../types';
export type XYMinimapInstance = {
update: (params: XYMinimapUpdate) => void;
destroy: () => void;
pointer: typeof pointer;
};
export type XYMinimapParams = {
panZoom: PanZoomInstance;
domNode: Element;
getTransform: () => Transform;
getViewScale: () => number;
};
export type XYMinimapUpdate = {
translateExtent: CoordinateExtent;
width: number;
height: number;
inversePan?: boolean;
zoomStep?: number;
pannable?: boolean;
zoomable?: boolean;
};
export function XYMinimap({ domNode, panZoom, getTransform, getViewScale }: XYMinimapParams) {
const selection = select(domNode);
function update({
translateExtent,
width,
height,
zoomStep = 10,
pannable = true,
zoomable = true,
inversePan = false,
}: XYMinimapUpdate) {
const zoomHandler = (event: D3ZoomEvent<SVGSVGElement, any>) => {
const transform = getTransform();
if (event.sourceEvent.type !== 'wheel' || !panZoom) {
return;
}
const pinchDelta =
-event.sourceEvent.deltaY *
(event.sourceEvent.deltaMode === 1 ? 0.05 : event.sourceEvent.deltaMode ? 1 : 0.002) *
zoomStep;
const nextZoom = transform[2] * Math.pow(2, pinchDelta);
panZoom.scaleTo(nextZoom);
};
const panHandler = (event: D3ZoomEvent<HTMLDivElement, any>) => {
const transform = getTransform();
if (event.sourceEvent.type !== 'mousemove' || !panZoom) {
return;
}
// @TODO: how to calculate the correct next position? Math.max(1, transform[2]) is a workaround.
const moveScale = getViewScale() * Math.max(1, transform[2]) * (inversePan ? -1 : 1);
const position = {
x: transform[0] - event.sourceEvent.movementX * moveScale,
y: transform[1] - event.sourceEvent.movementY * moveScale,
};
const extent: CoordinateExtent = [
[0, 0],
[width, height],
];
panZoom.setViewportConstrained(
{
x: position.x,
y: position.y,
zoom: transform[2],
},
extent,
translateExtent
);
};
const zoomAndPanHandler = zoom()
// @ts-ignore
.on('zoom', pannable ? panHandler : null)
// @ts-ignore
.on('zoom.wheel', zoomable ? zoomHandler : null);
selection.call(zoomAndPanHandler, {});
}
function destroy() {
selection.on('zoom', null);
}
return {
update,
destroy,
pointer,
};
}

33
pnpm-lock.yaml generated
View File

@@ -61,7 +61,6 @@ importers:
'@vitejs/plugin-react': 4.0.0
'@vitejs/plugin-react-swc': ^3.3.2
'@xyflow/react': workspace:*
'@xyflow/test': workspace:^0.0.1
classcat: ^5.0.3
cypress: ^10.6.0
cypress-real-events: ^1.7.1
@@ -76,7 +75,6 @@ importers:
zustand: ^4.3.1
dependencies:
'@xyflow/react': link:../../packages/react
'@xyflow/test': link:../../packages/test
classcat: registry.npmjs.org/classcat/5.0.4
dagre: registry.npmjs.org/dagre/0.8.5
localforage: registry.npmjs.org/localforage/1.10.0
@@ -224,35 +222,6 @@ importers:
'@xyflow/tsconfig': link:../../tooling/tsconfig
typescript: registry.npmjs.org/typescript/4.9.5
packages/test:
specifiers:
'@types/d3': ^7.4.0
'@types/d3-drag': ^3.0.1
'@types/d3-selection': ^3.0.3
'@types/d3-zoom': ^3.0.1
'@types/node': ^18.7.16
'@xyflow/eslint-config': workspace:*
'@xyflow/rollup-config': workspace:*
'@xyflow/tsconfig': workspace:*
d3-drag: ^3.0.0
d3-selection: ^3.0.0
d3-zoom: ^3.0.0
typescript: ^4.9.4
dependencies:
'@types/d3': registry.npmjs.org/@types/d3/7.4.0
'@types/d3-drag': registry.npmjs.org/@types/d3-drag/3.0.2
'@types/d3-selection': registry.npmjs.org/@types/d3-selection/3.0.5
'@types/d3-zoom': registry.npmjs.org/@types/d3-zoom/3.0.3
d3-drag: registry.npmjs.org/d3-drag/3.0.0
d3-selection: registry.npmjs.org/d3-selection/3.0.0
d3-zoom: registry.npmjs.org/d3-zoom/3.0.0
devDependencies:
'@types/node': registry.npmjs.org/@types/node/18.16.16
'@xyflow/eslint-config': link:../../tooling/eslint-config
'@xyflow/rollup-config': link:../../tooling/rollup-config
'@xyflow/tsconfig': link:../../tooling/tsconfig
typescript: registry.npmjs.org/typescript/4.9.5
tooling/eslint-config:
specifiers:
eslint: ^8.22.0
@@ -2083,7 +2052,7 @@ packages:
version: 2.10.0
requiresBuild: true
dependencies:
'@types/node': registry.npmjs.org/@types/node/18.16.16
'@types/node': registry.npmjs.org/@types/node/14.18.48
dev: true
optional: true