返回 oh-my-ppt
ElementAnimationPicker.tsx
根目录 / src / renderer / src / components / session-detail / workspace / toolbar / tool-rows / ElementAnimationPicker.tsx
1 import { useEffect, useMemo, useRef, useState } from 'react'
2 import { Check, Loader2, MousePointer2, Sparkles } from 'lucide-react'
3 import { useParams } from 'react-router-dom'
4 import {
5 DATA_ANIM_EDITABLE_TYPES,
6 type DataAnimType,
7 type ElementAnimationConfig,
8 type ElementAnimationEditableFrom,
9 type ElementAnimationEditableType,
10 type ElementAnimationPatch
11 } from '@shared/element-animation.js'
12 import { useT, type I18nKey } from '@renderer/i18n'
13 import { ipc } from '@renderer/lib/ipc'
14 import {
15 useGenerateStore,
16 useSessionDetailRuntimeStore,
17 useSessionDetailUiStore,
18 useToastStore
19 } from '@renderer/store'
20 import { normalizePagesForSelection } from '../../../shared'
21 import { Popover, PopoverContent, PopoverTrigger } from '../../../../ui/Popover'
22
23 type AnimationCategory = 'entrance' | 'emphasis' | 'exit'
24
25 interface AnimationPreset {
26 type: ElementAnimationEditableType
27 category: AnimationCategory
28 labelKey: I18nKey
29 }
30
31 const EXIT_TYPES = new Set<DataAnimType>([
32 'exit-fade',
33 'exit-scale',
34 'exit-zoom',
35 'exit-wipe',
36 'exit-fly'
37 ])
38 const DIRECTIONAL_TYPES = new Set<DataAnimType>(['fly-in', 'wipe', 'exit-wipe', 'exit-fly'])
39 const EMPHASIS_TYPES = new Set<DataAnimType>([
40 'pulse-soft',
41 'pulse',
42 'pulse-strong',
43 'grow-shrink-soft',
44 'grow-shrink',
45 'grow-shrink-strong'
46 ])
47
48 const labelKeys: Record<ElementAnimationEditableType, I18nKey> = {
49 fade: 'home.animationPreferenceOptions.fade',
50 'fade-up': 'home.animationPreferenceOptions.fade-up',
51 'fade-down': 'home.animationPreferenceOptions.fade-down',
52 'fade-left': 'home.animationPreferenceOptions.fade-left',
53 'fade-right': 'home.animationPreferenceOptions.fade-right',
54 'scale-in': 'home.animationPreferenceOptions.scale-in',
55 'slide-up': 'home.animationPreferenceOptions.slide-up',
56 'slide-down': 'home.animationPreferenceOptions.slide-down',
57 'slide-left': 'home.animationPreferenceOptions.slide-left',
58 'slide-right': 'home.animationPreferenceOptions.slide-right',
59 'fly-in': 'home.animationPreferenceOptions.fly-in',
60 wipe: 'home.animationPreferenceOptions.wipe',
61 'zoom-in': 'home.animationPreferenceOptions.zoom-in',
62 'spin-in': 'home.animationPreferenceOptions.spin-in',
63 'pulse-soft': 'home.animationPreferenceOptions.pulse-soft',
64 pulse: 'home.animationPreferenceOptions.pulse',
65 'pulse-strong': 'home.animationPreferenceOptions.pulse-strong',
66 'grow-shrink-soft': 'home.animationPreferenceOptions.grow-shrink-soft',
67 'grow-shrink': 'home.animationPreferenceOptions.grow-shrink',
68 'grow-shrink-strong': 'home.animationPreferenceOptions.grow-shrink-strong',
69 'exit-fade': 'sessionDetail.elementAnimationExitFade',
70 'exit-scale': 'sessionDetail.elementAnimationExitScale',
71 'exit-zoom': 'sessionDetail.elementAnimationExitZoom',
72 'exit-wipe': 'sessionDetail.elementAnimationExitWipe',
73 'exit-fly': 'sessionDetail.elementAnimationExitFly'
74 }
75
76 const presets: AnimationPreset[] = DATA_ANIM_EDITABLE_TYPES.map((type) => ({
77 type,
78 category: EXIT_TYPES.has(type) ? 'exit' : EMPHASIS_TYPES.has(type) ? 'emphasis' : 'entrance',
79 labelKey: labelKeys[type]
80 }))
81
82 const durationOptions = [
83 { value: 360, labelKey: 'sessionDetail.elementAnimationDurationFast' as I18nKey },
84 { value: 600, labelKey: 'sessionDetail.elementAnimationDurationStandard' as I18nKey },
85 { value: 900, labelKey: 'sessionDetail.elementAnimationDurationSlow' as I18nKey }
86 ]
87
88 const directionOptions: Array<{
89 value: ElementAnimationEditableFrom
90 labelKey: I18nKey
91 }> = [
92 { value: 'left', labelKey: 'sessionDetail.elementAnimationDirectionLeft' },
93 { value: 'right', labelKey: 'sessionDetail.elementAnimationDirectionRight' },
94 { value: 'top', labelKey: 'sessionDetail.elementAnimationDirectionTop' },
95 { value: 'bottom', labelKey: 'sessionDetail.elementAnimationDirectionBottom' }
96 ]
97
98 const elementAnimationPreviewStyles = `
99 .ppt-element-animation-grid {
100 display: grid;
101 grid-template-columns: repeat(4, minmax(0, 1fr));
102 gap: 8px;
103 }
104 .ppt-element-animation-card {
105 position: relative;
106 height: 84px;
107 overflow: hidden;
108 border: 1px solid rgba(216,204,181,.82);
109 border-radius: 8px;
110 background: linear-gradient(180deg,rgba(255,253,248,.98),rgba(246,241,229,.96));
111 transition: transform 160ms ease,border-color 160ms ease,box-shadow 160ms ease;
112 }
113 .ppt-element-animation-card:hover,.ppt-element-animation-card:focus-visible {
114 transform: translateY(-1px);
115 border-color: rgba(111,129,89,.58);
116 box-shadow: 0 10px 22px rgba(74,59,42,.11);
117 outline: none;
118 }
119 .ppt-element-animation-card[data-selected="true"] {
120 border-color: rgba(93,107,77,.78);
121 box-shadow: inset 0 0 0 1px rgba(93,107,77,.2),0 10px 22px rgba(74,59,42,.09);
122 }
123 .ppt-element-animation-stage {
124 position: absolute;
125 left: 8px;
126 right: 8px;
127 top: 8px;
128 height: 43px;
129 overflow: hidden;
130 border-radius: 6px;
131 background: linear-gradient(90deg,rgba(229,221,204,.72) 1px,transparent 1px) 0 0/12px 12px,#fbf8f1;
132 }
133 .ppt-element-animation-object {
134 position: absolute;
135 left: 50%;
136 top: 50%;
137 width: 48px;
138 height: 22px;
139 margin: -11px 0 0 -24px;
140 border-radius: 6px;
141 background: linear-gradient(135deg,#5d6b4d,#8fbc8f 58%,#f1b56f);
142 box-shadow: 0 5px 10px rgba(62,74,50,.17);
143 animation-duration: 1.8s;
144 animation-iteration-count: infinite;
145 animation-timing-function: cubic-bezier(.2,.82,.22,1);
146 }
147 .ppt-element-animation-card-label {
148 position: absolute;
149 left: 8px;
150 right: 28px;
151 bottom: 7px;
152 overflow: hidden;
153 text-overflow: ellipsis;
154 white-space: nowrap;
155 color: #314028;
156 font-size: 10px;
157 font-weight: 700;
158 }
159 .ppt-element-animation-check {
160 position: absolute;
161 right: 7px;
162 bottom: 6px;
163 display: inline-flex;
164 width: 17px;
165 height: 17px;
166 align-items: center;
167 justify-content: center;
168 border-radius: 999px;
169 background: #5d6b4d;
170 color: white;
171 }
172 [data-element-animation="fade"] .ppt-element-animation-object { animation-name: pptElementFade; }
173 [data-element-animation="fade-up"] .ppt-element-animation-object { animation-name: pptElementFadeUp; }
174 [data-element-animation="fade-down"] .ppt-element-animation-object { animation-name: pptElementFadeDown; }
175 [data-element-animation="fade-left"] .ppt-element-animation-object { animation-name: pptElementFadeLeft; }
176 [data-element-animation="fade-right"] .ppt-element-animation-object { animation-name: pptElementFadeRight; }
177 [data-element-animation="scale-in"] .ppt-element-animation-object { animation-name: pptElementScale; }
178 [data-element-animation="slide-up"] .ppt-element-animation-object { animation-name: pptElementSlideUp; }
179 [data-element-animation="slide-down"] .ppt-element-animation-object { animation-name: pptElementSlideDown; }
180 [data-element-animation="slide-left"] .ppt-element-animation-object { animation-name: pptElementSlideLeft; }
181 [data-element-animation="slide-right"] .ppt-element-animation-object { animation-name: pptElementSlideRight; }
182 [data-element-animation="fly-in"] .ppt-element-animation-object { animation-name: pptElementSlideRight; }
183 [data-element-animation="wipe"] .ppt-element-animation-object { animation-name: pptElementWipe; }
184 [data-element-animation="zoom-in"] .ppt-element-animation-object { animation-name: pptElementZoom; }
185 [data-element-animation="spin-in"] .ppt-element-animation-object { animation-name: pptElementSpin; }
186 [data-element-animation^="pulse"] .ppt-element-animation-object { animation-name: pptElementPulse; }
187 [data-element-animation^="grow-shrink"] .ppt-element-animation-object { animation-name: pptElementGrow; }
188 [data-element-animation="exit-fade"] .ppt-element-animation-object { animation-name: pptElementExitFade; }
189 [data-element-animation="exit-scale"] .ppt-element-animation-object { animation-name: pptElementExitScale; }
190 [data-element-animation="exit-zoom"] .ppt-element-animation-object { animation-name: pptElementExitZoom; }
191 [data-element-animation="exit-wipe"] .ppt-element-animation-object { animation-name: pptElementExitWipe; }
192 [data-element-animation="exit-fly"] .ppt-element-animation-object { animation-name: pptElementExitFly; }
193 @keyframes pptElementFade { 0%,18%{opacity:0} 58%,100%{opacity:1} }
194 @keyframes pptElementFadeUp { 0%,18%{opacity:0;transform:translateY(18px)} 58%,100%{opacity:1;transform:none} }
195 @keyframes pptElementFadeDown { 0%,18%{opacity:0;transform:translateY(-18px)} 58%,100%{opacity:1;transform:none} }
196 @keyframes pptElementFadeLeft { 0%,18%{opacity:0;transform:translateX(18px)} 58%,100%{opacity:1;transform:none} }
197 @keyframes pptElementFadeRight { 0%,18%{opacity:0;transform:translateX(-18px)} 58%,100%{opacity:1;transform:none} }
198 @keyframes pptElementScale { 0%,18%{opacity:0;transform:scale(.75)} 58%,100%{opacity:1;transform:scale(1)} }
199 @keyframes pptElementSlideUp { 0%,18%{opacity:0;transform:translateY(52px)} 58%,100%{opacity:1;transform:none} }
200 @keyframes pptElementSlideDown { 0%,18%{opacity:0;transform:translateY(-52px)} 58%,100%{opacity:1;transform:none} }
201 @keyframes pptElementSlideLeft { 0%,18%{opacity:0;transform:translateX(68px)} 58%,100%{opacity:1;transform:none} }
202 @keyframes pptElementSlideRight { 0%,18%{opacity:0;transform:translateX(-68px)} 58%,100%{opacity:1;transform:none} }
203 @keyframes pptElementWipe { 0%,18%{clip-path:inset(0 100% 0 0);opacity:0} 58%,100%{clip-path:inset(0);opacity:1} }
204 @keyframes pptElementZoom { 0%,18%{opacity:0;transform:scale(.6)} 58%,100%{opacity:1;transform:scale(1)} }
205 @keyframes pptElementSpin { 0%,18%{opacity:0;transform:rotate(-20deg) scale(.78)} 58%,100%{opacity:1;transform:none} }
206 @keyframes pptElementPulse { 0%,18%,100%{transform:scale(1)} 48%{transform:scale(1.12)} }
207 @keyframes pptElementGrow { 0%,18%,100%{transform:scale(.92)} 48%{transform:scale(1.16)} }
208 @keyframes pptElementExitFade { 0%,18%{opacity:1} 58%,100%{opacity:0} }
209 @keyframes pptElementExitScale { 0%,18%{opacity:1;transform:scale(1)} 58%,100%{opacity:0;transform:scale(.82)} }
210 @keyframes pptElementExitZoom { 0%,18%{opacity:1;transform:scale(1)} 58%,100%{opacity:0;transform:scale(.55)} }
211 @keyframes pptElementExitWipe { 0%,18%{clip-path:inset(0);opacity:1} 58%,100%{clip-path:inset(0 0 0 100%);opacity:0} }
212 @keyframes pptElementExitFly { 0%,18%{opacity:1;transform:none} 58%,100%{opacity:0;transform:translateX(42px)} }
213 @media (prefers-reduced-motion: reduce) {
214 .ppt-element-animation-object { animation: none !important; }
215 .ppt-element-animation-card { transition: none !important; }
216 }
217 `
218
219 export function ElementAnimationPicker({ disabled = false }: { disabled?: boolean }): React.JSX.Element {
220 const { id: sessionId } = useParams<{ id: string }>()
221 const t = useT()
222 const currentPages = useGenerateStore((state) => state.currentPages)
223 const selectedPageId = useSessionDetailUiStore((state) => state.selectedPageId)
224 const selectedSelector = useSessionDetailUiStore((state) => state.selectedSelector)
225 const refreshCurrentPreview = useSessionDetailRuntimeStore(
226 (state) => state.refreshCurrentPreview
227 )
228 const setInteractionMode = useSessionDetailUiStore((state) => state.setInteractionMode)
229 const bumpThumbnailVersion = useSessionDetailUiStore((state) => state.bumpThumbnailVersion)
230 const toastSuccess = useToastStore((state) => state.success)
231 const toastError = useToastStore((state) => state.error)
232 const [animation, setAnimation] = useState<ElementAnimationConfig | null>(null)
233 const [isLoading, setIsLoading] = useState(false)
234 const [isSaving, setIsSaving] = useState(false)
235 const [open, setOpen] = useState(false)
236 const [category, setCategory] = useState<AnimationCategory>('entrance')
237
238 const pages = useMemo(() => normalizePagesForSelection(currentPages), [currentPages])
239 const selectedPage = useMemo(
240 () => pages.find((page) => page.id === selectedPageId) ?? pages[0] ?? null,
241 [pages, selectedPageId]
242 )
243 const htmlPath = selectedPage?.htmlPath
244 const pageId = selectedPage?.pageId
245 const targetKey = `${sessionId || ''}:${htmlPath || ''}:${pageId || ''}:${selectedSelector || ''}`
246 const targetKeyRef = useRef(targetKey)
247 targetKeyRef.current = targetKey
248
249 useEffect(() => {
250 setInteractionMode('animation-select')
251 }, [pageId, setInteractionMode])
252
253 useEffect(() => {
254 if (!sessionId || !htmlPath || !pageId || !selectedSelector) {
255 setAnimation(null)
256 setIsLoading(false)
257 return
258 }
259 let cancelled = false
260 setAnimation(null)
261 setIsLoading(true)
262 void ipc
263 .getElementAnimation({ sessionId, htmlPath, pageId, selector: selectedSelector })
264 .then((result) => {
265 if (!cancelled) {
266 setAnimation(result.animation)
267 if (result.animation?.type && EXIT_TYPES.has(result.animation.type)) setCategory('exit')
268 else if (result.animation?.type && EMPHASIS_TYPES.has(result.animation.type))
269 setCategory('emphasis')
270 else setCategory('entrance')
271 }
272 })
273 .catch((error) => {
274 if (!cancelled) {
275 toastError(
276 error instanceof Error ? error.message : t('sessionDetail.elementAnimationLoadFailed')
277 )
278 }
279 })
280 .finally(() => {
281 if (!cancelled) setIsLoading(false)
282 })
283 return () => {
284 cancelled = true
285 }
286 }, [htmlPath, pageId, selectedSelector, sessionId, t, toastError])
287
288 const savePatch = async (patch: ElementAnimationPatch): Promise<void> => {
289 if (
290 !sessionId ||
291 !htmlPath ||
292 !pageId ||
293 !selectedSelector ||
294 disabled ||
295 isLoading ||
296 isSaving
297 )
298 return
299 const previous = animation
300 const savingTargetKey = targetKey
301 setIsSaving(true)
302 try {
303 const result = await ipc.setElementAnimation({
304 sessionId,
305 htmlPath,
306 pageId,
307 selector: selectedSelector,
308 patch
309 })
310 if (targetKeyRef.current === savingTargetKey) {
311 setAnimation(result.animation)
312 }
313 if (result.changed) {
314 bumpThumbnailVersion(pageId)
315 // Match index-transition: reload the page so the iframe reflects the saved
316 // data-anim. The reload clears the canvas highlight, but selectedSelector
317 // stays in the store and PreviewStage restores the animation selection.
318 refreshCurrentPreview()
319 toastSuccess(t('sessionDetail.elementAnimationSaved'))
320 }
321 } catch (error) {
322 if (targetKeyRef.current === savingTargetKey) {
323 setAnimation(previous)
324 }
325 toastError(
326 error instanceof Error ? error.message : t('sessionDetail.elementAnimationSaveFailed')
327 )
328 } finally {
329 setIsSaving(false)
330 }
331 }
332
333 const handleTypeChange = (type: ElementAnimationEditableType | null): void => {
334 if (type === null) {
335 if (!animation) return
336 setOpen(false)
337 void savePatch({ type: null })
338 return
339 }
340 if (animation?.type === type) return
341 setOpen(false)
342 const patch: ElementAnimationPatch = { type }
343 if (!animation) {
344 patch.trigger = EXIT_TYPES.has(type) ? 'click' : 'load'
345 patch.durationMs = 600
346 } else if (EXIT_TYPES.has(type) && animation.trigger !== 'click') {
347 patch.trigger = 'click'
348 }
349 if (DIRECTIONAL_TYPES.has(type) && (!animation?.from || animation.from === 'center')) {
350 patch.from = 'left'
351 }
352 void savePatch(patch)
353 }
354
355 const options = presets.filter((preset) => preset.category === category)
356 const selectedType = animation?.type
357 const triggerBucket = animation?.trigger === 'click' ? 'click' : 'load'
358 const currentDuration = animation?.durationMs ?? 600
359 const currentDirection =
360 animation?.from && animation.from !== 'center' ? animation.from : null
361 const customDuration = !durationOptions.some((option) => option.value === currentDuration)
362 const currentLabel =
363 selectedType === 'path'
364 ? t('sessionDetail.elementAnimationAdvancedPath')
365 : selectedType
366 ? t(labelKeys[selectedType])
367 : t('sessionDetail.indexTransitionNone')
368 const disabledState = disabled || isLoading || isSaving || !sessionId || !htmlPath || !pageId
369 const hasAnimation = animation !== null
370 // Lock type switching while an animation exists: clear via "None" first.
371 // Timing (duration / trigger / direction) stays editable.
372 const typeLocked = disabledState || hasAnimation
373 const triggerDisabled = disabledState || !selectedSelector
374 const TriggerIcon = isLoading || isSaving ? Loader2 : Sparkles
375
376 return (
377 <Popover open={open} onOpenChange={setOpen}>
378 <PopoverTrigger asChild>
379 <button
380 type="button"
381 disabled={triggerDisabled}
382 className="inline-flex h-7 max-w-[220px] 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"
383 >
384 <TriggerIcon
385 className={`h-3 w-3 shrink-0 ${isLoading || isSaving ? 'animate-spin' : ''}`}
386 />
387 <span className="shrink-0">{t('sessionDetail.elementAnimationLabel')}</span>
388 <span className="min-w-0 truncate text-[#6f8159]">
389 {selectedSelector ? currentLabel : t('sessionDetail.elementAnimationSelectTarget')}
390 </span>
391 </button>
392 </PopoverTrigger>
393 <PopoverContent
394 align="start"
395 className="w-[536px] max-w-[calc(100vw-2rem)] border-[#d8ccb5]/85 bg-[#fff9ef] p-2"
396 >
397 <style>{elementAnimationPreviewStyles}</style>
398 {!selectedSelector ? (
399 <div className="flex h-36 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-[#d8ccb5] bg-[#fffdf8]/72 text-center">
400 <MousePointer2 className="h-5 w-5 text-[#6f8159]" />
401 <p className="text-xs font-bold text-[#314028]">
402 {t('sessionDetail.elementAnimationSelectTargetTitle')}
403 </p>
404 <p className="text-[10px] text-[#7a806c]">
405 {t('sessionDetail.elementAnimationSelectTargetHint')}
406 </p>
407 </div>
408 ) : (
409 <div className="space-y-2">
410 <div className="flex items-center justify-end gap-1 rounded-lg bg-[#f2ecdf]/72 px-2 py-1.5">
411 {(['load', 'click'] as const).map((trigger) => (
412 <button
413 key={trigger}
414 type="button"
415 disabled={disabledState || !animation}
416 className={`rounded-full px-2 py-1 text-[10px] font-bold ${
417 triggerBucket === trigger
418 ? 'bg-[#5d6b4d] text-white'
419 : 'bg-[#fffdf8] text-[#617253] hover:bg-[#e5eedb]'
420 }`}
421 onClick={() => {
422 if (!animation || triggerBucket === trigger) return
423 void savePatch({ trigger })
424 }}
425 >
426 {t(
427 trigger === 'load'
428 ? 'sessionDetail.elementAnimationTriggerAuto'
429 : 'sessionDetail.elementAnimationTriggerClick'
430 )}
431 </button>
432 ))}
433 </div>
434
435 {animation ? (
436 <div className="flex flex-wrap items-center gap-1">
437 <span className="mr-1 text-[10px] font-bold text-[#6b765e]">
438 {t('sessionDetail.elementAnimationDuration')}
439 </span>
440 {durationOptions.map((option) => (
441 <button
442 key={option.value}
443 type="button"
444 disabled={disabledState}
445 className={`rounded-full px-2 py-1 text-[10px] font-bold ${
446 currentDuration === option.value
447 ? 'bg-[#d9e8cb] text-[#314028]'
448 : 'bg-[#fffdf8] text-[#69745e] hover:bg-[#edf4e6]'
449 }`}
450 onClick={() => {
451 if (currentDuration === option.value) return
452 void savePatch({ durationMs: option.value })
453 }}
454 >
455 {t(option.labelKey)}
456 </button>
457 ))}
458 {customDuration ? (
459 <span className="rounded-full bg-[#d9e8cb] px-2 py-1 text-[10px] font-bold text-[#314028]">
460 {t('sessionDetail.elementAnimationDurationCustom', {
461 duration: currentDuration
462 })}
463 </span>
464 ) : null}
465 </div>
466 ) : null}
467
468 {animation && DIRECTIONAL_TYPES.has(animation.type) ? (
469 <div className="flex flex-wrap items-center gap-1">
470 <span className="mr-1 text-[10px] font-bold text-[#6b765e]">
471 {t('sessionDetail.elementAnimationDirection')}
472 </span>
473 {directionOptions.map((option) => (
474 <button
475 key={option.value}
476 type="button"
477 disabled={disabledState}
478 className={`rounded-full px-2 py-1 text-[10px] font-bold ${
479 currentDirection === option.value
480 ? 'bg-[#d9e8cb] text-[#314028]'
481 : 'bg-[#fffdf8] text-[#69745e] hover:bg-[#edf4e6]'
482 }`}
483 onClick={() => {
484 if (currentDirection === option.value) return
485 void savePatch({ from: option.value })
486 }}
487 >
488 {t(option.labelKey)}
489 </button>
490 ))}
491 </div>
492 ) : null}
493
494 {hasAnimation ? (
495 <p className="rounded-md bg-[#f6ece2] px-2 py-1 text-[10px] font-bold leading-snug text-[#8b6658]">
496 {t('sessionDetail.elementAnimationClearFirstHint')}
497 </p>
498 ) : null}
499
500 <div className="flex items-center gap-1">
501 {(['entrance', 'emphasis', 'exit'] as const).map((item) => (
502 <button
503 key={item}
504 type="button"
505 disabled={typeLocked}
506 className={`rounded-full px-2.5 py-1 text-[10px] font-bold ${
507 category === item
508 ? 'bg-[#5d6b4d] text-white'
509 : 'bg-[#efe8da] text-[#617253] hover:bg-[#e3ecd9]'
510 } disabled:cursor-not-allowed disabled:opacity-50`}
511 onClick={() => setCategory(item)}
512 >
513 {t(`sessionDetail.elementAnimationCategory${item[0].toUpperCase()}${item.slice(1)}` as I18nKey)}
514 </button>
515 ))}
516 <button
517 type="button"
518 disabled={disabledState}
519 data-selected={!animation}
520 className={`ml-auto rounded-full px-2.5 py-1 text-[10px] font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
521 hasAnimation
522 ? 'bg-[#8b6658] text-white hover:bg-[#7a554a]'
523 : 'bg-[#fffdf8] text-[#8b6658] hover:bg-[#f8e8df]'
524 }`}
525 onClick={() => handleTypeChange(null)}
526 >
527 {t('sessionDetail.indexTransitionNone')}
528 </button>
529 </div>
530
531 <div className="ppt-element-animation-grid">
532 {options.map((option) => {
533 const selected = option.type === selectedType
534 return (
535 <button
536 type="button"
537 key={option.type}
538 data-selected={selected}
539 data-element-animation={option.type}
540 disabled={typeLocked}
541 className="ppt-element-animation-card disabled:cursor-not-allowed disabled:opacity-50"
542 onClick={() => handleTypeChange(option.type)}
543 >
544 <span className="ppt-element-animation-stage" aria-hidden="true">
545 <span className="ppt-element-animation-object" />
546 </span>
547 <span className="ppt-element-animation-card-label">
548 {t(option.labelKey)}
549 </span>
550 {selected ? (
551 <span className="ppt-element-animation-check" aria-hidden="true">
552 <Check className="h-3 w-3" />
553 </span>
554 ) : null}
555 </button>
556 )
557 })}
558 </div>
559 </div>
560 )}
561 </PopoverContent>
562 </Popover>
563 )
564 }
565
565 lines Plain Text