增强横向滚动功能

This commit is contained in:
北枳 2025-08-01 16:35:23 +08:00
parent 14a61b9dec
commit cd0cb015df
5 changed files with 296 additions and 115 deletions

View File

@ -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<HorizontalScrollerRef>
) => {
const containerRef = useRef<HTMLDivElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const dragInstance = useRef<Draggable | null>(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 (
<div
ref={containerRef}
className="relative w-full overflow-auto touch-none cursor-grab active:cursor-grabbing"
>
<div
ref={wrapperRef}
className="flex select-none"
style={{ gap: `${gap}px` }}
>
{React.Children.map(children, (child, index) => (
<motion.div
key={index}
onClick={() => {
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}
</motion.div>
))}
</div>
</div>
)
}
)
export default HorizontalScroller

View File

@ -10,9 +10,17 @@ type ImageBlurTransitionProps = {
alt?: string; alt?: string;
width?: number | string; width?: number | string;
height?: 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 [current, setCurrent] = useState(src);
const [isFlipping, setIsFlipping] = useState(false); const [isFlipping, setIsFlipping] = useState(false);
@ -33,36 +41,44 @@ export default function ImageBlurTransition({ src, alt = '', width = 480, height
style={{ style={{
width, width,
height, height,
perspective: 1000, // 关键:提供 3D 深度 perspective: enableAnimation ? 1000 : 'none', // 只在启用动画时提供 3D 深度
}} }}
> >
<AnimatePresence mode="wait"> {enableAnimation ? (
<motion.img <AnimatePresence mode="wait">
key={current} <motion.img
src={current} key={current}
src={current}
alt={alt}
className="absolute w-full h-auto object-cover rounded-xl"
initial={{
opacity: 0,
filter: 'blur(8px)',
scale: 1.02,
}}
animate={{
opacity: 1,
filter: 'blur(0px)',
scale: 1,
}}
exit={{
opacity: 0,
filter: 'blur(4px)',
scale: 0.98,
}}
transition={{
duration: 0.3,
ease: 'easeInOut',
}}
/>
</AnimatePresence>
) : (
<img
src={src}
alt={alt} alt={alt}
className="absolute w-full h-auto object-cover rounded-xl" className="absolute w-full h-auto object-cover rounded-xl"
initial={{
opacity: 0,
filter: 'blur(8px)',
scale: 1.02,
}}
animate={{
opacity: 1,
filter: 'blur(0px)',
scale: 1,
}}
exit={{
opacity: 0,
filter: 'blur(4px)',
scale: 0.98,
}}
transition={{
duration: 0.3,
ease: 'easeInOut',
}}
/> />
</AnimatePresence> )}
</div> </div>
); );
} }

View File

@ -7,6 +7,7 @@ import ImageBlurTransition from './ImageBlurTransition';
import FloatingGlassPanel from './FloatingGlassPanel'; import FloatingGlassPanel from './FloatingGlassPanel';
import { ReplaceCharacterPanel, mockShots, mockCharacter } from './replace-character-panel'; import { ReplaceCharacterPanel, mockShots, mockCharacter } from './replace-character-panel';
import { CharacterLibrarySelector } from './character-library-selector'; import { CharacterLibrarySelector } from './character-library-selector';
import HorizontalScroller from './HorizontalScroller';
interface Appearance { interface Appearance {
hairStyle: string; hairStyle: string;
@ -68,8 +69,12 @@ export function CharacterTabContent({
const [ignoreReplace, setIgnoreReplace] = useState(false); const [ignoreReplace, setIgnoreReplace] = useState(false);
const [isReplaceLibraryOpen, setIsReplaceLibraryOpen] = useState(false); const [isReplaceLibraryOpen, setIsReplaceLibraryOpen] = useState(false);
const [selectRoleIndex, setSelectRoleIndex] = useState(0);
const [enableAnimation, setEnableAnimation] = useState(true);
const handleReplaceCharacter = (url: string) => { const handleReplaceCharacter = (url: string) => {
setEnableAnimation(true);
setCurrentRole({ setCurrentRole({
...currentRole, ...currentRole,
url: url url: url
@ -85,13 +90,14 @@ export function CharacterTabContent({
setIsReplacePanelOpen(false); setIsReplacePanelOpen(false);
}; };
// 取消替换
const handleCloseReplacePanel = () => { const handleCloseReplacePanel = () => {
setIsReplacePanelOpen(false); setIsReplacePanelOpen(false);
setIgnoreReplace(true); setIgnoreReplace(true);
}; };
const handleChangeRole = (index: number) => { const handleChangeRole = (index: number) => {
if (currentRole.url !== roles[currentRoleIndex].url && !ignoreReplace) { if (currentRole.url !== roles[selectRoleIndex].url && !ignoreReplace) {
// 提示 角色已修改,弹出替换角色面板 // 提示 角色已修改,弹出替换角色面板
if (isReplacePanelOpen) { if (isReplacePanelOpen) {
setReplacePanelKey(replacePanelKey + 1); setReplacePanelKey(replacePanelKey + 1);
@ -100,7 +106,11 @@ export function CharacterTabContent({
} }
return; return;
} }
onSketchSelect(index); // 重置替换规则
setEnableAnimation(false);
setIgnoreReplace(false);
setSelectRoleIndex(index);
setCurrentRole(roles[index]); setCurrentRole(roles[index]);
}; };
@ -130,16 +140,20 @@ export function CharacterTabContent({
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
> >
<div className="relative"> <div className="relative">
<div className="flex gap-4 overflow-x-auto p-2 hide-scrollbar"> <HorizontalScroller
itemWidth={96}
gap={0}
selectedIndex={selectRoleIndex}
onItemClick={(i: number) => handleChangeRole(i)}
>
{roles.map((role, index) => ( {roles.map((role, index) => (
<motion.div <motion.div
key={`role-${index}`} key={`role-${index}`}
className={cn( className={cn(
'relative flex-shrink-0 w-24 rounded-lg overflow-hidden cursor-pointer', 'relative flex-shrink-0 w-24 rounded-lg overflow-hidden cursor-pointer',
'aspect-[9/16]', 'aspect-[9/16]',
currentRoleIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50' selectRoleIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
)} )}
onClick={() => handleChangeRole(index)}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
> >
@ -153,13 +167,13 @@ export function CharacterTabContent({
</div> </div>
</motion.div> </motion.div>
))} ))}
</div> </HorizontalScroller>
</div> </div>
</motion.div> </motion.div>
{/* 下部分:角色详情 */} {/* 下部分:角色详情 */}
<motion.div <motion.div
className="grid grid-cols-2 gap-6 p-4" className="grid grid-cols-2 gap-6"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
@ -174,6 +188,7 @@ export function CharacterTabContent({
alt={currentRole.name} alt={currentRole.name}
width='100%' width='100%'
height='auto' height='auto'
enableAnimation={enableAnimation}
/> />
{/* 应用角色按钮 */} {/* 应用角色按钮 */}
<div className='absolute top-3 right-3 flex gap-2'> <div className='absolute top-3 right-3 flex gap-2'>

View File

@ -7,6 +7,7 @@ import { cn } from '@/public/lib/utils';
import SceneEditor from './scene-editor'; import SceneEditor from './scene-editor';
import FloatingGlassPanel from './FloatingGlassPanel'; import FloatingGlassPanel from './FloatingGlassPanel';
import { ReplaceScenePanel, mockShots } from './replace-scene-panel'; import { ReplaceScenePanel, mockShots } from './replace-scene-panel';
import HorizontalScroller from './HorizontalScroller';
interface SceneEnvironment { interface SceneEnvironment {
time: { time: {
@ -195,45 +196,44 @@ export function SceneTabContent({
> >
{/* 分镜缩略图行 */} {/* 分镜缩略图行 */}
<div className="relative"> <div className="relative">
<div <HorizontalScroller
ref={thumbnailsRef} itemWidth={128}
className="relative flex gap-4 overflow-x-auto p-2 hide-scrollbar" gap={0}
selectedIndex={currentSketchIndex}
onItemClick={(i: number) => handleChangeScene(i)}
> >
<div className="flex gap-4 min-w-fit"> {sketches.map((sketch, index) => (
{sketches.map((sketch, index) => ( <motion.div
<motion.div key={sketch.id || index}
key={sketch.id || index} className={cn(
className={cn( 'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group',
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group', currentSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
currentSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50' )}
)} whileHover={{ scale: 1.05 }}
onClick={() => handleChangeScene(index)} whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }} >
whileTap={{ scale: 0.95 }} <img
> src={sketch.url}
<img alt={`Sketch ${index + 1}`}
src={sketch.url} className="w-full h-full object-cover"
alt={`Sketch ${index + 1}`} />
className="w-full h-full object-cover" <div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
/> <span className="text-xs text-white/90">Scene {index + 1}</span>
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent"> </div>
<span className="text-xs text-white/90">Scene {index + 1}</span> {/* 鼠标悬浮/移出 显示/隐藏 删除图标 */}
</div> {/* <div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
{/* 鼠标悬浮/移出 显示/隐藏 删除图标 */} <button
<div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300"> onClick={(e) => {
<button e.stopPropagation();
onClick={(e) => { console.log('Delete sketch');
e.stopPropagation(); }}
console.log('Delete sketch'); className="text-red-500"
}} >
className="text-red-500" <Trash2 className="w-4 h-4" />
> </button>
<Trash2 className="w-4 h-4" /> </div> */}
</button> </motion.div>
</div> ))}
</motion.div>
))}
</div>
{/* 新增占位符 */} {/* 新增占位符 */}
{/* <motion.div {/* <motion.div
className={cn( className={cn(
@ -251,14 +251,16 @@ export function SceneTabContent({
<span className="text-xs"></span> <span className="text-xs"></span>
</div> </div>
</motion.div> */} </motion.div> */}
</div> </HorizontalScroller>
</div> </div>
{/* 脚本预览行 - 单行滚动 */} {/* 脚本预览行 - 单行滚动 */}
<div className="relative group"> <div className="relative group">
<div <HorizontalScroller
ref={scriptsRef} itemWidth={'auto'}
className="flex overflow-x-auto hide-scrollbar py-2 gap-1" gap={0}
selectedIndex={currentSketchIndex}
onItemClick={(i: number) => handleChangeScene(i)}
> >
{sketches.map((script, index) => { {sketches.map((script, index) => {
const isActive = currentSketchIndex === index; const isActive = currentSketchIndex === index;
@ -269,7 +271,6 @@ export function SceneTabContent({
'flex-shrink-0 cursor-pointer transition-all duration-300', 'flex-shrink-0 cursor-pointer transition-all duration-300',
isActive ? 'text-white' : 'text-white/50 hover:text-white/80' isActive ? 'text-white' : 'text-white/50 hover:text-white/80'
)} )}
onClick={() => handleChangeScene(index)}
initial={false} initial={false}
animate={{ animate={{
scale: isActive ? 1.02 : 1, scale: isActive ? 1.02 : 1,
@ -286,7 +287,7 @@ export function SceneTabContent({
</motion.div> </motion.div>
); );
})} })}
</div> </HorizontalScroller>
{/* 渐变遮罩 */} {/* 渐变遮罩 */}
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" /> <div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" />

View File

@ -13,6 +13,7 @@ import ShotEditor from './shot-editor/ShotEditor';
import { CharacterLibrarySelector } from './character-library-selector'; import { CharacterLibrarySelector } from './character-library-selector';
import FloatingGlassPanel from './FloatingGlassPanel'; import FloatingGlassPanel from './FloatingGlassPanel';
import { ReplaceCharacterPanel, mockShots, mockCharacter } from './replace-character-panel'; import { ReplaceCharacterPanel, mockShots, mockCharacter } from './replace-character-panel';
import HorizontalScroller from './HorizontalScroller';
interface ShotTabContentProps { interface ShotTabContentProps {
taskSketch: any[]; taskSketch: any[];
@ -27,9 +28,7 @@ export function ShotTabContent({
onSketchSelect, onSketchSelect,
isPlaying: externalIsPlaying = true isPlaying: externalIsPlaying = true
}: ShotTabContentProps) { }: ShotTabContentProps) {
const thumbnailsRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
const videosRef = useRef<HTMLDivElement>(null);
const videoPlayerRef = useRef<HTMLVideoElement>(null); const videoPlayerRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = React.useState(externalIsPlaying); const [isPlaying, setIsPlaying] = React.useState(externalIsPlaying);
const [isMediaPropertiesModalOpen, setIsMediaPropertiesModalOpen] = React.useState(false); const [isMediaPropertiesModalOpen, setIsMediaPropertiesModalOpen] = React.useState(false);
@ -51,31 +50,6 @@ export function ShotTabContent({
// 确保 taskSketch 是数组 // 确保 taskSketch 是数组
const sketches = Array.isArray(taskSketch) ? 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(() => { useEffect(() => {
if (videoPlayerRef.current) { if (videoPlayerRef.current) {
@ -165,9 +139,11 @@ export function ShotTabContent({
> >
{/* 分镜缩略图行 */} {/* 分镜缩略图行 */}
<div className="relative"> <div className="relative">
<div <HorizontalScroller
ref={thumbnailsRef} itemWidth={128}
className="flex gap-4 overflow-x-auto p-2 hide-scrollbar" gap={0}
selectedIndex={currentSketchIndex}
onItemClick={(i: number) => onSketchSelect(i)}
> >
{sketches.map((sketch, index) => ( {sketches.map((sketch, index) => (
<motion.div <motion.div
@ -176,9 +152,8 @@ export function ShotTabContent({
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group', 'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group',
currentSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50' currentSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
)} )}
onClick={() => onSketchSelect(index)} whileHover={{ scale: 1.02 }}
whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.98 }}
whileTap={{ scale: 0.95 }}
> >
<video <video
src={sketch.url} src={sketch.url}
@ -203,14 +178,16 @@ export function ShotTabContent({
</div> */} </div> */}
</motion.div> </motion.div>
))} ))}
</div> </HorizontalScroller>
</div> </div>
{/* 视频描述行 - 单行滚动 */} {/* 视频描述行 - 单行滚动 */}
<div className="relative group"> <div className="relative group">
<div <HorizontalScroller
ref={videosRef} itemWidth={'auto'}
className="flex overflow-x-auto hide-scrollbar py-2 gap-1" gap={0}
selectedIndex={currentSketchIndex}
onItemClick={(i: number) => onSketchSelect(i)}
> >
{sketches.map((video, index) => { {sketches.map((video, index) => {
const isActive = currentSketchIndex === index; const isActive = currentSketchIndex === index;
@ -238,7 +215,7 @@ export function ShotTabContent({
</motion.div> </motion.div>
); );
})} })}
</div> </HorizontalScroller>
{/* 渐变遮罩 */} {/* 渐变遮罩 */}
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" /> <div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" />