Skip to content

Instantly share code, notes, and snippets.

@StephenHaney
Created September 11, 2023 05:00
Show Gist options
  • Save StephenHaney/a4bde95c318c1491bec548f0fa3c04d8 to your computer and use it in GitHub Desktop.
Save StephenHaney/a4bde95c318c1491bec548f0fa3c04d8 to your computer and use it in GitHub Desktop.
Modulz AutoPan on dragging
import { autorun } from 'mobx';
import { observer } from 'mobx-react-lite';
import React, { useEffect, useRef } from 'react';
import { useAppState } from 'src/store/context/AppStateProvider';
/** Moves the pan of the canvas if a cursor leaves the edges of the canvas during a drag */
export const AutoPan = observer(() => {
const { hoverState, dragState, cameraState, toolState, selectionState } = useAppState();
/** A RAF for the autopan function */
const autoPanAnimationFrame = useRef<number>(0);
/**
* Stores the direction the canvas needs to pan in order to move towards a cursor that's off canvas.
* You can set it from 0-1 to get just a little pan or full strength. 0.5 = half speed pan
*/
const canvasToCursorVector = useRef<{ dX: number; dY: number }>({ dX: 0, dY: 0 });
useEffect(() => {
/** The number of window pixels to move the pan per frame */
const autoPanAmountPerFrame = 15;
/** How close the cursor needs to be to the edge before auto pan starts */
const triggerDistanceFromEdge = 30;
/** We start the pan slowly, so how far into the trigger do we hit max speed? */
const triggerDistanceUntilMaxSpeed = triggerDistanceFromEdge * 3;
/** Schedule up a pan before the next frame */
function schedulePan(e: PointerEvent) {
cancelAnimationFrame(autoPanAnimationFrame.current);
autoPanAnimationFrame.current = requestAnimationFrame(() => {
const { dX, dY } = canvasToCursorVector.current;
if (
dragState.isDragging === 'MoveTool' ||
dragState.isResizing ||
selectionState.isDrawingSelectionBrush ||
toolState.drawingNode !== null
) {
if (dX !== 0 || dY !== 0) {
// Set the new pan:
const panAmountX = dX * autoPanAmountPerFrame;
const panAmountY = dY * autoPanAmountPerFrame;
const newPan = { x: cameraState.pan.x - panAmountX, y: cameraState.pan.y - panAmountY };
cameraState.setPan(newPan);
// Keep RAFing pan checks until the mouse moves back into the canvas
schedulePan(e);
}
} else if (dX !== 0 || dY !== 0) {
// The mouse event ended but we still have dX or dY
// maybe due to an escape key or drop while the cursor was off canvas
// this is benign, but go ahead and clear them out in prep for the next mouse event
canvasToCursorVector.current.dX = 0;
canvasToCursorVector.current.dY = 0;
}
});
}
function onPointerMove(e: PointerEvent) {
if (
dragState.isDragging === 'MoveTool' ||
dragState.isResizing ||
selectionState.isDrawingSelectionBrush ||
toolState.drawingNode !== null
) {
// These are all arranged so the number is positive if the cursor is outside the trigger area
const deltaLeftTrigger = e.clientX - (cameraState.currentViewableWindowArea.minX + triggerDistanceFromEdge);
const deltaRightTrigger = cameraState.currentViewableWindowArea.maxX - triggerDistanceFromEdge - e.clientX;
const deltaTopTrigger = e.clientY - (cameraState.currentViewableWindowArea.minY + triggerDistanceFromEdge);
const deltaBottomTrigger = cameraState.currentViewableWindowArea.maxY - triggerDistanceFromEdge - e.clientY;
// The smoothing works like:
// Math.abs(distanceFromEdge) / triggerDistanceUntilMaxSpeed
// So the scroll starts very slow and gets progressively faster until you're
// however far PAST the edge, at which point you hit max speed
// Check for the need to auto pan
if (deltaLeftTrigger <= 0) {
const multiplier = Math.min(1, Math.abs(deltaLeftTrigger) / triggerDistanceUntilMaxSpeed);
canvasToCursorVector.current.dX = -multiplier;
} else if (deltaRightTrigger <= 0) {
const multiplier = Math.min(1, Math.abs(deltaRightTrigger) / triggerDistanceUntilMaxSpeed);
canvasToCursorVector.current.dX = multiplier;
} else {
canvasToCursorVector.current.dX = 0;
}
if (deltaTopTrigger <= 0) {
const multiplier = Math.min(1, Math.abs(deltaTopTrigger) / triggerDistanceUntilMaxSpeed);
canvasToCursorVector.current.dY = -multiplier;
} else if (deltaBottomTrigger <= 0) {
const multiplier = Math.min(1, Math.abs(deltaBottomTrigger) / triggerDistanceUntilMaxSpeed);
canvasToCursorVector.current.dY = multiplier;
} else {
canvasToCursorVector.current.dY = 0;
}
if (canvasToCursorVector.current.dX !== 0 || canvasToCursorVector.current.dY !== 0) {
// Schedule a pan on the next raf
schedulePan(e);
}
}
}
// Wire up the mouse watcher
window.addEventListener('pointermove', onPointerMove);
// Clean up
return () => {
window.removeEventListener('pointermove', onPointerMove);
};
}, [cameraState, dragState, toolState, selectionState, hoverState]);
return null;
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment