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