From cf38388f149dadf3e9981d9b233f7aa1464d2ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20Mo=CC=88ller?= Date: Mon, 9 Nov 2020 17:23:59 +0100 Subject: [PATCH] refactor(zoom): move zoom methods into own hook, detatch from store --- example/src/Interaction/index.js | 1 + src/additional-components/Controls/index.tsx | 35 +++--- src/container/GraphView/index.tsx | 11 +- src/container/ReactFlow/Wrapper.tsx | 7 +- src/container/ZoomPane/index.tsx | 32 +++++- src/hooks/useZoomPanHelper.ts | 67 ++++++++++++ src/store/index.ts | 109 ++----------------- src/utils/graph.ts | 29 +---- 8 files changed, 139 insertions(+), 152 deletions(-) create mode 100644 src/hooks/useZoomPanHelper.ts diff --git a/example/src/Interaction/index.js b/example/src/Interaction/index.js index 90819509..39e83887 100644 --- a/example/src/Interaction/index.js +++ b/example/src/Interaction/index.js @@ -50,6 +50,7 @@ const InteractionFlow = () => { onPaneClick={captureZoomClick ? onPaneClick : undefined} onPaneScroll={captureZoomScroll ? onPaneScroll : undefined} onPaneContextMenu={captureZoomClick ? onPaneContextMenu : undefined} + onMoveEnd={(evt) => console.log('on move end', evt)} > diff --git a/src/additional-components/Controls/index.tsx b/src/additional-components/Controls/index.tsx index ae5a53f3..983701e9 100644 --- a/src/additional-components/Controls/index.tsx +++ b/src/additional-components/Controls/index.tsx @@ -10,6 +10,7 @@ import LockIcon from '../../../assets/icons/lock.svg'; import UnlockIcon from '../../../assets/icons/unlock.svg'; import './style.css'; +import useZoomPanHelper from '../../hooks/useZoomPanHelper'; export interface ControlProps extends React.HTMLAttributes { showZoom?: boolean; @@ -33,9 +34,7 @@ const Controls = ({ className, }: ControlProps) => { const setInteractive = useStoreActions((actions) => actions.setInteractive); - const fitView = useStoreActions((actions) => actions.fitView); - const zoomIn = useStoreActions((actions) => actions.zoomIn); - const zoomOut = useStoreActions((actions) => actions.zoomOut); + const { zoomIn, zoomOut, fitView } = useZoomPanHelper(); const isInteractive = useStoreState((s) => s.nodesDraggable && s.nodesConnectable && s.elementsSelectable); const mapClasses = cc(['react-flow__controls', className]); @@ -44,20 +43,26 @@ const Controls = ({
{showZoom && ( <> -
{ - zoomIn(); - if (onZoomIn) { - onZoomIn(); - } - }}> +
{ + zoomIn(); + if (onZoomIn) { + onZoomIn(); + } + }} + >
-
{ - zoomOut(); - if (onZoomOut) { - onZoomOut(); - } - }}> +
{ + zoomOut(); + if (onZoomOut) { + onZoomOut(); + } + }} + >
diff --git a/src/container/GraphView/index.tsx b/src/container/GraphView/index.tsx index eb68438d..3f9ce248 100644 --- a/src/container/GraphView/index.tsx +++ b/src/container/GraphView/index.tsx @@ -22,6 +22,7 @@ import { OnConnectEndFunc, TranslateExtent, } from '../../types'; +import useZoomPanHelper from '../../hooks/useZoomPanHelper'; export interface GraphViewProps { elements: Elements; @@ -145,10 +146,8 @@ const GraphView = ({ const setMinZoom = useStoreActions((actions) => actions.setMinZoom); const setMaxZoom = useStoreActions((actions) => actions.setMaxZoom); const setTranslateExtent = useStoreActions((actions) => actions.setTranslateExtent); - const fitView = useStoreActions((actions) => actions.fitView); - const zoom = useStoreActions((actions) => actions.zoom); - const zoomTo = useStoreActions((actions) => actions.zoomTo); const currentStore = useStore(); + const { zoomIn, zoomOut, zoomTo, fitView } = useZoomPanHelper(); useElementUpdater(elements); @@ -157,9 +156,9 @@ const GraphView = ({ if (onLoad) { onLoad({ fitView: (params = { padding: 0.1 }) => fitView(params), - zoomIn: () => zoom(0.2), - zoomOut: () => zoom(-0.2), - zoomTo: (zoomLevel) => zoomTo(zoomLevel), + zoomIn, + zoomOut, + zoomTo, project: onLoadProject(currentStore), getElements: onLoadGetElements(currentStore), setTransform: (transform: FlowTransform) => diff --git a/src/container/ReactFlow/Wrapper.tsx b/src/container/ReactFlow/Wrapper.tsx index c5cc6de0..2081e092 100644 --- a/src/container/ReactFlow/Wrapper.tsx +++ b/src/container/ReactFlow/Wrapper.tsx @@ -1,10 +1,11 @@ import React, { FC } from 'react'; -import { StoreProvider, useStore } from 'easy-peasy'; +import { StoreProvider } from 'easy-peasy'; -import store, { StoreModel } from '../../store'; +import store from '../../store'; +import { useStore } from '../../store/hooks'; const Wrapper: FC = ({ children }) => { - const easyPeasyStore = useStore(); + const easyPeasyStore = useStore(); const isWrapepdWithReactFlowProvider = easyPeasyStore?.getState()?.reactFlowVersion; if (isWrapepdWithReactFlowProvider) { diff --git a/src/container/ZoomPane/index.tsx b/src/container/ZoomPane/index.tsx index 9fe19301..9ff0bc14 100644 --- a/src/container/ZoomPane/index.tsx +++ b/src/container/ZoomPane/index.tsx @@ -1,7 +1,11 @@ import React, { useEffect, useRef, ReactNode } from 'react'; +import { zoom, zoomIdentity } from 'd3-zoom'; +import { select } from 'd3-selection'; +import { clamp } from '../../utils'; + import useResizeHandler from '../../hooks/useResizeHandler'; -import { useStoreState, useStoreActions } from '../../store/hooks'; +import { useStoreState, useStoreActions, useStore } from '../../store/hooks'; import { FlowTransform, TranslateExtent } from '../../types'; interface ZoomPaneProps { @@ -55,14 +59,36 @@ const ZoomPane = ({ const d3Selection = useStoreState((s) => s.d3Selection); const d3ZoomHandler = useStoreState((s) => s.d3ZoomHandler); - const initD3 = useStoreActions((actions) => actions.initD3); + const initD3Zoom = useStoreActions((actions) => actions.initD3Zoom); const updateTransform = useStoreActions((actions) => actions.updateTransform); + const store = useStore(); useResizeHandler(zoomPane); useEffect(() => { if (zoomPane.current) { - initD3({ zoomPane: zoomPane.current, defaultPosition, defaultZoom, translateExtent }); + // initD3: action((state, { zoomPane, defaultPosition, defaultZoom, translateExtent }) => { + 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); + // selection.property('__zoom', updatedTransform); + + const zoomHandler = selection.on('wheel.zoom'); + + d3ZoomInstance.transform(selection, updatedTransform); + + initD3Zoom({ + d3Zoom: d3ZoomInstance, + d3Selection: selection, + d3ZoomHandler: zoomHandler, + }); } }, []); diff --git a/src/hooks/useZoomPanHelper.ts b/src/hooks/useZoomPanHelper.ts new file mode 100644 index 00000000..239e3514 --- /dev/null +++ b/src/hooks/useZoomPanHelper.ts @@ -0,0 +1,67 @@ +import { useCallback } from 'react'; +import { zoomIdentity } from 'd3-zoom'; +import { useStoreState, useStore } from '../store/hooks'; + +import { clamp } from '../utils'; +import { getRectOfNodes } from '../utils/graph'; +import { FitViewParams } from '../types'; + +export default () => { + const store = useStore(); + const d3Zoom = useStoreState((s) => s.d3Zoom); + const d3Selection = useStoreState((s) => s.d3Selection); + + const zoomIn = useCallback(() => { + if (d3Selection) { + d3Zoom?.scaleBy(d3Selection, 1.2); + } + }, [d3Zoom, d3Selection]); + + const zoomOut = useCallback(() => { + if (d3Selection) { + d3Zoom?.scaleBy(d3Selection, 1 / 1.2); + } + }, [d3Zoom, d3Selection]); + + const zoomTo = useCallback( + (zoomLevel: number) => { + if (d3Selection) { + d3Zoom?.scaleTo(d3Selection, zoomLevel); + } + }, + [d3Zoom, d3Selection] + ); + + const fitView = useCallback( + (options: FitViewParams) => { + const { padding = 0.1 } = options; + const { nodes, width, height, minZoom, maxZoom } = store.getState(); + + if (!d3Selection || !nodes.length) { + return; + } + + const bounds = getRectOfNodes(nodes); + const xZoom = width / (bounds.width * (1 + padding)); + const yZoom = height / (bounds.height * (1 + padding)); + const zoom = Math.min(xZoom, yZoom); + const clampedZoom = clamp(zoom, minZoom, maxZoom); + const boundsCenterX = bounds.x + bounds.width / 2; + const boundsCenterY = bounds.y + bounds.height / 2; + const x = width / 2 - boundsCenterX * clampedZoom; + const y = height / 2 - boundsCenterY * clampedZoom; + const k = clampedZoom; + const transform = zoomIdentity.translate(x, y).scale(k); + + d3Zoom?.transform(d3Selection, transform); + }, + [store, d3Zoom, d3Selection] + ); + + return { + zoomIn, + zoomOut, + zoomTo, + fitView, + }; +}; diff --git a/src/store/index.ts b/src/store/index.ts index 263e8b2f..24685222 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,10 +1,9 @@ import { createStore, Action, action, Thunk, thunk, computed, Computed } from 'easy-peasy'; import isEqual from 'fast-deep-equal'; import { Selection as D3Selection, ZoomBehavior } from 'd3'; -import { zoom, zoomIdentity } from 'd3-zoom'; -import { select } from 'd3-selection'; +import { zoomIdentity } from 'd3-zoom'; -import { getDimensions, clamp } from '../utils'; +import { getDimensions } from '../utils'; import { getHandleBounds } from '../components/Nodes/utils'; import { getNodesInside, getConnectedEdges, getRectOfNodes, isNode, isEdge } from '../utils/graph'; @@ -26,7 +25,6 @@ import { SetConnectionId, NodePosUpdate, NodeDiffUpdate, - FitViewParams, TranslateExtent, SnapGrid, } from '../types'; @@ -42,11 +40,10 @@ type NodeDimensionUpdate = { nodeElement: HTMLDivElement; }; -type InitD3 = { - zoomPane: Element; - defaultPosition: [number, number]; - defaultZoom: number; - translateExtent?: TranslateExtent; +type InitD3Zoom = { + d3Zoom: ZoomBehavior; + d3Selection: D3Selection; + d3ZoomHandler: ((this: Element, event: any, d: unknown) => void) | undefined; }; export interface StoreModel { width: number; @@ -119,7 +116,7 @@ export interface StoreModel { updateSize: Action; - initD3: Action; + initD3Zoom: Action; setMinZoom: Action; setMaxZoom: Action; @@ -142,12 +139,6 @@ export interface StoreModel { updateUserSelection: Action; unsetUserSelection: Action; - fitView: Action; - zoomTo: Action; - zoom: Thunk; - zoomIn: Thunk; - zoomOut: Thunk; - setMultiSelectionActive: Action; } @@ -397,26 +388,10 @@ export const storeModel: StoreModel = { state.height = size.height || 500; }), - initD3: action((state, { zoomPane, defaultPosition, defaultZoom, translateExtent }) => { - const currentTranslateExtent = typeof translateExtent !== 'undefined' ? translateExtent : state.translateExtent; - const d3ZoomInstance = zoom().scaleExtent([state.minZoom, state.maxZoom]).translateExtent(currentTranslateExtent); - const selection = select(zoomPane).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); - selection.property('__zoom', updatedTransform); - - const defaultHandler = selection.on('wheel.zoom'); - - state.transform[0] = clampedX; - state.transform[1] = clampedY; - state.transform[2] = clampedZoom; - state.d3Zoom = d3ZoomInstance; - state.d3Selection = selection; - state.d3ZoomHandler = defaultHandler; + initD3Zoom: action((state, { d3Zoom, d3Selection, d3ZoomHandler }) => { + state.d3Zoom = d3Zoom; + state.d3Selection = d3Selection; + state.d3ZoomHandler = d3ZoomHandler; state.d3Initialised = true; }), @@ -481,68 +456,6 @@ export const storeModel: StoreModel = { state.elementsSelectable = elementsSelectable; }), - fitView: action((state, payload = { padding: 0.1 }) => { - const { padding } = payload; - const { nodes, width, height, d3Selection, minZoom, maxZoom } = state; - - if (!d3Selection || !nodes.length) { - return; - } - - const bounds = getRectOfNodes(nodes); - const xZoom = width / (bounds.width * (1 + padding)); - const yZoom = height / (bounds.height * (1 + padding)); - const zoom = Math.min(xZoom, yZoom); - const clampedZoom = clamp(zoom, minZoom, maxZoom); - const boundsCenterX = bounds.x + bounds.width / 2; - const boundsCenterY = bounds.y + bounds.height / 2; - const transform = [width / 2 - boundsCenterX * clampedZoom, height / 2 - boundsCenterY * clampedZoom]; - const fittedTransform = zoomIdentity.translate(transform[0], transform[1]).scale(clampedZoom); - - // we need to sync the d3 zoom transform with the fitted transform - d3Selection.property('__zoom', fittedTransform); - - state.transform[0] = fittedTransform.x; - state.transform[1] = fittedTransform.y; - state.transform[2] = fittedTransform.k; - }), - - zoomTo: action((state, zoomLevel) => { - const { d3Selection, transform, minZoom, maxZoom } = state; - const nextZoom = clamp(zoomLevel, minZoom, maxZoom); - - if (d3Selection) { - // we want to zoom in and out to the center of the zoom pane - const center = [state.width / 2, state.height / 2]; - const centerInverted = [(center[0] - transform[0]) / transform[2], (center[1] - transform[1]) / transform[2]]; - const x = center[0] - centerInverted[0] * nextZoom; - const y = center[1] - centerInverted[1] * nextZoom; - const zoomedTransform = zoomIdentity.translate(x, y).scale(nextZoom); - - // we need to sync the d3 zoom transform with the zoomed transform - d3Selection.property('__zoom', zoomedTransform); - - state.transform[0] = zoomedTransform.x; - state.transform[1] = zoomedTransform.y; - state.transform[2] = zoomedTransform.k; - } - }), - - zoom: thunk((actions, amount, helpers) => { - const { transform } = helpers.getState(); - const nextZoom = transform[2] + amount; - - actions.zoomTo(nextZoom); - }), - - zoomIn: thunk((actions) => { - actions.zoom(0.2); - }), - - zoomOut: thunk((actions) => { - actions.zoom(-0.2); - }), - setMultiSelectionActive: action((state, isActive) => { state.multiSelectionActive = isActive; }), diff --git a/src/utils/graph.ts b/src/utils/graph.ts index d39c8f7e..1870a0fb 100644 --- a/src/utils/graph.ts +++ b/src/utils/graph.ts @@ -1,7 +1,6 @@ import { Store } from 'easy-peasy'; - -import store, { StoreModel } from '../store'; -import { ElementId, Node, Edge, Elements, Transform, XYPosition, Rect, FitViewParams, Box, Connection } from '../types'; +import { StoreModel } from '../store'; +import { ElementId, Node, Edge, Elements, Transform, XYPosition, Rect, Box, Connection } from '../types'; export const isEdge = (element: Node | Connection | Edge): element is Edge => 'id' in element && 'source' in element && 'target' in element; @@ -106,12 +105,6 @@ export const onLoadProject = (currentStore: Store) => { }; }; -export const project = (position: XYPosition): XYPosition => { - const { transform, snapToGrid, snapGrid } = store.getState(); - - return pointToRendererPoint(position, transform, snapToGrid, snapGrid); -}; - export const parseElement = (element: Node | Edge): Node | Edge => { if (!element.id) { throw new Error('All nodes and edges need to have an id.'); @@ -227,18 +220,6 @@ export const getConnectedEdges = (nodes: Node[], edges: Edge[]): Edge[] => { }); }; -export const fitView = (params: FitViewParams = { padding: 0.1 }): void => { - store.getActions().fitView(params); -}; - -const zoom = (amount: number): void => { - store.getActions().zoom(amount); -}; - -export const zoomIn = (): void => zoom(0.2); - -export const zoomOut = (): void => zoom(-0.2); - const parseElements = (nodes: Node[], edges: Edge[]): Elements => { return [ ...nodes.map((node) => { @@ -258,9 +239,3 @@ export const onLoadGetElements = (currentStore: Store) => { return parseElements(nodes, edges); }; }; - -export const getElements = (): Elements => { - const { nodes = [], edges = [] } = store.getState(); - - return parseElements(nodes, edges); -};