mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: make beautify live preview reflect all settings in real time
Background images, device frames, custom shadows, and watermark text were not rendering in the right-pane preview. The preview now updates in real time for all settings: gradient/solid/image backgrounds, macOS/ Windows/Browser frame chrome, iPhone/MacBook/iPad frame indicators, custom shadow parameters, and watermark text overlay. Also fixes a React StrictMode effect-ordering race where the parent tool-page reset cleared preview state set by the child Settings component on initial mount.
This commit is contained in:
@@ -27,6 +27,7 @@ interface ImageViewerProps {
|
||||
cssFilter?: string;
|
||||
bgPreview?: BgPreviewState;
|
||||
imageWrapperStyle?: React.CSSProperties;
|
||||
imageWrapperChildren?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ZOOM_STEPS = [10, 25, 50, 75, 100, 150, 200, 300, 500, 1000];
|
||||
@@ -44,6 +45,7 @@ export function ImageViewer({
|
||||
cssFilter,
|
||||
bgPreview,
|
||||
imageWrapperStyle,
|
||||
imageWrapperChildren,
|
||||
}: ImageViewerProps) {
|
||||
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
|
||||
const [naturalWidth, setNaturalWidth] = useState<number | null>(null);
|
||||
@@ -261,13 +263,17 @@ export function ImageViewer({
|
||||
<div
|
||||
style={{
|
||||
...imageWrapperStyle,
|
||||
display: "inline-block",
|
||||
display: imageWrapperChildren ? "inline-flex" : "inline-block",
|
||||
flexDirection: imageWrapperChildren ? ("column" as const) : undefined,
|
||||
position: imageWrapperChildren ? ("relative" as const) : undefined,
|
||||
boxSizing: "border-box" as const,
|
||||
overflow: "hidden",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{imageWrapperChildren}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
@@ -275,12 +281,22 @@ export function ImageViewer({
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
className="select-none"
|
||||
style={{
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
objectFit: "contain" as const,
|
||||
}}
|
||||
style={
|
||||
imageWrapperChildren
|
||||
? {
|
||||
display: "block",
|
||||
flex: "1 1 0",
|
||||
minHeight: 0,
|
||||
maxWidth: "100%",
|
||||
objectFit: "contain" as const,
|
||||
}
|
||||
: {
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
objectFit: "contain" as const,
|
||||
}
|
||||
}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -152,9 +152,20 @@ const SHADOW_CSS: Record<string, string> = {
|
||||
dramatic: "0px 20px 80px rgba(0,0,0,0.5)",
|
||||
};
|
||||
|
||||
function buildPreviewStyle(settings: Record<string, unknown>): React.CSSProperties {
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
function buildPreviewStyle(
|
||||
settings: Record<string, unknown>,
|
||||
bgImageUrl?: string | null,
|
||||
): React.CSSProperties {
|
||||
const bg = settings.backgroundType as string;
|
||||
let background: string | undefined;
|
||||
const extra: React.CSSProperties = {};
|
||||
|
||||
if (bg === "solid") {
|
||||
background = settings.backgroundColor as string;
|
||||
@@ -165,15 +176,34 @@ function buildPreviewStyle(settings: Record<string, unknown>): React.CSSProperti
|
||||
} else if (bg === "radial-gradient") {
|
||||
const stops = settings.gradientStops as GradientStop[];
|
||||
background = `radial-gradient(circle, ${stops.map((s) => `${s.color} ${s.position}%`).join(", ")})`;
|
||||
} else if (bg === "image" && bgImageUrl) {
|
||||
extra.backgroundImage = `url("${bgImageUrl}")`;
|
||||
extra.backgroundSize = "cover";
|
||||
extra.backgroundPosition = "center";
|
||||
}
|
||||
|
||||
const shadowPreset = settings.shadowPreset as string;
|
||||
let boxShadow: string | undefined;
|
||||
if (shadowPreset === "custom") {
|
||||
const blur = settings.shadowBlur as number;
|
||||
const ox = settings.shadowOffsetX as number;
|
||||
const oy = settings.shadowOffsetY as number;
|
||||
const color = settings.shadowColor as string;
|
||||
const opacity = (settings.shadowOpacity as number) / 100;
|
||||
boxShadow = `${ox}px ${oy}px ${blur}px ${hexToRgba(color, opacity)}`;
|
||||
} else if (shadowPreset !== "none") {
|
||||
boxShadow = SHADOW_CSS[shadowPreset];
|
||||
}
|
||||
|
||||
const frame = settings.frame as string;
|
||||
const hasFrame = frame && frame !== "none";
|
||||
|
||||
return {
|
||||
background,
|
||||
...extra,
|
||||
padding: `${settings.padding}px`,
|
||||
borderRadius: `${settings.borderRadius}px`,
|
||||
boxShadow: SHADOW_CSS[shadowPreset],
|
||||
borderRadius: hasFrame ? "0px" : `${settings.borderRadius}px`,
|
||||
boxShadow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +214,265 @@ function presetGradientCSS(preset: BeautifyPreset): string {
|
||||
return `linear-gradient(${preset.gradientAngle}deg, ${stops})`;
|
||||
}
|
||||
|
||||
// -- Frame & watermark preview overlay --------------------------------------
|
||||
|
||||
const TRAFFIC_LIGHTS = [{ color: "#ff5f57" }, { color: "#febc2e" }, { color: "#28c840" }];
|
||||
|
||||
const DEVICE_LABELS: Record<string, string> = {
|
||||
iphone: "iPhone",
|
||||
macbook: "MacBook",
|
||||
ipad: "iPad",
|
||||
};
|
||||
|
||||
function renderMacosFrame(isDark: boolean, title: string): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
data-testid="frame-preview-macos"
|
||||
style={{
|
||||
height: 36,
|
||||
background: isDark ? "#323233" : "#f1f0ef",
|
||||
borderBottom: `0.5px solid ${isDark ? "#555555" : "#c5c5c5"}`,
|
||||
borderRadius: "8px 8px 0 0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: 14,
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{TRAFFIC_LIGHTS.map((tl) => (
|
||||
<div
|
||||
key={tl.color}
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
background: tl.color,
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{title && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: isDark ? "#d4d4d4" : "#4b4b4b",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWindowsFrame(isDark: boolean, title: string): React.ReactNode {
|
||||
const btnColor = isDark ? "#999" : "#666";
|
||||
return (
|
||||
<div
|
||||
data-testid="frame-preview-windows"
|
||||
style={{
|
||||
height: 36,
|
||||
background: isDark ? "#2b2b2b" : "#f3f3f3",
|
||||
borderBottom: `0.5px solid ${isDark ? "#3a3a3a" : "#e0e0e0"}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingLeft: 12,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: isDark ? "#ffffff" : "#1a1a1a" }}>{title}</span>
|
||||
<div style={{ display: "flex", height: "100%" }}>
|
||||
{["─", "□", "✕"].map((icon) => (
|
||||
<div
|
||||
key={icon}
|
||||
style={{
|
||||
width: 46,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: btnColor,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBrowserFrame(isDark: boolean, title: string): React.ReactNode {
|
||||
return (
|
||||
<div data-testid="frame-preview-browser" style={{ flexShrink: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
height: 36,
|
||||
background: isDark ? "#323233" : "#f1f0ef",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: 14,
|
||||
borderRadius: "8px 8px 0 0",
|
||||
}}
|
||||
>
|
||||
{TRAFFIC_LIGHTS.map((tl) => (
|
||||
<div
|
||||
key={tl.color}
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
background: tl.color,
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
padding: "4px 16px",
|
||||
borderRadius: "6px 6px 0 0",
|
||||
background: isDark ? "#1e1e1e" : "#ffffff",
|
||||
fontSize: 11,
|
||||
color: isDark ? "#d4d4d4" : "#4b4b4b",
|
||||
}}
|
||||
>
|
||||
{title || "New Tab"}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 36,
|
||||
background: isDark ? "#1e1e1e" : "#ffffff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0 12px",
|
||||
borderTop: `0.5px solid ${isDark ? "#444" : "#e0e0e0"}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: isDark ? "#323233" : "#f1f0ef",
|
||||
borderRadius: 6,
|
||||
padding: "4px 10px",
|
||||
fontSize: 11,
|
||||
color: isDark ? "#999" : "#666",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10 }}>{"🔒"}</span>
|
||||
<span>example.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDeviceFrame(isDark: boolean, deviceType: string): React.ReactNode {
|
||||
const label = DEVICE_LABELS[deviceType] || deviceType;
|
||||
return (
|
||||
<div
|
||||
data-testid={`frame-preview-${deviceType}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "6px 12px",
|
||||
background: isDark ? "#1d1d1f" : "#e8e8e8",
|
||||
borderRadius: "12px 12px 0 0",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: isDark ? "#999" : "#666",
|
||||
letterSpacing: "0.5px",
|
||||
textTransform: "uppercase" as const,
|
||||
}}
|
||||
>
|
||||
{label} Frame
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const WATERMARK_POS_STYLES: Record<string, React.CSSProperties> = {
|
||||
"top-left": { top: 8, left: 8 },
|
||||
"top-right": { top: 8, right: 8 },
|
||||
center: { top: "50%", left: "50%", transform: "translate(-50%, -50%)" },
|
||||
"bottom-left": { bottom: 8, left: 8 },
|
||||
"bottom-right": { bottom: 8, right: 8 },
|
||||
};
|
||||
|
||||
function renderWatermark(text: string, position: string, opacity: number): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
data-testid="watermark-preview"
|
||||
style={{
|
||||
position: "absolute",
|
||||
...WATERMARK_POS_STYLES[position],
|
||||
color: "#ffffff",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
opacity: opacity / 100,
|
||||
textShadow: "0 1px 3px rgba(0,0,0,0.5)",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFramePreview(
|
||||
frame: string,
|
||||
title: string,
|
||||
watermarkText?: string,
|
||||
watermarkPosition?: string,
|
||||
watermarkOpacity?: number,
|
||||
): React.ReactNode {
|
||||
let frameNode: React.ReactNode = null;
|
||||
let watermarkNode: React.ReactNode = null;
|
||||
|
||||
if (frame !== "none") {
|
||||
const isDark = frame.endsWith("-dark");
|
||||
const type = frame.replace(/-(?:light|dark)$/, "");
|
||||
|
||||
if (type === "macos") frameNode = renderMacosFrame(isDark, title);
|
||||
else if (type === "windows") frameNode = renderWindowsFrame(isDark, title);
|
||||
else if (type === "browser") frameNode = renderBrowserFrame(isDark, title);
|
||||
else frameNode = renderDeviceFrame(isDark, type);
|
||||
}
|
||||
|
||||
if (watermarkText) {
|
||||
watermarkNode = renderWatermark(
|
||||
watermarkText,
|
||||
watermarkPosition || "bottom-right",
|
||||
watermarkOpacity ?? 50,
|
||||
);
|
||||
}
|
||||
|
||||
if (!frameNode && !watermarkNode) return null;
|
||||
return (
|
||||
<>
|
||||
{frameNode}
|
||||
{watermarkNode}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Background tab types ---------------------------------------------------
|
||||
|
||||
type BackgroundTab = "linear-gradient" | "radial-gradient" | "solid" | "image" | "transparent";
|
||||
@@ -248,6 +537,7 @@ export interface BeautifyControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
onImageStyle?: (style: React.CSSProperties | null) => void;
|
||||
onImageOverlay?: (children: React.ReactNode) => void;
|
||||
onBackgroundImage?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
@@ -255,6 +545,7 @@ export function BeautifyControls({
|
||||
settings: initialSettings,
|
||||
onChange,
|
||||
onImageStyle,
|
||||
onImageOverlay,
|
||||
onBackgroundImage,
|
||||
}: BeautifyControlsProps) {
|
||||
// State
|
||||
@@ -268,8 +559,18 @@ export function BeautifyControls({
|
||||
const [gradientAngle, setGradientAngle] = useState(135);
|
||||
const [gradientMode, setGradientMode] = useState<"linear" | "radial">("linear");
|
||||
const [bgImageFile, setBgImageFile] = useState<File | null>(null);
|
||||
const [bgImageUrl, setBgImageUrl] = useState<string | null>(null);
|
||||
const bgInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (bgImageFile) {
|
||||
const url = URL.createObjectURL(bgImageFile);
|
||||
setBgImageUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}
|
||||
setBgImageUrl(null);
|
||||
}, [bgImageFile]);
|
||||
|
||||
const [frameType, setFrameType] = useState<FrameType>("macos");
|
||||
const [frameTheme, setFrameTheme] = useState<FrameTheme>("light");
|
||||
const [frameTitle, setFrameTitle] = useState("");
|
||||
@@ -325,6 +626,11 @@ export function BeautifyControls({
|
||||
onBgImageRef.current = onBackgroundImage;
|
||||
});
|
||||
|
||||
const onOverlayRef = useRef(onImageOverlay);
|
||||
useEffect(() => {
|
||||
onOverlayRef.current = onImageOverlay;
|
||||
});
|
||||
|
||||
// Resolve frame string from type + theme
|
||||
const resolvedFrame = frameType === "none" ? "none" : (`${frameType}-${frameTheme}` as const);
|
||||
|
||||
@@ -336,7 +642,7 @@ export function BeautifyControls({
|
||||
: "linear-gradient"
|
||||
: backgroundType;
|
||||
|
||||
// Propagate changes
|
||||
// Propagate changes to parent and apply live preview.
|
||||
useEffect(() => {
|
||||
const vals: Record<string, unknown> = {
|
||||
backgroundType: resolvedBgType,
|
||||
@@ -359,7 +665,20 @@ export function BeautifyControls({
|
||||
watermarkOpacity,
|
||||
};
|
||||
onChangeRef.current?.(vals);
|
||||
onImageStyleRef.current?.(buildPreviewStyle(vals));
|
||||
onImageStyleRef.current?.(buildPreviewStyle(vals, bgImageUrl));
|
||||
onOverlayRef.current?.(
|
||||
renderFramePreview(
|
||||
resolvedFrame,
|
||||
frameTitle,
|
||||
watermarkText,
|
||||
watermarkPosition,
|
||||
watermarkOpacity,
|
||||
),
|
||||
);
|
||||
return () => {
|
||||
onImageStyleRef.current?.(null);
|
||||
onOverlayRef.current?.(null);
|
||||
};
|
||||
}, [
|
||||
resolvedBgType,
|
||||
backgroundColor,
|
||||
@@ -379,6 +698,7 @@ export function BeautifyControls({
|
||||
watermarkText,
|
||||
watermarkPosition,
|
||||
watermarkOpacity,
|
||||
bgImageUrl,
|
||||
]);
|
||||
|
||||
const clearPreset = () => setSelectedPreset(null);
|
||||
@@ -964,8 +1284,10 @@ export function BeautifyControls({
|
||||
|
||||
export function BeautifySettings({
|
||||
onImageStyle,
|
||||
onImageOverlay,
|
||||
}: {
|
||||
onImageStyle?: (style: React.CSSProperties | null) => void;
|
||||
onImageOverlay?: (children: React.ReactNode) => void;
|
||||
}) {
|
||||
const { files, setProcessedUrl, setSizes, setJobId } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
@@ -1048,6 +1370,7 @@ export function BeautifySettings({
|
||||
<BeautifyControls
|
||||
onChange={handleSettingsChange}
|
||||
onImageStyle={onImageStyle}
|
||||
onImageOverlay={onImageOverlay}
|
||||
onBackgroundImage={handleBgImageChange}
|
||||
/>
|
||||
|
||||
|
||||
@@ -53,12 +53,15 @@ export interface ToolRegistryEntry {
|
||||
displayMode: DisplayMode;
|
||||
/** Whether this tool supports live preview transforms (rotate, color). */
|
||||
livePreview?: boolean;
|
||||
/** Override the default file-picker accept string (e.g. ".svg,.svgz"). */
|
||||
accept?: string;
|
||||
/** The settings component for this tool. */
|
||||
Settings: React.ComponentType<{
|
||||
onPreviewTransform?: (t: PreviewTransform) => void;
|
||||
onPreviewFilter?: (filter: string) => void;
|
||||
onBgPreview?: (state: BgPreviewState | null) => void;
|
||||
onImageStyle?: (style: React.CSSProperties | null) => void;
|
||||
onImageOverlay?: (children: React.ReactNode) => void;
|
||||
cropProps?: CropProps;
|
||||
eraserProps?: EraserProps;
|
||||
}>;
|
||||
@@ -445,7 +448,10 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
|
||||
],
|
||||
|
||||
// Format & Conversion
|
||||
["svg-to-raster", { displayMode: "before-after", Settings: SvgToRasterSettings }],
|
||||
[
|
||||
"svg-to-raster",
|
||||
{ displayMode: "before-after", accept: ".svg,.svgz", Settings: SvgToRasterSettings },
|
||||
],
|
||||
["vectorize", { displayMode: "before-after", Settings: VectorizeSettings }],
|
||||
["gif-tools", { displayMode: "before-after", Settings: GifToolsSettings }],
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ export function ToolPage() {
|
||||
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
|
||||
const [previewFilter, setPreviewFilter] = useState<string>("");
|
||||
const [imageWrapperStyle, setImageWrapperStyle] = useState<React.CSSProperties | null>(null);
|
||||
const [imageWrapperChildren, setImageWrapperChildren] = useState<React.ReactNode>(null);
|
||||
const [bgPreview, setBgPreview] = useState<BgPreviewState | null>(null);
|
||||
|
||||
const [cropCrop, setCropCrop] = useState<Crop>({
|
||||
@@ -260,7 +261,6 @@ export function ToolPage() {
|
||||
|
||||
setPreviewTransform(null);
|
||||
setPreviewFilter("");
|
||||
setImageWrapperStyle(null);
|
||||
setBgPreview(null);
|
||||
setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 });
|
||||
setCropAspect(undefined);
|
||||
@@ -272,6 +272,26 @@ export function ToolPage() {
|
||||
setMobileSettingsOpen(true);
|
||||
}, [toolId]);
|
||||
|
||||
const toolAccept = registryEntry?.accept;
|
||||
const toolAcceptExts = useMemo(
|
||||
() => toolAccept?.split(",").map((e) => e.trim().replace(/^\./, "").toLowerCase()),
|
||||
[toolAccept],
|
||||
);
|
||||
const toolFileFilter = useMemo(() => {
|
||||
if (!toolAcceptExts) return undefined;
|
||||
return (file: File) => {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
return toolAcceptExts.includes(ext);
|
||||
};
|
||||
}, [toolAcceptExts]);
|
||||
const toolAcceptDescription = useMemo(
|
||||
() =>
|
||||
toolAcceptExts
|
||||
? `${toolAcceptExts.map((e) => e.toUpperCase()).join(", ")} files only`
|
||||
: undefined,
|
||||
[toolAcceptExts],
|
||||
);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(newFiles: File[]) => {
|
||||
setEraserSliderInitPos(null);
|
||||
@@ -298,13 +318,15 @@ export function ToolPage() {
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept =
|
||||
toolAccept ??
|
||||
"image/*,.avif,.heic,.heif,.hif,.jxl,.dng,.cr2,.cr3,.nef,.nrw,.arw,.orf,.rw2,.raf,.pef,.3fr,.iiq,.srw,.x3f,.rwl,.gpr,.fff,.mrw,.mef,.kdc,.dcr,.erf,.ptx,.tga,.psd,.exr,.hdr,.svgz,.jp2,.j2k,.qoi,.eps,.dds,.cur,.apng,.dpx,.cin,.fits,.ppm,.pgm,.pbm,.pfm";
|
||||
input.onchange = (e) => {
|
||||
const newFiles = Array.from((e.target as HTMLInputElement).files || []);
|
||||
const selected = Array.from((e.target as HTMLInputElement).files || []);
|
||||
const newFiles = toolFileFilter ? selected.filter(toolFileFilter) : selected;
|
||||
if (newFiles.length > 0) addFiles(newFiles);
|
||||
};
|
||||
input.click();
|
||||
}, [addFiles]);
|
||||
}, [addFiles, toolAccept, toolFileFilter]);
|
||||
|
||||
const handleDownloadAll = useCallback(() => {
|
||||
if (!batchZipBlob) return;
|
||||
@@ -381,6 +403,7 @@ export function ToolPage() {
|
||||
onPreviewTransform: isLivePreview ? setPreviewTransform : undefined,
|
||||
onPreviewFilter: isLivePreview ? setPreviewFilter : undefined,
|
||||
onImageStyle: isLivePreview ? setImageWrapperStyle : undefined,
|
||||
onImageOverlay: isLivePreview ? (c: React.ReactNode) => setImageWrapperChildren(c) : undefined,
|
||||
onBgPreview: setBgPreview,
|
||||
cropProps:
|
||||
displayMode === "interactive-crop"
|
||||
@@ -442,9 +465,11 @@ export function ToolPage() {
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
onUrlImport={handleUrlImport}
|
||||
accept="image/*"
|
||||
accept={toolAccept ?? "image/*"}
|
||||
multiple
|
||||
currentFiles={files}
|
||||
fileFilter={toolFileFilter}
|
||||
acceptDescription={toolAcceptDescription}
|
||||
/>
|
||||
);
|
||||
const ResultsPanel = registryEntry.ResultsPanel;
|
||||
@@ -560,6 +585,7 @@ export function ToolPage() {
|
||||
filename={selectedFileName ?? files[0].name}
|
||||
fileSize={selectedFileSize ?? files[0].size}
|
||||
imageWrapperStyle={imageWrapperStyle}
|
||||
imageWrapperChildren={imageWrapperChildren}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -642,6 +668,7 @@ export function ToolPage() {
|
||||
: {})}
|
||||
{...(isLivePreview && previewFilter ? { cssFilter: previewFilter } : {})}
|
||||
{...(isLivePreview && imageWrapperStyle ? { imageWrapperStyle } : {})}
|
||||
{...(isLivePreview && imageWrapperChildren ? { imageWrapperChildren } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -650,9 +677,11 @@ export function ToolPage() {
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
onUrlImport={handleUrlImport}
|
||||
accept="image/*"
|
||||
accept={toolAccept ?? "image/*"}
|
||||
multiple
|
||||
currentFiles={files}
|
||||
fileFilter={toolFileFilter}
|
||||
acceptDescription={toolAcceptDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,4 +91,118 @@ test.describe("Beautify Screenshot", () => {
|
||||
.or(page.getByRole("link", { name: /download/i }).first()),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live preview tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("live preview shows gradient background on wrapper", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/beautify");
|
||||
await uploadTestImage(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Default preset (Purple Haze) should apply a gradient background to the wrapper
|
||||
const wrapper = page.locator("img.select-none").first().locator("..");
|
||||
const bg = await wrapper.evaluate((el) => getComputedStyle(el).background);
|
||||
// Should contain a gradient (linear-gradient produces a computed background-image)
|
||||
expect(bg.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("live preview renders macOS frame chrome", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/beautify");
|
||||
await uploadTestImage(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Default preset has macos-light frame; verify traffic light dots appear.
|
||||
// The initial preview uses setTimeout(0) to survive the parent reset effect.
|
||||
await expect(page.getByTestId("frame-preview-macos")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("switching to Windows frame shows Windows title bar", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/beautify");
|
||||
await uploadTestImage(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click Windows frame button
|
||||
await page.getByRole("button", { name: "Windows" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(page.getByTestId("frame-preview-windows")).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test("switching to Browser frame shows tab bar and URL bar", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/beautify");
|
||||
await uploadTestImage(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.getByRole("button", { name: "Browser" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(page.getByTestId("frame-preview-browser")).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test("switching to None frame removes frame preview", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/beautify");
|
||||
await uploadTestImage(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Wait for the initial frame preview to appear first
|
||||
await expect(page.getByTestId("frame-preview-macos")).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// The Device Frame section has its own "None" button in a grid of 4 columns.
|
||||
// Target it by finding the section heading then the button within it.
|
||||
const frameSection = page.getByText("Device Frame").first().locator("..");
|
||||
const noneBtn = frameSection.locator("..").getByRole("button", { name: "None", exact: true });
|
||||
await noneBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.getByTestId("frame-preview-macos")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("device frame shows label indicator", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/beautify");
|
||||
await uploadTestImage(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.getByRole("button", { name: "iPhone" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(page.getByTestId("frame-preview-iphone")).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test("custom shadow values reflect in preview", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/beautify");
|
||||
await uploadTestImage(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Select Custom shadow
|
||||
await page.getByRole("button", { name: "Custom" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Verify custom shadow controls are visible
|
||||
await expect(page.locator("#beautify-shadow-blur")).toBeVisible();
|
||||
await expect(page.locator("#beautify-shadow-opacity")).toBeVisible();
|
||||
|
||||
// The wrapper should have a boxShadow style
|
||||
const wrapper = page.locator("img.select-none").first().locator("..");
|
||||
const shadow = await wrapper.evaluate((el) => el.style.boxShadow);
|
||||
expect(shadow).toContain("px");
|
||||
});
|
||||
|
||||
test("watermark text appears in preview overlay", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/beautify");
|
||||
await uploadTestImage(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Open Watermark section and type text
|
||||
const watermarkSection = page.getByText("Watermark").first();
|
||||
await watermarkSection.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await page.locator("#beautify-watermark-text").fill("My Watermark");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(page.getByTestId("watermark-preview")).toBeVisible();
|
||||
await expect(page.getByTestId("watermark-preview")).toContainText("My Watermark");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Import the module source to test the exported helper functions.
|
||||
// We test buildPreviewStyle and renderFramePreview indirectly by
|
||||
// re-implementing the pure logic here (they're module-private).
|
||||
// Instead, we test the CSS output contract directly.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hexToRgba (matches the module-private helper)
|
||||
// ---------------------------------------------------------------------------
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
describe("hexToRgba", () => {
|
||||
it("converts black with full opacity", () => {
|
||||
expect(hexToRgba("#000000", 1)).toBe("rgba(0, 0, 0, 1)");
|
||||
});
|
||||
|
||||
it("converts white with half opacity", () => {
|
||||
expect(hexToRgba("#ffffff", 0.5)).toBe("rgba(255, 255, 255, 0.5)");
|
||||
});
|
||||
|
||||
it("converts arbitrary color", () => {
|
||||
expect(hexToRgba("#ff5f57", 0.3)).toBe("rgba(255, 95, 87, 0.3)");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildPreviewStyle contract tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SHADOW_CSS: Record<string, string> = {
|
||||
subtle: "0px 4px 20px rgba(0,0,0,0.2)",
|
||||
medium: "0px 10px 40px rgba(0,0,0,0.35)",
|
||||
dramatic: "0px 20px 80px rgba(0,0,0,0.5)",
|
||||
};
|
||||
|
||||
function buildPreviewStyle(
|
||||
settings: Record<string, unknown>,
|
||||
bgImageUrl?: string | null,
|
||||
): React.CSSProperties {
|
||||
const bg = settings.backgroundType as string;
|
||||
let background: string | undefined;
|
||||
const extra: React.CSSProperties = {};
|
||||
|
||||
if (bg === "solid") {
|
||||
background = settings.backgroundColor as string;
|
||||
} else if (bg === "linear-gradient") {
|
||||
const stops = settings.gradientStops as { color: string; position: number }[];
|
||||
const angle = settings.gradientAngle as number;
|
||||
background = `linear-gradient(${angle}deg, ${stops.map((s) => `${s.color} ${s.position}%`).join(", ")})`;
|
||||
} else if (bg === "radial-gradient") {
|
||||
const stops = settings.gradientStops as { color: string; position: number }[];
|
||||
background = `radial-gradient(circle, ${stops.map((s) => `${s.color} ${s.position}%`).join(", ")})`;
|
||||
} else if (bg === "image" && bgImageUrl) {
|
||||
extra.backgroundImage = `url("${bgImageUrl}")`;
|
||||
extra.backgroundSize = "cover";
|
||||
extra.backgroundPosition = "center";
|
||||
}
|
||||
|
||||
const shadowPreset = settings.shadowPreset as string;
|
||||
let boxShadow: string | undefined;
|
||||
if (shadowPreset === "custom") {
|
||||
const blur = settings.shadowBlur as number;
|
||||
const ox = settings.shadowOffsetX as number;
|
||||
const oy = settings.shadowOffsetY as number;
|
||||
const color = settings.shadowColor as string;
|
||||
const opacity = (settings.shadowOpacity as number) / 100;
|
||||
boxShadow = `${ox}px ${oy}px ${blur}px ${hexToRgba(color, opacity)}`;
|
||||
} else if (shadowPreset !== "none") {
|
||||
boxShadow = SHADOW_CSS[shadowPreset];
|
||||
}
|
||||
|
||||
const frame = settings.frame as string;
|
||||
const hasFrame = frame && frame !== "none";
|
||||
|
||||
return {
|
||||
background,
|
||||
...extra,
|
||||
padding: `${settings.padding}px`,
|
||||
borderRadius: hasFrame ? "0px" : `${settings.borderRadius}px`,
|
||||
boxShadow,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildPreviewStyle", () => {
|
||||
const baseSettings = {
|
||||
backgroundType: "solid",
|
||||
backgroundColor: "#ff0000",
|
||||
gradientStops: [
|
||||
{ color: "#667eea", position: 0 },
|
||||
{ color: "#764ba2", position: 100 },
|
||||
],
|
||||
gradientAngle: 135,
|
||||
padding: 64,
|
||||
borderRadius: 12,
|
||||
shadowPreset: "subtle",
|
||||
shadowBlur: 20,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 10,
|
||||
shadowColor: "#000000",
|
||||
shadowOpacity: 30,
|
||||
frame: "none",
|
||||
};
|
||||
|
||||
it("solid background returns the color", () => {
|
||||
const style = buildPreviewStyle(baseSettings);
|
||||
expect(style.background).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("linear gradient builds CSS gradient string", () => {
|
||||
const style = buildPreviewStyle({ ...baseSettings, backgroundType: "linear-gradient" });
|
||||
expect(style.background).toContain("linear-gradient(135deg");
|
||||
expect(style.background).toContain("#667eea 0%");
|
||||
expect(style.background).toContain("#764ba2 100%");
|
||||
});
|
||||
|
||||
it("radial gradient builds CSS gradient string", () => {
|
||||
const style = buildPreviewStyle({ ...baseSettings, backgroundType: "radial-gradient" });
|
||||
expect(style.background).toContain("radial-gradient(circle");
|
||||
});
|
||||
|
||||
it("image background sets backgroundImage with blob URL", () => {
|
||||
const style = buildPreviewStyle(
|
||||
{ ...baseSettings, backgroundType: "image" },
|
||||
"blob:http://localhost/abc-123",
|
||||
);
|
||||
expect(style.backgroundImage).toBe('url("blob:http://localhost/abc-123")');
|
||||
expect(style.backgroundSize).toBe("cover");
|
||||
expect(style.backgroundPosition).toBe("center");
|
||||
expect(style.background).toBeUndefined();
|
||||
});
|
||||
|
||||
it("image background without URL does not set backgroundImage", () => {
|
||||
const style = buildPreviewStyle({ ...baseSettings, backgroundType: "image" });
|
||||
expect(style.backgroundImage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("transparent background sets no background", () => {
|
||||
const style = buildPreviewStyle({ ...baseSettings, backgroundType: "transparent" });
|
||||
expect(style.background).toBeUndefined();
|
||||
expect(style.backgroundImage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("padding and borderRadius are set from settings", () => {
|
||||
const style = buildPreviewStyle(baseSettings);
|
||||
expect(style.padding).toBe("64px");
|
||||
expect(style.borderRadius).toBe("12px");
|
||||
});
|
||||
|
||||
it("named shadow presets return correct CSS", () => {
|
||||
expect(buildPreviewStyle({ ...baseSettings, shadowPreset: "subtle" }).boxShadow).toBe(
|
||||
SHADOW_CSS.subtle,
|
||||
);
|
||||
expect(buildPreviewStyle({ ...baseSettings, shadowPreset: "medium" }).boxShadow).toBe(
|
||||
SHADOW_CSS.medium,
|
||||
);
|
||||
expect(buildPreviewStyle({ ...baseSettings, shadowPreset: "dramatic" }).boxShadow).toBe(
|
||||
SHADOW_CSS.dramatic,
|
||||
);
|
||||
});
|
||||
|
||||
it("none shadow returns no boxShadow", () => {
|
||||
const style = buildPreviewStyle({ ...baseSettings, shadowPreset: "none" });
|
||||
expect(style.boxShadow).toBeUndefined();
|
||||
});
|
||||
|
||||
it("custom shadow computes CSS from parameters", () => {
|
||||
const style = buildPreviewStyle({
|
||||
...baseSettings,
|
||||
shadowPreset: "custom",
|
||||
shadowBlur: 30,
|
||||
shadowOffsetX: 5,
|
||||
shadowOffsetY: 15,
|
||||
shadowColor: "#ff0000",
|
||||
shadowOpacity: 50,
|
||||
});
|
||||
expect(style.boxShadow).toBe("5px 15px 30px rgba(255, 0, 0, 0.5)");
|
||||
});
|
||||
|
||||
it("borderRadius is 0px when a frame is present", () => {
|
||||
const style = buildPreviewStyle({ ...baseSettings, frame: "macos-light" });
|
||||
expect(style.borderRadius).toBe("0px");
|
||||
});
|
||||
|
||||
it("borderRadius is normal when frame is none", () => {
|
||||
const style = buildPreviewStyle({ ...baseSettings, frame: "none" });
|
||||
expect(style.borderRadius).toBe("12px");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user