返回 oh-my-ppt
StyleSelect.tsx
根目录 / src / renderer / src / components / style / StyleSelect.tsx
1 import * as React from 'react'
2 import { ChevronDown, Search, Star, X } from 'lucide-react'
3 import { useCallback, useMemo, useState } from 'react'
4 import { useThumbnailUpdates } from '../../hooks/useThumbnailUpdates'
5 import 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 label: string
15 description?: string
16 styleCase?: string
17 thumbnailPath?: string | null
18 favoriteAt?: number | null
19 }
20
21 export type StyleSelectProps = {
22 value: string
23 onChange: (id: string) => void
24 options: StyleSelectOption[]
25 placeholder?: string
26 compact?: boolean
27 disabled?: boolean
28 className?: string
29 dropdownAlign?: 'start' | 'center' | 'end'
30 dropdownClassName?: string
31 }
32
33 const thumbnailUrl = (filePath: string): string =>
34 import.meta.env.MODE === 'test' ? 'about:blank' : localAssetUrl(filePath)
35
36 const compareFavoriteOptions = (
37 a: StyleSelectOption,
38 b: StyleSelectOption,
39 order: Map<string, number>
40 ): number => {
41 const favoriteDiff = (b.favoriteAt || 0) - (a.favoriteAt || 0)
42 if (favoriteDiff !== 0) return favoriteDiff
43 return (order.get(a.id) || 0) - (order.get(b.id) || 0)
44 }
45
46 export function StyleSelect({
47 value,
48 onChange,
49 options,
50 placeholder,
51 compact = false,
52 disabled,
53 className,
54 dropdownAlign = 'start',
55 dropdownClassName
56 }: StyleSelectProps): React.JSX.Element {
57 const t = useT()
58 const [open, setOpen] = useState(false)
59 const [query, setQuery] = useState('')
60 const [thumbnailOverrides, setThumbnailOverrides] = useState<Record<string, string>>({})
61
62 const applyThumbnail = useCallback((task: HtmlThumbnailTask): void => {
63 const path = task.thumbnailPath
64 if (!path) return
65 setThumbnailOverrides((current) =>
66 current[task.resourceId] === path ? current : { ...current, [task.resourceId]: path }
67 )
68 }, [])
69 useThumbnailUpdates('style', applyThumbnail)
70
71 const selected = useMemo(() => options.find((option) => option.id === value), [options, value])
72 const optionOrder = useMemo(
73 () => new Map(options.map((option, index) => [option.id, index])),
74 [options]
75 )
76
77 // 搜索框按名称/描述/用途过滤(用途也命中,所以不再需要单独的 tag 栏)。
78 const filtered = useMemo(
79 () =>
80 [...filterByStyleKeyword(options, query)].sort((a, b) =>
81 compareFavoriteOptions(a, b, optionOrder)
82 ),
83 [optionOrder, options, query]
84 )
85
86 const handleOpenChange = useCallback((next: boolean) => {
87 setOpen(next)
88 if (!next) setQuery('')
89 }, [])
90
91 const handlePick = useCallback(
92 (id: string) => {
93 onChange(id)
94 setOpen(false)
95 },
96 [onChange]
97 )
98
99 return (
100 <Popover open={open} onOpenChange={handleOpenChange}>
101 <PopoverTrigger asChild>
102 <button
103 type="button"
104 disabled={disabled}
105 className={cn(
106 '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',
107 compact ? 'h-9 px-2.5 text-xs' : 'pr-3',
108 className
109 )}
110 >
111 <span className="flex min-w-0 items-center gap-1.5">
112 {selected ? (
113 <>
114 <span className="truncate font-medium">{selected.label}</span>
115 {selected.favoriteAt != null && (
116 <Star className="h-3.5 w-3.5 shrink-0 fill-[#d6a942] text-[#d6a942]" />
117 )}
118 {selected.styleCase && !compact && (
119 <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">
120 {parseStyleCases(selected.styleCase)[0]}
121 </span>
122 )}
123 </>
124 ) : (
125 <span className="text-muted-foreground">{placeholder}</span>
126 )}
127 </span>
128 <ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
129 </button>
130 </PopoverTrigger>
131 <PopoverContent
132 align={dropdownAlign}
133 side="bottom"
134 avoidCollisions={false}
135 className={cn(
136 '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)]',
137 dropdownClassName
138 )}
139 >
140 <div className="border-b border-[#e5ddc8]/80 p-2">
141 <div className="flex items-center gap-1.5 rounded-md border border-[#d8ccb5]/80 bg-white/80 px-2 py-1">
142 <Search className="h-3.5 w-3.5 shrink-0 text-[#7c6a4c]/60" />
143 <input
144 type="text"
145 value={query}
146 onChange={(event) => setQuery(event.target.value)}
147 placeholder={t('styles.searchPlaceholder')}
148 className="min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground"
149 />
150 {query ? (
151 <button
152 type="button"
153 onClick={() => setQuery('')}
154 className="shrink-0 text-[#7c6a4c]/60 transition-colors hover:text-[#7c6a4c]"
155 aria-label={t('styles.clearSearch')}
156 >
157 <X className="h-3.5 w-3.5" />
158 </button>
159 ) : null}
160 </div>
161 </div>
162 <div className="max-h-[min(300px,40vh)] overflow-y-auto p-1">
163 {filtered.length === 0 ? (
164 <p className="px-3 py-6 text-center text-xs text-muted-foreground">
165 {t('styles.noMatchingStyles')}
166 </p>
167 ) : (
168 filtered.map((option) => {
169 const thumb = thumbnailOverrides[option.id] || option.thumbnailPath
170 const isSelected = option.id === value
171 return (
172 <button
173 key={option.id}
174 type="button"
175 onClick={() => handlePick(option.id)}
176 className={cn(
177 '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',
178 isSelected && 'bg-[#dbe7ca] text-[#2f3b28]',
179 compact && 'py-1.5'
180 )}
181 >
182 {thumb ? (
183 <img
184 src={thumbnailUrl(thumb)}
185 alt=""
186 aria-hidden="true"
187 className="h-11 w-[78px] shrink-0 rounded-[3px] border border-black/5 object-cover"
188 />
189 ) : (
190 <span className="h-11 w-[78px] shrink-0 rounded-[3px] border border-[#e5ddc8] bg-[#f5f1e8]" />
191 )}
192 <div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 py-0.5">
193 <span className="flex min-w-0 items-center gap-1.5">
194 <span className={cn('truncate font-medium', compact ? 'text-xs' : 'text-sm')}>
195 {option.label}
196 </span>
197 {option.styleCase && (
198 <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]">
199 {option.styleCase}
200 </span>
201 )}
202 {option.favoriteAt != null && (
203 <Star className="h-3.5 w-3.5 shrink-0 fill-[#d6a942] text-[#d6a942]" />
204 )}
205 </span>
206 {option.description && (
207 <span
208 className={cn(
209 'truncate leading-tight text-muted-foreground',
210 compact ? 'text-[10px]' : 'text-[11px]'
211 )}
212 >
213 {option.description}
214 </span>
215 )}
216 </div>
217 </button>
218 )
219 })
220 )}
221 </div>
222 </PopoverContent>
223 </Popover>
224 )
225 }
226
226 lines Plain Text