diff --git a/web/package-lock.json b/web/package-lock.json index 49146151..668350d5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,6 +1,6 @@ { "name": "indihu-web", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 2, "requires": true, "packages": { diff --git a/web/package.json b/web/package.json index 24f3a0dc..30899382 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "indihu-web", - "version": "2.2.0", + "version": "2.3.0", "private": true, "dependencies": { "@emotion/react": "^11.11.1", diff --git a/web/public/locales/cs/expo-editor.json b/web/public/locales/cs/expo-editor.json index a4db1017..332c4fcf 100644 --- a/web/public/locales/cs/expo-editor.json +++ b/web/public/locales/cs/expo-editor.json @@ -234,7 +234,9 @@ "imageResultLabel": "Výsledek", "imageResultTooltip": "Z knihovny dokumentů vyberte obrázek, kde je znázorněno správné místo, které měli návštěvníci najít.", "showUsersTip": "Zobrazit tipy návštěvníků", - "showUsersTipTooltip": "Pokud chcete, aby návštěvníci viděli správné řešení i svůj tip, zaškrtněte toto pole." + "showUsersTipTooltip": "Pokud chcete, aby návštěvníci viděli správné řešení i svůj tip, zaškrtněte toto pole.", + "numberOfPinsLabel": "Počet bodů k nalezení", + "numberOfPinsTooltip": "Zadejte počet bodů, které chcete, aby návštěvník našel. Každý bod bude moci mít následně přidělen svůj vlastní jedinečný text." }, "gameDrawScreen": { "taskTooltip": "Co nejpřesněji popište návštěvníkům jejich úkol (např. Dokreslete do grafu, jak se vyvíjela těžba uhlí po roce 1950).", @@ -243,7 +245,11 @@ "imageResultLabel": "Výsledek", "imageResultTooltip": "Z knihovny dokumentů vyberte kompletní obrázek.", "showUsersDrawing": "Zobrazit kresbu návštěvníků", - "showUsersDrawingTooltip": "Pokud chcete, aby návštěvníci viděli správné řešení i svoje tipy, zaškrtněte toto pole." + "showUsersDrawingTooltip": "Pokud chcete, aby návštěvníci viděli správné řešení i svoje tipy, zaškrtněte toto pole.", + "initialDrawingSettingsTitle": "Počáteční nastavení pro kreslení", + "initialDrawingColorLabel": "Počáteční barva", + "initialDrawingThicknessLabel": "Počáteční tloušťka", + "resetInitialDrawingSettingsBtnLabel": "Reset" }, "gameWipeScreen": { "taskTooltip": "Co nejpřesněji popište návštěvníkům jejich úkol (např. Podívejte se, jak vypadala budova před její rekonstrukcí). ", @@ -278,7 +284,8 @@ "imageResultTooltip": "Z knihovny dokumentů vyberte výsledný kompletní obrázek.", "object": "Objekt", "objectSelectLabel": "Vybrat", - "objectTooltip": "Z knihovny dokumentů vyberte obrázek s částmi, ze kterých budou návštěvníci vybírat." + "objectTooltip": "Z knihovny dokumentů vyberte obrázek s částmi, ze kterých budou návštěvníci vybírat.", + "screenPreviewText": "Náhled obrazovky" }, "gameQuizScreen": { "nameLabel": "Název", diff --git a/web/public/locales/cs/view-exhibition.json b/web/public/locales/cs/view-exhibition.json index ca0a9fee..83ae3f05 100644 --- a/web/public/locales/cs/view-exhibition.json +++ b/web/public/locales/cs/view-exhibition.json @@ -27,5 +27,6 @@ "untitled-chapter": "Nepojmenovaná kapitola", "untitled-screen": "Nepojmenovaná obrazovka", "rate-expo": "Hodnotit výstavu", - "landscapeModeRecommendation": " Doporučujeme prohlížení výstavy v režimu na šířku." + "landscapeModeRecommendationSnackbarText": " Doporučujeme prohlížení výstavy v režimu na šířku.", + "audioWarningSnackbarText": "Z důvodu omezení iOS může být expozice přerušena, protože přehrávání zvuku vyžaduje interakci uživatele. Pokud se tak stane, klepněte na tlačítko přehrávání." } diff --git a/web/public/locales/cs/view-screen.json b/web/public/locales/cs/view-screen.json index 0d7b464a..09596b55 100644 --- a/web/public/locales/cs/view-screen.json +++ b/web/public/locales/cs/view-screen.json @@ -82,7 +82,6 @@ }, "game": { - "solution": "Správné řešení", "controls": { "finished": "Hotovo", "play-again": "Zahrát znovu" @@ -90,25 +89,25 @@ }, "game-find": { - "task": "Najdi na obrázku" + "solution": "Správne riešenie" }, "game-draw": { - "task": "Dokresli obrázek", + "solution": "Výborně! Splnil jsi zadání a toto je výsledek", "switchToPencilAction": "Přepnout na tužku", "switchToEraserAction": "Přepnout na gumu", "thicknessChooserAction": "Výběr tloušťky", "colorChooserAction": "Výběr barvy" }, "game-erase": { - "task": "Vymaž obrázek" + "solution": "Výborně! Splnil jsi zadání a toto je výsledek" }, "game-sizing": { - "task": "Uhodni správnou velikost" + "solution": "Správne riešenie" }, "game-move": { - "task": "Posuň na správné místo" + "solution": "Správne riešenie" }, "game-quiz": { - "task": "Vyber správné možnosti" + "solution": "Správne riešenie" } } diff --git a/web/public/locales/en/expo-editor.json b/web/public/locales/en/expo-editor.json index e5aa08bb..cc34138a 100644 --- a/web/public/locales/en/expo-editor.json +++ b/web/public/locales/en/expo-editor.json @@ -234,7 +234,9 @@ "imageResultLabel": "Result", "imageResultTooltip": "Select an image from the document library that shows the correct location that the user should have found.", "showUsersTip": "View user's guess", - "showUsersTipTooltip": "Check this box if you want the user to see both the correct solution and their guess." + "showUsersTipTooltip": "Check this box if you want the user to see both the correct solution and their guess.", + "numberOfPinsLabel": "Number of points to find", + "numberOfPinsTooltip": "Enter the number of points you want the visitor to find. Each point will then be able to have its own unique text assigned." }, "gameDrawScreen": { "taskTooltip": "Describe the user's task as precisely as possible (e.g. complete the graph of how coal mining developed after 1950).", @@ -243,7 +245,11 @@ "imageResultLabel": "Result", "imageResultTooltip": "Select a complete image from the document library.", "showUsersDrawing": "View user's drawing", - "showUsersDrawingTooltip": "Check this box if you want the user to see both the correct solution and their guess." + "showUsersDrawingTooltip": "Check this box if you want the user to see both the correct solution and their guess.", + "initialDrawingSettingsTitle": "Initial settings for drawing", + "initialDrawingColorLabel": "Initial color", + "initialDrawingThicknessLabel": "Initial thickness", + "resetInitialDrawingSettingsBtnLabel": "Reset" }, "gameWipeScreen": { "taskTooltip": "Describe the user's task as precisely as possible (e.g. see how the building looked before its renovation).", @@ -278,7 +284,8 @@ "imageResultTooltip": "Select the complete image from the document library.", "object": "Object", "objectSelectLabel": "Select", - "objectTooltip": "From the document library, select an image with parts for the user to select from." + "objectTooltip": "From the document library, select an image with parts for the user to select from.", + "screenPreviewText": "Screen preview" }, "gameQuizScreen": { "nameLabel": "Title", diff --git a/web/public/locales/en/view-exhibition.json b/web/public/locales/en/view-exhibition.json index 9af6dd0a..3aa32f82 100644 --- a/web/public/locales/en/view-exhibition.json +++ b/web/public/locales/en/view-exhibition.json @@ -27,5 +27,6 @@ "untitled-chapter": "Untitled chapter", "untitled-screen": "Untitled screen", "rate-expo": "Rate the exhibition", - "landscapeModeRecommendation": "We recommend viewing the exhibition in landscape mode." + "landscapeModeRecommendationSnackbarText": "We recommend viewing the exhibition in landscape mode.", + "audioWarningSnackbarText": "Due to iOS restrictions, the exposition may be interrupted because audio playback requires user interaction. If that happens, please tap the play button to continue." } diff --git a/web/public/locales/en/view-screen.json b/web/public/locales/en/view-screen.json index 1ff500bb..1f78eba3 100644 --- a/web/public/locales/en/view-screen.json +++ b/web/public/locales/en/view-screen.json @@ -82,7 +82,6 @@ }, "game": { - "solution": "The correct solution", "controls": { "finished": "Finished", "play-again": "Play again" @@ -90,25 +89,25 @@ }, "game-find": { - "task": "Find in the image" + "solution": "The correct solution" }, "game-draw": { - "task": "Draw in the image", + "solution": "Great! You completed the assignment and this is the result", "switchToPencilAction": "Switch to pencil", "switchToEraserAction": "Switch to eraser", "thicknessChooserAction": "Choice of thickness", "colorChooserAction": "Choice of color" }, "game-erase": { - "task": "Erase the image" + "solution": "Great! You completed the assignment and this is the result" }, "game-sizing": { - "task": "Guess the right size" + "solution": "The correct solution" }, "game-move": { - "task": "Move to the correct position" + "solution": "The correct solution" }, "game-quiz": { - "task": "Select the correct option(s)" + "solution": "The correct solution" } } diff --git a/web/public/locales/sk/expo-editor.json b/web/public/locales/sk/expo-editor.json index 5d3f99af..d3120344 100644 --- a/web/public/locales/sk/expo-editor.json +++ b/web/public/locales/sk/expo-editor.json @@ -234,7 +234,9 @@ "imageResultLabel": "Výsledok", "imageResultTooltip": "Z knižnice dokumentov vyberte obrázok, kde je znázornené správne miesto, ktoré mali návštevníci nájsť.", "showUsersTip": "Zobraziť tipy návštevníkov", - "showUsersTipTooltip": "Ak chcete, aby návštevníci videli správne riešenie aj svoj tip, zaškrtnite toto pole." + "showUsersTipTooltip": "Ak chcete, aby návštevníci videli správne riešenie aj svoj tip, zaškrtnite toto pole.", + "numberOfPinsLabel": "Počet bodov na nájdenie", + "numberOfPinsTooltip": "Zadajte počet bodov, ktoré chcete aby návštevník našiel. Každý bod bude môcť mať následne pridelený svoj vlastný jedinečný text." }, "gameDrawScreen": { "taskTooltip": "Čo najpresnejšie popíšte návštevníkom ich úlohu (napr. dokreslite do grafu, ako sa vyvíjala ťažba uhlia po roku 1950).", @@ -243,7 +245,11 @@ "imageResultLabel": "Výsledok", "imageResultTooltip": "Z knižnice dokumentov vyberte kompletný obrázok.", "showUsersDrawing": "Zobraziť kresbu návštevníkov", - "showUsersDrawingTooltip": "Ak chcete, aby návštevníci videli správne riešenie aj svoje tipy, zaškrtnite toto pole." + "showUsersDrawingTooltip": "Ak chcete, aby návštevníci videli správne riešenie aj svoje tipy, zaškrtnite toto pole.", + "initialDrawingSettingsTitle": "Počiatočné nastavenia pre kreslenie", + "initialDrawingColorLabel": "Počiatočná farba", + "initialDrawingThicknessLabel": "Počiatočná hrúbka", + "resetInitialDrawingSettingsBtnLabel": "Reset" }, "gameWipeScreen": { "taskTooltip": "Čo najpresnejšie popíšte návštevníkom ich úlohu (napr. Pozrite sa, ako vyzerala budova pred jej rekonštrukciou). ", @@ -278,7 +284,8 @@ "imageResultTooltip": "Z knižnice dokumentov vyberte výsledný kompletný obrázok.", "object": "Objekt", "objectSelectLabel": "Vybrať", - "objectTooltip": "Z knižnice dokumentov vyberte obrázok s časťami, z ktorých budú návštevníci vyberať." + "objectTooltip": "Z knižnice dokumentov vyberte obrázok s časťami, z ktorých budú návštevníci vyberať.", + "screenPreviewText": "Náhľad obrazovky" }, "gameQuizScreen": { "nameLabel": "Názov", diff --git a/web/public/locales/sk/view-exhibition.json b/web/public/locales/sk/view-exhibition.json index e3f8a06b..42dd131e 100644 --- a/web/public/locales/sk/view-exhibition.json +++ b/web/public/locales/sk/view-exhibition.json @@ -27,5 +27,6 @@ "untitled-chapter": "Nepomenovaná kapitola", "untitled-screen": "Nepomenovaná obrazovka", "rate-expo": "Hodnotiť výstavu", - "landscapeModeRecommendation": " Odporúčame prezeranie výstavy v režime na šírku." + "landscapeModeRecommendationSnackbarText": " Odporúčame prezeranie výstavy v režime na šírku.", + "audioWarningSnackbarText": "Z dôvodu obmedzenia iOS môže byť expozícia prerušená, pretože prehrávanie zvuku vyžaduje interakciu užívateľa. Ak sa tak stane, kliknite na tlačidlo prehrávania." } diff --git a/web/public/locales/sk/view-screen.json b/web/public/locales/sk/view-screen.json index 32d4532a..d8fa2df9 100644 --- a/web/public/locales/sk/view-screen.json +++ b/web/public/locales/sk/view-screen.json @@ -82,7 +82,6 @@ }, "game": { - "solution": "Správne riešenie", "controls": { "finished": "Hotovo", "play-again": "Zahrať znova" @@ -90,25 +89,25 @@ }, "game-find": { - "task": "Nájdi na obrázku" + "solution": "Správne riešenie" }, "game-draw": { - "task": "Dokresli obrázok", + "solution": "Výborne! Splnil si zadanie a toto je výsledok", "switchToPencilAction": "Prepnúť na ceruzku", "switchToEraserAction": "Prepnúť na gumu", "thicknessChooserAction": "Výber hrúbky", "colorChooserAction": "Výber farby" }, "game-erase": { - "task": "Vymaž obrázok" + "solution": "Výborne! Splnil si zadanie a toto je výsledok" }, "game-sizing": { - "task": "Uhádni správnu veľkosť" + "solution": "Správne riešenie" }, "game-move": { - "task": "Posuň na správne miesto" + "solution": "Správne riešenie" }, "game-quiz": { - "task": "Vyber správne možnosti" + "solution": "Správne riešenie" } } diff --git a/web/src/components/app-header/LoginAppHeader.tsx b/web/src/components/app-header/LoginAppHeader.tsx index c736e785..31582960 100644 --- a/web/src/components/app-header/LoginAppHeader.tsx +++ b/web/src/components/app-header/LoginAppHeader.tsx @@ -9,11 +9,6 @@ import { IconButton, Menu, MenuItem } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; -import { MdEdit } from "react-icons/md"; -import { MdEditOff } from "react-icons/md"; - -import { BasicTooltip } from "components/tooltip/tooltip"; - // Models import { ActiveExpo, Screen } from "models"; import { AppDispatch } from "store/store"; @@ -24,6 +19,7 @@ import { signOut } from "actions/user-actions"; import { openInNewTab } from "utils"; import { mapScreenTypeValuesToKeys } from "enums/screen-type"; import { useActiveExpoAccess } from "context/active-expo-access-provider/active-expo-access-provider"; +import { Icon } from "components/icon/icon"; // - - @@ -116,26 +112,39 @@ const LoginAppHeader = ({
{isReadWriteAccess ? ( -
- +
+
) : ( -
- +
+
)}
- - -
)}
diff --git a/web/src/components/button/button.tsx b/web/src/components/button/button.tsx index c0828178..156546cd 100644 --- a/web/src/components/button/button.tsx +++ b/web/src/components/button/button.tsx @@ -3,15 +3,8 @@ import { CSSProperties, forwardRef } from "react"; import { useExpoDesignData } from "hooks/view-hooks/expo-design-data-hook"; import cx from "classnames"; -import { BasicTooltip } from "components/tooltip/tooltip"; -import { PlacesType } from "react-tooltip"; - -type TooltipOption = { - id: string; - content: string; - variant?: "light" | "dark"; // if undefined, based on selected theme - place?: PlacesType; -}; +import { BasicTooltip } from "components/tooltip/BasicTooltip"; +import { BasicTooltipProps } from "components/tooltip/tooltip-props"; interface ButtonProps { color?: "default" | "primary" | "secondary" | "white" | "expoTheme"; @@ -27,7 +20,7 @@ interface ButtonProps { shadow?: boolean; iconBefore?: React.ReactNode; iconAfter?: React.ReactNode; - tooltip?: TooltipOption; + tooltip?: BasicTooltipProps; children?: React.ReactNode; } diff --git a/web/src/components/dialogs/audio-dialog/audio-dialog.tsx b/web/src/components/dialogs/audio-dialog/audio-dialog.tsx index b0466891..9fd7025f 100644 --- a/web/src/components/dialogs/audio-dialog/audio-dialog.tsx +++ b/web/src/components/dialogs/audio-dialog/audio-dialog.tsx @@ -12,8 +12,6 @@ import { Icon } from "components/icon/icon"; import { Divider } from "components/divider/divider"; import { Slider } from "@mui/material"; -import { Tooltip } from "react-tooltip"; - // Actions import { setExpoVolumes, @@ -160,17 +158,18 @@ const AudioSlider = ({ }) ); }} + tooltip={{ + id: `tooltip-${volumeKey}`, + content: tooltipContent ?? "", + place: "top", + style: { zIndex: 1 }, + }} > -
- -
+
{volumeObj.actualVolume}
- ); }; diff --git a/web/src/components/dialogs/chapters-dialog/screen-item.tsx b/web/src/components/dialogs/chapters-dialog/screen-item.tsx index 4d7d9423..4024acb3 100644 --- a/web/src/components/dialogs/chapters-dialog/screen-item.tsx +++ b/web/src/components/dialogs/chapters-dialog/screen-item.tsx @@ -85,7 +85,7 @@ export const ScreenItem = ({ )} diff --git a/web/src/components/dialogs/files-dialog/file-item.tsx b/web/src/components/dialogs/files-dialog/file-item.tsx index 917fe5aa..53290187 100644 --- a/web/src/components/dialogs/files-dialog/file-item.tsx +++ b/web/src/components/dialogs/files-dialog/file-item.tsx @@ -44,7 +44,7 @@ export const FileItem = ({ )} > @@ -137,7 +137,7 @@ const OverlayDialog = ({ } onClick={shouldIncrement ? pause : play} @@ -149,7 +149,7 @@ const OverlayDialog = ({ diff --git a/web/src/components/dialogs/share-expo-dialog/CopyClipboardBox.tsx b/web/src/components/dialogs/share-expo-dialog/CopyClipboardBox.tsx index f8bfb542..8f36ef99 100644 --- a/web/src/components/dialogs/share-expo-dialog/CopyClipboardBox.tsx +++ b/web/src/components/dialogs/share-expo-dialog/CopyClipboardBox.tsx @@ -1,7 +1,6 @@ import { useMemo } from "react"; import Snackbar from "react-md/lib/Snackbars"; import CopyToClipboard from "react-copy-to-clipboard"; -import { Tooltip } from "react-tooltip"; import { Icon } from "components/icon/icon"; import { useBoolean } from "hooks/boolean-hook"; @@ -32,11 +31,7 @@ export const CopyClipboardBox = ({ {text} -
+
{ @@ -44,21 +39,21 @@ export const CopyClipboardBox = ({ onCopy?.(); }} > - +
- {tooltipText && ( - - )} - { setCurrZoom((prevZoom) => prevZoom + 0.2); }} @@ -436,7 +436,7 @@ const SettingsPanel = ({ { setCurrZoom(1); }} @@ -450,7 +450,7 @@ const SettingsPanel = ({ { setCurrZoom((prevZoom) => prevZoom <= 1 ? prevZoom : prevZoom - 0.2 @@ -469,7 +469,7 @@ const SettingsPanel = ({ { changeImage(); }} @@ -483,7 +483,7 @@ const SettingsPanel = ({ { const imageEditorObj = { expoId: expoId, @@ -503,7 +503,7 @@ const SettingsPanel = ({ { dispatch( setDialog(DialogType.ConfirmDialog, { diff --git a/web/src/components/editors/game-description.tsx b/web/src/components/editors/game-description.tsx index a4998216..573a1bff 100644 --- a/web/src/components/editors/game-description.tsx +++ b/web/src/components/editors/game-description.tsx @@ -13,6 +13,7 @@ import { Screen } from "models"; import { getFileById } from "actions/file-actions-typed"; import { helpIconText } from "enums/text"; +import { GAME_SCREEN_DEFAULT_RESULT_TIME } from "constants/screen"; // - - @@ -49,7 +50,9 @@ const GameDescription = ({
diff --git a/web/src/components/form/formik/ColorPicker.tsx b/web/src/components/form/formik/ColorPicker.tsx index 0a0b1237..bb367fd0 100644 --- a/web/src/components/form/formik/ColorPicker.tsx +++ b/web/src/components/form/formik/ColorPicker.tsx @@ -79,17 +79,17 @@ const ColorPicker = ({ name, color, setColor, label }: ColorPickerProps) => {
setIsColorEditModeOn((prev) => !prev)} />
!isColorEditModeOn && openColorPicker()} />
diff --git a/web/src/components/icon/icon.tsx b/web/src/components/icon/icon.tsx index 89637cd8..4ff9adc5 100644 --- a/web/src/components/icon/icon.tsx +++ b/web/src/components/icon/icon.tsx @@ -1,23 +1,18 @@ import { CSSProperties, ReactNode } from "react"; - import { useExpoDesignData } from "hooks/view-hooks/expo-design-data-hook"; +// Components import { Icon as MuiIcon } from "@mui/material"; import FontIcon from "react-md/lib/FontIcons/FontIcon"; +import { BasicTooltip } from "components/tooltip/BasicTooltip"; +import { BasicTooltipProps } from "components/tooltip/tooltip-props"; -import { BasicTooltip } from "components/tooltip/tooltip"; -import { PlacesType } from "react-tooltip"; - +// Utils import cx from "classnames"; -type TooltipOption = { - id: string; - content: string; - variant?: "light" | "dark"; // if undefined, based on selected theme - place?: PlacesType; -}; +// - - - - -interface IconProps { +type IconProps = { name: string | ReactNode; // name either for FontIcon from 'react-md' or Icon from 'material-ui' useMaterialUiIcon?: boolean; color?: @@ -30,19 +25,25 @@ interface IconProps { | "inheritFromParents"; onClick?: () => void; noCenterPlace?: boolean; // by default, icon should be inside some container and centered - className?: string; - style?: CSSProperties; - tooltip?: TooltipOption; -} + containerClassName?: string; + containerStyle?: CSSProperties; + iconClassName?: string; + iconStyle?: CSSProperties; + tooltip?: BasicTooltipProps; +}; + +// - - - - export const Icon = ({ name, useMaterialUiIcon = false, color = "inheritFromParents", - className, - style, onClick, noCenterPlace = false, + containerClassName, + containerStyle, + iconClassName, + iconStyle, tooltip, }: IconProps) => { const { expoDesignData, fgThemingIf } = useExpoDesignData(); @@ -65,20 +66,29 @@ export const Icon = ({ "text-muted-400": color === "muted-400", "hover:cursor-pointer": !!onClick, }, - className + containerClassName )} style={{ + ...containerStyle, color: themeEnabledIconsColor, }} data-tooltip-id={tooltip?.id ?? undefined} > {/* Icon color either inherited from Button parent or div if some color prop is used */} {useMaterialUiIcon ? ( - + {name} ) : ( - + {name} )} diff --git a/web/src/components/infopoint/ScreenAnchorInfopoint.tsx b/web/src/components/infopoint/ScreenAnchorInfopoint.tsx deleted file mode 100644 index 90a72a1a..00000000 --- a/web/src/components/infopoint/ScreenAnchorInfopoint.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { DetailedHTMLProps, HTMLAttributes } from "react"; -import { animated, useSpring } from "react-spring"; -import cx from "classnames"; -import { Infopoint } from "models"; - -// - - - - - - - - - -type BaseProps = { - id: string; // used as "data-tooltip-id" - top: number; - left: number; - infopoint: Infopoint; - color?: "primary"; // backward compatibility -}; - -type ScreenAnchorInfopointProps = BaseProps & - DetailedHTMLProps, HTMLDivElement>; - -const ScreenAnchorInfopoint = (props: ScreenAnchorInfopointProps) => { - if (props.infopoint?.shape === "CIRCLE") { - return ; - } - if (props.infopoint?.shape === "ICON") { - return ; - } - return ; -}; - -// - - - - - - - - - -const CircleAnchorInfopoint = ({ - id, // used as "data-tooltip-id" - top, - left, - infopoint, - ...otherProps -}: ScreenAnchorInfopointProps) => { - // Animation - const { opacity, scale } = useSpring({ - from: { opacity: 1, scale: 1 }, - to: { opacity: 0, scale: 1.75 }, - loop: true, - config: { - friction: 50, - }, - }); - - return ( -
{ - e.stopPropagation(); - }} - > -
- -
-
- ); -}; - -// - - - - - - - - - -const IconAnchorInfopoint = ({ - id, - top, - left, - infopoint, - ...otherProps -}: ScreenAnchorInfopointProps) => { - if (!infopoint.iconFile?.fileId) { - return null; - } - - return ( -
{ - e.stopPropagation(); - }} - > - icon-infopoint -
- ); -}; - -// - - - - - - - - - -const SquareAnchorInfopoint = ({ - id, // used as "data-tooltip-id" - top, - left, - infopoint, - color = "primary", - ...otherProps -}: ScreenAnchorInfopointProps) => { - // Animation - const { opacity, scale } = useSpring({ - from: { opacity: 1, scale: 1 }, - to: { opacity: 0, scale: 1.75 }, - loop: true, - config: { - friction: 50, - }, - }); - - return ( -
{ - e.stopPropagation(); - }} - > - - -
-
- ); -}; - -// - - - - - - - - - -export default ScreenAnchorInfopoint; diff --git a/web/src/components/infopoint/components/anchor-infopoint/CircleAnchorInfopoint.tsx b/web/src/components/infopoint/components/anchor-infopoint/CircleAnchorInfopoint.tsx new file mode 100644 index 00000000..abc98d70 --- /dev/null +++ b/web/src/components/infopoint/components/anchor-infopoint/CircleAnchorInfopoint.tsx @@ -0,0 +1,64 @@ +import { useSpring, animated } from "react-spring"; +import { AnchorInfopointProps } from "."; +import cx from "classnames"; + +const CircleAnchorInfopoint = ({ + id, // used as "data-tooltip-id" + top, + left, + infopoint, + ...otherProps +}: AnchorInfopointProps) => { + // Animation + const { opacity, scale } = useSpring({ + from: { opacity: 1, scale: 1 }, + to: { opacity: 0, scale: 1.75 }, + loop: true, + config: { + friction: 50, + }, + }); + + return ( +
{ + e.stopPropagation(); + }} + > +
+ +
+
+ ); +}; + +export default CircleAnchorInfopoint; diff --git a/web/src/components/infopoint/components/anchor-infopoint/IconAnchorInfopoint.tsx b/web/src/components/infopoint/components/anchor-infopoint/IconAnchorInfopoint.tsx new file mode 100644 index 00000000..e944c928 --- /dev/null +++ b/web/src/components/infopoint/components/anchor-infopoint/IconAnchorInfopoint.tsx @@ -0,0 +1,41 @@ +import { AnchorInfopointProps } from "."; +import cx from "classnames"; + +const IconAnchorInfopoint = ({ + id, + top, + left, + infopoint, + ...otherProps +}: AnchorInfopointProps) => { + if (!infopoint.iconFile?.fileId) { + return null; + } + + return ( +
{ + e.stopPropagation(); + }} + > + icon-infopoint +
+ ); +}; + +export default IconAnchorInfopoint; diff --git a/web/src/components/infopoint/components/anchor-infopoint/SquareAnchorInfopoint.tsx b/web/src/components/infopoint/components/anchor-infopoint/SquareAnchorInfopoint.tsx new file mode 100644 index 00000000..4231192f --- /dev/null +++ b/web/src/components/infopoint/components/anchor-infopoint/SquareAnchorInfopoint.tsx @@ -0,0 +1,58 @@ +import { useSpring, animated } from "react-spring"; +import { AnchorInfopointProps } from "."; +import cx from "classnames"; + +const SquareAnchorInfopoint = ({ + id, // used as "data-tooltip-id" + top, + left, + infopoint, + color = "primary", + ...otherProps +}: AnchorInfopointProps) => { + // Animation + const { opacity, scale } = useSpring({ + from: { opacity: 1, scale: 1 }, + to: { opacity: 0, scale: 1.75 }, + loop: true, + config: { + friction: 50, + }, + }); + + return ( +
{ + e.stopPropagation(); + }} + > + + +
+
+ ); +}; + +export default SquareAnchorInfopoint; diff --git a/web/src/components/infopoint/components/anchor-infopoint/index.tsx b/web/src/components/infopoint/components/anchor-infopoint/index.tsx new file mode 100644 index 00000000..3ad56b13 --- /dev/null +++ b/web/src/components/infopoint/components/anchor-infopoint/index.tsx @@ -0,0 +1,36 @@ +import { DetailedHTMLProps, HTMLAttributes } from "react"; + +// Components +import SquareAnchorInfopoint from "./SquareAnchorInfopoint"; +import IconAnchorInfopoint from "./IconAnchorInfopoint"; +import CircleAnchorInfopoint from "./CircleAnchorInfopoint"; + +// Models +import { Infopoint } from "models"; + +// - - - - - - - - + +type BaseProps = { + id: string; // used as "data-tooltip-id" + top: number; + left: number; + infopoint: Infopoint; + color?: "primary"; // backward compatibility +}; + +export type AnchorInfopointProps = BaseProps & + DetailedHTMLProps, HTMLDivElement>; + +const AnchorInfopoint = (props: AnchorInfopointProps) => { + if (props.infopoint.shape === "CIRCLE") { + return ; + } + if (props.infopoint.shape === "ICON") { + return ; + } + + // Defaults to Square, because of backward compatibility + return ; +}; + +export default AnchorInfopoint; diff --git a/web/src/components/infopoint/InfopointBody.tsx b/web/src/components/infopoint/components/tooltip-infopoint/InfopointBody.tsx similarity index 100% rename from web/src/components/infopoint/InfopointBody.tsx rename to web/src/components/infopoint/components/tooltip-infopoint/InfopointBody.tsx diff --git a/web/src/components/infopoint/TooltipInfopoint.tsx b/web/src/components/infopoint/components/tooltip-infopoint/TooltipInfopoint.tsx similarity index 71% rename from web/src/components/infopoint/TooltipInfopoint.tsx rename to web/src/components/infopoint/components/tooltip-infopoint/TooltipInfopoint.tsx index 3099c354..8766bc4f 100644 --- a/web/src/components/infopoint/TooltipInfopoint.tsx +++ b/web/src/components/infopoint/components/tooltip-infopoint/TooltipInfopoint.tsx @@ -1,20 +1,24 @@ import { useState, Dispatch, SetStateAction } from "react"; -import { Tooltip } from "react-tooltip"; - import { useExpoDesignData } from "hooks/view-hooks/expo-design-data-hook"; + +import { Tooltip } from "react-tooltip"; import InfopointBody from "./InfopointBody"; -import { InfopointStatusObject } from "./parseScreenMaps"; +// Models +import { InfopointStatusObject } from "../../useTooltipInfopoint"; import { Infopoint } from "models"; +// Utils import cx from "classnames"; import { getTooltipArrowBorderClassName } from "utils/view-utils"; +// - - - - + type TooltipInfoPointProps = { id: string; infopoint: Infopoint; - infopointOpenStatusMap: Record; - setInfopointOpenStatusMap: Dispatch< + infopointStatusMap: Record; + setInfopointStatusMap: Dispatch< SetStateAction> >; primaryKey: string; @@ -22,38 +26,15 @@ type TooltipInfoPointProps = { canBeOpen?: boolean; }; -const TooltipInfoPoint = (props: TooltipInfoPointProps) => { - const keyMap = - props.secondaryKey === undefined - ? `${props.primaryKey}` - : `${props.primaryKey}-${props.secondaryKey}`; - - return ; -}; - -export default TooltipInfoPoint; - -// - - - - - -interface Props { - id: string; - infopoint: Infopoint; - infopointOpenStatusMap: Record; - setInfopointOpenStatusMap: Dispatch< - SetStateAction> - >; - keyMap: string; - canBeOpen?: boolean; -} - -const BasicTooltipInfopoint = ({ +const TooltipInfoPoint = ({ id, infopoint, - infopointOpenStatusMap, - setInfopointOpenStatusMap, - keyMap, + infopointStatusMap, + setInfopointStatusMap, + primaryKey, + secondaryKey, canBeOpen = true, -}: Props) => { +}: TooltipInfoPointProps) => { const { isLightMode } = useExpoDesignData(); const [isVideoLoaded, setIsVideoLoaded] = useState(false); // infopoints's video if video type @@ -70,6 +51,11 @@ const BasicTooltipInfopoint = ({ ? "right" : undefined; + const keyMap = + secondaryKey === undefined + ? `${primaryKey}` + : `${primaryKey}-${secondaryKey}`; + return ( { const closeThisInfopoint = () => { - setInfopointOpenStatusMap((prevMap) => ({ + setInfopointStatusMap((prevMap) => ({ ...prevMap, [keyMap]: { ...prevMap[keyMap], isOpen: false }, })); @@ -102,10 +88,10 @@ const BasicTooltipInfopoint = ({ setIsVideoLoaded, }); }} - isOpen={infopointOpenStatusMap[keyMap].isOpen && canBeOpen} + isOpen={infopointStatusMap[keyMap].isOpen && canBeOpen} setIsOpen={(isOpen) => { if (isOpen) { - setInfopointOpenStatusMap((prevMap) => ({ + setInfopointStatusMap((prevMap) => ({ ...prevMap, [keyMap]: { ...prevMap[keyMap], isOpen: !prevMap[keyMap].isOpen }, })); @@ -115,3 +101,5 @@ const BasicTooltipInfopoint = ({ /> ); }; + +export default TooltipInfoPoint; diff --git a/web/src/components/infopoint/VideoContentBody.tsx b/web/src/components/infopoint/components/tooltip-infopoint/VideoContentBody.tsx similarity index 98% rename from web/src/components/infopoint/VideoContentBody.tsx rename to web/src/components/infopoint/components/tooltip-infopoint/VideoContentBody.tsx index 80faa2e0..de6a152a 100644 --- a/web/src/components/infopoint/VideoContentBody.tsx +++ b/web/src/components/infopoint/components/tooltip-infopoint/VideoContentBody.tsx @@ -143,7 +143,7 @@ const VideoContentBody = ({ useMaterialUiIcon name={isVideoPlaying ? "pause" : "play_arrow"} onClick={() => playPauseVideo()} - style={{ fontSize: "22px" }} + iconStyle={{ fontSize: "22px" }} />
@@ -176,8 +176,8 @@ const VideoContentBody = ({ setVideoVolume(prevVideoVolume); } }} - style={{ fontSize: "22px" }} - className="ml-auto" + iconStyle={{ fontSize: "22px" }} + containerClassName="ml-auto" />
diff --git a/web/src/components/infopoint/hooks/useInfopointClosing.ts b/web/src/components/infopoint/hooks/useInfopointClosing.ts new file mode 100644 index 00000000..a76ab572 --- /dev/null +++ b/web/src/components/infopoint/hooks/useInfopointClosing.ts @@ -0,0 +1,81 @@ +import { useCallback, Dispatch, SetStateAction } from "react"; + +import { + InfopointStatusMap, + InfopointSupportedScreens, +} from "../useTooltipInfopoint"; + +import { + GameQuizScreen, + ImageScreen, + ImageChangeScreen, + SlideshowScreen, +} from "models"; + +import { screenType } from "enums/screen-type"; + +// - - - - + +type UseInfopointClosingProps = { + infopointStatusMap: InfopointStatusMap; + setInfopointStatusMap: Dispatch>; +}; + +export const useInfopointClosing = ({ + infopointStatusMap, + setInfopointStatusMap, +}: UseInfopointClosingProps) => { + // 1. Iterates through whole map and closes all of the infopoints + const closeAllInfopoints = useCallback(() => { + if (!infopointStatusMap) { + return; + } + + const entries = Object.entries(infopointStatusMap); + const closedEntries = entries.map(([key, infopoint]) => { + return [key, { ...infopoint, isOpen: false }]; + }); + + const newMap = Object.fromEntries(closedEntries); + setInfopointStatusMap(newMap); + }, [infopointStatusMap, setInfopointStatusMap]); + + // 2. Iterates through whole map, but closes only infopoints of current photo (primary-key) + const closePhotoInfopoints = useCallback( + (photoIndex: number) => { + if (!infopointStatusMap) { + return; + } + const entries = Object.entries(infopointStatusMap); + const closedEntries = entries.map(([key, infopoint]) => { + const parsedPhotoKey = parseInt(key.charAt(0)); + if (!isNaN(parsedPhotoKey) && parsedPhotoKey === photoIndex) { + return [key, { ...infopoint, isOpen: false }]; + } + return [key, { ...infopoint }]; + }); + + const newMap = Object.fromEntries(closedEntries); + setInfopointStatusMap(newMap); + }, + [infopointStatusMap, setInfopointStatusMap] + ); + + // 3. Wrapper for closing infopoints for particular screen + // Called e.g. when ESC was pressed or when clicking on the image outside any infopoint + // NOTE: Typescript method overloading + function closeInfopoints(screen: GameQuizScreen): () => void; + function closeInfopoints(screen: ImageScreen): () => void; + function closeInfopoints(screen: ImageChangeScreen): () => void; + function closeInfopoints( + screen: SlideshowScreen + ): (photoIndex: number) => void; + function closeInfopoints(screen: InfopointSupportedScreens) { + if (screen.type === screenType.SLIDESHOW) { + return closePhotoInfopoints; + } + return closeAllInfopoints; + } + + return { closeInfopoints }; +}; diff --git a/web/src/components/infopoint/hooks/useMobileInfopointAutoClosing.ts b/web/src/components/infopoint/hooks/useMobileInfopointAutoClosing.ts new file mode 100644 index 00000000..7c99c1f0 --- /dev/null +++ b/web/src/components/infopoint/hooks/useMobileInfopointAutoClosing.ts @@ -0,0 +1,64 @@ +import { useEffect, Dispatch, SetStateAction } from "react"; +import { useMediaDevice } from "context/media-device-provider/media-device-provider"; + +import { InfopointStatusMap } from "../useTooltipInfopoint"; + +type UseMobileInfopointAutoClosingProps = { + setInfopointStatusMap: Dispatch>; + isMapParsingDone: boolean; +}; + +/** + * On small (mobile) screens, all infopoints (even the 'isAlwaysVisible = true' ones) are by default closed first. + */ +export const useMobileInfopointAutoClosing = ({ + setInfopointStatusMap, + isMapParsingDone, +}: UseMobileInfopointAutoClosingProps) => { + const { isSm } = useMediaDevice(); + + useEffect(() => { + // Handle larger screens + if (!isSm) { + setInfopointStatusMap((prevMap) => { + if (!prevMap) { + return prevMap; + } + + const entries = Object.entries(prevMap); + const nextMap = entries.reduce( + (acc, [key, infopointStatus]) => ({ + ...acc, + [key]: { + ...infopointStatus, + isOpen: infopointStatus.isAlwaysVisible, + }, + }), + {} as InfopointStatusMap + ); + + return nextMap; + }); + + return; + } + + // Handle mobile screens + setInfopointStatusMap((prevMap) => { + if (!prevMap) { + return prevMap; + } + + const entries = Object.entries(prevMap); + const nextMapWithClosedInfopoints = entries.reduce( + (acc, [key, infopointStatus]) => ({ + ...acc, + [key]: { ...infopointStatus, isOpen: false }, + }), + {} as InfopointStatusMap + ); + + return nextMapWithClosedInfopoints; + }); + }, [isSm, isMapParsingDone, setInfopointStatusMap]); +}; diff --git a/web/src/components/infopoint/parseScreenMaps.ts b/web/src/components/infopoint/parseScreenMaps.ts deleted file mode 100644 index 2c91d5c0..00000000 --- a/web/src/components/infopoint/parseScreenMaps.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - ImageScreen, - SlideshowScreen, - ImageChangeScreen, - GameQuizScreen, -} from "models"; - -export type InfopointStatusObject = { - isOpen: boolean; - isAlwaysVisible: boolean; -}; - -export const parseSlideshowScreenMap = (viewScreen: SlideshowScreen) => { - const infopointsMap = viewScreen.images?.reduce( - (acc, currImage, currImageIndex) => { - const reducedImage = currImage.infopoints.reduce( - (innerAcc, currInfopoint, currInfopointIndex) => { - return { - ...innerAcc, - [`${currImageIndex}-${currInfopointIndex}`]: { - isOpen: currInfopoint.alwaysVisible, - isAlwaysVisible: currInfopoint.alwaysVisible, - }, - }; - }, - {} - ); - - return { ...acc, ...reducedImage }; - }, - {} - ); - - return infopointsMap as Record; -}; - -export const parseGameQuizScreenMap = (viewScreen: GameQuizScreen) => { - const infopointsMap = viewScreen.answers?.reduce( - (acc, currAnswer, currAnswerIndex) => { - const reducedAnswer = currAnswer.infopoints?.reduce( - (innerAcc, currInfopoint, currInfopointIndex) => { - return { - ...innerAcc, - [`${currAnswerIndex}-${currInfopointIndex}`]: { - isOpen: currInfopoint.alwaysVisible, - isAlwaysVisible: currInfopoint.alwaysVisible, - }, - }; - }, - {} - ); - - return { ...acc, ...reducedAnswer }; - }, - {} - ); - - return infopointsMap as Record; -}; - -export const parseImageScreenMap = (viewScreen: ImageScreen) => { - const infopointsMap = viewScreen?.infopoints?.reduce( - (acc, currInfopoint, currInfopointIndex) => { - return { - ...acc, - [`${currInfopointIndex}`]: { - isOpen: currInfopoint.alwaysVisible, - isAlwaysVisible: currInfopoint.alwaysVisible, - }, - }; - }, - {} - ); - - return infopointsMap as Record; -}; - -export const parseImageChangeScreenMap = (viewScreen: ImageChangeScreen) => { - const infopointsMapFirst = viewScreen?.image1Infopoints?.reduce( - (acc, currInfopoint, currInfopointIndex) => { - return { - ...acc, - [`0-${currInfopointIndex}`]: { - isOpen: currInfopoint.alwaysVisible, - isAlwaysVisible: currInfopoint.alwaysVisible, - }, - }; - }, - {} - ); - - const infopointsMapSecond = viewScreen?.image2Infopoints?.reduce( - (acc, currInfopoint, currInfopointIndex) => { - return { - ...acc, - [`1-${currInfopointIndex}`]: { - isOpen: currInfopoint.alwaysVisible, - isAlwaysVisible: currInfopoint.alwaysVisible, - }, - }; - }, - {} - ); - - const infopointsMap = { ...infopointsMapFirst, ...infopointsMapSecond }; - return infopointsMap as Record; -}; diff --git a/web/src/components/infopoint/screen-to-map-parsers/game-quiz-parser.ts b/web/src/components/infopoint/screen-to-map-parsers/game-quiz-parser.ts new file mode 100644 index 00000000..b0b29899 --- /dev/null +++ b/web/src/components/infopoint/screen-to-map-parsers/game-quiz-parser.ts @@ -0,0 +1,26 @@ +import { GameQuizScreen } from "models"; +import { InfopointStatusMap } from "../useTooltipInfopoint"; + +export const parseGameQuizScreenMap = (viewScreen: GameQuizScreen) => { + const infopointsMap = viewScreen.answers?.reduce( + (acc, currAnswer, currAnswerIndex) => { + const reducedAnswer = currAnswer.infopoints?.reduce( + (innerAcc, currInfopoint, currInfopointIndex) => { + return { + ...innerAcc, + [`${currAnswerIndex}-${currInfopointIndex}`]: { + isOpen: currInfopoint.alwaysVisible, + isAlwaysVisible: currInfopoint.alwaysVisible, + }, + }; + }, + {} + ); + + return { ...acc, ...reducedAnswer }; + }, + {} + ); + + return infopointsMap as InfopointStatusMap; +}; diff --git a/web/src/components/infopoint/screen-to-map-parsers/image-change-parser.ts b/web/src/components/infopoint/screen-to-map-parsers/image-change-parser.ts new file mode 100644 index 00000000..d8c6d0ee --- /dev/null +++ b/web/src/components/infopoint/screen-to-map-parsers/image-change-parser.ts @@ -0,0 +1,33 @@ +import { ImageChangeScreen } from "models"; +import { InfopointStatusMap } from "../useTooltipInfopoint"; + +export const parseImageChangeScreenMap = (viewScreen: ImageChangeScreen) => { + const infopointsMapFirst = viewScreen?.image1Infopoints?.reduce( + (acc, currInfopoint, currInfopointIndex) => { + return { + ...acc, + [`0-${currInfopointIndex}`]: { + isOpen: currInfopoint.alwaysVisible, + isAlwaysVisible: currInfopoint.alwaysVisible, + }, + }; + }, + {} + ); + + const infopointsMapSecond = viewScreen?.image2Infopoints?.reduce( + (acc, currInfopoint, currInfopointIndex) => { + return { + ...acc, + [`1-${currInfopointIndex}`]: { + isOpen: currInfopoint.alwaysVisible, + isAlwaysVisible: currInfopoint.alwaysVisible, + }, + }; + }, + {} + ); + + const infopointsMap = { ...infopointsMapFirst, ...infopointsMapSecond }; + return infopointsMap as InfopointStatusMap; +}; diff --git a/web/src/components/infopoint/screen-to-map-parsers/image-parser.ts b/web/src/components/infopoint/screen-to-map-parsers/image-parser.ts new file mode 100644 index 00000000..171054c8 --- /dev/null +++ b/web/src/components/infopoint/screen-to-map-parsers/image-parser.ts @@ -0,0 +1,19 @@ +import { ImageScreen } from "models"; +import { InfopointStatusMap } from "../useTooltipInfopoint"; + +export const parseImageScreenMap = (viewScreen: ImageScreen) => { + const infopointsMap = viewScreen?.infopoints?.reduce( + (acc, currInfopoint, currInfopointIndex) => { + return { + ...acc, + [`${currInfopointIndex}`]: { + isOpen: currInfopoint.alwaysVisible, + isAlwaysVisible: currInfopoint.alwaysVisible, + }, + }; + }, + {} + ); + + return infopointsMap as InfopointStatusMap; +}; diff --git a/web/src/components/infopoint/screen-to-map-parsers/index.ts b/web/src/components/infopoint/screen-to-map-parsers/index.ts new file mode 100644 index 00000000..c02dbc3b --- /dev/null +++ b/web/src/components/infopoint/screen-to-map-parsers/index.ts @@ -0,0 +1,28 @@ +import { parseGameQuizScreenMap } from "./game-quiz-parser"; +import { parseImageChangeScreenMap } from "./image-change-parser"; +import { parseImageScreenMap } from "./image-parser"; +import { parseSlideshowScreenMap } from "./slideshow-parser"; + +import { + InfopointStatusMap, + InfopointSupportedScreens, +} from "../useTooltipInfopoint"; + +import { screenType } from "enums/screen-type"; + +export const parseScreenToInfopointStatusMap = ( + viewScreen: InfopointSupportedScreens +): InfopointStatusMap => { + switch (viewScreen.type) { + case screenType.GAME_OPTIONS: + return parseGameQuizScreenMap(viewScreen); + case screenType.IMAGE_CHANGE: + return parseImageChangeScreenMap(viewScreen); + case screenType.IMAGE: + return parseImageScreenMap(viewScreen); + case screenType.SLIDESHOW: + return parseSlideshowScreenMap(viewScreen); + default: + throw new Error("Unsupported view screen type for infopoint map parser."); + } +}; diff --git a/web/src/components/infopoint/screen-to-map-parsers/slideshow-parser.ts b/web/src/components/infopoint/screen-to-map-parsers/slideshow-parser.ts new file mode 100644 index 00000000..129499be --- /dev/null +++ b/web/src/components/infopoint/screen-to-map-parsers/slideshow-parser.ts @@ -0,0 +1,26 @@ +import { SlideshowScreen } from "models"; +import { InfopointStatusMap } from "../useTooltipInfopoint"; + +export const parseSlideshowScreenMap = (viewScreen: SlideshowScreen) => { + const infopointsMap = viewScreen.images?.reduce( + (acc, currImage, currImageIndex) => { + const reducedImage = currImage.infopoints.reduce( + (innerAcc, currInfopoint, currInfopointIndex) => { + return { + ...innerAcc, + [`${currImageIndex}-${currInfopointIndex}`]: { + isOpen: currInfopoint.alwaysVisible, + isAlwaysVisible: currInfopoint.alwaysVisible, + }, + }; + }, + {} + ); + + return { ...acc, ...reducedImage }; + }, + {} + ); + + return infopointsMap as InfopointStatusMap; +}; diff --git a/web/src/components/infopoint/useTooltipInfopoint.tsx b/web/src/components/infopoint/useTooltipInfopoint.tsx index cc85aa17..3162c8c5 100644 --- a/web/src/components/infopoint/useTooltipInfopoint.tsx +++ b/web/src/components/infopoint/useTooltipInfopoint.tsx @@ -1,20 +1,14 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState } from "react"; -// Components -import ScreenAnchorInfopoint from "./ScreenAnchorInfopoint"; -import TooltipInfoPoint from "./TooltipInfopoint"; +import { useMobileInfopointAutoClosing } from "./hooks/useMobileInfopointAutoClosing"; +import { useInfopointClosing } from "./hooks/useInfopointClosing"; -import { useMediaQuery } from "@mui/material"; -import { breakpoints } from "hooks/media-query-hook/breakpoints"; +// Components +import AnchorInfopoint from "./components/anchor-infopoint"; +import TooltipInfoPoint from "./components/tooltip-infopoint/TooltipInfopoint"; // Utils -import { - parseImageScreenMap, - parseSlideshowScreenMap, - parseImageChangeScreenMap, - parseGameQuizScreenMap, - InfopointStatusObject, -} from "./parseScreenMaps"; +import { parseScreenToInfopointStatusMap } from "./screen-to-map-parsers"; // Models import { @@ -23,16 +17,22 @@ import { ImageChangeScreen, GameQuizScreen, } from "models"; -import { screenType } from "enums/screen-type"; // - - - - - -type InfopointSupportedScreens = +export type InfopointSupportedScreens = | ImageScreen | SlideshowScreen | ImageChangeScreen | GameQuizScreen; +export type InfopointStatusObject = { + isOpen: boolean; + isAlwaysVisible: boolean; +}; + +export type InfopointStatusMap = Record; + /** * 1. Import this hook into screen where you want to use Infopoints * 2. Implement map parser for your type of screen, currently implemented parsers are highly usable @@ -43,13 +43,13 @@ type InfopointSupportedScreens = * the infopoint is currently opened or closed * - alwaysVisible in czech "stale zobrazen", is a checkbox in screen administration * - alwaysVisible infopoint is at the beginning open, thats the only difference from another infopoint - * 3. Use ScreenAnchorInfopoint component in your screen + * 3. Use AnchorInfopoint component in your screen * - requires top and left props as absolute offset positioning * - requires id and content prop, which is used as data-tooltip-id, data-tooltip-content * - id of SquareInfopoint must be the same as the id prop of TooltipInfopoint * - content of SquareInfopoint will be through id linked and used in TooltipInfopoint * 4. Apply TooltipInfopoint component - * - requires id props which must be the same as the supplied id in ScreenAnchorInfopoint + * - requires id props which must be the same as the supplied id in AnchorInfopoint * - requires information whether this infopoint is marked as alwaysVisible from administration * - requires infopointOpenStatusMap and setter of this map.. created in first step and returned by this hook * - requires primary and optionally secondary key, they will be combined into one mapKey @@ -58,135 +58,36 @@ type InfopointSupportedScreens = * EXAMPLES: SLIDESHOW screen or IMAGE screen */ const useTooltipInfopoint = (viewScreen: InfopointSupportedScreens) => { - const isSm = useMediaQuery(breakpoints.down("sm")); - const [isMapParsingDone, setIsMapParsingDone] = useState(false); // Object containing information (isOpen, isAlwaysVisible) for each infopoint present in supported screen // isAlwaysVisible is the mark for each infopoint, set in the slideshow administration - const [infopointOpenStatusMap, setInfopointOpenStatusMap] = useState(() => { - let parsedInfopointStatusMap: Record | null = - null; - - if (viewScreen.type === screenType.GAME_OPTIONS) { - parsedInfopointStatusMap = parseGameQuizScreenMap(viewScreen); - } else if (viewScreen.type === screenType.SLIDESHOW) { - parsedInfopointStatusMap = parseSlideshowScreenMap(viewScreen); - } else if (viewScreen.type === screenType.IMAGE_CHANGE) { - parsedInfopointStatusMap = parseImageChangeScreenMap(viewScreen); - } else { - // TODO - improve structure - parsedInfopointStatusMap = parseImageScreenMap(viewScreen); - } - - setIsMapParsingDone(true); - return parsedInfopointStatusMap; - }); - - // - - - - - // On small (mobile) screens, all infopoint, even the alwaysVisible, are by default first closed and then could be opened - useEffect(() => { - if (!isSm) { - setInfopointOpenStatusMap((prevMap) => { - if (!prevMap) { - return prevMap; - } - const entries = Object.entries(prevMap); - const nextMap = entries.reduce( - (acc, [key, infopointStatus]) => ({ - ...acc, - [key]: { - ...infopointStatus, - isOpen: infopointStatus.isAlwaysVisible, - }, - }), - {} as Record - ); - - return nextMap; - }); - - return; - } - - setInfopointOpenStatusMap((prevMap) => { - if (!prevMap) { - return prevMap; - } - const entries = Object.entries(prevMap); - const nextMapWithClosedInfopoints = entries.reduce( - (acc, [key, infopointStatus]) => ({ - ...acc, - [key]: { ...infopointStatus, isOpen: false }, - }), - {} as Record - ); - - return nextMapWithClosedInfopoints; + const [infopointStatusMap, setInfopointStatusMap] = + useState(() => { + const parsedInfopointMap = parseScreenToInfopointStatusMap(viewScreen); + setIsMapParsingDone(true); + return parsedInfopointMap; }); - }, [isSm, isMapParsingDone]); // - - - - // Iterates through whole map and close all of the infopoints - const closeAllInfopoints = useCallback(() => { - if (!infopointOpenStatusMap) { - return; - } - const entries = Object.entries(infopointOpenStatusMap); - const closedEntries = entries.map(([key, infopoint]) => { - return [key, { ...infopoint, isOpen: false }]; - }); - - const newMap = Object.fromEntries(closedEntries); - setInfopointOpenStatusMap(newMap); - }, [infopointOpenStatusMap]); - - // Iterates through whole map, but close only infopoints of current photo - const closePhotoInfopoints = useCallback( - (photoIndex: number) => { - if (!infopointOpenStatusMap) { - return; - } - const entries = Object.entries(infopointOpenStatusMap); - const closedEntries = entries.map(([key, infopoint]) => { - const parsedPhotoKey = parseInt(key.charAt(0)); - if (!isNaN(parsedPhotoKey) && parsedPhotoKey === photoIndex) { - return [key, { ...infopoint, isOpen: false }]; - } - return [key, { ...infopoint }]; - }); - - const newMap = Object.fromEntries(closedEntries); - setInfopointOpenStatusMap(newMap); - }, - [infopointOpenStatusMap] - ); + useMobileInfopointAutoClosing({ + setInfopointStatusMap, + isMapParsingDone, + }); // - - - - // Function which will close infopoints - // Called e.g on ESC press + clicking on the image as outside any infopoint - // Typescript method overloading - function closeInfopoints(screen: GameQuizScreen): () => void; - function closeInfopoints(screen: ImageScreen): () => void; - function closeInfopoints(screen: ImageChangeScreen): () => void; - function closeInfopoints( - screen: SlideshowScreen - ): (photoIndex: number) => void; - function closeInfopoints(screen: InfopointSupportedScreens) { - if (screen.type === screenType.SLIDESHOW) { - return closePhotoInfopoints; - } - return closeAllInfopoints; - } + const { closeInfopoints } = useInfopointClosing({ + infopointStatusMap: infopointStatusMap, + setInfopointStatusMap: setInfopointStatusMap, + }); return { - infopointOpenStatusMap, - setInfopointOpenStatusMap, + infopointStatusMap, + setInfopointStatusMap, closeInfopoints, - ScreenAnchorInfopoint, + AnchorInfopoint, TooltipInfoPoint, }; }; diff --git a/web/src/components/tooltip/tooltip.tsx b/web/src/components/tooltip/BasicTooltip.tsx similarity index 57% rename from web/src/components/tooltip/tooltip.tsx rename to web/src/components/tooltip/BasicTooltip.tsx index 5485e1c3..32931b68 100644 --- a/web/src/components/tooltip/tooltip.tsx +++ b/web/src/components/tooltip/BasicTooltip.tsx @@ -1,13 +1,7 @@ -import { Tooltip as ReactTooltip, PlacesType } from "react-tooltip"; +import { Tooltip as ReactTooltip } from "react-tooltip"; import { useExpoDesignData } from "hooks/view-hooks/expo-design-data-hook"; - -type TooltipProps = { - id: string; - content: string; - variant?: "light" | "dark"; - place?: PlacesType; - className?: string; -}; +import { BasicTooltipProps } from "./tooltip-props"; +import { useMediaDevice } from "context/media-device-provider/media-device-provider"; /** * Basic tooltip which listens to theme changes if variant is not overridden by prop @@ -18,8 +12,15 @@ export const BasicTooltip = ({ variant, place, className, -}: TooltipProps) => { + style, +}: BasicTooltipProps) => { const { isLightMode } = useExpoDesignData(); + const { isMobile, isMobileLandscape } = useMediaDevice(); + + if (isMobile || isMobileLandscape) { + return null; + } + return ( ); }; diff --git a/web/src/components/tooltip/tooltip-props.ts b/web/src/components/tooltip/tooltip-props.ts new file mode 100644 index 00000000..a7fdab54 --- /dev/null +++ b/web/src/components/tooltip/tooltip-props.ts @@ -0,0 +1,11 @@ +import { CSSProperties } from "react"; +import { PlacesType } from "react-tooltip"; + +export type BasicTooltipProps = { + id: string; + content: string; + variant?: "light" | "dark"; + place?: PlacesType; + className?: string; + style?: CSSProperties; +}; diff --git a/web/src/constants/screen.ts b/web/src/constants/screen.ts index 06f96abb..6c2b205a 100644 --- a/web/src/constants/screen.ts +++ b/web/src/constants/screen.ts @@ -1,7 +1,15 @@ export const defaultScreenTimeInSeconds = 20; export const defaultScreenTimeInMs = defaultScreenTimeInSeconds * 1000; -export const OVERLAY_UNACTIVE_TIMEOUT = 3000; // in miliseconds +export const OVERLAY_UNACTIVE_TIMEOUT = 3000; // in milliseconds export const ZOOM_SCREEN_DEFAULT_STAY_IN_DETAIL_TIME = 3; // in seconds export const ZOOM_SCREEN_DEFAULT_SEQ_DELAY_TIME = 2; // in seconds + +export const GAME_SCREEN_DEFAULT_RESULT_TIME = 4; // in seconds + +export const GAME_FIND_DEFAULT_NUMBER_OF_PINS = 1; + +export const GAME_DRAW_DEFAULT_COLOR = "#000000"; +export const GAME_DRAW_DEFAULT_THICKNESS = 5; +export const GAME_DRAW_DEFAULT_IS_ERASING = false; diff --git a/web/src/containers/expo-administration/expo-editor/screen-game-draw/Images.tsx b/web/src/containers/expo-administration/expo-editor/screen-game-draw/Images.tsx index b021ee3d..07f3c042 100644 --- a/web/src/containers/expo-administration/expo-editor/screen-game-draw/Images.tsx +++ b/web/src/containers/expo-administration/expo-editor/screen-game-draw/Images.tsx @@ -1,7 +1,9 @@ +import { useCallback } from "react"; import { useDispatch } from "react-redux"; import { useTranslation } from "react-i18next"; // Components +import Button from "react-md/lib/Buttons/Button"; import Checkbox from "react-md/lib/SelectionControls/Checkbox"; import ImageBox from "components/editors/ImageBox"; import HelpIcon from "components/help-icon"; @@ -13,6 +15,10 @@ import { AppDispatch } from "store/store"; // Actions and utils import { getFileById } from "actions/file-actions-typed"; import { updateScreenData } from "actions/expoActions"; +import { + GAME_DRAW_DEFAULT_COLOR, + GAME_DRAW_DEFAULT_THICKNESS, +} from "constants/screen"; // - - @@ -37,6 +43,15 @@ const Images = ({ activeScreen }: ImagesProps) => { dispatch(updateScreenData({ image2: img.id })); }; + const resetInitialDrawingSettings = useCallback(() => { + dispatch( + updateScreenData({ + initialColor: GAME_DRAW_DEFAULT_COLOR, + initialThickness: GAME_DRAW_DEFAULT_THICKNESS, + }) + ); + }, [dispatch]); + return (
@@ -80,12 +95,13 @@ const Images = ({ activeScreen }: ImagesProps) => { />
+
dispatch(updateScreenData({ showDrawing: value })) } @@ -95,6 +111,64 @@ const Images = ({ activeScreen }: ImagesProps) => { id="editor-game-draw-show-drawing" />
+ + {/* Initial settings (color and thickness) */} +
+
{t("initialDrawingSettingsTitle")}
+ +
+
+
{t("initialDrawingColorLabel")}
+ { + const newInitialColor = e.target.value; + dispatch(updateScreenData({ initialColor: newInitialColor })); + }} + /> +
+ ({activeScreen.initialColor ?? GAME_DRAW_DEFAULT_COLOR}) +
+
+ +
+
{t("initialDrawingThicknessLabel")}
+ { + const newInitialThickness = parseInt(e.target.value); + dispatch( + updateScreenData({ + initialThickness: newInitialThickness, + }) + ); + }} + /> +
+ ({activeScreen.initialThickness ?? GAME_DRAW_DEFAULT_THICKNESS}) +
+
+ +
+
+
+
); diff --git a/web/src/containers/expo-administration/expo-editor/screen-game-find/Images.tsx b/web/src/containers/expo-administration/expo-editor/screen-game-find/Images.tsx index b6505c1d..ecf7b861 100644 --- a/web/src/containers/expo-administration/expo-editor/screen-game-find/Images.tsx +++ b/web/src/containers/expo-administration/expo-editor/screen-game-find/Images.tsx @@ -1,11 +1,16 @@ import { useDispatch } from "react-redux"; import { useTranslation } from "react-i18next"; +import { useNumberOfPinsListener } from "./useNumberOfPinsListener"; + // Components import Checkbox from "react-md/lib/SelectionControls/Checkbox"; import ImageBox from "components/editors/ImageBox"; import HelpIcon from "components/help-icon"; +import { NumberOfPinsField } from "./NumberOfPinsField"; +import { PinTextField } from "./PinTextField"; + // Models import { GameFindScreen, File as IndihuFile } from "models"; import { AppDispatch } from "store/store"; @@ -13,6 +18,7 @@ import { AppDispatch } from "store/store"; // Actions and utils import { getFileById } from "actions/file-actions-typed"; import { updateScreenData } from "actions/expoActions"; +import { GAME_FIND_DEFAULT_NUMBER_OF_PINS } from "constants/screen"; // - - @@ -26,6 +32,9 @@ const Images = ({ activeScreen }: ImagesProps) => { keyPrefix: "descFields.gameFindScreen", }); + const { numberOfPins = GAME_FIND_DEFAULT_NUMBER_OF_PINS, pinsTexts } = + activeScreen; + const image1 = dispatch(getFileById(activeScreen.image1)); const image2 = dispatch(getFileById(activeScreen.image2)); @@ -37,6 +46,9 @@ const Images = ({ activeScreen }: ImagesProps) => { dispatch(updateScreenData({ image2: img.id })); }; + // + useNumberOfPinsListener(activeScreen); + return (
@@ -80,12 +92,13 @@ const Images = ({ activeScreen }: ImagesProps) => { />
+
dispatch(updateScreenData({ showTip: value })) } @@ -95,6 +108,39 @@ const Images = ({ activeScreen }: ImagesProps) => { id="editor-game-find-show-tip" />
+ + {/* Pins */} +
+
+ +
+ +
+ {pinsTexts?.map((pinText: string, index: number) => { + if (index >= numberOfPins) { + return null; + } + + const onPinTextUpdate = (newPinText: string) => + dispatch( + updateScreenData({ + pinsTexts: pinsTexts.map((pinText, idx) => + index === idx ? newPinText : pinText + ), + }) + ); + + return ( + + ); + })} +
+
); diff --git a/web/src/containers/expo-administration/expo-editor/screen-game-find/NumberOfPinsField.tsx b/web/src/containers/expo-administration/expo-editor/screen-game-find/NumberOfPinsField.tsx new file mode 100644 index 00000000..69f9890c --- /dev/null +++ b/web/src/containers/expo-administration/expo-editor/screen-game-find/NumberOfPinsField.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { useDispatch } from "react-redux"; +import { useTranslation } from "react-i18next"; + +// Components +import TextField from "react-md/lib/TextFields"; +import HelpIcon from "components/help-icon"; + +// Models +import { AppDispatch } from "store/store"; + +// Actions and utils +import { updateScreenData } from "actions/expoActions"; + +// - - - - + +type NumberOfPinsFieldProps = { + numberOfPinsValue: number; +}; + +export const NumberOfPinsField = ({ + numberOfPinsValue, +}: NumberOfPinsFieldProps) => { + const { t } = useTranslation("expo-editor", { + keyPrefix: "descFields.gameFindScreen", + }); + + const dispatch = useDispatch(); + const [error, setError] = useState(null); + + return ( +
+
+
+ { + const numberValue = Number(newNumberOfPinsValue); + const ok = + !numberValue || + isNaN(numberValue) || + numberValue < 1 || + numberValue > 10; + setError(ok ? "Zadejte číslo v rozsahu 1 až 10." : null); + + if (!ok) { + dispatch( + updateScreenData({ + numberOfPins: numberValue, + }) + ); + } + }} + /> +
+ + {error && ( +
+ {error} +
+ )} +
+ + +
+ ); +}; diff --git a/web/src/containers/expo-administration/expo-editor/screen-game-find/PinTextField.tsx b/web/src/containers/expo-administration/expo-editor/screen-game-find/PinTextField.tsx new file mode 100644 index 00000000..b21795d2 --- /dev/null +++ b/web/src/containers/expo-administration/expo-editor/screen-game-find/PinTextField.tsx @@ -0,0 +1,26 @@ +import TextField from "react-md/lib/TextFields"; + +type PinTextFieldProps = { + pinTextValue: string; + index: number; + onPinTextUpdate: (newPinText: string) => void; +}; + +export const PinTextField = ({ + pinTextValue, + index, + onPinTextUpdate, +}: PinTextFieldProps) => { + return ( +
+ { + onPinTextUpdate(newPinTextValue); + }} + /> +
+ ); +}; diff --git a/web/src/containers/expo-administration/expo-editor/screen-game-find/useNumberOfPinsListener.ts b/web/src/containers/expo-administration/expo-editor/screen-game-find/useNumberOfPinsListener.ts new file mode 100644 index 00000000..35012f85 --- /dev/null +++ b/web/src/containers/expo-administration/expo-editor/screen-game-find/useNumberOfPinsListener.ts @@ -0,0 +1,67 @@ +import { useEffect } from "react"; +import { useDispatch } from "react-redux"; + +// Models +import { GameFindScreen } from "models"; +import { AppDispatch } from "store/store"; + +// Actions and utils +import { updateScreenData } from "actions/expoActions"; +import { GAME_FIND_DEFAULT_NUMBER_OF_PINS } from "constants/screen"; + +// - - - - + +export const extendPinsTextsArray = ( + numberOfPins: number, + pinTexts: string[] +): string[] => { + if (numberOfPins <= pinTexts.length) { + return pinTexts; + } + + const extendedArray = pinTexts.slice(); // Create a copy of the input array + + while (extendedArray.length < numberOfPins) { + extendedArray.push(""); + } + + return extendedArray; +}; + +// - - - - + +export const useNumberOfPinsListener = (activeScreen: GameFindScreen) => { + const dispatch = useDispatch(); + + const { numberOfPins = GAME_FIND_DEFAULT_NUMBER_OF_PINS, pinsTexts } = + activeScreen; + + useEffect(() => { + // Pins Texts initialization for this type of screen + if ( + numberOfPins === GAME_FIND_DEFAULT_NUMBER_OF_PINS && + pinsTexts === undefined + ) { + dispatch( + updateScreenData({ + pinsTexts: new Array(GAME_FIND_DEFAULT_NUMBER_OF_PINS).fill(""), + }) + ); + + return; + } + + if (pinsTexts === undefined) { + return; + } + + // Other use case.. not initialization but change of number in field + const extendedArr = extendPinsTextsArray(numberOfPins, pinsTexts); + dispatch( + updateScreenData({ + pinsTexts: extendedArr, + }) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [numberOfPins]); +}; diff --git a/web/src/containers/expo-administration/expo-editor/screen-game-move/Images.tsx b/web/src/containers/expo-administration/expo-editor/screen-game-move/Images.tsx index 61fa8bca..c8c67207 100644 --- a/web/src/containers/expo-administration/expo-editor/screen-game-move/Images.tsx +++ b/web/src/containers/expo-administration/expo-editor/screen-game-move/Images.tsx @@ -8,6 +8,8 @@ import TextField from "react-md/lib/TextFields"; import ImageBox from "components/editors/ImageBox"; import HelpIcon from "components/help-icon"; +import ObjectImagePreview from "./ObjectImagePreview"; + // Models import { GameMoveScreen, File as IndihuFile } from "models"; import { AppDispatch } from "store/store"; @@ -89,6 +91,7 @@ const Images = ({ activeScreen }: ImagesProps) => { /> +
{t("object")} image @@ -139,6 +142,7 @@ const Images = ({ activeScreen }: ImagesProps) => {
+ {object && ( { alt="" /> )} + + {image1 && object && ( + + )} ); diff --git a/web/src/containers/expo-administration/expo-editor/screen-game-move/ObjectImagePreview.tsx b/web/src/containers/expo-administration/expo-editor/screen-game-move/ObjectImagePreview.tsx new file mode 100644 index 00000000..47dce988 --- /dev/null +++ b/web/src/containers/expo-administration/expo-editor/screen-game-move/ObjectImagePreview.tsx @@ -0,0 +1,142 @@ +import { useDispatch } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { animated } from "react-spring"; + +import useResizeObserver from "hooks/use-resize-observer"; +import { useElementMove } from "hooks/spring-hooks/use-element-move"; +import { useElementResize } from "hooks/spring-hooks/use-element-resize"; + +// Models +import { AppDispatch } from "store/store"; +import { GameMoveScreen } from "models"; + +// Actions and utils +import { updateScreenData } from "actions/expoActions"; +import { calculateObjectFit } from "utils/object-fit"; + +// Assets +import expandImg from "../../../../assets/img/expand.png"; + +// - - - - + +type ObjectImagePreviewProps = { + activeScreen: GameMoveScreen; + image1Src: string; + objectImgSrc: string; +}; + +const ObjectImagePreview = ({ + activeScreen, + image1Src, + objectImgSrc, +}: ObjectImagePreviewProps) => { + const dispatch = useDispatch(); + const { t } = useTranslation("expo-editor", { + keyPrefix: "descFields.gameMoveScreen", + }); + + const image1OrigData = activeScreen.image1OrigData ?? { width: 0, height: 0 }; + const objectOrigData = activeScreen.objectOrigData ?? { width: 0, height: 0 }; + + const [containerRef, containerSize] = useResizeObserver(); + const [objectRef, objectSize] = useResizeObserver(); + + const { + width: containedImg1Width, + height: containedImg1Height, + left: fromLeft, + top: fromTop, + } = calculateObjectFit({ + type: "contain", + parent: containerSize, + child: image1OrigData, + }); + + const { moveSpring, bindMoveDrag } = useElementMove({ + containerSize: containerSize, + dragMovingObjectSize: objectSize, + initialPosition: activeScreen.objectPositionProps?.containerPosition, + additionalCallback: (left, top) => { + dispatch( + updateScreenData({ + objectPositionProps: { + containerPosition: { left: left, top: top }, + containedImgPosition: { + left: left - fromLeft, + top: top - fromTop, + }, + }, + }) + ); + }, + }); + + const { resizeSpring, bindResizeDrag } = useElementResize({ + containerSize: containerSize, + dragResizingImgOrigData: objectOrigData, + initialSize: activeScreen.objectSizeProps?.inContainerSize, + additionalCallback: (width, height) => { + dispatch( + updateScreenData({ + objectSizeProps: { + inContainerSize: { width: width, height: height }, + inContainedImgFractionSize: { + width: width / containedImg1Width, + height: height / containedImg1Height, + }, + }, + }) + ); + }, + }); + + return ( +
+
+ {t("screenPreviewText")} +
+ +
+ first img + + + object drag content + + expand image icon + +
+
+ ); +}; + +export default ObjectImagePreview; diff --git a/web/src/containers/expo-administration/expo-editor/screen-game-options/answers/AnswerItem.tsx b/web/src/containers/expo-administration/expo-editor/screen-game-options/answers/AnswerItem.tsx index d179a6a4..83e44e62 100644 --- a/web/src/containers/expo-administration/expo-editor/screen-game-options/answers/AnswerItem.tsx +++ b/web/src/containers/expo-administration/expo-editor/screen-game-options/answers/AnswerItem.tsx @@ -260,7 +260,7 @@ const AnswerItem = ({ (); + const { section, screen } = useSectionScreenParams(); const { @@ -126,7 +126,11 @@ export const NewViewScreen = ({ musicRef.loop = true; musicRef.volume = expoVolumes.musicVolume.actualVolume / 100; if (shouldIncrement) { - musicRef.play(); + musicRef.play().catch((error) => { + if (error instanceof Error && error.name === "NotAllowedError") { + dispatch(setViewProgress({ shouldIncrement: false })); + } + }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [musicSrc, musicRef]); @@ -158,7 +162,11 @@ export const NewViewScreen = ({ if (shouldIncrement) { audioRef.volume = expoVolumes.speechVolume.actualVolume / 100; - audioRef.play().catch((_error) => noop); + audioRef.play().catch((error) => { + if (error instanceof Error && error.name === "NotAllowedError") { + dispatch(setViewProgress({ shouldIncrement: false })); + } + }); } return () => { @@ -184,7 +192,11 @@ export const NewViewScreen = ({ useEffect(() => { if (musicRef) { if (shouldIncrement && !isMusicDisabled) { - musicRef.play(); + musicRef.play().catch((error) => { + if (error instanceof Error && error.name === "NotAllowedError") { + dispatch(setViewProgress({ shouldIncrement: false })); + } + }); } if (!shouldIncrement && !isMusicDisabled) { musicRef.pause(); @@ -193,7 +205,11 @@ export const NewViewScreen = ({ if (audioRef) { if (shouldIncrement) { - audioRef.play().catch((_error) => noop); + audioRef.play().catch((error) => { + if (error instanceof Error && error.name === "NotAllowedError") { + dispatch(setViewProgress({ shouldIncrement: false })); + } + }); } else { audioRef.pause(); } diff --git a/web/src/containers/expositions/ExpoCard.tsx b/web/src/containers/expositions/ExpoCard.tsx index a4bbe01a..76c26f0f 100644 --- a/web/src/containers/expositions/ExpoCard.tsx +++ b/web/src/containers/expositions/ExpoCard.tsx @@ -148,7 +148,7 @@ const ExpoCard = ({ > +
{/* Done button */} -
+
diff --git a/web/src/containers/views/games/GameInfoPanel.tsx b/web/src/containers/views/games/GameInfoPanel.tsx index bd5d26f3..7dbe9eb4 100644 --- a/web/src/containers/views/games/GameInfoPanel.tsx +++ b/web/src/containers/views/games/GameInfoPanel.tsx @@ -1,4 +1,3 @@ -import { useTranslation } from "react-i18next"; import { useExpoDesignData } from "hooks/view-hooks/expo-design-data-hook"; import { Icon } from "components/icon/icon"; import { RefCallback } from "context/tutorial-provider/use-tutorial"; @@ -8,40 +7,35 @@ import cx from "classnames"; type GameInfoPanelProps = { gameScreen: { title?: string; task?: string }; isGameFinished?: boolean; - text?: string; // if not present, screen title with custom game task wont be visible bindTutorial: { ref: RefCallback }; + solutionText: string; }; export const GameInfoPanel = ({ gameScreen, isGameFinished, - text, bindTutorial, + solutionText, }: GameInfoPanelProps) => { - const { t } = useTranslation("view-screen"); const { bgFgTheming } = useExpoDesignData(); const { title, task } = gameScreen; return (
- {text && ( -
-
- - - {isGameFinished ? t("game.solution") : title} - -
- {task && ( - {task} - )} +
+
+ + + {isGameFinished ? solutionText : title} +
- )} + {task && {task}} +
); }; diff --git a/web/src/containers/views/games/game-draw/configureContext.ts b/web/src/containers/views/games/game-draw/configureContext.ts index fd82efe2..0a77d2a9 100644 --- a/web/src/containers/views/games/game-draw/configureContext.ts +++ b/web/src/containers/views/games/game-draw/configureContext.ts @@ -1,8 +1,8 @@ import { - DEFAULT_COLOR, - DEFAULT_THICKNESS, - DEFAULT_IS_ERASING, -} from "./game-draw"; + GAME_DRAW_DEFAULT_COLOR, + GAME_DRAW_DEFAULT_THICKNESS, + GAME_DRAW_DEFAULT_IS_ERASING, +} from "constants/screen"; export const configureContext = ( ctx: CanvasRenderingContext2D | null, @@ -10,13 +10,15 @@ export const configureContext = ( thickness?: number, isErasing?: boolean ) => { - if (ctx === null) return; + if (ctx === null) { + return; + } - const erasing = isErasing ?? DEFAULT_IS_ERASING; + const erasing = isErasing ?? GAME_DRAW_DEFAULT_IS_ERASING; - ctx.fillStyle = color ?? DEFAULT_COLOR; - ctx.strokeStyle = color ?? DEFAULT_COLOR; - ctx.lineWidth = thickness ?? DEFAULT_THICKNESS; + ctx.fillStyle = color ?? GAME_DRAW_DEFAULT_COLOR; + ctx.strokeStyle = color ?? GAME_DRAW_DEFAULT_COLOR; + ctx.lineWidth = thickness ?? GAME_DRAW_DEFAULT_THICKNESS; ctx.globalCompositeOperation = erasing ? "destination-out" : "source-over"; ctx.lineCap = "round"; return ctx; diff --git a/web/src/containers/views/games/game-draw/game-draw.tsx b/web/src/containers/views/games/game-draw/game-draw.tsx index 339ac87f..a9fcd414 100644 --- a/web/src/containers/views/games/game-draw/game-draw.tsx +++ b/web/src/containers/views/games/game-draw/game-draw.tsx @@ -1,39 +1,47 @@ -import { useCallback, useState, MouseEvent, useRef, useEffect } from "react"; -import { animated, useTransition } from "react-spring"; import ReactDOM from "react-dom"; -import cx from "classnames"; +import { useCallback, useState, useRef } from "react"; +import { animated, useTransition } from "react-spring"; import { useTranslation } from "react-i18next"; -import { createSelector } from "reselect"; import { useSelector } from "react-redux"; -import { Icon } from "components/icon/icon"; +import { createSelector } from "reselect"; -import { Position, ScreenProps } from "models"; -import { AppState } from "store/store"; -import { GameDrawScreen } from "models"; +// Custom hooks +import { useTutorial } from "context/tutorial-provider/use-tutorial"; +import { useGameAutoNavigationOnResultTimeElapsed } from "../useGameAutoNavigationOnResultTimeElapsed"; +import { useBoolean } from "hooks/boolean-hook"; +import { useGameDraw } from "./useGameDraw"; -import classes from "./game-draw.module.scss"; +// Components import { GameInfoPanel } from "../GameInfoPanel"; -import { Button } from "components/button/button"; -import { useBoolean } from "hooks/boolean-hook"; -import { Popper } from "components/popper/popper"; import { GameActionsPanel } from "../GameActionsPanel"; -import { BasicTooltip } from "components/tooltip/tooltip"; -import { useTutorial } from "context/tutorial-provider/use-tutorial"; -import { configureContext } from "./configureContext"; -// - - +import { Popper } from "components/popper/popper"; +import { Button } from "components/button/button"; +import { Icon } from "components/icon/icon"; + +// Models +import { GameDrawScreen, ScreenProps } from "models"; +import { AppState } from "store/store"; -export const DEFAULT_COLOR = "#000000"; -export const DEFAULT_THICKNESS = 5; -export const DEFAULT_IS_ERASING = false; +// Utils +import cx from "classnames"; +import classes from "./game-draw.module.scss"; +import { GAME_SCREEN_DEFAULT_RESULT_TIME } from "constants/screen"; +import { + GAME_DRAW_DEFAULT_COLOR, + GAME_DRAW_DEFAULT_THICKNESS, + GAME_DRAW_DEFAULT_IS_ERASING, +} from "constants/screen"; -// - - +// - - - - const stateSelector = createSelector( ({ expo }: AppState) => expo.viewScreen as GameDrawScreen, (viewScreen) => ({ viewScreen }) ); +// - - - - + export const GameDraw = ({ screenPreloadedFiles, infoPanelRef, @@ -43,112 +51,56 @@ export const GameDraw = ({ const { t } = useTranslation("view-screen"); const { viewScreen } = useSelector(stateSelector); - const canvasRef = useRef(null); - const [ctx, setCtx] = useState(null); + const { + resultTime = GAME_SCREEN_DEFAULT_RESULT_TIME, + showDrawing = false, + initialColor = GAME_DRAW_DEFAULT_COLOR, + initialThickness = GAME_DRAW_DEFAULT_THICKNESS, + } = viewScreen; - const [isDrawing, setIsDrawing] = useState(false); - const [mousePosition, setMousePosition] = useState(null); // setting always, even when the pen or erase is not down + const { image1: assignmentImgSrc, image2: resultingImgSrc } = + screenPreloadedFiles; - const [color, setColor] = useState(DEFAULT_COLOR); + // - - States - - + + const [color, setColor] = useState(initialColor); + + const [thickness, setThickness] = useState(initialThickness); + + const [isErasing, { toggle: toggleTool }] = useBoolean( + GAME_DRAW_DEFAULT_IS_ERASING + ); - const [thickness, setThickness] = useState(DEFAULT_THICKNESS); const [thicknessAnchor, setThicknessAnchor] = useState(null); + const [ isThicknessPopoverOpen, { toggle: toggleThicknessPopover, setFalse: closeThicknessPopover }, ] = useBoolean(false); - const [isErasing, { toggle: toggleTool }] = useBoolean(DEFAULT_IS_ERASING); - - const [isGameFinished, setIsGameFinished] = useState(false); - - // - - - - useEffect(() => { - if (!canvasRef.current) return; // should not happen - - canvasRef.current.width = window.innerWidth; - canvasRef.current.height = window.innerHeight; - - const context = canvasRef.current.getContext("2d"); - setCtx(context); - }, []); - - const resizeCanvas = useCallback(() => { - if (!canvasRef.current) return; - - canvasRef.current.width = window.innerWidth; - canvasRef.current.height = window.innerHeight; - }, []); + const canvasRef = useRef(null); - useEffect(() => { - window.addEventListener("resize", resizeCanvas); - return () => window.removeEventListener("resize", resizeCanvas); - }, [resizeCanvas]); + const [isGameFinished, setIsGameFinished] = useState(false); - // - - + // - - Draw functionality - - - useEffect(() => { - if (!ctx) return; - - configureContext(ctx, color, thickness, isErasing); - }, [ + const { startDrawing, stopDrawing, draw, clearCanvas } = useGameDraw({ + canvasRef, + isGameFinished, color, thickness, isErasing, - ctx, - canvasRef.current?.width, - canvasRef.current?.height, - ]); - - // - - - - const startDrawing = useCallback((e: MouseEvent) => { - setMousePosition({ left: e.clientX, top: e.clientY }); - setIsDrawing(true); - }, []); - - const stopDrawing = useCallback(() => { - setIsDrawing(false); - }, []); - - const draw = useCallback( - (e: MouseEvent) => { - if (!isDrawing || isGameFinished || !ctx || !mousePosition) { - return; - } - - ctx.beginPath(); - ctx.moveTo(mousePosition.left, mousePosition.top); - ctx.lineTo(e.clientX, e.clientY); - ctx.stroke(); - - setMousePosition({ left: e.clientX, top: e.clientY }); - }, - [isDrawing, isGameFinished, mousePosition, ctx] - ); - - // - - - - const clearCanvas = useCallback(() => { - if (!canvasRef.current) return; - if (!ctx) return; - - ctx.clearRect( - 0, - 0, - canvasRef.current.width ?? 0, - canvasRef.current.height ?? 0 - ); - }, [ctx]); + }); - // - - + // - - - - const onGameFinish = useCallback(() => { setIsGameFinished(true); - clearCanvas(); - }, [clearCanvas]); + if (!showDrawing) { + clearCanvas(); + } + }, [clearCanvas, showDrawing]); const onGameReset = useCallback(() => { setIsGameFinished(false); @@ -166,41 +118,34 @@ export const GameDraw = ({ // - - Tutorial stuff - - - const { bind, TutorialTooltip, escapeTutorial } = useTutorial( - "gameDraw", - !isMobileOverlay - ); + const { bind, TutorialTooltip } = useTutorial("gameDraw", { + shouldOpen: !isMobileOverlay, + closeOnEsc: true, + }); - const onKeydownAction = useCallback( - (event) => { - if (event.key === "Escape") { - escapeTutorial(); - } - }, - [escapeTutorial] - ); + // - - - useEffect(() => { - document.addEventListener("keydown", onKeydownAction); - return () => document.removeEventListener("keydown", onKeydownAction); + useGameAutoNavigationOnResultTimeElapsed({ + gameResultTime: resultTime * 1000, + isGameFinished: isGameFinished, }); return ( -
- {transition(({ opacity }, finished) => - !finished ? ( +
+ {transition(({ opacity }, isGameFinished) => + !isGameFinished ? ( ) : ( ) )} @@ -239,8 +184,8 @@ export const GameDraw = ({ , infoPanelRef.current )} @@ -253,48 +198,41 @@ export const GameDraw = ({ onGameFinish={onGameFinish} onGameReset={onGameReset} gameActions={[ -
+
, -
+
, -
- -
, ]} />, diff --git a/web/src/containers/views/games/game-draw/useGameDraw.ts b/web/src/containers/views/games/game-draw/useGameDraw.ts new file mode 100644 index 00000000..14327029 --- /dev/null +++ b/web/src/containers/views/games/game-draw/useGameDraw.ts @@ -0,0 +1,135 @@ +import { + useState, + useEffect, + useCallback, + MutableRefObject, + MouseEvent, +} from "react"; +import { Position } from "models"; +import { configureContext } from "./configureContext"; + +type UseGameDrawProps = { + canvasRef: MutableRefObject; + isGameFinished: boolean; + color: string; + thickness: number; + isErasing: boolean; +}; + +// NOTE: canvasRef should never be null, because useEffect runs after all components are painted to DOM +export const useGameDraw = ({ + canvasRef, + isGameFinished, + color, + thickness, + isErasing, +}: UseGameDrawProps) => { + const [isDrawing, setIsDrawing] = useState(false); + const [mousePosition, setMousePosition] = useState(null); // setting always, even when the pen or erase is not down + + const [ctx, setCtx] = useState(null); + + // - - - - + + useEffect(() => { + if (!canvasRef.current) { + return; // should not happen + } + + canvasRef.current.width = window.innerWidth; + canvasRef.current.height = window.innerHeight; + + const context = canvasRef.current.getContext("2d"); + setCtx(context); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // - - - - + + const resizeCanvas = useCallback(() => { + if (!canvasRef.current) { + return; // should not happen + } + + canvasRef.current.width = window.innerWidth; + canvasRef.current.height = window.innerHeight; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + window.addEventListener("resize", resizeCanvas); + return () => window.removeEventListener("resize", resizeCanvas); + }, [resizeCanvas]); + + // - - - - + + useEffect(() => { + if (!ctx) { + return; + } + + configureContext(ctx, color, thickness, isErasing); + }, [ + ctx, + color, + thickness, + isErasing, + canvasRef.current?.width, + canvasRef.current?.height, + ]); + + // - - - - + + const startDrawing = useCallback((e: MouseEvent) => { + setMousePosition({ left: e.clientX, top: e.clientY }); + setIsDrawing(true); + }, []); + + const stopDrawing = useCallback(() => { + setIsDrawing(false); + }, []); + + const draw = useCallback( + (e: MouseEvent) => { + if (!ctx) { + return; + } + + if (!isDrawing || isGameFinished) { + return; + } + + if (!mousePosition) { + return; + } + + ctx.beginPath(); + ctx.moveTo(mousePosition.left, mousePosition.top); + ctx.lineTo(e.clientX, e.clientY); + ctx.stroke(); + + setMousePosition({ left: e.clientX, top: e.clientY }); + }, + [isDrawing, isGameFinished, mousePosition, ctx] + ); + + const clearCanvas = useCallback(() => { + if (!canvasRef.current) { + return; // should not happen + } + + if (!ctx) { + return; + } + + ctx.clearRect( + 0, + 0, + canvasRef.current.width ?? 0, + canvasRef.current.height ?? 0 + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ctx]); + + return { startDrawing, stopDrawing, draw, clearCanvas }; +}; diff --git a/web/src/containers/views/games/game-erase/game-erase.module.scss b/web/src/containers/views/games/game-erase/game-erase.module.scss index 78b950ad..f4dcfa0e 100644 --- a/web/src/containers/views/games/game-erase/game-erase.module.scss +++ b/web/src/containers/views/games/game-erase/game-erase.module.scss @@ -29,3 +29,12 @@ .eraserWipetowel { cursor: url("../../../../assets/img/erasers/wipe_towel.png") 10 20, auto; } + +// - - - - + +.erase-container { + user-select: none; /* Disables text selection */ + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} diff --git a/web/src/containers/views/games/game-erase/game-erase.tsx b/web/src/containers/views/games/game-erase/game-erase.tsx index 5588e525..76e01ffb 100644 --- a/web/src/containers/views/games/game-erase/game-erase.tsx +++ b/web/src/containers/views/games/game-erase/game-erase.tsx @@ -25,6 +25,8 @@ import classes from "./game-erase.module.scss"; import { calculateObjectFit } from "utils/object-fit"; import { useExpoDesignData } from "hooks/view-hooks/expo-design-data-hook"; import { useTutorial } from "context/tutorial-provider/use-tutorial"; +import { useGameAutoNavigationOnResultTimeElapsed } from "../useGameAutoNavigationOnResultTimeElapsed"; +import { GAME_SCREEN_DEFAULT_RESULT_TIME } from "constants/screen"; const stateSelector = createSelector( ({ expo }: AppState) => expo.viewScreen as GameWipeScreen, @@ -41,9 +43,11 @@ export const GameErase = ({ actionsPanelRef, isMobileOverlay, }: ScreenProps) => { - const { t } = useTranslation("view-screen"); const { viewScreen } = useSelector(stateSelector); const { expoDesignData, palette } = useExpoDesignData(); + const { t } = useTranslation("view-screen"); + + const { resultTime = GAME_SCREEN_DEFAULT_RESULT_TIME } = viewScreen; const canvasRef = useRef(null); const [setContainerRef, containerSize] = useResizeObserver(); @@ -198,27 +202,26 @@ export const GameErase = ({ // - - - const { bind, TutorialTooltip, escapeTutorial } = useTutorial( - "gameWipe", - !isMobileOverlay - ); + const { bind, TutorialTooltip } = useTutorial("gameWipe", { + shouldOpen: !isMobileOverlay, + closeOnEsc: true, + }); - const onKeydownAction = useCallback( - (event) => { - if (event.key === "Escape") { - escapeTutorial(); - } - }, - [escapeTutorial] - ); + // - - - useEffect(() => { - document.addEventListener("keydown", onKeydownAction); - return () => document.removeEventListener("keydown", onKeydownAction); + useGameAutoNavigationOnResultTimeElapsed({ + gameResultTime: resultTime * 1000, + isGameFinished: isGameFinished, }); return ( -
+
, infoPanelRef.current )} diff --git a/web/src/containers/views/games/game-find/game-find.tsx b/web/src/containers/views/games/game-find/game-find.tsx index 300f5f4a..7ca66dd8 100644 --- a/web/src/containers/views/games/game-find/game-find.tsx +++ b/web/src/containers/views/games/game-find/game-find.tsx @@ -1,140 +1,220 @@ import ReactDOM from "react-dom"; -import { useTranslation } from "react-i18next"; -import { MouseEvent, useCallback, useEffect, useState } from "react"; -import { animated, useTransition } from "react-spring"; -import { ScreenProps } from "models"; -import cx from "classnames"; +import { useState, useMemo, useCallback, MouseEvent } from "react"; +import { useTransition, animated } from "react-spring"; import { useSelector } from "react-redux"; import { createSelector } from "reselect"; +import { useTranslation } from "react-i18next"; -import { AppState } from "store/store"; -import { GameFindScreen } from "models"; +import { useTutorial } from "context/tutorial-provider/use-tutorial"; +import { useGameAutoNavigationOnResultTimeElapsed } from "../useGameAutoNavigationOnResultTimeElapsed"; +import { useCornerInfoBox } from "hooks/spring-hooks/use-corner-info-box"; -import pinIcon from "assets/img/pin.png"; -import classes from "./game-find.module.scss"; +// Components import { GameInfoPanel } from "../GameInfoPanel"; import { GameActionsPanel } from "../GameActionsPanel"; -import { useTutorial } from "context/tutorial-provider/use-tutorial"; +import { BasicTooltip } from "components/tooltip/BasicTooltip"; + +// Models +import { ScreenProps, Position } from "models"; +import { GameFindScreen } from "models"; +import { AppState } from "store/store"; + +// Utils +import cx from "classnames"; +import classes from "./game-find.module.scss"; +import { + GAME_FIND_DEFAULT_NUMBER_OF_PINS, + GAME_SCREEN_DEFAULT_RESULT_TIME, +} from "constants/screen"; + +// Assets +import pinIcon from "assets/img/pin.png"; + +// - - - - const stateSelector = createSelector( ({ expo }: AppState) => expo.viewScreen as GameFindScreen, (viewScreen) => ({ viewScreen }) ); +// - - - - + export const GameFind = ({ screenPreloadedFiles, infoPanelRef, actionsPanelRef, isMobileOverlay, }: ScreenProps) => { - const { viewScreen } = useSelector(stateSelector); - const [finished, setFinished] = useState(false); - const [pin, setPin] = useState<{ x: number; y: number }>(); const { t } = useTranslation("view-screen"); + const { viewScreen } = useSelector(stateSelector); + + const { + resultTime = GAME_SCREEN_DEFAULT_RESULT_TIME, + showTip = false, + numberOfPins = GAME_FIND_DEFAULT_NUMBER_OF_PINS, + pinsTexts, + } = viewScreen; + + // NOTE: pinsTexts - can store more than numberOfPins texts + const slicedPinsTexts = useMemo( + () => pinsTexts?.slice(0, numberOfPins), + [numberOfPins, pinsTexts] + ); + + const { image1: assignmentImgSrc, image2: resultingImgSrc } = + screenPreloadedFiles; + + // - - - - - const onFinish = useCallback(() => { - setFinished(true); + const [isGameFinished, setIsGameFinished] = useState(false); + + const [currentPinIndex, setCurrentPinIndex] = useState(0); + + const [pinPositions, setPinPositions] = useState<(Position | undefined)[]>( + new Array(numberOfPins).fill(undefined) + ); + + const areAllPinPositionsFilled = useMemo( + () => pinPositions.every((pos) => pos !== undefined), + [pinPositions] + ); + + const onGameFinish = useCallback(() => { + setIsGameFinished(true); }, []); - const onReset = useCallback(() => { - setFinished(false); - setPin(undefined); + const onGameReset = useCallback(() => { + setPinPositions((prev) => prev.map((_position) => undefined)); + setCurrentPinIndex(0); + setIsGameFinished(false); }, []); const pinImage = useCallback( (e: MouseEvent) => { - if (pin) { + if (areAllPinPositionsFilled || currentPinIndex === pinPositions.length) { return; } - setPin({ x: e.clientX, y: e.clientY }); + setPinPositions((prevPositions) => + prevPositions.map((pos, posIdx) => + posIdx === currentPinIndex ? { left: e.clientX, top: e.clientY } : pos + ) + ); + + setCurrentPinIndex((prev) => prev + 1); }, - [pin] + [areAllPinPositionsFilled, currentPinIndex, pinPositions.length] + ); + + // When game is not finished, display always and when game is finished, it depends on showTip prop + const shouldDisplayPin = useMemo( + () => !isGameFinished || (isGameFinished && showTip), + [isGameFinished, showTip] ); - const imageTransition = useTransition(finished, { + // - - Tutorial - - + + const { bind, TutorialTooltip } = useTutorial("gameFind", { + shouldOpen: !isMobileOverlay, + closeOnEsc: true, + }); + + // - - - - + + useGameAutoNavigationOnResultTimeElapsed({ + gameResultTime: resultTime * 1000, + isGameFinished: isGameFinished, + }); + + // - - Transitions - - + + const imageTransition = useTransition(isGameFinished, { initial: { opacity: 1 }, from: { opacity: 0 }, enter: { opacity: 1 }, leave: { opacity: 0 }, }); - const pinTransition = useTransition(pin, { + const pinPositionsTransition = useTransition(pinPositions, { from: { x: 0 }, enter: { x: 1 }, leave: { x: 1 }, }); - // - - const { bind, TutorialTooltip, escapeTutorial } = useTutorial( - "gameFind", - !isMobileOverlay - ); - - const onKeydownAction = useCallback( - (event) => { - if (event.key === "Escape") { - escapeTutorial(); - } - }, - [escapeTutorial] - ); - - useEffect(() => { - document.addEventListener("keydown", onKeydownAction); - return () => document.removeEventListener("keydown", onKeydownAction); + // NOTE: return value of this hook is transition as well + const CornerPinInfoBox = useCornerInfoBox({ + items: slicedPinsTexts, + currIndex: currentPinIndex, + textExtractor: (pinText) => pinText, + position: "left", }); return (
- {imageTransition(({ opacity }, finished) => - !finished ? ( + {imageTransition(({ opacity }, isGameFinished) => + !isGameFinished ? ( ) : ( ) )} - {pinTransition( - ({ x }, pin) => - pin && ( - + {pinPositionsTransition( + ({ x }, pinPos, _trState, trIndex) => + pinPos && + shouldDisplayPin && ( + <> + + + + ) )} + {CornerPinInfoBox} + {infoPanelRef.current && ReactDOM.createPortal( , infoPanelRef.current )} @@ -143,9 +223,9 @@ export const GameFind = ({ ReactDOM.createPortal( , actionsPanelRef.current )} diff --git a/web/src/containers/views/games/game-move/game-move.tsx b/web/src/containers/views/games/game-move/game-move.tsx index a597094d..0dd76f83 100644 --- a/web/src/containers/views/games/game-move/game-move.tsx +++ b/web/src/containers/views/games/game-move/game-move.tsx @@ -1,13 +1,14 @@ import ReactDOM from "react-dom"; -import { useCallback, useEffect, useState } from "react"; - +import { useState, useMemo, useCallback } from "react"; +import { animated, useTransition } from "react-spring"; import { useSelector } from "react-redux"; import { createSelector } from "reselect"; + import { useTranslation } from "react-i18next"; import { useTutorial } from "context/tutorial-provider/use-tutorial"; -import { animated, useSpring, useTransition } from "react-spring"; -import { useDrag } from "@use-gesture/react"; +import { useGameAutoNavigationOnResultTimeElapsed } from "../useGameAutoNavigationOnResultTimeElapsed"; import useResizeObserver from "hooks/use-resize-observer"; +import { useElementMove } from "../../../../hooks/spring-hooks/use-element-move"; // Components import { GameInfoPanel } from "../GameInfoPanel"; @@ -17,14 +18,18 @@ import { GameActionsPanel } from "../GameActionsPanel"; import { AppState } from "store/store"; import { ScreenProps, GameMoveScreen } from "models"; -// - - - +// Utils +import { calculateObjectInitialPosition, calculateObjectSize } from "./utils"; +import { GAME_SCREEN_DEFAULT_RESULT_TIME } from "constants/screen"; + +// - - - - - - const stateSelector = createSelector( ({ expo }: AppState) => expo.viewScreen as GameMoveScreen, (viewScreen) => ({ viewScreen }) ); -// - - - +// - - - - - - export const GameMove = ({ screenPreloadedFiles, @@ -35,53 +40,58 @@ export const GameMove = ({ const { t } = useTranslation("view-screen"); const { viewScreen } = useSelector(stateSelector); - const [containerRef, { width: containerWidth, height: containerHeight }] = - useResizeObserver(); - const [dragRef, { width: dragWidth, height: dragHeight }] = - useResizeObserver(); // dragTarget as a container with the object img + const { resultTime = GAME_SCREEN_DEFAULT_RESULT_TIME } = viewScreen; - // - - + const { + image1: assignmentImgSrc, + image2: resultingImgSrc, + object: objectImgSrc, + } = screenPreloadedFiles; - const [isGameFinished, setIsGameFinished] = useState(false); + // - - Move functionality - - + + const [containerRef, containerSize] = useResizeObserver(); + + const [objectDragRef, objectDragSize] = useResizeObserver(); + + const { objInitialLeft, objInitialTop } = useMemo( + () => calculateObjectInitialPosition(viewScreen, containerSize), + [containerSize, viewScreen] + ); + + const { moveSpring, moveSpringApi, bindMoveDrag } = useElementMove({ + containerSize: containerSize, + dragMovingObjectSize: objectDragSize, + initialPosition: { left: objInitialLeft, top: objInitialTop }, + }); - // Initialize the position for drag object image, it will be reset back to [0, 0] whenever the width or height of the container changes - const [{ dragLeft, dragTop }, dragApi] = useSpring( - () => ({ - dragLeft: 0, - dragTop: 0, - }), - [containerWidth, containerHeight] + // - - Size calculation of object, based on administration settings - - + // once at mount assigned through CSS and then used by `objectDragSize` + + const { objectWidth, objectHeight } = useMemo( + () => calculateObjectSize(viewScreen, containerSize), + [containerSize, viewScreen] ); + // - - Tutorial - - + + const { bind: bindTutorial, TutorialTooltip } = useTutorial("gameMove", { + shouldOpen: !isMobileOverlay, + closeOnEsc: true, + }); + + // - - - - + + const [isGameFinished, setIsGameFinished] = useState(false); + const onGameFinish = useCallback(() => { setIsGameFinished(true); }, []); const onGameReset = useCallback(() => { setIsGameFinished(false); - dragApi.start({ dragLeft: 0, dragTop: 0 }); - }, [dragApi]); - - // - - - - const bind = useDrag( - ({ down, offset: [x, y] }) => { - if (!down) { - return; - } - - dragApi.start({ dragLeft: x, dragTop: y, immediate: true }); - }, - { - from: () => [dragLeft.get(), dragTop.get()], - bounds: { - left: 0, - top: 0, - right: containerWidth - dragWidth, - bottom: containerHeight - dragHeight, - }, - } - ); + moveSpringApi.start({ left: objInitialLeft, top: objInitialTop }); + }, [moveSpringApi, objInitialLeft, objInitialTop]); const transition = useTransition(isGameFinished, { initial: { opacity: 1 }, @@ -90,64 +100,49 @@ export const GameMove = ({ leave: { opacity: 0 }, }); - // - - - - const { - bind: bindTutorial, - TutorialTooltip, - escapeTutorial, - } = useTutorial("gameMove", !isMobileOverlay); - - const onKeydownAction = useCallback( - (event) => { - if (event.key === "Escape") { - escapeTutorial(); - } - }, - [escapeTutorial] - ); + // - - - - - useEffect(() => { - document.addEventListener("keydown", onKeydownAction); - return () => document.removeEventListener("keydown", onKeydownAction); + useGameAutoNavigationOnResultTimeElapsed({ + gameResultTime: resultTime * 1000, + isGameFinished: isGameFinished, }); return (
{transition(({ opacity }, isGameFinished) => isGameFinished ? ( - // Image2 is result image ) : ( <> - {/* Image1 is background image (zadanie) */} - {/* Object */} drag content @@ -161,8 +156,8 @@ export const GameMove = ({ , infoPanelRef.current )} diff --git a/web/src/containers/views/games/game-move/utils.ts b/web/src/containers/views/games/game-move/utils.ts new file mode 100644 index 00000000..52cca2ae --- /dev/null +++ b/web/src/containers/views/games/game-move/utils.ts @@ -0,0 +1,76 @@ +import { GameMoveScreen, Size } from "models"; +import { calculateObjectFit } from "utils/object-fit"; + +export const calculateObjectInitialPosition = ( + viewScreen: GameMoveScreen, + containerSize: Size +) => { + const assignmentImgOrigData = viewScreen.image1OrigData ?? { + width: 0, + height: 0, + }; + + // Object position from administration against the contained image there + const objectPosition = viewScreen.objectPositionProps + ?.containedImgPosition ?? { + left: 0, + top: 0, + }; + + const { + width: assignmentImgWidth, + height: assignmentImgHeight, + left: assignmentImgLeftEdge, + top: assignmentImgTopEdge, + } = calculateObjectFit({ + type: "contain", + parent: containerSize, + child: assignmentImgOrigData, + }); + + // E.g. wFraction = 0.25 means that the object's left-top corner is located 25% left against contained img there + const wFraction = objectPosition.left / assignmentImgOrigData.width; + const hFraction = objectPosition.top / assignmentImgOrigData.height; + + const objInitialLeft = assignmentImgLeftEdge + wFraction * assignmentImgWidth; + const objInitialTop = assignmentImgTopEdge + hFraction * assignmentImgHeight; + + return { objInitialLeft, objInitialTop }; +}; + +export const calculateObjectSize = ( + viewScreen: GameMoveScreen, + containerSize: Size +) => { + const assignmentImgOrigData = viewScreen.image1OrigData ?? { + width: 0, + height: 0, + }; + + const { width: assignmentImgWidth, height: assignmentImgHeight } = + calculateObjectFit({ + type: "contain", + parent: containerSize, + child: assignmentImgOrigData, + }); + + const objectImgOrigData = viewScreen.objectOrigData ?? { + width: 0, + height: 0, + }; + + const inContainedImgFractionSize = + viewScreen.objectSizeProps?.inContainedImgFractionSize; + + if (inContainedImgFractionSize === undefined) { + return { + objectWidth: objectImgOrigData.width, + objectHeight: objectImgOrigData.height, + }; + } + + const objectWidth = inContainedImgFractionSize.width * assignmentImgWidth; + const objectHeight = inContainedImgFractionSize.height * assignmentImgHeight; + + return { objectWidth, objectHeight }; +}; diff --git a/web/src/containers/views/games/game-quiz/ImageTextAnswer.tsx b/web/src/containers/views/games/game-quiz/ImageTextAnswer.tsx index 08a22487..5ba2bc9c 100644 --- a/web/src/containers/views/games/game-quiz/ImageTextAnswer.tsx +++ b/web/src/containers/views/games/game-quiz/ImageTextAnswer.tsx @@ -1,9 +1,9 @@ import { Dispatch, SetStateAction, useMemo, Fragment } from "react"; -import useElementSize from "hooks/element-size-hook"; +import useResizeObserver from "hooks/use-resize-observer"; import { Checkbox, Radio } from "@mui/material"; -import ScreenAnchorInfopoint from "components/infopoint/ScreenAnchorInfopoint"; -import TooltipInfoPoint from "components/infopoint/TooltipInfopoint"; +import AnchorInfopoint from "components/infopoint/components/anchor-infopoint"; +import TooltipInfoPoint from "components/infopoint/components/tooltip-infopoint/TooltipInfopoint"; import { GameQuizAnswer, @@ -11,7 +11,7 @@ import { GameQuizType, Size, } from "models"; -import { InfopointStatusObject } from "components/infopoint/parseScreenMaps"; +import { InfopointStatusObject } from "components/infopoint/useTooltipInfopoint"; import cx from "classnames"; import { calculateObjectFit } from "utils/object-fit"; @@ -32,8 +32,8 @@ type ImageTextAnswerProps = { answersTextDisplayType: GameQuizAnswerDisplayType; // Infopoint stuff - infopointOpenStatusMap: Record; - setInfopointOpenStatusMap: Dispatch< + infopointStatusMap: Record; + setInfopointStatusMap: Dispatch< SetStateAction> >; }; @@ -48,13 +48,13 @@ const ImageTextAnswer = ({ setMarkedAnswers, quizType, answersTextDisplayType, - infopointOpenStatusMap, - setInfopointOpenStatusMap, + infopointStatusMap, + setInfopointStatusMap, }: ImageTextAnswerProps) => { const { isSm, isMobileLandscape } = useMediaDevice(); const [imageContainerRef, imageContainerSize] = - useElementSize(); + useResizeObserver(); const answerImageOrigData = useMemo( () => answer.imageOrigData ?? { width: 0, height: 0 }, @@ -159,7 +159,7 @@ const ImageTextAnswer = ({ - diff --git a/web/src/containers/views/games/game-quiz/game-quiz.tsx b/web/src/containers/views/games/game-quiz/game-quiz.tsx index 3082fd01..920a9113 100644 --- a/web/src/containers/views/games/game-quiz/game-quiz.tsx +++ b/web/src/containers/views/games/game-quiz/game-quiz.tsx @@ -3,7 +3,6 @@ import { useCallback, useState, useMemo, useEffect } from "react"; import { createSelector } from "reselect"; import { useSelector } from "react-redux"; -import { useTranslation } from "react-i18next"; import { useMediaDevice } from "context/media-device-provider/media-device-provider"; import useTooltipInfopoint from "components/infopoint/useTooltipInfopoint"; @@ -23,6 +22,7 @@ import { import cx from "classnames"; import { useTutorial } from "context/tutorial-provider/use-tutorial"; +import { useTranslation } from "react-i18next"; // - - @@ -43,8 +43,8 @@ export const GameQuiz = ({ const { isSm, isMobileLandscape } = useMediaDevice(); const { - infopointOpenStatusMap, - setInfopointOpenStatusMap, + infopointStatusMap, + setInfopointStatusMap, closeInfopoints: closeAllInfopoints, } = useTooltipInfopoint(viewScreen); @@ -76,19 +76,18 @@ export const GameQuiz = ({ // - - - const { bind, TutorialTooltip, escapeTutorial } = useTutorial( - "gameOptions", - !isMobileOverlay - ); + const { bind, TutorialTooltip } = useTutorial("gameOptions", { + shouldOpen: !isMobileOverlay, + closeOnEsc: true, + }); const onKeydownAction = useCallback( (event: KeyboardEvent) => { if (event.key === "Escape") { closeAllInfopoints(viewScreen)(); - escapeTutorial(); } }, - [closeAllInfopoints, escapeTutorial, viewScreen] + [closeAllInfopoints, viewScreen] ); useEffect(() => { @@ -138,8 +137,8 @@ export const GameQuiz = ({ quizType={quizType} answersTextDisplayType={answersTextDisplayType} setMarkedAnswers={setMarkedAnswers} - infopointOpenStatusMap={infopointOpenStatusMap} - setInfopointOpenStatusMap={setInfopointOpenStatusMap} + infopointStatusMap={infopointStatusMap} + setInfopointStatusMap={setInfopointStatusMap} /> ); })} @@ -150,8 +149,8 @@ export const GameQuiz = ({ , infoPanelRef.current )} diff --git a/web/src/containers/views/games/game-sizing/game-sizing.tsx b/web/src/containers/views/games/game-sizing/game-sizing.tsx index 091fdb1f..dcb2c00e 100644 --- a/web/src/containers/views/games/game-sizing/game-sizing.tsx +++ b/web/src/containers/views/games/game-sizing/game-sizing.tsx @@ -1,141 +1,148 @@ import ReactDOM from "react-dom"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { animated, useSpring, useTransition } from "react-spring"; -import { createSelector } from "reselect"; -import { useDrag } from "@use-gesture/react"; +import { useState, useCallback } from "react"; +import { animated, useTransition } from "react-spring"; import { useSelector } from "react-redux"; +import { createSelector } from "reselect"; -import { ScreenProps } from "models"; -import { GameSizingScreen } from "models"; -import { AppState } from "store/store"; import { useTranslation } from "react-i18next"; -import useElementSize from "hooks/element-size-hook"; +import { useTutorial } from "context/tutorial-provider/use-tutorial"; +import { useGameAutoNavigationOnResultTimeElapsed } from "../useGameAutoNavigationOnResultTimeElapsed"; +import useResizeObserver from "hooks/use-resize-observer"; +import { useElementResize } from "../../../../hooks/spring-hooks/use-element-resize"; -import expand from "../../../../assets/img/expand.png"; +// Components import { GameInfoPanel } from "../GameInfoPanel"; import { GameActionsPanel } from "../GameActionsPanel"; -import { useTutorial } from "context/tutorial-provider/use-tutorial"; + +// Models +import { ScreenProps } from "models"; +import { GameSizingScreen } from "models"; +import { AppState } from "store/store"; + +// Assets +import expandImg from "../../../../assets/img/expand.png"; +import { GAME_SCREEN_DEFAULT_RESULT_TIME } from "constants/screen"; + +// - - - - - - const stateSelector = createSelector( ({ expo }: AppState) => expo.viewScreen as GameSizingScreen, (viewScreen) => ({ viewScreen }) ); +// - - - - - - + export const GameSizing = ({ screenPreloadedFiles, infoPanelRef, actionsPanelRef, isMobileOverlay, }: ScreenProps) => { - const { viewScreen } = useSelector(stateSelector); - const [finished, setFinished] = useState(false); - const [ref, containerSize] = useElementSize(); const { t } = useTranslation("view-screen"); + const { viewScreen } = useSelector(stateSelector); - const onFinish = useCallback(() => { - setFinished(true); - }, []); + const { resultTime = GAME_SCREEN_DEFAULT_RESULT_TIME } = viewScreen; - const onReset = useCallback(() => { - setFinished(false); - }, []); + const { + image1: referenceImgSrc, + image2: comparisonImgSrc, + image3: resultingImgSrc, + } = screenPreloadedFiles; - const { height: originalHeight = 0, width: originalWidth = 0 } = - viewScreen.image2OrigData ?? {}; + // - - Resizing functionality - - - const [{ width, height }, api] = useSpring(() => ({ - width: originalWidth, - height: originalHeight, - })); + const { + image1OrigData: referenceImgOrigData, + image2OrigData: comparisonImgOrigData, + } = viewScreen; - const ratio = useMemo( - () => originalWidth / originalHeight, - [originalHeight, originalWidth] - ); + const [rightContainerRef, rightContainerSize] = useResizeObserver(); + const [leftContainerRef, leftContainerSize] = useResizeObserver(); - const bind = useDrag( - ({ down, offset: [x, y], lastOffset: [xp, yp] }) => { - if (!down) { - return; - } - - // we double the increments since the container is centered (grows on both sides) - const width = 2 * x - xp; - const height = 2 * y - yp; - - const widthBased = width > height * ratio; - - api.start({ - width: widthBased ? width : height * ratio, - height: widthBased ? width / ratio : height, - immediate: true, - }); - }, - { - from: () => [width.get(), height.get()], - bounds: (state) => { - const [xp = 0, yp = 0] = state?.lastOffset ?? []; - - const maxWidth = (containerSize.width - 100 + xp) / 2; - const maxHeight = (containerSize.height - 100 + yp) / 2; - const widthBased = containerSize.width < containerSize.height * ratio; - - return { - left: (50 + xp) / 2, - top: (50 + yp) / 2, - right: widthBased ? maxWidth : maxHeight * ratio, - bottom: widthBased ? maxWidth / ratio : maxHeight, - }; - }, - } - ); + const { + resizeSpring: comparisonImgResizeSpring, + bindResizeDrag: comparisongImgBindResizeDrag, + } = useElementResize({ + containerSize: rightContainerSize, + dragResizingImgOrigData: comparisonImgOrigData ?? { width: 0, height: 0 }, + }); + + const { + resizeSpring: referenceImgResizeSpring, + bindResizeDrag: referenceImgBindResizeDrag, + } = useElementResize({ + containerSize: leftContainerSize, + dragResizingImgOrigData: referenceImgOrigData ?? { width: 0, height: 0 }, + }); + + // - - Tutorial - - - const transition = useTransition(finished, { + const { bind: bindTutorial, TutorialTooltip } = useTutorial("gameSizing", { + shouldOpen: !isMobileOverlay, + closeOnEsc: true, + }); + + // - - - - + + const [isGameFinished, setIsGameFinished] = useState(false); + + const onGameFinish = useCallback(() => { + setIsGameFinished(true); + }, []); + + const onGameReset = useCallback(() => { + setIsGameFinished(false); + }, []); + + const transition = useTransition(isGameFinished, { initial: { opacity: 1 }, from: { opacity: 0 }, enter: { opacity: 1 }, leave: { opacity: 0 }, }); - // - - - - const { - bind: bindTutorial, - TutorialTooltip, - escapeTutorial, - } = useTutorial("gameSizing", !isMobileOverlay); - - const onKeydownAction = useCallback( - (event) => { - if (event.key === "Escape") { - escapeTutorial(); - } - }, - [escapeTutorial] - ); + // - - - - - useEffect(() => { - document.addEventListener("keydown", onKeydownAction); - return () => document.removeEventListener("keydown", onKeydownAction); + useGameAutoNavigationOnResultTimeElapsed({ + gameResultTime: resultTime * 1000, + isGameFinished: isGameFinished, }); return (
-
- + {/* Left container */} +
+
+ + expand image icon left +
+ + {/* Right container */}
- {transition(({ opacity }, finished) => - finished ? ( + {transition(({ opacity }, isGameFinished) => + isGameFinished ? ( ) : ( @@ -144,15 +151,18 @@ export const GameSizing = ({ style={{ opacity }} > expand icon ) @@ -163,9 +173,9 @@ export const GameSizing = ({ ReactDOM.createPortal( , infoPanelRef.current )} @@ -174,9 +184,9 @@ export const GameSizing = ({ ReactDOM.createPortal( , actionsPanelRef.current )} diff --git a/web/src/containers/views/games/useGameAutoNavigationOnResultTimeElapsed.ts b/web/src/containers/views/games/useGameAutoNavigationOnResultTimeElapsed.ts new file mode 100644 index 00000000..1829c50c --- /dev/null +++ b/web/src/containers/views/games/useGameAutoNavigationOnResultTimeElapsed.ts @@ -0,0 +1,26 @@ +import { useCallback } from "react"; +import { useCountdown } from "hooks/countdown-hook"; +import { useExpoNavigation } from "hooks/view-hooks/expo-navigation-hook"; + +type UseGameAutoNavigationOnResultTimeElapsedProps = { + gameResultTime: number; // in milliseconds + isGameFinished: boolean; +}; + +export const useGameAutoNavigationOnResultTimeElapsed = ({ + gameResultTime, + isGameFinished, +}: UseGameAutoNavigationOnResultTimeElapsedProps) => { + const { navigateForward } = useExpoNavigation(); + + // NOTE: it is important that the deps array is empty + const onCountdownFinish = useCallback(() => { + navigateForward(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useCountdown(gameResultTime, { + isPaused: !isGameFinished, + onFinish: onCountdownFinish, + }); +}; diff --git a/web/src/containers/views/view-finish/rating-panel/SendButton.tsx b/web/src/containers/views/view-finish/rating-panel/SendButton.tsx index 4b829ac0..ed9dc7ce 100644 --- a/web/src/containers/views/view-finish/rating-panel/SendButton.tsx +++ b/web/src/containers/views/view-finish/rating-panel/SendButton.tsx @@ -34,7 +34,7 @@ const SendButton = ({ disabled, onClick, isSubmitting }: SendButtonProps) => { > {t("sendActionButtonLabel")} {!isSubmitting && ( - + )} {isSubmitting && ( { const { image1, image2 } = screenPreloadedFiles; // Hook up with reference to screen container div, provide its current width and height - const [screenContainerRef, screenContainerSize] = useElementSize(); + const [screenContainerRef, screenContainerSize] = useResizeObserver(); const [imageBeforeEl, setImageBeforeEl] = useState( null @@ -94,10 +94,10 @@ export const ViewImageChange = ({ screenPreloadedFiles }: ScreenProps) => { // - - - const { - infopointOpenStatusMap, - setInfopointOpenStatusMap, + infopointStatusMap, + setInfopointStatusMap, closeInfopoints, - ScreenAnchorInfopoint, + AnchorInfopoint, TooltipInfoPoint, } = useTooltipInfopoint(viewScreen); @@ -145,10 +145,11 @@ export const ViewImageChange = ({ screenPreloadedFiles }: ScreenProps) => { bind: bindTutorial, TutorialTooltip, isTutorialOpen, - } = useTutorial( - "screenChange", - animation !== "GRADUAL_TRANSITION" && animation !== "FADE_IN_OUT_TWO_IMAGES" - ); + } = useTutorial("screenChange", { + shouldOpen: + animation !== "GRADUAL_TRANSITION" && + animation !== "FADE_IN_OUT_TWO_IMAGES", + }); // - - - @@ -509,7 +510,7 @@ export const ViewImageChange = ({ screenPreloadedFiles }: ScreenProps) => { // Render the anchor infopoints return ( - { { return ( - { { /* Wrapper is the whole
of this screen */ /* Container is
without tooltip */ - const [wrapperRef, parentSize] = useElementSize(); + const [wrapperRef, parentSize] = useResizeObserver(); const containerRef = useRef(null); const [containedImgEl, setContainedImgEl] = useState( null @@ -94,10 +94,10 @@ export const ViewImage = ({ screenPreloadedFiles }: ScreenProps) => { // - - - - const { - infopointOpenStatusMap, - setInfopointOpenStatusMap, + infopointStatusMap, + setInfopointStatusMap, closeInfopoints, - ScreenAnchorInfopoint, + AnchorInfopoint, TooltipInfoPoint, } = useTooltipInfopoint(viewScreen); @@ -186,7 +186,7 @@ export const ViewImage = ({ screenPreloadedFiles }: ScreenProps) => { return ( - { key={`infopoint-tooltip-${infopointIndex}`} id={`infopoint-tooltip-${infopointIndex}`} infopoint={infopoint} - infopointOpenStatusMap={infopointOpenStatusMap} - setInfopointOpenStatusMap={setInfopointOpenStatusMap} + infopointStatusMap={infopointStatusMap} + setInfopointStatusMap={setInfopointStatusMap} primaryKey={infopointIndex.toString()} /> diff --git a/web/src/containers/views/view-parallax/view-parallax.tsx b/web/src/containers/views/view-parallax/view-parallax.tsx index 2c9fc157..20c01343 100644 --- a/web/src/containers/views/view-parallax/view-parallax.tsx +++ b/web/src/containers/views/view-parallax/view-parallax.tsx @@ -3,7 +3,7 @@ import { useSelector } from "react-redux"; import { createSelector } from "reselect"; import { animated, easings, useSpring } from "react-spring"; -import useElementSize from "hooks/element-size-hook"; +import useResizeObserver from "hooks/use-resize-observer"; // Models import { ScreenProps, ParallaxScreeen } from "models"; @@ -56,7 +56,7 @@ export const ViewParallax = ({ screenPreloadedFiles }: ScreenProps) => { const [ viewContainerRef, { width: viewContainerWidth, height: viewContainerHeight }, - ] = useElementSize(); + ] = useResizeObserver(); const totalDistance = useMemo( () => diff --git a/web/src/containers/views/view-photogallery/Lightbox.tsx b/web/src/containers/views/view-photogallery/Lightbox.tsx index a80ab59c..b638bba6 100644 --- a/web/src/containers/views/view-photogallery/Lightbox.tsx +++ b/web/src/containers/views/view-photogallery/Lightbox.tsx @@ -5,13 +5,12 @@ import { useDialogRef } from "context/dialog-ref-provider/dialog-ref-provider"; import { DialogRefType } from "context/dialog-ref-provider/dialog-ref-types"; import DialogPortal from "context/dialog-ref-provider/DialogPortal"; -import useElementSize from "hooks/element-size-hook"; +import useResizeObserver from "hooks/use-resize-observer"; import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; import { Button } from "components/button/button"; import { Icon } from "components/icon/icon"; -import { BasicTooltip } from "components/tooltip/tooltip"; import { InformationDialog } from "components/dialogs/information-dialog/information-dialog"; @@ -33,7 +32,7 @@ const LightBox = ({ closeLightBox, overlayOpacityAnimation, }: LightBoxProps) => { - const [imgContainerRef, imgContainerSize] = useElementSize(); + const [imgContainerRef, imgContainerSize] = useResizeObserver(); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [containedImageSize, setContainedImageSize] = useState({ width: 0, @@ -138,85 +137,98 @@ const Toolbar = ({ style={{ opacity: overlayOpacityAnimation.opacity }} >
-
- -
-
- -
-
- -
{currPhotoObj.photoDescription && ( -
- -
)}
-
- -
diff --git a/web/src/containers/views/view-screen-overlay/ActionsPanel/ActionsPanel.tsx b/web/src/containers/views/view-screen-overlay/ActionsPanel/ActionsPanel.tsx index 053f3c51..912f53c1 100644 --- a/web/src/containers/views/view-screen-overlay/ActionsPanel/ActionsPanel.tsx +++ b/web/src/containers/views/view-screen-overlay/ActionsPanel/ActionsPanel.tsx @@ -9,7 +9,7 @@ import { GlassMagnifierDialog } from "components/dialogs/glass-magnifier-dialog/ import { SettingsDialog } from "components/dialogs/settings-dialog/settings-dialog"; import { ChaptersDialog } from "components/dialogs/chapters-dialog/chapters-dialog"; -import useElementSize from "hooks/element-size-hook"; +import useResizeObserver from "hooks/use-resize-observer"; import { useSectionScreenParams } from "hooks/view-hooks/section-screen-hook"; // Components @@ -108,7 +108,7 @@ const ActionsPanel = ({ const sectionScreen = useSectionScreenParams(); const { section, screen } = sectionScreen; - const [actionsBoxRef, actionsBoxSize] = useElementSize(); + const [actionsBoxRef, actionsBoxSize] = useResizeObserver(); const { openNewTopDialog, diff --git a/web/src/containers/views/view-screen-overlay/ActionsPanel/AudioButton.tsx b/web/src/containers/views/view-screen-overlay/ActionsPanel/AudioButton.tsx index 2d91a9cb..13c94b1f 100644 --- a/web/src/containers/views/view-screen-overlay/ActionsPanel/AudioButton.tsx +++ b/web/src/containers/views/view-screen-overlay/ActionsPanel/AudioButton.tsx @@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next"; import { Button } from "components/button/button"; import { Icon } from "components/icon/icon"; -import { BasicTooltip } from "components/tooltip/tooltip"; import { RefCallback } from "context/tutorial-provider/use-tutorial"; @@ -25,28 +24,24 @@ const AudioButton = ({ const { openNewTopDialog } = useDialogRef(); return ( - <> -
+ -
- - - + + +
); }; diff --git a/web/src/containers/views/view-screen-overlay/ActionsPanel/EditorButton.tsx b/web/src/containers/views/view-screen-overlay/ActionsPanel/EditorButton.tsx index f20c70da..125bce6d 100644 --- a/web/src/containers/views/view-screen-overlay/ActionsPanel/EditorButton.tsx +++ b/web/src/containers/views/view-screen-overlay/ActionsPanel/EditorButton.tsx @@ -2,7 +2,6 @@ import { useTranslation } from "react-i18next"; import { Button } from "components/button/button"; import { Icon } from "components/icon/icon"; -import { BasicTooltip } from "components/tooltip/tooltip"; import { RefCallback } from "context/tutorial-provider/use-tutorial"; @@ -24,25 +23,24 @@ const EditorButton = ({ const { t } = useTranslation("view-screen", { keyPrefix: "overlay" }); return ( - <> -
+ -
- - - + + +
); }; diff --git a/web/src/containers/views/view-screen-overlay/ActionsPanel/GlassMagnifierButton/GlassMagnifierButton.tsx b/web/src/containers/views/view-screen-overlay/ActionsPanel/GlassMagnifierButton/GlassMagnifierButton.tsx index bdf8acab..1935f983 100644 --- a/web/src/containers/views/view-screen-overlay/ActionsPanel/GlassMagnifierButton/GlassMagnifierButton.tsx +++ b/web/src/containers/views/view-screen-overlay/ActionsPanel/GlassMagnifierButton/GlassMagnifierButton.tsx @@ -24,35 +24,32 @@ const GlassMagnifierButton = ({ const { isLightMode } = useExpoDesignData(); return ( - <> -
-
- -
- - } - /> +
+
+
- + + } + /> +
); }; diff --git a/web/src/containers/views/view-screen-overlay/ActionsPanel/PlayButton.tsx b/web/src/containers/views/view-screen-overlay/ActionsPanel/PlayButton.tsx index 063e4239..996edfd5 100644 --- a/web/src/containers/views/view-screen-overlay/ActionsPanel/PlayButton.tsx +++ b/web/src/containers/views/view-screen-overlay/ActionsPanel/PlayButton.tsx @@ -1,6 +1,5 @@ import { Button } from "components/button/button"; import { Icon } from "components/icon/icon"; -import { BasicTooltip } from "components/tooltip/tooltip"; import { RefCallback } from "context/tutorial-provider/use-tutorial"; @@ -27,27 +26,26 @@ const PlayButton = ({ const { t } = useTranslation("view-screen", { keyPrefix: "overlay" }); return ( - <> -
+ -
- - - + + +
); }; diff --git a/web/src/containers/views/view-screen-overlay/ActionsPanel/ReactivateTutorialButton.tsx b/web/src/containers/views/view-screen-overlay/ActionsPanel/ReactivateTutorialButton.tsx index 74384af0..558b2fb4 100644 --- a/web/src/containers/views/view-screen-overlay/ActionsPanel/ReactivateTutorialButton.tsx +++ b/web/src/containers/views/view-screen-overlay/ActionsPanel/ReactivateTutorialButton.tsx @@ -2,7 +2,6 @@ import { useTranslation } from "react-i18next"; import { Button } from "components/button/button"; import { Icon } from "components/icon/icon"; -import { BasicTooltip } from "components/tooltip/tooltip"; import cx from "classnames"; import { RefCallback } from "context/tutorial-provider/use-tutorial"; @@ -23,25 +22,24 @@ const ReactivateTutorialButton = ({ const { t } = useTranslation("view-screen", { keyPrefix: "overlay" }); return ( - <> -
+ -
- - - + + +
); }; diff --git a/web/src/containers/views/view-screen-overlay/ActionsPanel/SettingsButton.tsx b/web/src/containers/views/view-screen-overlay/ActionsPanel/SettingsButton.tsx index 1ee0161a..1c74786d 100644 --- a/web/src/containers/views/view-screen-overlay/ActionsPanel/SettingsButton.tsx +++ b/web/src/containers/views/view-screen-overlay/ActionsPanel/SettingsButton.tsx @@ -4,7 +4,6 @@ import { DialogRefType } from "context/dialog-ref-provider/dialog-ref-types"; import { Button } from "components/button/button"; import { Icon } from "components/icon/icon"; -import { BasicTooltip } from "components/tooltip/tooltip"; import { RefCallback } from "context/tutorial-provider/use-tutorial"; @@ -25,28 +24,24 @@ const SettingsButton = ({ const { t } = useTranslation("view-screen", { keyPrefix: "overlay" }); return ( - <> -
+ -
- - - + + +
); }; diff --git a/web/src/containers/views/view-screen-overlay/view-screen-overlay.tsx b/web/src/containers/views/view-screen-overlay/view-screen-overlay.tsx index 3be51530..25cb18ba 100644 --- a/web/src/containers/views/view-screen-overlay/view-screen-overlay.tsx +++ b/web/src/containers/views/view-screen-overlay/view-screen-overlay.tsx @@ -127,10 +127,10 @@ export const ViewScreenOverlay = ({ getTutorialEnhanceClassnameByStepkeys, reactivateTutorial, isTutorialCompleted, - } = useTutorial( - isMobileOverlay ? "mobile-overlay" : "overlay", - isOverlayActive === true && (isMobileOverlay ? true : !amIGameScreen) - ); + } = useTutorial(isMobileOverlay ? "mobile-overlay" : "overlay", { + shouldOpen: + isOverlayActive === true && (isMobileOverlay ? true : !amIGameScreen), + }); const isAnyTutorialOpen = useIsAnyTutorialOpened(); diff --git a/web/src/containers/views/view-signpost/ReferenceItem.tsx b/web/src/containers/views/view-signpost/ReferenceItem.tsx index 5100f719..0a6b652d 100644 --- a/web/src/containers/views/view-signpost/ReferenceItem.tsx +++ b/web/src/containers/views/view-signpost/ReferenceItem.tsx @@ -73,7 +73,7 @@ const ReferenceItem = ({
@@ -86,7 +86,7 @@ const ReferenceItem = ({ )} diff --git a/web/src/containers/views/view-slideshow/view-slideshow.tsx b/web/src/containers/views/view-slideshow/view-slideshow.tsx index 0bda693a..58035945 100644 --- a/web/src/containers/views/view-slideshow/view-slideshow.tsx +++ b/web/src/containers/views/view-slideshow/view-slideshow.tsx @@ -4,7 +4,7 @@ import { createSelector } from "reselect"; import { animated, useTransition } from "react-spring"; import { useCountdown } from "hooks/countdown-hook"; -import useElementSize from "hooks/element-size-hook"; +import useResizeObserver from "hooks/use-resize-observer"; import useTooltipInfopoint from "components/infopoint/useTooltipInfopoint"; import { useGlassMagnifier } from "hooks/view-hooks/glass-magnifier-hook/useGlassMagnifier"; @@ -43,7 +43,7 @@ export const ViewSlideshow = ({ screenPreloadedFiles }: ScreenProps) => { // - - - // Container here is parent, { width, height } in pixels of the whole available screen container - const [containerRef, containerSize, containerEl] = useElementSize(); + const [containerRef, containerSize, containerEl] = useResizeObserver(); const [photoImgEl, setPhotoImgEl] = useState(null); const { GlassMagnifier } = useGlassMagnifier(containerEl, photoImgEl); @@ -82,10 +82,10 @@ export const ViewSlideshow = ({ screenPreloadedFiles }: ScreenProps) => { // - - - - const { - infopointOpenStatusMap, - setInfopointOpenStatusMap, + infopointStatusMap, + setInfopointStatusMap, closeInfopoints, - ScreenAnchorInfopoint, + AnchorInfopoint, TooltipInfoPoint, } = useTooltipInfopoint(viewScreen); @@ -137,11 +137,11 @@ export const ViewSlideshow = ({ screenPreloadedFiles }: ScreenProps) => { }, [photosTimes, dispatch]); // One countdown for each photo in slideshow.. after finished, setIndex to next photo - const { reset } = useCountdown(photosTimesArr[photoIndex], { - paused: !viewProgress.shouldIncrement, + const { resetCountdown } = useCountdown(photosTimesArr[photoIndex], { + isPaused: !viewProgress.shouldIncrement, onFinish: () => { setPhotoIndex((prev) => (prev + 1) % (viewScreen.images?.length ?? 1)); - reset(); + resetCountdown(); }, }); @@ -334,7 +334,7 @@ export const ViewSlideshow = ({ screenPreloadedFiles }: ScreenProps) => { - { key={`infopoint-tooltip-${photoIndex}-${infopointIndex}`} id={`infopoint-tooltip-${photoIndex}-${infopointIndex}`} infopoint={infopoint} - infopointOpenStatusMap={infopointOpenStatusMap} - setInfopointOpenStatusMap={setInfopointOpenStatusMap} + infopointStatusMap={infopointStatusMap} + setInfopointStatusMap={setInfopointStatusMap} primaryKey={photoIndex.toString()} secondaryKey={infopointIndex.toString()} canBeOpen={!isAnimationRunning} diff --git a/web/src/containers/views/view-start/view-start.tsx b/web/src/containers/views/view-start/view-start.tsx index 56c320d8..5ff86b62 100644 --- a/web/src/containers/views/view-start/view-start.tsx +++ b/web/src/containers/views/view-start/view-start.tsx @@ -8,7 +8,6 @@ import { useDialogRef } from "context/dialog-ref-provider/dialog-ref-provider"; import { DialogRefType } from "context/dialog-ref-provider/dialog-ref-types"; import DialogPortal from "context/dialog-ref-provider/DialogPortal"; -import useElementSize from "hooks/element-size-hook"; import useResizeObserver from "hooks/use-resize-observer"; import { useExpoNavigation } from "hooks/view-hooks/expo-navigation-hook"; import { useViewStartAnimation } from "./view-start-animation-hook"; @@ -74,12 +73,17 @@ export const ViewStart = ({ screenPreloadedFiles }: ScreenProps) => { const [isInfoPanelOpen, setIsInfoPanelOpen] = useState(false); const [isDetailPanelOpen, setIsDetailPanelOpen] = useState(false); - const [screenContainerRef, screenContainerSize] = useElementSize(); + const [screenContainerRef, screenContainerSize] = useResizeObserver(); const [infoPanelRef, infoPanelSize] = useResizeObserver({ ignoreUpdate: true, }); - const [isLandscapeRecommendationOpen, setIsLandscapeRecommendationOpen] = + const [ + isLandscapeRecommendationSnackbarOpen, + setIsLandscapeRecommendationSnackbarOpen, + ] = useState(isMobile ? true : false); + + const [isAudioWarningSnackbarOpen, setIsAudioWarningSnackbarOpen] = useState(isMobile ? true : false); // - - @@ -324,14 +328,31 @@ export const ViewStart = ({ screenPreloadedFiles }: ScreenProps) => { {/* Mobile snackbar for recommendation to turn the mobile into landscape mode */} + setIsLandscapeRecommendationSnackbarOpen(false)} + > + {t("landscapeModeRecommendationSnackbarText")} + + + + {/* Mobile snackbar for warning that after every screen change, the expo needs to be stopped, + because without user interaction, the audio tracks can not start playing */} + setIsLandscapeRecommendationOpen(false)} + onClose={() => setIsAudioWarningSnackbarOpen(false)} > - {t("landscapeModeRecommendation")} + {t("audioWarningSnackbarText")} diff --git a/web/src/containers/views/view-zoom/view-zoom.tsx b/web/src/containers/views/view-zoom/view-zoom.tsx index 53f60a4f..b7bc10ed 100644 --- a/web/src/containers/views/view-zoom/view-zoom.tsx +++ b/web/src/containers/views/view-zoom/view-zoom.tsx @@ -4,7 +4,7 @@ import { createSelector } from "reselect"; import { animated, useSpring, useTransition, easings } from "react-spring"; import { useExpoDesignData } from "hooks/view-hooks/expo-design-data-hook"; -import useElementSize from "hooks/element-size-hook"; +import useResizeObserver from "hooks/use-resize-observer"; import { useZoomPhase, calculateSequenceParameters } from "./useZoomPhase"; // Models @@ -58,7 +58,7 @@ export const ViewZoom = ({ screenPreloadedFiles }: ScreenProps) => { // - - - const [containerRef, containerSize] = useElementSize(); + const [containerRef, containerSize] = useResizeObserver(); const { width: containedImgWidth, height: containedImgHeight } = useMemo( () => diff --git a/web/src/context/tutorial-provider/use-tutorial.tsx b/web/src/context/tutorial-provider/use-tutorial.tsx index 3c6d462c..8c291d0d 100644 --- a/web/src/context/tutorial-provider/use-tutorial.tsx +++ b/web/src/context/tutorial-provider/use-tutorial.tsx @@ -17,7 +17,17 @@ export type RefCallback = (ref: HTMLElement | null) => void; // - - - - - - - - -export const useTutorial = (tutorialKey: TutorialKey, shouldOpen = true) => { +type UseTutorialOptions = { + shouldOpen?: boolean; + closeOnEsc?: boolean; +}; + +export const useTutorial = ( + tutorialKey: TutorialKey, + options?: UseTutorialOptions +) => { + const { shouldOpen = true, closeOnEsc = false } = options ?? {}; + // Whole object will all tutorials and their info + helper functions to manipulate with them const { store, @@ -224,6 +234,23 @@ export const useTutorial = (tutorialKey: TutorialKey, shouldOpen = true) => { [currStepObj?.stepKey, isTutorialOpen] ); + // - - + // Register escape tutorial on Esc button press + + const onKeydownAction = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Escape" && closeOnEsc) { + escapeTutorial(); + } + }, + [closeOnEsc, escapeTutorial] + ); + + useEffect(() => { + document.addEventListener("keydown", onKeydownAction); + return () => document.removeEventListener("keydown", onKeydownAction); + }, [onKeydownAction]); + // - - // Returned object diff --git a/web/src/hooks/countdown-hook.ts b/web/src/hooks/countdown-hook.ts index 5b5bf9db..fc5d0627 100644 --- a/web/src/hooks/countdown-hook.ts +++ b/web/src/hooks/countdown-hook.ts @@ -2,38 +2,45 @@ import { useCallback, useEffect, useMemo, useState } from "react"; type Options = { onFinish?: () => void; - paused?: boolean; + isPaused?: boolean; tick?: number; }; +/** + * NOTE: onFinish function should be wrapped within useCallback + */ export const useCountdown = (time: number, options?: Options) => { - const { onFinish, paused = false, tick = 250 } = options ?? {}; + const { onFinish, isPaused = false, tick = 250 } = options ?? {}; - const [elapsed, setElapsed] = useState(0); + const [elapsedTime, setElapsedTime] = useState(0); - const finished = useMemo(() => elapsed >= time, [elapsed, time]); + const isFinished = useMemo(() => elapsedTime >= time, [elapsedTime, time]); - useEffect(() => setElapsed(0), [time]); + const resetCountdown = useCallback(() => setElapsedTime(0), []); + + // Restart countdown on input time change + useEffect(() => setElapsedTime(0), [time]); useEffect(() => { - if (paused) { + if (isPaused) { return; } - const interval = setInterval(() => setElapsed((prev) => prev + tick), tick); + const interval = setInterval( + () => setElapsedTime((prev) => prev + tick), + tick + ); return () => clearInterval(interval); - }, [paused, tick]); - - const reset = useCallback(() => setElapsed(0), []); + }, [isPaused, tick]); useEffect(() => { - if (!finished) { + if (!isFinished) { return; } onFinish?.(); - }, [finished, onFinish]); + }, [isFinished, onFinish]); - return { reset, elapsed, finished }; + return { resetCountdown, elapsedTime, isFinished }; }; diff --git a/web/src/hooks/element-size-hook.ts b/web/src/hooks/element-size-hook.ts deleted file mode 100644 index cc485141..00000000 --- a/web/src/hooks/element-size-hook.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import { Size } from "models"; - -const useElementSize = (): [ - (node: T | null) => void, - Size, - T | null -] => { - const [ref, setRef] = useState(null); - const [size, setSize] = useState({ - width: 0, - height: 0, - }); - - // - - - - useEffect(() => { - handleSize(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ref?.offsetHeight, ref?.offsetWidth]); - - // - - - - const handleSize = useCallback(() => { - setSize({ - width: ref?.offsetWidth || 0, - height: ref?.offsetHeight || 0, - }); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ref?.offsetHeight, ref?.offsetWidth]); - - useEffect(() => { - window.addEventListener("resize", handleSize); - - return () => window.removeEventListener("resize", handleSize); - }, [handleSize]); - - return [setRef, size, ref]; -}; - -export default useElementSize; diff --git a/web/src/hooks/spring-hooks/use-corner-info-box.tsx b/web/src/hooks/spring-hooks/use-corner-info-box.tsx new file mode 100644 index 00000000..a7db810f --- /dev/null +++ b/web/src/hooks/spring-hooks/use-corner-info-box.tsx @@ -0,0 +1,54 @@ +import { useMemo, CSSProperties } from "react"; +import { useTransition, animated } from "react-spring"; +import cx from "classnames"; + +type UseCornerInfoBoxProps = { + items: T[] | undefined; + currIndex: number; + textExtractor: (item: T) => string; + position: "left" | "right"; // upper-left or upper-right + className?: string; + style?: CSSProperties; +}; + +export const useCornerInfoBox = ({ + items, + currIndex, + textExtractor, + position, + className, + style, +}: UseCornerInfoBoxProps) => { + const currItem = useMemo(() => items?.[currIndex], [currIndex, items]); + + const infoBoxStyle = useMemo(() => { + if (position === "left") { + return { left: 20, top: 20 }; + } + + return { right: 20, top: 20 }; + }, [position]); + + const itemsInfoTransition = useTransition(currItem, { + from: { opacity: 0, translateX: position === "right" ? 15 : -15 }, + enter: { opacity: 1, translateX: 0, delay: 250 }, + leave: { opacity: 0, translateX: position === "right" ? 15 : -15 }, + }); + + const Element = itemsInfoTransition( + ({ opacity, translateX }, item) => + item && ( + + {textExtractor(item)} + + ) + ); + + return Element; +}; diff --git a/web/src/hooks/spring-hooks/use-element-move.ts b/web/src/hooks/spring-hooks/use-element-move.ts new file mode 100644 index 00000000..a30918cd --- /dev/null +++ b/web/src/hooks/spring-hooks/use-element-move.ts @@ -0,0 +1,73 @@ +import { CSSProperties } from "react"; +import { useSpring } from "react-spring"; +import { useDrag } from "@use-gesture/react"; + +import { Position, Size } from "models"; + +type UseElementMoveProps = { + containerSize: Size; + dragMovingObjectSize: Size; + initialPosition?: Position; + additionalCallback?: (left: number, top: number) => void; +}; + +/** + * This hook enables an element (e.g. image) to be moved within a defined container boundary. + * Moving object should be positioned 'absolute', while its container should be positioned 'relative'. + * NOTE: Add `draggable={false}` html property to the element being moved + * + * @param containerSize size of the container which acts as a boundary when moving our element + * @param dragMovingObjectSize size of the object which is being moved + * @param initialPosition position of moved object inside container boundary, defaults [0, 0] + * @param additionalCallback function which is called when new left and top position is being assigned + */ +export const useElementMove = ({ + containerSize, + dragMovingObjectSize, + initialPosition, + additionalCallback, +}: UseElementMoveProps) => { + // Initial position of image being dragged + // It will be also reset back to [0, 0] whenever the container size changes (because of the deps array) + const [moveSpring, moveSpringApi] = useSpring( + () => ({ + left: initialPosition?.left ?? 0, + top: initialPosition?.top ?? 0, + }), + [containerSize.width, containerSize.height] + ); + + const bindMoveDrag = useDrag( + ({ down, offset: [x, y] }) => { + if (!down) { + return; + } + + moveSpringApi.start({ left: x, top: y, immediate: true }); + additionalCallback?.(x, y); + }, + { + from: () => [moveSpring.left.get(), moveSpring.top.get()], + bounds: { + left: 0, + top: 0, + right: containerSize.width - dragMovingObjectSize.width, + bottom: containerSize.height - dragMovingObjectSize.height, + }, + } + ); + + return { moveSpring, moveSpringApi, bindMoveDrag }; +}; + +export const moveContainerStyle: CSSProperties = { + position: "relative", +}; + +// NOTE: also add 'hover:cursor-move' +export const dragMovingObjectStyle: CSSProperties = { + position: "absolute", + touchAction: "none", + WebkitUserSelect: "none", + WebkitTouchCallout: "none", +}; diff --git a/web/src/hooks/spring-hooks/use-element-resize.ts b/web/src/hooks/spring-hooks/use-element-resize.ts new file mode 100644 index 00000000..73440146 --- /dev/null +++ b/web/src/hooks/spring-hooks/use-element-resize.ts @@ -0,0 +1,108 @@ +import { useMemo, CSSProperties } from "react"; +import { useSpring } from "react-spring"; +import { useDrag } from "@use-gesture/react"; + +import { ImageOrigData, Size } from "models"; + +type UseElementResizeProps = { + containerSize: Size; + dragResizingImgOrigData: ImageOrigData; + initialSize?: Size; + additionalCallback?: (width: number, height: number) => void; +}; + +/** + * This hook enables an element (e.g. image) to be resized within a defined container boundary. + * Object being resized should be positioned 'absolute', while its container should be positioned 'relative'. + * NOTE: Add `draggable={false}` html property to the element being resized + * + * @param containerSize size of the container which acts as a boundary when resizing our element + * @param dragResizingImgOrigData needed for aspect ratio of the element which is being resized + * @param initialSize first size of the object which is being resized inside container boundary + * @param additionalCallback function which is called when new width and height size is being assigned + */ +export const useElementResize = ({ + containerSize, + dragResizingImgOrigData, + initialSize, + additionalCallback, +}: UseElementResizeProps) => { + const { width: origImgWidth, height: origImgHeight } = + dragResizingImgOrigData; + + const origImgRatio = useMemo( + () => origImgWidth / origImgHeight, + [origImgHeight, origImgWidth] + ); + + const [resizeSpring, resizeSpringApi] = useSpring(() => ({ + width: initialSize?.width ?? origImgWidth, + height: initialSize?.height ?? origImgHeight, + })); + + const bindResizeDrag = useDrag( + (state) => { + const { down, offset, lastOffset } = state; + const [x, y] = offset; + const [xp, yp] = lastOffset; + + if (!down) { + return; + } + + // we double the increments since the container is centered (grows on both sides) + const width = 2 * x - xp; + const height = 2 * y - yp; + + const widthBased = width > height * origImgRatio; + + const finalWidth = widthBased ? width : height * origImgRatio; + const finalHeight = widthBased ? width / origImgRatio : height; + + resizeSpringApi.start({ + width: finalWidth, + height: finalHeight, + immediate: true, + }); + + additionalCallback?.(finalWidth, finalHeight); + }, + { + from: () => [resizeSpring.width.get(), resizeSpring.height.get()], + bounds: (state) => { + const [xp = 0, yp = 0] = state?.lastOffset ?? []; + + const maxWidth = (containerSize.width - 100 + xp) / 2; + const maxHeight = (containerSize.height - 100 + yp) / 2; + + const widthBased = + containerSize.width < containerSize.height * origImgRatio; + + return { + left: (50 + xp) / 2, + top: (50 + yp) / 2, + right: widthBased ? maxWidth : maxHeight * origImgRatio, + bottom: widthBased ? maxWidth / origImgRatio : maxHeight, + }; + }, + } + ); + + return { + resizeSpring, + resizeSpringApi, + bindResizeDrag, + }; +}; + +export const resizeContainerStyle: CSSProperties = { + position: "relative", +}; + +// NOTE: also add 'hover:cursor-se-resize' +export const dragResizingObjectStyle: CSSProperties = { + position: "absolute", + touchAction: "none", + // WebkitUserSelect: "none", + // WebkitTouchCallout: "none", +}; diff --git a/web/src/hooks/view-hooks/glass-magnifier-hook/GlassMagnifier.tsx b/web/src/hooks/view-hooks/glass-magnifier-hook/GlassMagnifier.tsx index aa6e6c19..0476b8e9 100644 --- a/web/src/hooks/view-hooks/glass-magnifier-hook/GlassMagnifier.tsx +++ b/web/src/hooks/view-hooks/glass-magnifier-hook/GlassMagnifier.tsx @@ -6,10 +6,10 @@ import { Position } from "models"; // Thats why image container should be positioned relatively and this absolutel type GlassMagnifierProps = { + containedImgSrc: string | undefined; containedImgSize: { width: number; height: number }; cursorPosition: Position; targetPosition: Position; - containedImgSrc: string | undefined; lensContainerStyle?: React.CSSProperties; lensStyle?: React.CSSProperties; }; diff --git a/web/src/hooks/view-hooks/glass-magnifier-hook/calculate-positions.ts b/web/src/hooks/view-hooks/glass-magnifier-hook/calculate-positions.ts index 99483de8..ef9d209c 100644 --- a/web/src/hooks/view-hooks/glass-magnifier-hook/calculate-positions.ts +++ b/web/src/hooks/view-hooks/glass-magnifier-hook/calculate-positions.ts @@ -139,7 +139,7 @@ export const calculatePositions = ( return { containedImgSize, - newContainerCursorPosition, - newTargetPosition, + cursorPosition: newContainerCursorPosition, + targetPosition: newTargetPosition, }; }; diff --git a/web/src/hooks/view-hooks/glass-magnifier-hook/glass-magnifier-hook.tsx b/web/src/hooks/view-hooks/glass-magnifier-hook/glass-magnifier-hook.tsx deleted file mode 100644 index 84da7d59..00000000 --- a/web/src/hooks/view-hooks/glass-magnifier-hook/glass-magnifier-hook.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useState, useMemo, useEffect, useCallback } from "react"; -import { calculatePositions } from "./calculate-positions"; -import { useGlassMagnifierConfig } from "context/glass-magnifier-config-provider/glass-magnifier-config-provider"; -import GlassMagnifier from "./GlassMagnifier"; - -import { Position } from "models"; - -export const useGlassMagnifier = ( - imageContainerEl: HTMLDivElement | null, - containedImageEl: HTMLImageElement | null -) => { - const { isGlassMagnifierEnabled, glassMagnifierPxSize } = - useGlassMagnifierConfig(); - - // Cursor position (left and top from image container) - // Cursor is in the middle of the glass magnifier - const [cursorPosition, setCursorPosition] = useState({ - left: 0, - top: 0, - }); - - // Position (left and top) in the natural dimensions of contained img - const [targetPosition, setTargetPosition] = useState({ - left: 0, - top: 0, - }); - - const [containedImgSize, setContainedImgSize] = useState({ - width: 0, - height: 0, - }); - - const containedImgSrc = useMemo( - () => containedImageEl?.src, - [containedImageEl] - ); - - // - - - - // ImageContainerOnMouseMoveHandler - const imgContainerMouseMoveHandler = useCallback( - (event: globalThis.MouseEvent | globalThis.TouchEvent) => { - if (!isGlassMagnifierEnabled) { - return; - } - if (!imageContainerEl || !containedImageEl || !containedImgSrc) { - return; - } - - const { - containedImgSize, - newContainerCursorPosition, - newTargetPosition, - } = calculatePositions( - imageContainerEl, - containedImageEl, - event, - glassMagnifierPxSize - ); - - setContainedImgSize(containedImgSize); - setCursorPosition(newContainerCursorPosition); - setTargetPosition(newTargetPosition); - - return; - }, - [ - containedImageEl, - containedImgSrc, - glassMagnifierPxSize, - imageContainerEl, - isGlassMagnifierEnabled, - ] - ); - - // Apply the required styles for the imageContainer - useEffect(() => { - if (!imageContainerEl) { - return; - } - - imageContainerEl.style.position = "relative"; - imageContainerEl.style.overflow = "hidden"; - imageContainerEl.style.cursor = isGlassMagnifierEnabled ? "none" : "auto"; - imageContainerEl.style.touchAction = isGlassMagnifierEnabled - ? "none" // disable default browser touch actions such as scrolling, zooming, ... - : "auto"; - }, [imageContainerEl, isGlassMagnifierEnabled]); - - // Add the onMouseMove and touchStart handler for the imageContainer - useEffect(() => { - if (!imageContainerEl) { - return; - } - - imageContainerEl.addEventListener( - "mousemove", - imgContainerMouseMoveHandler - ); - - imageContainerEl.addEventListener( - "touchmove", - imgContainerMouseMoveHandler - ); - - return () => { - imageContainerEl.removeEventListener( - "mousemove", - imgContainerMouseMoveHandler - ); - - imageContainerEl.removeEventListener( - "touchmove", - imgContainerMouseMoveHandler - ); - }; - }, [imageContainerEl, imgContainerMouseMoveHandler]); - - // - - - - // Glass Magnifier component with current cursor positions - // Adding this component into viewScreen will render "lens" at the current cursor position - type CalculatedGlassMagnifierProps = { - lensContainerStyle?: React.CSSProperties; - lensStyle?: React.CSSProperties; - }; - - const CalculatedGlassMagnifier = ({ - lensContainerStyle, - lensStyle, - }: CalculatedGlassMagnifierProps) => { - return ( - - ); - }; - - return { - GlassMagnifier: CalculatedGlassMagnifier, - }; -}; diff --git a/web/src/hooks/view-hooks/glass-magnifier-hook/useGlassMagnifier.tsx b/web/src/hooks/view-hooks/glass-magnifier-hook/useGlassMagnifier.tsx index 14ef891d..9ad99d17 100644 --- a/web/src/hooks/view-hooks/glass-magnifier-hook/useGlassMagnifier.tsx +++ b/web/src/hooks/view-hooks/glass-magnifier-hook/useGlassMagnifier.tsx @@ -1,8 +1,16 @@ import { useState, useMemo, useEffect, useCallback } from "react"; -import { calculatePositions } from "./calculate-positions"; import { useGlassMagnifierConfig } from "context/glass-magnifier-config-provider/glass-magnifier-config-provider"; + import GlassMagnifier from "./GlassMagnifier"; -import { Position } from "models"; + +import { Position, Size } from "models"; +import { calculatePositions } from "./calculate-positions"; + +type PositionsInfoObj = { + containedImgSize: Size; + cursorPosition: Position; + targetPosition: Position; +}; export const useGlassMagnifier = ( imageContainerEl: HTMLDivElement | null, @@ -11,56 +19,45 @@ export const useGlassMagnifier = ( const { isGlassMagnifierEnabled, glassMagnifierPxSize } = useGlassMagnifierConfig(); - // Cursor position (left and top from image container) - // Cursor is in the middle of the glass magnifier - const [cursorPosition, setCursorPosition] = useState({ - left: 0, - top: 0, - }); - - // Position (left and top) in the natural dimensions of contained img - const [targetPosition, setTargetPosition] = useState({ - left: 0, - top: 0, - }); - - const [containedImgSize, setContainedImgSize] = useState({ - width: 0, - height: 0, - }); - const containedImgSrc = useMemo( () => containedImageEl?.src, [containedImageEl] ); - // - - - // ImageContainerOnMouseMoveHandler + const [positionsInfo, setPositionsInfo] = useState({ + // Size of the contained image + containedImgSize: { width: 0, height: 0 }, + + // Current position of the cursor relative to the image container (e.g. whole screen) + // Cursor is located in the middle of the glass magnifier and is not visible when glass magnifier is enabled + cursorPosition: { left: 0, top: 0 }, + + // Represents current position of the left-top corner of the lens -> relative to the natural dimensions of contained img + targetPosition: { left: 0, top: 0 }, + }); + + // - - - - - - - - - - - - + // Event Handlers + // - - - - - - - - - - - - + const imageContainerOnMouseMoveHandler = useCallback( (event: globalThis.MouseEvent | globalThis.TouchEvent) => { if (!isGlassMagnifierEnabled) { return; } + if (!imageContainerEl || !containedImageEl || !containedImgSrc) { return; } - const { - containedImgSize, - newContainerCursorPosition, - newTargetPosition, - } = calculatePositions( + const newPositionsInfo = calculatePositions( imageContainerEl, containedImageEl, event, glassMagnifierPxSize ); - setContainedImgSize(containedImgSize); - setCursorPosition(newContainerCursorPosition); - setTargetPosition(newTargetPosition); - - return; + setPositionsInfo(newPositionsInfo); }, [ containedImageEl, @@ -71,31 +68,23 @@ export const useGlassMagnifier = ( ] ); - // Disable default browser behavior, like saving image when long touch and other.. - // const imageContainerElOnTouchStart = useCallback( - // (e: TouchEvent) => { - // if (isGlassMagnifierEnabled) { - // e.preventDefault(); - // } - // }, - // [isGlassMagnifierEnabled] - // ); - - // Apply the required styles for the imageContainer - useEffect(() => { - if (!imageContainerEl) { - return; - } + // Handler responsible for disabling the default browser behavior, e.g. saving image pop-up when long touch + /* + const imageContainerOnTouchStart = useCallback( + (e: TouchEvent) => { + if (isGlassMagnifierEnabled) { + e.preventDefault(); + } + }, + [isGlassMagnifierEnabled] + ); + */ - imageContainerEl.style.position = "relative"; - imageContainerEl.style.overflow = "hidden"; - imageContainerEl.style.cursor = isGlassMagnifierEnabled ? "none" : "auto"; - imageContainerEl.style.touchAction = isGlassMagnifierEnabled - ? "none" // disable default browser touch actions such as scrolling, zooming, ... - : "auto"; - }, [imageContainerEl, isGlassMagnifierEnabled]); + // - - - - - - - - - - - - + // Effects + // - - - - - - - - - - - - - // Add the onMouseMove handler for the imageContainer + // Effect responsible for registration/unregistration of event handlers for imageContainer element useEffect(() => { if (!imageContainerEl) { return; @@ -105,37 +94,53 @@ export const useGlassMagnifier = ( imageContainerOnMouseMoveHandler ); - // imageContainerEl.addEventListener( - // "touchstart", - // imageContainerElOnTouchStart - // ); - imageContainerEl.addEventListener( "touchmove", imageContainerOnMouseMoveHandler ); + // imageContainerEl.addEventListener( + // "touchstart", + // imageContainerOnTouchStart + // ); + return () => { imageContainerEl.removeEventListener( "mousemove", imageContainerOnMouseMoveHandler ); - // imageContainerEl.removeEventListener( - // "touchstart", - // imageContainerElOnTouchStart - // ); - imageContainerEl.removeEventListener( "touchmove", imageContainerOnMouseMoveHandler ); + + // imageContainerEl.removeEventListener( + // "touchstart", + // imageContainerOnTouchStart + // ); }; }, [imageContainerEl, imageContainerOnMouseMoveHandler]); - // - - - // Glass Magnifier component with current cursor positions - // Adding this component into viewScreen will render "lens" at the current cursor position + // Effect responsible for applying all necessary styles for the imageContainer element + useEffect(() => { + if (!imageContainerEl) { + return; + } + + imageContainerEl.style.position = "relative"; + imageContainerEl.style.overflow = "hidden"; + imageContainerEl.style.cursor = isGlassMagnifierEnabled ? "none" : "auto"; + imageContainerEl.style.touchAction = isGlassMagnifierEnabled + ? "none" // disable default browser touch actions such as scrolling, zooming, ... + : "auto"; + }, [imageContainerEl, isGlassMagnifierEnabled]); + + // - - - - - - - - - - - - + // Prepared GlassMagnifier component + // - adding this component into viewScreen will render "glass magnifier lens" at the current cursor position + // - - - - - - - - - - - - + type CalculatedGlassMagnifierProps = { lensContainerStyle?: React.CSSProperties; lensStyle?: React.CSSProperties; @@ -147,10 +152,10 @@ export const useGlassMagnifier = ( }: CalculatedGlassMagnifierProps) => { return ( diff --git a/web/src/models/infopoint.ts b/web/src/models/infopoint.ts index fb75efb8..0a073103 100644 --- a/web/src/models/infopoint.ts +++ b/web/src/models/infopoint.ts @@ -6,19 +6,19 @@ export type InfopointShape = "SQUARE" | "CIRCLE" | "ICON"; export type Infopoint = { header?: string; // optional header - bodyContentType: InfopointBodyType; + bodyContentType?: InfopointBodyType; text?: string; // present only if bodyContentType is TEXT imageFile?: File; // present only if bodyContentType is IMAGE videoFile?: File; // present only if bodyContentType is VIDEO alwaysVisible: boolean; - isUrlIncluded: boolean; + isUrlIncluded?: boolean; url?: string; // present only if isUrlIncluded is set to true urlName?: string; // present only if isUrlIncluded is set to true isScreenIdIncluded: boolean; screenIdReference?: string; // id of the screen, present only if isScreenIdIncluded is set to true screenNameReference?: string; // present only if isScreenIdIncluded is set to true - shape: InfopointShape; - pxSize: number; // number representing size of infopoint in pixels + shape?: InfopointShape; + pxSize?: number; // number representing size of infopoint in pixels color?: string; // present only if shape !== "ICON", hexa string (3B or 4B.. depends if alpha is not 100%) iconFile?: File; // present only if shape === "ICON" // Rest diff --git a/web/src/models/screen.ts b/web/src/models/screen.ts index 1a1d345b..a2651946 100644 --- a/web/src/models/screen.ts +++ b/web/src/models/screen.ts @@ -8,6 +8,7 @@ import { Document } from "./document"; import { Infopoint } from "./infopoint"; import { ScreenPreloadedFiles } from "context/file-preloader/file-preloader-provider"; import { ActiveExpo } from "./exposition"; +import { Position, Size } from "models"; // NEW import { @@ -397,12 +398,14 @@ export type GameFindScreen = { image2?: string; image1OrigData?: ImageOrigData; image2OrigData?: ImageOrigData; - showTip: boolean; + showTip?: boolean; aloneScreen: boolean; music?: string; muteChapterMusic: boolean; screenCompleted: boolean; resultTime?: number; + numberOfPins?: number; + pinsTexts?: string[]; }; export type GameDrawScreen = { @@ -414,12 +417,14 @@ export type GameDrawScreen = { image2?: string; image1OrigData?: ImageOrigData; image2OrigData?: ImageOrigData; - showDrawing: boolean; + showDrawing?: boolean; aloneScreen: boolean; music?: string; muteChapterMusic: boolean; screenCompleted: boolean; resultTime?: number; + initialColor?: string; + initialThickness?: number; }; export type GameWipeScreen = { @@ -468,6 +473,14 @@ export type GameMoveScreen = { image1OrigData?: ImageOrigData; image2OrigData?: ImageOrigData; objectOrigData?: ImageOrigData; + objectPositionProps?: { + containerPosition: Position; + containedImgPosition: Position; + }; + objectSizeProps?: { + inContainerSize: Size; + inContainedImgFractionSize: Size; + }; aloneScreen: boolean; music?: string; muteChapterMusic: boolean;