Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 154 additions & 31 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,76 @@ const ExportModal: React.FC<ExportModalProps> = ({ isOpen, onClose, onExport, fi
);
};

// --- Crop Suggestion Modal ---

interface CropSuggestionModalProps {
pending: PendingCrop | null;
onAcceptCrop: () => void;
onSkip: () => void;
}

const CropSuggestionModal: React.FC<CropSuggestionModalProps> = ({ pending, onAcceptCrop, onSkip }) => {
if (!pending) return null;

const whitespacePercent = Math.round(pending.whitespaceRatio * 100);

return (
<div className="fixed inset-0 z-[100] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-3xl bg-zinc-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden ring-1 ring-white/5 animate-in fade-in duration-200" onClick={(e) => e.stopPropagation()}>
<div className="p-6 space-y-6">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-white">Trim transparent padding?</h2>
<p className="text-sm text-zinc-400 max-w-xl">
We noticed about {whitespacePercent}% of this image is transparent padding. Crop it so your upload fills the frame?
</p>
</div>
<button
onClick={onSkip}
className="p-2 -mr-2 rounded-md text-zinc-500 hover:text-white hover:bg-white/10 transition-colors"
title="Keep image as-is"
>
<X size={18} />
</button>
</div>

<div className="grid grid-cols-2 gap-4">
<div className="bg-zinc-950 border border-white/5 rounded-xl p-3 flex flex-col gap-3">
<div className="text-xs font-semibold text-zinc-300">Original</div>
<div className="aspect-square rounded-lg bg-zinc-900 border border-white/5 flex items-center justify-center overflow-hidden">
<img src={pending.originalSrc} alt="Original upload" className="object-contain max-h-full" />
</div>
</div>
<div className="bg-zinc-950 border border-white/5 rounded-xl p-3 flex flex-col gap-3">
<div className="text-xs font-semibold text-zinc-300">Cropped preview</div>
<div className="aspect-square rounded-lg bg-zinc-900 border border-white/5 flex items-center justify-center overflow-hidden">
<img src={pending.croppedSrc} alt="Cropped preview" className="object-contain max-h-full" />
</div>
</div>
</div>

<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={onAcceptCrop}
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-white text-black hover:bg-zinc-200 font-semibold text-sm transition-all"
>
<Check size={16} />
Crop away the whitespace
</button>
<button
onClick={onSkip}
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl border border-white/10 text-zinc-200 hover:border-white/30 hover:bg-white/5 font-semibold text-sm transition-all"
>
<X size={16} />
Keep original
</button>
</div>
</div>
</div>
</div>
);
};

const generateId = () => Math.random().toString(36).substring(2, 9) + Date.now().toString(36);

const clamp = (value: number, min = 0, max = 1) => Math.min(Math.max(value, min), max);
Expand Down Expand Up @@ -445,6 +515,7 @@ export default function App() {
...INITIAL_CONFIG,
...f.config,
imageSize: f.config.imageSize || (f.config.imageScale ? 256 : INITIAL_CONFIG.imageSize),
imageColor: f.config.imageColor || INITIAL_CONFIG.imageColor,
radialGlareOpacity: f.config.radialGlareOpacity ?? 0,
backgroundTransitioning: false,
}
Expand Down Expand Up @@ -507,7 +578,12 @@ export default function App() {
const applyImageSource = useCallback((src: string) => {
setHistory((curr) => ({
past: [...curr.past, curr.present],
present: { ...curr.present, mode: 'image', imageSrc: src },
present: {
...curr.present,
mode: 'image',
imageSrc: src,
imageColor: curr.present.imageColor || INITIAL_CONFIG.imageColor
},
future: []
}));
}, []);
Expand Down Expand Up @@ -568,21 +644,28 @@ export default function App() {
reader.onload = (event) => {
if (event.target?.result) {
const src = event.target.result as string;
const img = new Image();
img.onload = () => {
const analysis = detectTransparentWhitespace(img);
if (analysis) {
setPendingCrop({
originalSrc: src,
croppedSrc: analysis.croppedSrc,
whitespaceRatio: analysis.whitespaceRatio,
fileName: file.name
});
} else {
applyImageSource(src);
}
};
img.src = src;
const isSvg = src.startsWith('data:image/svg');

// Skip cropping analysis for SVGs to preserve them as SVGs
if (isSvg) {
applyImageSource(src);
} else {
const img = new Image();
img.onload = () => {
const analysis = detectTransparentWhitespace(img);
if (analysis) {
setPendingCrop({
originalSrc: src,
croppedSrc: analysis.croppedSrc,
whitespaceRatio: analysis.whitespaceRatio,
fileName: file.name
});
} else {
applyImageSource(src);
}
};
img.src = src;
}
}
};
reader.readAsDataURL(file);
Expand Down Expand Up @@ -1020,14 +1103,31 @@ export default function App() {
contentSvg = `<g>${rects}</g>`;
} else if (config.mode === 'image' && config.imageSrc) {
const drawSize = config.imageSize;
contentSvg = `<image
href="${config.imageSrc}"
x="${(ICON_SIZE - drawSize)/2}"
y="${(ICON_SIZE - drawSize)/2 + config.imageOffsetY}"
width="${drawSize}"
height="${drawSize}"
preserveAspectRatio="xMidYMid meet"
/>`;
const isSvg = config.imageSrc.startsWith('data:image/svg');

if (isSvg) {
// For SVG images, we can apply a color filter
contentSvg = `<g style="color: ${config.imageColor};">
<image
href="${config.imageSrc}"
x="${(ICON_SIZE - drawSize)/2}"
y="${(ICON_SIZE - drawSize)/2 + config.imageOffsetY}"
width="${drawSize}"
height="${drawSize}"
preserveAspectRatio="xMidYMid meet"
style="fill: currentColor; color: ${config.imageColor};"
/>
</g>`;
} else {
contentSvg = `<image
href="${config.imageSrc}"
x="${(ICON_SIZE - drawSize)/2}"
y="${(ICON_SIZE - drawSize)/2 + config.imageOffsetY}"
width="${drawSize}"
height="${drawSize}"
preserveAspectRatio="xMidYMid meet"
/>`;
}
}

// 2. Assemble Final SVG
Expand Down Expand Up @@ -1215,6 +1315,8 @@ export default function App() {
}
} else if (config.mode === 'image' && config.imageSrc) {
const img = new Image();
const isSvg = config.imageSrc.startsWith('data:image/svg');

await new Promise((resolve) => {
img.onload = resolve;
img.src = config.imageSrc!;
Expand All @@ -1234,13 +1336,31 @@ export default function App() {

const offsetY = config.imageOffsetY * scaleFactor;

ctx.drawImage(
img,
-w / 2,
-h / 2 + offsetY,
w,
h
);
if (isSvg) {
// For SVG images, apply color tinting
// First draw the image normally
ctx.drawImage(
img,
-w / 2,
-h / 2 + offsetY,
w,
h
);

// Then apply color overlay using composite operation
ctx.globalCompositeOperation = 'source-in';
ctx.fillStyle = config.imageColor;
ctx.fillRect(-w / 2, -h / 2 + offsetY, w, h);
ctx.globalCompositeOperation = 'source-over';
} else {
ctx.drawImage(
img,
-w / 2,
-h / 2 + offsetY,
w,
h
);
}
}

ctx.restore();
Expand Down Expand Up @@ -1832,6 +1952,9 @@ export default function App() {
<Section title="Image Settings">
<NumberInput label="Size" value={config.imageSize} min={32} max={1024} step={8} suffix="px" onChange={(v) => updateConfig({ imageSize: v })} />
<NumberInput label="Vertical Offset" value={config.imageOffsetY} min={-512} max={512} step={4} suffix="px" onChange={(v) => updateConfig({ imageOffsetY: v })} />
{config.imageSrc && config.imageSrc.startsWith('data:image/svg') && (
<ColorInput label="Image Color" value={config.imageColor} onChange={(v) => updateConfig({ imageColor: v })} />
)}
</Section>
)}
</div>
Expand Down
38 changes: 26 additions & 12 deletions components/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,20 +160,34 @@ const Preview: React.FC<PreviewProps> = ({ config, id }) => {
alignItems: 'center',
justifyContent: 'center',
transform: `translateY(${config.imageOffsetY * scale}px)`,
...(config.imageSrc.startsWith('data:image/svg') ? {
// For SVG, use the image as a mask and fill with the color
WebkitMaskImage: `url(${config.imageSrc})`,
WebkitMaskSize: 'contain',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
maskImage: `url(${config.imageSrc})`,
maskSize: 'contain',
maskRepeat: 'no-repeat',
maskPosition: 'center',
backgroundColor: config.imageColor,
} : {})
}}
>
<img
src={config.imageSrc}
alt="Uploaded content"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
userSelect: 'none',
pointerEvents: 'none',
display: 'block'
}}
/>
{!config.imageSrc.startsWith('data:image/svg') && (
<img
src={config.imageSrc}
alt="Uploaded content"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
userSelect: 'none',
pointerEvents: 'none',
display: 'block',
}}
/>
)}
</div>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export const INITIAL_CONFIG: IconConfig = {
imageSrc: null,
imageSize: 256,
imageOffsetY: 0,
imageColor: '#ffffff',
exportSize: 1024,
withBackground: true,
};
Expand Down
Loading