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:
SnapOtter
2026-05-13 10:28:44 +08:00
parent 0874cfc809
commit bf01dcd47e
6 changed files with 701 additions and 18 deletions
@@ -27,6 +27,7 @@ interface ImageViewerProps {
cssFilter?: string; cssFilter?: string;
bgPreview?: BgPreviewState; bgPreview?: BgPreviewState;
imageWrapperStyle?: React.CSSProperties; imageWrapperStyle?: React.CSSProperties;
imageWrapperChildren?: React.ReactNode;
} }
const ZOOM_STEPS = [10, 25, 50, 75, 100, 150, 200, 300, 500, 1000]; const ZOOM_STEPS = [10, 25, 50, 75, 100, 150, 200, 300, 500, 1000];
@@ -44,6 +45,7 @@ export function ImageViewer({
cssFilter, cssFilter,
bgPreview, bgPreview,
imageWrapperStyle, imageWrapperStyle,
imageWrapperChildren,
}: ImageViewerProps) { }: ImageViewerProps) {
const [zoom, setZoom] = useState(DEFAULT_ZOOM); const [zoom, setZoom] = useState(DEFAULT_ZOOM);
const [naturalWidth, setNaturalWidth] = useState<number | null>(null); const [naturalWidth, setNaturalWidth] = useState<number | null>(null);
@@ -261,13 +263,17 @@ export function ImageViewer({
<div <div
style={{ style={{
...imageWrapperStyle, ...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", overflow: "hidden",
maxWidth: "100%", maxWidth: "100%",
maxHeight: "100%", maxHeight: "100%",
transition: "all 0.15s ease", transition: "all 0.15s ease",
}} }}
> >
{imageWrapperChildren}
<img <img
ref={imgRef} ref={imgRef}
src={src} src={src}
@@ -275,12 +281,22 @@ export function ImageViewer({
onLoad={handleImageLoad} onLoad={handleImageLoad}
onError={handleImageError} onError={handleImageError}
className="select-none" className="select-none"
style={{ style={
display: "block", imageWrapperChildren
maxWidth: "100%", ? {
maxHeight: "100%", display: "block",
objectFit: "contain" as const, flex: "1 1 0",
}} minHeight: 0,
maxWidth: "100%",
objectFit: "contain" as const,
}
: {
display: "block",
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain" as const,
}
}
draggable={false} draggable={false}
/> />
</div> </div>
@@ -152,9 +152,20 @@ const SHADOW_CSS: Record<string, string> = {
dramatic: "0px 20px 80px rgba(0,0,0,0.5)", 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; const bg = settings.backgroundType as string;
let background: string | undefined; let background: string | undefined;
const extra: React.CSSProperties = {};
if (bg === "solid") { if (bg === "solid") {
background = settings.backgroundColor as string; background = settings.backgroundColor as string;
@@ -165,15 +176,34 @@ function buildPreviewStyle(settings: Record<string, unknown>): React.CSSProperti
} else if (bg === "radial-gradient") { } else if (bg === "radial-gradient") {
const stops = settings.gradientStops as GradientStop[]; const stops = settings.gradientStops as GradientStop[];
background = `radial-gradient(circle, ${stops.map((s) => `${s.color} ${s.position}%`).join(", ")})`; 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; 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 { return {
background, background,
...extra,
padding: `${settings.padding}px`, padding: `${settings.padding}px`,
borderRadius: `${settings.borderRadius}px`, borderRadius: hasFrame ? "0px" : `${settings.borderRadius}px`,
boxShadow: SHADOW_CSS[shadowPreset], boxShadow,
}; };
} }
@@ -184,6 +214,265 @@ function presetGradientCSS(preset: BeautifyPreset): string {
return `linear-gradient(${preset.gradientAngle}deg, ${stops})`; 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 --------------------------------------------------- // -- Background tab types ---------------------------------------------------
type BackgroundTab = "linear-gradient" | "radial-gradient" | "solid" | "image" | "transparent"; type BackgroundTab = "linear-gradient" | "radial-gradient" | "solid" | "image" | "transparent";
@@ -248,6 +537,7 @@ export interface BeautifyControlsProps {
settings?: Record<string, unknown>; settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void; onChange?: (settings: Record<string, unknown>) => void;
onImageStyle?: (style: React.CSSProperties | null) => void; onImageStyle?: (style: React.CSSProperties | null) => void;
onImageOverlay?: (children: React.ReactNode) => void;
onBackgroundImage?: (file: File | null) => void; onBackgroundImage?: (file: File | null) => void;
} }
@@ -255,6 +545,7 @@ export function BeautifyControls({
settings: initialSettings, settings: initialSettings,
onChange, onChange,
onImageStyle, onImageStyle,
onImageOverlay,
onBackgroundImage, onBackgroundImage,
}: BeautifyControlsProps) { }: BeautifyControlsProps) {
// State // State
@@ -268,8 +559,18 @@ export function BeautifyControls({
const [gradientAngle, setGradientAngle] = useState(135); const [gradientAngle, setGradientAngle] = useState(135);
const [gradientMode, setGradientMode] = useState<"linear" | "radial">("linear"); const [gradientMode, setGradientMode] = useState<"linear" | "radial">("linear");
const [bgImageFile, setBgImageFile] = useState<File | null>(null); const [bgImageFile, setBgImageFile] = useState<File | null>(null);
const [bgImageUrl, setBgImageUrl] = useState<string | null>(null);
const bgInputRef = useRef<HTMLInputElement>(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 [frameType, setFrameType] = useState<FrameType>("macos");
const [frameTheme, setFrameTheme] = useState<FrameTheme>("light"); const [frameTheme, setFrameTheme] = useState<FrameTheme>("light");
const [frameTitle, setFrameTitle] = useState(""); const [frameTitle, setFrameTitle] = useState("");
@@ -325,6 +626,11 @@ export function BeautifyControls({
onBgImageRef.current = onBackgroundImage; onBgImageRef.current = onBackgroundImage;
}); });
const onOverlayRef = useRef(onImageOverlay);
useEffect(() => {
onOverlayRef.current = onImageOverlay;
});
// Resolve frame string from type + theme // Resolve frame string from type + theme
const resolvedFrame = frameType === "none" ? "none" : (`${frameType}-${frameTheme}` as const); const resolvedFrame = frameType === "none" ? "none" : (`${frameType}-${frameTheme}` as const);
@@ -336,7 +642,7 @@ export function BeautifyControls({
: "linear-gradient" : "linear-gradient"
: backgroundType; : backgroundType;
// Propagate changes // Propagate changes to parent and apply live preview.
useEffect(() => { useEffect(() => {
const vals: Record<string, unknown> = { const vals: Record<string, unknown> = {
backgroundType: resolvedBgType, backgroundType: resolvedBgType,
@@ -359,7 +665,20 @@ export function BeautifyControls({
watermarkOpacity, watermarkOpacity,
}; };
onChangeRef.current?.(vals); 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, resolvedBgType,
backgroundColor, backgroundColor,
@@ -379,6 +698,7 @@ export function BeautifyControls({
watermarkText, watermarkText,
watermarkPosition, watermarkPosition,
watermarkOpacity, watermarkOpacity,
bgImageUrl,
]); ]);
const clearPreset = () => setSelectedPreset(null); const clearPreset = () => setSelectedPreset(null);
@@ -964,8 +1284,10 @@ export function BeautifyControls({
export function BeautifySettings({ export function BeautifySettings({
onImageStyle, onImageStyle,
onImageOverlay,
}: { }: {
onImageStyle?: (style: React.CSSProperties | null) => void; onImageStyle?: (style: React.CSSProperties | null) => void;
onImageOverlay?: (children: React.ReactNode) => void;
}) { }) {
const { files, setProcessedUrl, setSizes, setJobId } = useFileStore(); const { files, setProcessedUrl, setSizes, setJobId } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
@@ -1048,6 +1370,7 @@ export function BeautifySettings({
<BeautifyControls <BeautifyControls
onChange={handleSettingsChange} onChange={handleSettingsChange}
onImageStyle={onImageStyle} onImageStyle={onImageStyle}
onImageOverlay={onImageOverlay}
onBackgroundImage={handleBgImageChange} onBackgroundImage={handleBgImageChange}
/> />
+7 -1
View File
@@ -53,12 +53,15 @@ export interface ToolRegistryEntry {
displayMode: DisplayMode; displayMode: DisplayMode;
/** Whether this tool supports live preview transforms (rotate, color). */ /** Whether this tool supports live preview transforms (rotate, color). */
livePreview?: boolean; livePreview?: boolean;
/** Override the default file-picker accept string (e.g. ".svg,.svgz"). */
accept?: string;
/** The settings component for this tool. */ /** The settings component for this tool. */
Settings: React.ComponentType<{ Settings: React.ComponentType<{
onPreviewTransform?: (t: PreviewTransform) => void; onPreviewTransform?: (t: PreviewTransform) => void;
onPreviewFilter?: (filter: string) => void; onPreviewFilter?: (filter: string) => void;
onBgPreview?: (state: BgPreviewState | null) => void; onBgPreview?: (state: BgPreviewState | null) => void;
onImageStyle?: (style: React.CSSProperties | null) => void; onImageStyle?: (style: React.CSSProperties | null) => void;
onImageOverlay?: (children: React.ReactNode) => void;
cropProps?: CropProps; cropProps?: CropProps;
eraserProps?: EraserProps; eraserProps?: EraserProps;
}>; }>;
@@ -445,7 +448,10 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
], ],
// Format & Conversion // 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 }], ["vectorize", { displayMode: "before-after", Settings: VectorizeSettings }],
["gif-tools", { displayMode: "before-after", Settings: GifToolsSettings }], ["gif-tools", { displayMode: "before-after", Settings: GifToolsSettings }],
+34 -5
View File
@@ -214,6 +214,7 @@ export function ToolPage() {
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null); const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
const [previewFilter, setPreviewFilter] = useState<string>(""); const [previewFilter, setPreviewFilter] = useState<string>("");
const [imageWrapperStyle, setImageWrapperStyle] = useState<React.CSSProperties | null>(null); const [imageWrapperStyle, setImageWrapperStyle] = useState<React.CSSProperties | null>(null);
const [imageWrapperChildren, setImageWrapperChildren] = useState<React.ReactNode>(null);
const [bgPreview, setBgPreview] = useState<BgPreviewState | null>(null); const [bgPreview, setBgPreview] = useState<BgPreviewState | null>(null);
const [cropCrop, setCropCrop] = useState<Crop>({ const [cropCrop, setCropCrop] = useState<Crop>({
@@ -260,7 +261,6 @@ export function ToolPage() {
setPreviewTransform(null); setPreviewTransform(null);
setPreviewFilter(""); setPreviewFilter("");
setImageWrapperStyle(null);
setBgPreview(null); setBgPreview(null);
setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 }); setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 });
setCropAspect(undefined); setCropAspect(undefined);
@@ -272,6 +272,26 @@ export function ToolPage() {
setMobileSettingsOpen(true); setMobileSettingsOpen(true);
}, [toolId]); }, [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( const handleFiles = useCallback(
(newFiles: File[]) => { (newFiles: File[]) => {
setEraserSliderInitPos(null); setEraserSliderInitPos(null);
@@ -298,13 +318,15 @@ export function ToolPage() {
input.type = "file"; input.type = "file";
input.multiple = true; input.multiple = true;
input.accept = 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"; "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) => { 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); if (newFiles.length > 0) addFiles(newFiles);
}; };
input.click(); input.click();
}, [addFiles]); }, [addFiles, toolAccept, toolFileFilter]);
const handleDownloadAll = useCallback(() => { const handleDownloadAll = useCallback(() => {
if (!batchZipBlob) return; if (!batchZipBlob) return;
@@ -381,6 +403,7 @@ export function ToolPage() {
onPreviewTransform: isLivePreview ? setPreviewTransform : undefined, onPreviewTransform: isLivePreview ? setPreviewTransform : undefined,
onPreviewFilter: isLivePreview ? setPreviewFilter : undefined, onPreviewFilter: isLivePreview ? setPreviewFilter : undefined,
onImageStyle: isLivePreview ? setImageWrapperStyle : undefined, onImageStyle: isLivePreview ? setImageWrapperStyle : undefined,
onImageOverlay: isLivePreview ? (c: React.ReactNode) => setImageWrapperChildren(c) : undefined,
onBgPreview: setBgPreview, onBgPreview: setBgPreview,
cropProps: cropProps:
displayMode === "interactive-crop" displayMode === "interactive-crop"
@@ -442,9 +465,11 @@ export function ToolPage() {
<Dropzone <Dropzone
onFiles={handleFiles} onFiles={handleFiles}
onUrlImport={handleUrlImport} onUrlImport={handleUrlImport}
accept="image/*" accept={toolAccept ?? "image/*"}
multiple multiple
currentFiles={files} currentFiles={files}
fileFilter={toolFileFilter}
acceptDescription={toolAcceptDescription}
/> />
); );
const ResultsPanel = registryEntry.ResultsPanel; const ResultsPanel = registryEntry.ResultsPanel;
@@ -560,6 +585,7 @@ export function ToolPage() {
filename={selectedFileName ?? files[0].name} filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size} fileSize={selectedFileSize ?? files[0].size}
imageWrapperStyle={imageWrapperStyle} imageWrapperStyle={imageWrapperStyle}
imageWrapperChildren={imageWrapperChildren}
/> />
); );
} }
@@ -642,6 +668,7 @@ export function ToolPage() {
: {})} : {})}
{...(isLivePreview && previewFilter ? { cssFilter: previewFilter } : {})} {...(isLivePreview && previewFilter ? { cssFilter: previewFilter } : {})}
{...(isLivePreview && imageWrapperStyle ? { imageWrapperStyle } : {})} {...(isLivePreview && imageWrapperStyle ? { imageWrapperStyle } : {})}
{...(isLivePreview && imageWrapperChildren ? { imageWrapperChildren } : {})}
/> />
); );
} }
@@ -650,9 +677,11 @@ export function ToolPage() {
<Dropzone <Dropzone
onFiles={handleFiles} onFiles={handleFiles}
onUrlImport={handleUrlImport} onUrlImport={handleUrlImport}
accept="image/*" accept={toolAccept ?? "image/*"}
multiple multiple
currentFiles={files} currentFiles={files}
fileFilter={toolFileFilter}
acceptDescription={toolAcceptDescription}
/> />
); );
} }
+114
View File
@@ -91,4 +91,118 @@ test.describe("Beautify Screenshot", () => {
.or(page.getByRole("link", { name: /download/i }).first()), .or(page.getByRole("link", { name: /download/i }).first()),
).toBeVisible({ timeout: 30_000 }); ).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");
});
}); });
+195
View File
@@ -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");
});
});