返回 presentation-ai
icon-picker.tsx
根目录 / src / components / ui / icon-picker.tsx
1 "use client";
2
3 import { type VariantProps } from "class-variance-authority";
4 import { Loader2, Plus, X } from "lucide-react";
5 import { useReadOnly } from "platejs/react";
6 import React, { useEffect, useState, type ReactNode } from "react";
7
8 import {
9 DEFAULT_PRESENTATION_ICON,
10 getPopularPresentationIcons,
11 resolvePresentationIcon,
12 searchPresentationIcons,
13 type ResolvedPresentationIcon,
14 } from "@/components/notebook/presentation/editor/custom-elements/presentation-icon-utils";
15 import { Button } from "@/components/ui/button";
16 import { type buttonVariants } from "@/components/ui/button";
17 import { Input } from "@/components/ui/input";
18 import { cn } from "@/lib/utils";
19 import { usePresentationState } from "@/states/presentation-state";
20
21 const sizeClasses = {
22 sm: "h-8 w-8",
23 md: "h-10 w-10",
24 lg: "h-12 w-12",
25 };
26
27 // Define interfaces for type safety
28 interface IconItem {
29 name: string;
30 component: ReactNode;
31 }
32
33 const renderResolvedIcon = (
34 resolvedIcon: ResolvedPresentationIcon | null,
35 iconPixelSize = 16,
36 iconClassName?: string,
37 ) => {
38 if (!resolvedIcon) {
39 return null;
40 }
41
42 return React.createElement(resolvedIcon.Component, {
43 "aria-hidden": true,
44 className: iconClassName,
45 size: iconPixelSize,
46 });
47 };
48
49 // Define the prop types
50 interface IconPickerProps
51 extends
52 React.ComponentProps<"button">,
53 Omit<VariantProps<typeof buttonVariants>, "size"> {
54 onIconSelect?: (iconName: string, iconComponent: ReactNode) => void;
55 onIconRemove?: () => void;
56 defaultIcon?: string;
57 hidePlaceholderWhenEmpty?: boolean;
58 iconClassName?: string;
59 iconPixelSize?: number;
60 placeholder?: ReactNode;
61 size?: "sm" | "md" | "lg";
62 }
63
64 // Main Icon Picker Component
65 const IconPicker = ({
66 onIconSelect,
67 onIconRemove,
68 defaultIcon,
69 placeholder,
70 className,
71 hidePlaceholderWhenEmpty,
72 iconClassName,
73 iconPixelSize,
74 size,
75 ...props
76 }: IconPickerProps) => {
77 const [icon, setIcon] = useState<string>(defaultIcon?.trim() ?? "");
78 const [iconComponent, setIconComponent] = useState<ReactNode>(null);
79 const [isLoading, setIsLoading] = useState<boolean>(false);
80 const openIconPicker = usePresentationState((state) => state.openIconPicker);
81 const readOnly = useReadOnly();
82 const hasIcon = Boolean(icon.trim());
83
84 // Size mappings for the trigger button
85
86 useEffect(() => {
87 let cancelled = false;
88
89 const syncCurrentIcon = async () => {
90 const normalizedDefaultIcon = defaultIcon?.trim();
91 setIcon(normalizedDefaultIcon ?? "");
92
93 if (!normalizedDefaultIcon) {
94 setIconComponent(null);
95 return;
96 }
97
98 setIsLoading(true);
99
100 try {
101 const resolvedIcon = await resolvePresentationIcon(
102 normalizedDefaultIcon,
103 );
104
105 if (cancelled) {
106 if (!cancelled) {
107 setIsLoading(false);
108 }
109 return;
110 }
111
112 if (resolvedIcon) {
113 setIcon(resolvedIcon.name);
114 setIconComponent(
115 renderResolvedIcon(resolvedIcon, iconPixelSize, iconClassName),
116 );
117 if (!cancelled) {
118 setIsLoading(false);
119 }
120 return;
121 }
122
123 const fallbackIcon = await resolvePresentationIcon(
124 DEFAULT_PRESENTATION_ICON,
125 );
126
127 if (cancelled) {
128 if (!cancelled) {
129 setIsLoading(false);
130 }
131 return;
132 }
133
134 setIcon(fallbackIcon?.name ?? DEFAULT_PRESENTATION_ICON);
135 setIconComponent(
136 renderResolvedIcon(fallbackIcon, iconPixelSize, iconClassName),
137 );
138 } catch (error) {
139 try {
140 console.error("Error resolving icon:", error);
141
142 if (!cancelled && normalizedDefaultIcon) {
143 const fallbackIcon = await resolvePresentationIcon(
144 DEFAULT_PRESENTATION_ICON,
145 );
146
147 if (!cancelled) {
148 setIcon(fallbackIcon?.name ?? DEFAULT_PRESENTATION_ICON);
149 setIconComponent(
150 renderResolvedIcon(fallbackIcon, iconPixelSize, iconClassName),
151 );
152 }
153 } else if (!cancelled) {
154 setIcon("");
155 setIconComponent(null);
156 }
157 } catch (reactDoctorFinallyError) {
158 if (!cancelled) {
159 setIsLoading(false);
160 }
161 throw reactDoctorFinallyError;
162 }
163 }
164 if (!cancelled) {
165 setIsLoading(false);
166 }
167 };
168
169 void syncCurrentIcon();
170
171 return () => {
172 cancelled = true;
173 };
174 }, [defaultIcon, iconClassName, iconPixelSize]);
175
176 const handleRemoveIcon = () => {
177 setIcon("");
178 setIconComponent(null);
179 onIconRemove?.();
180 };
181
182 const handleOpenIconPicker = (event: React.MouseEvent<HTMLButtonElement>) => {
183 props.onClick?.(event);
184
185 if (event.defaultPrevented || props.disabled) {
186 return;
187 }
188
189 if (readOnly) {
190 return;
191 }
192
193 openIconPicker(
194 icon,
195 async (selectedName) => {
196 setIsLoading(true);
197
198 try {
199 const resolvedIcon = await resolvePresentationIcon(selectedName);
200 const resolvedName = resolvedIcon?.name ?? selectedName;
201 const component = renderResolvedIcon(
202 resolvedIcon,
203 iconPixelSize,
204 iconClassName,
205 );
206
207 setIcon(resolvedName);
208 setIconComponent(component);
209 onIconSelect?.(resolvedName, component);
210 } catch (error) {
211 try {
212 console.error("Error selecting icon:", error);
213 } catch (reactDoctorCatchError) {
214 setIsLoading(false);
215 throw reactDoctorCatchError;
216 }
217 }
218 setIsLoading(false);
219 },
220 handleRemoveIcon,
221 );
222 };
223
224 return (
225 <Button
226 variant="outline"
227 size="icon"
228 className={cn(
229 sizeClasses[size ?? "md"],
230 "flex items-center justify-center rounded-md border shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden",
231 className,
232 hidePlaceholderWhenEmpty &&
233 !hasIcon &&
234 !readOnly &&
235 "pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100",
236 hidePlaceholderWhenEmpty && !hasIcon && readOnly && "hidden",
237 )}
238 aria-label="Select icon"
239 {...props}
240 onClick={handleOpenIconPicker}
241 >
242 {isLoading ? (
243 <Loader2 className="size-4 animate-spin" />
244 ) : (
245 (iconComponent ??
246 placeholder ?? <Plus className="size-4 text-muted-foreground" />)
247 )}
248 </Button>
249 );
250 };
251
252 const IconPickerPanel = () => {
253 const initialIcon = usePresentationState(
254 (state) => state.iconPickerCurrentIcon,
255 );
256 const [currentIcon, setCurrentIcon] = useState(initialIcon);
257
258 useEffect(() => {
259 setCurrentIcon(initialIcon);
260 }, [initialIcon]);
261 const selectIcon = usePresentationState(
262 (state) => state.iconPickerSelectIcon,
263 );
264 const removeIcon = usePresentationState(
265 (state) => state.iconPickerRemoveIcon,
266 );
267 const [isLoading, setIsLoading] = useState<boolean>(false);
268 const [internalSearchTerm, setInternalSearchTerm] = useState<string>("");
269 const [filteredIcons, setFilteredIcons] = useState<IconItem[]>([]);
270
271 useEffect(() => {
272 let cancelled = false;
273
274 const loadIcons = async () => {
275 setIsLoading(true);
276
277 try {
278 const resolvedIcons = internalSearchTerm.trim()
279 ? await searchPresentationIcons(internalSearchTerm, 60)
280 : await getPopularPresentationIcons(30);
281
282 if (cancelled) {
283 if (!cancelled) {
284 setIsLoading(false);
285 }
286 return;
287 }
288
289 setFilteredIcons(
290 resolvedIcons.map((resolvedIcon) => ({
291 name: resolvedIcon.name,
292 component: renderResolvedIcon(resolvedIcon),
293 })),
294 );
295 } catch (error) {
296 try {
297 console.error("Error loading icons:", error);
298
299 if (!cancelled) {
300 setFilteredIcons([]);
301 }
302 } catch (reactDoctorFinallyError) {
303 if (!cancelled) {
304 setIsLoading(false);
305 }
306 throw reactDoctorFinallyError;
307 }
308 }
309 if (!cancelled) {
310 setIsLoading(false);
311 }
312 };
313
314 void loadIcons();
315
316 return () => {
317 cancelled = true;
318 };
319 }, [internalSearchTerm]);
320
321 const handleSelectIcon = (selectedName: string) => {
322 setCurrentIcon(selectedName);
323 selectIcon?.(selectedName);
324 };
325
326 const handleRemoveIcon = (e: React.MouseEvent) => {
327 e.stopPropagation();
328 e.preventDefault();
329 setCurrentIcon("");
330 removeIcon?.();
331 };
332
333 const hasIcon = Boolean(currentIcon.trim());
334
335 return (
336 <div className="flex h-full min-h-0 flex-col gap-4 p-4">
337 <Input
338 placeholder="Search icons..."
339 value={internalSearchTerm}
340 onChange={(e) => setInternalSearchTerm(e.target.value)}
341 className="w-full"
342 autoFocus
343 />
344
345 {isLoading ? (
346 <div className="flex h-60 items-center justify-center">
347 <Loader2 className="size-8 animate-spin text-muted-foreground" />
348 </div>
349 ) : (
350 <div className="grid min-h-0 flex-1 grid-cols-6 content-start gap-1 overflow-y-auto p-1">
351 {hasIcon && (
352 <Button
353 variant="outline"
354 className="flex aspect-square h-10 items-center justify-center p-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
355 onClick={handleRemoveIcon}
356 title="Remove icon"
357 >
358 <X className="size-4" />
359 </Button>
360 )}
361 {filteredIcons.length > 0 ? (
362 filteredIcons.map((item, index) => (
363 <Button
364 key={`${item.name}-${index}`}
365 variant={currentIcon === item.name ? "default" : "ghost"}
366 className="flex aspect-square h-10 items-center justify-center p-0"
367 onClick={() => handleSelectIcon(item.name)}
368 title={item.name}
369 >
370 {item.component}
371 </Button>
372 ))
373 ) : (
374 <div className="col-span-5 py-8 text-center text-muted-foreground">
375 No icons found. Try a different search term.
376 </div>
377 )}
378 </div>
379 )}
380 </div>
381 );
382 };
383
384 // Export the component
385 export { IconPicker, IconPickerPanel };
386
386 lines Plain Text