Selector.jsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import React from "react";
  2. import {
  3. atom,
  4. useRecoilValue,
  5. useSetRecoilState,
  6. useRecoilCallback,
  7. } from "recoil";
  8. import styled from "styled-components";
  9. import {
  10. PanZoomRotateAtom,
  11. BoardConfigAtom,
  12. ItemMapAtom,
  13. BoardStateAtom,
  14. } from "./";
  15. import { insideClass, isPointInsideRect } from "../../utils";
  16. import throttle from "lodash.throttle";
  17. import Gesture from "./Gesture";
  18. export const selectedItemsAtom = atom({
  19. key: "selectedItems",
  20. default: [],
  21. });
  22. const SelectorZone = styled.div.attrs(({ top, left, height, width }) => ({
  23. className: "selector",
  24. style: {
  25. transform: `translate(${left}px, ${top}px)`,
  26. height: `${height}px`,
  27. width: `${width}px`,
  28. },
  29. }))`
  30. z-index: 210;
  31. position: absolute;
  32. background-color: hsla(0, 40%, 50%, 10%);
  33. border: 2px solid hsl(0, 55%, 40%);
  34. `;
  35. const findSelected = (itemMap) => {
  36. const selector = document.body.querySelector(".selector");
  37. if (!selector) {
  38. return [];
  39. }
  40. const rect = selector.getBoundingClientRect();
  41. return Array.from(document.getElementsByClassName("item"))
  42. .filter((elem) => {
  43. const { id } = elem;
  44. const item = itemMap[id];
  45. if (!item) {
  46. // Avoid to find item that are not yet removed from DOM
  47. console.error(`Missing item ${id}`);
  48. return false;
  49. }
  50. if (item.locked) {
  51. return false;
  52. }
  53. const fourElem = Array.from(elem.querySelectorAll(".corner"));
  54. return fourElem.every((corner) => {
  55. const { top: y, left: x } = corner.getBoundingClientRect();
  56. return isPointInsideRect({ x, y }, rect);
  57. });
  58. })
  59. .map((elem) => elem.id);
  60. };
  61. const Selector = ({ children, moveFirst }) => {
  62. const setSelected = useSetRecoilState(selectedItemsAtom);
  63. const setBoardState = useSetRecoilState(BoardStateAtom);
  64. const [selector, setSelector] = React.useState({});
  65. const [emptySelection] = React.useState([]);
  66. const wrapperRef = React.useRef(null);
  67. const stateRef = React.useRef({
  68. moving: false,
  69. });
  70. const config = useRecoilValue(BoardConfigAtom);
  71. // Reset selection on game loading
  72. React.useEffect(() => {
  73. setSelected(emptySelection);
  74. }, [config, emptySelection, setSelected]);
  75. const throttledSetSelected = useRecoilCallback(
  76. ({ snapshot }) =>
  77. throttle(async () => {
  78. if (stateRef.current.moving) {
  79. const itemMap = await snapshot.getPromise(ItemMapAtom);
  80. const selected = findSelected(itemMap);
  81. setSelected((prevSelected) => {
  82. if (JSON.stringify(prevSelected) !== JSON.stringify(selected)) {
  83. return selected;
  84. }
  85. return prevSelected;
  86. });
  87. }
  88. }, 300),
  89. [setSelected]
  90. );
  91. React.useEffect(() => {
  92. throttledSetSelected();
  93. }, [selector, throttledSetSelected]);
  94. // Reset selected on unmount
  95. React.useEffect(() => {
  96. return () => {
  97. setSelected(emptySelection);
  98. };
  99. }, [setSelected, emptySelection]);
  100. const onDragStart = ({ button, altKey, ctrlKey, metaKey, target }) => {
  101. const outsideItem =
  102. !insideClass(target, "item") || insideClass(target, "locked");
  103. const metaKeyPressed = altKey || ctrlKey || metaKey;
  104. const goodButton = moveFirst
  105. ? button === 1 || (button === 0 && metaKeyPressed)
  106. : button === 0 && !metaKeyPressed;
  107. if (goodButton && (outsideItem || moveFirst)) {
  108. stateRef.current.moving = true;
  109. setBoardState((prev) => ({ ...prev, selecting: true }));
  110. wrapperRef.current.style.cursor = "crosshair";
  111. }
  112. };
  113. const onDrag = useRecoilCallback(
  114. ({ snapshot }) => async ({ distanceY, distanceX, startX, startY }) => {
  115. if (stateRef.current.moving) {
  116. const { top, left } = wrapperRef.current.getBoundingClientRect();
  117. const panZoomRotate = await snapshot.getPromise(PanZoomRotateAtom);
  118. const displayX = (startX - left) / panZoomRotate.scale;
  119. const displayY = (startY - top) / panZoomRotate.scale;
  120. const displayDistanceX = distanceX / panZoomRotate.scale;
  121. const displayDistanceY = distanceY / panZoomRotate.scale;
  122. if (displayDistanceX > 0) {
  123. stateRef.current.left = displayX;
  124. stateRef.current.width = displayDistanceX;
  125. } else {
  126. stateRef.current.left = displayX + displayDistanceX;
  127. stateRef.current.width = -displayDistanceX;
  128. }
  129. if (displayDistanceY > 0) {
  130. stateRef.current.top = displayY;
  131. stateRef.current.height = displayDistanceY;
  132. } else {
  133. stateRef.current.top = displayY + displayDistanceY;
  134. stateRef.current.height = -displayDistanceY;
  135. }
  136. setSelector({ ...stateRef.current, moving: true });
  137. }
  138. },
  139. []
  140. );
  141. const onDragEnd = () => {
  142. if (stateRef.current.moving) {
  143. setBoardState((prev) => ({ ...prev, selecting: false }));
  144. stateRef.current.moving = false;
  145. setSelector({ moving: false });
  146. wrapperRef.current.style.cursor = "auto";
  147. }
  148. };
  149. const onLongTap = React.useCallback(
  150. ({ target }) => {
  151. const foundElement = insideClass(target, "item");
  152. if (foundElement) {
  153. setSelected([foundElement.id]);
  154. }
  155. },
  156. [setSelected]
  157. );
  158. const onTap = useRecoilCallback(
  159. ({ snapshot }) => async ({ target, ctrlKey, metaKey }) => {
  160. const foundItem = insideClass(target, "item");
  161. if (
  162. (!foundItem || insideClass(foundItem, "locked")) &&
  163. insideClass(target, "board")
  164. ) {
  165. setSelected(emptySelection);
  166. } else {
  167. const selectedItems = await snapshot.getPromise(selectedItemsAtom);
  168. if (foundItem && !selectedItems.includes(foundItem.id)) {
  169. if (ctrlKey || metaKey) {
  170. setSelected((prev) => [...prev, foundItem.id]);
  171. } else {
  172. setSelected([foundItem.id]);
  173. }
  174. }
  175. }
  176. },
  177. [emptySelection, setSelected]
  178. );
  179. return (
  180. <Gesture
  181. onDragStart={onDragStart}
  182. onDrag={onDrag}
  183. onDragEnd={onDragEnd}
  184. onTap={onTap}
  185. onLongTap={onLongTap}
  186. >
  187. <div ref={wrapperRef}>
  188. {selector.moving && <SelectorZone {...selector} />}
  189. {children}
  190. </div>
  191. </Gesture>
  192. );
  193. };
  194. export default Selector;