mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: make beautify live preview reflect all settings in real time
Background images, device frames, custom shadows, and watermark text were not rendering in the right-pane preview. The preview now updates in real time for all settings: gradient/solid/image backgrounds, macOS/ Windows/Browser frame chrome, iPhone/MacBook/iPad frame indicators, custom shadow parameters, and watermark text overlay. Also fixes a React StrictMode effect-ordering race where the parent tool-page reset cleared preview state set by the child Settings component on initial mount.
This commit is contained in:
@@ -27,6 +27,7 @@ interface ImageViewerProps {
|
||||
cssFilter?: string;
|
||||
bgPreview?: BgPreviewState;
|
||||
imageWrapperStyle?: React.CSSProperties;
|
||||
imageWrapperChildren?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ZOOM_STEPS = [10, 25, 50, 75, 100, 150, 200, 300, 500, 1000];
|
||||
@@ -44,6 +45,7 @@ export function ImageViewer({
|
||||
cssFilter,
|
||||
bgPreview,
|
||||
imageWrapperStyle,
|
||||
imageWrapperChildren,
|
||||
}: ImageViewerProps) {
|
||||
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
|
||||
const [naturalWidth, setNaturalWidth] = useState<number | null>(null);
|
||||
@@ -261,13 +263,17 @@ export function ImageViewer({
|
||||
<div
|
||||
style={{
|
||||
...imageWrapperStyle,
|
||||
display: "inline-block",
|
||||
display: imageWrapperChildren ? "inline-flex" : "inline-block",
|
||||
flexDirection: imageWrapperChildren ? ("column" as const) : undefined,
|
||||
position: imageWrapperChildren ? ("relative" as const) : undefined,
|
||||
boxSizing: "border-box" as const,
|
||||
overflow: "hidden",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{imageWrapperChildren}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
@@ -275,12 +281,22 @@ export function ImageViewer({
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
className="select-none"
|
||||
style={{
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
objectFit: "contain" as const,
|
||||
}}
|
||||
style={
|
||||
imageWrapperChildren
|
||||
? {
|
||||
display: "block",
|
||||
flex: "1 1 0",
|
||||
minHeight: 0,
|
||||
maxWidth: "100%",
|
||||
objectFit: "contain" as const,
|
||||
}
|
||||
: {
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
objectFit: "contain" as const,
|
||||
}
|
||||
}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -152,9 +152,20 @@ const SHADOW_CSS: Record<string, string> = {
|
||||
dramatic: "0px 20px 80px rgba(0,0,0,0.5)",
|
||||
};
|
||||
|
||||
function buildPreviewStyle(settings: Record<string, unknown>): React.CSSProperties {
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
function buildPreviewStyle(
|
||||
settings: Record<string, unknown>,
|
||||
bgImageUrl?: string | null,
|
||||
): React.CSSProperties {
|
||||
const bg = settings.backgroundType as string;
|
||||
let background: string | undefined;
|
||||
const extra: React.CSSProperties = {};
|
||||
|
||||
if (bg === "solid") {
|
||||
background = settings.backgroundColor as string;
|
||||
@@ -165,15 +176,34 @@ function buildPreviewStyle(settings: Record<string, unknown>): React.CSSProperti
|
||||
} else if (bg === "radial-gradient") {
|
||||
const stops = settings.gradientStops as GradientStop[];
|
||||
background = `radial-gradient(circle, ${stops.map((s) => `${s.color} ${s.position}%`).join(", ")})`;
|
||||
} else if (bg === "image" && bgImageUrl) {
|
||||
extra.backgroundImage = `url("${bgImageUrl}")`;
|
||||
extra.backgroundSize = "cover";
|
||||
extra.backgroundPosition = "center";
|
||||
}
|
||||
|
||||
const shadowPreset = settings.shadowPreset as string;
|
||||
let boxShadow: string | undefined;
|
||||
if (shadowPreset === "custom") {
|
||||
const blur = settings.shadowBlur as number;
|
||||
const ox = settings.shadowOffsetX as number;
|
||||
const oy = settings.shadowOffsetY as number;
|
||||
const color = settings.shadowColor as string;
|
||||
const opacity = (settings.shadowOpacity as number) / 100;
|
||||
boxShadow = `${ox}px ${oy}px ${blur}px ${hexToRgba(color, opacity)}`;
|
||||
} else if (shadowPreset !== "none") {
|
||||
boxShadow = SHADOW_CSS[shadowPreset];
|
||||
}
|
||||
|
||||
const frame = settings.frame as string;
|
||||
const hasFrame = frame && frame !== "none";
|
||||
|
||||
return {
|
||||
background,
|
||||
...extra,
|
||||
padding: `${settings.padding}px`,
|
||||
borderRadius: `${settings.borderRadius}px`,
|
||||
boxShadow: SHADOW_CSS[shadowPreset],
|
||||
borderRadius: hasFrame ? "0px" : `${settings.borderRadius}px`,
|
||||
boxShadow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +214,265 @@ function presetGradientCSS(preset: BeautifyPreset): string {
|
||||
return `linear-gradient(${preset.gradientAngle}deg, ${stops})`;
|
||||
}
|
||||
|
||||
// -- Frame & watermark preview overlay --------------------------------------
|
||||
|
||||
const TRAFFIC_LIGHTS = [{ color: "#ff5f57" }, { color: "#febc2e" }, { color: "#28c840" }];
|
||||
|
||||
const DEVICE_LABELS: Record<string, string> = {
|
||||
iphone: "iPhone",
|
||||
macbook: "MacBook",
|
||||
ipad: "iPad",
|
||||
};
|
||||
|
||||
function renderMacosFrame(isDark: boolean, title: string): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
data-testid="frame-preview-macos"
|
||||
style={{
|
||||
height: 36,
|
||||
background: isDark ? "#323233" : "#f1f0ef",
|
||||
borderBottom: `0.5px solid ${isDark ? "#555555" : "#c5c5c5"}`,
|
||||
borderRadius: "8px 8px 0 0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: 14,
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{TRAFFIC_LIGHTS.map((tl) => (
|
||||
<div
|
||||
key={tl.color}
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
background: tl.color,
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{title && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: isDark ? "#d4d4d4" : "#4b4b4b",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWindowsFrame(isDark: boolean, title: string): React.ReactNode {
|
||||
const btnColor = isDark ? "#999" : "#666";
|
||||
return (
|
||||
<div
|
||||
data-testid="frame-preview-windows"
|
||||
style={{
|
||||
height: 36,
|
||||
background: isDark ? "#2b2b2b" : "#f3f3f3",
|
||||
borderBottom: `0.5px solid ${isDark ? "#3a3a3a" : "#e0e0e0"}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingLeft: 12,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: isDark ? "#ffffff" : "#1a1a1a" }}>{title}</span>
|
||||
<div style={{ display: "flex", height: "100%" }}>
|
||||
{["─", "□", "✕"].map((icon) => (
|
||||
<div
|
||||
key={icon}
|
||||
style={{
|
||||
width: 46,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: btnColor,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBrowserFrame(isDark: boolean, title: string): React.ReactNode {
|
||||
return (
|
||||
<div data-testid="frame-preview-browser" style={{ flexShrink: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
height: 36,
|
||||
background: isDark ? "#323233" : "#f1f0ef",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: 14,
|
||||
borderRadius: "8px 8px 0 0",
|
||||
}}
|
||||
>
|
||||
{TRAFFIC_LIGHTS.map((tl) => (
|
||||
<div
|
||||
key={tl.color}
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
background: tl.color,
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
padding: "4px 16px",
|
||||
borderRadius: "6px 6px 0 0",
|
||||
background: isDark ? "#1e1e1e" : "#ffffff",
|
||||
fontSize: 11,
|
||||
color: isDark ? "#d4d4d4" : "#4b4b4b",
|
||||
}}
|
||||
>
|
||||
{title || "New Tab"}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 36,
|
||||
background: isDark ? "#1e1e1e" : "#ffffff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0 12px",
|
||||
borderTop: `0.5px solid ${isDark ? "#444" : "#e0e0e0"}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: isDark ? "#323233" : "#f1f0ef",
|
||||
borderRadius: 6,
|
||||
padding: "4px 10px",
|
||||
fontSize: 11,
|
||||
color: isDark ? "#999" : "#666",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10 }}>{"🔒"}</span>
|
||||
<span>example.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDeviceFrame(isDark: boolean, deviceType: string): React.ReactNode {
|
||||
const label = DEVICE_LABELS[deviceType] || deviceType;
|
||||
return (
|
||||
<div
|
||||
data-testid={`frame-preview-${deviceType}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "6px 12px",
|
||||
background: isDark ? "#1d1d1f" : "#e8e8e8",
|
||||
borderRadius: "12px 12px 0 0",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: isDark ? "#999" : "#666",
|
||||
letterSpacing: "0.5px",
|
||||
textTransform: "uppercase" as const,
|
||||
}}
|
||||
>
|
||||
{label} Frame
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const WATERMARK_POS_STYLES: Record<string, React.CSSProperties> = {
|
||||
"top-left": { top: 8, left: 8 },
|
||||
"top-right": { top: 8, right: 8 },
|
||||
center: { top: "50%", left: "50%", transform: "translate(-50%, -50%)" },
|
||||
"bottom-left": { bottom: 8, left: 8 },
|
||||
"bottom-right": { bottom: 8, right: 8 },
|
||||
};
|
||||
|
||||
function renderWatermark(text: string, position: string, opacity: number): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
data-testid="watermark-preview"
|
||||
style={{
|
||||
position: "absolute",
|
||||
...WATERMARK_POS_STYLES[position],
|
||||
color: "#ffffff",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
opacity: opacity / 100,
|
||||
textShadow: "0 1px 3px rgba(0,0,0,0.5)",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFramePreview(
|
||||
frame: string,
|
||||
title: string,
|
||||
watermarkText?: string,
|
||||
watermarkPosition?: string,
|
||||
watermarkOpacity?: number,
|
||||
): React.ReactNode {
|
||||
let frameNode: React.ReactNode = null;
|
||||
let watermarkNode: React.ReactNode = null;
|
||||
|
||||
if (frame !== "none") {
|
||||
const isDark = frame.endsWith("-dark");
|
||||
const type = frame.replace(/-(?:light|dark)$/, "");
|
||||
|
||||
if (type === "macos") frameNode = renderMacosFrame(isDark, title);
|
||||
else if (type === "windows") frameNode = renderWindowsFrame(isDark, title);
|
||||
else if (type === "browser") frameNode = renderBrowserFrame(isDark, title);
|
||||
else frameNode = renderDeviceFrame(isDark, type);
|
||||
}
|
||||
|
||||
if (watermarkText) {
|
||||
watermarkNode = renderWatermark(
|
||||
watermarkText,
|
||||
watermarkPosition || "bottom-right",
|
||||
watermarkOpacity ?? 50,
|
||||
);
|
||||
}
|
||||
|
||||
if (!frameNode && !watermarkNode) return null;
|
||||
return (
|
||||
<>
|
||||
{frameNode}
|
||||
{watermarkNode}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Background tab types ---------------------------------------------------
|
||||
|
||||
type BackgroundTab = "linear-gradient" | "radial-gradient" | "solid" | "image" | "transparent";
|
||||
@@ -248,6 +537,7 @@ export interface BeautifyControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
onImageStyle?: (style: React.CSSProperties | null) => void;
|
||||
onImageOverlay?: (children: React.ReactNode) => void;
|
||||
onBackgroundImage?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
@@ -255,6 +545,7 @@ export function BeautifyControls({
|
||||
settings: initialSettings,
|
||||
onChange,
|
||||
onImageStyle,
|
||||
onImageOverlay,
|
||||
onBackgroundImage,
|
||||
}: BeautifyControlsProps) {
|
||||
// State
|
||||
@@ -268,8 +559,18 @@ export function BeautifyControls({
|
||||
const [gradientAngle, setGradientAngle] = useState(135);
|
||||
const [gradientMode, setGradientMode] = useState<"linear" | "radial">("linear");
|
||||
const [bgImageFile, setBgImageFile] = useState<File | null>(null);
|
||||
const [bgImageUrl, setBgImageUrl] = useState<string | null>(null);
|
||||
const bgInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (bgImageFile) {
|
||||
const url = URL.createObjectURL(bgImageFile);
|
||||
setBgImageUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}
|
||||
setBgImageUrl(null);
|
||||
}, [bgImageFile]);
|
||||
|
||||
const [frameType, setFrameType] = useState<FrameType>("macos");
|
||||
const [frameTheme, setFrameTheme] = useState<FrameTheme>("light");
|
||||
const [frameTitle, setFrameTitle] = useState("");
|
||||
@@ -325,6 +626,11 @@ export function BeautifyControls({
|
||||
onBgImageRef.current = onBackgroundImage;
|
||||
});
|
||||
|
||||
const onOverlayRef = useRef(onImageOverlay);
|
||||
useEffect(() => {
|
||||
onOverlayRef.current = onImageOverlay;
|
||||
});
|
||||
|
||||
// Resolve frame string from type + theme
|
||||
const resolvedFrame = frameType === "none" ? "none" : (`${frameType}-${frameTheme}` as const);
|
||||
|
||||
@@ -336,7 +642,7 @@ export function BeautifyControls({
|
||||
: "linear-gradient"
|
||||
: backgroundType;
|
||||
|
||||
// Propagate changes
|
||||
// Propagate changes to parent and apply live preview.
|
||||
useEffect(() => {
|
||||
const vals: Record<string, unknown> = {
|
||||
backgroundType: resolvedBgType,
|
||||
@@ -359,7 +665,20 @@ export function BeautifyControls({
|
||||
watermarkOpacity,
|
||||
};
|
||||
onChangeRef.current?.(vals);
|
||||
onImageStyleRef.current?.(buildPreviewStyle(vals));
|
||||
onImageStyleRef.current?.(buildPreviewStyle(vals, bgImageUrl));
|
||||
onOverlayRef.current?.(
|
||||
renderFramePreview(
|
||||
resolvedFrame,
|
||||
frameTitle,
|
||||
watermarkText,
|
||||
watermarkPosition,
|
||||
watermarkOpacity,
|
||||
),
|
||||
);
|
||||
return () => {
|
||||
onImageStyleRef.current?.(null);
|
||||
onOverlayRef.current?.(null);
|
||||
};
|
||||
}, [
|
||||
resolvedBgType,
|
||||
backgroundColor,
|
||||
@@ -379,6 +698,7 @@ export function BeautifyControls({
|
||||
watermarkText,
|
||||
watermarkPosition,
|
||||
watermarkOpacity,
|
||||
bgImageUrl,
|
||||
]);
|
||||
|
||||
const clearPreset = () => setSelectedPreset(null);
|
||||
@@ -964,8 +1284,10 @@ export function BeautifyControls({
|
||||
|
||||
export function BeautifySettings({
|
||||
onImageStyle,
|
||||
onImageOverlay,
|
||||
}: {
|
||||
onImageStyle?: (style: React.CSSProperties | null) => void;
|
||||
onImageOverlay?: (children: React.ReactNode) => void;
|
||||
}) {
|
||||
const { files, setProcessedUrl, setSizes, setJobId } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
@@ -1048,6 +1370,7 @@ export function BeautifySettings({
|
||||
<BeautifyControls
|
||||
onChange={handleSettingsChange}
|
||||
onImageStyle={onImageStyle}
|
||||
onImageOverlay={onImageOverlay}
|
||||
onBackgroundImage={handleBgImageChange}
|
||||
/>
|
||||
|
||||
|
||||
@@ -53,12 +53,15 @@ export interface ToolRegistryEntry {
|
||||
displayMode: DisplayMode;
|
||||
/** Whether this tool supports live preview transforms (rotate, color). */
|
||||
livePreview?: boolean;
|
||||
/** Override the default file-picker accept string (e.g. ".svg,.svgz"). */
|
||||
accept?: string;
|
||||
/** The settings component for this tool. */
|
||||
Settings: React.ComponentType<{
|
||||
onPreviewTransform?: (t: PreviewTransform) => void;
|
||||
onPreviewFilter?: (filter: string) => void;
|
||||
onBgPreview?: (state: BgPreviewState | null) => void;
|
||||
onImageStyle?: (style: React.CSSProperties | null) => void;
|
||||
onImageOverlay?: (children: React.ReactNode) => void;
|
||||
cropProps?: CropProps;
|
||||
eraserProps?: EraserProps;
|
||||
}>;
|
||||
@@ -445,7 +448,10 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
|
||||
],
|
||||
|
||||
// Format & Conversion
|
||||
["svg-to-raster", { displayMode: "before-after", Settings: SvgToRasterSettings }],
|
||||
[
|
||||
"svg-to-raster",
|
||||
{ displayMode: "before-after", accept: ".svg,.svgz", Settings: SvgToRasterSettings },
|
||||
],
|
||||
["vectorize", { displayMode: "before-after", Settings: VectorizeSettings }],
|
||||
["gif-tools", { displayMode: "before-after", Settings: GifToolsSettings }],
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ export function ToolPage() {
|
||||
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
|
||||
const [previewFilter, setPreviewFilter] = useState<string>("");
|
||||
const [imageWrapperStyle, setImageWrapperStyle] = useState<React.CSSProperties | null>(null);
|
||||
const [imageWrapperChildren, setImageWrapperChildren] = useState<React.ReactNode>(null);
|
||||
const [bgPreview, setBgPreview] = useState<BgPreviewState | null>(null);
|
||||
|
||||
const [cropCrop, setCropCrop] = useState<Crop>({
|
||||
@@ -260,7 +261,6 @@ export function ToolPage() {
|
||||
|
||||
setPreviewTransform(null);
|
||||
setPreviewFilter("");
|
||||
setImageWrapperStyle(null);
|
||||
setBgPreview(null);
|
||||
setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 });
|
||||
setCropAspect(undefined);
|
||||
@@ -272,6 +272,26 @@ export function ToolPage() {
|
||||
setMobileSettingsOpen(true);
|
||||
}, [toolId]);
|
||||
|
||||
const toolAccept = registryEntry?.accept;
|
||||
const toolAcceptExts = useMemo(
|
||||
() => toolAccept?.split(",").map((e) => e.trim().replace(/^\./, "").toLowerCase()),
|
||||
[toolAccept],
|
||||
);
|
||||
const toolFileFilter = useMemo(() => {
|
||||
if (!toolAcceptExts) return undefined;
|
||||
return (file: File) => {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
return toolAcceptExts.includes(ext);
|
||||
};
|
||||
}, [toolAcceptExts]);
|
||||
const toolAcceptDescription = useMemo(
|
||||
() =>
|
||||
toolAcceptExts
|
||||
? `${toolAcceptExts.map((e) => e.toUpperCase()).join(", ")} files only`
|
||||
: undefined,
|
||||
[toolAcceptExts],
|
||||
);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(newFiles: File[]) => {
|
||||
setEraserSliderInitPos(null);
|
||||
@@ -298,13 +318,15 @@ export function ToolPage() {
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept =
|
||||
toolAccept ??
|
||||
"image/*,.avif,.heic,.heif,.hif,.jxl,.dng,.cr2,.cr3,.nef,.nrw,.arw,.orf,.rw2,.raf,.pef,.3fr,.iiq,.srw,.x3f,.rwl,.gpr,.fff,.mrw,.mef,.kdc,.dcr,.erf,.ptx,.tga,.psd,.exr,.hdr,.svgz,.jp2,.j2k,.qoi,.eps,.dds,.cur,.apng,.dpx,.cin,.fits,.ppm,.pgm,.pbm,.pfm";
|
||||
input.onchange = (e) => {
|
||||
const newFiles = Array.from((e.target as HTMLInputElement).files || []);
|
||||
const selected = Array.from((e.target as HTMLInputElement).files || []);
|
||||
const newFiles = toolFileFilter ? selected.filter(toolFileFilter) : selected;
|
||||
if (newFiles.length > 0) addFiles(newFiles);
|
||||
};
|
||||
input.click();
|
||||
}, [addFiles]);
|
||||
}, [addFiles, toolAccept, toolFileFilter]);
|
||||
|
||||
const handleDownloadAll = useCallback(() => {
|
||||
if (!batchZipBlob) return;
|
||||
@@ -381,6 +403,7 @@ export function ToolPage() {
|
||||
onPreviewTransform: isLivePreview ? setPreviewTransform : undefined,
|
||||
onPreviewFilter: isLivePreview ? setPreviewFilter : undefined,
|
||||
onImageStyle: isLivePreview ? setImageWrapperStyle : undefined,
|
||||
onImageOverlay: isLivePreview ? (c: React.ReactNode) => setImageWrapperChildren(c) : undefined,
|
||||
onBgPreview: setBgPreview,
|
||||
cropProps:
|
||||
displayMode === "interactive-crop"
|
||||
@@ -442,9 +465,11 @@ export function ToolPage() {
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
onUrlImport={handleUrlImport}
|
||||
accept="image/*"
|
||||
accept={toolAccept ?? "image/*"}
|
||||
multiple
|
||||
currentFiles={files}
|
||||
fileFilter={toolFileFilter}
|
||||
acceptDescription={toolAcceptDescription}
|
||||
/>
|
||||
);
|
||||
const ResultsPanel = registryEntry.ResultsPanel;
|
||||
@@ -560,6 +585,7 @@ export function ToolPage() {
|
||||
filename={selectedFileName ?? files[0].name}
|
||||
fileSize={selectedFileSize ?? files[0].size}
|
||||
imageWrapperStyle={imageWrapperStyle}
|
||||
imageWrapperChildren={imageWrapperChildren}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -642,6 +668,7 @@ export function ToolPage() {
|
||||
: {})}
|
||||
{...(isLivePreview && previewFilter ? { cssFilter: previewFilter } : {})}
|
||||
{...(isLivePreview && imageWrapperStyle ? { imageWrapperStyle } : {})}
|
||||
{...(isLivePreview && imageWrapperChildren ? { imageWrapperChildren } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -650,9 +677,11 @@ export function ToolPage() {
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
onUrlImport={handleUrlImport}
|
||||
accept="image/*"
|
||||
accept={toolAccept ?? "image/*"}
|
||||
multiple
|
||||
currentFiles={files}
|
||||
fileFilter={toolFileFilter}
|
||||
acceptDescription={toolAcceptDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user