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;
|
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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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 }],
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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