返回 presentation-ai
ThemeSelector.tsx
根目录 / src / components / presentation / edit-panel / sections / theme / ThemeSelector.tsx
1 "use client";
2
3 import {
4 useCallback,
5 useEffect,
6 useMemo,
7 useRef,
8 useState,
9 type KeyboardEvent,
10 } from "react";
11
12 import { Skeleton } from "@/components/ui/skeleton";
13 import { isBuiltInPresentationTheme } from "@/lib/presentation/theme-resolution";
14 import { type ThemeProperties } from "@/lib/presentation/themes";
15 import { usePresentationState } from "@/states/presentation-state";
16 import { ThemeCard } from "./ThemeCard";
17
18 const KEYBOARD_APPLY_DELAY_MS = 100;
19 const THEME_GRID_COLUMNS = 2;
20
21 export interface ThemeListItem {
22 theme: ThemeProperties;
23 themeId: string;
24 isFavorite?: boolean;
25 likeCount?: number;
26 isLiked?: boolean;
27 canLike?: boolean;
28 showFavoriteButton?: boolean;
29 isUserTheme?: boolean;
30 isAdminTheme?: boolean;
31 canEditSystemTheme?: boolean;
32 }
33
34 interface ThemeSelectorProps {
35 themes: ThemeListItem[];
36 activeKey: string;
37 isLoading?: boolean;
38 emptyMessage?: string;
39 skeletonCount?: number;
40 }
41
42 export function ThemeSelector({
43 themes,
44 activeKey,
45 isLoading = false,
46 emptyMessage = "No themes available.",
47 skeletonCount = 6,
48 }: ThemeSelectorProps) {
49 const { userThemes, otherThemes, orderedThemes } = useMemo(() => {
50 const ownedThemes = themes.filter((item) => item.isUserTheme);
51 const sharedThemes = themes.filter((item) => !item.isUserTheme);
52
53 return {
54 userThemes: ownedThemes,
55 otherThemes: sharedThemes,
56 orderedThemes: [...ownedThemes, ...sharedThemes],
57 };
58 }, [themes]);
59 const hasUserThemes = userThemes.length > 0;
60 const hasOtherThemes = otherThemes.length > 0;
61 const activeThemeIndex = orderedThemes.findIndex(
62 (item) => item.themeId === activeKey,
63 );
64 const initialSelectedIndex = activeThemeIndex >= 0 ? activeThemeIndex : null;
65 const [selectedIndex, setSelectedIndex] = useState<number | null>(
66 initialSelectedIndex,
67 );
68 const cardRefs = useRef<Array<HTMLDivElement | null>>([]);
69 const applyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
70 const activeKeyRef = useRef(activeKey);
71 const pendingKeyboardIndexRef = useRef<number | null>(null);
72
73 useEffect(() => {
74 activeKeyRef.current = activeKey;
75 }, [activeKey]);
76
77 const focusCard = useCallback((index: number) => {
78 cardRefs.current[index]?.focus();
79 }, []);
80
81 useEffect(() => {
82 setSelectedIndex(initialSelectedIndex);
83
84 if (initialSelectedIndex === null) {
85 return;
86 }
87
88 const animationFrameId = window.requestAnimationFrame(() => {
89 focusCard(initialSelectedIndex);
90 });
91
92 return () => window.cancelAnimationFrame(animationFrameId);
93 }, [focusCard, initialSelectedIndex]);
94
95 useEffect(
96 () => () => {
97 if (applyTimeoutRef.current) {
98 clearTimeout(applyTimeoutRef.current);
99 }
100 },
101 [],
102 );
103
104 const commitSelection = useCallback(
105 (index: number) => {
106 const item = orderedThemes[index];
107
108 if (!item) return;
109 if (item.themeId === activeKeyRef.current) return;
110
111 activeKeyRef.current = item.themeId;
112 usePresentationState
113 .getState()
114 .setTheme(
115 item.themeId,
116 isBuiltInPresentationTheme(item.themeId) ? undefined : item.theme,
117 );
118 },
119 [orderedThemes],
120 );
121
122 const selectItem = useCallback(
123 (index: number) => {
124 if (applyTimeoutRef.current) {
125 clearTimeout(applyTimeoutRef.current);
126 applyTimeoutRef.current = null;
127 }
128
129 pendingKeyboardIndexRef.current = null;
130 setSelectedIndex(index);
131 commitSelection(index);
132 },
133 [commitSelection],
134 );
135
136 const schedulePendingKeyboardCommit = useCallback(() => {
137 if (pendingKeyboardIndexRef.current === null) return;
138
139 if (applyTimeoutRef.current) {
140 clearTimeout(applyTimeoutRef.current);
141 }
142
143 applyTimeoutRef.current = setTimeout(() => {
144 const pendingIndex = pendingKeyboardIndexRef.current;
145 applyTimeoutRef.current = null;
146 pendingKeyboardIndexRef.current = null;
147
148 if (pendingIndex === null) return;
149
150 commitSelection(pendingIndex);
151 }, KEYBOARD_APPLY_DELAY_MS);
152 }, [commitSelection]);
153
154 const moveSelection = useCallback(
155 (nextIndex: number) => {
156 const boundedIndex = Math.min(
157 Math.max(nextIndex, 0),
158 orderedThemes.length - 1,
159 );
160
161 if (applyTimeoutRef.current) {
162 clearTimeout(applyTimeoutRef.current);
163 applyTimeoutRef.current = null;
164 }
165
166 pendingKeyboardIndexRef.current = boundedIndex;
167 setSelectedIndex(boundedIndex);
168 focusCard(boundedIndex);
169 },
170 [focusCard, orderedThemes.length],
171 );
172
173 const handleCardKeyDown = useCallback(
174 (event: KeyboardEvent<HTMLDivElement>, index: number) => {
175 switch (event.key) {
176 case "ArrowLeft":
177 event.preventDefault();
178 event.stopPropagation();
179 moveSelection(index - 1);
180 break;
181 case "ArrowRight":
182 event.preventDefault();
183 event.stopPropagation();
184 moveSelection(index + 1);
185 break;
186 case "ArrowUp":
187 event.preventDefault();
188 event.stopPropagation();
189 moveSelection(index - THEME_GRID_COLUMNS);
190 break;
191 case "ArrowDown":
192 event.preventDefault();
193 event.stopPropagation();
194 moveSelection(index + THEME_GRID_COLUMNS);
195 break;
196 case "Home":
197 event.preventDefault();
198 event.stopPropagation();
199 moveSelection(0);
200 break;
201 case "End":
202 event.preventDefault();
203 event.stopPropagation();
204 moveSelection(orderedThemes.length - 1);
205 break;
206 }
207 },
208 [moveSelection, orderedThemes.length],
209 );
210
211 const handleCardKeyUp = useCallback(
212 (event: KeyboardEvent<HTMLDivElement>) => {
213 switch (event.key) {
214 case "ArrowLeft":
215 case "ArrowRight":
216 case "ArrowUp":
217 case "ArrowDown":
218 case "Home":
219 case "End":
220 event.preventDefault();
221 event.stopPropagation();
222 schedulePendingKeyboardCommit();
223 break;
224 }
225 },
226 [schedulePendingKeyboardCommit],
227 );
228
229 if (isLoading) {
230 return (
231 <div className="grid grid-cols-2 gap-3 px-4 pt-3">
232 {Array.from({ length: skeletonCount }).map((_, index) => (
233 <ThemeCardSkeleton key={`theme-skeleton-${index}`} />
234 ))}
235 </div>
236 );
237 }
238
239 if (!themes.length) {
240 return (
241 <div className="mx-4 flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">
242 <span>{emptyMessage}</span>
243 </div>
244 );
245 }
246
247 return (
248 <div className="scrollbar-thin max-h-[calc(100vh-64px-2*80px)] overflow-y-auto scrollbar-thumb-primary scrollbar-track-transparent">
249 {hasUserThemes && (
250 <div className="px-4 pt-3">
251 <h3 className="mb-3 text-xs font-semibold tracking-wider text-muted-foreground uppercase">
252 My Themes
253 </h3>
254 <div className="grid grid-cols-2 gap-3">
255 {userThemes.map((item, index) => (
256 <ThemeCard
257 key={item.themeId}
258 themeId={item.themeId}
259 theme={item.theme}
260 isSelected={activeKey === item.themeId}
261 isFavorite={item.isFavorite}
262 likeCount={item.likeCount}
263 isLiked={item.isLiked}
264 showLikeButton={item.canLike ?? false}
265 showFavoriteButton={item.showFavoriteButton}
266 personalizeLabel="Personlize"
267 isOwner={item.isUserTheme}
268 isPublic={false}
269 isAdminTheme={item.isAdminTheme}
270 canEditSystemTheme={item.canEditSystemTheme}
271 isFocused={selectedIndex === index}
272 refCallback={(node) => {
273 cardRefs.current[index] = node;
274 }}
275 tabIndex={selectedIndex === index ? 0 : -1}
276 onSelect={() => selectItem(index)}
277 onFocus={() => setSelectedIndex(index)}
278 onKeyDown={(event) => handleCardKeyDown(event, index)}
279 onKeyUp={handleCardKeyUp}
280 />
281 ))}
282 </div>
283 </div>
284 )}
285
286 {hasOtherThemes && (
287 <div className={`px-4 ${hasUserThemes ? "pt-6" : "pt-3"}`}>
288 {hasUserThemes && (
289 <h3 className="mb-3 text-xs font-semibold tracking-wider text-muted-foreground uppercase">
290 ALLWEONE Themes
291 </h3>
292 )}
293 <div className="grid grid-cols-2 gap-3">
294 {otherThemes.map((item, publicIndex) => {
295 const index = userThemes.length + publicIndex;
296
297 return (
298 <ThemeCard
299 key={item.themeId}
300 themeId={item.themeId}
301 theme={item.theme}
302 isSelected={activeKey === item.themeId}
303 isFavorite={item.isFavorite}
304 likeCount={item.likeCount}
305 isLiked={item.isLiked}
306 showLikeButton={item.canLike ?? false}
307 showFavoriteButton={item.showFavoriteButton}
308 personalizeLabel="Personlize"
309 isOwner={item.isUserTheme}
310 isPublic={!item.isUserTheme}
311 isAdminTheme={item.isAdminTheme}
312 canEditSystemTheme={item.canEditSystemTheme}
313 isFocused={selectedIndex === index}
314 refCallback={(node) => {
315 cardRefs.current[index] = node;
316 }}
317 tabIndex={selectedIndex === index ? 0 : -1}
318 onSelect={() => selectItem(index)}
319 onFocus={() => setSelectedIndex(index)}
320 onKeyDown={(event) => handleCardKeyDown(event, index)}
321 onKeyUp={handleCardKeyUp}
322 />
323 );
324 })}
325 </div>
326 </div>
327 )}
328 </div>
329 );
330 }
331
332 function ThemeCardSkeleton() {
333 return (
334 <div className="relative rounded-xl border border-border bg-card/50 p-2">
335 <Skeleton className="aspect-4/3 w-full rounded-lg" />
336 <div className="mt-3 space-y-2 px-2 pb-2">
337 <Skeleton className="h-4 w-3/5" />
338 <Skeleton className="h-3 w-2/5" />
339 </div>
340 </div>
341 );
342 }
343
343 lines Plain Text