diff --git a/components/ui/HorizontalScroller.tsx b/components/ui/HorizontalScroller.tsx new file mode 100644 index 0000000..4bd65f8 --- /dev/null +++ b/components/ui/HorizontalScroller.tsx @@ -0,0 +1,172 @@ +import React, { + useRef, + useEffect, + useImperativeHandle, + useCallback, + forwardRef, + ReactNode, + Ref, + useState, +} from 'react' +import gsap from 'gsap' +import { Draggable } from 'gsap/Draggable' +import { motion } from 'framer-motion' + +gsap.registerPlugin(Draggable) + +export interface HorizontalScrollerProps { + children: ReactNode[] + itemWidth?: number | 'auto' + gap?: number + selectedIndex?: number + onItemClick?: (index: number) => void +} + +export interface HorizontalScrollerRef { + scrollToIndex: (index: number) => void +} + +const HorizontalScroller = forwardRef( + ( + { + children, + itemWidth = 300, + gap = 16, + selectedIndex, + onItemClick, + }: HorizontalScrollerProps, + ref: Ref + ) => { + const containerRef = useRef(null) + const wrapperRef = useRef(null) + const dragInstance = useRef(null) + const itemRefs = useRef<(HTMLDivElement | null)[]>([]) + const [currentIndex, setCurrentIndex] = useState(selectedIndex || 0) + + const scrollToIndex = useCallback( + (index: number) => { + if (!containerRef.current || !wrapperRef.current || index < 0 || index >= children.length) return + + const containerWidth = containerRef.current.offsetWidth + let targetX = 0 + + if (itemWidth === 'auto') { + const itemElement = itemRefs.current[index] + if (!itemElement) return + + const itemRect = itemElement.getBoundingClientRect() + const wrapperRect = wrapperRef.current.getBoundingClientRect() + targetX = -(itemElement.offsetLeft - (containerWidth - itemRect.width) / 2) + } else { + targetX = -(index * (itemWidth + gap) - (containerWidth - itemWidth) / 2) + } + + const maxScroll = wrapperRef.current.scrollWidth - containerWidth + targetX = Math.max(-maxScroll, Math.min(targetX, 0)) + + gsap.to(wrapperRef.current, { + x: targetX, + duration: 0.6, + ease: 'power3.out', + onUpdate: () => { + if (dragInstance.current?.update) { + dragInstance.current.update() + } + }, + }) + + setCurrentIndex(index) + }, + [itemWidth, gap, children.length] + ) + + useEffect(() => { + if (!wrapperRef.current || !containerRef.current) return + + const maxScroll = + wrapperRef.current.scrollWidth - containerRef.current.offsetWidth + + dragInstance.current = Draggable.create(wrapperRef.current, { + type: 'x', + edgeResistance: 0.85, + inertia: true, + bounds: { + minX: -maxScroll, + maxX: 0, + }, + onDrag: function () { + gsap.to(wrapperRef.current!, { + x: this.x, + duration: 0.3, + ease: 'power3.out', + }) + }, + })[0] + }, []) + + useEffect(() => { + if (typeof selectedIndex === 'number') { + scrollToIndex(selectedIndex) + } + }, [selectedIndex, scrollToIndex]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + e.preventDefault() + let newIndex = Math.max(0, currentIndex - 1) + if (newIndex === 0 && currentIndex - 1 < 0) { + newIndex = children.length - 1 + } + onItemClick?.(newIndex) + } else if (e.key === 'ArrowRight') { + e.preventDefault() + let newIndex = Math.min(children.length - 1, currentIndex + 1) + if (newIndex === children.length - 1 && currentIndex + 1 > children.length - 1) { + newIndex = 0 + } + onItemClick?.(newIndex) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [currentIndex, children.length, scrollToIndex, onItemClick]) + + useImperativeHandle(ref, () => ({ + scrollToIndex, + })) + + return ( +
+
+ {React.Children.map(children, (child, index) => ( + { + onItemClick?.(index) + }} + ref={(el) => (itemRefs.current[index] = el)} + style={{ + minWidth: itemWidth === 'auto' ? 'auto' : itemWidth, + flexShrink: 0, + }} + className={`p-2 rounded-lg overflow-hidden cursor-pointer hover:scale-[1.03] transition-transform duration-300`} + > + {child} + + ))} +
+
+ ) + } +) + +export default HorizontalScroller diff --git a/components/ui/ImageBlurTransition.tsx b/components/ui/ImageBlurTransition.tsx index 361a199..84bb73a 100644 --- a/components/ui/ImageBlurTransition.tsx +++ b/components/ui/ImageBlurTransition.tsx @@ -10,9 +10,17 @@ type ImageBlurTransitionProps = { alt?: string; width?: number | string; height?: number | string; + enableAnimation?: boolean; }; -export default function ImageBlurTransition({ src, alt = '', width = 480, height = 300, className }: ImageBlurTransitionProps) { +export default function ImageBlurTransition({ + src, + alt = '', + width = 480, + height = 300, + className, + enableAnimation = true +}: ImageBlurTransitionProps) { const [current, setCurrent] = useState(src); const [isFlipping, setIsFlipping] = useState(false); @@ -33,36 +41,44 @@ export default function ImageBlurTransition({ src, alt = '', width = 480, height style={{ width, height, - perspective: 1000, // 关键:提供 3D 深度 + perspective: enableAnimation ? 1000 : 'none', // 只在启用动画时提供 3D 深度 }} > - - + + + ) : ( + {alt} - + )} ); } diff --git a/components/ui/character-tab-content.tsx b/components/ui/character-tab-content.tsx index 42d7204..cf6ac7c 100644 --- a/components/ui/character-tab-content.tsx +++ b/components/ui/character-tab-content.tsx @@ -7,6 +7,7 @@ import ImageBlurTransition from './ImageBlurTransition'; import FloatingGlassPanel from './FloatingGlassPanel'; import { ReplaceCharacterPanel, mockShots, mockCharacter } from './replace-character-panel'; import { CharacterLibrarySelector } from './character-library-selector'; +import HorizontalScroller from './HorizontalScroller'; interface Appearance { hairStyle: string; @@ -67,9 +68,13 @@ export function CharacterTabContent({ const [replacePanelKey, setReplacePanelKey] = useState(0); const [ignoreReplace, setIgnoreReplace] = useState(false); const [isReplaceLibraryOpen, setIsReplaceLibraryOpen] = useState(false); + + const [selectRoleIndex, setSelectRoleIndex] = useState(0); + const [enableAnimation, setEnableAnimation] = useState(true); const handleReplaceCharacter = (url: string) => { + setEnableAnimation(true); setCurrentRole({ ...currentRole, url: url @@ -85,13 +90,14 @@ export function CharacterTabContent({ setIsReplacePanelOpen(false); }; + // 取消替换 const handleCloseReplacePanel = () => { setIsReplacePanelOpen(false); setIgnoreReplace(true); }; const handleChangeRole = (index: number) => { - if (currentRole.url !== roles[currentRoleIndex].url && !ignoreReplace) { + if (currentRole.url !== roles[selectRoleIndex].url && !ignoreReplace) { // 提示 角色已修改,弹出替换角色面板 if (isReplacePanelOpen) { setReplacePanelKey(replacePanelKey + 1); @@ -100,7 +106,11 @@ export function CharacterTabContent({ } return; } - onSketchSelect(index); + // 重置替换规则 + setEnableAnimation(false); + setIgnoreReplace(false); + + setSelectRoleIndex(index); setCurrentRole(roles[index]); }; @@ -130,16 +140,20 @@ export function CharacterTabContent({ animate={{ opacity: 1, y: 0 }} >
-
+ handleChangeRole(i)} + > {roles.map((role, index) => ( handleChangeRole(index)} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} > @@ -153,13 +167,13 @@ export function CharacterTabContent({
))} -
+ {/* 下部分:角色详情 */} {/* 应用角色按钮 */}
diff --git a/components/ui/scene-tab-content.tsx b/components/ui/scene-tab-content.tsx index 8f87d31..414556a 100644 --- a/components/ui/scene-tab-content.tsx +++ b/components/ui/scene-tab-content.tsx @@ -7,6 +7,7 @@ import { cn } from '@/public/lib/utils'; import SceneEditor from './scene-editor'; import FloatingGlassPanel from './FloatingGlassPanel'; import { ReplaceScenePanel, mockShots } from './replace-scene-panel'; +import HorizontalScroller from './HorizontalScroller'; interface SceneEnvironment { time: { @@ -195,45 +196,44 @@ export function SceneTabContent({ > {/* 分镜缩略图行 */}
-
handleChangeScene(i)} > -
- {sketches.map((sketch, index) => ( - handleChangeScene(index)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - {`Sketch -
- Scene {index + 1} -
- {/* 鼠标悬浮/移出 显示/隐藏 删除图标 */} -
- -
-
- ))} -
+ {sketches.map((sketch, index) => ( + + {`Sketch +
+ Scene {index + 1} +
+ {/* 鼠标悬浮/移出 显示/隐藏 删除图标 */} + {/*
+ +
*/} +
+ ))} {/* 新增占位符 */} {/* 添加场景
*/} -
+
{/* 脚本预览行 - 单行滚动 */}
-
handleChangeScene(i)} > {sketches.map((script, index) => { const isActive = currentSketchIndex === index; @@ -269,7 +271,6 @@ export function SceneTabContent({ 'flex-shrink-0 cursor-pointer transition-all duration-300', isActive ? 'text-white' : 'text-white/50 hover:text-white/80' )} - onClick={() => handleChangeScene(index)} initial={false} animate={{ scale: isActive ? 1.02 : 1, @@ -286,7 +287,7 @@ export function SceneTabContent({ ); })} -
+ {/* 渐变遮罩 */}
diff --git a/components/ui/shot-tab-content.tsx b/components/ui/shot-tab-content.tsx index 9c9b7cf..3b78e26 100644 --- a/components/ui/shot-tab-content.tsx +++ b/components/ui/shot-tab-content.tsx @@ -13,6 +13,7 @@ import ShotEditor from './shot-editor/ShotEditor'; import { CharacterLibrarySelector } from './character-library-selector'; import FloatingGlassPanel from './FloatingGlassPanel'; import { ReplaceCharacterPanel, mockShots, mockCharacter } from './replace-character-panel'; +import HorizontalScroller from './HorizontalScroller'; interface ShotTabContentProps { taskSketch: any[]; @@ -27,9 +28,7 @@ export function ShotTabContent({ onSketchSelect, isPlaying: externalIsPlaying = true }: ShotTabContentProps) { - const thumbnailsRef = useRef(null); const editorRef = useRef(null); - const videosRef = useRef(null); const videoPlayerRef = useRef(null); const [isPlaying, setIsPlaying] = React.useState(externalIsPlaying); const [isMediaPropertiesModalOpen, setIsMediaPropertiesModalOpen] = React.useState(false); @@ -51,31 +50,6 @@ export function ShotTabContent({ // 确保 taskSketch 是数组 const sketches = Array.isArray(taskSketch) ? taskSketch : []; - // 自动滚动到选中项 - useEffect(() => { - if (thumbnailsRef.current && videosRef.current) { - const thumbnailContainer = thumbnailsRef.current; - const videoContainer = videosRef.current; - - const thumbnailWidth = thumbnailContainer.children[0]?.clientWidth ?? 0; - const thumbnailGap = 16; // gap-4 = 16px - const thumbnailScrollPosition = (thumbnailWidth + thumbnailGap) * currentSketchIndex; - - const videoElement = videoContainer.children[currentSketchIndex] as HTMLElement; - const videoScrollPosition = videoElement?.offsetLeft ?? 0; - - thumbnailContainer.scrollTo({ - left: thumbnailScrollPosition - thumbnailContainer.clientWidth / 2 + thumbnailWidth / 2, - behavior: 'smooth' - }); - - videoContainer.scrollTo({ - left: videoScrollPosition - videoContainer.clientWidth / 2 + videoElement?.clientWidth / 2, - behavior: 'smooth' - }); - } - }, [currentSketchIndex]); - // 视频播放控制 useEffect(() => { if (videoPlayerRef.current) { @@ -165,9 +139,11 @@ export function ShotTabContent({ > {/* 分镜缩略图行 */}
-
onSketchSelect(i)} > {sketches.map((sketch, index) => ( onSketchSelect(index)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} > ))} -
+
{/* 视频描述行 - 单行滚动 */}
-
onSketchSelect(i)} > {sketches.map((video, index) => { const isActive = currentSketchIndex === index; @@ -238,7 +215,7 @@ export function ShotTabContent({ ); })} -
+ {/* 渐变遮罩 */}