This commit is contained in:
Balázs Szücs 2025-12-18 17:21:49 +00:00 committed by GitHub
commit 3181e437ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 228 additions and 21 deletions

View File

@ -2339,6 +2339,8 @@ saved = "Saved"
label = "Upload signature image"
placeholder = "Select image file"
hint = "Upload a PNG or JPG image of your signature"
removeBackground = "Remove white background (make transparent)"
processing = "Processing image..."
[sign.instructions]
title = "How to add signature"

View File

@ -1,7 +1,8 @@
import React from 'react';
import { FileInput, Text, Stack } from '@mantine/core';
import React, { useState } from 'react';
import { FileInput, Text, Stack, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { removeWhiteBackground } from '@app/utils/imageTransparency';
interface ImageUploaderProps {
onImageChange: (file: File | null) => void;
@ -9,6 +10,8 @@ interface ImageUploaderProps {
label?: string;
placeholder?: string;
hint?: string;
allowBackgroundRemoval?: boolean;
onProcessedImageData?: (dataUrl: string | null) => void;
}
export const ImageUploader: React.FC<ImageUploaderProps> = ({
@ -16,9 +19,45 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
disabled = false,
label,
placeholder,
hint
hint,
allowBackgroundRemoval = false,
onProcessedImageData
}) => {
const { t } = useTranslation();
const [removeBackground, setRemoveBackground] = useState(false);
const [currentFile, setCurrentFile] = useState<File | null>(null);
const [originalImageData, setOriginalImageData] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const processImage = async (imageSource: File | string, shouldRemoveBackground: boolean): Promise<void> => {
if (shouldRemoveBackground && allowBackgroundRemoval) {
setIsProcessing(true);
try {
const transparentImageDataUrl = await removeWhiteBackground(imageSource, {
autoDetectCorner: true,
tolerance: 15
});
onProcessedImageData?.(transparentImageDataUrl);
} catch (error) {
console.error('Error removing background:', error);
onProcessedImageData?.(null);
} finally {
setIsProcessing(false);
}
} else {
// When background removal is disabled, return the original image data
if (typeof imageSource === 'string') {
onProcessedImageData?.(imageSource);
} else {
// Convert File to data URL if needed
const reader = new FileReader();
reader.onload = (e) => {
onProcessedImageData?.(e.target?.result as string);
};
reader.readAsDataURL(imageSource);
}
}
};
const handleImageChange = async (file: File | null) => {
if (file && !disabled) {
@ -29,13 +68,35 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
return;
}
setCurrentFile(file);
onImageChange(file);
const originalDataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target?.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
setOriginalImageData(originalDataUrl);
await processImage(file, removeBackground);
} catch (error) {
console.error('Error processing image file:', error);
}
} else if (!file) {
// Clear image data when no file is selected
setCurrentFile(null);
setOriginalImageData(null);
onImageChange(null);
onProcessedImageData?.(null);
}
};
const handleBackgroundRemovalChange = async (checked: boolean) => {
if (isProcessing) return; // Prevent race conditions
setRemoveBackground(checked);
if (originalImageData) {
await processImage(originalImageData, checked);
}
};
@ -47,14 +108,27 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
accept="image/*"
onChange={handleImageChange}
disabled={disabled}
disabled={disabled || isProcessing}
/>
</PrivateContent>
{allowBackgroundRemoval && (
<Checkbox
label={t('sign.image.removeBackground', 'Remove white background (make transparent)')}
checked={removeBackground}
onChange={(event) => handleBackgroundRemovalChange(event.currentTarget.checked)}
disabled={disabled || !currentFile || isProcessing}
/>
)}
{hint && (
<Text size="sm" c="dimmed">
{hint}
</Text>
)}
{isProcessing && (
<Text size="sm" c="dimmed">
{t('sign.image.processing', 'Processing image...')}
</Text>
)}
</Stack>
);
};
};

View File

@ -461,23 +461,13 @@ const SignSettings = ({
const handleImageChange = async (file: File | null) => {
if (file && !disabled) {
try {
const result = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
// Reset pause state and directly activate placement
setPlacementManuallyPaused(false);
lastAppliedPlacementKey.current = null;
setImageSignatureData(result);
// Image data will be set by onProcessedImageData callback in ImageUploader
// This avoids the race condition where both handleImageChange and onProcessedImageData
// try to set the image data, potentially with the wrong version
// Directly activate placement on image upload
if (typeof window !== 'undefined') {
@ -491,8 +481,6 @@ const SignSettings = ({
} else if (!file) {
setImageSignatureData(undefined);
onDeactivateSignature?.();
setImageSignatureData(undefined);
onDeactivateSignature?.();
}
};
@ -835,6 +823,12 @@ const SignSettings = ({
<ImageUploader
onImageChange={handleImageChange}
disabled={disabled}
allowBackgroundRemoval={true}
onProcessedImageData={(dataUrl) => {
if (dataUrl) {
setImageSignatureData(dataUrl);
}
}}
/>
{renderSaveButtonRow('image', hasImageSignature, handleSaveImageSignature)}
</Stack>

View File

@ -0,0 +1,137 @@
export interface TransparencyOptions {
lowerBound?: { r: number; g: number; b: number };
upperBound?: { r: number; g: number; b: number };
autoDetectCorner?: boolean;
tolerance?: number;
}
const DEFAULT_LOWER_BOUND = { r: 200, g: 200, b: 200 };
const DEFAULT_UPPER_BOUND = { r: 255, g: 255, b: 255 }; // #FFFFFF
export async function removeWhiteBackground(
imageFile: File | string,
options: TransparencyOptions = {}
): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
try {
const result = processImageTransparency(img, options);
resolve(result);
} catch (error) {
reject(error);
}
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
if (typeof imageFile === 'string') {
img.src = imageFile;
} else {
const reader = new FileReader();
reader.onload = (e) => {
img.src = e.target?.result as string;
};
reader.onerror = () => {
reject(new Error('Failed to read image file'));
};
reader.readAsDataURL(imageFile);
}
});
}
function processImageTransparency(
img: HTMLImageElement,
options: TransparencyOptions
): string {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
let lowerBound = options.lowerBound || DEFAULT_LOWER_BOUND;
let upperBound = options.upperBound || DEFAULT_UPPER_BOUND;
if (options.autoDetectCorner) {
const cornerColor = detectCornerColor(imageData);
const tolerance = options.tolerance || 10;
lowerBound = {
r: Math.max(0, cornerColor.r - tolerance),
g: Math.max(0, cornerColor.g - tolerance),
b: Math.max(0, cornerColor.b - tolerance)
};
upperBound = {
r: Math.min(255, cornerColor.r + tolerance),
g: Math.min(255, cornerColor.g + tolerance),
b: Math.min(255, cornerColor.b + tolerance)
};
}
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
if (
r >= lowerBound.r && r <= upperBound.r &&
g >= lowerBound.g && g <= upperBound.g &&
b >= lowerBound.b && b <= upperBound.b
) {
data[i + 3] = 0;
}
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL('image/png');
}
function detectCornerColor(imageData: ImageData): { r: number; g: number; b: number } {
const { width, height, data } = imageData;
const sampleSize = 5;
const corners = [
{ x: 0, y: 0 }, // top-left
{ x: width - sampleSize, y: 0 }, // top-right
{ x: 0, y: height - sampleSize }, // bottom-left
{ x: width - sampleSize, y: height - sampleSize } // bottom-right
];
let totalR = 0, totalG = 0, totalB = 0;
let samples = 0;
corners.forEach(corner => {
for (let dy = 0; dy < sampleSize; dy++) {
for (let dx = 0; dx < sampleSize; dx++) {
const x = Math.min(width - 1, Math.max(0, corner.x + dx));
const y = Math.min(height - 1, Math.max(0, corner.y + dy));
const i = (y * width + x) * 4;
totalR += data[i];
totalG += data[i + 1];
totalB += data[i + 2];
samples++;
}
}
});
return {
r: Math.round(totalR / samples),
g: Math.round(totalG / samples),
b: Math.round(totalB / samples)
};
}