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;
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}
/>
+7 -1
View File
@@ -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 }],
+34 -5
View File
@@ -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}
/>
);
}