From e6d67e744624a8a5ad9e5a6b8db442c0b4a68107 Mon Sep 17 00:00:00 2001 From: Jorge Perez Date: Mon, 11 Nov 2024 23:19:14 +0800 Subject: [PATCH 1/2] Added 'Make image a circle' toggle to RoundedTool & its updated component --- .../(tools)/rounded-border/rounded-tool.tsx | 144 ++++++++++++++---- src/components/border-radius-selector.tsx | 13 +- 2 files changed, 121 insertions(+), 36 deletions(-) diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index a5dbf34..dceb3f6 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -19,16 +19,29 @@ function useImageConverter(props: { canvas: HTMLCanvasElement | null; imageContent: string; radius: Radius; + isCircle: boolean; background: BackgroundOption; fileName?: string; imageMetadata: { width: number; height: number; name: string }; }) { - const { width, height } = useMemo(() => { - return { - width: props.imageMetadata.width, - height: props.imageMetadata.height, - }; - }, [props.imageMetadata]); + const { width, height, offsetX, offsetY } = useMemo(() => { + if (props.isCircle) { + const size = Math.min( + props.imageMetadata.width, + props.imageMetadata.height, + ); + const offsetX = (props.imageMetadata.width - size) / 2; + const offsetY = (props.imageMetadata.height - size) / 2; + return { width: size, height: size, offsetX, offsetY }; + } else { + return { + width: props.imageMetadata.width, + height: props.imageMetadata.height, + offsetX: 0, + offsetY: 0, + }; + } + }, [props.imageMetadata, props.isCircle]); const convertToPng = async () => { const ctx = props.canvas?.getContext("2d"); @@ -47,22 +60,48 @@ function useImageConverter(props: { const img = new Image(); img.onload = () => { + props.canvas!.width = width; + props.canvas!.height = height; + ctx.clearRect(0, 0, width, height); ctx.fillStyle = props.background; ctx.fillRect(0, 0, width, height); - ctx.beginPath(); - ctx.moveTo(props.radius, 0); - ctx.lineTo(width - props.radius, 0); - ctx.quadraticCurveTo(width, 0, width, props.radius); - ctx.lineTo(width, height - props.radius); - ctx.quadraticCurveTo(width, height, width - props.radius, height); - ctx.lineTo(props.radius, height); - ctx.quadraticCurveTo(0, height, 0, height - props.radius); - ctx.lineTo(0, props.radius); - ctx.quadraticCurveTo(0, 0, props.radius, 0); - ctx.closePath(); - ctx.clip(); - ctx.drawImage(img, 0, 0, width, height); + + if (props.isCircle) { + ctx.save(); + ctx.beginPath(); + ctx.arc(width / 2, height / 2, width / 2, 0, 2 * Math.PI); + ctx.closePath(); + ctx.clip(); + + ctx.drawImage( + img, + offsetX, + offsetY, + width, + height, + 0, + 0, + width, + height, + ); + ctx.restore(); + } else { + const radius = props.radius; + ctx.beginPath(); + ctx.moveTo(radius, 0); + ctx.lineTo(width - radius, 0); + ctx.quadraticCurveTo(width, 0, width, radius); + ctx.lineTo(width, height - radius); + ctx.quadraticCurveTo(width, height, width - radius, height); + ctx.lineTo(radius, height); + ctx.quadraticCurveTo(0, height, 0, height - radius); + ctx.lineTo(0, radius); + ctx.quadraticCurveTo(0, 0, radius, 0); + ctx.closePath(); + ctx.clip(); + ctx.drawImage(img, 0, 0, width, height); + } saveImage(); }; @@ -71,19 +110,21 @@ function useImageConverter(props: { return { convertToPng, - canvasProps: { width: width, height: height }, + canvasProps: { width, height }, }; } interface ImageRendererProps { imageContent: string; radius: Radius; + isCircle: boolean; background: BackgroundOption; } const ImageRenderer = ({ imageContent, radius, + isCircle, background, }: ImageRendererProps) => { const containerRef = useRef(null); @@ -92,22 +133,36 @@ const ImageRenderer = ({ if (containerRef.current) { const imgElement = containerRef.current.querySelector("img"); if (imgElement) { - imgElement.style.borderRadius = `${radius}px`; + if (isCircle) { + imgElement.style.borderRadius = "50%"; + imgElement.style.objectFit = "cover"; + imgElement.style.width = "100%"; + imgElement.style.height = "100%"; + } else { + imgElement.style.borderRadius = `${radius}px`; + imgElement.style.objectFit = "contain"; + imgElement.style.width = "100%"; + imgElement.style.height = "auto"; + } } } - }, [imageContent, radius]); + }, [imageContent, radius, isCircle]); return ( -
-
+
Preview
); @@ -116,11 +171,13 @@ const ImageRenderer = ({ function SaveAsPngButton({ imageContent, radius, + isCircle, background, imageMetadata, }: { imageContent: string; radius: Radius; + isCircle: boolean; background: BackgroundOption; imageMetadata: { width: number; height: number; name: string }; }) { @@ -129,6 +186,7 @@ function SaveAsPngButton({ canvas: canvasRef, imageContent, radius, + isCircle, background, imageMetadata, }); @@ -160,6 +218,7 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { "roundedTool_background", "transparent", ); + const [isCircle, setIsCircle] = useState(false); const handleRadiusChange = (value: number | "custom") => { if (value === "custom") { @@ -188,6 +247,7 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) {

@@ -196,9 +256,16 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) {

- Original Size + + {isCircle ? "Cropped Size" : "Original Size"} + - {imageMetadata.width} × {imageMetadata.height} + {isCircle + ? `${Math.min(imageMetadata.width, imageMetadata.height)} × ${Math.min( + imageMetadata.width, + imageMetadata.height, + )}` + : `${imageMetadata.width} × ${imageMetadata.height}`}
@@ -209,8 +276,22 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { onChange={handleRadiusChange} customValue={radius} onCustomValueChange={setRadius} + disabled={isCircle} /> +
+ setIsCircle(e.target.checked)} + className="form-checkbox h-5 w-5 text-green-600" + /> + +
+ diff --git a/src/components/border-radius-selector.tsx b/src/components/border-radius-selector.tsx index 8ff2ed8..0aac7cc 100644 --- a/src/components/border-radius-selector.tsx +++ b/src/components/border-radius-selector.tsx @@ -7,6 +7,7 @@ interface BorderRadiusSelectorProps { onChange: (value: number | "custom") => void; customValue?: number; onCustomValueChange?: (value: number) => void; + disabled?: boolean; // Added the optional 'disabled' prop } export function BorderRadiusSelector({ @@ -16,6 +17,7 @@ export function BorderRadiusSelector({ onChange, customValue, onCustomValueChange, + disabled, // Destructure the 'disabled' prop }: BorderRadiusSelectorProps) { const containerRef = useRef(null); const selectedRef = useRef(null); @@ -24,11 +26,11 @@ export function BorderRadiusSelector({ useEffect(() => { if (selectedRef.current && highlightRef.current && containerRef.current) { const container = containerRef.current; - const selected = selectedRef.current; + const selectedButton = selectedRef.current; const highlight = highlightRef.current; const containerRect = container.getBoundingClientRect(); - const selectedRect = selected.getBoundingClientRect(); + const selectedRect = selectedButton.getBoundingClientRect(); highlight.style.left = `${selectedRect.left - containerRect.left}px`; highlight.style.width = `${selectedRect.width}px`; @@ -79,17 +81,18 @@ export function BorderRadiusSelector({ onClick={() => onChange(typeof option === "number" ? option : "custom") } + disabled={disabled} className={`relative rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${ option === selected ? "text-white" : "text-white/80 hover:text-white" - }`} + } ${disabled ? "cursor-not-allowed opacity-50" : ""}`} > - {option === "custom" ? "Custom" : option} + {option === "custom" ? "Custom" : `${option}px`} ))}
- {selected === "custom" && ( + {selected === "custom" && !disabled && (
Date: Wed, 13 Nov 2024 10:24:48 +0800 Subject: [PATCH 2/2] Replaced ugly circle checkbox with a more intuitive shape selector --- .../(tools)/rounded-border/rounded-tool.tsx | 79 +++++++++---------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index dceb3f6..3061bbb 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -1,4 +1,5 @@ "use client"; + import { usePlausible } from "next-plausible"; import { useEffect, useMemo, useRef, useState } from "react"; import { useLocalStorage } from "@/hooks/use-local-storage"; @@ -14,18 +15,19 @@ import { FileDropzone } from "@/components/shared/file-dropzone"; type Radius = number; type BackgroundOption = "white" | "black" | "transparent"; +type ShapeOption = "rounded" | "circle"; function useImageConverter(props: { canvas: HTMLCanvasElement | null; imageContent: string; radius: Radius; - isCircle: boolean; + shapeOption: ShapeOption; background: BackgroundOption; fileName?: string; imageMetadata: { width: number; height: number; name: string }; }) { const { width, height, offsetX, offsetY } = useMemo(() => { - if (props.isCircle) { + if (props.shapeOption === "circle") { const size = Math.min( props.imageMetadata.width, props.imageMetadata.height, @@ -41,7 +43,7 @@ function useImageConverter(props: { offsetY: 0, }; } - }, [props.imageMetadata, props.isCircle]); + }, [props.imageMetadata, props.shapeOption]); const convertToPng = async () => { const ctx = props.canvas?.getContext("2d"); @@ -67,7 +69,7 @@ function useImageConverter(props: { ctx.fillStyle = props.background; ctx.fillRect(0, 0, width, height); - if (props.isCircle) { + if (props.shapeOption === "circle") { ctx.save(); ctx.beginPath(); ctx.arc(width / 2, height / 2, width / 2, 0, 2 * Math.PI); @@ -117,14 +119,14 @@ function useImageConverter(props: { interface ImageRendererProps { imageContent: string; radius: Radius; - isCircle: boolean; + shapeOption: ShapeOption; background: BackgroundOption; } const ImageRenderer = ({ imageContent, radius, - isCircle, + shapeOption, background, }: ImageRendererProps) => { const containerRef = useRef(null); @@ -133,7 +135,7 @@ const ImageRenderer = ({ if (containerRef.current) { const imgElement = containerRef.current.querySelector("img"); if (imgElement) { - if (isCircle) { + if (shapeOption === "circle") { imgElement.style.borderRadius = "50%"; imgElement.style.objectFit = "cover"; imgElement.style.width = "100%"; @@ -146,15 +148,15 @@ const ImageRenderer = ({ } } } - }, [imageContent, radius, isCircle]); + }, [imageContent, radius, shapeOption]); return (
@@ -171,13 +173,13 @@ const ImageRenderer = ({ function SaveAsPngButton({ imageContent, radius, - isCircle, + shapeOption, background, imageMetadata, }: { imageContent: string; radius: Radius; - isCircle: boolean; + shapeOption: ShapeOption; background: BackgroundOption; imageMetadata: { width: number; height: number; name: string }; }) { @@ -186,7 +188,7 @@ function SaveAsPngButton({ canvas: canvasRef, imageContent, radius, - isCircle, + shapeOption, background, imageMetadata, }); @@ -218,7 +220,7 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { "roundedTool_background", "transparent", ); - const [isCircle, setIsCircle] = useState(false); + const [shapeOption, setShapeOption] = useState("rounded"); const handleRadiusChange = (value: number | "custom") => { if (value === "custom") { @@ -242,12 +244,12 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { } return ( -
-
+
+

@@ -257,10 +259,10 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) {

- {isCircle ? "Cropped Size" : "Original Size"} + {shapeOption === "circle" ? "Cropped Size" : "Original Size"} - {isCircle + {shapeOption === "circle" ? `${Math.min(imageMetadata.width, imageMetadata.height)} × ${Math.min( imageMetadata.width, imageMetadata.height, @@ -269,28 +271,25 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) {
- + setShapeOption(option === "Circle" ? "circle" : "rounded") + } + formatOption={(option) => option} /> - -
- setIsCircle(e.target.checked)} - className="form-checkbox h-5 w-5 text-green-600" + {shapeOption === "rounded" && ( + - -
+ )}