Kaynağa Gözat

Add screen item

Jeremie Pardou-Piquemal 2 yıl önce
ebeveyn
işleme
e6cf0705a4

+ 7 - 2
cypress/integration/studio.spec.js

@@ -164,11 +164,16 @@ describe("Studio", () => {
       cy.get(".item").click({ force: true });
       cy.get("button img[alt^='Edit']").click({ force: true });
 
-      cy.get('input[name="locked"]').click();
+      cy.get("input[name='locked']").click();
 
       cy.get(".item").should("have.class", "locked");
 
-      cy.contains("New").click();
+      cy.get(".board").click({
+        scrollBehavior: false,
+        force: true,
+      });
+
+      // cy.contains("New").click();
 
       cy.get(".item").click({ force: true });
 

+ 15 - 15
package-lock.json

@@ -39,10 +39,10 @@
         "react-query": "^3.13.4",
         "react-router": "^5.2.0",
         "react-router-dom": "^5.2.0",
-        "react-sync-board": "^0.4.5",
+        "react-sync-board": "^0.5.3",
         "react-toastify": "^6.1.0",
         "react-useportal": "^1.0.14",
-        "recoil": "^0.3.1",
+        "recoil": "^0.5.2",
         "socket.io-client": "^4.1.2",
         "styled-components": "^5.3.0",
         "use-async-effect": "^2.2.3"
@@ -11667,9 +11667,9 @@
       }
     },
     "node_modules/react-sync-board": {
-      "version": "0.4.5",
-      "resolved": "https://registry.npmjs.org/react-sync-board/-/react-sync-board-0.4.5.tgz",
-      "integrity": "sha512-YsffM8deD3kZpX2S8wWYUtufyhSOd0scxcED61UgDqztYOdrfQY7vobERwvH3vLWa+xN9/BY0rBcctuqMA1Bdg==",
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/react-sync-board/-/react-sync-board-0.5.3.tgz",
+      "integrity": "sha512-yvlDQvdfvAvu3H5MYZhORcBk0fYPYGT5A2DR3u7y7hWa3LZy3OD/4e5VrEJYktLj2MT8M8YPZ0L1BqfG/S5t7w==",
       "dependencies": {
         "@emotion/react": "^11.4.0",
         "@emotion/styled": "^11.3.0",
@@ -11684,7 +11684,7 @@
       "peerDependencies": {
         "react": ">= 17",
         "react-dom": ">= 17",
-        "recoil": ">= 0.3.1"
+        "recoil": "^0.5.2"
       }
     },
     "node_modules/react-sync-board/node_modules/randomcolor": {
@@ -11771,9 +11771,9 @@
       }
     },
     "node_modules/recoil": {
-      "version": "0.3.1",
-      "resolved": "https://registry.npmjs.org/recoil/-/recoil-0.3.1.tgz",
-      "integrity": "sha512-KNA3DRqgxX4rRC8E7fc6uIw7BACmMPuraIYy+ejhE8tsw7w32CetMm8w7AMZa34wzanKKkev3vl3H7Z4s0QSiA==",
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/recoil/-/recoil-0.5.2.tgz",
+      "integrity": "sha512-Edibzpu3dbUMLy6QRg73WL8dvMl9Xqhp+kU+f2sJtXxsaXvAlxU/GcnDE8HXPkprXrhHF2e6SZozptNvjNF5fw==",
       "dependencies": {
         "hamt_plus": "1.0.2"
       },
@@ -23186,9 +23186,9 @@
       }
     },
     "react-sync-board": {
-      "version": "0.4.5",
-      "resolved": "https://registry.npmjs.org/react-sync-board/-/react-sync-board-0.4.5.tgz",
-      "integrity": "sha512-YsffM8deD3kZpX2S8wWYUtufyhSOd0scxcED61UgDqztYOdrfQY7vobERwvH3vLWa+xN9/BY0rBcctuqMA1Bdg==",
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/react-sync-board/-/react-sync-board-0.5.3.tgz",
+      "integrity": "sha512-yvlDQvdfvAvu3H5MYZhORcBk0fYPYGT5A2DR3u7y7hWa3LZy3OD/4e5VrEJYktLj2MT8M8YPZ0L1BqfG/S5t7w==",
       "requires": {
         "@emotion/react": "^11.4.0",
         "@emotion/styled": "^11.3.0",
@@ -23273,9 +23273,9 @@
       }
     },
     "recoil": {
-      "version": "0.3.1",
-      "resolved": "https://registry.npmjs.org/recoil/-/recoil-0.3.1.tgz",
-      "integrity": "sha512-KNA3DRqgxX4rRC8E7fc6uIw7BACmMPuraIYy+ejhE8tsw7w32CetMm8w7AMZa34wzanKKkev3vl3H7Z4s0QSiA==",
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/recoil/-/recoil-0.5.2.tgz",
+      "integrity": "sha512-Edibzpu3dbUMLy6QRg73WL8dvMl9Xqhp+kU+f2sJtXxsaXvAlxU/GcnDE8HXPkprXrhHF2e6SZozptNvjNF5fw==",
       "requires": {
         "hamt_plus": "1.0.2"
       }

+ 2 - 2
package.json

@@ -34,10 +34,10 @@
     "react-query": "^3.13.4",
     "react-router": "^5.2.0",
     "react-router-dom": "^5.2.0",
-    "react-sync-board": "^0.4.5",
+    "react-sync-board": "^0.5.3",
     "react-toastify": "^6.1.0",
     "react-useportal": "^1.0.14",
-    "recoil": "^0.3.1",
+    "recoil": "^0.5.2",
     "socket.io-client": "^4.1.2",
     "styled-components": "^5.3.0",
     "use-async-effect": "^2.2.3"

+ 8 - 0
src/gameComponents/Counter.jsx

@@ -8,17 +8,25 @@ const CounterPane = styled.div`
     text-align: center;
     border-radius: 3px;
     box-shadow: 4px 4px 5px 0px rgb(0, 0, 0, 0.3);
+
+    .item-library__component & {
+      transform: scale(0.7);
+    }
+
     button {
       padding: 1rem;
     }
+
     input {
       width: 2em;
     }
+
     h3 {
       user-select: none;
       padding: 0;
       margin: 0;
     }
+
     div {
       display: flex;
       justify-content: space-between;

+ 6 - 0
src/gameComponents/Dice.jsx

@@ -13,11 +13,17 @@ const DicePane = styled.div`
     align-items: center;
     border-radius: 3px;
     box-shadow: 3px 3px 8px 0px rgb(0, 0, 0, 0.3);
+
     & h3 {
       user-select: none;
       padding: 0;
       margin: 0;
     }
+
+    .item-library__component & {
+      transform: scale(0.8);
+    }
+
     &
       input:not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="color"]):not([type="button"]):not([type="reset"]).result {
       width: 3em;

+ 15 - 11
src/gameComponents/Generator.jsx

@@ -21,11 +21,13 @@ const StyledShape = styled.div`
       opacity: 0.3;
       position: relative;
     }
+
     & .item-wrapper {
       position: absolute;
       top: ${({ center: { top } }) => `${top}px`};
       left: ${({ center: { left } }) => `${left}px`};
     }
+
     & .handle {
       position: absolute;
       top: -15px;
@@ -35,6 +37,18 @@ const StyledShape = styled.div`
         pointer-events: none;
       }
     }
+
+    & .generator__empty-message {
+      display: block;
+      width: 60px;
+      height: 60px;
+      font-size: 0.65em;
+      text-align: center;
+
+      .item-library__component & {
+        visibility: hidden;
+      }
+    }
   `}
 `;
 
@@ -263,17 +277,7 @@ const Generator = ({ color = "#ccc", item, id, currentItemId, setState }) => {
 
   // Define item component if type is defined
   let Item = () => (
-    <div
-      style={{
-        display: "block",
-        width: "60px",
-        height: "60px",
-        fontSize: "0.65em",
-        textAlign: "center",
-      }}
-    >
-      {t("No item type defined")}
-    </div>
+    <div className="generator__empty-message">{t("No item type defined")}</div>
   );
   if (item) {
     const itemTemplate = itemTemplates[item.type];

+ 20 - 4
src/gameComponents/Note.jsx

@@ -13,12 +13,17 @@ const NotePane = styled.div`
     box-shadow: 5px 5px 7px rgba(33, 33, 33, 0.7);
     color: ${textColor};
 
-    & h3 {
+    .item-library__component & {
+      background-color: #ccc;
+    }
+
+    & .note__title {
       font-weight: bold;
       padding: 0.2em 0;
       margin: 0;
     }
-    & textarea {
+
+    & .note__textarea {
       height: ${height}px;
       font-family: "Satisfy", cursive;
       width: 100%;
@@ -27,6 +32,10 @@ const NotePane = styled.div`
       resize: none;
       border: 1px solid #00000011;
       font-size: ${fontSize}px;
+
+      .item-library__component & {
+        height: 35px;
+      }
     }
   `}
 `;
@@ -74,10 +83,17 @@ const Note = ({
       onWheel={(e) =>
         e.target === document.activeElement && e.stopPropagation()
       }
+      onPointerMove={(e) =>
+        e.target === document.activeElement && e.stopPropagation()
+      }
     >
       <label style={{ userSelect: "none" }}>
-        <h3>{label}</h3>
-        <textarea value={currentValue} onChange={setValue} />
+        <h3 className="note__title">{label}</h3>
+        <textarea
+          className="note__textarea"
+          value={currentValue}
+          onChange={setValue}
+        />
       </label>
     </NotePane>
   );

+ 147 - 0
src/gameComponents/Screen.jsx

@@ -0,0 +1,147 @@
+import React from "react";
+import { memo } from "react";
+import styled, { css } from "styled-components";
+import { useUsers } from "react-sync-board";
+import { useTranslation } from "react-i18next";
+import { readableColor, lighten } from "color2k";
+
+const ScreenWrapper = styled.div`
+  ${({
+    width = 200,
+    height = 200,
+    borderColor = "#cccccc33",
+    borderStyle = "solid",
+    backgroundColor = "#ccc",
+    owned = false,
+  }) => css`
+    width: ${width}px;
+    height: ${height}px;
+    ${owned ? `border: 2px ${borderStyle} ${borderColor};` : ""}
+    border-radius: 5px;
+    position: relative;
+    color: ${readableColor(backgroundColor)};
+
+    .screen__release-button {
+      position: absolute;
+      bottom: -48px;
+      left: 0;
+    }
+
+    .screen__claim-button {
+      .item-library__component & {
+        display: none;
+      }
+    }
+
+    .screen__visible-message,
+    .screen__claimed-message {
+      width: 80%;
+      text-align: center;
+
+      .item-library__component & {
+        display: none;
+      }
+    }
+
+    .screen__overlay {
+      position: absolute;
+      inset: 0;
+      ${owned
+        ? ""
+        : `background-image: radial-gradient(${lighten(
+            backgroundColor,
+            0.2
+          )}, ${backgroundColor});`}
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      flex-direction: column;
+      border-radius: 5px;
+    }
+  `}
+`;
+
+const Screen = ({
+  width,
+  height,
+  borderColor,
+  borderStyle,
+  backgroundColor,
+  ownedBy,
+  setState,
+}) => {
+  const { t } = useTranslation();
+  const { currentUser, localUsers: users } = useUsers();
+
+  const ownedByUser = React.useMemo(() => {
+    if (Array.isArray(ownedBy)) {
+      const result = ownedBy
+        .filter((userId) => users.find(({ uid }) => userId === uid))
+        .map((userId) => users.find(({ uid }) => userId === uid));
+      if (result.length > 0) {
+        return result[0];
+      }
+    }
+    return null;
+  }, [ownedBy, users]);
+
+  const ownedByMe = ownedByUser?.uid === currentUser.uid;
+
+  const claimIt = React.useCallback(
+    (e) => {
+      e.stopPropagation();
+      setState((prev) => {
+        let ownedBy = Array.isArray(prev.ownedBy) ? prev.ownedBy : [];
+
+        if (!ownedBy.includes(currentUser.uid)) {
+          ownedBy = [currentUser.uid];
+        } else {
+          ownedBy = ownedBy.filter((id) => id !== currentUser.uid);
+        }
+        return {
+          ...prev,
+          ownedBy,
+        };
+      });
+    },
+    [currentUser.uid, setState]
+  );
+
+  return (
+    <ScreenWrapper
+      width={width}
+      height={height}
+      borderStyle={borderStyle}
+      borderColor={borderColor}
+      backgroundColor={backgroundColor}
+      owned={ownedByMe}
+    >
+      <div className="screen__overlay">
+        {!ownedByUser && (
+          <>
+            <button className="screen__claim-button" onClick={claimIt}>
+              {t("Claim it")}
+            </button>
+            <div className="screen__visible-message">
+              {t(
+                "If you claim this screen, everything inside this zone will be hidden from other players."
+              )}
+            </div>
+          </>
+        )}
+        {ownedByUser && !ownedByMe && (
+          <div className="screen__claimed-message">
+            {t("This screen is owned by {{name}}", ownedByUser)}
+          </div>
+        )}
+      </div>
+      {ownedByMe && (
+        <button className="screen__release-button" onClick={claimIt}>
+          {t("Release it")}
+        </button>
+      )}
+    </ScreenWrapper>
+  );
+};
+
+export default memo(Screen);

+ 86 - 0
src/gameComponents/forms/ScreenFormFields.jsx

@@ -0,0 +1,86 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Field } from "react-final-form";
+
+import Label from "../../ui/formUtils/Label";
+import ColorPicker from "../../ui/formUtils/ColorPicker";
+
+const Form = ({ initialValues }) => {
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <Label>
+        {t("Label")}
+        <Field
+          name="label"
+          component="input"
+          initialValue={initialValues.label}
+        />
+      </Label>
+      <Label>
+        {t("Width")}
+        <Field
+          name="width"
+          component="input"
+          initialValue={initialValues.width}
+        >
+          {(props) => <input {...props.input} type="number" />}
+        </Field>
+      </Label>
+      <Label>
+        {t("Height")}
+        <Field
+          name="height"
+          component="input"
+          initialValue={initialValues.height}
+        >
+          {(props) => <input {...props.input} type="number" />}
+        </Field>
+      </Label>
+      <Label>
+        {t("Background color")}
+        <Field
+          name="backgroundColor"
+          component="input"
+          initialValue={initialValues.backgroundColor || "#CCCCCC"}
+        >
+          {({ input: { onChange, value } }) => (
+            <ColorPicker value={value} onChange={onChange} />
+          )}
+        </Field>
+      </Label>
+      <Label>
+        {t("Border color")}
+        <Field
+          name="borderColor"
+          component="input"
+          initialValue={initialValues.borderColor || "#CCCCCC33"}
+        >
+          {({ input: { onChange, value } }) => (
+            <ColorPicker
+              value={value}
+              onChange={onChange}
+              disableAlpha={false}
+            />
+          )}
+        </Field>
+      </Label>
+      <Label>
+        {t("Border style")}
+        <Field
+          name="borderStyle"
+          component="select"
+          initialValue={initialValues.borderStyle || "dotted"}
+          style={{ width: "10em" }}
+        >
+          <option value="dotted">{t("Dotted")}</option>
+          <option value="Solid">{t("solid")}</option>
+          <option value="dashed">{t("Dashed")}</option>
+        </Field>
+      </Label>
+    </>
+  );
+};
+
+export default Form;

+ 19 - 0
src/gameComponents/itemTemplates.js

@@ -12,6 +12,7 @@ import Dice from "./Dice";
 import DiceImage from "./DiceImage";
 import Note from "./Note";
 import Zone from "./Zone";
+import Screen from "./Screen";
 import Meeple from "./Meeple";
 import Jewel from "./Jewel";
 import Pawn from "./Pawn";
@@ -28,6 +29,7 @@ import DiceFormFields from "./forms/DiceFormFields";
 import DiceImageFormFields from "./forms/DiceImageFormFields";
 import NoteFormFields from "./forms/NoteFormFields";
 import ZoneFormFields from "./forms/ZoneFormFields";
+import ScreenFormFields from "./forms/ScreenFormFields";
 import TokenFormFields from "./forms/TokenFormFields";
 import MeepleFormFields from "./forms/MeepleFormFields";
 import JewelFormFields from "./forms/JewelFormFields";
@@ -331,6 +333,23 @@ const itemTemplates = {
       layer: -1,
     },
   },
+  screen: {
+    component: Screen,
+    defaultActions: ["clone", "lock", "remove"],
+    availableActions: ["clone", "lock", "remove"],
+    form: ScreenFormFields,
+    name: i18n.t("Screen"),
+    template: {
+      layer: -2,
+    },
+    stateHook: (state, { currentUser }) => {
+      const { ownedBy } = state;
+      if (!Array.isArray(ownedBy) || !ownedBy.includes(currentUser.uid)) {
+        return { ...state, layer: 3.6 };
+      }
+      return state;
+    },
+  },
   generator: {
     component: Generator,
     defaultActions: ["clone", "lock", "remove"],

+ 12 - 1
src/i18n/en.json

@@ -274,5 +274,16 @@
   "Custom": "Custom",
   "Background": "Background",
   "Type": "Type",
-  "Main image": "Main image"
+  "Main image": "Main image",
+  "Background color": "Background color",
+  "Border color": "Border color",
+  "Border style": "Border style",
+  "Dotted": "Dotted",
+  "solid": "Solid",
+  "Dashed": "Dashed",
+  "Screen": "Screen",
+  "Claim it": "Claim it",
+  "If you claim this screen, everything inside this zone will be hidden from other players.": "If you claim this screen, everything inside this zone will be hidden from other players.",
+  "This screen is owned by {{name}}": "This screen is owned by {{name}}",
+  "Release it": "Release it"
 }

+ 12 - 1
src/i18n/fr.json

@@ -274,5 +274,16 @@
   "Custom": "Personnaliser",
   "Background": "Fond du plateau",
   "Type": "Type",
-  "Main image": "Image principale"
+  "Main image": "Image principale",
+  "Background color": "couleur de fond",
+  "Border color": "couleur de bordure",
+  "Border style": "Style de bordure",
+  "Dotted": "Pointillés",
+  "solid": "Trait plein",
+  "Dashed": "Tirets",
+  "Screen": "Écran",
+  "Claim it": "Révéler",
+  "If you claim this screen, everything inside this zone will be hidden from other players.": "Si vous révélez cet écran, tout ce qui sera sous la zone de l'écran sera caché pour les autres joueurs.",
+  "This screen is owned by {{name}}": "Cet écran est révélé par {{name}}",
+  "Release it": "Cacher"
 }

+ 31 - 28
src/mediaLibrary/MediaLibraryModal.jsx

@@ -88,36 +88,39 @@ const MediaLibraryModal = ({ show, setShow, onSelect }) => {
     }
   );
 
-  const onRemove = React.useCallback((key) => {
-    confirmAlert({
-      title: t("Confirmation"),
-      message: t("Do you really want to remove this media?"),
-      buttons: [
-        {
-          label: t("Yes"),
-          onClick: async () => {
-            try {
-              await removeMedia(key);
-              toast.success(t("Media deleted"), { autoClose: 1500 });
-            } catch (e) {
-              if (e.message === "Forbidden") {
-                toast.error(t("Action forbidden. Try logging in again."));
-              } else {
-                console.log(e);
-                toast.error(
-                  t("Error while deleting media. Try again later...")
-                );
+  const onRemove = React.useCallback(
+    (key) => {
+      confirmAlert({
+        title: t("Confirmation"),
+        message: t("Do you really want to remove this media?"),
+        buttons: [
+          {
+            label: t("Yes"),
+            onClick: async () => {
+              try {
+                await removeMedia(key);
+                toast.success(t("Media deleted"), { autoClose: 1500 });
+              } catch (e) {
+                if (e.message === "Forbidden") {
+                  toast.error(t("Action forbidden. Try logging in again."));
+                } else {
+                  console.log(e);
+                  toast.error(
+                    t("Error while deleting media. Try again later...")
+                  );
+                }
               }
-            }
+            },
           },
-        },
-        {
-          label: t("No"),
-          onClick: () => {},
-        },
-      ],
-    });
-  }, []);
+          {
+            label: t("No"),
+            onClick: () => {},
+          },
+        ],
+      });
+    },
+    [removeMedia, t]
+  );
 
   const { getRootProps, getInputProps } = useDropzone({
     onDrop: uploadMediaMutation.mutate,

+ 1 - 1
src/views/BoardView/AddItemButton.jsx

@@ -1,6 +1,6 @@
 import React from "react";
 import { useTranslation } from "react-i18next";
-import ItemLibrary from "../../mediaLibrary/ItemLibrary";
+import ItemLibrary from "./ItemLibrary";
 
 import Touch from "../../ui/Touch";
 import SidePanel from "../../ui/SidePanel";

+ 3 - 3
src/mediaLibrary/ItemLibrary.jsx → src/views/BoardView/ItemLibrary.jsx

@@ -6,9 +6,9 @@ import debounce from "lodash.debounce";
 
 import { useItemActions } from "react-sync-board";
 
-import { search, uid } from "../utils";
+import { search, uid } from "../../utils";
 
-import Chevron from "../ui/Chevron";
+import Chevron from "../../ui/Chevron";
 
 const StyledItemList = styled.ul`
   display: flex;
@@ -74,7 +74,7 @@ const NewItem = memo(({ type, template, component: Component, name }) => {
 
   return (
     <StyledItem onClick={addItem}>
-      <div>
+      <div className="item-library__component">
         <Component
           {...(typeof template === "function" ? template() : template)}
           width={size}

+ 7 - 3
src/views/BoardView/SelectedItemsPane.jsx

@@ -10,6 +10,7 @@ import {
   useSelectionBox,
   useSelectedItems,
   useBoardState,
+  useItemActions,
 } from "react-sync-board";
 import useGameItemActions from "../../gameComponents/useGameItemActions";
 
@@ -73,6 +74,7 @@ const ActionPane = styled.div.attrs(({ top, left, height }) => {
 `;
 
 const SelectedItemsPane = ({ hideMenu = false }) => {
+  const { findElementUnderPointer } = useItemActions();
   const { actionMap } = useGameItemActions();
 
   const { availableActions } = useAvailableActions();
@@ -123,8 +125,10 @@ const SelectedItemsPane = ({ hideMenu = false }) => {
   }, [actionMap, availableActions, parsedAvailableActions, showEdit]);
 
   const onDblClick = React.useCallback(
-    (e) => {
-      const foundElement = insideClass(e.target, "item");
+    async (e) => {
+      const foundElement = await findElementUnderPointer(e, {
+        returnLocked: true,
+      });
 
       // We dblclick outside of an element
       if (!foundElement) return;
@@ -146,7 +150,7 @@ const SelectedItemsPane = ({ hideMenu = false }) => {
         filteredActions[0].action();
       }
     },
-    [parsedAvailableActions, t]
+    [findElementUnderPointer, parsedAvailableActions, t]
   );
 
   React.useEffect(() => {