import React, { useEffect, useRef, ReactNode } from 'react'; import { zoom, zoomIdentity } from 'd3-zoom'; import { select, pointer } from 'd3-selection'; import shallow from 'zustand/shallow'; import { clamp } from '../../utils'; import useKeyPress from '../../hooks/useKeyPress'; import useResizeHandler from '../../hooks/useResizeHandler'; import { useStore, useStoreApi } from '../../store'; import { FlowTransform, TranslateExtent, PanOnScrollMode, KeyCode, ReactFlowState } from '../../types'; interface ZoomPaneProps { selectionKeyPressed: boolean; elementsSelectable?: boolean; zoomOnScroll?: boolean; zoomOnPinch?: boolean; panOnScroll?: boolean; panOnScrollSpeed?: number; panOnScrollMode?: PanOnScrollMode; zoomOnDoubleClick?: boolean; paneMoveable?: boolean; defaultPosition?: [number, number]; defaultZoom?: number; translateExtent?: TranslateExtent; onMove?: (flowTransform?: FlowTransform) => void; onMoveStart?: (flowTransform?: FlowTransform) => void; onMoveEnd?: (flowTransform?: FlowTransform) => void; zoomActivationKeyCode?: KeyCode; preventScrolling?: boolean; children: ReactNode; } const viewChanged = (prevTransform: FlowTransform, eventTransform: any): boolean => prevTransform.x !== eventTransform.x || prevTransform.y !== eventTransform.y || prevTransform.zoom !== eventTransform.k; const eventToFlowTransform = (eventTransform: any): FlowTransform => ({ x: eventTransform.x, y: eventTransform.y, zoom: eventTransform.k, }); const hasNoWheelClass = (event: any) => event.target.closest('.nowheel'); const selector = (s: ReactFlowState) => ({ d3Zoom: s.d3Zoom, d3Selection: s.d3Selection, d3ZoomHandler: s.d3ZoomHandler, initD3Zoom: s.initD3Zoom, updateTransform: s.updateTransform, }); const ZoomPane = ({ onMove, onMoveStart, onMoveEnd, zoomOnScroll = true, zoomOnPinch = true, panOnScroll = false, panOnScrollSpeed = 0.5, panOnScrollMode = PanOnScrollMode.Free, zoomOnDoubleClick = true, selectionKeyPressed, elementsSelectable, paneMoveable = true, defaultPosition = [0, 0], defaultZoom = 1, translateExtent, zoomActivationKeyCode, preventScrolling = true, children, }: ZoomPaneProps) => { const store = useStoreApi(); const zoomPane = useRef(null); const prevTransform = useRef({ x: 0, y: 0, zoom: 0 }); const { d3Zoom, d3Selection, d3ZoomHandler, initD3Zoom, updateTransform } = useStore(selector, shallow); const zoomActivationKeyPressed = useKeyPress(zoomActivationKeyCode); useResizeHandler(zoomPane); useEffect(() => { if (zoomPane.current) { const state = store.getState(); const currentTranslateExtent = typeof translateExtent !== 'undefined' ? translateExtent : state.translateExtent; const d3ZoomInstance = zoom().scaleExtent([state.minZoom, state.maxZoom]).translateExtent(currentTranslateExtent); const selection = select(zoomPane.current as Element).call(d3ZoomInstance); const clampedX = clamp(defaultPosition[0], currentTranslateExtent[0][0], currentTranslateExtent[1][0]); const clampedY = clamp(defaultPosition[1], currentTranslateExtent[0][1], currentTranslateExtent[1][1]); const clampedZoom = clamp(defaultZoom, state.minZoom, state.maxZoom); const updatedTransform = zoomIdentity.translate(clampedX, clampedY).scale(clampedZoom); d3ZoomInstance.transform(selection, updatedTransform); initD3Zoom({ 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: [clampedX, clampedY, clampedZoom], }); } }, []); useEffect(() => { if (d3Selection && d3Zoom) { if (panOnScroll && !zoomActivationKeyPressed) { d3Selection .on('wheel', (event: any) => { if (hasNoWheelClass(event)) { 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 ); }) .on('wheel.zoom', null); } else if (typeof d3ZoomHandler !== 'undefined') { d3Selection .on('wheel', (event: any) => { if (!preventScrolling || hasNoWheelClass(event)) { return null; } event.preventDefault(); }) .on('wheel.zoom', d3ZoomHandler); } } }, [ panOnScroll, panOnScrollMode, d3Selection, d3Zoom, d3ZoomHandler, zoomActivationKeyPressed, zoomOnPinch, preventScrolling, ]); useEffect(() => { if (d3Zoom) { if (selectionKeyPressed) { d3Zoom.on('zoom', null); } else { d3Zoom.on('zoom', (event: any) => { updateTransform([event.transform.x, event.transform.y, event.transform.k]); if (onMove) { const flowTransform = eventToFlowTransform(event.transform); onMove(flowTransform); } }); } } }, [selectionKeyPressed, d3Zoom, updateTransform, onMove]); useEffect(() => { if (d3Zoom) { if (onMoveStart) { d3Zoom.on('start', (event: any) => { const flowTransform = eventToFlowTransform(event.transform); prevTransform.current = flowTransform; onMoveStart(flowTransform); }); } else { d3Zoom.on('start', null); } } }, [d3Zoom, onMoveStart]); useEffect(() => { if (d3Zoom) { if (onMoveEnd) { d3Zoom.on('end', (event: any) => { if (viewChanged(prevTransform.current, event.transform)) { const flowTransform = eventToFlowTransform(event.transform); prevTransform.current = flowTransform; onMoveEnd(flowTransform); } }); } else { d3Zoom.on('end', null); } } }, [d3Zoom, onMoveEnd]); useEffect(() => { if (d3Zoom) { d3Zoom.filter((event: any) => { const zoomScroll = zoomActivationKeyPressed || zoomOnScroll; const pinchZoom = zoomOnPinch && event.ctrlKey; // if all interactions are disabled, we prevent all zoom events if (!paneMoveable && !zoomScroll && !panOnScroll && !zoomOnDoubleClick && !zoomOnPinch) { return false; } // during a selection we prevent all other interactions if (selectionKeyPressed) { return false; } // if zoom on double click is disabled, we prevent the double click event if (!zoomOnDoubleClick && event.type === 'dblclick') { return false; } if (hasNoWheelClass(event) && event.type === 'wheel') { return false; } // when the target element is a node, we still allow zooming if ( (event.target.closest('.react-flow__node') || event.target.closest('.react-flow__edge')) && event.type !== 'wheel' ) { return false; } // when the target element is a node selection, we still allow zooming if (event.target.closest('.react-flow__nodesselection') && 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 (!paneMoveable && (event.type === 'mousedown' || event.type === 'touchstart')) { return false; } // default filter for d3-zoom return (!event.ctrlKey || event.type === 'wheel') && !event.button; }); } }, [ d3Zoom, zoomOnScroll, zoomOnPinch, panOnScroll, zoomOnDoubleClick, paneMoveable, selectionKeyPressed, elementsSelectable, zoomActivationKeyPressed, ]); return (
{children}
); }; export default ZoomPane;