forked from 77media/video-flow
增强横向滚动功能
This commit is contained in:
parent
14a61b9dec
commit
cd0cb015df
172
components/ui/HorizontalScroller.tsx
Normal file
172
components/ui/HorizontalScroller.tsx
Normal 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
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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" />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user