create-pixi-vn 2.0.14 → 2.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/template-react-vite-muijoy/package-lock.json +12 -8
  3. package/template-react-vite-muijoy/package.json +1 -1
  4. package/template-react-vite-muijoy/src/components/loading.tsx +11 -1
  5. package/template-react-vite-muijoy/src/components/menus/choice-menus.tsx +4 -0
  6. package/template-react-vite-muijoy/src/components/menus/main-menu.tsx +1 -6
  7. package/template-react-vite-muijoy/src/components/menus/settings/index.tsx +3 -1
  8. package/template-react-vite-muijoy/src/components/modals/input-request-dialogues.tsx +1 -1
  9. package/template-react-vite-muijoy/src/components/modals/quick-actions-wheel.tsx +3 -1
  10. package/template-react-vite-muijoy/src/components/providers/alert-dialog-provider.tsx +3 -0
  11. package/template-react-vite-muijoy/src/components/screens/narration/narration-cards.tsx +40 -19
  12. package/template-react-vite-muijoy/src/lib/hooks/hotkeys-hooks.ts +111 -10
  13. package/template-react-vite-muijoy/src/lib/hooks/narration-hooks.ts +7 -1
  14. package/template-react-vite-muijoy/src/lib/query/narration-query.ts +32 -2
  15. package/template-react-vite-muijoy/src/lib/stores/alert-dialog-store.ts +13 -0
  16. package/template-react-vite-muijoy/src/lib/stores/text-display-settings-store.ts +20 -1
  17. package/template-react-vite-muijoy/src/locales/en.json +2 -0
  18. package/template-react-vite-muijoy/src/routes/__root.tsx +3 -4
  19. package/template-react-vite-muijoy-ink/package-lock.json +12 -8
  20. package/template-react-vite-muijoy-ink/package.json +1 -1
  21. package/template-react-vite-muijoy-ink/src/components/loading.tsx +11 -1
  22. package/template-react-vite-muijoy-ink/src/components/menus/choice-menus.tsx +4 -0
  23. package/template-react-vite-muijoy-ink/src/components/menus/main-menu.tsx +1 -6
  24. package/template-react-vite-muijoy-ink/src/components/menus/settings/index.tsx +3 -1
  25. package/template-react-vite-muijoy-ink/src/components/modals/input-request-dialogues.tsx +1 -1
  26. package/template-react-vite-muijoy-ink/src/components/modals/quick-actions-wheel.tsx +3 -1
  27. package/template-react-vite-muijoy-ink/src/components/providers/alert-dialog-provider.tsx +3 -0
  28. package/template-react-vite-muijoy-ink/src/components/screens/narration/narration-cards.tsx +40 -19
  29. package/template-react-vite-muijoy-ink/src/lib/hooks/hotkeys-hooks.ts +111 -10
  30. package/template-react-vite-muijoy-ink/src/lib/hooks/narration-hooks.ts +7 -1
  31. package/template-react-vite-muijoy-ink/src/lib/query/narration-query.ts +32 -2
  32. package/template-react-vite-muijoy-ink/src/lib/stores/alert-dialog-store.ts +13 -0
  33. package/template-react-vite-muijoy-ink/src/lib/stores/text-display-settings-store.ts +20 -1
  34. package/template-react-vite-muijoy-ink/src/locales/en.json +2 -0
  35. package/template-react-vite-muijoy-ink/src/routes/__root.tsx +3 -4
  36. package/template-react-vite-muijoy-ink-tauri/package-lock.json +12 -8
  37. package/template-react-vite-muijoy-ink-tauri/package.json +1 -1
  38. package/template-react-vite-muijoy-ink-tauri/src/components/loading.tsx +11 -1
  39. package/template-react-vite-muijoy-ink-tauri/src/components/menus/choice-menus.tsx +4 -0
  40. package/template-react-vite-muijoy-ink-tauri/src/components/menus/main-menu.tsx +1 -6
  41. package/template-react-vite-muijoy-ink-tauri/src/components/menus/settings/index.tsx +3 -1
  42. package/template-react-vite-muijoy-ink-tauri/src/components/modals/input-request-dialogues.tsx +1 -1
  43. package/template-react-vite-muijoy-ink-tauri/src/components/modals/quick-actions-wheel.tsx +3 -1
  44. package/template-react-vite-muijoy-ink-tauri/src/components/providers/alert-dialog-provider.tsx +3 -0
  45. package/template-react-vite-muijoy-ink-tauri/src/components/screens/narration/narration-cards.tsx +40 -19
  46. package/template-react-vite-muijoy-ink-tauri/src/lib/hooks/hotkeys-hooks.ts +111 -10
  47. package/template-react-vite-muijoy-ink-tauri/src/lib/hooks/narration-hooks.ts +7 -1
  48. package/template-react-vite-muijoy-ink-tauri/src/lib/query/narration-query.ts +32 -2
  49. package/template-react-vite-muijoy-ink-tauri/src/lib/stores/alert-dialog-store.ts +13 -0
  50. package/template-react-vite-muijoy-ink-tauri/src/lib/stores/text-display-settings-store.ts +20 -1
  51. package/template-react-vite-muijoy-ink-tauri/src/locales/en.json +2 -0
  52. package/template-react-vite-muijoy-ink-tauri/src/routes/__root.tsx +3 -4
  53. package/template-react-vite-muijoy-tauri/package-lock.json +12 -8
  54. package/template-react-vite-muijoy-tauri/package.json +1 -1
  55. package/template-react-vite-muijoy-tauri/src/components/loading.tsx +11 -1
  56. package/template-react-vite-muijoy-tauri/src/components/menus/choice-menus.tsx +4 -0
  57. package/template-react-vite-muijoy-tauri/src/components/menus/main-menu.tsx +1 -6
  58. package/template-react-vite-muijoy-tauri/src/components/menus/settings/index.tsx +3 -1
  59. package/template-react-vite-muijoy-tauri/src/components/modals/input-request-dialogues.tsx +1 -1
  60. package/template-react-vite-muijoy-tauri/src/components/modals/quick-actions-wheel.tsx +3 -1
  61. package/template-react-vite-muijoy-tauri/src/components/providers/alert-dialog-provider.tsx +3 -0
  62. package/template-react-vite-muijoy-tauri/src/components/screens/narration/narration-cards.tsx +40 -19
  63. package/template-react-vite-muijoy-tauri/src/lib/hooks/hotkeys-hooks.ts +111 -10
  64. package/template-react-vite-muijoy-tauri/src/lib/hooks/narration-hooks.ts +7 -1
  65. package/template-react-vite-muijoy-tauri/src/lib/query/narration-query.ts +32 -2
  66. package/template-react-vite-muijoy-tauri/src/lib/stores/alert-dialog-store.ts +13 -0
  67. package/template-react-vite-muijoy-tauri/src/lib/stores/text-display-settings-store.ts +20 -1
  68. package/template-react-vite-muijoy-tauri/src/locales/en.json +2 -0
  69. package/template-react-vite-muijoy-tauri/src/routes/__root.tsx +3 -4
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-pixi-vn",
3
3
  "description": "Create a new Pixi’VN project",
4
- "version": "2.0.14",
4
+ "version": "2.0.15",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0",
7
7
  "author": "DRincs-Productions",
@@ -34,7 +34,7 @@
34
34
  "react-dom": "^19.2.6",
35
35
  "react-i18next": "^17.0.8",
36
36
  "react-markdown": "^10.1.0",
37
- "react-markdown-typewriter": "^1.2.0",
37
+ "react-markdown-typewriter": "^1.3.4",
38
38
  "react-resizable-panels": "^4.11.2",
39
39
  "rehype-raw": "^7.0.0",
40
40
  "remark-gfm": "^4.0.1",
@@ -9929,6 +9929,7 @@
9929
9929
  "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
9930
9930
  "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
9931
9931
  "license": "MIT",
9932
+ "peer": true,
9932
9933
  "dependencies": {
9933
9934
  "motion-dom": "^12.38.0",
9934
9935
  "motion-utils": "^12.36.0",
@@ -13592,6 +13593,7 @@
13592
13593
  "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
13593
13594
  "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
13594
13595
  "license": "MIT",
13596
+ "peer": true,
13595
13597
  "dependencies": {
13596
13598
  "framer-motion": "^12.38.0",
13597
13599
  "tslib": "^2.4.0"
@@ -13618,6 +13620,7 @@
13618
13620
  "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
13619
13621
  "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
13620
13622
  "license": "MIT",
13623
+ "peer": true,
13621
13624
  "dependencies": {
13622
13625
  "motion-utils": "^12.36.0"
13623
13626
  }
@@ -13626,7 +13629,8 @@
13626
13629
  "version": "12.36.0",
13627
13630
  "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
13628
13631
  "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
13629
- "license": "MIT"
13632
+ "license": "MIT",
13633
+ "peer": true
13630
13634
  },
13631
13635
  "node_modules/ms": {
13632
13636
  "version": "2.1.3",
@@ -14713,12 +14717,12 @@
14713
14717
  }
14714
14718
  },
14715
14719
  "node_modules/react-markdown-typewriter": {
14716
- "version": "1.2.0",
14717
- "resolved": "https://registry.npmjs.org/react-markdown-typewriter/-/react-markdown-typewriter-1.2.0.tgz",
14718
- "integrity": "sha512-jztq8XBfYB+CwG/fRk3VxE8A63LhVbcO7ZqjCYCg97cbn3Kl+hUFyA57LJ4vxG2b7z/FCqCuE/9hzBOhNiyzeA==",
14719
- "dependencies": {
14720
- "motion": "^12.38.0"
14721
- },
14720
+ "version": "1.3.4",
14721
+ "resolved": "https://registry.npmjs.org/react-markdown-typewriter/-/react-markdown-typewriter-1.3.4.tgz",
14722
+ "integrity": "sha512-LE/7xzyta4A6Gz6CfeTP0saCgVsxbCEVj4le4xobrkM/IG9MVWdrpyGFGFdsKmZ1k5otnRF3HP52YXbMv7P1rg==",
14723
+ "workspaces": [
14724
+ "playground"
14725
+ ],
14722
14726
  "peerDependencies": {
14723
14727
  "motion": ">=12.9.0",
14724
14728
  "react": ">=18.0.0",
@@ -43,7 +43,7 @@
43
43
  "react-dom": "^19.2.6",
44
44
  "react-i18next": "^17.0.8",
45
45
  "react-markdown": "^10.1.0",
46
- "react-markdown-typewriter": "^1.2.0",
46
+ "react-markdown-typewriter": "^1.3.4",
47
47
  "react-resizable-panels": "^4.11.2",
48
48
  "rehype-raw": "^7.0.0",
49
49
  "remark-gfm": "^4.0.1",
@@ -1,4 +1,5 @@
1
1
  import { Spinner } from "@/components/ui/spinner";
2
+ import { useEffect, useState } from "react";
2
3
 
3
4
  export function PendingComponent() {
4
5
  return (
@@ -12,10 +13,19 @@ export function PendingComponent() {
12
13
 
13
14
  export function AnimatedDots() {
14
15
  return (
15
- <span className="flex pointer-events-none select-none">
16
+ <span className="inline-flex pointer-events-none select-none">
16
17
  <span className="animate-bounce [animation-delay:0s]">.</span>
17
18
  <span className="animate-bounce [animation-delay:0.2s]">.</span>
18
19
  <span className="animate-bounce [animation-delay:0.4s]">.</span>
19
20
  </span>
20
21
  );
21
22
  }
23
+
24
+ export function DelayedAnimatedDots({ delay = 300 }: { delay?: number }) {
25
+ const [visible, setVisible] = useState(false);
26
+ useEffect(() => {
27
+ const id = setTimeout(() => setVisible(true), delay);
28
+ return () => clearTimeout(id);
29
+ }, [delay]);
30
+ return visible ? <AnimatedDots /> : null;
31
+ }
@@ -1,4 +1,5 @@
1
1
  import { Button } from "@/components/ui/button";
2
+ import { useChoiceMenuHotkeys } from "@/lib/hooks/hotkeys-hooks";
2
3
  import { useNarrationFunctions } from "@/lib/hooks/narration-hooks";
3
4
  import { useQueryChoiceMenuOptions } from "@/lib/query/narration-query";
4
5
  import { GameStatus } from "@/lib/stores/game-status-store";
@@ -13,9 +14,11 @@ export function ChoiceMenu() {
13
14
  const isTyping = useSelector(TextDisplaySettings.store, (state) => state.inProgress);
14
15
  const { selectChoice } = useNarrationFunctions();
15
16
  const [debouncedMenu] = useDebouncedValue(isTyping ? [] : menu, { wait: 50 });
17
+ const { menuRef } = useChoiceMenuHotkeys(debouncedMenu.length);
16
18
 
17
19
  return (
18
20
  <div
21
+ ref={menuRef}
19
22
  className="flex flex-col items-center justify-center gap-2 w-full h-full overflow-auto"
20
23
  role="menu"
21
24
  >
@@ -28,6 +31,7 @@ export function ChoiceMenu() {
28
31
  style={{ animationDelay: `${index * 150}ms` }}
29
32
  >
30
33
  <Button
34
+ role="menuitem"
31
35
  disabled={loading}
32
36
  onClick={() => selectChoice(item)}
33
37
  size="sm"
@@ -26,7 +26,6 @@ const menuButtonClass =
26
26
  "justify-start hover:scale-105 focus-visible:scale-105 transition-transform duration-150 ease-out";
27
27
 
28
28
  export function MainMenu() {
29
- const queryClient = useQueryClient();
30
29
  const gameProps = useGameProps();
31
30
  const { uiTransition: t, navigate } = gameProps;
32
31
  const setSettings = useSetSearchParamState<boolean>("settings");
@@ -147,11 +146,7 @@ export function MainMenu() {
147
146
  setLoading(true);
148
147
  await navigate({ to: "/game/narration" });
149
148
  Game.start("start", gameProps)
150
- .then(() =>
151
- queryClient.invalidateQueries({
152
- queryKey: [INTERFACE_DATA_USE_QUERY_KEY],
153
- }),
154
- )
149
+ .then(() => gameProps.invalidateInterfaceData())
155
150
  .finally(() => setLoading(false));
156
151
  }}
157
152
  disabled={loading}
@@ -208,7 +208,9 @@ export function SettingsDialogue() {
208
208
  onOpenChange={(isOpen) => {
209
209
  setOpen(isOpen || undefined);
210
210
  if (!isOpen) {
211
- setSettingsTab(undefined);
211
+ // Delay clearing the tab until after the 100ms close animation
212
+ // to avoid briefly flashing the main settings view during close.
213
+ setTimeout(() => setSettingsTab(undefined), 150);
212
214
  }
213
215
  }}
214
216
  >
@@ -14,7 +14,7 @@ import rehypeRaw from "rehype-raw";
14
14
  import remarkGfm from "remark-gfm";
15
15
 
16
16
  export function InputRequestDialog() {
17
- const { data: { animatedText: text } = {} } = useQueryDialogue();
17
+ const { data: { lastText: text } = {} } = useQueryDialogue();
18
18
  const {
19
19
  data: { isRequired, type, currentValue } = { currentValue: undefined, isRequired: false },
20
20
  } = useQueryInputValue<string | number>();
@@ -223,7 +223,9 @@ function QuickActionsWheelContent({
223
223
  >
224
224
  <span className="font-semibold">{registration.label}</span>
225
225
  <span className="text-[11px] opacity-85">
226
- {registration.hotkeyLabel}
226
+ {registration.hotkeyLabel.length > 15
227
+ ? `${registration.hotkeyLabel.slice(0, 12)}…`
228
+ : registration.hotkeyLabel}
227
229
  </span>
228
230
  </button>
229
231
  );
@@ -1,3 +1,4 @@
1
+ import { AlertDialogState } from "@/lib/stores/alert-dialog-store";
1
2
  import { createContext, type ReactNode, useCallback, useContext, useState } from "react";
2
3
  import { useTranslation } from "react-i18next";
3
4
  import {
@@ -47,6 +48,7 @@ export function AlertDialogProvider({ children }: { children: ReactNode }) {
47
48
 
48
49
  const closeDialog = useCallback((id: string) => {
49
50
  setDialogs((prev) => prev.map((d) => (d.id === id ? { ...d, open: false } : d)));
51
+ AlertDialogState.decrement();
50
52
  setTimeout(() => {
51
53
  setDialogs((prev) => prev.filter((d) => d.id !== id));
52
54
  }, DIALOG_CLOSE_ANIMATION_DURATION_MS);
@@ -54,6 +56,7 @@ export function AlertDialogProvider({ children }: { children: ReactNode }) {
54
56
 
55
57
  const openAlertDialog = useCallback((options: AlertDialogOptions) => {
56
58
  const id = generateId();
59
+ AlertDialogState.increment();
57
60
  setDialogs((prev) => [...prev, { id, open: true, loading: false, options }]);
58
61
  }, []);
59
62
 
@@ -1,4 +1,4 @@
1
- import { AnimatedDots } from "@/components/loading";
1
+ import { DelayedAnimatedDots } from "@/components/loading";
2
2
  import { QuickTools } from "@/components/quick-tools";
3
3
  import { Card, CardContent } from "@/components/ui/card";
4
4
  import { Image } from "@/components/ui/image";
@@ -8,7 +8,7 @@ import { useNarrationPointerHandlers } from "@/lib/hooks/narration-hooks";
8
8
  import { useQueryDialogue } from "@/lib/query/narration-query";
9
9
  import { TextDisplaySettings } from "@/lib/stores/text-display-settings-store";
10
10
  import { useSelector } from "@tanstack/react-store";
11
- import { type RefObject, useCallback, useRef } from "react";
11
+ import { type RefObject, memo, useCallback, useMemo, useRef } from "react";
12
12
  import Markdown from "react-markdown";
13
13
  import { MarkdownTypewriterHooks } from "react-markdown-typewriter";
14
14
  import rehypeRaw from "rehype-raw";
@@ -63,9 +63,13 @@ export function NarrationCards() {
63
63
  );
64
64
  }
65
65
 
66
- export function Text({ paragraphRef }: { paragraphRef: RefObject<HTMLDivElement | null> }) {
66
+ export const Text = memo(function Text({
67
+ paragraphRef,
68
+ }: {
69
+ paragraphRef: RefObject<HTMLDivElement | null>;
70
+ }) {
67
71
  const typewriterDelay = useSelector(TextDisplaySettings.store, (state) => state.delay);
68
- const { data: { animatedText, text } = {} } = useQueryDialogue();
72
+ const { data: { animatedText, text } = {}, finalizeDialogue } = useQueryDialogue();
69
73
 
70
74
  const handleCharacterAnimationComplete = useCallback(
71
75
  (ref: { current: HTMLSpanElement | null }) => {
@@ -74,7 +78,6 @@ export function Text({ paragraphRef }: { paragraphRef: RefObject<HTMLDivElement
74
78
  if (container && char) {
75
79
  const containerRect = container.getBoundingClientRect();
76
80
  const charRect = char.getBoundingClientRect();
77
- // Position of the character relative to the top of the scroll container
78
81
  const charOffsetInContainer =
79
82
  charRect.top - containerRect.top + container.scrollTop;
80
83
  const scrollTop = charOffsetInContainer - container.clientHeight / 2;
@@ -87,15 +90,40 @@ export function Text({ paragraphRef }: { paragraphRef: RefObject<HTMLDivElement
87
90
  [paragraphRef],
88
91
  );
89
92
 
93
+ const markdownComponents = useMemo(() => ({ p: (props: object) => <span {...props} /> }), []);
94
+
95
+ const motionProps = useMemo(
96
+ () => ({
97
+ onAnimationStart: TextDisplaySettings.start,
98
+ onAnimationComplete: (definition: "visible" | "hidden") => {
99
+ if (definition === "visible") {
100
+ finalizeDialogue();
101
+ TextDisplaySettings.end();
102
+ }
103
+ },
104
+ onCharacterAnimationComplete: handleCharacterAnimationComplete,
105
+ }),
106
+ [finalizeDialogue, handleCharacterAnimationComplete],
107
+ );
108
+
109
+ const specialCharacters = useMemo(
110
+ () => ({
111
+ ".": { delayAfter: typewriterDelay + 300 },
112
+ "!": { delayAfter: typewriterDelay + 300 },
113
+ "?": { delayAfter: typewriterDelay + 300 },
114
+ ",": { delayAfter: typewriterDelay + 75 },
115
+ ":": { delay: typewriterDelay + 50, delayAfter: typewriterDelay + 150 },
116
+ }),
117
+ [typewriterDelay],
118
+ );
119
+
90
120
  return (
91
121
  <p className="prose dark:prose-invert m-0 max-w-full p-0">
92
122
  <span>
93
123
  <Markdown
94
124
  remarkPlugins={[remarkGfm]}
95
125
  rehypePlugins={[rehypeRaw]}
96
- components={{
97
- p: (props) => <span {...props} />,
98
- }}
126
+ components={markdownComponents}
99
127
  >
100
128
  {text}
101
129
  </Markdown>
@@ -106,23 +134,16 @@ export function Text({ paragraphRef }: { paragraphRef: RefObject<HTMLDivElement
106
134
  remarkPlugins={[remarkGfm]}
107
135
  rehypePlugins={[rehypeRaw]}
108
136
  delay={typewriterDelay}
109
- motionProps={{
110
- onAnimationStart: TextDisplaySettings.start,
111
- onAnimationComplete: (definition: "visible" | "hidden") => {
112
- if (definition === "visible") {
113
- TextDisplaySettings.end();
114
- }
115
- },
116
- onCharacterAnimationComplete: handleCharacterAnimationComplete,
117
- }}
118
- fallback={<AnimatedDots />}
137
+ motionProps={motionProps}
138
+ fallback={<DelayedAnimatedDots />}
139
+ specialCharacters={specialCharacters}
119
140
  >
120
141
  {animatedText}
121
142
  </MarkdownTypewriterHooks>
122
143
  </span>
123
144
  </p>
124
145
  );
125
- }
146
+ });
126
147
 
127
148
  export function CharacterIcon({ alt, icon }: { icon: string; alt: string }) {
128
149
  return (
@@ -2,22 +2,47 @@ import { useAlertDialog } from "@/components/providers/alert-dialog-provider";
2
2
  import { useNarrationFunctions } from "@/lib/hooks/narration-hooks";
3
3
  import { useSetSearchParamState } from "@/lib/hooks/navigation-hooks";
4
4
  import { useGameProps } from "@/lib/hooks/props-hooks";
5
- import { useQueryInputValue } from "@/lib/query/narration-query";
5
+ import { useQueryCanGoNext, useQueryInputValue } from "@/lib/query/narration-query";
6
6
  import {
7
7
  LAST_SAVE_USE_QUERY_KEY,
8
8
  SAVES_USE_QUERY_KEY,
9
9
  useQueryLastSave,
10
10
  } from "@/lib/query/save-query";
11
+ import { AlertDialogState } from "@/lib/stores/alert-dialog-store";
11
12
  import { QuickActionsWheelState } from "@/lib/stores/quick-actions-wheel-store";
13
+ import { SearchParams } from "@/lib/stores/search-param-store";
12
14
  import { SkipSettings } from "@/lib/stores/skip-settings-store";
15
+ import { TextDisplaySettings } from "@/lib/stores/text-display-settings-store";
13
16
  import { loadSave, saveGameToIndexDB } from "@/lib/utils/save-utility";
17
+ import { narration } from "@drincs/pixi-vn";
14
18
  import { useHotkeys } from "@tanstack/react-hotkeys";
15
19
  import { useQueryClient } from "@tanstack/react-query";
16
20
  import { useLocation } from "@tanstack/react-router";
17
- import { useCallback } from "react";
21
+ import { useSelector } from "@tanstack/react-store";
22
+ import { useCallback, useMemo, useRef } from "react";
18
23
  import { useTranslation } from "react-i18next";
19
24
  import { toast } from "sonner";
20
25
 
26
+ function useMenuDialogState() {
27
+ const searchParams = useSelector(SearchParams.store, (state) => state);
28
+ const alertDialogCount = useSelector(AlertDialogState.store, (state) => state.count);
29
+ const { data: { isRequired } = {} } = useQueryInputValue<string | number>();
30
+
31
+ /** true when the settings menu (or any sub-page) is open */
32
+ const isSettingsOpen = useMemo(
33
+ () => Object.values(searchParams).some((v) => v !== undefined),
34
+ [searchParams],
35
+ );
36
+
37
+ /** true when an alert confirmation dialog or the input-request dialog is blocking interaction */
38
+ const isAlertOrInputOpen = alertDialogCount > 0 || !!isRequired;
39
+
40
+ /** true when any menu or dialog that should disable hotkeys is open */
41
+ const isAnyMenuOrDialogOpen = isSettingsOpen || isAlertOrInputOpen;
42
+
43
+ return { isAnyMenuOrDialogOpen, isSettingsOpen, isAlertOrInputOpen };
44
+ }
45
+
21
46
  /**
22
47
  * useSaveHotkeys
23
48
  *
@@ -40,6 +65,7 @@ export function useSaveHotkeys(): null {
40
65
  const { data: lastSave = null } = useQueryLastSave();
41
66
  const { openAlertDialog } = useAlertDialog();
42
67
  const gameProps = useGameProps();
68
+ const { isSettingsOpen } = useMenuDialogState();
43
69
 
44
70
  const quickSave = useCallback(() => {
45
71
  if (location.pathname === "/") {
@@ -87,6 +113,7 @@ export function useSaveHotkeys(): null {
87
113
  hotkey: "F5",
88
114
  callback: quickSave,
89
115
  options: {
116
+ enabled: !isSettingsOpen,
90
117
  meta: {
91
118
  name: t("quick_save"),
92
119
  description: t("quick_save_hotkey_description"),
@@ -97,6 +124,7 @@ export function useSaveHotkeys(): null {
97
124
  hotkey: "Control+S",
98
125
  callback: quickSave,
99
126
  options: {
127
+ enabled: !isSettingsOpen,
100
128
  meta: {
101
129
  name: t("quick_save"),
102
130
  description: t("quick_save_hotkey_alternative_description"),
@@ -107,6 +135,7 @@ export function useSaveHotkeys(): null {
107
135
  hotkey: "F9",
108
136
  callback: quickLoad,
109
137
  options: {
138
+ enabled: !isSettingsOpen,
110
139
  meta: {
111
140
  name: t("load_last_save"),
112
141
  description: t("quick_load_hotkey_description"),
@@ -117,6 +146,7 @@ export function useSaveHotkeys(): null {
117
146
  hotkey: "Control+L",
118
147
  callback: quickLoad,
119
148
  options: {
149
+ enabled: !isSettingsOpen,
120
150
  meta: {
121
151
  name: t("load_last_save"),
122
152
  description: t("quick_load_hotkey_alternative_description"),
@@ -132,6 +162,7 @@ export function useSettingsHotkeys(): null {
132
162
  const { t } = useTranslation(["ui"]);
133
163
  const setSettingsOpen = useSetSearchParamState<boolean>("settings");
134
164
  const setSettingsTab = useSetSearchParamState<string>("settings_tab");
165
+ const { isAnyMenuOrDialogOpen } = useMenuDialogState();
135
166
 
136
167
  const openControlsPage = useCallback(() => {
137
168
  setSettingsOpen(true);
@@ -150,9 +181,10 @@ export function useSettingsHotkeys(): null {
150
181
  },
151
182
  },
152
183
  {
153
- hotkey: "Control+K",
184
+ hotkey: "K",
154
185
  callback: openControlsPage,
155
186
  options: {
187
+ enabled: !isAnyMenuOrDialogOpen,
156
188
  meta: {
157
189
  name: t("hotkeys_menu"),
158
190
  description: t("hotkeys_menu_shortcut_description"),
@@ -169,6 +201,7 @@ export function useGameHotkeys(): null {
169
201
  const setSettingsTab = useSetSearchParamState<string>("settings_tab");
170
202
  const setHistory = useSetSearchParamState<boolean>("history");
171
203
  const { t } = useTranslation(["ui"]);
204
+ const { isAnyMenuOrDialogOpen } = useMenuDialogState();
172
205
 
173
206
  const openHistoryPage = useCallback(() => {
174
207
  setHistory(undefined);
@@ -187,9 +220,10 @@ export function useGameHotkeys(): null {
187
220
 
188
221
  useHotkeys([
189
222
  {
190
- hotkey: "Control+H",
223
+ hotkey: "H",
191
224
  callback: openHistoryPage,
192
225
  options: {
226
+ enabled: !isAnyMenuOrDialogOpen,
193
227
  meta: {
194
228
  name: t("history"),
195
229
  description: t("history_hotkey_description"),
@@ -200,6 +234,7 @@ export function useGameHotkeys(): null {
200
234
  hotkey: "Tab",
201
235
  callback: toggleQuickActionsWheel,
202
236
  options: {
237
+ enabled: !isAnyMenuOrDialogOpen,
203
238
  meta: {
204
239
  name: t("quick_actions"),
205
240
  description: t("quick_actions_open_description"),
@@ -211,38 +246,104 @@ export function useGameHotkeys(): null {
211
246
  return null;
212
247
  }
213
248
 
249
+ export function useChoiceMenuHotkeys(menuLength: number) {
250
+ const { t } = useTranslation(["ui"]);
251
+ const menuRef = useRef<HTMLDivElement>(null);
252
+
253
+ function getMenuItems(): HTMLButtonElement[] {
254
+ if (!menuRef.current) return [];
255
+ return Array.from(
256
+ menuRef.current.querySelectorAll<HTMLButtonElement>(
257
+ "button[role='menuitem']:not(:disabled)",
258
+ ),
259
+ );
260
+ }
261
+
262
+ function focusMenuItem(direction: "up" | "down") {
263
+ const items = getMenuItems();
264
+ if (!items.length) return;
265
+ const active = document.activeElement as HTMLElement;
266
+ const currentIndex = items.indexOf(active as HTMLButtonElement);
267
+ let next: number;
268
+ if (direction === "down") {
269
+ next = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
270
+ } else {
271
+ next = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
272
+ }
273
+ items[next].focus();
274
+ }
275
+
276
+ useHotkeys([
277
+ {
278
+ hotkey: "ArrowDown",
279
+ callback: () => focusMenuItem("down"),
280
+ options: {
281
+ enabled: menuLength > 0,
282
+ preventDefault: true,
283
+ meta: {
284
+ name: t("choice_navigation"),
285
+ description: t("choice_navigation_description"),
286
+ },
287
+ },
288
+ },
289
+ {
290
+ hotkey: "ArrowUp",
291
+ callback: () => focusMenuItem("up"),
292
+ options: {
293
+ enabled: menuLength > 0,
294
+ preventDefault: true,
295
+ meta: {
296
+ name: t("choice_navigation"),
297
+ description: t("choice_navigation_description"),
298
+ },
299
+ },
300
+ },
301
+ ]);
302
+
303
+ return { menuRef };
304
+ }
305
+
214
306
  export function useNarrationHotkeys(): null {
215
307
  const { t } = useTranslation(["ui"]);
216
308
  const { goNext } = useNarrationFunctions();
217
- const { data: { isRequired } = {} } = useQueryInputValue<string | number>();
309
+ const typewriterInProgress = useSelector(
310
+ TextDisplaySettings.store,
311
+ (state) => state.inProgress,
312
+ );
313
+ const { isAnyMenuOrDialogOpen } = useMenuDialogState();
314
+ const { data: canGoNext } = useQueryCanGoNext();
218
315
 
219
316
  const onSkipKeyDown = useCallback(() => SkipSettings.setEnabled(true), []);
220
317
  const onSkipKeyUp = useCallback(() => {
221
318
  SkipSettings.setEnabled(false);
319
+ if (typewriterInProgress && !narration.dialogGlue) {
320
+ TextDisplaySettings.complete();
321
+ return;
322
+ }
222
323
  goNext();
223
- }, [goNext]);
324
+ }, [goNext, typewriterInProgress]);
224
325
 
225
326
  useHotkeys([
226
327
  {
227
328
  hotkey: "Enter",
228
329
  callback: onSkipKeyDown,
229
330
  options: {
331
+ enabled: !isAnyMenuOrDialogOpen && canGoNext,
230
332
  meta: {
231
333
  name: t("skip"),
232
334
  description: t("skip_hold_description"),
233
335
  },
234
- enabled: !isRequired,
235
336
  },
236
337
  },
237
338
  {
238
339
  hotkey: "Space",
239
340
  callback: onSkipKeyDown,
240
341
  options: {
342
+ enabled: !isAnyMenuOrDialogOpen && canGoNext,
241
343
  meta: {
242
344
  name: t("skip"),
243
345
  description: t("skip_hold_space_description"),
244
346
  },
245
- enabled: !isRequired,
246
347
  },
247
348
  },
248
349
  {
@@ -251,11 +352,11 @@ export function useNarrationHotkeys(): null {
251
352
  options: {
252
353
  eventType: "keyup",
253
354
  conflictBehavior: "allow",
355
+ enabled: !isAnyMenuOrDialogOpen && canGoNext,
254
356
  meta: {
255
357
  name: t("next"),
256
358
  description: t("skip_release_description"),
257
359
  },
258
- enabled: !isRequired,
259
360
  },
260
361
  },
261
362
  {
@@ -264,11 +365,11 @@ export function useNarrationHotkeys(): null {
264
365
  options: {
265
366
  eventType: "keyup",
266
367
  conflictBehavior: "allow",
368
+ enabled: !isAnyMenuOrDialogOpen && canGoNext,
267
369
  meta: {
268
370
  name: t("next"),
269
371
  description: t("skip_release_space_description"),
270
372
  },
271
- enabled: !isRequired,
272
373
  },
273
374
  },
274
375
  ]);
@@ -22,6 +22,7 @@ export function useNarrationFunctions() {
22
22
 
23
23
  const goNext = useCallback(async () => {
24
24
  if (hasOpenMenu) return;
25
+ TextDisplaySettings.resetForNext();
25
26
  GameStatus.setLoading(true);
26
27
  try {
27
28
  if (!narration.canContinue) {
@@ -94,6 +95,7 @@ const isDragGesture = (dx: number, dy: number) =>
94
95
  export function useNarrationPointerHandlers() {
95
96
  const { goNext } = useNarrationFunctions();
96
97
  const skipEnabled = useSelector(SkipSettings.store, (state) => state.enabled);
98
+ const typewriterInProgress = useSelector(TextDisplaySettings.store, (state) => state.inProgress);
97
99
  const pointerDownPos = useRef<{ x: number; y: number } | null>(null);
98
100
  const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
99
101
  const longPressTriggered = useRef(false);
@@ -166,9 +168,13 @@ export function useNarrationPointerHandlers() {
166
168
  if (skipEnabled) {
167
169
  SkipSettings.setEnabled(false);
168
170
  }
171
+ if (typewriterInProgress && !narration.dialogGlue) {
172
+ TextDisplaySettings.complete();
173
+ return;
174
+ }
169
175
  goNext();
170
176
  },
171
- [clearLongPressTimer, goNext, skipEnabled],
177
+ [clearLongPressTimer, goNext, skipEnabled, typewriterInProgress],
172
178
  );
173
179
 
174
180
  const handlePointerMove = useCallback(