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,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",
@@ -11,13 +11,12 @@ import { defineAssets } from "@/lib/utils/assets-utility";
11
11
  import { initializeIndexedDB } from "@/lib/utils/db-utility";
12
12
  import { loadRefreshSave } from "@/lib/utils/save-utility";
13
13
  import type { RouterContext } from "@/router";
14
+ import { narration } from "@drincs/pixi-vn";
14
15
  import { setupPixivnViteData } from "@drincs/pixi-vn/vite-listener";
15
16
  import { createRootRouteWithContext, ErrorComponent, Outlet } from "@tanstack/react-router";
16
17
  import { lazy, Suspense } from "react";
17
18
 
18
- const DevDevtools = import.meta.env.DEV
19
- ? lazy(() => import("@/components/dev-devtools"))
20
- : null;
19
+ const DevDevtools = import.meta.env.DEV ? lazy(() => import("@/components/dev-devtools")) : null;
21
20
 
22
21
  export const Route = createRootRouteWithContext<RouterContext>()({
23
22
  validateSearch: (search) => SearchParams.setMany(search),
@@ -27,7 +26,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
27
26
  // Game.onNavigate(async (to) => redirect({ to }));
28
27
  await Promise.all([import("@/content"), initializeIndexedDB(), defineAssets(), useI18n()]);
29
28
  await setupPixivnViteData();
30
- if (location.pathname !== "/") {
29
+ if (location.pathname !== "/" && narration.stepCounter === 0) {
31
30
  const isRefreshSaveExist = await loadRefreshSave();
32
31
  if (isRefreshSaveExist) {
33
32
  await context.queryClient.invalidateQueries({
@@ -35,7 +35,7 @@
35
35
  "react-dom": "^19.2.6",
36
36
  "react-i18next": "^17.0.8",
37
37
  "react-markdown": "^10.1.0",
38
- "react-markdown-typewriter": "^1.2.0",
38
+ "react-markdown-typewriter": "^1.3.4",
39
39
  "react-resizable-panels": "^4.11.2",
40
40
  "rehype-raw": "^7.0.0",
41
41
  "remark-gfm": "^4.0.1",
@@ -10108,6 +10108,7 @@
10108
10108
  "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
10109
10109
  "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
10110
10110
  "license": "MIT",
10111
+ "peer": true,
10111
10112
  "dependencies": {
10112
10113
  "motion-dom": "^12.38.0",
10113
10114
  "motion-utils": "^12.36.0",
@@ -13771,6 +13772,7 @@
13771
13772
  "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
13772
13773
  "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
13773
13774
  "license": "MIT",
13775
+ "peer": true,
13774
13776
  "dependencies": {
13775
13777
  "framer-motion": "^12.38.0",
13776
13778
  "tslib": "^2.4.0"
@@ -13797,6 +13799,7 @@
13797
13799
  "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
13798
13800
  "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
13799
13801
  "license": "MIT",
13802
+ "peer": true,
13800
13803
  "dependencies": {
13801
13804
  "motion-utils": "^12.36.0"
13802
13805
  }
@@ -13805,7 +13808,8 @@
13805
13808
  "version": "12.36.0",
13806
13809
  "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
13807
13810
  "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
13808
- "license": "MIT"
13811
+ "license": "MIT",
13812
+ "peer": true
13809
13813
  },
13810
13814
  "node_modules/ms": {
13811
13815
  "version": "2.1.3",
@@ -14892,12 +14896,12 @@
14892
14896
  }
14893
14897
  },
14894
14898
  "node_modules/react-markdown-typewriter": {
14895
- "version": "1.2.0",
14896
- "resolved": "https://registry.npmjs.org/react-markdown-typewriter/-/react-markdown-typewriter-1.2.0.tgz",
14897
- "integrity": "sha512-jztq8XBfYB+CwG/fRk3VxE8A63LhVbcO7ZqjCYCg97cbn3Kl+hUFyA57LJ4vxG2b7z/FCqCuE/9hzBOhNiyzeA==",
14898
- "dependencies": {
14899
- "motion": "^12.38.0"
14900
- },
14899
+ "version": "1.3.4",
14900
+ "resolved": "https://registry.npmjs.org/react-markdown-typewriter/-/react-markdown-typewriter-1.3.4.tgz",
14901
+ "integrity": "sha512-LE/7xzyta4A6Gz6CfeTP0saCgVsxbCEVj4le4xobrkM/IG9MVWdrpyGFGFdsKmZ1k5otnRF3HP52YXbMv7P1rg==",
14902
+ "workspaces": [
14903
+ "playground"
14904
+ ],
14901
14905
  "peerDependencies": {
14902
14906
  "motion": ">=12.9.0",
14903
14907
  "react": ">=18.0.0",
@@ -44,7 +44,7 @@
44
44
  "react-dom": "^19.2.6",
45
45
  "react-i18next": "^17.0.8",
46
46
  "react-markdown": "^10.1.0",
47
- "react-markdown-typewriter": "^1.2.0",
47
+ "react-markdown-typewriter": "^1.3.4",
48
48
  "react-resizable-panels": "^4.11.2",
49
49
  "rehype-raw": "^7.0.0",
50
50
  "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 (