SelectedItemsPane.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import React from "react";
  2. import styled from "styled-components";
  3. import { toast } from "react-toastify";
  4. import { useRecoilValue, useRecoilCallback } from "recoil";
  5. import { useItemActions } from "./boardComponents/useItemActions";
  6. import {
  7. selectedItemsAtom,
  8. PanZoomRotateAtom,
  9. BoardStateAtom,
  10. ItemMapAtom,
  11. } from "../components/Board/";
  12. import debounce from "lodash.debounce";
  13. import { insideClass, hasClass } from "../utils";
  14. import SidePanel from "../ui/SidePanel";
  15. import ItemFormFactory from "./boardComponents/ItemFormFactory";
  16. // import { confirmAlert } from "react-confirm-alert";
  17. import "react-confirm-alert/src/react-confirm-alert.css";
  18. import { useTranslation } from "react-i18next";
  19. const ActionPane = styled.div.attrs(({ top, left, height }) => {
  20. if (top < 120) {
  21. return {
  22. style: {
  23. transform: `translate(${left}px, ${top + height + 5}px)`,
  24. },
  25. };
  26. } else {
  27. return {
  28. style: {
  29. transform: `translate(${left}px, ${top - 60}px)`,
  30. },
  31. };
  32. }
  33. })`
  34. top: 0;
  35. left: 0;
  36. user-select: none;
  37. touch-action: none;
  38. position: absolute;
  39. display: flex;
  40. background-color: var(--color-blueGrey);
  41. justify-content: center;
  42. align-items: center;
  43. border-radius: 4px;
  44. padding: 0.1em 0.5em;
  45. transition: opacity 300ms;
  46. opacity: ${({ hide }) => (hide ? 0 : 0.9)};
  47. box-shadow: 2px 2px 10px 0.3px rgba(0, 0, 0, 0.5);
  48. &:hover{
  49. opacity: 1;
  50. }
  51. & button{
  52. margin 0 4px;
  53. padding: 0em;
  54. height: 50px
  55. }
  56. & .button.icon-only{
  57. padding: 0em;
  58. opacity: 0.5;
  59. }
  60. & button.icon-only:hover{
  61. opacity: 1;
  62. }
  63. & .count{
  64. color: var(--color-secondary);
  65. display: flex;
  66. flex-direction: column;
  67. align-items: center;
  68. line-height: 0.8em;
  69. }
  70. & .number{
  71. font-size: 1.5em;
  72. line-height: 1em;
  73. }
  74. `;
  75. const CardContent = styled.div.attrs(() => ({ className: "content" }))`
  76. display: flex;
  77. flex-direction: column;
  78. padding: 0.5em;
  79. `;
  80. const BoundingBoxZone = styled.div.attrs(({ top, left, height, width }) => ({
  81. style: {
  82. transform: `translate(${left}px, ${top}px)`,
  83. height: `${height}px`,
  84. width: `${width}px`,
  85. },
  86. }))`
  87. top: 0;
  88. left: 0;
  89. z-index: 6;
  90. position: absolute;
  91. background-color: hsla(0, 40%, 50%, 0%);
  92. border: 1px dashed hsl(20, 55%, 40%);
  93. pointer-events: none;
  94. `;
  95. const BoundingBox = ({
  96. boundingBoxLast,
  97. setBoundingBoxLast,
  98. selectedItems,
  99. }) => {
  100. const panZoomRotate = useRecoilValue(PanZoomRotateAtom);
  101. const itemMap = useRecoilValue(ItemMapAtom);
  102. // Update selection bounding box
  103. const updateBox = useRecoilCallback(
  104. ({ snapshot }) => async () => {
  105. const selectedItems = await snapshot.getPromise(selectedItemsAtom);
  106. if (selectedItems.length === 0) {
  107. setBoundingBoxLast(null);
  108. return;
  109. }
  110. let boundingBox = null;
  111. selectedItems.forEach((itemId) => {
  112. const elem = document.getElementById(itemId);
  113. if (!elem) return;
  114. const {
  115. right: x2,
  116. bottom: y2,
  117. top: y,
  118. left: x,
  119. } = elem.getBoundingClientRect();
  120. if (!boundingBox) {
  121. boundingBox = { x, y, x2, y2 };
  122. } else {
  123. if (x < boundingBox.x) {
  124. boundingBox.x = x;
  125. }
  126. if (y < boundingBox.y) {
  127. boundingBox.y = y;
  128. }
  129. if (x2 > boundingBox.x2) {
  130. boundingBox.x2 = x2;
  131. }
  132. if (y2 > boundingBox.y2) {
  133. boundingBox.y2 = y2;
  134. }
  135. }
  136. });
  137. if (!boundingBox) {
  138. setBoundingBoxLast(null);
  139. return;
  140. }
  141. const newBB = {
  142. top: boundingBox.y,
  143. left: boundingBox.x,
  144. height: boundingBox.y2 - boundingBox.y,
  145. width: boundingBox.x2 - boundingBox.x,
  146. };
  147. setBoundingBoxLast((prevBB) => {
  148. if (
  149. !prevBB ||
  150. prevBB.top !== newBB.top ||
  151. prevBB.left !== newBB.left ||
  152. prevBB.width !== newBB.width ||
  153. prevBB.height !== newBB.height
  154. ) {
  155. return newBB;
  156. }
  157. return prevBB;
  158. });
  159. },
  160. [setBoundingBoxLast]
  161. );
  162. // Debounced version of update box
  163. // eslint-disable-next-line react-hooks/exhaustive-deps
  164. const updateBoxDelay = React.useCallback(
  165. debounce(() => {
  166. updateBox();
  167. }, 300),
  168. [updateBox]
  169. );
  170. React.useEffect(() => {
  171. // Update selected elements bounding box
  172. updateBox();
  173. updateBoxDelay(); // Delay to update after board item animation like tap/untap.
  174. }, [selectedItems, itemMap, panZoomRotate, updateBox, updateBoxDelay]);
  175. if (!boundingBoxLast || selectedItems.length < 2) return null;
  176. return <BoundingBoxZone {...boundingBoxLast} />;
  177. };
  178. export const SelectedItemsPane = () => {
  179. const { availableActions, actionMap } = useItemActions();
  180. const [showEdit, setShowEdit] = React.useState(false);
  181. const { t } = useTranslation();
  182. const selectedItems = useRecoilValue(selectedItemsAtom);
  183. const boardState = useRecoilValue(BoardStateAtom);
  184. const [boundingBoxLast, setBoundingBoxLast] = React.useState(null);
  185. React.useEffect(() => {
  186. const onKeyUp = (e) => {
  187. // Block shortcut if we are typing in a textarea or input
  188. if (["INPUT", "TEXTAREA"].includes(e.target.tagName)) return;
  189. Object.values(actionMap).forEach(
  190. ({ shortcut, action, edit: whileEdit }) => {
  191. if (e.key === shortcut && showEdit === !!whileEdit) {
  192. action();
  193. }
  194. }
  195. );
  196. };
  197. document.addEventListener("keyup", onKeyUp);
  198. return () => {
  199. document.removeEventListener("keyup", onKeyUp);
  200. };
  201. }, [actionMap, showEdit]);
  202. const onDblClick = React.useCallback(
  203. (e) => {
  204. const foundElement = insideClass(e.target, "item");
  205. // We dblclick outside of an element
  206. if (!foundElement) return;
  207. if (hasClass(foundElement, "locked")) {
  208. toast.info(t("Long click to select locked elements"));
  209. return;
  210. }
  211. const filteredActions = availableActions.filter(
  212. (action) => !actionMap[action].disableDblclick
  213. );
  214. if (e.ctrlKey && filteredActions.length > 1) {
  215. // Use second action
  216. actionMap[filteredActions[1]].action();
  217. } else {
  218. if (filteredActions.length > 0) {
  219. actionMap[filteredActions[0]].action();
  220. }
  221. }
  222. },
  223. [actionMap, availableActions, t]
  224. );
  225. React.useEffect(() => {
  226. document.addEventListener("dblclick", onDblClick);
  227. return () => {
  228. document.removeEventListener("dblclick", onDblClick);
  229. };
  230. }, [onDblClick]);
  231. if (selectedItems.length === 0) {
  232. return null;
  233. }
  234. // Keep this code for later
  235. /*const onRemove = () => {
  236. confirmAlert({
  237. title: t("Confirmation"),
  238. message: t("Do you really want to remove selected items ?"),
  239. buttons: [
  240. {
  241. label: t("Yes"),
  242. onClick: remove,
  243. },
  244. {
  245. label: t("No"),
  246. onClick: () => {},
  247. },
  248. ],
  249. });
  250. };*/
  251. return (
  252. <>
  253. {showEdit && !boardState.selecting && (
  254. <SidePanel
  255. key={selectedItems[0]}
  256. onClose={() => {
  257. setShowEdit(false);
  258. }}
  259. >
  260. <div>
  261. <header>
  262. {selectedItems.length === 1 && <h3>{t("Edit item")}</h3>}
  263. {selectedItems.length > 1 && <h3>{t("Edit all items")}</h3>}
  264. </header>
  265. <CardContent>
  266. <ItemFormFactory />
  267. </CardContent>
  268. </div>
  269. </SidePanel>
  270. )}
  271. {selectedItems.length && (
  272. <ActionPane
  273. {...boundingBoxLast}
  274. hide={
  275. boardState.zooming || boardState.panning || boardState.movingItems
  276. }
  277. >
  278. {(selectedItems.length > 1 || boardState.selecting) && (
  279. <div className="count">
  280. <span className="number">{selectedItems.length}</span>
  281. <span>{t("Items")}</span>
  282. </div>
  283. )}
  284. {!boardState.selecting &&
  285. availableActions.map((action) => {
  286. const {
  287. label,
  288. action: handler,
  289. multiple,
  290. edit: onlyEdit,
  291. icon,
  292. } = actionMap[action];
  293. if (multiple && selectedItems.length < 2) return null;
  294. if (onlyEdit && !showEdit) return null;
  295. return (
  296. <button
  297. className="button clear icon-only"
  298. key={action}
  299. onClick={handler}
  300. title={label}
  301. >
  302. <img
  303. src={icon}
  304. style={{ width: "32px", height: "32px" }}
  305. alt={label}
  306. />
  307. </button>
  308. );
  309. })}
  310. {!boardState.selecting && (
  311. <button
  312. className="button clear icon-only"
  313. onClick={() => setShowEdit((prev) => !prev)}
  314. title={t("Edit")}
  315. >
  316. {!showEdit && (
  317. <img
  318. src="https://icongr.am/feather/edit.svg?size=32&color=ffffff"
  319. alt={t("Edit")}
  320. />
  321. )}
  322. {showEdit && (
  323. <img
  324. src="https://icongr.am/feather/edit.svg?size=32&color=db5034"
  325. alt={t("Edit")}
  326. />
  327. )}
  328. </button>
  329. )}
  330. </ActionPane>
  331. )}
  332. <BoundingBox
  333. boundingBoxLast={boundingBoxLast}
  334. setBoundingBoxLast={setBoundingBoxLast}
  335. selectedItems={selectedItems}
  336. />
  337. </>
  338. );
  339. };
  340. export default SelectedItemsPane;