Browse Source

Add generator (#377)

* Refactor form factory to prepare child edition
* Generated item update works
* Exclude fields from edition for generator
* Add translation
Jérémie Pardou-Piquemal 2 years ago
parent
commit
ae9dba21ca

+ 1 - 2
package-lock.json

@@ -7690,8 +7690,7 @@
     "lodash.merge": {
       "version": "4.6.2",
       "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
-      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
-      "dev": true
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
     },
     "lodash.once": {
       "version": "4.1.1",

+ 1 - 0
package.json

@@ -16,6 +16,7 @@
     "i18next-browser-languagedetector": "^4.3.1",
     "lodash.debounce": "^4.0.8",
     "lodash.findlast": "^4.6.0",
+    "lodash.merge": "^4.6.2",
     "lodash.throttle": "^4.1.1",
     "marked": "^2.0.0",
     "memoizee": "^0.4.14",

+ 303 - 0
src/gameComponents/Generator.jsx

@@ -0,0 +1,303 @@
+import React, { memo } from "react";
+import styled, { css } from "styled-components";
+import { useItemActions, useItemInteraction, useWire } from "react-sync-board";
+
+import { uid } from "../utils";
+import itemTemplates from "./itemTemplates";
+import { useTranslation } from "react-i18next";
+import debounce from "lodash.debounce";
+
+const StyledShape = styled.div`
+  ${({ color }) => css`
+    box-shadow: rgba(60, 64, 67, 0.3) 0px 1px 2px 0px,
+      rgba(60, 64, 67, 0.15) 0px 1px 3px 1px;
+    border: 3px dashed black;
+    border-color: ${color};
+
+    border-radius: 3px;
+    background-color: #cccccc22;
+
+    & .wrapper {
+      opacity: 0.3;
+      position: relative;
+    }
+    & .item-wrapper {
+      position: absolute;
+      top: ${({ center: { top } }) => `${top}px`};
+      left: ${({ center: { left } }) => `${left}px`};
+    }
+    & .handle {
+      position: absolute;
+      top: -15px;
+      left: -15px;
+      user-select: none;
+      & img {
+        pointer-events: none;
+      }
+    }
+  `}
+`;
+
+const Generator = ({ color = "#ccc", item, id, currentItemId, setState }) => {
+  const { t } = useTranslation();
+  const { isMaster } = useWire("board");
+  const itemRef = React.useRef(null);
+  const [dimension, setDimension] = React.useState({
+    width: 50,
+    height: 50,
+  });
+  const [center, setCenter] = React.useState({ top: 0, left: 0 });
+  const { register } = useItemInteraction("place");
+  const {
+    pushItem,
+    getItems,
+    batchUpdateItems,
+    removeItems,
+  } = useItemActions();
+
+  const centerRef = React.useRef(center);
+  Object.assign(centerRef.current, center);
+  const currentItemRef = React.useRef(currentItemId);
+  currentItemRef.current = currentItemId;
+
+  const addItem = React.useCallback(async () => {
+    /**
+     * Add new generated item
+     */
+    const [thisItem] = await getItems([id]);
+    const { item } = thisItem || {}; // Inside item library, thisItem is not defined
+    if (item?.type) {
+      const newItemId = uid();
+      await pushItem({
+        ...item,
+        x: thisItem.x + centerRef.current.left + 3,
+        y: thisItem.y + centerRef.current.top + 3,
+        layer: thisItem.layer + 1,
+        editable: false,
+        id: newItemId,
+      });
+      currentItemRef.current = newItemId;
+      setState((prev) => ({ ...prev, currentItemId: newItemId }));
+    }
+  }, [getItems, id, pushItem, setState]);
+
+  const centerItem = React.useCallback(async () => {
+    /**
+     * Center generated item
+     */
+    const [thisItem, other] = await getItems([id, currentItemRef.current]);
+    if (!other) {
+      // Item has been deleted, need a new one.
+      currentItemRef.current = undefined;
+      setState((prev) => ({ ...prev, currentItemId: undefined }));
+    } else {
+      batchUpdateItems([currentItemRef.current], (item) => {
+        const newX = thisItem.x + centerRef.current.left + 3;
+        const newY = thisItem.y + centerRef.current.top + 3;
+        /* Prevent modification if item doesn't need update */
+        if (
+          newX !== item.x ||
+          newY !== item.y ||
+          item.layer !== thisItem.layer + 1
+        ) {
+          return {
+            ...item,
+            x: newX,
+            y: newY,
+            layer: thisItem.layer + 1,
+          };
+        }
+        return item;
+      });
+    }
+  }, [batchUpdateItems, getItems, id, setState]);
+
+  const onPlaceItem = React.useCallback(
+    async (itemIds) => {
+      /**
+       * Callback if generated item or generator is placed
+       */
+      const placeSelf = itemIds.includes(id);
+      if (itemIds.includes(currentItemRef.current) && !placeSelf) {
+        // We have removed generated item so we create a new one.
+        const [thisItem] = await getItems([id]);
+        batchUpdateItems([currentItemRef.current], (item) => {
+          const result = {
+            ...item,
+            layer: thisItem.layer,
+          };
+          delete result.editable;
+          return result;
+        });
+        await addItem();
+      }
+      if (placeSelf) {
+        if (!currentItemRef.current) {
+          // Missing item for any reason
+          await addItem();
+        } else {
+          // We are moving generator so we must
+          // update generated item position
+          await centerItem();
+        }
+      }
+    },
+    [addItem, batchUpdateItems, centerItem, getItems, id]
+  );
+
+  /**
+   * Set generator dimension according to Item content.
+   */
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const resize = React.useCallback(
+    debounce((rotation) => {
+      let targetWidth, targetHeight;
+      const { clientWidth, clientHeight } = itemRef.current;
+      targetWidth = clientWidth;
+      targetHeight = clientHeight;
+
+      if (currentItemRef.current) {
+        // Get size from current item if any
+        const currentDomItem = document.getElementById(currentItemRef.current);
+        if (currentDomItem) {
+          targetWidth = currentDomItem.clientWidth;
+          targetHeight = currentDomItem.clientHeight;
+        }
+      }
+
+      /* Compute size relative to rotation */
+      const rad = (rotation || 0) * (Math.PI / 180);
+
+      const cos = Math.abs(Math.cos(rad));
+      const sin = Math.abs(Math.sin(rad));
+
+      const width = targetWidth * cos + targetHeight * sin;
+      const height = targetWidth * sin + targetHeight * cos;
+
+      const top = -targetHeight / 2 + height / 2 + 3;
+      const left = -targetWidth / 2 + width / 2 + 3;
+
+      setCenter({
+        top,
+        left,
+      });
+      centerRef.current = {
+        top,
+        left,
+      };
+
+      setDimension((prev) => ({ ...prev, width, height }));
+    }, 100),
+    []
+  );
+
+  React.useEffect(() => {
+    /**
+     * update item on modifications only if master
+     */
+    if (item?.type && isMaster) {
+      batchUpdateItems([currentItemRef.current], (prev) => ({
+        ...prev,
+        ...item,
+      }));
+    }
+  }, [batchUpdateItems, isMaster, item]);
+
+  React.useEffect(() => {
+    /**
+     * Add item if missing
+     */
+    if (isMaster && !currentItemId && item?.type) {
+      addItem();
+    }
+  }, [addItem, currentItemId, isMaster, item?.type]);
+
+  React.useEffect(() => {
+    /**
+     * Check if type is defined
+     */
+    const checkType = async () => {
+      if (currentItemRef.current) {
+        const [currentItem] = await getItems([currentItemRef.current]);
+        if (currentItem?.type !== item.type) {
+          if (currentItem) {
+            // Remove old if exists
+            await removeItems([currentItemRef.current]);
+          }
+          // Add new item on new type
+          await addItem();
+        }
+      }
+    };
+
+    if (item?.type && isMaster) {
+      checkType();
+    }
+  }, [addItem, getItems, item?.type, removeItems, isMaster]);
+
+  React.useEffect(() => {
+    /**
+     * Register onPlaceItem callback
+     */
+    const unregisterList = [];
+    if (currentItemId) {
+      unregisterList.push(register(onPlaceItem));
+    }
+    return () => {
+      unregisterList.forEach((callback) => callback());
+    };
+  }, [register, onPlaceItem, currentItemId]);
+
+  React.useEffect(() => {
+    /**
+     * Update center and generator width height
+     */
+    resize(item?.rotation);
+  }, [item, resize, dimension.height, dimension.width]);
+
+  React.useEffect(() => {
+    if (currentItemRef.current && isMaster) {
+      centerItem();
+    }
+  }, [item, centerItem, isMaster, center]);
+
+  // 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>
+  );
+  if (item) {
+    const itemTemplate = itemTemplates[item.type];
+    Item = itemTemplate.component;
+  }
+
+  return (
+    <StyledShape color={color} center={center}>
+      <div className="handle">
+        <img src="https://icongr.am/clarity/cursor-move.svg?size=20&color=ffffff" />
+      </div>
+      <div className="wrapper" style={dimension}>
+        <div
+          style={{
+            transform: `rotate(${item?.rotation || 0}deg)`,
+          }}
+          ref={itemRef}
+          className="item-wrapper"
+        >
+          <Item {...item} />
+        </div>
+      </div>
+    </StyledShape>
+  );
+};
+
+export default memo(Generator);

+ 87 - 72
src/gameComponents/ItemForm.jsx

@@ -41,17 +41,23 @@ const getAvailableActionsFromItem = (item) => {
   return [];
 };
 
-const ItemForm = ({ items, types }) => {
+const getExcludedFields = (types) => {
+  return types.reduce((excluded, type) => {
+    return Object.assign(excluded, itemTemplates[type].excludeFields || {});
+  }, {});
+};
+
+const ItemForm = ({ items, types, extraExcludeFields = {} }) => {
   const { t } = useTranslation();
 
   let FieldsComponent;
-  const oneType = types.size === 1;
+  const oneType = types.length === 1;
 
   if (items.length === 1) {
     FieldsComponent = getFormFieldComponent(items[0].type);
   } else {
     if (oneType) {
-      FieldsComponent = getFormFieldComponent(Array.from(types)[0]);
+      FieldsComponent = getFormFieldComponent(types[0]);
     } else {
       FieldsComponent = () => null;
     }
@@ -71,9 +77,12 @@ const ItemForm = ({ items, types }) => {
     return [];
   }, [items, oneType]);
 
+  // Merge extra excluded fields and all item excluded fields
+  const excludeFields = { ...getExcludedFields(types), ...extraExcludeFields };
+
   let initialValues;
 
-  // Set initial values to item values if only one element selected
+  // Set initial values to item values only if one element selected
   // Empty object otherwise
   if (items.length === 1) {
     initialValues = { ...items[0] };
@@ -93,74 +102,80 @@ const ItemForm = ({ items, types }) => {
 
   return (
     <>
-      <Label>
-        <Field
-          name="locked"
-          component="input"
-          type="checkbox"
-          initialValue={initialValues.locked}
-        />
-        <span className="checkable">{t("Locked?")}</span>
-        <Hint>{t("Lock action help")}</Hint>
-      </Label>
-      <Label>
-        {t("Rotation")}
-        <Field name="rotation" initialValue={initialValues.rotation}>
-          {({ input: { onChange, value } }) => {
-            return (
-              <Slider
-                defaultValue={0}
-                value={value}
-                min={-180}
-                max={180}
-                step={5}
-                included={false}
-                marks={{
-                  "-180": -180,
-                  "-90": -90,
-                  "-45": -45,
-                  "-30": -30,
-                  0: 0,
-                  30: 30,
-                  45: 45,
-                  90: 90,
-                  180: 180,
-                }}
-                onChange={onChange}
-                className={"slider-rotation"}
-              />
-            );
-          }}
-        </Field>
-      </Label>
-      <Label>
-        {t("Layer")}
-        <Field name="layer" initialValue={initialValues.layer}>
-          {({ input: { onChange, value } }) => {
-            return (
-              <Slider
-                defaultValue={0}
-                value={value}
-                min={-3}
-                max={3}
-                step={1}
-                included={false}
-                marks={{
-                  "-3": -3,
-                  "-2": -2,
-                  "-1": -1,
-                  0: 0,
-                  "1": 1,
-                  "2": 2,
-                  "3": 3,
-                }}
-                onChange={onChange}
-                className={"slider-layer"}
-              />
-            );
-          }}
-        </Field>
-      </Label>
+      {!excludeFields.locked && (
+        <Label>
+          <Field
+            name="locked"
+            component="input"
+            type="checkbox"
+            initialValue={initialValues.locked}
+          />
+          <span className="checkable">{t("Locked?")}</span>
+          <Hint>{t("Lock action help")}</Hint>
+        </Label>
+      )}
+      {!excludeFields.rotation && (
+        <Label>
+          {t("Rotation")}
+          <Field name="rotation" initialValue={initialValues.rotation}>
+            {({ input: { onChange, value } }) => {
+              return (
+                <Slider
+                  defaultValue={0}
+                  value={value}
+                  min={-180}
+                  max={180}
+                  step={5}
+                  included={false}
+                  marks={{
+                    "-180": -180,
+                    "-90": -90,
+                    "-45": -45,
+                    "-30": -30,
+                    0: 0,
+                    30: 30,
+                    45: 45,
+                    90: 90,
+                    180: 180,
+                  }}
+                  onChange={onChange}
+                  className={"slider-rotation"}
+                />
+              );
+            }}
+          </Field>
+        </Label>
+      )}
+      {!excludeFields.layer && (
+        <Label>
+          {t("Layer")}
+          <Field name="layer" initialValue={initialValues.layer}>
+            {({ input: { onChange, value } }) => {
+              return (
+                <Slider
+                  defaultValue={0}
+                  value={value}
+                  min={-3}
+                  max={3}
+                  step={1}
+                  included={false}
+                  marks={{
+                    "-3": -3,
+                    "-2": -2,
+                    "-1": -1,
+                    0: 0,
+                    "1": 1,
+                    "2": 2,
+                    "3": 3,
+                  }}
+                  onChange={onChange}
+                  className={"slider-layer"}
+                />
+              );
+            }}
+          </Field>
+        </Label>
+      )}
       <FieldsComponent initialValues={initialValues} />
       <h3>{t("Snap to grid")}</h3>
       <Label>

+ 0 - 45
src/gameComponents/forms/ActionsField.jsx

@@ -1,45 +0,0 @@
-import React from "react";
-
-import Label from "../../ui/formUtils/Label";
-
-import { Field } from "react-final-form";
-
-const ActionsField = ({
-  value: globalValue,
-  onChange: globalOnChange,
-  availableActions,
-  actionMap,
-}) => {
-  return (
-    <div>
-      {availableActions.map((action) => {
-        const { label } = actionMap[action];
-        return (
-          <Label key={action}>
-            <Field value={globalValue.includes(action)} type="checkbox">
-              {({ input: { value } }) => {
-                const toggleAction = (e) => {
-                  if (e.target.checked) {
-                    globalOnChange([...globalValue, action]);
-                  } else {
-                    globalOnChange(globalValue.filter((act) => act !== action));
-                  }
-                };
-                return (
-                  <input
-                    type="checkbox"
-                    checked={value}
-                    onChange={toggleAction}
-                  />
-                );
-              }}
-            </Field>
-            <span className="checkable">{label}</span>
-          </Label>
-        );
-      })}
-    </div>
-  );
-};
-
-export default ActionsField;

+ 116 - 0
src/gameComponents/forms/GeneratorFormFields.jsx

@@ -0,0 +1,116 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Field, useField } from "react-final-form";
+import { useItemActions } from "react-sync-board";
+import styled from "styled-components";
+
+import Label from "../../ui/formUtils/Label";
+import SidePanel from "../../ui/SidePanel";
+import ItemFormFactory from "../../views/BoardView/ItemFormFactory";
+import itemTemplates from "../itemTemplates";
+
+const CardContent = styled.div.attrs(() => ({ className: "content" }))`
+  display: flex;
+  flex-direction: column;
+  padding: 0.5em;
+`;
+
+const EditSubItemButton = ({ showEdit, setShowEdit, subItem, onUpdate }) => {
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <button onClick={() => setShowEdit((prev) => !prev)}>
+        {t("Edit generated item")}
+      </button>
+      <SidePanel
+        layer={1}
+        open={showEdit}
+        onClose={() => {
+          setShowEdit(false);
+        }}
+        title={t("Generated item")}
+        width="25%"
+      >
+        <CardContent>
+          <ItemFormFactory
+            onUpdate={onUpdate}
+            items={[subItem]}
+            extraExcludeFields={{ locked: true, layer: true }}
+          />
+        </CardContent>
+      </SidePanel>
+    </>
+  );
+};
+
+const Form = ({ initialValues }) => {
+  const { t } = useTranslation();
+  const {
+    input: { value: itemType },
+  } = useField("item.type", { initialValue: initialValues.item?.type });
+
+  const [showEdit, setShowEdit] = React.useState(false);
+
+  const { batchUpdateItems } = useItemActions();
+
+  const onSubmitHandler = React.useCallback(
+    (formValues) => {
+      batchUpdateItems(initialValues.id, (item) => {
+        return {
+          ...item,
+          item: {
+            ...item.item,
+            ...formValues,
+          },
+        };
+      });
+    },
+    [batchUpdateItems, initialValues.id]
+  );
+
+  return (
+    <>
+      <Label>
+        {t("Label")}
+        <Field
+          name="text"
+          component="input"
+          initialValue={initialValues.text}
+        />
+      </Label>
+
+      <Label>
+        {t("Item type")}
+        <Field
+          name="item.type"
+          component="select"
+          initialValue={initialValues.item?.type}
+        >
+          <option />
+          {Object.keys(itemTemplates).map((key) => {
+            if (key === "generator") {
+              return null;
+            }
+            return (
+              <option key={key} value={key}>
+                {itemTemplates[key].name}
+              </option>
+            );
+          })}
+        </Field>
+      </Label>
+
+      {itemType && (
+        <EditSubItemButton
+          showEdit={showEdit}
+          setShowEdit={setShowEdit}
+          subItem={initialValues.item}
+          onUpdate={onSubmitHandler}
+        />
+      )}
+    </>
+  );
+};
+
+export default Form;

+ 11 - 0
src/gameComponents/itemTemplates.js

@@ -17,6 +17,7 @@ import Jewel from "./Jewel";
 import Pawn from "./Pawn";
 import CheckerBoard from "./CheckerBoard";
 import Cylinder from "./Cylinder";
+import Generator from "./Generator";
 
 import ImageFormFields from "./forms/ImageFormFields";
 import CounterFormFields from "./forms/CounterFormFields";
@@ -33,6 +34,7 @@ import JewelFormFields from "./forms/JewelFormFields";
 import PawnFormFields from "./forms/PawnFormFields";
 import CheckerBoardFormFields from "./forms/CheckerBoardFormFields";
 import CylinderFormFields from "./forms/CylinderFormFields";
+import GeneratorFormFields from "./forms/GeneratorFormFields";
 
 const defaultDiceImages = () => [
   {
@@ -329,6 +331,15 @@ const itemTemplates = {
       layer: -1,
     },
   },
+  generator: {
+    component: Generator,
+    defaultActions: ["clone", "lock", "remove"],
+    availableActions: ["clone", "lock", "remove"],
+    form: GeneratorFormFields,
+    excludeFields: { rotation: true },
+    name: i18n.t("Generator"),
+    template: { layer: 0 },
+  },
 };
 
 export const itemLibrary = Object.keys(itemTemplates).map((key) => ({

+ 14 - 0
src/games/testGame.js

@@ -191,6 +191,20 @@ const genGame = () => {
     y: 600,
   });
 
+  items.push({
+    label: "Generator",
+    type: "generator",
+    layer: -1,
+    x: 500,
+    y: 700,
+    item: {
+      label: "My jewel",
+      type: "jewel",
+      size: 70,
+      color: "#ff0000",
+    },
+  });
+
   return {
     items,
     availableItems: [

+ 0 - 1
src/hooks/SubscribeSessionEvents.jsx

@@ -1,4 +1,3 @@
-import debounce from "lodash.debounce";
 import React from "react";
 
 import { useWire, useBoardConfig } from "react-sync-board";

+ 6 - 1
src/i18n/en.json

@@ -255,5 +255,10 @@
   "Rotate {{angle}}°": "Rotate {{angle}}°",
   "Rotate": "Rotate",
   "Rotate randomly {{angle}}°": "Rotate randomly {{angle}}°",
-  "Rotate randomly": "Rotate randomly"
+  "Rotate randomly": "Rotate randomly",
+  "Edit generated item": "Edit generated item",
+  "Generated item": "Generated item",
+  "Item type": "Generated item type",
+  "No item type defined": "No item type defined",
+  "Generator": "Generator"
 }

+ 6 - 1
src/i18n/fr.json

@@ -255,5 +255,10 @@
   "Rotate {{angle}}°": "Rotation de {{angle}}°",
   "Rotate": "Rotation",
   "Rotate randomly {{angle}}°": "Rotation aléatoire de {{angle}}°",
-  "Rotate randomly": "Rotation aléatoire"
+  "Rotate randomly": "Rotation aléatoire",
+  "Edit generated item": "Modifier l'élément généré",
+  "Generated item": "Élément généré",
+  "Item type": "Type de l'élément généré",
+  "No item type defined": "Aucun type sélectionné",
+  "Generator": "Générateur"
 }

+ 3 - 1
src/ui/SidePanel.jsx

@@ -17,7 +17,7 @@ const StyledSidePanel = styled.div`
   ${({ position }) => (position === "right" ? "right: 0;" : "left: 0;")}
   top: 0;
   bottom: 0;
-  z-index: ${({ modal }) => (modal ? 290 : 280)};
+  z-index: ${({ modal, layer }) => (modal ? 290 : 280) + layer};
   display: flex;
   flex-direction: column;
   height: 100%;
@@ -105,6 +105,7 @@ const SidePanel = ({
   open = show,
   modal = false,
   width,
+  layer = 0,
 }) => {
   const { t } = useTranslation();
 
@@ -151,6 +152,7 @@ const SidePanel = ({
         width={width}
         modal={modal}
         className={isOpen ? "side-panel open" : "side-panel"}
+        layer={layer}
       >
         <header>
           {title && <h2 className="title">{title}</h2>}

+ 2 - 2
src/views/BoardView/BoardView.jsx

@@ -8,7 +8,7 @@ import NavBar from "./NavBar";
 import BoardForm from "./BoardForm";
 import SelectedItemPane from "./SelectedItemsPane";
 
-import { ItemForm, itemTemplates } from "../../gameComponents";
+import { itemTemplates } from "../../gameComponents";
 
 import ActionBar from "./ActionBar";
 
@@ -51,7 +51,7 @@ export const BoardView = ({ mediaLibraries, edit, itemLibraries }) => {
         />
         <WelcomeModal show={showWelcomeModal} setShow={setShowWelcomeModal} />
       </ImageDropNPaste>
-      <SelectedItemPane ItemFormComponent={ItemForm} hideMenu={hideMenu} />
+      <SelectedItemPane hideMenu={hideMenu} />
     </MediaLibraryProvider>
   );
 };

+ 22 - 4
src/views/BoardView/EditItemButton.jsx

@@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next";
 
 import SidePanel from "../../ui/SidePanel";
 import ItemFormFactory from "./ItemFormFactory";
-import { useSelectedItems } from "react-sync-board";
+import { useItemActions, useSelectedItems, useItems } from "react-sync-board";
+import merge from "lodash.merge";
 
 const CardContent = styled.div.attrs(() => ({ className: "content" }))`
   display: flex;
@@ -12,10 +13,27 @@ const CardContent = styled.div.attrs(() => ({ className: "content" }))`
   padding: 0.5em;
 `;
 
-const EditItemButton = ({ ItemFormComponent, showEdit, setShowEdit }) => {
-  const selectedItems = useSelectedItems();
+const EditItemButton = ({ showEdit, setShowEdit }) => {
   const { t } = useTranslation();
 
+  const items = useItems();
+  const selectedItems = useSelectedItems();
+  const { batchUpdateItems } = useItemActions();
+
+  const currentItems = React.useMemo(
+    () => items.filter(({ id }) => selectedItems.includes(id)),
+    [items, selectedItems]
+  );
+
+  const onSubmitHandler = React.useCallback(
+    (formValues) => {
+      batchUpdateItems(selectedItems, (item) => {
+        return merge(JSON.parse(JSON.stringify(item)), formValues);
+      });
+    },
+    [batchUpdateItems, selectedItems]
+  );
+
   let title = "";
   if (selectedItems.length === 1) {
     title = t("Edit item");
@@ -53,7 +71,7 @@ const EditItemButton = ({ ItemFormComponent, showEdit, setShowEdit }) => {
         width="25%"
       >
         <CardContent>
-          <ItemFormFactory ItemFormComponent={ItemFormComponent} />
+          <ItemFormFactory onUpdate={onSubmitHandler} items={currentItems} />
         </CardContent>
       </SidePanel>
     </>

+ 11 - 28
src/views/BoardView/ItemFormFactory.jsx

@@ -2,39 +2,18 @@ import React from "react";
 import { Form } from "react-final-form";
 import arrayMutators from "final-form-arrays";
 
+import ItemForm from "../../gameComponents/ItemForm";
 import AutoSave from "../../ui/formUtils/AutoSave";
-import { useItemActions, useSelectedItems, useItems } from "react-sync-board";
-
-const ItemFormFactory = ({ ItemFormComponent }) => {
-  const { batchUpdateItems } = useItemActions();
-  const items = useItems();
-  const selectedItems = useSelectedItems();
-
-  const currentItems = React.useMemo(
-    () => items.filter(({ id }) => selectedItems.includes(id)),
-    [items, selectedItems]
-  );
 
+const ItemFormFactory = ({ onUpdate, items, extraExcludeFields = {} }) => {
   const types = React.useMemo(
-    () => new Set(currentItems.map(({ type }) => type)),
-    [currentItems]
-  );
-
-  const onSubmitHandler = React.useCallback(
-    (formValues) => {
-      batchUpdateItems(selectedItems, (item) => {
-        return {
-          ...item,
-          ...formValues,
-        };
-      });
-    },
-    [batchUpdateItems, selectedItems]
+    () => Array.from(new Set(items.map(({ type }) => type))),
+    [items]
   );
 
   return (
     <Form
-      onSubmit={onSubmitHandler}
+      onSubmit={onUpdate}
       mutators={{
         ...arrayMutators,
       }}
@@ -45,8 +24,12 @@ const ItemFormFactory = ({ ItemFormComponent }) => {
             flexDirection: "column",
           }}
         >
-          <AutoSave save={onSubmitHandler} />
-          <ItemFormComponent items={currentItems} types={types} />
+          <AutoSave save={onUpdate} />
+          <ItemForm
+            items={items}
+            types={types}
+            extraExcludeFields={extraExcludeFields}
+          />
         </div>
       )}
     />

+ 2 - 6
src/views/BoardView/SelectedItemsPane.jsx

@@ -72,7 +72,7 @@ const ActionPane = styled.div.attrs(({ top, left, height }) => {
   }
 `;
 
-const SelectedItemsPane = ({ hideMenu = false, ItemFormComponent }) => {
+const SelectedItemsPane = ({ hideMenu = false }) => {
   const { actionMap } = useGameItemActions();
 
   const { availableActions } = useAvailableActions();
@@ -200,11 +200,7 @@ const SelectedItemsPane = ({ hideMenu = false, ItemFormComponent }) => {
         )}
 
       {showEditButton && (
-        <EditItemButton
-          ItemFormComponent={ItemFormComponent}
-          showEdit={showEdit}
-          setShowEdit={setShowEdit}
-        />
+        <EditItemButton showEdit={showEdit} setShowEdit={setShowEdit} />
       )}
     </ActionPane>
   );