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
@@ -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(
@@ -1,6 +1,9 @@
1
1
  import { INTERFACE_DATA_USE_QUERY_KEY } from "@/constants";
2
+ import { TextDisplaySettings } from "@/lib/stores/text-display-settings-store";
2
3
  import { type CharacterInterface, narration, stepHistory } from "@drincs/pixi-vn";
3
4
  import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { useSelector } from "@tanstack/react-store";
6
+ import { useCallback } from "react";
4
7
  import { useTranslation } from "react-i18next";
5
8
 
6
9
  const CAN_GO_BACK_USE_QUERY_KEY = "can_go_back_use_query_key";
@@ -24,7 +27,6 @@ export function useQueryChoiceMenuOptions() {
24
27
  ? t(option.text)
25
28
  : option.text.map((text) => t(text)).join(" "),
26
29
  })) || [],
27
- placeholderData: keepPreviousData,
28
30
  });
29
31
  }
30
32
 
@@ -45,13 +47,30 @@ type DialogueModel = {
45
47
  animatedText?: string;
46
48
  text?: string;
47
49
  character?: CharacterInterface;
50
+ lastText?: string;
48
51
  };
49
52
  const DIALOGUE_USE_QUERY_KEY = "dialogue_use_query_key";
50
53
  export function useQueryDialogue() {
51
54
  const { t } = useTranslation(["narration"]);
52
55
  const queryClient = useQueryClient();
56
+ const forceComplete = useSelector(TextDisplaySettings.store, (state) => state.forceComplete);
53
57
 
54
- return useQuery<DialogueModel>({
58
+ const finalizeDialogue = useCallback(() => {
59
+ queryClient.setQueryData<DialogueModel>(
60
+ [INTERFACE_DATA_USE_QUERY_KEY, DIALOGUE_USE_QUERY_KEY],
61
+ (prev) => {
62
+ if (!prev) return prev;
63
+ const fullText = (prev.text || "") + (prev.animatedText || "");
64
+ return {
65
+ ...prev,
66
+ text: fullText || undefined,
67
+ animatedText: undefined,
68
+ };
69
+ },
70
+ );
71
+ }, [queryClient]);
72
+
73
+ const query = useQuery<DialogueModel>({
55
74
  queryKey: [INTERFACE_DATA_USE_QUERY_KEY, DIALOGUE_USE_QUERY_KEY],
56
75
  queryFn: async ({ queryKey }) => {
57
76
  const dialogue = narration.dialogue;
@@ -78,6 +97,7 @@ export function useQueryDialogue() {
78
97
  }
79
98
  return {
80
99
  animatedText: newText,
100
+ lastText: newText,
81
101
  text: oldText,
82
102
  character: character,
83
103
  };
@@ -85,11 +105,21 @@ export function useQueryDialogue() {
85
105
 
86
106
  return {
87
107
  animatedText: text,
108
+ lastText: text,
88
109
  character: character,
89
110
  };
90
111
  },
91
112
  placeholderData: keepPreviousData,
113
+ select: forceComplete
114
+ ? (data) => ({
115
+ ...data,
116
+ text: (data.text || "") + (data.animatedText || "") || undefined,
117
+ animatedText: undefined,
118
+ })
119
+ : undefined,
92
120
  });
121
+
122
+ return { ...query, finalizeDialogue };
93
123
  }
94
124
 
95
125
  const CAN_GO_NEXT_USE_QUERY_KEY = "can_go_next_use_query_key";
@@ -0,0 +1,13 @@
1
+ import { Store } from "@tanstack/store";
2
+
3
+ export namespace AlertDialogState {
4
+ export const store = new Store<{ count: number }>({ count: 0 });
5
+
6
+ export function increment() {
7
+ store.setState((state) => ({ count: state.count + 1 }));
8
+ }
9
+
10
+ export function decrement() {
11
+ store.setState((state) => ({ count: Math.max(0, state.count - 1) }));
12
+ }
13
+ }
@@ -9,6 +9,10 @@ type TextDisplayStorage = {
9
9
  * Whether the typewriter effect is in progress
10
10
  */
11
11
  inProgress: boolean;
12
+ /**
13
+ * Whether the typewriter effect should be force-completed instantly
14
+ */
15
+ forceComplete: boolean;
12
16
  /**
13
17
  * The font size in percent (100 = default)
14
18
  */
@@ -22,6 +26,7 @@ export namespace TextDisplaySettings {
22
26
  export const store = new Store<TextDisplayStorage>({
23
27
  delay: Number(localStorage.getItem("typewriter_delay_millisecond") ?? 10),
24
28
  inProgress: false,
29
+ forceComplete: false,
25
30
  fontSize: initialFontSize,
26
31
  });
27
32
 
@@ -57,6 +62,20 @@ export namespace TextDisplaySettings {
57
62
  * End the typewriter effect
58
63
  */
59
64
  export function end() {
60
- store.setState((state) => ({ ...state, inProgress: false }));
65
+ store.setState((state) => ({ ...state, inProgress: false, forceComplete: false }));
66
+ }
67
+
68
+ /**
69
+ * Force the typewriter effect to complete instantly
70
+ */
71
+ export function complete() {
72
+ store.setState((state) => ({ ...state, forceComplete: true, inProgress: false }));
73
+ }
74
+
75
+ /**
76
+ * Clear the forceComplete flag before advancing to the next step
77
+ */
78
+ export function resetForNext() {
79
+ store.setState((state) => ({ ...state, forceComplete: false }));
61
80
  }
62
81
  }
@@ -81,6 +81,8 @@
81
81
  "menu_navigation_up_description": "Focus the previous menu item.",
82
82
  "menu_navigation_home_description": "Focus the first menu item.",
83
83
  "menu_navigation_end_description": "Focus the last menu item.",
84
+ "choice_navigation": "Choices",
85
+ "choice_navigation_description": "Use ↑ / ↓ to select an choice, then Enter to run it.",
84
86
  "success_delete": "Delete successful",
85
87
  "success_load": "Load successful",
86
88
  "success_save": "Save successful",
@@ -12,14 +12,13 @@ import { defineAssets } from "@/lib/utils/assets-utility";
12
12
  import { initializeIndexedDB } from "@/lib/utils/db-utility";
13
13
  import { loadRefreshSave } from "@/lib/utils/save-utility";
14
14
  import type { RouterContext } from "@/router";
15
+ import { narration } from "@drincs/pixi-vn";
15
16
  import { setupInkHmrListener } from "@drincs/pixi-vn-ink/vite-listener";
16
17
  import { setupPixivnViteData } from "@drincs/pixi-vn/vite-listener";
17
18
  import { createRootRouteWithContext, ErrorComponent, Outlet } from "@tanstack/react-router";
18
19
  import { lazy, Suspense } from "react";
19
20
 
20
- const DevDevtools = import.meta.env.DEV
21
- ? lazy(() => import("@/components/dev-devtools"))
22
- : null;
21
+ const DevDevtools = import.meta.env.DEV ? lazy(() => import("@/components/dev-devtools")) : null;
23
22
 
24
23
  export const Route = createRootRouteWithContext<RouterContext>()({
25
24
  validateSearch: (search) => SearchParams.setMany(search),
@@ -30,7 +29,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
30
29
  await Promise.all([import("@/content"), initializeIndexedDB(), defineAssets(), useI18n()]);
31
30
  await setupPixivnViteData();
32
31
  await setupInkHmrListener();
33
- if (location.pathname !== "/") {
32
+ if (location.pathname !== "/" && narration.stepCounter === 0) {
34
33
  const isRefreshSaveExist = await loadRefreshSave();
35
34
  if (isRefreshSaveExist) {
36
35
  await context.queryClient.invalidateQueries({
@@ -36,7 +36,7 @@
36
36
  "react-dom": "^19.2.6",
37
37
  "react-i18next": "^17.0.8",
38
38
  "react-markdown": "^10.1.0",
39
- "react-markdown-typewriter": "^1.2.0",
39
+ "react-markdown-typewriter": "^1.3.4",
40
40
  "react-resizable-panels": "^4.11.2",
41
41
  "rehype-raw": "^7.0.0",
42
42
  "remark-gfm": "^4.0.1",
@@ -10179,6 +10179,7 @@
10179
10179
  "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
10180
10180
  "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
10181
10181
  "license": "MIT",
10182
+ "peer": true,
10182
10183
  "dependencies": {
10183
10184
  "motion-dom": "^12.38.0",
10184
10185
  "motion-utils": "^12.36.0",
@@ -13842,6 +13843,7 @@
13842
13843
  "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
13843
13844
  "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
13844
13845
  "license": "MIT",
13846
+ "peer": true,
13845
13847
  "dependencies": {
13846
13848
  "framer-motion": "^12.38.0",
13847
13849
  "tslib": "^2.4.0"
@@ -13868,6 +13870,7 @@
13868
13870
  "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
13869
13871
  "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
13870
13872
  "license": "MIT",
13873
+ "peer": true,
13871
13874
  "dependencies": {
13872
13875
  "motion-utils": "^12.36.0"
13873
13876
  }
@@ -13876,7 +13879,8 @@
13876
13879
  "version": "12.36.0",
13877
13880
  "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
13878
13881
  "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
13879
- "license": "MIT"
13882
+ "license": "MIT",
13883
+ "peer": true
13880
13884
  },
13881
13885
  "node_modules/ms": {
13882
13886
  "version": "2.1.3",
@@ -14963,12 +14967,12 @@
14963
14967
  }
14964
14968
  },
14965
14969
  "node_modules/react-markdown-typewriter": {
14966
- "version": "1.2.0",
14967
- "resolved": "https://registry.npmjs.org/react-markdown-typewriter/-/react-markdown-typewriter-1.2.0.tgz",
14968
- "integrity": "sha512-jztq8XBfYB+CwG/fRk3VxE8A63LhVbcO7ZqjCYCg97cbn3Kl+hUFyA57LJ4vxG2b7z/FCqCuE/9hzBOhNiyzeA==",
14969
- "dependencies": {
14970
- "motion": "^12.38.0"
14971
- },
14970
+ "version": "1.3.4",
14971
+ "resolved": "https://registry.npmjs.org/react-markdown-typewriter/-/react-markdown-typewriter-1.3.4.tgz",
14972
+ "integrity": "sha512-LE/7xzyta4A6Gz6CfeTP0saCgVsxbCEVj4le4xobrkM/IG9MVWdrpyGFGFdsKmZ1k5otnRF3HP52YXbMv7P1rg==",
14973
+ "workspaces": [
14974
+ "playground"
14975
+ ],
14972
14976
  "peerDependencies": {
14973
14977
  "motion": ">=12.9.0",
14974
14978
  "react": ">=18.0.0",
@@ -50,7 +50,7 @@
50
50
  "react-dom": "^19.2.6",
51
51
  "react-i18next": "^17.0.8",
52
52
  "react-markdown": "^10.1.0",
53
- "react-markdown-typewriter": "^1.2.0",
53
+ "react-markdown-typewriter": "^1.3.4",
54
54
  "react-resizable-panels": "^4.11.2",
55
55
  "rehype-raw": "^7.0.0",
56
56
  "remark-gfm": "^4.0.1",