diff --git a/packages/core/src/container/ZoomPane/index.tsx b/packages/core/src/container/ZoomPane/index.tsx index cfe79418..4d8025a6 100644 --- a/packages/core/src/container/ZoomPane/index.tsx +++ b/packages/core/src/container/ZoomPane/index.tsx @@ -1,10 +1,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { useEffect, useRef } from 'react'; -import { zoom, zoomIdentity, type D3ZoomEvent } from 'd3-zoom'; -import { select, pointer } from 'd3-selection'; import { shallow } from 'zustand/shallow'; -import { clamp } from '@reactflow/utils'; -import { CoordinateExtent, PanOnScrollMode, type Viewport } from '@reactflow/system'; +import { + initPanZoom, + createPanOnScrollHandler, + createZoomOnScrollHandler, + createFilter, + createPanZoomStartHandler, + ZoomPanValues, + createPanZoomHandler, + createPanZoomEndHandler, +} from '@reactflow/utils'; +import { PanOnScrollMode, Transform } from '@reactflow/system'; import useKeyPress from '../../hooks/useKeyPress'; import useResizeHandler from '../../hooks/useResizeHandler'; @@ -23,20 +30,6 @@ type ZoomPaneProps = Omit< | 'selectionOnDrag' >; -const viewChanged = (prevViewport: Viewport, eventViewport: any): boolean => - prevViewport.x !== eventViewport.x || prevViewport.y !== eventViewport.y || prevViewport.zoom !== eventViewport.k; - -const eventToFlowTransform = (eventViewport: any): Viewport => ({ - x: eventViewport.x, - y: eventViewport.y, - zoom: eventViewport.k, -}); - -const isWrappedWithClass = (event: any, className: string | undefined) => event.target.closest(`.${className}`); - -const isRightClickPan = (panOnDrag: FlowRendererProps['panOnDrag'], usedButton: number) => - usedButton === 2 && Array.isArray(panOnDrag) && panOnDrag.includes(2); - const selector = (s: ReactFlowState) => ({ d3Zoom: s.d3Zoom, d3Selection: s.d3Selection, @@ -67,40 +60,36 @@ const ZoomPane = ({ noWheelClassName, noPanClassName, }: ZoomPaneProps) => { - const timerId = useRef>(); const store = useStoreApi(); - const isZoomingOrPanning = useRef(false); - const zoomedWithRightMouseButton = useRef(false); const zoomPane = useRef(null); - const prevTransform = useRef({ x: 0, y: 0, zoom: 0 }); const { d3Zoom, d3Selection, d3ZoomHandler, userSelectionActive } = useStore(selector, shallow); const zoomActivationKeyPressed = useKeyPress(zoomActivationKeyCode); - const mouseButton = useRef(0); + + const zoomPanValues = useRef({ + isZoomingOrPanning: false, + zoomedWithRightMouseButton: false, + prevTransform: { x: 0, y: 0, zoom: 0 }, + mouseButton: 0, + timerId: undefined, + }); useResizeHandler(zoomPane); useEffect(() => { if (zoomPane.current) { - const bbox = zoomPane.current.getBoundingClientRect(); - const d3ZoomInstance = zoom().scaleExtent([minZoom, maxZoom]).translateExtent(translateExtent); - const selection = select(zoomPane.current as Element).call(d3ZoomInstance); - const updatedTransform = zoomIdentity - .translate(defaultViewport.x, defaultViewport.y) - .scale(clamp(defaultViewport.zoom, minZoom, maxZoom)); - const extent: CoordinateExtent = [ - [0, 0], - [bbox.width, bbox.height], - ]; - - const constrainedTransform = d3ZoomInstance.constrain()(updatedTransform, extent, translateExtent); - d3ZoomInstance.transform(selection, constrainedTransform); + const { d3ZoomInstance, d3Selection, d3ZoomHandler, transform } = initPanZoom({ + domNode: zoomPane.current, + minZoom, + maxZoom, + translateExtent, + defaultViewport, + }); store.setState({ d3Zoom: d3ZoomInstance, - d3Selection: selection, - d3ZoomHandler: selection.on('wheel.zoom'), - // we need to pass transform because zoom handler is not registered when we set the initial transform - transform: [constrainedTransform.x, constrainedTransform.y, constrainedTransform.k], + d3Selection, + d3ZoomHandler, + transform, domNode: zoomPane.current.closest('.react-flow') as HTMLDivElement, }); } @@ -109,46 +98,22 @@ const ZoomPane = ({ useEffect(() => { if (d3Selection && d3Zoom) { if (panOnScroll && !zoomActivationKeyPressed && !userSelectionActive) { - d3Selection.on('wheel.zoom', (event: any) => { - if (isWrappedWithClass(event, noWheelClassName)) { - return false; - } - event.preventDefault(); - event.stopImmediatePropagation(); - - const currentZoom = d3Selection.property('__zoom').k || 1; - - if (event.ctrlKey && zoomOnPinch) { - const point = pointer(event); - // taken from https://github.com/d3/d3-zoom/blob/master/src/zoom.js - const pinchDelta = -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * 10; - const zoom = currentZoom * Math.pow(2, pinchDelta); - d3Zoom.scaleTo(d3Selection, zoom, point); - - return; - } - - // increase scroll speed in firefox - // firefox: deltaMode === 1; chrome: deltaMode === 0 - const deltaNormalize = event.deltaMode === 1 ? 20 : 1; - const deltaX = panOnScrollMode === PanOnScrollMode.Vertical ? 0 : event.deltaX * deltaNormalize; - const deltaY = panOnScrollMode === PanOnScrollMode.Horizontal ? 0 : event.deltaY * deltaNormalize; - - d3Zoom.translateBy( - d3Selection, - -(deltaX / currentZoom) * panOnScrollSpeed, - -(deltaY / currentZoom) * panOnScrollSpeed - ); - }, { passive: false }); - } else if (typeof d3ZoomHandler !== 'undefined') { - d3Selection.on('wheel.zoom', function (event: any, d: any) { - if (!preventScrolling || isWrappedWithClass(event, noWheelClassName)) { - return null; - } - - event.preventDefault(); - d3ZoomHandler.call(this, event, d); - }, { passive: false }); + const panOnScrollHandler = createPanOnScrollHandler({ + noWheelClassName, + d3Selection, + d3Zoom, + panOnScrollMode, + panOnScrollSpeed, + zoomOnPinch, + }); + d3Selection.on('wheel.zoom', panOnScrollHandler, { passive: false }); + } else if (d3ZoomHandler !== null) { + const zoomOnScrollHandler = createZoomOnScrollHandler({ + noWheelClassName, + preventScrolling, + d3ZoomHandler, + }); + d3Selection.on('wheel.zoom', zoomOnScrollHandler, { passive: false }); } } }, [ @@ -166,162 +131,63 @@ const ZoomPane = ({ useEffect(() => { if (d3Zoom) { - d3Zoom.on('start', (event: D3ZoomEvent) => { - if (!event.sourceEvent) { - return null; - } - - // we need to remember it here, because it's always 0 in the "zoom" event - mouseButton.current = event.sourceEvent.button; - - const { onViewportChangeStart } = store.getState(); - isZoomingOrPanning.current = true; - - if (event.sourceEvent?.type === 'mousedown') { - store.setState({ paneDragging: true }); - } - - if (onMoveStart || onViewportChangeStart) { - const flowTransform = eventToFlowTransform(event.transform); - prevTransform.current = flowTransform; - - onViewportChangeStart?.(flowTransform); - onMoveStart?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform); - } + const startHandler = createPanZoomStartHandler({ + zoomPanValues: zoomPanValues.current, + setDragging: (paneDragging: boolean) => store.setState({ paneDragging }), + onMoveStart, + onViewportChangeStart: store.getState().onViewportChangeStart, }); + d3Zoom.on('start', startHandler); } }, [d3Zoom, onMoveStart]); useEffect(() => { if (d3Zoom) { - if (userSelectionActive && !isZoomingOrPanning.current) { + if (userSelectionActive && !zoomPanValues.current.isZoomingOrPanning) { d3Zoom.on('zoom', null); } else if (!userSelectionActive) { - d3Zoom.on('zoom', (event: D3ZoomEvent) => { - const { onViewportChange } = store.getState(); - store.setState({ transform: [event.transform.x, event.transform.y, event.transform.k] }); - - zoomedWithRightMouseButton.current = !!( - onPaneContextMenu && isRightClickPan(panOnDrag, mouseButton.current ?? 0) - ); - - if (onMove || onViewportChange) { - const flowTransform = eventToFlowTransform(event.transform); - - onViewportChange?.(flowTransform); - onMove?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform); - } + const panZoomHandler = createPanZoomHandler({ + zoomPanValues: zoomPanValues.current, + panOnDrag, + onPaneContextMenu: !!onPaneContextMenu, + onMove, + onViewportChange: store.getState().onViewportChange, + setTransform: (transform: Transform) => store.setState({ transform }), }); + d3Zoom.on('zoom', panZoomHandler); } } }, [userSelectionActive, d3Zoom, onMove, panOnDrag, onPaneContextMenu]); useEffect(() => { if (d3Zoom) { - d3Zoom.on('end', (event: D3ZoomEvent) => { - if (!event.sourceEvent) { - return null; - } - const { onViewportChangeEnd } = store.getState(); - - isZoomingOrPanning.current = false; - store.setState({ paneDragging: false }); - - if ( - onPaneContextMenu && - isRightClickPan(panOnDrag, mouseButton.current ?? 0) && - !zoomedWithRightMouseButton.current - ) { - onPaneContextMenu(event.sourceEvent); - } - zoomedWithRightMouseButton.current = false; - - if ((onMoveEnd || onViewportChangeEnd) && viewChanged(prevTransform.current, event.transform)) { - const flowTransform = eventToFlowTransform(event.transform); - prevTransform.current = flowTransform; - - clearTimeout(timerId.current); - timerId.current = setTimeout( - () => { - onViewportChangeEnd?.(flowTransform); - onMoveEnd?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform); - }, - panOnScroll ? 150 : 0 - ); - } + const panZoomEndHandler = createPanZoomEndHandler({ + zoomPanValues: zoomPanValues.current, + panOnDrag, + panOnScroll, + onPaneContextMenu, + onMoveEnd, + onViewportChangeEnd: store.getState().onViewportChangeEnd, + setDragging: (paneDragging: boolean) => store.setState({ paneDragging }), }); + d3Zoom.on('end', panZoomEndHandler); } }, [d3Zoom, panOnScroll, panOnDrag, onMoveEnd, onPaneContextMenu]); useEffect(() => { if (d3Zoom) { - d3Zoom.filter((event: any) => { - const zoomScroll = zoomActivationKeyPressed || zoomOnScroll; - const pinchZoom = zoomOnPinch && event.ctrlKey; - - if ( - event.button === 1 && - event.type === 'mousedown' && - (isWrappedWithClass(event, 'react-flow__node') || isWrappedWithClass(event, 'react-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 (userSelectionActive) { - 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, noPanClassName) && 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') && buttonAllowed; + const filter = createFilter({ + zoomActivationKeyPressed, + panOnDrag, + zoomOnScroll, + panOnScroll, + zoomOnDoubleClick, + zoomOnPinch, + userSelectionActive, + noPanClassName, + noWheelClassName, }); + d3Zoom.filter(filter); } }, [ userSelectionActive, diff --git a/packages/core/src/store/initialState.ts b/packages/core/src/store/initialState.ts index 87d9874c..fb67730d 100644 --- a/packages/core/src/store/initialState.ts +++ b/packages/core/src/store/initialState.ts @@ -16,7 +16,7 @@ const initialState: ReactFlowStore = { hasDefaultEdges: false, d3Zoom: null, d3Selection: null, - d3ZoomHandler: undefined, + d3ZoomHandler: null, minZoom: 0.5, maxZoom: 2, translateExtent: infiniteExtent, diff --git a/packages/core/src/types/store.ts b/packages/core/src/types/store.ts index 50080084..712ca731 100644 --- a/packages/core/src/types/store.ts +++ b/packages/core/src/types/store.ts @@ -16,6 +16,7 @@ import { ConnectingHandle, Transform, XYPosition, + D3ZoomHandler, } from '@reactflow/system'; import type { @@ -55,7 +56,7 @@ export type ReactFlowStore = { d3Zoom: D3ZoomInstance | null; d3Selection: D3SelectionInstance | null; - d3ZoomHandler: ((this: Element, event: any, d: unknown) => void) | undefined; + d3ZoomHandler: D3ZoomHandler | null; minZoom: number; maxZoom: number; translateExtent: CoordinateExtent; diff --git a/packages/svelte/src/lib/actions/zoom/index.ts b/packages/svelte/src/lib/actions/zoom/index.ts index ddfcc3cd..828ffe4b 100644 --- a/packages/svelte/src/lib/actions/zoom/index.ts +++ b/packages/svelte/src/lib/actions/zoom/index.ts @@ -2,12 +2,34 @@ 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, Transform, Viewport } from '@reactflow/system'; +import type { + D3SelectionInstance, + D3ZoomInstance, + OnMove, + OnMoveEnd, + OnMoveStart, + 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; @@ -85,10 +107,15 @@ type ZoomParams = { minZoom: number; maxZoom: number; initialViewport: Viewport; + dragging: Writable; + onMoveStart?: OnMoveStart; + onMove?: OnMove; + onMoveEnd?: OnMoveEnd; }; export default function zoom(domNode: Element, params: ZoomParams) { - const { transform, d3, minZoom, maxZoom, initialViewport } = params; + const { d3, minZoom, maxZoom, initialViewport } = params; + const d3ZoomInstance = d3Zoom().scaleExtent([minZoom, maxZoom]); const selection = select(domNode).call(d3ZoomInstance); @@ -98,31 +125,93 @@ export default function zoom(domNode: Element, params: ZoomParams) { const d3ZoomHandler = selection.on('wheel.zoom'); - d3ZoomInstance.on('zoom', (event: D3ZoomEvent) => { - transform.set([event.transform.x, event.transform.y, event.transform.k]); - }); + 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) => { + // 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) => { + 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) => { + 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); - selection.on('wheel.zoom', function (event: any, d: any) { - if (isWrappedWithClass(event, 'nowheel')) { - return null; - } - - event.preventDefault(); - d3ZoomHandler!.call(this, event, d); - }); + updateZoomHandling(params); d3.set({ zoom: d3ZoomInstance, selection }); - d3ZoomInstance.filter((event: any) => filter(event, params)); - return { update(params: ZoomParams) { - d3ZoomInstance.filter((event: any) => filter(event, params)); + updateZoomHandling(params); } }; } diff --git a/packages/svelte/src/lib/container/Pane/Pane.svelte b/packages/svelte/src/lib/container/Pane/Pane.svelte index e4524760..4c962d6a 100644 --- a/packages/svelte/src/lib/container/Pane/Pane.svelte +++ b/packages/svelte/src/lib/container/Pane/Pane.svelte @@ -43,22 +43,20 @@ edges, transform, dragging, + elementsSelectable, selectionRect, selectionRectMode, selectionKeyPressed, + selectionMode, resetSelectedElements, - selectionMode } = useStore(); - // @todo take from props - const elementsSelectable = true; - let container: HTMLDivElement; let containerBounds: DOMRect | null = null; let selectedNodes: Node[] = []; $: isSelecting = $selectionKeyPressed; - $: hasActiveSelection = elementsSelectable && (isSelecting || $selectionRectMode === 'user'); + $: hasActiveSelection = $elementsSelectable && (isSelecting || $selectionRectMode === 'user'); function onClick(event: MouseEvent) { dispatch('pane:click', event); diff --git a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte index c1a3131e..fe52a955 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte +++ b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte @@ -33,6 +33,9 @@ export let deleteKey: $$Props['deleteKey'] = undefined; export let connectionRadius: $$Props['connectionRadius'] = undefined; export let connectionLineType: $$Props['connectionLineType'] = undefined; + export let onMoveStart: $$Props['onMoveStart'] = undefined; + export let onMove: $$Props['onMove'] = undefined; + export let onMoveEnd: $$Props['onMoveEnd'] = undefined; export let isValidConnection: $$Props['isValidConnection'] = undefined; export let defaultMarkerColor = '#b1b1b7'; @@ -111,7 +114,7 @@ {...$$restProps} > - + diff --git a/packages/svelte/src/lib/container/SvelteFlow/types.ts b/packages/svelte/src/lib/container/SvelteFlow/types.ts index 653faf48..30ab2566 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/types.ts +++ b/packages/svelte/src/lib/container/SvelteFlow/types.ts @@ -6,7 +6,10 @@ import type { OnConnectStartParams, Viewport, SelectionMode, - SnapGrid + SnapGrid, + OnMoveStart, + OnMove, + OnMoveEnd } from '@reactflow/system'; import type { @@ -43,6 +46,10 @@ export type SvelteFlowProps = DOMAttributes & { connectionLineType?: ConnectionLineType; isValidConnection?: IsValidConnection; + onMoveStart?: OnMoveStart; + onMove?: OnMove; + onMoveEnd?: OnMoveEnd; + 'on:node:click'?: CustomEvent; 'on:node:mouseenter'?: CustomEvent; 'on:node:mousemove'?: CustomEvent; diff --git a/packages/svelte/src/lib/container/Zoom/Zoom.svelte b/packages/svelte/src/lib/container/Zoom/Zoom.svelte index 26fa014b..9b6bd101 100644 --- a/packages/svelte/src/lib/container/Zoom/Zoom.svelte +++ b/packages/svelte/src/lib/container/Zoom/Zoom.svelte @@ -1,18 +1,33 @@ - -
+
diff --git a/packages/svelte/src/lib/container/Zoom/types.ts b/packages/svelte/src/lib/container/Zoom/types.ts new file mode 100644 index 00000000..20c66483 --- /dev/null +++ b/packages/svelte/src/lib/container/Zoom/types.ts @@ -0,0 +1,8 @@ +import type { OnMove, OnMoveStart, OnMoveEnd, Viewport } from '@reactflow/system'; + +export type ZoomProps = { + initialViewport?: Viewport; + onMove?: OnMove; + onMoveStart?: OnMoveStart; + onMoveEnd?: OnMoveEnd; +}; diff --git a/packages/svelte/src/lib/plugins/Controls/Controls.svelte b/packages/svelte/src/lib/plugins/Controls/Controls.svelte index c3d73450..e9e7b79c 100644 --- a/packages/svelte/src/lib/plugins/Controls/Controls.svelte +++ b/packages/svelte/src/lib/plugins/Controls/Controls.svelte @@ -21,7 +21,7 @@ zoomIn, zoomOut, fitView, transform, minZoom, maxZoom, nodesDraggable, nodesConnectable, elementsSelectable } = useStore(); - const isInteractive = true; + $: isInteractive = $nodesDraggable || $nodesConnectable || $elementsSelectable; $: minZoomReached = $transform[2] <= $minZoom; $: maxZoomReached = $transform[2] >= $maxZoom; @@ -38,9 +38,11 @@ }; const onToggleInteractivity = () => { - nodesDraggable.set(!isInteractive); - nodesConnectable.set(!isInteractive); - elementsSelectable.set(!isInteractive); + isInteractive = !isInteractive; + + nodesDraggable.set(isInteractive); + nodesConnectable.set(isInteractive); + elementsSelectable.set(isInteractive); }; diff --git a/packages/system/src/types/general.ts b/packages/system/src/types/general.ts index cebfbe76..7c5473df 100644 --- a/packages/system/src/types/general.ts +++ b/packages/system/src/types/general.ts @@ -7,7 +7,7 @@ import type { HandleType } from './handles'; export type Project = (position: XYPosition) => XYPosition; -export type OnMove = (event: MouseEvent | TouchEvent, viewport: Viewport) => void; +export type OnMove = (event: MouseEvent | TouchEvent | null, viewport: Viewport) => void; export type OnMoveStart = OnMove; export type OnMoveEnd = OnMove; @@ -93,6 +93,7 @@ export type OnViewportChange = (viewport: Viewport) => void; export type D3ZoomInstance = ZoomBehavior; export type D3SelectionInstance = D3Selection; +export type D3ZoomHandler = (this: Element, event: any, d: unknown) => void; export type UpdateNodeInternals = (nodeId: string) => void; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index d4a84150..4f66d9a4 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,3 +1,4 @@ export * from './graph'; export * from './utils'; export * from './marker'; +export * from './panzoom'; diff --git a/packages/utils/src/panzoom.ts b/packages/utils/src/panzoom.ts new file mode 100644 index 00000000..3e308838 --- /dev/null +++ b/packages/utils/src/panzoom.ts @@ -0,0 +1,352 @@ +import { D3ZoomEvent, zoom, zoomIdentity } from 'd3-zoom'; +import { select, pointer } from 'd3-selection'; +import { + PanOnScrollMode, + type CoordinateExtent, + type Transform, + type Viewport, + D3ZoomInstance, + D3SelectionInstance, + D3ZoomHandler, + OnMoveStart, + OnMove, + OnMoveEnd, +} from '@reactflow/system'; + +import { clamp } from './utils'; + +const viewChanged = (prevViewport: Viewport, eventViewport: any): boolean => + prevViewport.x !== eventViewport.x || prevViewport.y !== eventViewport.y || prevViewport.zoom !== eventViewport.k; + +const eventToFlowTransform = (eventViewport: any): Viewport => ({ + x: eventViewport.x, + y: eventViewport.y, + zoom: eventViewport.k, +}); + +const isWrappedWithClass = (event: any, className: string | undefined) => event.target.closest(`.${className}`); + +const isRightClickPan = (panOnDrag: boolean | number[], usedButton: number) => + usedButton === 2 && Array.isArray(panOnDrag) && panOnDrag.includes(2); + +export type InitPanZoomParams = { + domNode: Element; + minZoom: number; + maxZoom: number; + defaultViewport: Viewport; + translateExtent: CoordinateExtent; +}; + +export function initPanZoom({ domNode, minZoom, maxZoom, translateExtent, defaultViewport }: InitPanZoomParams): { + d3ZoomInstance: D3ZoomInstance; + d3Selection: D3SelectionInstance; + d3ZoomHandler: D3ZoomHandler | null; + transform: Transform; +} { + const bbox = domNode.getBoundingClientRect(); + const d3ZoomInstance = zoom().scaleExtent([minZoom, maxZoom]).translateExtent(translateExtent); + const d3Selection = select(domNode).call(d3ZoomInstance); + const updatedTransform = zoomIdentity + .translate(defaultViewport.x, defaultViewport.y) + .scale(clamp(defaultViewport.zoom, minZoom, maxZoom)); + const extent: CoordinateExtent = [ + [0, 0], + [bbox.width, bbox.height], + ]; + + const constrainedTransform = d3ZoomInstance.constrain()(updatedTransform, extent, translateExtent); + d3ZoomInstance.transform(d3Selection, constrainedTransform); + const d3ZoomHandler = d3Selection.on('wheel.zoom') || null; + + return { + d3ZoomInstance, + d3Selection, + d3ZoomHandler, + // we need to pass transform because zoom handler is not registered when we set the initial transform + transform: [constrainedTransform.x, constrainedTransform.y, constrainedTransform.k], + }; +} + +type PanOnScrollParams = { + noWheelClassName: string; + d3Selection: D3SelectionInstance; + d3Zoom: D3ZoomInstance; + panOnScrollMode: PanOnScrollMode; + panOnScrollSpeed: number; + zoomOnPinch: boolean; +}; + +export function createPanOnScrollHandler({ + noWheelClassName, + d3Selection, + d3Zoom, + panOnScrollMode, + panOnScrollSpeed, + zoomOnPinch, +}: PanOnScrollParams) { + return (event: any) => { + if (isWrappedWithClass(event, noWheelClassName)) { + return false; + } + event.preventDefault(); + event.stopImmediatePropagation(); + + const currentZoom = d3Selection.property('__zoom').k || 1; + + if (event.ctrlKey && zoomOnPinch) { + const point = pointer(event); + // taken from https://github.com/d3/d3-zoom/blob/master/src/zoom.js + const pinchDelta = -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * 10; + const zoom = currentZoom * Math.pow(2, pinchDelta); + d3Zoom.scaleTo(d3Selection, zoom, point); + + return; + } + + // increase scroll speed in firefox + // firefox: deltaMode === 1; chrome: deltaMode === 0 + const deltaNormalize = event.deltaMode === 1 ? 20 : 1; + const deltaX = panOnScrollMode === PanOnScrollMode.Vertical ? 0 : event.deltaX * deltaNormalize; + const deltaY = panOnScrollMode === PanOnScrollMode.Horizontal ? 0 : event.deltaY * deltaNormalize; + + d3Zoom.translateBy( + d3Selection, + -(deltaX / currentZoom) * panOnScrollSpeed, + -(deltaY / currentZoom) * panOnScrollSpeed + ); + }; +} + +type ZoomOnScrollParams = { + noWheelClassName: string; + preventScrolling: boolean; + d3ZoomHandler: D3ZoomHandler; +}; + +export function createZoomOnScrollHandler({ noWheelClassName, preventScrolling, d3ZoomHandler }: ZoomOnScrollParams) { + return function (this: Element, event: any, d: unknown) { + if (!preventScrolling || isWrappedWithClass(event, noWheelClassName)) { + return null; + } + + event.preventDefault(); + + d3ZoomHandler.call(this, event, d); + }; +} + +export type ZoomPanValues = { + isZoomingOrPanning: boolean; + zoomedWithRightMouseButton: boolean; + prevTransform: Viewport; + mouseButton: number; + timerId: ReturnType | undefined; +}; + +export type PanZoomStartParams = { + zoomPanValues: ZoomPanValues; + setDragging: (isDragging: boolean) => void; + onMoveStart?: OnMoveStart; + onViewportChangeStart?: (viewport: Viewport) => void; +}; + +export function createPanZoomStartHandler({ + zoomPanValues, + setDragging, + onMoveStart, + onViewportChangeStart, +}: PanZoomStartParams) { + return (event: D3ZoomEvent) => { + // we need to remember it here, because it's always 0 in the "zoom" event + zoomPanValues.mouseButton = event.sourceEvent?.button || 0; + + zoomPanValues.isZoomingOrPanning = true; + + if (event.sourceEvent?.type === 'mousedown') { + setDragging(true); + } + + if (onMoveStart || onViewportChangeStart) { + const flowTransform = eventToFlowTransform(event.transform); + zoomPanValues.prevTransform = flowTransform; + + onViewportChangeStart?.(flowTransform); + onMoveStart?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform); + } + }; +} + +export type PanZoomParams = { + zoomPanValues: ZoomPanValues; + panOnDrag: boolean | number[]; + onPaneContextMenu: boolean; + setTransform: (transform: Transform) => void; + onMove?: OnMove; + onViewportChange?: (viewport: Viewport) => void; +}; + +export function createPanZoomHandler({ + zoomPanValues, + panOnDrag, + onPaneContextMenu, + setTransform, + onMove, + onViewportChange, +}: PanZoomParams) { + return (event: D3ZoomEvent) => { + zoomPanValues.zoomedWithRightMouseButton = !!( + onPaneContextMenu && isRightClickPan(panOnDrag, zoomPanValues.mouseButton ?? 0) + ); + + setTransform([event.transform.x, event.transform.y, event.transform.k]); + + if (onMove || onViewportChange) { + const flowTransform = eventToFlowTransform(event.transform); + + onViewportChange?.(flowTransform); + onMove?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform); + } + }; +} + +export type PanZoomEndParams = { + zoomPanValues: ZoomPanValues; + panOnDrag: boolean | number[]; + panOnScroll: boolean; + setDragging: (isDragging: boolean) => void; + onMoveEnd?: OnMoveEnd; + onViewportChangeEnd?: (viewport: Viewport) => void; + onPaneContextMenu?: (event: any) => void; +}; + +export function createPanZoomEndHandler({ + zoomPanValues, + panOnDrag, + panOnScroll, + setDragging, + onMoveEnd, + onViewportChangeEnd, + onPaneContextMenu, +}: PanZoomEndParams) { + return (event: D3ZoomEvent) => { + zoomPanValues.isZoomingOrPanning = false; + + if ( + onPaneContextMenu && + isRightClickPan(panOnDrag, zoomPanValues.mouseButton ?? 0) && + !zoomPanValues.zoomedWithRightMouseButton && + event.sourceEvent + ) { + onPaneContextMenu(event.sourceEvent); + } + zoomPanValues.zoomedWithRightMouseButton = false; + + setDragging(false); + + if ((onMoveEnd || onViewportChangeEnd) && viewChanged(zoomPanValues.prevTransform, event.transform)) { + const flowTransform = eventToFlowTransform(event.transform); + zoomPanValues.prevTransform = flowTransform; + + clearTimeout(zoomPanValues.timerId); + zoomPanValues.timerId = setTimeout( + () => { + onViewportChangeEnd?.(flowTransform); + onMoveEnd?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform); + }, + panOnScroll ? 150 : 0 + ); + } + }; +} + +type FilterParams = { + zoomActivationKeyPressed: boolean; + zoomOnScroll: boolean; + zoomOnPinch: boolean; + panOnDrag: boolean | number[]; + panOnScroll: boolean; + zoomOnDoubleClick: boolean; + userSelectionActive: boolean; + noWheelClassName: string; + noPanClassName: string; +}; + +export function createFilter({ + zoomActivationKeyPressed, + zoomOnScroll, + zoomOnPinch, + panOnDrag, + panOnScroll, + zoomOnDoubleClick, + userSelectionActive, + noWheelClassName, + noPanClassName, +}: FilterParams) { + return (event: any): boolean => { + const zoomScroll = zoomActivationKeyPressed || zoomOnScroll; + const pinchZoom = zoomOnPinch && event.ctrlKey; + + if ( + event.button === 1 && + event.type === 'mousedown' && + (isWrappedWithClass(event, 'react-flow__node') || isWrappedWithClass(event, 'react-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 (userSelectionActive) { + 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, noPanClassName) && 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') && buttonAllowed; + }; +}