From fe95ed9721b16e1385c5e580e1244ccbdfaa0e8e Mon Sep 17 00:00:00 2001 From: Umamichi Date: Sat, 21 Feb 2026 00:08:32 +0800 Subject: [PATCH 1/2] feat: Implement Undo/Redo functionality with Redux middleware and UI integration --- package-lock.json | 121 +++++++++++++----- package.json | 1 + src/components/root/window-header.tsx | 56 +++++++- .../branch-side-panel/coline-card.tsx | 2 + .../station-side-panel/info-section.tsx | 5 + .../station-side-panel/interchange-card.tsx | 2 + .../interchange-section.tsx | 2 + .../station-side-panel/more-section.tsx | 2 + .../style-side-panel/gzmtr-note-card.tsx | 2 + src/redux/app/action.ts | 3 + src/redux/index.ts | 12 +- src/redux/undo/undo-middleware.test.ts | 95 ++++++++++++++ src/redux/undo/undo-middleware.ts | 91 +++++++++++++ src/redux/undo/undo-slice.ts | 26 ++++ src/redux/undo/undoable.ts | 79 ++++++++++++ 15 files changed, 465 insertions(+), 34 deletions(-) create mode 100644 src/redux/undo/undo-middleware.test.ts create mode 100644 src/redux/undo/undo-middleware.ts create mode 100644 src/redux/undo/undo-slice.ts create mode 100644 src/redux/undo/undoable.ts diff --git a/package-lock.json b/package-lock.json index 05e74d37d..e36e2844e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "nanoid": "^5.1.6", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hotkeys-hook": "^5.2.4", "react-i18next": "^15.7.4", "react-icons": "^5.5.0", "react-redux": "^9.2.0", @@ -162,6 +163,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1720,6 +1722,7 @@ "resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-2.10.9.tgz", "integrity": "sha512-lhdcgoocOiURwBNR3L8OioCNIaGCZqRfuKioLyaQLjOanl4jr0PQclsGb+w0cmito252vEWpsz2xRqF7y+Flrw==", "license": "MIT", + "peer": true, "dependencies": { "@chakra-ui/hooks": "2.4.5", "@chakra-ui/styled-system": "2.12.4", @@ -1745,6 +1748,7 @@ "resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.12.4.tgz", "integrity": "sha512-oa07UG7Lic5hHSQtGRiMEnYjuhIa8lszyuVhZjZqR2Ap3VMF688y1MVPJ1pK+8OwY5uhXBgVd5c0+rI8aBZlwg==", "license": "MIT", + "peer": true, "dependencies": { "@chakra-ui/utils": "2.2.5", "csstype": "^3.1.2" @@ -1879,6 +1883,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1922,6 +1927,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1984,6 +1990,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2027,6 +2034,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2883,6 +2891,7 @@ "resolved": "https://registry.npmjs.org/@railmapgen/rmg-runtime/-/rmg-runtime-12.0.4.tgz", "integrity": "sha512-IYHxLa74QstqsPhUk/dbO3otdIkQjRMPeam+54pVhnc0YtPHkJv6iMVVAE/Yh0MnKICh6NIkn70UAkJrYo9NIA==", "license": "GPL-3.0-only", + "peer": true, "dependencies": { "i18next": "^25.6.2" } @@ -3353,8 +3362,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3455,6 +3463,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3476,6 +3485,7 @@ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3487,6 +3497,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3562,6 +3573,7 @@ "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/types": "8.46.4", @@ -4009,6 +4021,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4045,6 +4058,7 @@ "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-33.3.2.tgz", "integrity": "sha512-5bv4JIJvGov23sduIUIyQTqpa/qhoQrRkQm5pFOQb7RMwusfx6xBPrkLwIIlCJiQ8g0OOinxWzZ2kQ2Zml6tLw==", "license": "MIT", + "peer": true, "dependencies": { "ag-grid-community": "33.3.2", "prop-types": "^15.8.1" @@ -4086,7 +4100,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -4418,6 +4431,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -4883,8 +4897,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -5173,6 +5186,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5604,6 +5618,7 @@ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", "license": "MIT", + "peer": true, "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", @@ -5984,6 +5999,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -6581,6 +6597,7 @@ "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.23", "@asamuzakjp/dom-selector": "^6.7.4", @@ -6781,7 +6798,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7235,6 +7251,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7315,6 +7332,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7342,7 +7360,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7358,7 +7375,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -7417,6 +7433,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7440,6 +7457,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7477,6 +7495,16 @@ } } }, + "node_modules/react-hotkeys-hook": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-5.2.4.tgz", + "integrity": "sha512-BgKg+A1+TawkYluh5Bo4cTmcgMN5L29uhJbDUQdHwPX+qgXRjIPYU5kIDHyxnAwCkCBiu9V5OpB2mpyeluVF2A==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-i18next": { "version": "15.7.4", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", @@ -7517,8 +7545,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-nanny": { "version": "2.15.0", @@ -7534,6 +7561,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -7696,7 +7724,8 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8451,6 +8480,7 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -8690,6 +8720,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8894,6 +8925,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -8969,6 +9001,7 @@ "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.10", "@vitest/mocker": "4.0.10", @@ -9382,6 +9415,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, + "peer": true, "requires": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -10362,6 +10396,7 @@ "version": "2.10.9", "resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-2.10.9.tgz", "integrity": "sha512-lhdcgoocOiURwBNR3L8OioCNIaGCZqRfuKioLyaQLjOanl4jr0PQclsGb+w0cmito252vEWpsz2xRqF7y+Flrw==", + "peer": true, "requires": { "@chakra-ui/hooks": "2.4.5", "@chakra-ui/styled-system": "2.12.4", @@ -10379,6 +10414,7 @@ "version": "2.12.4", "resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.12.4.tgz", "integrity": "sha512-oa07UG7Lic5hHSQtGRiMEnYjuhIa8lszyuVhZjZqR2Ap3VMF688y1MVPJ1pK+8OwY5uhXBgVd5c0+rI8aBZlwg==", + "peer": true, "requires": { "@chakra-ui/utils": "2.2.5", "csstype": "^3.1.2" @@ -10441,6 +10477,7 @@ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "dev": true, + "peer": true, "requires": {} }, "@csstools/css-syntax-patches-for-csstree": { @@ -10453,7 +10490,8 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true + "dev": true, + "peer": true }, "@emotion/babel-plugin": { "version": "11.13.5", @@ -10507,6 +10545,7 @@ "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "peer": true, "requires": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -10539,6 +10578,7 @@ "version": "11.14.1", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "peer": true, "requires": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -10995,6 +11035,7 @@ "version": "12.0.4", "resolved": "https://registry.npmjs.org/@railmapgen/rmg-runtime/-/rmg-runtime-12.0.4.tgz", "integrity": "sha512-IYHxLa74QstqsPhUk/dbO3otdIkQjRMPeam+54pVhnc0YtPHkJv6iMVVAE/Yh0MnKICh6NIkn70UAkJrYo9NIA==", + "peer": true, "requires": { "i18next": "^25.6.2" } @@ -11255,8 +11296,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "peer": true + "dev": true }, "@types/babel__core": { "version": "7.20.5", @@ -11349,6 +11389,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, + "peer": true, "requires": { "undici-types": "~7.16.0" } @@ -11367,6 +11408,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "devOptional": true, + "peer": true, "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -11377,6 +11419,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, + "peer": true, "requires": {} }, "@types/react-router": { @@ -11431,6 +11474,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, + "peer": true, "requires": { "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/types": "8.46.4", @@ -11698,7 +11742,8 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true + "dev": true, + "peer": true }, "acorn-jsx": { "version": "5.3.2", @@ -11724,6 +11769,7 @@ "version": "33.3.2", "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-33.3.2.tgz", "integrity": "sha512-5bv4JIJvGov23sduIUIyQTqpa/qhoQrRkQm5pFOQb7RMwusfx6xBPrkLwIIlCJiQ8g0OOinxWzZ2kQ2Zml6tLw==", + "peer": true, "requires": { "ag-grid-community": "33.3.2", "prop-types": "^15.8.1" @@ -11751,8 +11797,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "peer": true + "dev": true }, "ansi-styles": { "version": "4.3.0", @@ -11979,6 +12024,7 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", "dev": true, + "peer": true, "requires": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -12278,8 +12324,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "peer": true + "dev": true }, "dunder-proto": { "version": "1.0.1", @@ -12501,6 +12546,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, + "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12792,6 +12838,7 @@ "version": "11.18.2", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "peer": true, "requires": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", @@ -13024,6 +13071,7 @@ "version": "25.6.2", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.2.tgz", "integrity": "sha512-0GawNyVUw0yvJoOEBq1VHMAsqdM23XrHkMtl2gKEjviJQSLVXsrPqsoYAxBEugW5AB96I2pZkwRxyl8WZVoWdw==", + "peer": true, "requires": { "@babel/runtime": "^7.27.6" } @@ -13392,6 +13440,7 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", "dev": true, + "peer": true, "requires": { "@acemir/cssom": "^0.9.23", "@asamuzakjp/dom-selector": "^6.7.4", @@ -13537,8 +13586,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "peer": true + "dev": true }, "magic-string": { "version": "0.30.21", @@ -13831,7 +13879,8 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true + "dev": true, + "peer": true }, "possible-typed-array-names": { "version": "1.0.0", @@ -13868,7 +13917,8 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", - "dev": true + "dev": true, + "peer": true }, "prettier-linter-helpers": { "version": "1.0.0", @@ -13882,7 +13932,6 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, - "peer": true, "requires": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -13893,8 +13942,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "peer": true + "dev": true } } }, @@ -13930,6 +13978,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "requires": { "loose-envify": "^1.1.0" } @@ -13946,6 +13995,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -13969,6 +14019,12 @@ "use-sidecar": "^1.1.2" } }, + "react-hotkeys-hook": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-5.2.4.tgz", + "integrity": "sha512-BgKg+A1+TawkYluh5Bo4cTmcgMN5L29uhJbDUQdHwPX+qgXRjIPYU5kIDHyxnAwCkCBiu9V5OpB2mpyeluVF2A==", + "requires": {} + }, "react-i18next": { "version": "15.7.4", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", @@ -13988,8 +14044,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "peer": true + "dev": true }, "react-nanny": { "version": "2.15.0", @@ -14001,6 +14056,7 @@ "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "peer": true, "requires": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -14083,7 +14139,8 @@ "redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "peer": true }, "redux-thunk": { "version": "3.1.0", @@ -14613,6 +14670,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, + "peer": true, "requires": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -14781,7 +14839,8 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true + "devOptional": true, + "peer": true }, "typescript-eslint": { "version": "8.46.4", @@ -14891,6 +14950,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, + "peer": true, "requires": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -14906,6 +14966,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.10.tgz", "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==", "dev": true, + "peer": true, "requires": { "@vitest/expect": "4.0.10", "@vitest/mocker": "4.0.10", diff --git a/package.json b/package.json index 882cddf41..97f5e6539 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "nanoid": "^5.1.6", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hotkeys-hook": "^5.2.4", "react-i18next": "^15.7.4", "react-icons": "^5.5.0", "react-redux": "^9.2.0", diff --git a/src/components/root/window-header.tsx b/src/components/root/window-header.tsx index e8df90884..f61ce1dbf 100644 --- a/src/components/root/window-header.tsx +++ b/src/components/root/window-header.tsx @@ -1,19 +1,56 @@ + import { useState } from 'react'; import { Heading, HStack, IconButton } from '@chakra-ui/react'; import { useTranslation } from 'react-i18next'; -import { MdHelp } from 'react-icons/md'; +import { MdHelp, MdRedo, MdUndo } from 'react-icons/md'; import HelpModal from '../modal/help-modal'; import { RmgEnvBadge, RmgWindowHeader } from '@railmapgen/rmg-components'; import rmgRuntime from '@railmapgen/rmg-runtime'; +import { useRootDispatch, useRootSelector } from '../../redux'; +import { redo, undo } from '../../redux/undo/undo-middleware'; +import { useHotkeys } from 'react-hotkeys-hook'; export const WindowHeader = () => { const { t } = useTranslation(); + const dispatch = useRootDispatch(); + const { pastCount, futureCount } = useRootSelector(state => state.undo); const environment = rmgRuntime.getEnv(); const appVersion = rmgRuntime.getAppVersion(); const [isHelpModalOpen, setIsHelpModalOpen] = useState(false); + const isMacClient = navigator.platform.startsWith('Mac'); + let undoHotkeys, redoHotkeys; + + if (isMacClient) { + undoHotkeys = 'meta+z'; + redoHotkeys = 'meta+shift+z'; + } else { + undoHotkeys = 'ctrl+z'; + redoHotkeys = 'ctrl+y'; + } + + useHotkeys( + undoHotkeys, + e => { + e.preventDefault(); + if (pastCount > 0) dispatch(undo()); + }, + { enableOnFormTags: true }, + [pastCount] + ); + + useHotkeys( + redoHotkeys, + e => { + e.preventDefault(); + if (futureCount > 0) dispatch(redo()); + }, + { enableOnFormTags: true }, + [futureCount] + ); + return ( @@ -22,6 +59,22 @@ export const WindowHeader = () => { + } + isDisabled={pastCount === 0} + onClick={() => dispatch(undo())} + /> + } + isDisabled={futureCount === 0} + onClick={() => dispatch(redo())} + /> { ); }; - export const ImportViewWindowHeader = () => { const { t } = useTranslation(); diff --git a/src/components/side-panel/branch-side-panel/coline-card.tsx b/src/components/side-panel/branch-side-panel/coline-card.tsx index c6e53adfe..96fd0bcaa 100644 --- a/src/components/side-panel/branch-side-panel/coline-card.tsx +++ b/src/components/side-panel/branch-side-panel/coline-card.tsx @@ -76,6 +76,7 @@ export default function ColineCard(props: ColineCardProps) { value, colineInfo.colors[0][5], ]), + debouncedDelay: 300, }, { type: 'input', @@ -91,6 +92,7 @@ export default function ColineCard(props: ColineCardProps) { colineInfo.colors[0][4], value, ]), + debouncedDelay: 300, }, ]; diff --git a/src/components/side-panel/station-side-panel/info-section.tsx b/src/components/side-panel/station-side-panel/info-section.tsx index 45cbcbd62..2434b5e10 100644 --- a/src/components/side-panel/station-side-panel/info-section.tsx +++ b/src/components/side-panel/station-side-panel/info-section.tsx @@ -29,18 +29,21 @@ export default function InfoSection() { placeholder: '01', onChange: (value: string) => dispatch(updateStationNum(selectedStation, value)), hidden: ![RmgStyle.GZMTR].includes(style), + debouncedDelay: 300, }, { type: 'input', label: t('Chinese name'), value: localisedName.zh ?? '', onChange: (value: string) => dispatch(updateStationName(selectedStation, 'zh', value)), + debouncedDelay: 300, }, { type: 'input', label: t('English name'), value: localisedName.en ?? '', onChange: (value: string) => dispatch(updateStationName(selectedStation, 'en', value)), + debouncedDelay: 300, }, { type: 'custom', @@ -66,6 +69,7 @@ export default function InfoSection() { placeholder: '1号航站楼', onChange: (value: string) => dispatch(updateStationSecondaryName(selectedStation, 'zh', value)), hidden: !localisedSecondaryName || ![RmgStyle.GZMTR].includes(style), + debouncedDelay: 300, }, { type: 'input', @@ -74,6 +78,7 @@ export default function InfoSection() { placeholder: 'Terminal 1', onChange: (value: string) => dispatch(updateStationSecondaryName(selectedStation, 'en', value)), hidden: !localisedSecondaryName || ![RmgStyle.GZMTR].includes(style), + debouncedDelay: 300, }, ]; diff --git a/src/components/side-panel/station-side-panel/interchange-card.tsx b/src/components/side-panel/station-side-panel/interchange-card.tsx index bd351906b..2c1e6bb84 100644 --- a/src/components/side-panel/station-side-panel/interchange-card.tsx +++ b/src/components/side-panel/station-side-panel/interchange-card.tsx @@ -66,6 +66,7 @@ export default function InterchangeCard(props: InterchangeCardProps) { value: it.name[0], onChange: val => onUpdate?.(i, { ...it, name: [val, it.name[1]] }), optionList: usedNameList[0], + debouncedDelay: 300, }, { type: 'input', @@ -73,6 +74,7 @@ export default function InterchangeCard(props: InterchangeCardProps) { value: it.name[1], onChange: val => onUpdate?.(i, { ...it, name: [it.name[0], val] }), optionList: usedNameList[1], + debouncedDelay: 300, }, { type: 'select', diff --git a/src/components/side-panel/station-side-panel/interchange-section.tsx b/src/components/side-panel/station-side-panel/interchange-section.tsx index f25dd300d..c772704b9 100644 --- a/src/components/side-panel/station-side-panel/interchange-section.tsx +++ b/src/components/side-panel/station-side-panel/interchange-section.tsx @@ -34,6 +34,7 @@ export default function InterchangeSection() { dispatch( updateStationOsiName(selectedStation, setIndex, [value, transfer.groups[setIndex].name?.[1] ?? '']) ), + debouncedDelay: 300, }, { type: 'input', @@ -44,6 +45,7 @@ export default function InterchangeSection() { dispatch( updateStationOsiName(selectedStation, setIndex, [transfer.groups[setIndex].name?.[0] ?? '', value]) ), + debouncedDelay: 300, }, { type: 'switch', diff --git a/src/components/side-panel/station-side-panel/more-section.tsx b/src/components/side-panel/station-side-panel/more-section.tsx index 3cf15d8ea..0ae9976b3 100644 --- a/src/components/side-panel/station-side-panel/more-section.tsx +++ b/src/components/side-panel/station-side-panel/more-section.tsx @@ -90,6 +90,7 @@ export default function MoreSection() { validator: val => Number.isInteger(val), onChange: val => dispatch(updateStationIntPadding(selectedStation, Number(val))), hidden: ![RmgStyle.SHMetro].includes(style), + debouncedDelay: 300, }, { type: 'custom', @@ -111,6 +112,7 @@ export default function MoreSection() { validator: val => Number.isInteger(val), onChange: val => dispatch(updateStationCharacterSpacing(selectedStation, Number(val))), hidden: ![RmgStyle.SHSuburbanRailway].includes(style), + debouncedDelay: 300, }, { type: 'custom', diff --git a/src/components/side-panel/style-side-panel/gzmtr-note-card.tsx b/src/components/side-panel/style-side-panel/gzmtr-note-card.tsx index 92fb82b4a..6868a49a6 100644 --- a/src/components/side-panel/style-side-panel/gzmtr-note-card.tsx +++ b/src/components/side-panel/style-side-panel/gzmtr-note-card.tsx @@ -20,12 +20,14 @@ export default function GZMTRNoteCard(props: GZMTRNoteCardProps) { label: t('StyleSidePanel.note.zhNote'), value: note[0], onChange: value => onUpdate?.([value, note[1], note[2], note[3], note[4]]), + debouncedDelay: 300, }, { type: 'textarea', label: t('StyleSidePanel.note.enNote'), value: note[1], onChange: value => onUpdate?.([note[0], value, note[2], note[3], note[4]]), + debouncedDelay: 300, }, { type: 'switch', diff --git a/src/redux/app/action.ts b/src/redux/app/action.ts index dc2b6ce80..c4002fd6c 100644 --- a/src/redux/app/action.ts +++ b/src/redux/app/action.ts @@ -4,6 +4,7 @@ import { updateParam, updateThemes } from '../../util/param-updater-utils'; import { setFullParam } from '../param/action'; import { setParamConfig } from './app-slice'; import { getParam } from '../../util/param-manager-utils'; +import { clearHistory } from '../undo/undo-middleware'; export const readParam = (paramId: string) => { return async (dispatch: RootDispatch): Promise => { @@ -19,10 +20,12 @@ export const readParam = (paramId: string) => { const updatedParam = await updateThemes(nextParam); dispatch(setParamConfig(nextParamConfig)); dispatch(setFullParam(updatedParam)); + dispatch(clearHistory()); } catch (e) { console.warn('Unable to update themes', e); dispatch(setParamConfig(nextParamConfig)); dispatch(setFullParam(nextParam)); + dispatch(clearHistory()); } return true; } catch (err) { diff --git a/src/redux/index.ts b/src/redux/index.ts index 3d4cc477e..c31841895 100644 --- a/src/redux/index.ts +++ b/src/redux/index.ts @@ -1,26 +1,34 @@ + import appReducer from './app/app-slice'; import paramReducer from './param/param-slice'; import helperReducer from './helper/helper-slice'; +import undoReducer from './undo/undo-slice'; import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import { combineReducers, configureStore, createListenerMiddleware, TypedStartListening } from '@reduxjs/toolkit'; +import { createUndoMiddleware } from './undo/undo-middleware'; const rootReducer = combineReducers({ app: appReducer, param: paramReducer, helper: helperReducer, + undo: undoReducer, }); export type RootState = ReturnType; const listenerMiddleware = createListenerMiddleware(); +const undoMiddleware = createUndoMiddleware(); + export const createStore = (preloadedState: Partial = {}) => configureStore({ reducer: rootReducer, - middleware: getDefaultMiddleware => getDefaultMiddleware().prepend(listenerMiddleware.middleware), + middleware: getDefaultMiddleware => + getDefaultMiddleware({ serializableCheck: false }) + .prepend(listenerMiddleware.middleware) + .concat(undoMiddleware), preloadedState, }); const store = createStore(); export type RootStore = typeof store; - export type RootDispatch = typeof store.dispatch; export const useRootDispatch = () => useDispatch(); export const useRootSelector: TypedUseSelectorHook = useSelector; diff --git a/src/redux/undo/undo-middleware.test.ts b/src/redux/undo/undo-middleware.test.ts new file mode 100644 index 000000000..e55d4c2a5 --- /dev/null +++ b/src/redux/undo/undo-middleware.test.ts @@ -0,0 +1,95 @@ + +import { describe, expect, it } from 'vitest'; +import { createUndoMiddleware, undo, clearHistory } from './undo-middleware'; +import { configureStore, createSlice } from '@reduxjs/toolkit'; +import { updateUndoCounts } from './undo-slice'; + +// Mock param slice +const paramSlice = createSlice({ + name: 'param', + initialState: { value: 0 }, + reducers: { + update: (state, action) => { + state.value = action.payload; + }, + setFullParam: (state, action) => { + state.value = action.payload.value; + }, + }, +}); + +describe('undo middleware', () => { + // Cast middleware to any to bypass strict RootState check for testing + const undoMiddleware = createUndoMiddleware() as any; + + const createTestStore = () => { + return configureStore({ + reducer: { + param: paramSlice.reducer, + undo: (state = { pastCount: 0, futureCount: 0 }, action: any) => { + if (action.type === updateUndoCounts.type) { + return { pastCount: action.payload.past, futureCount: action.payload.future }; + } + return state; + }, + }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(undoMiddleware), + }); + }; + + it('should clear history on clearHistory action', () => { + const store = createTestStore(); + const getUndoState = () => (store.getState() as any).undo; + const getParamState = () => (store.getState() as any).param; + + // 1. Initial State + expect(getParamState().value).toBe(0); + expect(getUndoState().pastCount).toBe(0); + + // 2. Perform actions (starts with param/) + store.dispatch(paramSlice.actions.update(1)); // param/update + store.dispatch(paramSlice.actions.update(2)); // param/update + + expect(getParamState().value).toBe(2); + expect(getUndoState().pastCount).toBe(2); + + // 3. Undo once + store.dispatch(undo()); + expect(getParamState().value).toBe(1); + expect(getUndoState().pastCount).toBe(1); + + // 4. Clear History + store.dispatch(clearHistory()); + expect(getUndoState().pastCount).toBe(0); + expect(getUndoState().futureCount).toBe(0); + + // 5. Undo should do nothing (past is empty) + store.dispatch(undo()); + expect(getParamState().value).toBe(1); // Still 1 + }); + + it('should handle setFullParam followed by clearHistory correctly', () => { + const store = createTestStore(); + const getUndoState = () => (store.getState() as any).undo; + const getParamState = () => (store.getState() as any).param; + + // 1. We have some state + store.dispatch(paramSlice.actions.update(1)); + expect(getUndoState().pastCount).toBe(1); + + // 2. Load new project (param/setFullParam) + // param/setFullParam matches middleware filter + store.dispatch(paramSlice.actions.setFullParam({ value: 99 })); + + // Middleware should capture old state (1) + expect(getUndoState().pastCount).toBe(2); + expect(getParamState().value).toBe(99); + + // 3. Clear History immediately + store.dispatch(clearHistory()); + + expect(getUndoState().pastCount).toBe(0); + expect(getUndoState().futureCount).toBe(0); + expect(getParamState().value).toBe(99); + }); +}); diff --git a/src/redux/undo/undo-middleware.ts b/src/redux/undo/undo-middleware.ts new file mode 100644 index 000000000..4efad0014 --- /dev/null +++ b/src/redux/undo/undo-middleware.ts @@ -0,0 +1,91 @@ +import { Middleware } from 'redux'; +import { RootState } from '../index'; +import { setFullParam } from '../param/action'; // Use the thunk from action.ts +import { updateUndoCounts } from './undo-slice'; + +export const UNDO = 'UNDO'; +export const REDO = 'REDO'; +export const CLEAR_HISTORY = 'CLEAR_HISTORY'; + +export const undo = () => ({ type: UNDO }); +export const redo = () => ({ type: REDO }); +export const clearHistory = () => ({ type: CLEAR_HISTORY }); + +// We need to keep history outside of the redux store to avoid circular dependency +// or putting large objects in the store repeatedly if we used a slice. +// Ideally, this should be in a closure or a class. Given middleware is a function, we can use closure. + +export const createUndoMiddleware = (): Middleware => { + const past: any[] = []; + let future: any[] = []; + + // We need to track if the current action is caused by undo/redo to avoid loop + let isUndoing = false; + + return store => next => (action: any) => { + if (typeof action === 'function') { + return next(action); + } + + // Helper to update store counts + const updateCounts = () => { + store.dispatch(updateUndoCounts({ past: past.length, future: future.length })); + }; + + if (action.type === UNDO) { + const state = store.getState(); + if (past.length > 0) { + const previous = past.pop(); + future.push(state.param); + isUndoing = true; + // Dispatch the THUNK which updates helper as well + store.dispatch(setFullParam(previous) as any); + isUndoing = false; + updateCounts(); + } + return; + } + + if (action.type === REDO) { + const state = store.getState(); + if (future.length > 0) { + const nextState = future.pop(); + past.push(state.param); + isUndoing = true; + store.dispatch(setFullParam(nextState) as any); + isUndoing = false; + updateCounts(); + } + return; + } + + if (action.type === CLEAR_HISTORY) { + past.length = 0; + future.length = 0; + updateCounts(); + return; + } + + if (action.type && action.type.startsWith('param/') && !isUndoing) { + const state = store.getState(); + // Store param state before modification + past.push(state.param); + + // Limit history + if (past.length > 20) { + past.shift(); + } + + // Clear future on new action + future = []; + + // Allow the action to proceed first, then update counts? + // Actually, we capture state BEFORE action. + // But we can update counts now. + // past.length has increased by 1. + updateCounts(); + } + + return next(action); + }; +}; diff --git a/src/redux/undo/undo-slice.ts b/src/redux/undo/undo-slice.ts new file mode 100644 index 000000000..27f1199c3 --- /dev/null +++ b/src/redux/undo/undo-slice.ts @@ -0,0 +1,26 @@ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface UndoState { + pastCount: number; + futureCount: number; +} + +const initialState: UndoState = { + pastCount: 0, + futureCount: 0, +}; + +const undoSlice = createSlice({ + name: 'undo', + initialState, + reducers: { + updateUndoCounts: (state, action: PayloadAction<{ past: number; future: number }>) => { + state.pastCount = action.payload.past; + state.futureCount = action.payload.future; + }, + }, +}); + +export const { updateUndoCounts } = undoSlice.actions; +export default undoSlice.reducer; diff --git a/src/redux/undo/undoable.ts b/src/redux/undo/undoable.ts new file mode 100644 index 000000000..f3a2cd179 --- /dev/null +++ b/src/redux/undo/undoable.ts @@ -0,0 +1,79 @@ + +import { AnyAction, Reducer, UnknownAction } from '@reduxjs/toolkit'; + +// Actions +export const UNDO = 'UNDO'; +export const REDO = 'REDO'; + +export const undo = () => ({ type: UNDO }); +export const redo = () => ({ type: REDO }); + +export interface UndoableState { + past: T[]; + present: T; + future: T[]; +} + +export const undoable = (reducer: Reducer) => { + return function (state: UndoableState | undefined, action: AnyAction | UnknownAction): UndoableState { + if (!state) { + // Initialize + const initialState = reducer(undefined, action); + return { + past: [], + present: initialState, + future: [], + }; + } + + const { past, present, future } = state; + + switch (action.type) { + case UNDO: { + if (past.length === 0) return state; + const previous = past[past.length - 1]; + const newPast = past.slice(0, past.length - 1); + return { + past: newPast, + present: previous, + future: [present, ...future], + }; + } + case REDO: { + if (future.length === 0) return state; + const next = future[0]; + const newFuture = future.slice(1); + return { + past: [...past, present], + present: next, + future: newFuture, + }; + } + default: { + // Delegate to the wrapped reducer + const newPresent = reducer(present, action); + + if (present === newPresent) { + return state; // No change + } + + // If the action is an initialization or internal redux action, don't update history + // But typically we only want to track user actions. + // For now, let's just track everything that changes state. + + // Limit history size + const MAX_HISTORY = 50; + let nextPast = [...past, present]; + if (nextPast.length > MAX_HISTORY) { + nextPast = nextPast.slice(nextPast.length - MAX_HISTORY); + } + + return { + past: nextPast, + present: newPresent, + future: [], // Clear future on new action + }; + } + } + }; +}; From 3a891570ecbfea8cdf96d2dc83dc931021f83566 Mon Sep 17 00:00:00 2001 From: Umamichi Date: Sat, 21 Feb 2026 18:00:48 +0800 Subject: [PATCH 2/2] feat: simplify station deletion by removing confirmation modal --- .../modal/remove-confirm-modal.test.tsx | 120 ------------------ src/components/modal/remove-confirm-modal.tsx | 89 ------------- .../station-side-panel-footer.tsx | 33 +++-- 3 files changed, 25 insertions(+), 217 deletions(-) delete mode 100644 src/components/modal/remove-confirm-modal.test.tsx delete mode 100644 src/components/modal/remove-confirm-modal.tsx diff --git a/src/components/modal/remove-confirm-modal.test.tsx b/src/components/modal/remove-confirm-modal.test.tsx deleted file mode 100644 index 696d5fb98..000000000 --- a/src/components/modal/remove-confirm-modal.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { fireEvent, screen } from '@testing-library/react'; -import { SidePanelMode, StationDict } from '../../constants/constants'; -import { createTestStore } from '../../setupTests'; -import { render } from '../../test-utils'; -import RemoveConfirmModal from './remove-confirm-modal'; -import rootReducer from '../../redux'; -import { vi } from 'vitest'; - -const realStore = rootReducer.getState(); -const mockCallbacks = { - onClose: vi.fn(), -}; - -describe('RemoveConfirmModal', () => { - it('Can display error message if station is not removable', async () => { - /** - * stn1 - stn2 - * ^ - */ - const mockStationList = { - linestart: { - parents: [], - children: ['stn1'], - branch: { left: [], right: [] }, - }, - stn1: { - parents: ['linestart'], - children: ['stn2'], - branch: { left: [], right: [] }, - }, - stn2: { - parents: ['stn1'], - children: ['lineend'], - branch: { left: [], right: [] }, - }, - lineend: { - parents: ['stn2'], - children: [], - branch: { left: [], right: [] }, - }, - } as any as StationDict; - const mockStore = createTestStore({ - app: { - ...realStore.app, - selectedStation: 'stn1', - }, - param: { - ...realStore.param, - stn_list: mockStationList, - }, - }); - const prevState = mockStore.getState().param; - - render(, { store: mockStore }); - - // click confirm button - fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); - - // redux state not modified - expect(mockStore.getState().param).toEqual(prevState); - - // display error message - expect(screen.getByRole('alert')).toBeInTheDocument(); - expect(screen.getByRole('alert')).toHaveTextContent('Unable to remove this station.'); - }); - - it('Can remove station and clear states as expected', async () => { - /** - * stn1 - stn2 - stn3 - * ^ - */ - const mockStationList = { - linestart: { - parents: [], - children: ['stn1'], - branch: { left: [], right: [] }, - }, - stn1: { - parents: ['linestart'], - children: ['stn2'], - branch: { left: [], right: [] }, - }, - stn2: { - parents: ['stn1'], - children: ['stn3'], - branch: { left: [], right: [] }, - }, - stn3: { - parents: ['stn2'], - children: ['lineend'], - branch: { left: [], right: [] }, - }, - lineend: { - parents: ['stn3'], - children: [], - branch: { left: [], right: [] }, - }, - } as any as StationDict; - const mockStore = createTestStore({ - app: { - ...realStore.app, - selectedStation: 'stn2', - }, - param: { - ...realStore.param, - stn_list: mockStationList, - }, - }); - - render(, { store: mockStore }); - - // click confirm button - fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); - - // assertions - expect(mockStore.getState().param.stn_list).not.toHaveProperty('stn2'); // removal of station - expect(mockStore.getState().app.sidePanelMode).toBe(SidePanelMode.CLOSE); // close side panel - expect(mockStore.getState().app.selectedStation).toBe('linestart'); // reset station selection - }); -}); diff --git a/src/components/modal/remove-confirm-modal.tsx b/src/components/modal/remove-confirm-modal.tsx deleted file mode 100644 index b5c3ee209..000000000 --- a/src/components/modal/remove-confirm-modal.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useEffect, useState } from 'react'; -import { - Alert, - AlertIcon, - Box, - Button, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, -} from '@chakra-ui/react'; -import { useTranslation } from 'react-i18next'; -import { checkStationCouldBeRemoved, removeStation } from '../../redux/param/remove-station-action'; -import { setSelectedStation, setSidePanelMode } from '../../redux/app/app-slice'; -import { useRootDispatch, useRootSelector } from '../../redux'; -import { Events, SidePanelMode } from '../../constants/constants'; -import { removeInvalidColineOnRemoveStation } from '../../redux/param/coline-action'; -import rmgRuntime from '@railmapgen/rmg-runtime'; - -interface RemoveConfirmModalProps { - isOpen: boolean; - onClose: () => void; -} - -export default function RemoveConfirmModal(props: RemoveConfirmModalProps) { - const { isOpen, onClose } = props; - const { t } = useTranslation(); - - const dispatch = useRootDispatch(); - const selectedStation = useRootSelector(state => state.app.selectedStation); - - const [error, setError] = useState(false); - - useEffect(() => { - if (!isOpen) { - setError(false); - } - }, [isOpen]); - - const handleConfirm = () => { - const result = dispatch(checkStationCouldBeRemoved(selectedStation)); - if (result) { - onClose(); - - // close side panel - dispatch(setSidePanelMode(SidePanelMode.CLOSE)); - - // reset selected station - dispatch(setSelectedStation('linestart')); - - dispatch(removeInvalidColineOnRemoveStation(selectedStation)); - dispatch(removeStation(selectedStation)); - } else { - setError(true); - } - - rmgRuntime.event(Events.REMOVE_STATION, { success: result }); - }; - - return ( - - - - {error && ( - - - {t('Unable to remove this station.')} - - )} - - {t('Remove station')} - - - - {t('Are you sure to remove station? You cannot undo this action.')} - - - - - - - - ); -} diff --git a/src/components/side-panel/station-side-panel/station-side-panel-footer.tsx b/src/components/side-panel/station-side-panel/station-side-panel-footer.tsx index f1dc9cfe1..4e663c099 100644 --- a/src/components/side-panel/station-side-panel/station-side-panel-footer.tsx +++ b/src/components/side-panel/station-side-panel/station-side-panel-footer.tsx @@ -1,20 +1,39 @@ -import { Button, HStack } from '@chakra-ui/react'; +import { Button, HStack, useToast } from '@chakra-ui/react'; import { RmgSidePanelFooter } from '@railmapgen/rmg-components'; -import { useState } from 'react'; import { useRootDispatch, useRootSelector } from '../../../redux'; -import RemoveConfirmModal from '../../modal/remove-confirm-modal'; import { setCurrentStation, setLoopMidpointStation } from '../../../redux/param/param-slice'; import { useTranslation } from 'react-i18next'; -import { RmgStyle } from '../../../constants/constants'; +import { Events, RmgStyle, SidePanelMode } from '../../../constants/constants'; +import { checkStationCouldBeRemoved, removeStation } from '../../../redux/param/remove-station-action'; +import { setSelectedStation, setSidePanelMode } from '../../../redux/app/app-slice'; +import { removeInvalidColineOnRemoveStation } from '../../../redux/param/coline-action'; +import rmgRuntime from '@railmapgen/rmg-runtime'; export default function StationSidePanelFooter() { const { t } = useTranslation(); const dispatch = useRootDispatch(); + const toast = useToast(); const { selectedStation } = useRootSelector(state => state.app); const { loop, style } = useRootSelector(state => state.param); - const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false); + const handleRemove = () => { + const result = dispatch(checkStationCouldBeRemoved(selectedStation)); + if (result) { + dispatch(setSidePanelMode(SidePanelMode.CLOSE)); + dispatch(setSelectedStation('linestart')); + dispatch(removeInvalidColineOnRemoveStation(selectedStation)); + dispatch(removeStation(selectedStation)); + } else { + toast({ + title: t('Unable to remove this station.'), + status: 'error', + duration: 3000, + isClosable: true, + }); + } + rmgRuntime.event(Events.REMOVE_STATION, { success: result }); + }; return ( @@ -31,12 +50,10 @@ export default function StationSidePanelFooter() { {t('Set as midpoint')} )} - - - setIsRemoveModalOpen(false)} /> ); }