返回 oh-my-ppt
StyleSelect.tsx
根目录 / src / renderer / src / components / style / StyleSelect.tsx
1 import * as React from 'react'
2 import { ChevronDown, LoaderCircle, Search, Sparkles, Star, X } from 'lucide-react'
3 import { useCallback, useMemo, useState } from 'react'
4 import { useThumbnailUpdates } from '../../hooks/useThumbnailUpdates'
5 import { ipc, type HtmlThumbnailTask } from '../../lib/ipc'
6 import { filterByStyleKeyword, parseStyleCases } from '../../lib/style-case'
7 import { useT } from '../../i18n'
8 import { Popover, PopoverContent, PopoverTrigger } from '../ui/Popover'
9 import { cn } from '@renderer/lib/utils'
10 import { localAssetUrl } from '@shared/local-asset'
11
12 export type StyleSelectOption = {
13 id: string
14 styleKey?: string
15 label: string
16 description?: string
17 styleCase?: string
18 imageGenerationPrompt?: string
19 thumbnailPath?: string | null
20 favoriteAt?: number | null
21 }
22
23 export type StyleSelectProps = {
24 value: string
25 onChange: (id: string) => void
26 options: StyleSelectOption[]
27 placeholder?: string
28 compact?: boolean
29 disabled?: boolean
30 className?: string
31 dropdownAlign?: 'start' | 'center' | 'end'
32 dropdownClassName?: string
33 recommendation?: {
34 topic: string
35 brief?: string
36 modelConfigId?: string
37 }
38 }
39
40 const thumbnailUrl = (filePath: string): string =>
41 import.meta.env.MODE === 'test' ? 'about:blank' : localAssetUrl(filePath)
42
43 const compareFavoriteOptions = (
44 a: StyleSelectOption,
45 b: StyleSelectOption,
46 order: Map<string, number>
47 ): number => {
48 const favoriteDiff = (b.favoriteAt || 0) - (a.favoriteAt || 0)
49 if (favoriteDiff !== 0) return favoriteDiff
50 return (order.get(a.id) || 0) - (order.get(b.id) || 0)
51 }
52
53 export function StyleSelect({
54 value,
55 onChange,
56 options,
57 placeholder,
58 compact = false,
59 disabled,
60 className,
61 dropdownAlign = 'start',
62 dropdownClassName,
63 recommendation
64 }: StyleSelectProps): React.JSX.Element {
65 const t = useT()
66 const [open, setOpen] = useState(false)
67 const [query, setQuery] = useState('')
68 const [thumbnailOverrides, setThumbnailOverrides] = useState<Record<string, string>>({})
69 const [recommendedStyleKeys, setRecommendedStyleKeys] = useState<string[]>([])
70 const [recommendationLoading, setRecommendationLoading] = useState(false)
71 const [recommendationError, setRecommendationError] = useState('')
72
73 const applyThumbnail = useCallback((task: HtmlThumbnailTask): void => {
74 const path = task.thumbnailPath
75 if (!path) return
76 setThumbnailOverrides((current) =>
77 current[task.resourceId] === path ? current : { ...current, [task.resourceId]: path }
78 )
79 }, [])
80 useThumbnailUpdates('style', applyThumbnail)
81
82 const selected = useMemo(() => options.find((option) => option.id === value), [options, value])
83 const optionOrder = useMemo(
84 () => new Map(options.map((option, index) => [option.id, index])),
85 [options]
86 )
87
88 const recommendedOptions = useMemo(() => {
89 if (recommendedStyleKeys.length === 0) return options
90 const order = new Map(recommendedStyleKeys.map((styleKey, index) => [styleKey, index]))
91 return options
92 .filter((option) => option.styleKey && order.has(option.styleKey))
93 .sort((a, b) => (order.get(a.styleKey || '') || 0) - (order.get(b.styleKey || '') || 0))
94 }, [options, recommendedStyleKeys])
95
96 // 搜索框按名称/描述/用途过滤(用途也命中,所以不再需要单独的 tag 栏)。
97 const filtered = useMemo(
98 () =>
99 recommendedStyleKeys.length > 0
100 ? filterByStyleKeyword(recommendedOptions, query)
101 : [...filterByStyleKeyword(recommendedOptions, query)].sort((a, b) =>
102 compareFavoriteOptions(a, b, optionOrder)
103 ),
104 [optionOrder, query, recommendedOptions, recommendedStyleKeys.length]
105 )
106
107 const handleOpenChange = useCallback((next: boolean) => {
108 setOpen(next)
109 if (!next) {
110 setQuery('')
111 setRecommendedStyleKeys([])
112 setRecommendationError('')
113 }
114 }, [])
115
116 const handlePick = useCallback(
117 (id: string) => {
118 onChange(id)
119 setOpen(false)
120 },
121 [onChange]
122 )
123
124 const handleRecommend = useCallback(async () => {
125 const topic = recommendation?.topic.trim() || ''
126 if (!topic || recommendationLoading) return
127 setRecommendationLoading(true)
128 setRecommendationError('')
129 try {
130 const result = await ipc.recommendStyles({
131 topic,
132 brief: recommendation?.brief,
133 modelConfigId: recommendation?.modelConfigId
134 })
135 const knownStyleKeys = new Set(options.map((option) => option.styleKey).filter(Boolean))
136 const styleKeys = result.styleKeys.filter((styleKey) => knownStyleKeys.has(styleKey))
137 if (styleKeys.length === 0) throw new Error(t('styles.aiRecommendationFailed'))
138 setRecommendedStyleKeys(styleKeys)
139 setQuery('')
140 } catch (error) {
141 setRecommendedStyleKeys([])
142 setRecommendationError(
143 error instanceof Error && error.message ? error.message : t('styles.aiRecommendationFailed')
144 )
145 } finally {
146 setRecommendationLoading(false)
147 }
148 }, [options, recommendation, recommendationLoading, t])
149
150 return (
151 <Popover open={open} onOpenChange={handleOpenChange}>
152 <PopoverTrigger asChild>
153 <button
154 type="button"
155 disabled={disabled}
156 className={cn(
157 'flex w-full items-center justify-between gap-2 rounded-lg border border-[#d8ccb5]/80 bg-[#fff9ef]/86 py-2.5 pl-3 text-sm text-foreground shadow-[inset_0_1px_2px_rgba(77,63,46,0.08)] focus:outline-none focus:ring-2 focus:ring-[#8fbc8f] disabled:cursor-not-allowed disabled:opacity-50',
158 compact ? 'h-9 px-2.5 text-xs' : 'pr-3',
159 className
160 )}
161 >
162 <span className="flex min-w-0 items-center gap-1.5">
163 {selected ? (
164 <>
165 <span className="truncate font-medium">{selected.label}</span>
166 {selected.favoriteAt != null && (
167 <Star className="h-3.5 w-3.5 shrink-0 fill-[#d6a942] text-[#d6a942]" />
168 )}
169 {selected.imageGenerationPrompt ? (
170 compact ? (
171 <Sparkles
172 className="h-3.5 w-3.5 shrink-0 text-[#39724a]"
173 aria-label={t('styles.supportsImageGeneration')}
174 />
175 ) : (
176 <span className="hidden shrink-0 items-center gap-1 rounded-md border border-[#8fc49a]/70 bg-[#ecf8ee] px-1.5 py-px text-[10px] font-medium leading-tight text-[#39724a] sm:inline-flex">
177 <Sparkles className="h-3 w-3" />
178 {t('styles.supportsImageGeneration')}
179 </span>
180 )
181 ) : null}
182 {selected.styleCase && !compact && (
183 <span className="hidden shrink-0 truncate rounded-md border border-[#d6c08d]/80 bg-[#fff7e8] px-1.5 py-px text-[10px] font-medium leading-tight text-[#7c6a4c] sm:inline-block">
184 {parseStyleCases(selected.styleCase)[0]}
185 </span>
186 )}
187 </>
188 ) : (
189 <span className="text-muted-foreground">{placeholder}</span>
190 )}
191 </span>
192 <ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
193 </button>
194 </PopoverTrigger>
195 <PopoverContent
196 align={dropdownAlign}
197 side="bottom"
198 avoidCollisions={false}
199 className={cn(
200 'min-w-[var(--radix-popover-trigger-width)] w-[360px] overflow-hidden rounded-lg border border-[#d8ccb5]/85 bg-[#fff9ef] p-0 text-foreground shadow-[0_12px_28px_rgba(88,72,54,0.18)]',
201 dropdownClassName
202 )}
203 >
204 <div className="border-b border-[#e5ddc8]/80 p-2">
205 <div className="flex items-center gap-1.5">
206 <div className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md border border-[#d8ccb5]/80 bg-white/80 px-2 py-1">
207 <Search className="h-3.5 w-3.5 shrink-0 text-[#7c6a4c]/60" />
208 <input
209 type="text"
210 value={query}
211 onChange={(event) => {
212 setQuery(event.target.value)
213 setRecommendedStyleKeys([])
214 setRecommendationError('')
215 }}
216 placeholder={t('styles.searchPlaceholder')}
217 className="min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground"
218 />
219 {query ? (
220 <button
221 type="button"
222 onClick={() => setQuery('')}
223 className="shrink-0 text-[#7c6a4c]/60 transition-colors hover:text-[#7c6a4c]"
224 aria-label={t('styles.clearSearch')}
225 >
226 <X className="h-3.5 w-3.5" />
227 </button>
228 ) : null}
229 </div>
230 {recommendation ? (
231 <button
232 type="button"
233 onClick={() => void handleRecommend()}
234 disabled={!recommendation.topic.trim() || recommendationLoading}
235 className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-[#8fc49a]/70 bg-[#ecf8ee] px-2 text-xs font-medium text-[#39724a] transition-colors hover:bg-[#dff2e2] disabled:cursor-not-allowed disabled:opacity-50"
236 aria-label={t('styles.aiRecommend')}
237 title={
238 recommendation.topic.trim()
239 ? t('styles.aiRecommend')
240 : t('styles.aiRecommendationTopicRequired')
241 }
242 >
243 {recommendationLoading ? (
244 <LoaderCircle className="h-3.5 w-3.5 animate-spin" />
245 ) : (
246 <Sparkles className="h-3.5 w-3.5" />
247 )}
248 {t(recommendationLoading ? 'styles.aiRecommending' : 'styles.aiRecommend')}
249 </button>
250 ) : null}
251 {recommendedStyleKeys.length > 0 ? (
252 <button
253 type="button"
254 onClick={() => setRecommendedStyleKeys([])}
255 className="shrink-0 text-[#7c6a4c]/60 transition-colors hover:text-[#7c6a4c]"
256 aria-label={t('styles.clearRecommendation')}
257 title={t('styles.clearRecommendation')}
258 >
259 <X className="h-3.5 w-3.5" />
260 </button>
261 ) : null}
262 </div>
263 {recommendationError ? (
264 <p role="alert" className="px-1 pt-1.5 text-[11px] leading-tight text-destructive">
265 {recommendationError}
266 </p>
267 ) : null}
268 </div>
269 <div className="max-h-[min(300px,40vh)] overflow-y-auto p-1">
270 {filtered.length === 0 ? (
271 <p className="px-3 py-6 text-center text-xs text-muted-foreground">
272 {t('styles.noMatchingStyles')}
273 </p>
274 ) : (
275 filtered.map((option) => {
276 const thumb = thumbnailOverrides[option.id] || option.thumbnailPath
277 const isSelected = option.id === value
278 return (
279 <button
280 key={option.id}
281 type="button"
282 onClick={() => handlePick(option.id)}
283 className={cn(
284 'relative flex w-full items-stretch gap-2.5 rounded-md px-2.5 py-2 text-left outline-none transition-colors hover:bg-[#efe5d3]/70 focus-visible:bg-[#efe5d3]/70',
285 isSelected && 'bg-[#dbe7ca] text-[#2f3b28]',
286 compact && 'py-1.5'
287 )}
288 >
289 {thumb ? (
290 <img
291 src={thumbnailUrl(thumb)}
292 alt=""
293 aria-hidden="true"
294 className="h-11 w-[78px] shrink-0 rounded-[3px] border border-black/5 object-cover"
295 />
296 ) : (
297 <span className="h-11 w-[78px] shrink-0 rounded-[3px] border border-[#e5ddc8] bg-[#f5f1e8]" />
298 )}
299 <div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 py-0.5">
300 <span className="flex min-w-0 items-center gap-1.5">
301 <span className={cn('truncate font-medium', compact ? 'text-xs' : 'text-sm')}>
302 {option.label}
303 </span>
304 {option.imageGenerationPrompt ? (
305 <span className="inline-flex shrink-0 items-center gap-1 rounded-md border border-[#8fc49a]/70 bg-[#ecf8ee] px-1.5 py-px text-[10px] font-medium leading-tight text-[#39724a]">
306 <Sparkles className="h-3 w-3" />
307 {t('styles.supportsImageGeneration')}
308 </span>
309 ) : null}
310 {option.styleCase && (
311 <span className="shrink-0 truncate rounded-md border border-[#d6c08d]/80 bg-[#fff7e8] px-1.5 py-px text-[10px] font-medium leading-tight text-[#7c6a4c]">
312 {option.styleCase}
313 </span>
314 )}
315 {option.favoriteAt != null && (
316 <Star className="h-3.5 w-3.5 shrink-0 fill-[#d6a942] text-[#d6a942]" />
317 )}
318 </span>
319 {option.description && (
320 <span
321 className={cn(
322 'truncate leading-tight text-muted-foreground',
323 compact ? 'text-[10px]' : 'text-[11px]'
324 )}
325 >
326 {option.description}
327 </span>
328 )}
329 </div>
330 </button>
331 )
332 })
333 )}
334 </div>
335 </PopoverContent>
336 </Popover>
337 )
338 }
339
339 lines Plain Text