返回 oh-my-ppt
fonts.tsx
根目录 / src / renderer / src / pages / fonts.tsx
1 import { useEffect, useState } from 'react'
2 import { Button } from '@renderer/components/ui/Button'
3 import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/Card'
4 import {
5 Dialog,
6 DialogContent,
7 DialogDescription,
8 DialogHeader,
9 DialogTitle
10 } from '@renderer/components/ui/Dialog'
11 import { Input } from '@renderer/components/ui/Input'
12 import {
13 Select,
14 SelectContent,
15 SelectItem,
16 SelectTrigger,
17 SelectValue
18 } from '@renderer/components/ui/Select'
19 import { ipc, type FontListItem, type FontRole, type FontScript } from '@renderer/lib/ipc'
20 import { useToastStore } from '@renderer/store'
21 import { useT } from '@renderer/i18n'
22 import { FolderOpen, Loader2, Trash2, Type, Upload, X } from 'lucide-react'
23
24 const roleClassName = (role: FontRole[]): string => {
25 const hasTitle = role.includes('title')
26 const hasBody = role.includes('body')
27 if (hasTitle && hasBody) return 'border-[#bad8b7]/80 bg-[#eef9ec] text-[#4a7a46]'
28 if (hasTitle) return 'border-[#d6c08d]/80 bg-[#fff7e8] text-[#7c6a4c]'
29 if (hasBody) return 'border-[#bdd2e6]/80 bg-[#eef6ff] text-[#3e6685]'
30 return 'border-[#d5cfc5]/60 bg-[#f9f6f1] text-[#6b6560]'
31 }
32
33 const scriptsClassName = (scripts: FontScript[]): string => {
34 const hasLatin = scripts.includes('latin')
35 const hasCjk = scripts.includes('cjk')
36 if (hasLatin && hasCjk) return 'border-[#c8b8d4]/80 bg-[#f4eff8] text-[#5e4a72]'
37 if (hasCjk) return 'border-[#d6c08d]/80 bg-[#fff7e8] text-[#7c6a4c]'
38 if (hasLatin) return 'border-[#c5d4c0]/80 bg-[#f0f6ec] text-[#4a6940]'
39 return 'border-[#d5cfc5]/60 bg-[#f9f6f1] text-[#6b6560]'
40 }
41
42 const roleFromValue = (value: string): FontRole[] => {
43 if (value === 'title') return ['title']
44 if (value === 'body') return ['body']
45 return ['title', 'body']
46 }
47
48 const scriptsFromValue = (value: string): FontScript[] => {
49 if (value === 'latin') return ['latin']
50 if (value === 'cjk') return ['cjk']
51 return ['latin', 'cjk']
52 }
53
54 const previewText = (scripts: FontScript[]): string => {
55 const hasCjk = scripts.includes('cjk')
56 if (hasCjk) return 'Aa 永远好奇'
57 return 'Aa Always Curious'
58 }
59
60 const WEIGHT_FROM_NAME: Record<string, string> = {
61 thin: '100',
62 hairline: '100',
63 extralight: '200',
64 ultralight: '200',
65 light: '300',
66 regular: '400',
67 normal: '400',
68 medium: '500',
69 semibold: '600',
70 demibold: '600',
71 bold: '700',
72 extrabold: '800',
73 ultrabold: '800',
74 black: '900',
75 heavy: '900'
76 }
77
78 const guessWeightAndStyle = (
79 filePath: string
80 ): { weight: string; style: 'normal' | 'italic' } => {
81 const name = filePath.split(/[\\/]/).pop()?.replace(/\.woff2$/i, '') || ''
82 const isItalic = /\bitalic\b/i.test(name)
83 const weight = Object.entries(WEIGHT_FROM_NAME).find(([key]) => {
84 const re = new RegExp(`(?:[-_]|\\b)${key}(?:[-_]|\\b|$)`, 'i')
85 return re.test(name)
86 })?.[1] || '400'
87 return { weight, style: isItalic ? 'italic' : 'normal' }
88 }
89
90 export function FontsPage(): React.JSX.Element {
91 const { success, error } = useToastStore()
92 const t = useT()
93 const [loading, setLoading] = useState(true)
94 const [uploading, setUploading] = useState(false)
95 const [previewReady, setPreviewReady] = useState(false)
96 const [googleFonts, setGoogleFonts] = useState<FontListItem[]>([])
97 const [userFonts, setUserFonts] = useState<FontListItem[]>([])
98 const [family, setFamily] = useState('')
99 const [category, setCategory] = useState('sans')
100 const [role, setRole] = useState('both')
101 const [scripts, setScripts] = useState('mixed')
102 const [fileEntries, setFileEntries] = useState<
103 Array<{ path: string; weight: string; style: 'normal' | 'italic' }>
104 >([])
105 const [uploadOpen, setUploadOpen] = useState(false)
106
107 const roleToLabel = (r: FontRole[]): string => {
108 const hasTitle = r.includes('title')
109 const hasBody = r.includes('body')
110 if (hasTitle && hasBody) return t('fonts.roleBoth')
111 if (hasTitle) return t('fonts.roleTitle')
112 if (hasBody) return t('fonts.roleBody')
113 return t('fonts.roleNone')
114 }
115
116 const scriptsToLabel = (s: FontScript[]): string => {
117 const hasLatin = s.includes('latin')
118 const hasCjk = s.includes('cjk')
119 if (hasLatin && hasCjk) return t('fonts.scriptsMixed')
120 if (hasCjk) return t('fonts.scriptsCjk')
121 if (hasLatin) return t('fonts.scriptsLatin')
122 return t('fonts.scriptsNone')
123 }
124
125 const categoryLabels: Record<string, string> = {
126 sans: t('fonts.categorySans'),
127 serif: t('fonts.categorySerif'),
128 display: t('fonts.categoryDisplay'),
129 handwriting: t('fonts.categoryHandwriting'),
130 monospace: t('fonts.categoryMonospace')
131 }
132
133 const loadFonts = async (): Promise<void> => {
134 setLoading(true)
135 try {
136 const result = await ipc.listFonts()
137 setGoogleFonts(result.googleFonts)
138 setUserFonts(result.userFonts)
139 } catch (err) {
140 error(t('fonts.loadFailed'), {
141 description: err instanceof Error ? err.message : t('common.retryLater')
142 })
143 } finally {
144 setLoading(false)
145 }
146 }
147
148 const loadPreviewCss = async (): Promise<void> => {
149 try {
150 const css = await ipc.loadFontPreviewCss()
151 if (!css) return
152 const id = 'font-preview-styles'
153 let el = document.getElementById(id) as HTMLStyleElement | null
154 if (!el) {
155 el = document.createElement('style')
156 el.id = id
157 document.head.appendChild(el)
158 }
159 el.textContent = css
160 setPreviewReady(true)
161 } catch {
162 // Preview is non-critical
163 }
164 }
165
166 useEffect(() => {
167 void loadFonts()
168 void loadPreviewCss()
169 }, [])
170
171 const handleChooseFiles = async (): Promise<void> => {
172 try {
173 const result = await ipc.chooseFontFiles()
174 if (!result.canceled) {
175 setFileEntries(
176 (result.filePaths || []).map((p) => ({ path: p, ...guessWeightAndStyle(p) }))
177 )
178 }
179 } catch (err) {
180 error(t('fonts.chooseFailed'), {
181 description: err instanceof Error ? err.message : t('common.retryLater')
182 })
183 }
184 }
185
186 const updateFileEntry = (
187 index: number,
188 field: 'weight' | 'style',
189 value: string
190 ): void => {
191 setFileEntries((prev) =>
192 prev.map((e, i) =>
193 i === index
194 ? { ...e, [field]: field === 'style' ? (value as 'normal' | 'italic') : value }
195 : e
196 )
197 )
198 }
199
200 const removeFileEntry = (index: number): void => {
201 setFileEntries((prev) => prev.filter((_, i) => i !== index))
202 }
203
204 const handleUpload = async (): Promise<void> => {
205 const familyText = family.trim()
206 if (!familyText) {
207 error(t('fonts.fillFamily'))
208 return
209 }
210 if (fileEntries.length === 0) {
211 error(t('fonts.selectFile'))
212 return
213 }
214 if (!scripts) {
215 error(t('fonts.selectScripts'))
216 return
217 }
218 setUploading(true)
219 try {
220 await ipc.uploadFont({
221 family: familyText,
222 category,
223 role: roleFromValue(role),
224 scripts: scriptsFromValue(scripts),
225 files: fileEntries.map((entry) => {
226 const w = Number.parseInt(entry.weight, 10)
227 return {
228 path: entry.path,
229 weight: Number.isFinite(w) ? w : 400,
230 style: entry.style
231 }
232 })
233 })
234 success(t('fonts.uploaded'))
235 setUploadOpen(false)
236 setFamily('')
237 setCategory('sans')
238 setRole('both')
239 setScripts('')
240 setFileEntries([])
241 await loadFonts()
242 void loadPreviewCss()
243 } catch (err) {
244 error(t('fonts.uploadFailed'), {
245 description: err instanceof Error ? err.message : t('common.retryLater')
246 })
247 } finally {
248 setUploading(false)
249 }
250 }
251
252 const handleDelete = async (font: FontListItem): Promise<void> => {
253 try {
254 await ipc.deleteFont(font.id)
255 success(t('fonts.deleted'))
256 await loadFonts()
257 void loadPreviewCss()
258 } catch (err) {
259 error(t('fonts.deleteFailed'), {
260 description: err instanceof Error ? err.message : t('common.retryLater')
261 })
262 }
263 }
264
265 return (
266 <div className="mx-auto w-full max-w-6xl p-6">
267 <div className="mb-6">
268 <p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{t('fonts.eyebrow')}</p>
269 <div className="mt-2 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
270 <h1 className="organic-serif text-[32px] font-semibold leading-none text-[#3e4a32]">
271 {t('fonts.title')}
272 </h1>
273 <div className="flex gap-2">
274 <Button size="sm" variant="outline" onClick={() => void ipc.revealFontsFolder()}>
275 <FolderOpen className="mr-2 h-4 w-4" />
276 {t('fonts.openFolder')}
277 </Button>
278 <Button size="sm" className="border-[#7ea06f]/45" onClick={() => setUploadOpen(true)}>
279 <Upload className="mr-2 h-4 w-4" />
280 {t('fonts.upload')}
281 </Button>
282 </div>
283 </div>
284 <p className="mt-2 text-[12px] text-muted-foreground">
285 {t('fonts.description')}
286 </p>
287 </div>
288
289 <div className="space-y-4">
290 {/* Upload dialog */}
291 <Dialog open={uploadOpen} onOpenChange={(open) => {
292 setUploadOpen(open)
293 if (!open) {
294 setFamily('')
295 setCategory('sans')
296 setRole('both')
297 setScripts('mixed')
298 setFileEntries([])
299 }
300 }}>
301 <DialogContent className="max-w-2xl">
302 <DialogHeader>
303 <DialogTitle>{t('fonts.uploadDialogTitle')}</DialogTitle>
304 <DialogDescription className="text-xs text-muted-foreground/70">
305 {t('fonts.uploadDialogDescription')}{' '}
306 {t('fonts.uploadDialogDownloadPre')}{' '}
307 <a
308 href="https://gwfh.mranftl.com/fonts"
309 target="_blank"
310 rel="noopener noreferrer"
311 className="text-[#5a7a4e] underline underline-offset-2 hover:text-[#3e5a34]"
312 >
313 {t('fonts.googleFontsHelperLink')}
314 </a>{' '}
315 {t('fonts.uploadDialogDownloadPost')}
316 </DialogDescription>
317 </DialogHeader>
318 <div className="grid gap-3 sm:grid-cols-[1fr_160px_160px]">
319 <div>
320 <label className="mb-1 block text-sm font-medium">{t('fonts.familyName')}</label>
321 <Input
322 placeholder={t('fonts.familyNamePlaceholder')}
323 value={family}
324 onChange={(e) => setFamily(e.target.value)}
325 className="h-9"
326 />
327 </div>
328 <div>
329 <label className="mb-1 block text-sm font-medium">{t('fonts.role')}</label>
330 <Select value={role} onValueChange={setRole}>
331 <SelectTrigger className="h-9">
332 <SelectValue />
333 </SelectTrigger>
334 <SelectContent>
335 <SelectItem value="both">{t('fonts.roleBoth')}</SelectItem>
336 <SelectItem value="title">{t('fonts.roleTitle')}</SelectItem>
337 <SelectItem value="body">{t('fonts.roleBody')}</SelectItem>
338 </SelectContent>
339 </Select>
340 </div>
341 <div>
342 <label className="mb-1 block text-sm font-medium">{t('fonts.scripts')}</label>
343 <Select value={scripts} onValueChange={setScripts}>
344 <SelectTrigger className="h-9">
345 <SelectValue placeholder={t('fonts.scriptsPlaceholder')} />
346 </SelectTrigger>
347 <SelectContent>
348 <SelectItem value="latin">{t('fonts.scriptsLatin')}</SelectItem>
349 <SelectItem value="cjk">{t('fonts.scriptsCjk')}</SelectItem>
350 <SelectItem value="mixed">{t('fonts.scriptsMixed')}</SelectItem>
351 </SelectContent>
352 </Select>
353 </div>
354 </div>
355 <div className="flex flex-wrap items-end gap-3">
356 <div className="w-40">
357 <label className="mb-1 block text-sm font-medium">{t('fonts.category')}</label>
358 <Select value={category} onValueChange={setCategory}>
359 <SelectTrigger className="h-9">
360 <SelectValue />
361 </SelectTrigger>
362 <SelectContent>
363 {Object.entries(categoryLabels).map(([value, label]) => (
364 <SelectItem key={value} value={value}>
365 {label}
366 </SelectItem>
367 ))}
368 </SelectContent>
369 </Select>
370 </div>
371 <Button
372 type="button"
373 variant="outline"
374 size="sm"
375 className="h-9 border-[#7ea06f]/45"
376 onClick={() => void handleChooseFiles()}
377 >
378 <Type className="mr-1.5 h-3.5 w-3.5" />
379 {t('fonts.chooseFiles')}
380 </Button>
381 </div>
382 <table className="w-full text-sm">
383 <thead>
384 <tr className="border-b border-[#d8ccb5]/60 text-xs text-muted-foreground">
385 <th className="pb-1.5 text-left font-medium">File</th>
386 <th className="pb-1.5 text-center font-medium" style={{ width: 72 }}>Font Weight</th>
387 <th className="pb-1.5 text-center font-medium" style={{ width: 110 }}>Style</th>
388 <th className="pb-1.5 font-medium" style={{ width: 32 }}></th>
389 </tr>
390 </thead>
391 {fileEntries.length > 0 && (
392 <tbody>
393 {fileEntries.map((entry, i) => (
394 <tr key={entry.path} className="border-b border-[#d8ccb5]/30 align-middle">
395 <td className="py-1.5 pr-2">
396 <span className="block truncate text-[#33402a]">
397 {entry.path.split(/[\\/]/).pop() || entry.path}
398 </span>
399 </td>
400 <td className="py-1.5">
401 <Input
402 value={entry.weight}
403 inputMode="numeric"
404 onChange={(e) => updateFileEntry(i, 'weight', e.target.value)}
405 className="h-7 w-[64px] text-center text-sm"
406 />
407 </td>
408 <td className="py-1.5 text-center">
409 <Select
410 value={entry.style}
411 onValueChange={(v) => updateFileEntry(i, 'style', v)}
412 >
413 <SelectTrigger className="h-7 w-[110px] text-sm">
414 <SelectValue />
415 </SelectTrigger>
416 <SelectContent>
417 <SelectItem value="normal">Normal</SelectItem>
418 <SelectItem value="italic">Italic</SelectItem>
419 </SelectContent>
420 </Select>
421 </td>
422 <td className="py-1.5 text-center">
423 <Button
424 type="button"
425 size="sm"
426 variant="ghost"
427 className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
428 onClick={() => removeFileEntry(i)}
429 aria-label={t('fonts.removeFile')}
430 >
431 <X className="h-3.5 w-3.5" />
432 </Button>
433 </td>
434 </tr>
435 ))}
436 </tbody>
437 )}
438 </table>
439 <div className="flex justify-end pt-2">
440 <Button
441 type="button"
442 size="sm"
443 className="h-9 min-w-[120px]"
444 onClick={() => void handleUpload()}
445 disabled={uploading}
446 >
447 {uploading ? (
448 <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
449 ) : (
450 <Upload className="mr-1.5 h-3.5 w-3.5" />
451 )}
452 {t('fonts.uploadButton')}
453 </Button>
454 </div>
455 </DialogContent>
456 </Dialog>
457
458 {/* User fonts */}
459 <Card>
460 <CardHeader className="p-5 pb-3">
461 <CardTitle className="text-base">{t('fonts.uploadedFonts')}</CardTitle>
462 {userFonts.length > 0 && (
463 <p className="mt-1 text-xs text-muted-foreground">
464 {t('fonts.fontCount', { count: userFonts.length })}
465 </p>
466 )}
467 </CardHeader>
468 <CardContent className="p-5 pt-0">
469 {loading ? (
470 <p className="py-4 text-center text-sm text-muted-foreground">{t('fonts.loading')}</p>
471 ) : userFonts.length === 0 ? (
472 <div className="rounded-lg border border-dashed border-[#d8ccb5]/85 bg-[#fff9ef]/70 py-6 text-center text-sm text-muted-foreground">
473 {t('fonts.emptyUpload')}
474 </div>
475 ) : (
476 <div className="space-y-2">
477 {userFonts.map((font) => (
478 <div
479 key={font.id}
480 className="group flex items-center justify-between gap-3 rounded-lg border border-[#d8ccb5]/80 bg-[#fffdf8]/78 p-3 transition-all hover:border-[#c4b89e]/90 hover:shadow-[0_8px_20px_rgba(90,72,52,0.1)]"
481 >
482 <div className="min-w-0 flex-1">
483 <p className="truncate text-sm font-medium text-[#33402a]">{font.family}</p>
484 {previewReady && (
485 <p
486 className="mt-1 truncate text-lg text-[#5a6650]/80"
487 style={{ fontFamily: `"${font.family}", sans-serif` }}
488 >
489 {previewText(font.scripts)}
490 </p>
491 )}
492 <div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs">
493 <span className={`rounded-md border px-1.5 py-0.5 font-medium ${roleClassName(font.role)}`}>
494 {roleToLabel(font.role)}
495 </span>
496 <span className={`rounded-md border px-1.5 py-0.5 font-medium ${scriptsClassName(font.scripts)}`}>
497 {scriptsToLabel(font.scripts)}
498 </span>
499 <span className="rounded-md border border-[#d5cfc5]/60 bg-[#f9f6f1] px-1.5 py-0.5 text-[#6b6560]">
500 {categoryLabels[font.category] || font.category}
501 </span>
502 <span className="text-muted-foreground">
503 {t('fonts.fileCount', { count: font.files?.length || 0 })}
504 </span>
505 </div>
506 </div>
507 <Button
508 type="button"
509 size="sm"
510 variant="ghost"
511 className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
512 onClick={() => void handleDelete(font)}
513 aria-label={t('fonts.deleteLabel', { family: font.family })}
514 >
515 <Trash2 className="h-4 w-4" />
516 </Button>
517 </div>
518 ))}
519 </div>
520 )}
521 </CardContent>
522 </Card>
523
524 {/* Google fonts */}
525 <Card>
526 <CardHeader className="p-5 pb-3">
527 <div className="flex items-center justify-between">
528 <div>
529 <CardTitle className="text-base">{t('fonts.googleFontsTitle')}</CardTitle>
530 <p className="mt-1 text-xs text-muted-foreground">
531 {t('fonts.googleFontsDesc')}
532 </p>
533 </div>
534 <span className="rounded-full bg-[#e9efde] px-2.5 py-0.5 text-[11px] font-medium text-[#506141]">
535 {googleFonts.length}
536 </span>
537 </div>
538 </CardHeader>
539 <CardContent className="p-5 pt-0">
540 <div className="max-h-[460px] overflow-auto pr-1">
541 <div className="grid gap-2 sm:grid-cols-2">
542 {googleFonts.map((font) => (
543 <div
544 key={font.id}
545 className="rounded-lg border border-[#d8ccb5]/60 bg-[#fffdf8]/50 px-3 py-2.5 transition-colors hover:border-[#c4b89e]/80 hover:bg-[#fffdf8]"
546 >
547 {previewReady && (
548 <p
549 className="truncate text-lg text-[#5a6650]/80"
550 style={{ fontFamily: `"${font.family}", sans-serif` }}
551 >
552 {previewText(font.scripts)}
553 </p>
554 )}
555 <p className="text-sm font-medium text-[#33402a]">{font.family}</p>
556 <div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs">
557 <span className={`rounded-md border px-1.5 py-0.5 font-medium ${roleClassName(font.role)}`}>
558 {roleToLabel(font.role)}
559 </span>
560 <span className={`rounded-md border px-1.5 py-0.5 font-medium ${scriptsClassName(font.scripts)}`}>
561 {scriptsToLabel(font.scripts)}
562 </span>
563 <span className="text-muted-foreground">{font.category}</span>
564 </div>
565 </div>
566 ))}
567 </div>
568 </div>
569 </CardContent>
570 </Card>
571 </div>
572 </div>
573 )
574 }
575
575 lines Plain Text