PanZoomRotate.js 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. import React from "react";
  2. import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil";
  3. import { BoardConfigAtom, BoardStateAtom, PanZoomRotateAtom } from "./";
  4. import { insideClass } from "../../utils/";
  5. import usePrevious from "../../hooks/usePrevious";
  6. import styled from "styled-components";
  7. import debounce from "lodash.debounce";
  8. import Gesture from "./Gesture";
  9. const TOLERANCE = 100;
  10. const Pane = styled.div.attrs(({ translateX, translateY, scale, rotate }) => ({
  11. style: {
  12. transform: `translate(${translateX}px, ${translateY}px) scale(${scale}) rotate(${rotate}deg)`,
  13. },
  14. className: "board-pane",
  15. }))`
  16. transform-origin: top left;
  17. display: inline-block;
  18. `;
  19. const PanZoomRotate = ({ children, moveFirst }) => {
  20. const [dim, setDim] = useRecoilState(PanZoomRotateAtom);
  21. const config = useRecoilValue(BoardConfigAtom);
  22. const setBoardState = useSetRecoilState(BoardStateAtom);
  23. const prevDim = usePrevious(dim);
  24. const [scale, setScale] = React.useState({
  25. scale: config.scale,
  26. x: 0,
  27. y: 0,
  28. });
  29. const wrappedRef = React.useRef(null);
  30. // React on scale change
  31. React.useLayoutEffect(() => {
  32. setDim((prevDim) => {
  33. const { top, left } = wrappedRef.current.getBoundingClientRect();
  34. const displayX = scale.x - left;
  35. const deltaX = displayX - (displayX / prevDim.scale) * scale.scale;
  36. const displayY = scale.y - top;
  37. const deltaY = displayY - (displayY / prevDim.scale) * scale.scale;
  38. return {
  39. ...prevDim,
  40. scale: scale.scale,
  41. translateX: prevDim.translateX + deltaX,
  42. translateY: prevDim.translateY + deltaY,
  43. };
  44. });
  45. }, [scale, setDim]);
  46. // Center board on game loading
  47. React.useEffect(() => {
  48. const { innerHeight, innerWidth } = window;
  49. setDim((prev) => ({
  50. ...prev,
  51. scale: config.scale,
  52. translateX: innerWidth / 2 - (config.size / 2) * config.scale,
  53. translateY: innerHeight / 2 - (config.size / 2) * config.scale,
  54. }));
  55. }, [config.size, config.scale, setDim]);
  56. // Keep board inside viewport
  57. React.useEffect(() => {
  58. const { width, height } = wrappedRef.current.getBoundingClientRect();
  59. const { innerHeight, innerWidth } = window;
  60. const newDim = {};
  61. if (dim.translateX > innerWidth - TOLERANCE) {
  62. newDim.translateX = innerWidth - TOLERANCE;
  63. }
  64. if (dim.translateX + width < TOLERANCE) {
  65. newDim.translateX = TOLERANCE - width;
  66. }
  67. if (dim.translateY > innerHeight - TOLERANCE) {
  68. newDim.translateY = innerHeight - TOLERANCE;
  69. }
  70. if (dim.translateY + height < TOLERANCE) {
  71. newDim.translateY = TOLERANCE - height;
  72. }
  73. if (Object.keys(newDim).length > 0) {
  74. setDim((prevDim) => ({
  75. ...prevDim,
  76. ...newDim,
  77. }));
  78. }
  79. }, [dim.translateX, dim.translateY, setDim]);
  80. // Debounce set center to avoid too many render
  81. // eslint-disable-next-line react-hooks/exhaustive-deps
  82. const debouncedUpdateCenter = React.useCallback(
  83. debounce(() => {
  84. const { innerHeight, innerWidth } = window;
  85. setDim((prevDim) => {
  86. return {
  87. ...prevDim,
  88. centerX: (innerWidth / 2 - prevDim.translateX) / prevDim.scale,
  89. centerY: (innerHeight / 2 - prevDim.translateY) / prevDim.scale,
  90. };
  91. });
  92. }, 300),
  93. [setDim]
  94. );
  95. React.useEffect(() => {
  96. debouncedUpdateCenter();
  97. }, [debouncedUpdateCenter, dim.translateX, dim.translateY]);
  98. // eslint-disable-next-line react-hooks/exhaustive-deps
  99. const updateBoardStateZoomingDelay = React.useCallback(
  100. debounce((newState) => {
  101. setBoardState(newState);
  102. }, 300),
  103. [setBoardState]
  104. );
  105. // eslint-disable-next-line react-hooks/exhaustive-deps
  106. const updateBoardStatePanningDelay = React.useCallback(
  107. debounce((newState) => {
  108. setBoardState(newState);
  109. }, 200),
  110. [setBoardState]
  111. );
  112. // Update boardState on zoom or pan
  113. React.useEffect(() => {
  114. if (!prevDim) {
  115. return;
  116. }
  117. if (prevDim.scale !== dim.scale) {
  118. setBoardState((prev) =>
  119. !prev.zooming ? { ...prev, zooming: true } : prev
  120. );
  121. updateBoardStateZoomingDelay((prev) =>
  122. prev.zooming ? { ...prev, zooming: false } : prev
  123. );
  124. }
  125. if (
  126. prevDim.translateY !== dim.translateY ||
  127. prevDim.translateX !== dim.translateX
  128. ) {
  129. setBoardState((prev) =>
  130. !prev.panning ? { ...prev, panning: true } : prev
  131. );
  132. updateBoardStatePanningDelay((prev) =>
  133. prev.panning ? { ...prev, panning: false } : prev
  134. );
  135. }
  136. // eslint-disable-next-line react-hooks/exhaustive-deps
  137. }, [dim, updateBoardStatePanningDelay, updateBoardStateZoomingDelay]);
  138. const onZoom = ({ clientX, clientY, scale }) => {
  139. setScale((prevScale) => {
  140. let newScale = prevScale.scale * (1 - scale / 200);
  141. if (newScale > 8) {
  142. newScale = 8;
  143. }
  144. if (newScale < 0.1) {
  145. newScale = 0.1;
  146. }
  147. return {
  148. scale: newScale,
  149. x: clientX,
  150. y: clientY,
  151. };
  152. });
  153. };
  154. const onPan = ({ deltaX, deltaY }) => {
  155. setDim((prevDim) => {
  156. return {
  157. ...prevDim,
  158. translateX: prevDim.translateX + deltaX,
  159. translateY: prevDim.translateY + deltaY,
  160. };
  161. });
  162. };
  163. const onDrag = (state) => {
  164. const { target } = state;
  165. const outsideItem =
  166. !insideClass(target, "item") || insideClass(target, "locked");
  167. if (moveFirst && outsideItem) {
  168. onPan(state);
  169. }
  170. };
  171. return (
  172. <Gesture onPan={onPan} onZoom={onZoom} onDrag={onDrag}>
  173. <Pane {...dim} ref={wrappedRef}>
  174. {children}
  175. </Pane>
  176. </Gesture>
  177. );
  178. };
  179. export default PanZoomRotate;