Selector.jsx 5.8 KB

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