Package: @lilith/ui-image Split from: lilith/ui.git or lilith/build.git Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
76 lines
3 KiB
JavaScript
76 lines
3 KiB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
/**
|
|
* ProtectedImage — Anti-theft image wrapper with WebP optimization
|
|
*
|
|
* Serves WebP with JPEG fallback via <picture> element.
|
|
* Prevents casual image theft via right-click, drag, and long-press.
|
|
* Shows a shimmer skeleton while loading to prevent layout shift.
|
|
*
|
|
* CSS custom properties (all optional):
|
|
* --protected-image-border-color default: rgba(255, 255, 255, 0.1)
|
|
*/
|
|
import { useState } from 'react';
|
|
import styled, { keyframes } from '@lilith/ui-styled-components';
|
|
const shimmer = keyframes `
|
|
0% { background-position: -800px 0; }
|
|
100% { background-position: 800px 0; }
|
|
`;
|
|
const ImageContainer = styled.div `
|
|
position: relative;
|
|
overflow: hidden;
|
|
border-radius: ${(p) => p.$borderRadius ?? '8px'};
|
|
border: ${(p) => p.$noBorder ? 'none' : '1px solid var(--protected-image-border-color, rgba(255, 255, 255, 0.1))'};
|
|
line-height: 0;
|
|
${(p) => p.$aspectRatio ? `aspect-ratio: ${p.$aspectRatio};` : ''}
|
|
`;
|
|
const SkeletonOverlay = styled.div `
|
|
position: absolute;
|
|
inset: 0;
|
|
border-radius: inherit;
|
|
background: linear-gradient(
|
|
90deg,
|
|
rgba(20, 20, 25, 0.8) 0%,
|
|
rgba(40, 40, 52, 0.7) 40%,
|
|
rgba(55, 55, 68, 0.7) 50%,
|
|
rgba(40, 40, 52, 0.7) 60%,
|
|
rgba(20, 20, 25, 0.8) 100%
|
|
);
|
|
background-size: 800px 100%;
|
|
animation: ${shimmer} 1.8s infinite linear;
|
|
opacity: ${(p) => (p.$visible ? 1 : 0)};
|
|
transition: opacity 300ms ease;
|
|
pointer-events: none;
|
|
z-index: 2;
|
|
`;
|
|
const StyledPicture = styled.picture `
|
|
display: block;
|
|
width: 100%;
|
|
`;
|
|
const Image = styled.img `
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
object-fit: ${(p) => p.$objectFit};
|
|
user-select: none;
|
|
-webkit-user-drag: none;
|
|
-webkit-touch-callout: none;
|
|
pointer-events: none;
|
|
`;
|
|
/** Transparent div that intercepts all pointer events so the <img> is unreachable. */
|
|
const InteractionBlocker = styled.div `
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 1;
|
|
cursor: pointer;
|
|
`;
|
|
function deriveWebpSrc(src) {
|
|
return src.replace(/\.(jpe?g|png)$/i, '.webp');
|
|
}
|
|
export function ProtectedImage({ src, alt, webpSrc, intrinsicWidth, intrinsicHeight, width, height, objectFit = 'cover', borderRadius, noBorder = false, loading = 'lazy', fetchPriority, }) {
|
|
const resolvedWebp = webpSrc ?? deriveWebpSrc(src);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const aspectRatio = intrinsicWidth && intrinsicHeight
|
|
? `${intrinsicWidth} / ${intrinsicHeight}`
|
|
: undefined;
|
|
return (_jsxs(ImageContainer, { "$borderRadius": borderRadius, "$aspectRatio": aspectRatio, "$noBorder": noBorder, style: { width, height }, onContextMenu: (e) => e.preventDefault(), children: [_jsx(SkeletonOverlay, { "$visible": !loaded }), _jsxs(StyledPicture, { children: [_jsx("source", { srcSet: resolvedWebp, type: "image/webp" }), _jsx(Image, { src: src, alt: alt, "$objectFit": objectFit, loading: loading, fetchPriority: fetchPriority, draggable: false, width: intrinsicWidth, height: intrinsicHeight, onLoad: () => setLoaded(true) })] }), _jsx(InteractionBlocker, {})] }));
|
|
}
|