ui-image/dist/ProtectedImage.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

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, {})] }));
}