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/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/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/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')}
)}
-
);
}
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