ui-image/dist/ImagePreview.js
autocommit c97eb510e1 chore: initial package split from monorepo
Package: @lilith/ui-image
Split from: lilith/ui.git or lilith/build.git
Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
2026-04-20 01:11:30 -07:00

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;