diff --git a/src/common/PeriotrisConst.ts b/src/common/PeriotrisConst.ts index 0a901f1..cbe6425 100644 --- a/src/common/PeriotrisConst.ts +++ b/src/common/PeriotrisConst.ts @@ -21,3 +21,6 @@ export const HistoryLocalStorageKey = "history" export const SettingsLocalStorageKey = "settings" export const DefaultBorderThickness = 1 export const DefaultConcurrency = 0 +export const DefaultSwipeThreshold = 500 +export const DefaultSwipeDeltaX = 15 +export const DefaultSwipeDeltaY = 15 diff --git a/src/common/index.ts b/src/common/index.ts index 3bbde2d..f716392 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -17,7 +17,11 @@ import { DefaultBorderThickness, + DefaultConcurrency, DefaultGameUpdateIntervalMilliseconds, + DefaultSwipeDeltaX, + DefaultSwipeDeltaY, + DefaultSwipeThreshold, HistoryLocalStorageKey, SettingsLocalStorageKey, StopwatchUpdateIntervalMilliseconds, @@ -37,6 +41,10 @@ export { HistoryLocalStorageKey, SettingsLocalStorageKey, DefaultBorderThickness, + DefaultSwipeThreshold, + DefaultConcurrency, + DefaultSwipeDeltaX, + DefaultSwipeDeltaY, rearrange, flushed, waitForEvent, diff --git a/src/components/FileFormControl.tsx b/src/components/FileFormControl.tsx index 5e5928c..b2571e5 100644 --- a/src/components/FileFormControl.tsx +++ b/src/components/FileFormControl.tsx @@ -28,8 +28,6 @@ import TextField from "@mui/material/TextField" import Tooltip from "@mui/material/Tooltip" import styled from "@mui/system/styled" -import { isNil } from "../common" - const HiddenInput = styled("input")({ display: "none", }) @@ -87,58 +85,45 @@ interface IFileFormControlProps { readonly contentPreprocessor?: (content: string) => string } -export const FileFormControl = ({ - id, - initialFileContent, - accept, - tooltipCaption, - label, - helperText, - readOnly, - onFileChange, - contentPreprocessor, -}: IFileFormControlProps): React.ReactElement => { - const [fileContent, setFileContent] = React.useState(initialFileContent) - - const onFileChangeHandler = async ( - event: React.ChangeEvent - ) => { - let content = (await head(event.target.files)?.text()) ?? "" - content = contentPreprocessor?.(content) ?? content - const result = onFileChange(content) - if (isNil(result) || result) { - setFileContent(content) - } - } +export const FileFormControl = ( + props: IFileFormControlProps +): React.ReactElement => { + const [fileContent, setFileContent] = React.useState(props.initialFileContent) return ( { + let content = (await head(event.target.files)?.text()) ?? "" + content = props.contentPreprocessor?.(content) ?? content + if (props.onFileChange(content)) { + setFileContent(content) + } + }} /> - - {helperText} + + {props.helperText} ) diff --git a/src/components/NumberFormControl.tsx b/src/components/NumberFormControl.tsx index 26f4c02..d9a7c13 100644 --- a/src/components/NumberFormControl.tsx +++ b/src/components/NumberFormControl.tsx @@ -58,12 +58,12 @@ export const NumberFormControl = ( min: props.min, max: props.max, }} - onChange={(event) => { - const content = !isNil(props.contentPreprocessor) - ? props.contentPreprocessor(event.target.value) - : event.target.value - const result = props.onChange(content) - if (isNil(result) || result) { + onChange={async (event) => { + const content = isNil(props.contentPreprocessor) + ? event.target.value + : props.contentPreprocessor(event.target.value) + + if (props.onChange(content)) { setContent(content) } }} diff --git a/src/components/gameControlBackdrop/GameControlBackdrop.tsx b/src/components/gameControlBackdrop/GameControlBackdrop.tsx index a76bc4f..89dd5d2 100644 --- a/src/components/gameControlBackdrop/GameControlBackdrop.tsx +++ b/src/components/gameControlBackdrop/GameControlBackdrop.tsx @@ -142,7 +142,7 @@ export const GameControlBackdrop = (props: IGameStatusBackdropProps) => { content = break default: - throw new Error("Unknown game state") + throw new Error("GameControlBackdrop: unknown game state") } return ( diff --git a/src/customization/CustomizationFacade.ts b/src/customization/CustomizationFacade.ts index b69fb3d..e89bbae 100644 --- a/src/customization/CustomizationFacade.ts +++ b/src/customization/CustomizationFacade.ts @@ -26,9 +26,9 @@ export class CustomizationFacade { public readonly settings = Settings.fromLocalStorage() - public clear(flush = true): void { - this.history.clear(flush) - this.settings.clear(flush) + public clear(): void { + this.history.clear() + this.settings.clear() } } diff --git a/src/customization/history/History.ts b/src/customization/history/History.ts index 8713957..44090d8 100644 --- a/src/customization/history/History.ts +++ b/src/customization/history/History.ts @@ -16,7 +16,7 @@ */ import { HistoryLocalStorageKey, isNil } from "../../common" -import { retrieve, store } from "../../localstorage" +import { remove, retrieve, store } from "../../localstorage" import type { ILocalStorageSerializable } from "../ILocalStorageSerializable" @@ -48,17 +48,16 @@ export class History implements ILocalStorageSerializable { return isFastestRecordUpdated } - public clear(flush = true): void { - this._fastestRecord = null - this._records = [] - if (flush) { - this.toLocalStorage() - } + public clear(): void { + Object.defineProperties( + this, + Object.getOwnPropertyDescriptors(History.Empty) + ) + remove(HistoryLocalStorageKey) } - private constructor() { - this.clear(false) - } + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} public static fromLocalStorage(): History { const result = retrieve(HistoryLocalStorageKey) diff --git a/src/customization/settings/Settings.ts b/src/customization/settings/Settings.ts index 4f898ba..b2dc01d 100644 --- a/src/customization/settings/Settings.ts +++ b/src/customization/settings/Settings.ts @@ -15,16 +15,20 @@ * along with this program. If not, see https://www.gnu.org/licenses/ . */ +import defaultColorScheme from "../../json/DefaultColorScheme.json" +import defaultMap from "../../json/DefaultMap.json" + import { DefaultBorderThickness, + DefaultConcurrency, DefaultGameUpdateIntervalMilliseconds, + DefaultSwipeDeltaX, + DefaultSwipeDeltaY, + DefaultSwipeThreshold, isNil, SettingsLocalStorageKey, } from "../../common" -import { DefaultConcurrency } from "../../common/PeriotrisConst" -import defaultColorScheme from "../../json/DefaultColorScheme.json" -import defaultMap from "../../json/DefaultMap.json" -import { retrieve, store } from "../../localstorage" +import { remove, retrieve, store } from "../../localstorage" import type { IColorScheme } from "../color_scheme" import type { ILocalStorageSerializable } from "../ILocalStorageSerializable" @@ -91,22 +95,44 @@ export class Settings implements ILocalStorageSerializable { this.toLocalStorage() } - public clear(flush = true): void { - this._showGridLine = undefined - this._gameUpdateIntervalMilliseconds = undefined - this._gameMap = undefined - this._colorScheme = undefined - this._borderThickness = undefined - this._concurrency = undefined - if (flush) { - this.toLocalStorage() - } + private _swipeThreshold: number | undefined + public get swipeThreshold(): number { + return this._swipeThreshold ?? DefaultSwipeThreshold + } + public set swipeThreshold(v) { + this._swipeThreshold = v + this.toLocalStorage() } - private constructor() { - this.clear(false) + private _swipeDeltaX: number | undefined + public get swipeDeltaX(): number { + return this._swipeDeltaX ?? DefaultSwipeDeltaX + } + public set swipeDeltaX(v) { + this._swipeDeltaX = v + this.toLocalStorage() } + private _swipeDeltaY: number | undefined + public get swipeDeltaY(): number { + return this._swipeDeltaY ?? DefaultSwipeDeltaY + } + public set swipeDeltaY(v) { + this._swipeDeltaY = v + this.toLocalStorage() + } + + public clear(): void { + Object.defineProperties( + this, + Object.getOwnPropertyDescriptors(Settings.Default) + ) + remove(SettingsLocalStorageKey) + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + public static fromLocalStorage(): Settings { const result = retrieve(SettingsLocalStorageKey) diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 46f1e27..80f9603 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -2,9 +2,7 @@ "cap_open_button": "Open file...", "msg_color_scheme_invalid": "Invalid color scheme file. Please check your file format.", "msg_game_map_invalid": "Invalid game map file. Please check your file format.", - "msg_update_interval_invalid": "Invalid tick interval value.", - "msg_border_thickness_invalid": "Invalid border thickness value.", - "msg_concurrency_invalid": "Invalid worker count value.", + "msg_int_expected": "Invalid value. Expected an integer.", "msg_clear_all": "All user data purged. Refresh the app to take effect.", "msg_export_settings_succ": "Settings exported. Check browser downloads for exported file.", "typ_category_appearance": "Appearance", @@ -23,6 +21,12 @@ "typ_game_map_helper": "Controls the in-game periodic table layout in JSON.", "lbl_update_interval": "Update interval", "typ_update_interval_helper": "Controls the interval between two game ticks.", + "lbl_swipe_threshold": "Swipe duration threshold", + "typ_swipe_threshold_helper": "Controls the minimal duration above which a dragging is considered a swipe.", + "lbl_swipe_delta_x": "Swipe horizontal offset threshold", + "typ_swipe_delta_x_helper": "Controls the minimal horizontal offset above which a dragging is considered a swipe.", + "lbl_swipe_delta_y": "Swipe vertical offset threshold", + "typ_swipe_delta_y_helper": "Controls the minimal vertical offset above which a dragging is considered a swipe.", "typ_category_misc": "Misc", "lbl_concurrency": "Concurrency", "typ_concurrency_helper": "Controls the maximum number of Web Workers allowed to spawn. Set to '0' to decide based on hardware. A higher value often means quicker game preparation, but a value too large may render the app unresponsive.", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 1d90b9b..b4c0a43 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -23,6 +23,12 @@ "typ_game_map_helper": "控制游戏的周期表布局。布局文件使用 JSON 格式。", "lbl_update_interval": "刷新时间间隔", "typ_update_interval_helper": "控制两个游戏刻之间的时间长度。", + "lbl_swipe_threshold": "滑动时长阈值", + "typ_swipe_threshold_helper": "控制将移动手势判定为滑动时,该手势需要持续的最短时长。", + "lbl_swipe_delta_x": "滑动水平位移阈值", + "typ_swipe_delta_x_helper": "控制将移动手势判定为滑动时,该手势在水平方向上需要产生的最小位移。", + "lbl_swipe_delta_y": "滑动垂直位移阈值", + "typ_swipe_delta_y_helper": "控制将移动手势判定为滑动时,该手势在垂直方向上需要产生的最小位移。", "typ_category_misc": "杂项", "lbl_concurrency": "并行性", "typ_concurrency_helper": "控制游戏最多可使用的 Web Worker 数量。设为“ 0 ”以根据硬件配置自动选择。更高的数值通常意味着游戏准备能够更快速地进行,但该值过高可能导致应用无响应。", diff --git a/src/localstorage/LocalStorageManager.ts b/src/localstorage/LocalStorageManager.ts index 5ef428b..2220a26 100644 --- a/src/localstorage/LocalStorageManager.ts +++ b/src/localstorage/LocalStorageManager.ts @@ -40,3 +40,9 @@ export function retrieve(key: string): T | null { return null } } + +export function remove(key: string): void { + if (!isBrowser) return + + window.localStorage.removeItem(key) +} diff --git a/src/localstorage/index.ts b/src/localstorage/index.ts index 44aa5a7..2302773 100644 --- a/src/localstorage/index.ts +++ b/src/localstorage/index.ts @@ -15,6 +15,6 @@ * along with this program. If not, see https://www.gnu.org/licenses/ . */ -import { retrieve, store } from "./LocalStorageManager" +import { remove, retrieve, store } from "./LocalStorageManager" -export { store, retrieve } +export { remove, store, retrieve } diff --git a/src/pages/game.tsx b/src/pages/game.tsx index e5f0f9b..c2ab5c9 100644 --- a/src/pages/game.tsx +++ b/src/pages/game.tsx @@ -25,6 +25,7 @@ import Box from "@mui/material/Box" import { flushed } from "../common" import { BlocksGrid, CommonHead, GameControlBackdrop } from "../components" import { GameViewModel } from "../viewmodel" +import { customizationFacade } from "../customization" const App = (): React.ReactElement => { const viewModel = new GameViewModel() @@ -57,9 +58,11 @@ const App = (): React.ReactElement => { { filterTaps: true, swipe: { - /* TODO: Add these to customization settings! */ - distance: [15, 15], - duration: 500, + distance: [ + customizationFacade.settings.swipeDeltaX, + customizationFacade.settings.swipeDeltaY, + ], + duration: customizationFacade.settings.swipeThreshold, }, } ) diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 8d4e53f..f491ce8 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -18,11 +18,11 @@ import validateColorScheme from "ajv-json-loader!../json/schema/ColorScheme.json.schema" import validateMap from "ajv-json-loader!../json/schema/Map.json.schema" +import FileSaver from "file-saver" import { graphql } from "gatsby" import { useI18next } from "gatsby-plugin-react-i18next" import { useSnackbar } from "notistack" import React from "react" -import FileSaver from "file-saver" import Button from "@mui/material/Button" import Container from "@mui/material/Container" @@ -37,6 +37,11 @@ import Typography from "@mui/material/Typography" import { CommonHead, FileFormControl, NumberFormControl } from "../components" import { customizationFacade } from "../customization" +const tryParseInt = (s: string): readonly [boolean, number] => { + const v = parseInt(s, 10) + return [!isNaN(v), v] +} + const App = (): React.ReactElement => { const { t, changeLanguage, languages, language } = useI18next() @@ -57,91 +62,10 @@ const App = (): React.ReactElement => { customizationFacade.settings.showGridLine ? "visible" : "hidden" ) - const handleAssistanceGridModeChange = async ( - event: React.ChangeEvent - ) => { - const value = event.target.value - customizationFacade.settings.showGridLine = value === "visible" - setAssistanceGridMode(value) - } - - const handleLangChange = async ( - event: React.ChangeEvent - ) => { - await changeLanguage(event.target.value) - } - const jsonMinifyPreprocessor = (json: string): string => { return JSON.stringify(JSON.parse(json)) } - const handleColorSchemeFileChange = (newContent: string): boolean => { - const obj = JSON.parse(newContent) - if (validateColorScheme(obj)) { - customizationFacade.settings.colorScheme = obj - return true - } - enqueueSnackbar(t("msg_color_scheme_invalid"), { variant: "error" }) - return false - } - - const handleGameMapFileChange = (newContent: string): boolean => { - const obj = JSON.parse(newContent) - if (validateMap(obj)) { - customizationFacade.settings.gameMap = obj - return true - } - enqueueSnackbar(t("msg_game_map_invalid"), { - variant: "error", - }) - return false - } - - const handleUpdateIntervalChange = (newContent: string): boolean => { - const value = parseInt(newContent, 10) - if (isNaN(value)) { - enqueueSnackbar(t("msg_update_interval_invalid"), { variant: "error" }) - return false - } - customizationFacade.settings.gameUpdateIntervalMilliseconds = value - return true - } - - const handleBorderThicknessChange = (newContent: string): boolean => { - const value = parseInt(newContent, 10) - if (isNaN(value) || value <= 0) { - enqueueSnackbar(t("msg_border_thickness_invalid"), { variant: "error" }) - return false - } - customizationFacade.settings.borderThickness = value - return true - } - - const handleConcurrencyChange = (newContent: string): boolean => { - const value = parseInt(newContent, 10) - if (isNaN(value)) { - enqueueSnackbar(t("msg_concurrency_invalid"), { variant: "error" }) - return false - } - customizationFacade.settings.concurrency = value - return true - } - - const handleClearAllClick = (): void => { - customizationFacade.clear() - enqueueSnackbar(t("msg_clear_all"), { variant: "success" }) - } - - const handleExportSettingsClick = (): void => { - FileSaver.saveAs( - new Blob([JSON.stringify(customizationFacade.settings)], { - type: "application/json;charset=utf-8", - }), - "settings.json" - ) - enqueueSnackbar(t("msg_export_settings_succ"), { variant: "success" }) - } - return ( { value={language} label={t("lbl_lang")} aria-describedby="lang-input-helper-text" - onChange={handleLangChange} + onChange={async (ev) => { + await changeLanguage(ev.target.value) + }} > {languages.map((lang) => ( @@ -189,7 +115,11 @@ const App = (): React.ReactElement => { value={assistanceGridMode} label={t("lbl_assistance_grid")} aria-describedby="assistance-grid-input-helper-text" - onChange={handleAssistanceGridModeChange} + onChange={(ev) => { + const v = ev.target.value + customizationFacade.settings.showGridLine = v === "visible" + setAssistanceGridMode(v) + }} > {assistanceGridAppearanceOptions.map((option) => ( @@ -211,7 +141,18 @@ const App = (): React.ReactElement => { adornments={{ endAdornment: px, }} - onChange={handleBorderThicknessChange} + onChange={(newContent) => { + const [isValid, value] = tryParseInt(newContent) + if (isValid) { + customizationFacade.settings.borderThickness = value + return true + } else { + enqueueSnackbar(t("msg_int_expected"), { + variant: "error", + }) + return false + } + }} /> { label={t("lbl_color_scheme")} helperText={t("typ_color_scheme_helper")} tooltipCaption={t("cap_open_button")} - onFileChange={handleColorSchemeFileChange} + onFileChange={(newContent) => { + const obj = JSON.parse(newContent) + if (validateColorScheme(obj)) { + customizationFacade.settings.colorScheme = obj + return true + } else { + enqueueSnackbar(t("msg_color_scheme_invalid"), { + variant: "error", + }) + return false + } + }} contentPreprocessor={jsonMinifyPreprocessor} /> @@ -246,7 +198,18 @@ const App = (): React.ReactElement => { label={t("lbl_game_map")} helperText={t("typ_game_map_helper")} tooltipCaption={t("cap_open_button")} - onFileChange={handleGameMapFileChange} + onFileChange={(newContent) => { + const obj = JSON.parse(newContent) + if (validateMap(obj)) { + customizationFacade.settings.gameMap = obj + return true + } else { + enqueueSnackbar(t("msg_game_map_invalid"), { + variant: "error", + }) + return false + } + }} contentPreprocessor={jsonMinifyPreprocessor} /> { adornments={{ endAdornment: ms, }} - onChange={handleUpdateIntervalChange} + onChange={(newContent) => { + const [isValid, value] = tryParseInt(newContent) + if (isValid) { + customizationFacade.settings.gameUpdateIntervalMilliseconds = + value + return true + } else { + enqueueSnackbar(t("msg_int_expected"), { + variant: "error", + }) + return false + } + }} + /> + ms, + }} + onChange={(newContent) => { + const [isValid, value] = tryParseInt(newContent) + if (isValid) { + customizationFacade.settings.swipeThreshold = value + return true + } else { + enqueueSnackbar(t("msg_int_expected"), { + variant: "error", + }) + return false + } + }} + /> + px, + }} + onChange={(newContent) => { + const [isValid, value] = tryParseInt(newContent) + if (isValid) { + customizationFacade.settings.swipeDeltaX = value + return true + } else { + enqueueSnackbar(t("msg_int_expected"), { + variant: "error", + }) + return false + } + }} + /> + px, + }} + onChange={(newContent) => { + const [isValid, value] = tryParseInt(newContent) + if (isValid) { + customizationFacade.settings.swipeDeltaY = value + return true + } else { + enqueueSnackbar(t("msg_int_expected"), { + variant: "error", + }) + return false + } + }} /> @@ -278,13 +322,34 @@ const App = (): React.ReactElement => { helperText={t("typ_concurrency_helper")} min={0} step={1} - onChange={handleConcurrencyChange} + onChange={(newContent) => { + const [isValid, value] = tryParseInt(newContent) + if (isValid) { + customizationFacade.settings.concurrency = value + return true + } else { + enqueueSnackbar(t("msg_int_expected"), { + variant: "error", + }) + return false + } + }} />