增强横向滚动功能

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;
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 深度
}}
>
<AnimatePresence mode="wait">
<motion.img
key={current}
src={current}
{enableAnimation ? (
<AnimatePresence mode="wait">
<motion.img
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}
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>
);
}

View File

@ -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 }}
>
<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) => (
<motion.div
key={`role-${index}`}
className={cn(
'relative flex-shrink-0 w-24 rounded-lg overflow-hidden cursor-pointer',
'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 }}
whileTap={{ scale: 0.95 }}
>
@ -153,13 +167,13 @@ export function CharacterTabContent({
</div>
</motion.div>
))}
</div>
</HorizontalScroller>
</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 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
@ -174,6 +188,7 @@ export function CharacterTabContent({
alt={currentRole.name}
width='100%'
height='auto'
enableAnimation={enableAnimation}
/>
{/* 应用角色按钮 */}
<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 FloatingGlassPanel from './FloatingGlassPanel';
import { ReplaceScenePanel, mockShots } from './replace-scene-panel';
import HorizontalScroller from './HorizontalScroller';
interface SceneEnvironment {
time: {
@ -195,45 +196,44 @@ export function SceneTabContent({
>
{/* 分镜缩略图行 */}
<div className="relative">
<div
ref={thumbnailsRef}
className="relative flex gap-4 overflow-x-auto p-2 hide-scrollbar"
<HorizontalScroller
itemWidth={128}
gap={0}
selectedIndex={currentSketchIndex}
onItemClick={(i: number) => handleChangeScene(i)}
>
<div className="flex gap-4 min-w-fit">
{sketches.map((sketch, index) => (
<motion.div
key={sketch.id || index}
className={cn(
'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'
)}
onClick={() => handleChangeScene(index)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<img
src={sketch.url}
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>
{/* 鼠标悬浮/移出 显示/隐藏 删除图标 */}
<div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button
onClick={(e) => {
e.stopPropagation();
console.log('Delete sketch');
}}
className="text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</motion.div>
))}
</div>
{sketches.map((sketch, index) => (
<motion.div
key={sketch.id || index}
className={cn(
'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'
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<img
src={sketch.url}
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>
{/* 鼠标悬浮/移出 显示/隐藏 删除图标 */}
{/* <div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button
onClick={(e) => {
e.stopPropagation();
console.log('Delete sketch');
}}
className="text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</div> */}
</motion.div>
))}
{/* 新增占位符 */}
{/* <motion.div
className={cn(
@ -251,14 +251,16 @@ export function SceneTabContent({
<span className="text-xs"></span>
</div>
</motion.div> */}
</div>
</HorizontalScroller>
</div>
{/* 脚本预览行 - 单行滚动 */}
<div className="relative group">
<div
ref={scriptsRef}
className="flex overflow-x-auto hide-scrollbar py-2 gap-1"
<HorizontalScroller
itemWidth={'auto'}
gap={0}
selectedIndex={currentSketchIndex}
onItemClick={(i: number) => 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({
</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" />

View File

@ -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<HTMLDivElement>(null);
const editorRef = useRef<any>(null);
const videosRef = useRef<HTMLDivElement>(null);
const videoPlayerRef = useRef<HTMLVideoElement>(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({
>
{/* 分镜缩略图行 */}
<div className="relative">
<div
ref={thumbnailsRef}
className="flex gap-4 overflow-x-auto p-2 hide-scrollbar"
<HorizontalScroller
itemWidth={128}
gap={0}
selectedIndex={currentSketchIndex}
onItemClick={(i: number) => onSketchSelect(i)}
>
{sketches.map((sketch, index) => (
<motion.div
@ -176,9 +152,8 @@ export function ShotTabContent({
'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'
)}
onClick={() => onSketchSelect(index)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<video
src={sketch.url}
@ -203,14 +178,16 @@ export function ShotTabContent({
</div> */}
</motion.div>
))}
</div>
</HorizontalScroller>
</div>
{/* 视频描述行 - 单行滚动 */}
<div className="relative group">
<div
ref={videosRef}
className="flex overflow-x-auto hide-scrollbar py-2 gap-1"
<HorizontalScroller
itemWidth={'auto'}
gap={0}
selectedIndex={currentSketchIndex}
onItemClick={(i: number) => onSketchSelect(i)}
>
{sketches.map((video, index) => {
const isActive = currentSketchIndex === index;
@ -238,7 +215,7 @@ export function ShotTabContent({
</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" />