refactor pan/zoom behaviour, implement pan on scroll #359, edit interaction example

This commit is contained in:
Christopher Möller
2020-10-29 17:58:36 +01:00
parent 2280ed39af
commit d6a6eb82d8
6 changed files with 96 additions and 33 deletions
+23 -9
View File
@@ -26,6 +26,7 @@ const InteractionFlow = () => {
const [isDraggable, setIsDraggable] = useState(false); const [isDraggable, setIsDraggable] = useState(false);
const [isConnectable, setIsConnectable] = useState(false); const [isConnectable, setIsConnectable] = useState(false);
const [zoomOnScroll, setZoomOnScroll] = useState(false); const [zoomOnScroll, setZoomOnScroll] = useState(false);
const [panOnScroll, setPanOnScroll] = useState(true);
const [zoomOnDoubleClick, setZoomOnDoubleClick] = useState(false); const [zoomOnDoubleClick, setZoomOnDoubleClick] = useState(false);
const [paneMoveable, setPaneMoveable] = useState(true); const [paneMoveable, setPaneMoveable] = useState(true);
const [captureZoomClick, setCaptureZoomClick] = useState(false); const [captureZoomClick, setCaptureZoomClick] = useState(false);
@@ -39,6 +40,7 @@ const InteractionFlow = () => {
nodesConnectable={isConnectable} nodesConnectable={isConnectable}
nodesDraggable={isDraggable} nodesDraggable={isDraggable}
zoomOnScroll={zoomOnScroll} zoomOnScroll={zoomOnScroll}
panOnScroll={panOnScroll}
zoomOnDoubleClick={zoomOnDoubleClick} zoomOnDoubleClick={zoomOnDoubleClick}
onConnect={onConnect} onConnect={onConnect}
onElementClick={captureElementClick ? onElementClick : undefined} onElementClick={captureElementClick ? onElementClick : undefined}
@@ -55,7 +57,7 @@ const InteractionFlow = () => {
<div style={{ position: 'absolute', left: 10, top: 10, zIndex: 4 }}> <div style={{ position: 'absolute', left: 10, top: 10, zIndex: 4 }}>
<div> <div>
<label htmlFor="draggable"> <label htmlFor="draggable">
draggable nodesDraggable
<input <input
id="draggable" id="draggable"
type="checkbox" type="checkbox"
@@ -67,7 +69,7 @@ const InteractionFlow = () => {
</div> </div>
<div> <div>
<label htmlFor="connectable"> <label htmlFor="connectable">
connectable nodesConnectable
<input <input
id="connectable" id="connectable"
type="checkbox" type="checkbox"
@@ -79,7 +81,7 @@ const InteractionFlow = () => {
</div> </div>
<div> <div>
<label htmlFor="selectable"> <label htmlFor="selectable">
selectable elementsSelectable
<input <input
id="selectable" id="selectable"
type="checkbox" type="checkbox"
@@ -91,7 +93,7 @@ const InteractionFlow = () => {
</div> </div>
<div> <div>
<label htmlFor="zoomonscroll"> <label htmlFor="zoomonscroll">
zoom on scroll zoomOnScroll
<input <input
id="zoomonscroll" id="zoomonscroll"
type="checkbox" type="checkbox"
@@ -101,9 +103,21 @@ const InteractionFlow = () => {
/> />
</label> </label>
</div> </div>
<div>
<label htmlFor="panonscroll">
panOnScroll
<input
id="panonscroll"
type="checkbox"
checked={panOnScroll}
onChange={(event) => setPanOnScroll(event.target.checked)}
className="react-flow__panonscroll"
/>
</label>
</div>
<div> <div>
<label htmlFor="zoomondbl"> <label htmlFor="zoomondbl">
zoom on double click zoomOnDoubleClick
<input <input
id="zoomondbl" id="zoomondbl"
type="checkbox" type="checkbox"
@@ -115,7 +129,7 @@ const InteractionFlow = () => {
</div> </div>
<div> <div>
<label htmlFor="panemoveable"> <label htmlFor="panemoveable">
pane moveable paneMoveable
<input <input
id="panemoveable" id="panemoveable"
type="checkbox" type="checkbox"
@@ -127,7 +141,7 @@ const InteractionFlow = () => {
</div> </div>
<div> <div>
<label htmlFor="capturezoompaneclick"> <label htmlFor="capturezoompaneclick">
capture zoom pane click capture onPaneClick
<input <input
id="capturezoompaneclick" id="capturezoompaneclick"
type="checkbox" type="checkbox"
@@ -139,7 +153,7 @@ const InteractionFlow = () => {
</div> </div>
<div> <div>
<label htmlFor="capturezoompanescroll"> <label htmlFor="capturezoompanescroll">
capture zoom pane scroll capture onPaneScroll
<input <input
id="capturezoompanescroll" id="capturezoompanescroll"
type="checkbox" type="checkbox"
@@ -151,7 +165,7 @@ const InteractionFlow = () => {
</div> </div>
<div> <div>
<label htmlFor="captureelementclick"> <label htmlFor="captureelementclick">
capture element click capture onElementClick
<input <input
id="captureelementclick" id="captureelementclick"
type="checkbox" type="checkbox"
+4
View File
@@ -36,7 +36,9 @@ const FlowRenderer = ({
onMoveStart, onMoveStart,
onMoveEnd, onMoveEnd,
selectionKeyCode, selectionKeyCode,
elementsSelectable,
zoomOnScroll, zoomOnScroll,
panOnScroll,
zoomOnDoubleClick, zoomOnDoubleClick,
paneMoveable, paneMoveable,
defaultPosition, defaultPosition,
@@ -61,7 +63,9 @@ const FlowRenderer = ({
onMoveStart, onMoveStart,
onMoveEnd, onMoveEnd,
selectionKeyPressed, selectionKeyPressed,
elementsSelectable,
zoomOnScroll, zoomOnScroll,
panOnScroll,
zoomOnDoubleClick, zoomOnDoubleClick,
paneMoveable, paneMoveable,
defaultPosition, defaultPosition,
+4
View File
@@ -70,6 +70,7 @@ export interface GraphViewProps {
arrowHeadColor: string; arrowHeadColor: string;
markerEndId?: string; markerEndId?: string;
zoomOnScroll?: boolean; zoomOnScroll?: boolean;
panOnScroll?: boolean;
zoomOnDoubleClick?: boolean; zoomOnDoubleClick?: boolean;
paneMoveable?: boolean; paneMoveable?: boolean;
} }
@@ -118,6 +119,7 @@ const GraphView = ({
arrowHeadColor, arrowHeadColor,
markerEndId, markerEndId,
zoomOnScroll, zoomOnScroll,
panOnScroll,
zoomOnDoubleClick, zoomOnDoubleClick,
paneMoveable, paneMoveable,
onPaneClick, onPaneClick,
@@ -245,11 +247,13 @@ const GraphView = ({
onElementsRemove={onElementsRemove} onElementsRemove={onElementsRemove}
deleteKeyCode={deleteKeyCode} deleteKeyCode={deleteKeyCode}
selectionKeyCode={selectionKeyCode} selectionKeyCode={selectionKeyCode}
elementsSelectable={elementsSelectable}
onMove={onMove} onMove={onMove}
onMoveStart={onMoveStart} onMoveStart={onMoveStart}
onMoveEnd={onMoveEnd} onMoveEnd={onMoveEnd}
zoomOnScroll={zoomOnScroll} zoomOnScroll={zoomOnScroll}
zoomOnDoubleClick={zoomOnDoubleClick} zoomOnDoubleClick={zoomOnDoubleClick}
panOnScroll={panOnScroll}
paneMoveable={paneMoveable} paneMoveable={paneMoveable}
defaultPosition={defaultPosition} defaultPosition={defaultPosition}
defaultZoom={defaultZoom} defaultZoom={defaultZoom}
+3
View File
@@ -98,6 +98,7 @@ export interface ReactFlowProps extends Omit<HTMLAttributes<HTMLDivElement>, 'on
arrowHeadColor?: string; arrowHeadColor?: string;
markerEndId?: string; markerEndId?: string;
zoomOnScroll?: boolean; zoomOnScroll?: boolean;
panOnScroll?: boolean;
zoomOnDoubleClick?: boolean; zoomOnDoubleClick?: boolean;
} }
@@ -147,6 +148,7 @@ const ReactFlow = ({
arrowHeadColor = '#b1b1b7', arrowHeadColor = '#b1b1b7',
markerEndId, markerEndId,
zoomOnScroll = true, zoomOnScroll = true,
panOnScroll = false,
zoomOnDoubleClick = true, zoomOnDoubleClick = true,
paneMoveable = true, paneMoveable = true,
onPaneClick, onPaneClick,
@@ -203,6 +205,7 @@ const ReactFlow = ({
markerEndId={markerEndId} markerEndId={markerEndId}
zoomOnScroll={zoomOnScroll} zoomOnScroll={zoomOnScroll}
zoomOnDoubleClick={zoomOnDoubleClick} zoomOnDoubleClick={zoomOnDoubleClick}
panOnScroll={panOnScroll}
paneMoveable={paneMoveable} paneMoveable={paneMoveable}
onPaneClick={onPaneClick} onPaneClick={onPaneClick}
onPaneScroll={onPaneScroll} onPaneScroll={onPaneScroll}
+41 -24
View File
@@ -6,7 +6,9 @@ import { FlowTransform, TranslateExtent } from '../types';
interface UseD3ZoomParams { interface UseD3ZoomParams {
zoomPane: MutableRefObject<Element | null>; zoomPane: MutableRefObject<Element | null>;
selectionKeyPressed: boolean; selectionKeyPressed: boolean;
elementsSelectable?: boolean;
zoomOnScroll?: boolean; zoomOnScroll?: boolean;
panOnScroll?: boolean;
zoomOnDoubleClick?: boolean; zoomOnDoubleClick?: boolean;
paneMoveable?: boolean; paneMoveable?: boolean;
defaultPosition?: [number, number]; defaultPosition?: [number, number];
@@ -34,8 +36,10 @@ export default ({
onMoveStart, onMoveStart,
onMoveEnd, onMoveEnd,
zoomOnScroll = true, zoomOnScroll = true,
panOnScroll = false,
zoomOnDoubleClick = true, zoomOnDoubleClick = true,
selectionKeyPressed, selectionKeyPressed,
elementsSelectable,
paneMoveable = true, paneMoveable = true,
defaultPosition = [0, 0], defaultPosition = [0, 0],
defaultZoom = 1, defaultZoom = 1,
@@ -46,6 +50,7 @@ export default ({
const initD3 = useStoreActions((actions) => actions.initD3); const initD3 = useStoreActions((actions) => actions.initD3);
const updateTransform = useStoreActions((actions) => actions.updateTransform); const updateTransform = useStoreActions((actions) => actions.updateTransform);
const updateTransformDelta = useStoreActions((actions) => actions.updateTransformDelta);
useEffect(() => { useEffect(() => {
if (zoomPane.current) { if (zoomPane.current) {
@@ -55,20 +60,22 @@ export default ({
useEffect(() => { useEffect(() => {
if (d3Zoom) { if (d3Zoom) {
if (selectionKeyPressed) { d3Zoom.on('zoom', (event: any) => {
d3Zoom.on('zoom', null); if (panOnScroll && event.sourceEvent.type === 'wheel') {
} else { const { deltaX, deltaY } = event.sourceEvent;
d3Zoom.on('zoom', (event: any) => { updateTransformDelta({ x: deltaX, y: deltaY });
} else {
updateTransform(event.transform); updateTransform(event.transform);
// todo: should we move this into the store or into a wrapper component with useEffect?
if (onMove) { if (onMove) {
const flowTransform = eventToFlowTransform(event.transform); const flowTransform = eventToFlowTransform(event.transform);
onMove(flowTransform); onMove(flowTransform);
} }
}); }
} });
} }
}, [selectionKeyPressed, d3Zoom, updateTransform, onMove]); }, [selectionKeyPressed, panOnScroll, d3Zoom, updateTransform, updateTransformDelta, onMove]);
useEffect(() => { useEffect(() => {
if (d3Zoom) { if (d3Zoom) {
@@ -107,34 +114,44 @@ export default ({
useEffect(() => { useEffect(() => {
if (d3Zoom) { if (d3Zoom) {
d3Zoom.filter((event: any) => { d3Zoom.filter((event: any) => {
if (selectionKeyPressed) { // if all interactions are disabled, we prevent all zoom events
if (!paneMoveable && !zoomOnScroll && !panOnScroll && !zoomOnDoubleClick) {
return false; return false;
} }
// only allow zoom on nodes // during a selection we prevent all other interactions
if (event.target.closest('.react-flow__node') && event.type !== 'wheel') { if (elementsSelectable && selectionKeyPressed) {
return false;
}
// only allow zoom on user selection
if (event.target.closest('.react-flow__nodesselection') && event.type !== 'wheel') {
return false;
}
if (!paneMoveable) {
return false;
}
if (!zoomOnScroll && event.type === 'wheel') {
return false; return false;
} }
// if zoom on double click is disabled, we prevent the double click event
if (!zoomOnDoubleClick && event.type === 'dblclick') { if (!zoomOnDoubleClick && event.type === 'dblclick') {
return false; return false;
} }
// when the target element is a node, we still allow zooming
if (event.target.closest('.react-flow__node') && 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;
}
// when there is no scroll handling enabled, we prevent all wheel events
if (!zoomOnScroll && !panOnScroll && event.type === 'wheel') {
return false;
}
// if the pane is not movable, we prevent dragging it with the mouse
if (!paneMoveable && event.type === 'mousedown') {
return false;
}
// default filter for d3-zoom, prevents zooming on buttons and when ctrl is pressed
return !event.ctrlKey && !event.button; return !event.ctrlKey && !event.button;
}); });
} }
}, [d3Zoom, zoomOnScroll, zoomOnDoubleClick, paneMoveable, selectionKeyPressed]); }, [d3Zoom, zoomOnScroll, panOnScroll, zoomOnDoubleClick, paneMoveable, selectionKeyPressed, elementsSelectable]);
}; };
+21
View File
@@ -37,6 +37,12 @@ type TransformXYK = {
k: number; k: number;
}; };
type TransformDeltas = {
x?: number;
y?: number;
k?: number;
};
type NodeDimensionUpdate = { type NodeDimensionUpdate = {
id: ElementId; id: ElementId;
nodeElement: HTMLDivElement; nodeElement: HTMLDivElement;
@@ -109,6 +115,7 @@ export interface StoreModel {
setSelectedElements: Action<StoreModel, Elements | Node | Edge>; setSelectedElements: Action<StoreModel, Elements | Node | Edge>;
updateTransform: Action<StoreModel, TransformXYK>; updateTransform: Action<StoreModel, TransformXYK>;
updateTransformDelta: Action<StoreModel, TransformDeltas>;
setInitTransform: Action<StoreModel, TransformXYK>; setInitTransform: Action<StoreModel, TransformXYK>;
@@ -351,6 +358,20 @@ export const storeModel: StoreModel = {
state.transform[2] = transform.k; state.transform[2] = transform.k;
}), }),
updateTransformDelta: action((state, deltas) => {
const { x = 0, y = 0, k = 0 } = deltas;
state.transform[0] += x;
state.transform[1] += y;
state.transform[2] += k;
if (state.d3Selection) {
const updatedTransform = zoomIdentity.translate(state.transform[0], state.transform[1]).scale(state.transform[2]);
// we need to sync the d3 zoom transform with the updated transform
state.d3Selection.property('__zoom', updatedTransform);
}
}),
setInitTransform: action((state, transform) => { setInitTransform: action((state, transform) => {
state.transform[0] = transform.x; state.transform[0] = transform.x;
state.transform[1] = transform.y; state.transform[1] = transform.y;