Package: @lilith/ui-image Split from: lilith/ui.git or lilith/build.git Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
164 lines
6.8 KiB
JavaScript
164 lines
6.8 KiB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
/**
|
|
* ImagePreview Component
|
|
*
|
|
* Image thumbnail with hover preview showing larger version.
|
|
* Features:
|
|
* - Hover to show enlarged preview
|
|
* - Smooth animations with framer-motion
|
|
* - Smart positioning to stay within viewport
|
|
* - Optional click to open full lightbox
|
|
* - Keyboard accessible
|
|
*/
|
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import styled from '@lilith/ui-styled-components';
|
|
import { m, AnimatePresence } from '@lilith/ui-motion';
|
|
import { ZINDEX_LAYERS } from '@lilith/ui-zname';
|
|
const ThumbnailContainer = styled.div `
|
|
position: relative;
|
|
display: inline-block;
|
|
width: ${({ $width }) => $width ? (typeof $width === 'number' ? `${$width}px` : $width) : 'auto'};
|
|
height: ${({ $height }) => $height ? (typeof $height === 'number' ? `${$height}px` : $height) : 'auto'};
|
|
border-radius: ${({ $borderRadius }) => $borderRadius
|
|
? typeof $borderRadius === 'number'
|
|
? `${$borderRadius}px`
|
|
: $borderRadius
|
|
: '0'};
|
|
overflow: hidden;
|
|
cursor: ${({ $clickable }) => ($clickable ? 'pointer' : 'default')};
|
|
|
|
&:focus-visible {
|
|
outline: 2px solid var(--color-primary, #6366f1);
|
|
outline-offset: 2px;
|
|
}
|
|
`;
|
|
const ThumbnailImage = styled.img `
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: ${({ $objectFit }) => $objectFit || 'cover'};
|
|
display: block;
|
|
transition: transform 0.2s ease;
|
|
|
|
${ThumbnailContainer}:hover & {
|
|
transform: scale(1.02);
|
|
}
|
|
`;
|
|
const PreviewPortal = styled(m.div) `
|
|
position: fixed;
|
|
left: ${({ $x }) => $x}px;
|
|
top: ${({ $y }) => $y}px;
|
|
z-index: ${ZINDEX_LAYERS['high-priority']};
|
|
pointer-events: none;
|
|
`;
|
|
const PreviewCard = styled(m.div) `
|
|
width: ${({ $width }) => $width}px;
|
|
max-height: ${({ $maxHeight }) => $maxHeight}px;
|
|
background: var(--color-surface, #1a1a2e);
|
|
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
|
`;
|
|
const PreviewImage = styled.img `
|
|
width: 100%;
|
|
height: auto;
|
|
max-height: 100%;
|
|
object-fit: contain;
|
|
display: block;
|
|
`;
|
|
const PreviewFooter = styled.div `
|
|
padding: 12px 16px;
|
|
background: rgba(0, 0, 0, 0.3);
|
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
|
font-size: 13px;
|
|
color: var(--color-text-muted, rgba(255, 255, 255, 0.7));
|
|
`;
|
|
const previewVariants = {
|
|
hidden: {
|
|
opacity: 0,
|
|
scale: 0.9,
|
|
transition: { duration: 0.15, ease: 'easeOut' },
|
|
},
|
|
visible: {
|
|
opacity: 1,
|
|
scale: 1,
|
|
transition: { duration: 0.2, ease: 'easeOut' },
|
|
},
|
|
};
|
|
export function ImagePreview({ src, alt, width, height, previewWidth = 400, previewMaxHeight = 600, hoverDelay = 200, borderRadius, objectFit = 'cover', className, showPreview = true, onClick, previewFooter, children, }) {
|
|
const [showPreviewPanel, setShowPreviewPanel] = useState(false);
|
|
const [previewPosition, setPreviewPosition] = useState({ x: 0, y: 0 });
|
|
const containerRef = useRef(null);
|
|
const hoverTimeoutRef = useRef(null);
|
|
const calculatePreviewPosition = useCallback(() => {
|
|
if (!containerRef.current)
|
|
return { x: 0, y: 0 };
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
const padding = 16;
|
|
// Default: position to the right of the thumbnail
|
|
let x = rect.right + padding;
|
|
let y = rect.top;
|
|
// If preview would overflow right edge, position to the left
|
|
if (x + previewWidth > viewportWidth - padding) {
|
|
x = rect.left - previewWidth - padding;
|
|
}
|
|
// If preview would overflow left edge, center it
|
|
if (x < padding) {
|
|
x = Math.max(padding, (viewportWidth - previewWidth) / 2);
|
|
}
|
|
// Adjust vertical position to stay within viewport
|
|
if (y + previewMaxHeight > viewportHeight - padding) {
|
|
y = Math.max(padding, viewportHeight - previewMaxHeight - padding);
|
|
}
|
|
return { x, y };
|
|
}, [previewWidth, previewMaxHeight]);
|
|
const handleMouseEnter = useCallback(() => {
|
|
if (!showPreview)
|
|
return;
|
|
hoverTimeoutRef.current = setTimeout(() => {
|
|
setPreviewPosition(calculatePreviewPosition());
|
|
setShowPreviewPanel(true);
|
|
}, hoverDelay);
|
|
}, [showPreview, hoverDelay, calculatePreviewPosition]);
|
|
const handleMouseLeave = useCallback(() => {
|
|
if (hoverTimeoutRef.current) {
|
|
clearTimeout(hoverTimeoutRef.current);
|
|
hoverTimeoutRef.current = null;
|
|
}
|
|
setShowPreviewPanel(false);
|
|
}, []);
|
|
const handleKeyDown = useCallback((e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onClick?.();
|
|
}
|
|
}, [onClick]);
|
|
// Update position on scroll/resize while hovering
|
|
useEffect(() => {
|
|
if (!showPreviewPanel)
|
|
return;
|
|
const updatePosition = () => {
|
|
setPreviewPosition(calculatePreviewPosition());
|
|
};
|
|
window.addEventListener('scroll', updatePosition, true);
|
|
window.addEventListener('resize', updatePosition);
|
|
return () => {
|
|
window.removeEventListener('scroll', updatePosition, true);
|
|
window.removeEventListener('resize', updatePosition);
|
|
};
|
|
}, [showPreviewPanel, calculatePreviewPosition]);
|
|
// Cleanup timeout on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (hoverTimeoutRef.current) {
|
|
clearTimeout(hoverTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
return (_jsxs(_Fragment, { children: [_jsxs(ThumbnailContainer, { ref: containerRef, "$width": width, "$height": height, "$borderRadius": borderRadius, "$clickable": !!onClick, className: className, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onClick: onClick, onKeyDown: handleKeyDown, tabIndex: onClick ? 0 : undefined, role: onClick ? 'button' : undefined, "aria-label": onClick ? `View ${alt}` : undefined, children: [_jsx(ThumbnailImage, { src: src, alt: alt, "$objectFit": objectFit, loading: "lazy" }), children] }), typeof document !== 'undefined' &&
|
|
createPortal(_jsx(AnimatePresence, { children: showPreviewPanel && (_jsx(PreviewPortal, { "$x": previewPosition.x, "$y": previewPosition.y, initial: "hidden", animate: "visible", exit: "hidden", variants: previewVariants, children: _jsxs(PreviewCard, { "$width": previewWidth, "$maxHeight": previewMaxHeight, children: [_jsx(PreviewImage, { src: src, alt: alt }), previewFooter && _jsx(PreviewFooter, { children: previewFooter })] }) })) }), document.body)] }));
|
|
}
|
|
export default ImagePreview;
|