forked from 77media/video-flow
221 lines
6.0 KiB
TypeScript
221 lines
6.0 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import styled from 'styled-components';
|
|
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
|
import { Check } from 'lucide-react';
|
|
|
|
interface ImageWaveProps {
|
|
// 图片列表数据
|
|
images: string[];
|
|
// 容器宽度
|
|
containerWidth?: string;
|
|
// 容器高度
|
|
containerHeight?: string;
|
|
// 单个图片宽度
|
|
itemWidth?: string;
|
|
// 单个图片高度
|
|
itemHeight?: string;
|
|
// 图片间距
|
|
gap?: string;
|
|
// 是否开启自动动画
|
|
autoAnimate?: boolean;
|
|
// 自动动画间隔时间(ms)
|
|
autoAnimateInterval?: number;
|
|
// 是否开启点击事件
|
|
onClick?: (index: number) => void;
|
|
}
|
|
|
|
const Wrapper = styled.div<{ width?: string; height?: string }>`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: ${props => props.width || '100vw'};
|
|
height: ${props => props.height || '100vh'};
|
|
background-color: transparent;
|
|
`;
|
|
|
|
const Items = styled.div<{ gap?: string }>`
|
|
display: flex;
|
|
gap: ${props => props.gap || '0.4rem'};
|
|
perspective: calc(var(--index) * 35);
|
|
|
|
&.has-expanded .item:not(.expanded) {
|
|
filter: grayscale(1) brightness(0.3);
|
|
}
|
|
|
|
&.has-expanded .item:hover {
|
|
filter: grayscale(1) brightness(0.3);
|
|
}
|
|
|
|
&.has-expanded .item.expanded:hover {
|
|
filter: inherit;
|
|
transform: translateZ(calc(var(--index) * 10));
|
|
}
|
|
`;
|
|
|
|
const Item = styled.div<{ width?: string; height?: string }>`
|
|
width: ${props => props.width || 'calc(var(--index) * 3)'};
|
|
height: ${props => props.height || 'calc(var(--index) * 12)'};
|
|
background-color: #222;
|
|
background-size: cover;
|
|
background-position: center;
|
|
cursor: pointer;
|
|
filter: grayscale(1) brightness(0.5);
|
|
transition: transform 1.25s var(--transition),
|
|
filter 3s var(--transition),
|
|
width 1.25s var(--transition),
|
|
margin 1.25s var(--transition);
|
|
will-change: transform, filter, rotateY, width;
|
|
|
|
&::before {
|
|
content: '';
|
|
position: absolute;
|
|
width: 20px;
|
|
height: 100%;
|
|
right: calc(var(--index) * -1);
|
|
}
|
|
|
|
&::after {
|
|
left: calc(var(--index) * -1);
|
|
}
|
|
|
|
&:hover {
|
|
filter: inherit;
|
|
transform: translateZ(calc(var(--index) * 7.8));
|
|
}
|
|
&:hover + * {
|
|
filter: inherit;
|
|
transform: translateZ(calc(var(--index) * 6.8)) rotateY(35deg);
|
|
z-index: -1;
|
|
}
|
|
&:hover + * + * {
|
|
filter: inherit;
|
|
transform: translateZ(calc(var(--index) * 5.6)) rotateY(40deg);
|
|
z-index: -2;
|
|
}
|
|
&:hover + * + * + * {
|
|
filter: inherit;
|
|
transform: translateZ(calc(var(--index) * 3.6)) rotateY(30deg);
|
|
z-index: -3;
|
|
}
|
|
&:hover + * + * + * + * {
|
|
filter: inherit;
|
|
transform: translateZ(calc(var(--index) * .6)) rotateY(15deg);
|
|
z-index: -4;
|
|
}
|
|
&:has(+ :hover) {
|
|
filter: inherit;
|
|
transform: translateZ(calc(var(--index) * 6.8)) rotateY(-35deg);
|
|
}
|
|
&:has(+ * + :hover) {
|
|
filter: inherit;
|
|
transform: translateZ(calc(var(--index) * 5.6)) rotateY(-40deg);
|
|
}
|
|
&:has(+ * + * + :hover) {
|
|
filter: inherit;
|
|
transform: translateZ(calc(var(--index) * 3.6)) rotateY(-30deg);
|
|
}
|
|
&:has(+ * + * + * + :hover) {
|
|
filter: inherit;
|
|
transform: translateZ(calc(var(--index) * .6)) rotateY(-15deg);
|
|
}
|
|
|
|
&.expanded {
|
|
width: 28vw;
|
|
filter: inherit;
|
|
z-index: 100;
|
|
transform: translateZ(calc(var(--index) * 7.8));
|
|
margin: 0.45vw;
|
|
}
|
|
|
|
&.selected {
|
|
filter: inherit;
|
|
}
|
|
`;
|
|
|
|
export const ImageWave: React.FC<ImageWaveProps> = ({
|
|
images,
|
|
containerWidth,
|
|
containerHeight,
|
|
itemWidth,
|
|
itemHeight,
|
|
gap,
|
|
autoAnimate = false,
|
|
autoAnimateInterval = 2000,
|
|
onClick,
|
|
}) => {
|
|
const [currentExpandedItem, setCurrentExpandedItem] = useState<number | null>(null);
|
|
const [currentSelectedIndex, setCurrentSelectedIndex] = useState<number | null>(null);
|
|
const itemsRef = useRef<HTMLDivElement>(null);
|
|
const autoAnimateRef = useRef<number | null>(null);
|
|
const currentIndexRef = useRef<number>(0);
|
|
|
|
const handleItemClick = (index: number) => {
|
|
if (currentExpandedItem === index) {
|
|
setCurrentExpandedItem(null);
|
|
} else {
|
|
setCurrentExpandedItem(index);
|
|
}
|
|
};
|
|
|
|
const handleOutsideClick = useCallback((e: MouseEvent) => {
|
|
if (itemsRef.current && !itemsRef.current.contains(e.target as Node)) {
|
|
setCurrentExpandedItem(null);
|
|
}
|
|
}, []);
|
|
|
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
setCurrentExpandedItem(null);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
document.addEventListener('click', handleOutsideClick);
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
|
|
return () => {
|
|
document.removeEventListener('click', handleOutsideClick);
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [handleOutsideClick, handleKeyDown]);
|
|
|
|
useEffect(() => {
|
|
|
|
return () => {
|
|
if (autoAnimateRef.current) {
|
|
clearTimeout(autoAnimateRef.current);
|
|
}
|
|
};
|
|
}, [autoAnimate]);
|
|
|
|
const handleSelectImage = (index: number) => {
|
|
setCurrentSelectedIndex(index);
|
|
onClick?.(index);
|
|
};
|
|
|
|
return (
|
|
<Wrapper width={containerWidth} height={containerHeight}>
|
|
<Items ref={itemsRef} gap={gap} className={currentExpandedItem !== null ? 'has-expanded' : ''}>
|
|
{images.map((image, index) => (
|
|
<Item
|
|
key={index}
|
|
width={itemWidth}
|
|
height={itemHeight}
|
|
className={`group relative item ${currentExpandedItem === index ? 'expanded' : ''} ${currentSelectedIndex === index ? 'selected' : ''}`}
|
|
style={{ backgroundImage: `url(${image})` }}
|
|
onClick={() => handleItemClick(index)}
|
|
tabIndex={0}
|
|
>
|
|
{/* 添加一个玻璃按钮 勾选当前图片 移入/选中改变图标颜色 */}
|
|
<GlassIconButton
|
|
icon={Check}
|
|
size='sm'
|
|
onClick={() => handleSelectImage(index)}
|
|
className="absolute top-1 right-1 z-[999] cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity duration-300 group-hover:bg-blue-500/50"
|
|
/>
|
|
</Item>
|
|
))}
|
|
</Items>
|
|
</Wrapper>
|
|
);
|
|
};
|