返回 oh-my-ppt
IndexTransitionPicker.tsx
根目录 / src / renderer / src / components / session-detail / workspace / toolbar / tool-rows / IndexTransitionPicker.tsx
1 import { useEffect, useMemo, useState } from 'react'
2 import { useParams } from 'react-router-dom'
3 import { Check, Clapperboard, Loader2 } from 'lucide-react'
4 import {
5 INDEX_TRANSITION_PRESETS,
6 type IndexTransitionConfig,
7 type IndexTransitionType
8 } from '@shared/index-transition.js'
9 import { useT, type I18nKey } from '@renderer/i18n'
10 import { ipc } from '@renderer/lib/ipc'
11 import { useSessionDetailUiStore, useToastStore } from '@renderer/store'
12 import { Popover, PopoverContent, PopoverTrigger } from '../../../../ui/Popover'
13
14 interface IndexTransitionPickerProps {
15 disabled?: boolean
16 }
17
18 const transitionPreviewStyles = `
19 .ppt-index-transition-grid {
20 display: grid;
21 grid-template-columns: repeat(4, minmax(0, 1fr));
22 gap: 8px;
23 }
24 .ppt-index-transition-card {
25 position: relative;
26 min-width: 0;
27 height: 88px;
28 overflow: hidden;
29 border-radius: 8px;
30 border: 1px solid rgba(216, 204, 181, 0.82);
31 background: linear-gradient(180deg, rgba(255,253,248,0.98), rgba(246,241,229,0.96));
32 box-shadow: inset 0 1px 0 rgba(255,255,255,0.72), 0 8px 18px rgba(74,59,42,0.08);
33 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
34 }
35 .ppt-index-transition-card:hover,
36 .ppt-index-transition-card:focus-visible {
37 transform: translateY(-1px);
38 border-color: rgba(111,129,89,0.58);
39 background: linear-gradient(180deg, rgba(255,253,248,1), rgba(238,245,229,0.98));
40 box-shadow: inset 0 1px 0 rgba(255,255,255,0.78), 0 12px 24px rgba(74,59,42,0.12);
41 outline: none;
42 }
43 .ppt-index-transition-card[data-selected="true"] {
44 border-color: rgba(93,107,77,0.78);
45 box-shadow: inset 0 0 0 1px rgba(93,107,77,0.22), 0 12px 24px rgba(74,59,42,0.1);
46 }
47 .ppt-index-transition-card:disabled {
48 cursor: not-allowed;
49 opacity: 0.58;
50 transform: none;
51 box-shadow: inset 0 1px 0 rgba(255,255,255,0.62);
52 }
53 .ppt-index-transition-card-label {
54 position: absolute;
55 left: 8px;
56 right: 30px;
57 bottom: 7px;
58 overflow: hidden;
59 text-overflow: ellipsis;
60 white-space: nowrap;
61 font-size: 11px;
62 font-weight: 700;
63 line-height: 1;
64 color: #314028;
65 }
66 .ppt-index-transition-card-check {
67 position: absolute;
68 right: 7px;
69 bottom: 6px;
70 display: inline-flex;
71 width: 17px;
72 height: 17px;
73 align-items: center;
74 justify-content: center;
75 border-radius: 999px;
76 background: #5d6b4d;
77 color: #fff;
78 }
79 .ppt-index-transition-stage {
80 position: absolute;
81 left: 8px;
82 right: 8px;
83 top: 8px;
84 height: 50px;
85 overflow: hidden;
86 border-radius: 6px;
87 background:
88 linear-gradient(90deg, rgba(229,221,204,0.9) 1px, transparent 1px) 0 0 / 14px 14px,
89 linear-gradient(0deg, rgba(229,221,204,0.72) 1px, transparent 1px) 0 0 / 14px 14px,
90 #fbf8f1;
91 perspective: 220px;
92 }
93 .ppt-index-transition-frame {
94 position: absolute;
95 inset: 7px 14px;
96 border-radius: 5px;
97 border: 1px solid rgba(80,69,52,0.16);
98 background: linear-gradient(135deg, #5d6b4d 0%, #8fbc8f 44%, #f1b56f 100%);
99 box-shadow: 0 6px 12px rgba(62,74,50,0.16);
100 transform-origin: center;
101 }
102 .ppt-index-transition-frame-old {
103 opacity: 0.42;
104 background: linear-gradient(135deg, #d8ccb5 0%, #a9b793 100%);
105 }
106 .ppt-index-transition-card[data-transition-type="fade"] .ppt-index-transition-frame-new {
107 animation: pptIndexTransitionFade 1.7s ease-in-out infinite;
108 }
109 .ppt-index-transition-card[data-transition-type="slide-left"] .ppt-index-transition-frame-new {
110 animation: pptIndexTransitionSlideLeft 1.8s cubic-bezier(0.2,0.82,0.22,1) infinite;
111 }
112 .ppt-index-transition-card[data-transition-type="slide-up"] .ppt-index-transition-frame-new {
113 animation: pptIndexTransitionSlideUp 1.8s cubic-bezier(0.2,0.82,0.22,1) infinite;
114 }
115 .ppt-index-transition-card[data-transition-type="push"] .ppt-index-transition-frame-old {
116 animation: pptIndexTransitionPushOld 1.8s cubic-bezier(0.2,0.82,0.22,1) infinite;
117 }
118 .ppt-index-transition-card[data-transition-type="push"] .ppt-index-transition-frame-new {
119 animation: pptIndexTransitionPushNew 1.8s cubic-bezier(0.2,0.82,0.22,1) infinite;
120 }
121 .ppt-index-transition-card[data-transition-type="wipe"] .ppt-index-transition-frame-new {
122 animation: pptIndexTransitionWipe 1.9s ease-in-out infinite;
123 }
124 .ppt-index-transition-card[data-transition-type="zoom"] .ppt-index-transition-frame-new {
125 animation: pptIndexTransitionZoom 1.8s ease-in-out infinite;
126 }
127 .ppt-index-transition-card[data-transition-type="flip"] .ppt-index-transition-frame-new {
128 animation: pptIndexTransitionFlip 2s ease-in-out infinite;
129 }
130 .ppt-index-transition-card[data-transition-type="stack"] .ppt-index-transition-frame-old {
131 animation: pptIndexTransitionStackOld 1.9s ease-in-out infinite;
132 }
133 .ppt-index-transition-card[data-transition-type="stack"] .ppt-index-transition-frame-new {
134 animation: pptIndexTransitionStackNew 1.9s ease-in-out infinite;
135 }
136 .ppt-index-transition-card[data-transition-type="rotate"] .ppt-index-transition-frame-old {
137 animation: pptIndexTransitionRotateOld 1.9s ease-in-out infinite;
138 }
139 .ppt-index-transition-card[data-transition-type="rotate"] .ppt-index-transition-frame-new {
140 animation: pptIndexTransitionRotateNew 1.9s ease-in-out infinite;
141 }
142 .ppt-index-transition-card[data-transition-type="cube"] .ppt-index-transition-frame-old {
143 transform-origin: left center;
144 animation: pptIndexTransitionCubeOld 2s ease-in-out infinite;
145 }
146 .ppt-index-transition-card[data-transition-type="cube"] .ppt-index-transition-frame-new {
147 transform-origin: right center;
148 animation: pptIndexTransitionCubeNew 2s ease-in-out infinite;
149 }
150 .ppt-index-transition-card[data-transition-type="cover-flow"] .ppt-index-transition-frame-old {
151 animation: pptIndexTransitionCoverOld 2s ease-in-out infinite;
152 }
153 .ppt-index-transition-card[data-transition-type="cover-flow"] .ppt-index-transition-frame-new {
154 animation: pptIndexTransitionCoverNew 2s ease-in-out infinite;
155 }
156 .ppt-index-transition-card[data-transition-type="blur"] .ppt-index-transition-frame-old {
157 animation: pptIndexTransitionBlurOld 1.9s ease-in-out infinite;
158 }
159 .ppt-index-transition-card[data-transition-type="blur"] .ppt-index-transition-frame-new {
160 animation: pptIndexTransitionBlurNew 1.9s ease-in-out infinite;
161 }
162 .ppt-index-transition-card[data-transition-type="iris"] .ppt-index-transition-frame-new {
163 animation: pptIndexTransitionIris 2s ease-in-out infinite;
164 }
165 .ppt-index-transition-card[data-transition-type="swing"] .ppt-index-transition-frame-old {
166 animation: pptIndexTransitionSwingOld 2s ease-in-out infinite;
167 }
168 .ppt-index-transition-card[data-transition-type="swing"] .ppt-index-transition-frame-new {
169 animation: pptIndexTransitionSwingNew 2s ease-in-out infinite;
170 }
171 .ppt-index-transition-card[data-transition-type="center-reveal"] .ppt-index-transition-frame-new {
172 animation: pptIndexTransitionCenterReveal 1.9s ease-in-out infinite;
173 }
174 .ppt-index-transition-card[data-transition-type="none"] .ppt-index-transition-frame-new {
175 transform: translateX(8px) translateY(-3px);
176 opacity: 0.78;
177 }
178 @keyframes pptIndexTransitionFade {
179 0%, 18% { opacity: 0; }
180 48%, 100% { opacity: 1; }
181 }
182 @keyframes pptIndexTransitionSlideLeft {
183 0%, 18% { transform: translateX(105%); }
184 54%, 100% { transform: translateX(0); }
185 }
186 @keyframes pptIndexTransitionSlideUp {
187 0%, 18% { transform: translateY(105%); }
188 54%, 100% { transform: translateY(0); }
189 }
190 @keyframes pptIndexTransitionPushOld {
191 0%, 18% { transform: translateX(0); opacity: 0.42; }
192 54%, 100% { transform: translateX(-72%); opacity: 0.18; }
193 }
194 @keyframes pptIndexTransitionPushNew {
195 0%, 18% { transform: translateX(105%); }
196 54%, 100% { transform: translateX(0); }
197 }
198 @keyframes pptIndexTransitionWipe {
199 0%, 18% { clip-path: inset(0 0 0 100%); }
200 58%, 100% { clip-path: inset(0 0 0 0); }
201 }
202 @keyframes pptIndexTransitionZoom {
203 0%, 18% { transform: scale(1.18); opacity: 0; }
204 54%, 100% { transform: scale(1); opacity: 1; }
205 }
206 @keyframes pptIndexTransitionFlip {
207 0%, 18% { transform: rotateY(74deg); opacity: 0; }
208 58%, 100% { transform: rotateY(0deg); opacity: 1; }
209 }
210 @keyframes pptIndexTransitionStackOld {
211 0%, 18% { transform: translateY(0) scale(1); opacity: 0.42; }
212 58%, 100% { transform: translateY(8px) scale(0.94); opacity: 0.22; }
213 }
214 @keyframes pptIndexTransitionStackNew {
215 0%, 18% { transform: translateY(-12px) scale(0.94); opacity: 0; }
216 58%, 100% { transform: translateY(0) scale(1); opacity: 1; }
217 }
218 @keyframes pptIndexTransitionRotateOld {
219 0%, 18% { transform: rotate(0deg) scale(1); opacity: 0.42; }
220 58%, 100% { transform: rotate(-9deg) scale(0.88); opacity: 0; }
221 }
222 @keyframes pptIndexTransitionRotateNew {
223 0%, 18% { transform: rotate(10deg) scale(1.12); opacity: 0; }
224 58%, 100% { transform: rotate(0deg) scale(1); opacity: 1; }
225 }
226 @keyframes pptIndexTransitionCubeOld {
227 0%, 18% { transform: translateX(0) rotateY(0deg); opacity: 0.42; }
228 58%, 100% { transform: translateX(-26%) rotateY(-76deg); opacity: 0.12; }
229 }
230 @keyframes pptIndexTransitionCubeNew {
231 0%, 18% { transform: translateX(36%) rotateY(76deg); opacity: 0.12; }
232 58%, 100% { transform: translateX(0) rotateY(0deg); opacity: 1; }
233 }
234 @keyframes pptIndexTransitionCoverOld {
235 0%, 18% { transform: translateX(0) scale(1) rotateY(0deg); opacity: 0.42; }
236 58%, 100% { transform: translateX(-34%) scale(0.78) rotateY(38deg); opacity: 0.18; }
237 }
238 @keyframes pptIndexTransitionCoverNew {
239 0%, 18% { transform: translateX(34%) scale(0.78) rotateY(-38deg); opacity: 0.22; }
240 58%, 100% { transform: translateX(0) scale(1) rotateY(0deg); opacity: 1; }
241 }
242 @keyframes pptIndexTransitionBlurOld {
243 0%, 18% { transform: scale(1); filter: blur(0); opacity: 0.42; }
244 58%, 100% { transform: scale(1.08); filter: blur(4px); opacity: 0; }
245 }
246 @keyframes pptIndexTransitionBlurNew {
247 0%, 18% { transform: scale(0.94); filter: blur(4px); opacity: 0; }
248 58%, 100% { transform: scale(1); filter: blur(0); opacity: 1; }
249 }
250 @keyframes pptIndexTransitionIris {
251 0%, 18% { clip-path: circle(0% at 50% 50%); opacity: 1; }
252 62%, 100% { clip-path: circle(78% at 50% 50%); opacity: 1; }
253 }
254 @keyframes pptIndexTransitionSwingOld {
255 0%, 18% { transform: translateX(0) rotate(0deg) scale(1); opacity: 0.42; }
256 58%, 100% { transform: translateX(-18%) rotate(-12deg) scale(0.94); opacity: 0; }
257 }
258 @keyframes pptIndexTransitionSwingNew {
259 0%, 18% { transform: translateX(20%) rotate(12deg) scale(0.96); opacity: 0; }
260 58%, 100% { transform: translateX(0) rotate(0deg) scale(1); opacity: 1; }
261 }
262 @keyframes pptIndexTransitionCenterReveal {
263 0%, 18% { clip-path: inset(0 50% 0 50%); transform: scale(1.02); opacity: 1; }
264 62%, 100% { clip-path: inset(0 0 0 0); transform: scale(1); opacity: 1; }
265 }
266 @media (prefers-reduced-motion: reduce) {
267 .ppt-index-transition-card,
268 .ppt-index-transition-frame {
269 animation: none !important;
270 transition: none !important;
271 }
272 }
273 `
274
275 export function IndexTransitionPicker({
276 disabled = false
277 }: IndexTransitionPickerProps): React.JSX.Element {
278 const { id: sessionId } = useParams<{ id: string }>()
279 const t = useT()
280 const toastSuccess = useToastStore((state) => state.success)
281 const toastError = useToastStore((state) => state.error)
282 const bumpPreviewKey = useSessionDetailUiStore((state) => state.bumpPreviewKey)
283 const [transition, setTransition] = useState<IndexTransitionConfig | null>(null)
284 const [isLoading, setIsLoading] = useState(false)
285 const [isSaving, setIsSaving] = useState(false)
286 const [open, setOpen] = useState(false)
287
288 const options = useMemo(
289 () =>
290 INDEX_TRANSITION_PRESETS.map((preset) => ({
291 ...preset,
292 label: t(preset.labelKey as I18nKey)
293 })),
294 [t]
295 )
296
297 useEffect(() => {
298 if (!sessionId) {
299 setTransition(null)
300 return
301 }
302 let cancelled = false
303 setIsLoading(true)
304 void ipc
305 .getIndexTransition(sessionId)
306 .then((config) => {
307 if (!cancelled) setTransition(config)
308 })
309 .catch((error) => {
310 if (!cancelled) {
311 toastError(
312 error instanceof Error ? error.message : t('sessionDetail.indexTransitionLoadFailed')
313 )
314 }
315 })
316 .finally(() => {
317 if (!cancelled) setIsLoading(false)
318 })
319 return () => {
320 cancelled = true
321 }
322 }, [sessionId, t, toastError])
323
324 const currentType = transition?.type ?? 'fade'
325 const currentOption =
326 options.find((option) => option.type === currentType) ??
327 options.find((option) => option.type === 'fade') ??
328 options[0]
329 const disabledState = disabled || isLoading || isSaving || !sessionId
330 const TriggerIcon = isLoading || isSaving ? Loader2 : Clapperboard
331
332 const handleTransitionChange = async (type: IndexTransitionType): Promise<void> => {
333 if (!sessionId || disabled || isSaving) return
334 const selected = INDEX_TRANSITION_PRESETS.find((preset) => preset.type === type)
335 if (!selected) return
336 const previous = transition
337 if (previous?.type === type && previous.durationMs === selected.durationMs) {
338 setOpen(false)
339 return
340 }
341 const optimisticConfig = { type, durationMs: selected.durationMs }
342 setTransition(optimisticConfig)
343 setOpen(false)
344 setIsSaving(true)
345 try {
346 const result = await ipc.setIndexTransition({
347 sessionId,
348 type,
349 durationMs: selected.durationMs
350 })
351 setTransition(result.transition)
352 bumpPreviewKey()
353 toastSuccess(t('sessionDetail.indexTransitionSaved'))
354 } catch (error) {
355 setTransition(previous)
356 toastError(error instanceof Error ? error.message : t('sessionDetail.indexTransitionSaveFailed'))
357 } finally {
358 setIsSaving(false)
359 }
360 }
361
362 return (
363 <Popover open={open} onOpenChange={setOpen}>
364 <PopoverTrigger asChild>
365 <button
366 type="button"
367 disabled={disabledState}
368 className="inline-flex h-7 max-w-[190px] shrink-0 items-center gap-1.5 rounded-full border border-[#d8ccb5]/70 bg-[#fffdf8]/88 px-2.5 text-[10px] font-bold leading-none text-[#314028] shadow-[inset_0_1px_2px_rgba(74,59,42,0.04)] transition-colors hover:bg-[#f3f7ed] disabled:cursor-not-allowed disabled:opacity-50"
369 >
370 <TriggerIcon
371 className={`h-3 w-3 shrink-0 ${isLoading || isSaving ? 'animate-spin' : ''}`}
372 />
373 <span className="shrink-0">{t('sessionDetail.indexTransitionLabel')}</span>
374 <span className="min-w-0 truncate text-[#6f8159]">{currentOption?.label}</span>
375 </button>
376 </PopoverTrigger>
377 <PopoverContent
378 align="start"
379 className="w-[536px] max-w-[calc(100vw-2rem)] border-[#d8ccb5]/85 bg-[#fff9ef] p-2"
380 >
381 <style>{transitionPreviewStyles}</style>
382 <div className="ppt-index-transition-grid">
383 {options.map((option) => {
384 const selected = option.type === currentType
385 return (
386 <button
387 type="button"
388 key={option.type}
389 data-selected={selected}
390 data-transition-type={option.type}
391 disabled={disabledState}
392 className="ppt-index-transition-card"
393 onClick={() => {
394 void handleTransitionChange(option.type)
395 }}
396 >
397 <span className="ppt-index-transition-stage" aria-hidden="true">
398 <span className="ppt-index-transition-frame ppt-index-transition-frame-old" />
399 <span className="ppt-index-transition-frame ppt-index-transition-frame-new" />
400 </span>
401 <span className="ppt-index-transition-card-label">{option.label}</span>
402 {selected ? (
403 <span className="ppt-index-transition-card-check" aria-hidden="true">
404 <Check className="h-3 w-3" />
405 </span>
406 ) : null}
407 </button>
408 )
409 })}
410 </div>
411 </PopoverContent>
412 </Popover>
413 )
414 }
415
415 lines Plain Text