PanZoomRotate.jsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import React from "react";
  2. import {
  3. useRecoilState,
  4. useRecoilValue,
  5. useSetRecoilState,
  6. useRecoilCallback,
  7. } from "recoil";
  8. import {
  9. BoardConfigAtom,
  10. BoardStateAtom,
  11. PanZoomRotateAtom,
  12. SelectedItemsAtom,
  13. } from "./";
  14. import { insideClass } from "../utils";
  15. import usePrevious from "../hooks/usePrevious";
  16. import styled from "styled-components";
  17. import debounce from "lodash.debounce";
  18. import Gesture from "./Gesture";
  19. import usePositionNavigator from "./usePositionNavigator";
  20. const TOLERANCE = 100;
  21. const Pane = styled.div.attrs(({ translateX, translateY, scale, rotate }) => ({
  22. style: {
  23. transform: `translate(${translateX}px, ${translateY}px) scale(${scale}) rotate(${rotate}deg)`,
  24. },
  25. className: "board-pane",
  26. }))`
  27. transform-origin: top left;
  28. display: inline-block;
  29. `;
  30. const PanZoomRotate = ({ children, moveFirst }) => {
  31. const [scaleBoundaries, setScaleBoundaries] = React.useState([0.1, 8]);
  32. const [dim, setDim] = useRecoilState(PanZoomRotateAtom);
  33. const config = useRecoilValue(BoardConfigAtom);
  34. const setBoardState = useSetRecoilState(BoardStateAtom);
  35. const prevDim = usePrevious(dim);
  36. // Hooks to save/restore position
  37. usePositionNavigator();
  38. const [scale, setScale] = React.useState({
  39. scale: 1,
  40. x: 0,
  41. y: 0,
  42. });
  43. const wrappedRef = React.useRef(null);
  44. // React on scale change
  45. React.useLayoutEffect(() => {
  46. setDim((prevDim) => {
  47. const { top, left } = wrappedRef.current.getBoundingClientRect();
  48. const displayX = scale.x - left;
  49. const deltaX = displayX - (displayX / prevDim.scale) * scale.scale;
  50. const displayY = scale.y - top;
  51. const deltaY = displayY - (displayY / prevDim.scale) * scale.scale;
  52. return {
  53. ...prevDim,
  54. scale: scale.scale,
  55. translateX: prevDim.translateX + deltaX,
  56. translateY: prevDim.translateY + deltaY,
  57. };
  58. });
  59. }, [scale, setDim]);
  60. // Center board on game loading
  61. React.useEffect(() => {
  62. const { innerHeight, innerWidth } = window;
  63. const minSize = Math.min(innerHeight, innerWidth);
  64. const newScale = (minSize / config.size) * 0.8;
  65. setScaleBoundaries([newScale * 0.8, Math.max(newScale * 30, 8)]);
  66. setDim((prev) => ({
  67. ...prev,
  68. scale: newScale,
  69. translateX: innerWidth / 2 - (config.size / 2) * newScale,
  70. translateY: innerHeight / 2 - (config.size / 2) * newScale,
  71. }));
  72. setScale((prev) => {
  73. return { ...prev, scale: newScale, x: 0, y: 0 };
  74. });
  75. // We only want to do it at component mount
  76. // eslint-disable-next-line react-hooks/exhaustive-deps
  77. }, [config.size]);
  78. // Keep board inside viewport
  79. React.useEffect(() => {
  80. const { width, height } = wrappedRef.current.getBoundingClientRect();
  81. const { innerHeight, innerWidth } = window;
  82. const newDim = {};
  83. if (dim.translateX > innerWidth - TOLERANCE) {
  84. newDim.translateX = innerWidth - TOLERANCE;
  85. }
  86. if (dim.translateX + width < TOLERANCE) {
  87. newDim.translateX = TOLERANCE - width;
  88. }
  89. if (dim.translateY > innerHeight - TOLERANCE) {
  90. newDim.translateY = innerHeight - TOLERANCE;
  91. }
  92. if (dim.translateY + height < TOLERANCE) {
  93. newDim.translateY = TOLERANCE - height;
  94. }
  95. if (Object.keys(newDim).length > 0) {
  96. setDim((prevDim) => ({
  97. ...prevDim,
  98. ...newDim,
  99. }));
  100. }
  101. }, [dim.translateX, dim.translateY, setDim]);
  102. // Debounce set center to avoid too many render
  103. // eslint-disable-next-line react-hooks/exhaustive-deps
  104. const debouncedUpdateCenter = React.useCallback(
  105. debounce(() => {
  106. const { innerHeight, innerWidth } = window;
  107. setDim((prevDim) => {
  108. return {
  109. ...prevDim,
  110. centerX: (innerWidth / 2 - prevDim.translateX) / prevDim.scale,
  111. centerY: (innerHeight / 2 - prevDim.translateY) / prevDim.scale,
  112. };
  113. });
  114. }, 300),
  115. [setDim]
  116. );
  117. React.useEffect(() => {
  118. debouncedUpdateCenter();
  119. }, [debouncedUpdateCenter, dim.translateX, dim.translateY]);
  120. const zoomTo = React.useCallback(
  121. (factor, zoomCenter) => {
  122. let center = zoomCenter;
  123. if (!center) {
  124. const { innerHeight, innerWidth } = window;
  125. center = {
  126. x: innerWidth / 2,
  127. y: innerHeight / 2,
  128. };
  129. }
  130. setScale((prevScale) => {
  131. let newScale = prevScale.scale * factor;
  132. if (newScale > scaleBoundaries[1]) {
  133. newScale = scaleBoundaries[1];
  134. }
  135. if (newScale < scaleBoundaries[0]) {
  136. newScale = scaleBoundaries[0];
  137. }
  138. return {
  139. scale: newScale,
  140. ...center,
  141. };
  142. });
  143. },
  144. [scaleBoundaries]
  145. );
  146. // eslint-disable-next-line react-hooks/exhaustive-deps
  147. const updateBoardStateZoomingDelay = React.useCallback(
  148. debounce((newState) => {
  149. setBoardState(newState);
  150. }, 300),
  151. [setBoardState]
  152. );
  153. // eslint-disable-next-line react-hooks/exhaustive-deps
  154. const updateBoardStatePanningDelay = React.useCallback(
  155. debounce((newState) => {
  156. setBoardState(newState);
  157. }, 200),
  158. [setBoardState]
  159. );
  160. // Update boardState on zoom or pan
  161. React.useEffect(() => {
  162. if (!prevDim) {
  163. return;
  164. }
  165. if (prevDim.scale !== dim.scale) {
  166. setBoardState((prev) =>
  167. !prev.zooming ? { ...prev, zooming: true } : prev
  168. );
  169. updateBoardStateZoomingDelay((prev) =>
  170. prev.zooming ? { ...prev, zooming: false } : prev
  171. );
  172. }
  173. if (
  174. prevDim.translateY !== dim.translateY ||
  175. prevDim.translateX !== dim.translateX
  176. ) {
  177. setBoardState((prev) =>
  178. !prev.panning ? { ...prev, panning: true } : prev
  179. );
  180. updateBoardStatePanningDelay((prev) =>
  181. prev.panning ? { ...prev, panning: false } : prev
  182. );
  183. }
  184. // eslint-disable-next-line react-hooks/exhaustive-deps
  185. }, [dim, updateBoardStatePanningDelay, updateBoardStateZoomingDelay]);
  186. const onZoom = React.useCallback(
  187. ({ clientX, clientY, scale }) => {
  188. zoomTo(1 - scale / 200, { x: clientX, y: clientY });
  189. },
  190. [zoomTo]
  191. );
  192. const onPan = React.useCallback(
  193. ({ deltaX, deltaY }) => {
  194. setDim((prevDim) => {
  195. return {
  196. ...prevDim,
  197. translateX: prevDim.translateX + deltaX,
  198. translateY: prevDim.translateY + deltaY,
  199. };
  200. });
  201. },
  202. [setDim]
  203. );
  204. const onDrag = React.useCallback(
  205. (state) => {
  206. const { target } = state;
  207. const outsideItem =
  208. !insideClass(target, "item") || insideClass(target, "locked");
  209. if (moveFirst && outsideItem) {
  210. onPan(state);
  211. }
  212. },
  213. [moveFirst, onPan]
  214. );
  215. const onKeyDown = useRecoilCallback(
  216. ({ snapshot }) => async (e) => {
  217. // Block shortcut if we are typing in a textarea or input
  218. if (["INPUT", "TEXTAREA"].includes(e.target.tagName)) return;
  219. let moveX = 0;
  220. let moveY = 0;
  221. let zoom = 1;
  222. switch (e.key) {
  223. case "ArrowLeft":
  224. moveX = 10;
  225. break;
  226. case "ArrowRight":
  227. moveX = -10;
  228. break;
  229. case "ArrowUp":
  230. moveY = 10;
  231. break;
  232. case "ArrowDown":
  233. moveY = -10;
  234. break;
  235. case "PageUp":
  236. zoom = 1.2;
  237. break;
  238. case "PageDown":
  239. zoom = 0.8;
  240. break;
  241. }
  242. if (moveX || moveY || zoom !== 1) {
  243. // Don't move board if moving item
  244. const selectedItems = await snapshot.getPromise(SelectedItemsAtom);
  245. if (zoom === 1 && selectedItems.length) {
  246. return;
  247. }
  248. if (e.shiftKey) {
  249. moveX = moveX * 5;
  250. moveY = moveY * 5;
  251. }
  252. if (e.ctrlKey || e.altKey || e.metaKey) {
  253. moveX = moveX / 5;
  254. moveY = moveY / 5;
  255. }
  256. setDim((prev) => ({
  257. ...prev,
  258. translateY: prev.translateY + moveY,
  259. translateX: prev.translateX + moveX,
  260. }));
  261. zoomTo(zoom);
  262. e.preventDefault();
  263. }
  264. // Temporally zoom
  265. if (e.key === " " && !e.repeat) {
  266. zoomTo(3);
  267. }
  268. },
  269. [setDim, zoomTo]
  270. );
  271. const onKeyUp = React.useCallback(
  272. (e) => {
  273. // Zoom out on release
  274. if (e.key === " ") {
  275. zoomTo(1 / 3);
  276. }
  277. },
  278. [zoomTo]
  279. );
  280. React.useEffect(() => {
  281. document.addEventListener("keydown", onKeyDown);
  282. document.addEventListener("keyup", onKeyUp);
  283. return () => {
  284. document.removeEventListener("keydown", onKeyDown);
  285. document.removeEventListener("keyup", onKeyUp);
  286. };
  287. }, [onKeyDown, onKeyUp]);
  288. return (
  289. <Gesture onPan={onPan} onZoom={onZoom} onDrag={onDrag}>
  290. <Pane
  291. {...dim}
  292. ref={wrappedRef}
  293. onContextMenu={(e) => {
  294. e.preventDefault();
  295. }}
  296. >
  297. {children}
  298. </Pane>
  299. </Gesture>
  300. );
  301. };
  302. export default PanZoomRotate;