返回 oh-my-ppt
StyleView.tsx
根目录 / src / renderer / src / components / session-detail / style / StyleView.tsx
1 import { useCallback, useEffect, useMemo, useState } from 'react'
2 import { Check, Loader2, Palette } from 'lucide-react'
3 import { ipc, type HtmlThumbnailTask, type StyleListItem } from '@renderer/lib/ipc'
4 import { useT } from '@renderer/i18n'
5 import { useModelAction } from '@renderer/hooks/useModelAction'
6 import { useThumbnailUpdates } from '@renderer/hooks/useThumbnailUpdates'
7 import { useVisibleItemIds } from '@renderer/hooks/useVisibleItemIds'
8 import { cn } from '@renderer/lib/utils'
9 import { localAssetUrl } from '@shared/local-asset'
10 import {
11 hydrateStyleSwitchJob,
12 useGenerateStore,
13 useSessionDetailUiStore,
14 useSessionStore,
15 useToastStore
16 } from '@renderer/store'
17 import { ScrollArea } from '../../ui/ScrollArea'
18
19 const MAX_VISIBLE_IFRAMES = 8
20
21 const stylePreviewUrl = (filePath: string): string =>
22 import.meta.env.MODE === 'test' ? 'about:blank' : localAssetUrl(filePath)
23
24 export function StyleView({ sessionId }: { sessionId: string }): React.JSX.Element {
25 const t = useT()
26 const currentStyleId = useSessionStore((state) => state.currentSession?.styleId || '')
27 const isGenerating = useGenerateStore((state) => state.isGenerating)
28 const isDeckEditing = useGenerateStore((state) => Boolean(state.deckEditJobs[sessionId]))
29 const currentPages = useGenerateStore((state) => state.currentPages)
30 const styleSwitchJob = useGenerateStore((state) => state.styleSwitchJobs[sessionId] || null)
31 const isStyleSwitching =
32 styleSwitchJob?.status === 'starting' ||
33 styleSwitchJob?.status === 'running' ||
34 styleSwitchJob?.status === 'cancelling'
35 const { error } = useToastStore()
36 const { selectedModelConfigId, ensureModelActive } = useModelAction()
37 const [styles, setStyles] = useState<StyleListItem[]>([])
38 const [loading, setLoading] = useState(true)
39
40 const loadStyles = useCallback(async (): Promise<void> => {
41 setLoading(true)
42 try {
43 const result = await ipc.listStyles({ sessionId })
44 setStyles(result.items)
45 } catch (loadError) {
46 error(t('sessionDetail.styleLoadFailed'), {
47 description: loadError instanceof Error ? loadError.message : t('common.retryLater')
48 })
49 } finally {
50 setLoading(false)
51 }
52 }, [error, sessionId, t])
53
54 const applyThumbnail = useCallback((task: HtmlThumbnailTask): void => {
55 if (!task.thumbnailPath) return
56 setStyles((current) =>
57 current.map((style) =>
58 style.id === task.resourceId ? { ...style, thumbnailPath: task.thumbnailPath } : style
59 )
60 )
61 }, [])
62
63 useThumbnailUpdates('style', applyThumbnail)
64
65 useEffect(() => {
66 void loadStyles()
67 }, [currentStyleId, loadStyles])
68
69 const orderedStyles = useMemo(
70 () =>
71 [...styles].sort((left, right) => {
72 if (left.id === currentStyleId) return -1
73 if (right.id === currentStyleId) return 1
74 return (right.updatedAt || 0) - (left.updatedAt || 0)
75 }),
76 [currentStyleId, styles]
77 )
78 const fallbackStyleIds = useMemo(
79 () =>
80 new Set(
81 orderedStyles
82 .filter((style) => !style.thumbnailPath && style.previewPath)
83 .map((style) => style.id)
84 ),
85 [orderedStyles]
86 )
87 const { visibleIds: visibleFallbackIds, setItemRef } = useVisibleItemIds(
88 fallbackStyleIds,
89 MAX_VISIBLE_IFRAMES
90 )
91
92 const handleSwitch = async (style: StyleListItem): Promise<void> => {
93 if (style.id === currentStyleId || isGenerating || isDeckEditing || isStyleSwitching) return
94 const modelConfigId = await ensureModelActive(selectedModelConfigId)
95 if (!modelConfigId) return
96 useSessionDetailUiStore.getState().setWorkspaceTab('preview')
97 useSessionDetailUiStore.getState().setInteractionMode('preview')
98 useGenerateStore.getState().clearSessionError(sessionId)
99 useGenerateStore.getState().startStyleSwitch(sessionId, {
100 styleId: style.id,
101 styleName: style.label,
102 totalPages: Math.max(1, currentPages.length),
103 pages: currentPages.map((page) => ({
104 pageId: page.pageId || page.id,
105 pageNumber: page.pageNumber,
106 title: page.title,
107 status: 'pending',
108 error: null,
109 retryCount: 0
110 }))
111 })
112 try {
113 const result = await ipc.startStyleSwitch({ sessionId, styleId: style.id, modelConfigId })
114 if (result.alreadyRunning) {
115 hydrateStyleSwitchJob(sessionId, await ipc.getStyleSwitchState(sessionId))
116 return
117 }
118 if (result.unchanged) {
119 useGenerateStore.getState().finishStyleSwitch(sessionId, {
120 status: 'completed',
121 error: null
122 })
123 useGenerateStore.getState().clearStyleSwitchJob(sessionId)
124 } else if (result.runId) {
125 const currentJob = useGenerateStore.getState().styleSwitchJobs[sessionId]
126 if (currentJob) {
127 useGenerateStore.getState().updateStyleSwitchJob(sessionId, {
128 runId: result.runId,
129 status: currentJob.status === 'cancelling' ? 'cancelling' : 'running'
130 })
131 }
132 }
133 } catch (switchError) {
134 const message = switchError instanceof Error ? switchError.message : t('common.retryLater')
135 useGenerateStore.getState().setSessionError(sessionId, message)
136 useGenerateStore.getState().finishStyleSwitch(sessionId, { status: 'failed', error: message })
137 await useSessionStore.getState().loadSession(sessionId)
138 error(t('sessionDetail.styleSwitchFailed'), { description: message })
139 }
140 }
141
142 if (loading) {
143 return (
144 <div className="flex flex-1 items-center justify-center text-sm text-[#8a9a7b]">
145 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
146 {t('sessionDetail.styleLoading')}
147 </div>
148 )
149 }
150
151 return (
152 <ScrollArea className="flex-1">
153 <div className="p-6">
154 <div className="mb-4">
155 <h2 className="text-lg font-semibold text-[#3e4a32]">{t('sessionDetail.styleTitle')}</h2>
156 <p className="mt-1 text-xs text-[#718064]">{t('sessionDetail.styleDescription')}</p>
157 </div>
158 <div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-5">
159 {orderedStyles.map((style) => {
160 const isCurrent = style.id === currentStyleId
161 const isSwitching = style.id === styleSwitchJob?.styleId && isStyleSwitching
162 return (
163 <div
164 key={style.id}
165 ref={!style.thumbnailPath && style.previewPath ? setItemRef(style.id) : undefined}
166 data-style-card-id={style.id}
167 role="button"
168 aria-current={isCurrent ? 'true' : undefined}
169 tabIndex={isCurrent || isGenerating || isDeckEditing || isStyleSwitching ? -1 : 0}
170 aria-disabled={isCurrent || isGenerating || isDeckEditing || isStyleSwitching}
171 onClick={() => {
172 if (!isCurrent && !isGenerating && !isDeckEditing && !isStyleSwitching) {
173 void handleSwitch(style)
174 }
175 }}
176 onKeyDown={(event) => {
177 if (
178 (event.key === 'Enter' || event.key === ' ') &&
179 !isCurrent &&
180 !isGenerating &&
181 !isDeckEditing &&
182 !isStyleSwitching
183 ) {
184 event.preventDefault()
185 void handleSwitch(style)
186 }
187 }}
188 className={cn(
189 'group overflow-hidden rounded-2xl border border-[#d8cfbc]/75 bg-white/70 text-left shadow-[0_4px_16px_rgba(93,107,77,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_10px_26px_rgba(93,107,77,0.15)] aria-disabled:cursor-default aria-disabled:hover:translate-y-0',
190 isStyleSwitching && 'pointer-events-none opacity-45 grayscale'
191 )}
192 >
193 <div className="relative aspect-video overflow-hidden bg-[#f5f1e8]">
194 {style.thumbnailPath ? (
195 <img
196 src={stylePreviewUrl(style.thumbnailPath)}
197 loading="lazy"
198 alt=""
199 aria-hidden="true"
200 className="absolute inset-0 h-full w-full object-cover"
201 />
202 ) : style.previewPath && visibleFallbackIds.has(style.id) ? (
203 <iframe
204 data-testid="style-preview-iframe"
205 src={stylePreviewUrl(style.previewPath)}
206 sandbox=""
207 tabIndex={-1}
208 className="pointer-events-none absolute left-0 top-0 h-[900px] w-[1600px] origin-top-left border-0 bg-white"
209 style={{ transform: 'scale(0.2)' }}
210 title={`${style.label} preview`}
211 />
212 ) : (
213 <div className="flex h-full items-center justify-center text-[#8a9a7b]">
214 <Palette className="h-8 w-8" />
215 </div>
216 )}
217 <span
218 data-testid="style-selection-checkbox"
219 data-state={isCurrent ? 'checked' : 'unchecked'}
220 aria-hidden="true"
221 className={`absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-md border-2 shadow-[0_3px_10px_rgba(40,48,34,0.22)] transition-colors ${
222 isCurrent || isSwitching
223 ? 'border-[#5d6b4d] bg-[#5d6b4d] text-white'
224 : 'border-[#718064] bg-white/95 text-transparent'
225 }`}
226 >
227 {isSwitching ? (
228 <Loader2 className="h-4 w-4 animate-spin text-white" />
229 ) : (
230 <Check className="h-4 w-4" strokeWidth={3} />
231 )}
232 </span>
233 </div>
234 <div className="p-3">
235 <div className="flex items-start justify-between gap-3">
236 <div className="min-w-0">
237 <p className="truncate text-sm font-semibold text-[#3e4a32]">{style.label}</p>
238 <p className="mt-0.5 text-[10px] font-medium text-[#718064]">
239 {style.category} · {style.source || 'builtin'}
240 </p>
241 </div>
242 </div>
243 <p className="mt-2 line-clamp-2 text-xs leading-5 text-[#6f6658]">
244 {style.description || style.id}
245 </p>
246 {style.styleCase && (
247 <p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8a7048]">
248 {style.styleCase}
249 </p>
250 )}
251 </div>
252 </div>
253 )
254 })}
255 </div>
256 </div>
257 </ScrollArea>
258 )
259 }
260
260 lines Plain Text