From e0ccc0f980f3097564ec8526a8a0599cef2e4da3 Mon Sep 17 00:00:00 2001 From: moklick Date: Mon, 5 Jun 2023 17:32:40 +0200 Subject: [PATCH] feat(utils): vanilla interactive minimap --- package.json | 2 +- .../additional-components/Controls/style.css | 0 .../additional-components/MiniMap/MiniMap.tsx | 102 +++--- .../react/src/components/Edges/wrapEdge.tsx | 6 +- packages/svelte/README.md | 4 +- .../src/lib/plugins/Minimap/Minimap.svelte | 103 +++--- .../svelte/src/lib/plugins/Minimap/index.ts | 2 +- .../src/lib/plugins/Minimap/interactive.ts | 41 +++ .../svelte/src/lib/plugins/Minimap/types.ts | 6 +- .../svelte/src/routes/customnode/+page.svelte | 4 +- .../svelte/src/routes/drag-n-drop/Flow.svelte | 4 +- packages/svelte/src/routes/edges/+page.svelte | 4 +- .../src/routes/interaction/+page.svelte | 4 +- .../svelte/src/routes/overview/+page.svelte | 4 +- .../svelte/src/routes/stress/+page.svelte | 4 +- .../svelte/src/routes/subflows/+page.svelte | 4 +- .../src/routes/usesvelteflow/Flow.svelte | 4 +- packages/system/src/index.ts | 1 + packages/system/src/xyhandle/XYHandle.ts | 311 +++++++++++++++++ packages/system/src/xyhandle/index.ts | 312 +----------------- packages/system/src/xyminimap/index.ts | 104 ++++++ pnpm-lock.yaml | 33 +- 22 files changed, 584 insertions(+), 475 deletions(-) delete mode 100644 packages/react/src/additional-components/Controls/style.css create mode 100644 packages/svelte/src/lib/plugins/Minimap/interactive.ts create mode 100644 packages/system/src/xyhandle/XYHandle.ts create mode 100644 packages/system/src/xyminimap/index.ts diff --git a/package.json b/package.json index b0ee43f8..49679c47 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/react/src/additional-components/Controls/style.css b/packages/react/src/additional-components/Controls/style.css deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/react/src/additional-components/MiniMap/MiniMap.tsx b/packages/react/src/additional-components/MiniMap/MiniMap.tsx index 125f40e9..245f75bf 100644 --- a/packages/react/src/additional-components/MiniMap/MiniMap.tsx +++ b/packages/react/src/additional-components/MiniMap/MiniMap.tsx @@ -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(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(); viewScaleRef.current = viewScale; useEffect(() => { - if (svg.current) { - const selection = select(svg.current as Element); - - const zoomHandler = (event: D3ZoomEvent) => { - 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) => { - 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; diff --git a/packages/react/src/components/Edges/wrapEdge.tsx b/packages/react/src/components/Edges/wrapEdge.tsx index 8fd7db22..8172dfb6 100644 --- a/packages/react/src/components/Edges/wrapEdge.tsx +++ b/packages/react/src/components/Edges/wrapEdge.tsx @@ -98,10 +98,9 @@ export default (EdgeComponent: ComponentType) => { autoPanOnConnect, domNode, edges, - isValidConnection: isValidConnectionStore, + isValidConnection, connectionMode, connectionRadius, - transform, lib, onConnectStart, onConnectEnd, @@ -113,7 +112,6 @@ export default (EdgeComponent: ComponentType) => { 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) => { nodes, isTarget, edgeUpdaterType: handleType, - transform, lib, cancelConnection, panBy, @@ -149,6 +146,7 @@ export default (EdgeComponent: ComponentType) => { onConnectEnd, onEdgeUpdateEnd: _onEdgeUpdateEnd, updateConnection, + getTransform: () => store.getState().transform, }); }; diff --git a/packages/svelte/README.md b/packages/svelte/README.md index bb200bfd..60152e7a 100644 --- a/packages/svelte/README.md +++ b/packages/svelte/README.md @@ -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: > - + ``` diff --git a/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte b/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte index 57c02277..b8749ef8 100644 --- a/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte +++ b/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte @@ -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; - - {#if ariaLabel}{ariaLabel}{/if} + {#if $panZoom} + + {#if ariaLabel}{ariaLabel}{/if} - {#each $nodes as node (node.id)} - {#if node.width && node.height} - {@const pos = getNodePositionWithOrigin(node).positionAbsolute} - - {/if} - {/each} - - + {#each $nodes as node (node.id)} + {#if node.width && node.height} + {@const pos = getNodePositionWithOrigin(node).positionAbsolute} + + {/if} + {/each} + + + {/if} diff --git a/packages/svelte/src/lib/plugins/Minimap/index.ts b/packages/svelte/src/lib/plugins/Minimap/index.ts index b21255c0..192f51ce 100644 --- a/packages/svelte/src/lib/plugins/Minimap/index.ts +++ b/packages/svelte/src/lib/plugins/Minimap/index.ts @@ -1 +1 @@ -export { default as Minimap } from './Minimap.svelte'; +export { default as MiniMap } from './Minimap.svelte'; diff --git a/packages/svelte/src/lib/plugins/Minimap/interactive.ts b/packages/svelte/src/lib/plugins/Minimap/interactive.ts new file mode 100644 index 00000000..9bdbea13 --- /dev/null +++ b/packages/svelte/src/lib/plugins/Minimap/interactive.ts @@ -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; + 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(); + } + }; +} diff --git a/packages/svelte/src/lib/plugins/Minimap/types.ts b/packages/svelte/src/lib/plugins/Minimap/types.ts index b4cb0000..c141aac9 100644 --- a/packages/svelte/src/lib/plugins/Minimap/types.ts +++ b/packages/svelte/src/lib/plugins/Minimap/types.ts @@ -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; }; diff --git a/packages/svelte/src/routes/customnode/+page.svelte b/packages/svelte/src/routes/customnode/+page.svelte index 6cad6815..3a3338fd 100644 --- a/packages/svelte/src/routes/customnode/+page.svelte +++ b/packages/svelte/src/routes/customnode/+page.svelte @@ -6,7 +6,7 @@ Controls, Background, BackgroundVariant, - Minimap, + MiniMap, createNodes, createEdges, type NodeTypes, @@ -108,7 +108,7 @@ > - + diff --git a/packages/svelte/src/routes/drag-n-drop/Flow.svelte b/packages/svelte/src/routes/drag-n-drop/Flow.svelte index 3d3c16c9..7141b5e6 100644 --- a/packages/svelte/src/routes/drag-n-drop/Flow.svelte +++ b/packages/svelte/src/routes/drag-n-drop/Flow.svelte @@ -3,7 +3,7 @@ Controls, Background, BackgroundVariant, - Minimap, + MiniMap, useSvelteFlow, type Node } from '../../lib/index'; @@ -52,7 +52,7 @@ > - + diff --git a/packages/svelte/src/routes/edges/+page.svelte b/packages/svelte/src/routes/edges/+page.svelte index abd48eec..b8788b9a 100644 --- a/packages/svelte/src/routes/edges/+page.svelte +++ b/packages/svelte/src/routes/edges/+page.svelte @@ -4,7 +4,7 @@ Controls, Background, BackgroundVariant, - Minimap, + MiniMap, createNodes, createEdges, type NodeTypes, @@ -154,7 +154,7 @@ > - + diff --git a/packages/svelte/src/routes/interaction/+page.svelte b/packages/svelte/src/routes/interaction/+page.svelte index 34f0be84..6729d0a4 100644 --- a/packages/svelte/src/routes/interaction/+page.svelte +++ b/packages/svelte/src/routes/interaction/+page.svelte @@ -6,7 +6,7 @@ createNodes, createEdges, PanOnScrollMode, - Minimap, + MiniMap, type OnMoveEnd, type Node, type Edge, @@ -70,7 +70,7 @@ panOnDrag={panOnDrag} onMoveEnd={onMoveEnd} > - + diff --git a/packages/svelte/src/routes/overview/+page.svelte b/packages/svelte/src/routes/overview/+page.svelte index 3207b232..ce121917 100644 --- a/packages/svelte/src/routes/overview/+page.svelte +++ b/packages/svelte/src/routes/overview/+page.svelte @@ -4,7 +4,7 @@ Controls, Background, BackgroundVariant, - Minimap, + MiniMap, Panel, createNodes, createEdges, @@ -138,7 +138,7 @@ > - + diff --git a/packages/svelte/src/routes/stress/+page.svelte b/packages/svelte/src/routes/stress/+page.svelte index 68585a83..4a5ce390 100644 --- a/packages/svelte/src/routes/stress/+page.svelte +++ b/packages/svelte/src/routes/stress/+page.svelte @@ -4,7 +4,7 @@ Controls, Background, BackgroundVariant, - Minimap, + MiniMap, createNodes, createEdges, type Node, @@ -59,6 +59,6 @@ > - + \ No newline at end of file diff --git a/packages/svelte/src/routes/subflows/+page.svelte b/packages/svelte/src/routes/subflows/+page.svelte index 9743360a..de115ba2 100644 --- a/packages/svelte/src/routes/subflows/+page.svelte +++ b/packages/svelte/src/routes/subflows/+page.svelte @@ -4,7 +4,7 @@ Controls, Background, BackgroundVariant, - Minimap, + MiniMap, createNodes, createEdges, type NodeTypes, @@ -130,7 +130,7 @@ > - + diff --git a/packages/svelte/src/routes/usesvelteflow/Flow.svelte b/packages/svelte/src/routes/usesvelteflow/Flow.svelte index 5effcafc..fc98236f 100644 --- a/packages/svelte/src/routes/usesvelteflow/Flow.svelte +++ b/packages/svelte/src/routes/usesvelteflow/Flow.svelte @@ -3,7 +3,7 @@ Controls, Background, BackgroundVariant, - Minimap, + MiniMap, } from '../../lib/index'; import Sidebar from './Sidebar.svelte'; @@ -14,7 +14,7 @@ > - + diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts index 2099ffb8..78da8d4f 100644 --- a/packages/system/src/index.ts +++ b/packages/system/src/index.ts @@ -3,4 +3,5 @@ export * from './types'; export * from './utils'; export * from './xydrag'; export * from './xyhandle'; +export * from './xyminimap'; export * from './xypanzoom'; diff --git a/packages/system/src/xyhandle/XYHandle.ts b/packages/system/src/xyhandle/XYHandle.ts new file mode 100644 index 00000000..f459f3f0 --- /dev/null +++ b/packages/system/src/xyhandle/XYHandle.ts @@ -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 | 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, +}; diff --git a/packages/system/src/xyhandle/index.ts b/packages/system/src/xyhandle/index.ts index f459f3f0..b5717582 100644 --- a/packages/system/src/xyhandle/index.ts +++ b/packages/system/src/xyhandle/index.ts @@ -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 | 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'; diff --git a/packages/system/src/xyminimap/index.ts b/packages/system/src/xyminimap/index.ts new file mode 100644 index 00000000..885679b8 --- /dev/null +++ b/packages/system/src/xyminimap/index.ts @@ -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) => { + 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) => { + 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, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d814f277..d122b28d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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