| 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 |