返回 oh-my-ppt
style-editor.tsx
根目录 / src / renderer / src / pages / style-editor.tsx
1 import { useEffect, useMemo, useRef, useState } from 'react'
2 import { useNavigate, useParams } from 'react-router-dom'
3 import { Button } from '../components/ui/Button'
4 import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card'
5 import {
6 AlertDialog,
7 AlertDialogAction,
8 AlertDialogCancel,
9 AlertDialogContent,
10 AlertDialogDescription,
11 AlertDialogTitle,
12 AlertDialogTrigger
13 } from '../components/ui/AlertDialog'
14 import { Input, Textarea } from '../components/ui/Input'
15 import { Popover, PopoverContent, PopoverTrigger } from '../components/ui/Popover'
16 import { ScrollArea } from '../components/ui/ScrollArea'
17 import { useToastStore } from '../store'
18 import { ipc, type StyleDetail, type StyleParseResult } from '@renderer/lib/ipc'
19 import ReactMarkdown from 'react-markdown'
20 import {
21 ArrowLeft,
22 CircleHelp,
23 Eye,
24 Import,
25 Loader2,
26 Pencil,
27 Save,
28 Trash2
29 } from 'lucide-react'
30 import { useT } from '../i18n'
31 import { ModelSplitButton } from '../components/model/ModelActionButton'
32 import { useModelAction } from '../hooks/useModelAction'
33 import {
34 isSupportedImageMimeType,
35 normalizeImageMimeType
36 } from '@shared/image-mime'
37
38 const MAX_STYLE_TEXT_FILE_SIZE_MB = 10
39 const MAX_STYLE_PPTX_FILE_SIZE_MB = 500
40 const MAX_STYLE_IMAGE_SIZE_MB = 5
41 const MAX_STYLE_TEXT_FILE_SIZE_BYTES = MAX_STYLE_TEXT_FILE_SIZE_MB * 1024 * 1024
42 const MAX_STYLE_PPTX_FILE_SIZE_BYTES = MAX_STYLE_PPTX_FILE_SIZE_MB * 1024 * 1024
43 const MAX_STYLE_IMAGE_SIZE_BYTES = MAX_STYLE_IMAGE_SIZE_MB * 1024 * 1024
44 const isPptxFileName = (name: string): boolean => /\.pptx$/i.test(name.trim())
45 const isImageFileName = (name: string): boolean => /\.(png|jpe?g|webp)$/i.test(name.trim())
46 const getImageMimeTypeFromFileName = (name: string): string => {
47 const normalized = name.trim().toLowerCase()
48 if (normalized.endsWith('.png')) return 'image/png'
49 if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg'
50 if (normalized.endsWith('.webp')) return 'image/webp'
51 return ''
52 }
53
54 export function StyleEditorPage(): React.JSX.Element {
55 const navigate = useNavigate()
56 const { styleId = 'new' } = useParams<{ styleId: string }>()
57 const isNew = styleId === 'new'
58
59 const [draft, setDraft] = useState<StyleDetail | null>(null)
60 const [loadedRecordId, setLoadedRecordId] = useState<string>('')
61 const [labelInput, setLabelInput] = useState('')
62 const [descriptionInput, setDescriptionInput] = useState('')
63 const [markdownInput, setMarkdownInput] = useState('')
64 const [saving, setSaving] = useState(false)
65 const [deleting, setDeleting] = useState(false)
66 const [loading, setLoading] = useState(false)
67 const [importing, setImporting] = useState(false)
68 const [mode, setMode] = useState<'edit' | 'preview'>('edit')
69 const styleFileInputRef = useRef<HTMLInputElement | null>(null)
70 const pendingImportModelConfigIdRef = useRef<string | null>(null)
71 const { success, error, warning, info } = useToastStore()
72 const modelAction = useModelAction()
73 const { selectedModelConfigId, ensureModelActive } = modelAction
74 const t = useT()
75
76 useEffect(() => {
77 const run = async (): Promise<void> => {
78 setLoading(true)
79 try {
80 if (isNew) {
81 const initial: StyleDetail = {
82 id: '',
83 label: t('styleEditor.defaultLabel'),
84 description: t('styleEditor.defaultDescription'),
85 aliases: [],
86 styleSkill: t('styleEditor.template'),
87 source: 'custom',
88 editable: true,
89 category: t('styleEditor.defaultCategory'),
90 styleCase: '',
91 }
92 setDraft(initial)
93 setLabelInput(initial.label)
94 setDescriptionInput(initial.description || '')
95 setMarkdownInput(initial.styleSkill)
96 setLoadedRecordId('')
97 return
98 }
99 const detail = await ipc.getStyleDetail(styleId)
100 setDraft(detail)
101 setLoadedRecordId(detail.id)
102 setLabelInput(detail.label)
103 setDescriptionInput(detail.description || '')
104 setMarkdownInput(detail.styleSkill)
105 } catch (e) {
106 error(t('styleEditor.detailLoadFailed'), {
107 description: e instanceof Error ? e.message : t('common.retryLater'),
108 })
109 } finally {
110 setLoading(false)
111 }
112 }
113 void run()
114 }, [error, isNew, styleId, t])
115
116 const currentStyleName = useMemo(
117 () => (isNew ? t('styleEditor.createTitle') : t('styleEditor.editTitle')),
118 [isNew, t]
119 )
120
121 const handleSave = async (): Promise<void> => {
122 if (!draft) return
123 const nextLabel = labelInput.trim()
124 const nextDescription = descriptionInput.trim()
125 const nextMarkdown = markdownInput.trim()
126 const shouldCreate = isNew || !loadedRecordId
127
128 if (!shouldCreate && !draft.id.trim()) {
129 warning(t('styleEditor.invalidStyleId'), { description: t('styleEditor.backAndRetry') })
130 return
131 }
132 if (!nextLabel) {
133 warning(t('styleEditor.fillName'))
134 return
135 }
136 if (!nextMarkdown) {
137 warning(t('styleEditor.fillPrompt'))
138 return
139 }
140 setSaving(true)
141 try {
142 const createPayload = {
143 label: nextLabel,
144 description: nextDescription,
145 category: draft.category || t('styleEditor.defaultCategory'),
146 aliases: draft.aliases || [],
147 styleSkill: nextMarkdown,
148 styleCase: draft.styleCase || ''
149 }
150 const result = shouldCreate
151 ? await ipc.createStyle(createPayload)
152 : await ipc.updateStyle({
153 ...createPayload,
154 id: draft.id.trim().toLowerCase()
155 })
156 setLoadedRecordId(result.id)
157 success(t('styleEditor.saved'), {
158 description:
159 result.source === 'override' ? t('styleEditor.savedOverride') : t('styleEditor.savedCustom')
160 })
161 setDraft((prev) =>
162 prev
163 ? {
164 ...prev,
165 id: result.id,
166 label: createPayload.label,
167 description: createPayload.description,
168 category: createPayload.category,
169 aliases: createPayload.aliases,
170 styleSkill: createPayload.styleSkill,
171 styleCase: createPayload.styleCase
172 }
173 : prev
174 )
175 navigate('/styles', { replace: true })
176 } catch (e) {
177 error(t('styleEditor.saveFailed'), {
178 description: e instanceof Error ? e.message : t('common.retryLater'),
179 })
180 } finally {
181 setSaving(false)
182 }
183 }
184
185 const ensureUploadPrerequisites = async (): Promise<boolean> => {
186 const validation = await ipc.validateUploadPrerequisites()
187 if (validation.ready) return true
188 warning(t('home.settingsRequiredTitle'), {
189 description: validation.message || t('home.settingsRequired'),
190 action: {
191 label: t('home.goToSettings'),
192 onClick: () => navigate('/settings')
193 }
194 })
195 return false
196 }
197
198 const handleImportStyleClick = async (modelConfigId = selectedModelConfigId): Promise<void> => {
199 if (importing) return
200 const resolvedModelConfigId = await ensureModelActive(modelConfigId)
201 if (!resolvedModelConfigId) return
202 if (!(await ensureUploadPrerequisites())) return
203 pendingImportModelConfigIdRef.current = resolvedModelConfigId
204 styleFileInputRef.current?.click()
205 }
206
207 const applyParsedStyle = (result: StyleParseResult): void => {
208 setLabelInput(result.label)
209 setDescriptionInput(result.description)
210 setMarkdownInput(result.styleSkill)
211 setDraft((prev) =>
212 prev
213 ? {
214 ...prev,
215 label: result.label,
216 description: result.description,
217 category: result.category,
218 aliases: result.aliases,
219 styleSkill: result.styleSkill,
220 styleCase: result.styleCase || prev.styleCase || ''
221 }
222 : prev
223 )
224 }
225
226 const parseSelectedStyleFile = async (
227 file: File,
228 modelConfigId: string
229 ): Promise<StyleParseResult> => {
230 const isPptx = isPptxFileName(file.name || '')
231 const maxSizeBytes = isPptx ? MAX_STYLE_PPTX_FILE_SIZE_BYTES : MAX_STYLE_TEXT_FILE_SIZE_BYTES
232 const maxSizeMb = isPptx ? MAX_STYLE_PPTX_FILE_SIZE_MB : MAX_STYLE_TEXT_FILE_SIZE_MB
233 if (file.size > maxSizeBytes) {
234 throw new Error(t('styleEditor.fileTooLarge', { maxSize: maxSizeMb }))
235 }
236
237 const filePath = window.electron?.getPathForFile?.(file) || ''
238 if (!filePath) {
239 throw new Error(t('styleEditor.filePathFailed'))
240 }
241
242 return isPptx
243 ? await ipc.parseStylePptx({ filePath, modelConfigId })
244 : await ipc.parseStyleFile({ filePath, modelConfigId })
245 }
246
247 const parseSelectedStyleImage = async (
248 file: File,
249 modelConfigId: string
250 ): Promise<StyleParseResult> => {
251 const hintedMimeType = normalizeImageMimeType(file.type)
252 const fallbackMimeType = getImageMimeTypeFromFileName(file.name || '')
253 if (!isSupportedImageMimeType(file.type) && !fallbackMimeType) {
254 throw new Error(t('styleEditor.imageFormatInvalid'))
255 }
256 if (file.size > MAX_STYLE_IMAGE_SIZE_BYTES) {
257 throw new Error(t('styleEditor.fileTooLarge', { maxSize: MAX_STYLE_IMAGE_SIZE_MB }))
258 }
259
260 const dataUrl = await new Promise<string>((resolve, reject) => {
261 const reader = new FileReader()
262 reader.onload = () => resolve(String(reader.result || ''))
263 reader.onerror = () => reject(new Error(t('styleEditor.imageReadFailed')))
264 reader.readAsDataURL(file)
265 })
266 const match = dataUrl.match(/^data:([^;]*);base64,(.+)$/)
267 if (!match) {
268 throw new Error(t('styleEditor.imageReadFailed'))
269 }
270 const dataUrlMimeType = normalizeImageMimeType(match[1])
271 const mimeType = isSupportedImageMimeType(match[1])
272 ? dataUrlMimeType
273 : isSupportedImageMimeType(file.type)
274 ? hintedMimeType
275 : fallbackMimeType
276 const imageBase64 = String(match[2] || '').trim()
277 if (!mimeType || !imageBase64) {
278 throw new Error(t('styleEditor.imageReadFailed'))
279 }
280 return await ipc.parseStyleImage({ imageBase64, mimeType, modelConfigId })
281 }
282
283 const handleStyleFileSelected = async (files: FileList | null): Promise<void> => {
284 const file = files?.[0]
285 if (styleFileInputRef.current) styleFileInputRef.current.value = ''
286 if (!file) return
287 if (!(await ensureUploadPrerequisites())) return
288
289 setImporting(true)
290 try {
291 const modelConfigId = await ensureModelActive(
292 pendingImportModelConfigIdRef.current || selectedModelConfigId
293 )
294 if (!modelConfigId) return
295 const isImage = isSupportedImageMimeType(file.type) || isImageFileName(file.name || '')
296 const result = isImage
297 ? await parseSelectedStyleImage(file, modelConfigId)
298 : await parseSelectedStyleFile(file, modelConfigId)
299 applyParsedStyle(result)
300 success(t('styleEditor.importSuccess'))
301 } catch (e) {
302 error(t('styleEditor.importFailed'), {
303 description: e instanceof Error ? e.message : t('common.retryLater')
304 })
305 } finally {
306 pendingImportModelConfigIdRef.current = null
307 setImporting(false)
308 }
309 }
310
311 const handleDelete = async (): Promise<void> => {
312 if (!draft || deleting) return
313 setDeleting(true)
314 try {
315 const result = await ipc.deleteStyle(draft.id)
316 if (!result.deleted) {
317 warning(t('styleEditor.deleteFailed'), { description: t('common.retryLater') })
318 return
319 }
320 info(t('styleEditor.deleted'))
321 navigate('/styles', { replace: true })
322 } catch (e) {
323 error(t('styleEditor.deleteFailed'), {
324 description: e instanceof Error ? e.message : t('common.retryLater'),
325 })
326 } finally {
327 setDeleting(false)
328 }
329 }
330
331 return (
332 <div className="mx-auto w-full max-w-6xl p-6">
333 <div className="mb-6">
334 <p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{t('styleEditor.eyebrow')}</p>
335 <div className="mt-2 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
336 <div className="min-w-0">
337 <h1 className="organic-serif text-[28px] font-semibold leading-none text-[#3e4a32]">{currentStyleName}</h1>
338 </div>
339 <div className="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">
340 <Button size="sm" variant="secondary" className="min-w-[112px]" onClick={() => navigate('/styles')}>
341 <ArrowLeft className="mr-2 h-4 w-4" />
342 {t('styleEditor.backToList')}
343 </Button>
344 </div>
345 </div>
346 </div>
347
348 {loading || !draft ? (
349 <Card>
350 <CardContent className="py-10 text-sm text-muted-foreground">{t('styleEditor.loading')}</CardContent>
351 </Card>
352 ) : (
353 <>
354 {isNew ? (
355 <div className="mb-4 space-y-2">
356 <ModelSplitButton
357 modelAction={modelAction}
358 label={t('styleEditor.importStyle')}
359 loadingLabel={t('styleEditor.importing')}
360 loading={importing}
361 icon={Import}
362 tone="subtle"
363 dropdownAlign="start"
364 onRun={handleImportStyleClick}
365 />
366 <p className="text-xs text-muted-foreground">
367 {t('styleEditor.importHint', {
368 textMaxSize: MAX_STYLE_TEXT_FILE_SIZE_MB,
369 pptxMaxSize: MAX_STYLE_PPTX_FILE_SIZE_MB,
370 imageMaxSize: MAX_STYLE_IMAGE_SIZE_MB
371 })}
372 </p>
373 </div>
374 ) : null}
375 <input
376 ref={styleFileInputRef}
377 type="file"
378 accept=".md,.txt,.html,.htm,.pptx,image/png,image/jpeg,image/webp"
379 multiple={false}
380 className="hidden"
381 onChange={(e) => void handleStyleFileSelected(e.target.files)}
382 />
383 <Card>
384 <CardHeader className="p-4">
385 <CardTitle className="text-sm">{t('styleEditor.skillMarkdown')}</CardTitle>
386 </CardHeader>
387 <CardContent className="space-y-3 p-4 pt-0">
388 <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
389 <div>
390 <label className="mb-1.5 block text-xs font-medium">{t('styleEditor.name')}</label>
391 <Input
392 value={labelInput}
393 onChange={(e) => setLabelInput(e.target.value)}
394 className="h-8 px-3 py-1.5 text-xs"
395 />
396 </div>
397 <div>
398 <label className="mb-1.5 block text-xs font-medium">{t('styleEditor.descriptionLabel')}</label>
399 <Input
400 value={descriptionInput}
401 onChange={(e) => setDescriptionInput(e.target.value)}
402 placeholder={t('styleEditor.descriptionPlaceholder')}
403 className="h-8 px-3 py-1.5 text-xs"
404 />
405 </div>
406 <div className="md:col-span-2">
407 <label className="mb-1.5 block text-xs font-medium">{t('styleEditor.styleCaseLabel')}</label>
408 <Input
409 value={draft.styleCase || ''}
410 onChange={(e) =>
411 setDraft((prev) => (prev ? { ...prev, styleCase: e.target.value } : prev))
412 }
413 placeholder={t('styleEditor.styleCasePlaceholder')}
414 className="h-8 px-3 py-1.5 text-xs"
415 />
416 </div>
417 </div>
418 <div className="rounded-lg border border-[#d9ccb4]/70 bg-[#f8f0e2]/72 p-2.5">
419 <p className="mb-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-[#5d6f4d]">
420 {t('styleEditor.writingTips')}
421 <Popover>
422 <PopoverTrigger asChild>
423 <CircleHelp className="ml-1 inline h-3.5 w-3.5 cursor-pointer text-[#5d6f4d]/60 hover:text-[#5d6f4d]" />
424 </PopoverTrigger>
425 <PopoverContent align="start" side="bottom" className="w-80 border-[#d8ccb5]/80 bg-[#fffdf8] p-3">
426 <p className="mb-1.5 text-[11px] font-semibold text-[#3e4a32]">
427 {t('styleEditor.promptReferenceTitle')}
428 <span className="ml-1 font-normal text-[#5b6b4d]/70">{t('styleEditor.promptReferenceSubtitle')}</span>
429 </p>
430 <ul className="list-disc space-y-1 pl-4 text-[11px] leading-5 text-[#5b6b4d]">
431 {[1, 2, 3, 4, 5, 6, 7].map((i) => (
432 <li key={i}>{t(`styleEditor.promptRef${i}` as Parameters<typeof t>[0])}</li>
433 ))}
434 </ul>
435 </PopoverContent>
436 </Popover>
437 </p>
438 <ul className="list-disc space-y-0.5 pl-5 text-[11px] leading-5 text-[#5b6b4d]">
439 <li>{t('styleEditor.tipStructure')}</li>
440 <li>{t('styleEditor.tipAnimation')}</li>
441 <li>{t('styleEditor.tipNatural')}</li>
442 <li>{t('styleEditor.tipReadable')}</li>
443 </ul>
444 </div>
445
446 {mode === 'edit' ? (
447 <div>
448 <div className="mb-2 flex items-center justify-between">
449 <label className="block text-xs font-medium">Markdown</label>
450 <div className="flex items-center gap-1 rounded-lg border border-border p-0.5">
451 <button
452 type="button"
453 onClick={() => setMode('edit')}
454 className="flex items-center gap-1.5 rounded-md bg-foreground px-3 py-1 text-xs font-medium text-background transition-colors"
455 >
456 <Pencil className="h-3.5 w-3.5" />
457 {t('common.edit')}
458 </button>
459 <button
460 type="button"
461 onClick={() => setMode('preview')}
462 className="flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
463 >
464 <Eye className="h-3.5 w-3.5" />
465 {t('common.preview')}
466 </button>
467 </div>
468 </div>
469 <Textarea
470 value={markdownInput}
471 onChange={(e) => setMarkdownInput(e.target.value)}
472 rows={24}
473 className="resize-y px-3 py-2 font-mono text-xs leading-5"
474 />
475 </div>
476 ) : (
477 <div>
478 <div className="mb-2 flex items-center justify-between">
479 <label className="block text-xs font-medium">Markdown</label>
480 <div className="flex items-center gap-1 rounded-lg border border-border p-0.5">
481 <button
482 type="button"
483 onClick={() => setMode('edit')}
484 className="flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
485 >
486 <Pencil className="h-3.5 w-3.5" />
487 {t('common.edit')}
488 </button>
489 <button
490 type="button"
491 onClick={() => setMode('preview')}
492 className="flex items-center gap-1.5 rounded-md bg-foreground px-3 py-1 text-xs font-medium text-background transition-colors"
493 >
494 <Eye className="h-3.5 w-3.5" />
495 {t('common.preview')}
496 </button>
497 </div>
498 </div>
499 <ScrollArea className="h-[400px] rounded-lg border border-border/70 bg-background/70" viewportClassName="p-4">
500 <ReactMarkdown
501 components={{
502 h1: ({ children }) => <h1 className="mb-2 text-lg font-semibold text-foreground">{children}</h1>,
503 h2: ({ children }) => <h2 className="mb-2 mt-3 text-base font-semibold text-foreground">{children}</h2>,
504 h3: ({ children }) => <h3 className="mb-1.5 mt-2.5 text-sm font-semibold text-foreground">{children}</h3>,
505 p: ({ children }) => <p className="mb-2 text-xs leading-5 text-muted-foreground">{children}</p>,
506 ul: ({ children }) => <ul className="mb-2 list-disc space-y-0.5 pl-5 text-xs text-muted-foreground">{children}</ul>,
507 ol: ({ children }) => <ol className="mb-2 list-decimal space-y-0.5 pl-5 text-xs text-muted-foreground">{children}</ol>,
508 li: ({ children }) => <li>{children}</li>,
509 code: ({ children }) => (
510 <code className="rounded bg-muted px-1.5 py-0.5 text-xs text-foreground">{children}</code>
511 ),
512 blockquote: ({ children }) => (
513 <blockquote className="mb-2 border-l-2 border-border pl-3 text-xs text-muted-foreground">{children}</blockquote>
514 ),
515 }}
516 >
517 {markdownInput || t('styleEditor.emptyMarkdown')}
518 </ReactMarkdown>
519 </ScrollArea>
520 </div>
521 )}
522
523 <div className="flex flex-wrap items-center justify-end gap-2">
524 <Button
525 size="sm"
526 className="h-8 px-3 text-xs"
527 onClick={handleSave}
528 disabled={saving || deleting}
529 >
530 <Save className="mr-1.5 h-3.5 w-3.5" />
531 {saving ? t('common.saving') : t('styleEditor.saveStyle')}
532 </Button>
533 {!isNew && (
534 <AlertDialog>
535 <AlertDialogTrigger asChild>
536 <Button
537 size="sm"
538 variant="outline"
539 className="h-8 px-3 text-xs"
540 disabled={saving || deleting}
541 >
542 <Trash2 className="mr-1.5 h-3.5 w-3.5" />
543 {t('common.delete')}
544 </Button>
545 </AlertDialogTrigger>
546 <AlertDialogContent>
547 <AlertDialogTitle>{t('styles.deleteConfirmTitle')}</AlertDialogTitle>
548 <AlertDialogDescription>
549 {t('styles.deleteConfirmDescription', { name: draft.label })}
550 </AlertDialogDescription>
551 <div className="flex justify-end gap-2">
552 <AlertDialogCancel disabled={deleting}>{t('common.cancel')}</AlertDialogCancel>
553 <AlertDialogAction
554 disabled={deleting}
555 onClick={(event) => {
556 event.preventDefault()
557 void handleDelete()
558 }}
559 className="bg-[#8f3f31] text-white hover:bg-[#743126] disabled:cursor-not-allowed disabled:opacity-65"
560 >
561 {deleting ? (
562 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
563 ) : (
564 <Trash2 className="mr-2 h-4 w-4" />
565 )}
566 {t('common.delete')}
567 </AlertDialogAction>
568 </div>
569 </AlertDialogContent>
570 </AlertDialog>
571 )}
572 </div>
573
574 <p className="text-[11px] text-muted-foreground">
575 {t('styleEditor.currentMode', {
576 mode: draft.source === 'builtin' ? t('styleEditor.builtinMode') : draft.source || ''
577 })}
578 </p>
579 </CardContent>
580 </Card>
581 </>
582 )}
583 </div>
584 )
585 }
586
586 lines Plain Text