diff --git a/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx b/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx index 76eee42..278de52 100644 --- a/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx +++ b/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx @@ -7,17 +7,19 @@ import { PopoverTrigger, } from "@chakra-ui/react" import { memo, useEffect, useState } from "react" -import ColorPicker from "react-best-gradient-color-picker" +import ColorPicker, { ColorPickerProps } from "react-best-gradient-color-picker" import { useDebounce } from "../../hooks/useDebounce" const ColorPickerField = ({ fieldName, value, setValue, + fieldProps, }: { fieldName: string value: string setValue: (name: string, value: string) => void + fieldProps?: ColorPickerProps }) => { const [localValue, setLocalValue] = useState(value) @@ -35,7 +37,7 @@ const ColorPickerField = ({ .map((v) => v.toString(16).padStart(2, "0")) .join("") - if (a === undefined) { + if (fieldProps?.hideOpacity === true || a === undefined) { setLocalValue(`#${rgbHex}`) } else { const alphaDec = a > 1 ? a / 100 : a @@ -107,6 +109,8 @@ const ColorPickerField = ({ hideInputs hideAdvancedSliders hideColorGuide + // @ts-expect-error - fieldProps may include props not declared in ColorPickerProps, but they are intentionally forwarded + {...fieldProps} /> diff --git a/packages/imagekit-editor-dev/src/components/sidebar/transformation-config-sidebar.tsx b/packages/imagekit-editor-dev/src/components/sidebar/transformation-config-sidebar.tsx index 24a8bb6..c920463 100644 --- a/packages/imagekit-editor-dev/src/components/sidebar/transformation-config-sidebar.tsx +++ b/packages/imagekit-editor-dev/src/components/sidebar/transformation-config-sidebar.tsx @@ -55,6 +55,7 @@ import { SidebarBody } from "./sidebar-body" import { SidebarFooter } from "./sidebar-footer" import { SidebarHeader } from "./sidebar-header" import { SidebarRoot } from "./sidebar-root" +import { ColorPickerProps } from "react-best-gradient-color-picker" export const TransformationConfigSidebar: React.FC = () => { const { @@ -533,6 +534,7 @@ export const TransformationConfigSidebar: React.FC = () => { fieldName={field.name} value={watch(field.name) as string} setValue={setValue} + fieldProps={field.fieldProps as ColorPickerProps} /> ) : null} {field.fieldType === "anchor" ? ( diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 94fec2b..392109a 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -19,6 +19,8 @@ import { heightValidator, layerXValidator, layerYValidator, + optionalPositiveFloatNumberValidator, + refineUnsharpenMask, widthValidator, } from "./transformation" @@ -1575,6 +1577,348 @@ export const transformationSchema: TransformationSchema[] = [ }, ], }, + { + key: "adjust-border", + name: "Border", + description: + "Add a border to the image. Specify a border width and color.", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#border---b", + defaultTransformation: {}, + schema: z + .object({ + borderWidth: z.union([widthValidator, heightValidator]).optional(), + borderColor: colorValidator, + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false + }, + { + message: "Border width and color are required", + path: [], + }, + ), + + transformations: [ + { + label: "Border Width", + name: "borderWidth", + fieldType: "input", + isTransformation: false, + transformationGroup: "border", + helpText: "Enter a border width", + fieldProps: { + defaultValue: 1, + min: 1, + max: 99, + step: 1, + }, + }, + { + label: "Border Color", + name: "borderColor", + fieldType: "color-picker", + isTransformation: false, + transformationGroup: "border", + helpText: "Select the color of the border.", + fieldProps: { + hideOpacity: true, + showHexAlpha: false, + defaultValue: "#000000", + }, + }, + ], + }, + { + key: "adjust-trim", + name: "Trim", + description: + "Trim solid or nearly solid backgrounds from the edges of the image, leaving only the central object.", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#trim-edges---t", + defaultTransformation: {}, + schema: z + .object({ + trimEnabled: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + trim: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .int() + .min(1) + .max(99) + .optional(), + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false + }, + { + message: "At least one value is required", + path: [], + }, + ), + transformations: [ + { + label: "Enable Trim", + name: "trimEnabled", + fieldType: "switch", + isTransformation: false, + transformationGroup: "trim", + helpText: + "Toggle to trim background edges for images with solid or near-solid backgrounds.", + }, + { + label: "Threshold", + name: "trim", + fieldType: "slider", + isTransformation: false, + transformationGroup: "trim", + helpText: + "Trim edges for images with solid or near-solid backgrounds. Use a threshold between 1 and 99.", + fieldProps: { + defaultValue: 10, + min: 1, + max: 99, + step: 1, + }, + isVisible: ({ trimEnabled }) => trimEnabled === true, + }, + ], + }, + { + key: "adjust-color-replace", + name: "Color Replace", + description: + "Replace specific colors in the image with a new color, while preserving the original image's luminance and chroma relationships.", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#color-replace---cr", + defaultTransformation: {}, + schema: z + .object({ + toColor: colorValidator, + tolerance: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .int() + .min(0) + .max(100) + .optional(), + fromColor: z.union([colorValidator, z.literal("")]).optional(), + }) + .refine( + (val) => { + // At least toColor must be provided + return val.toColor !== undefined && val.toColor !== "" + }, + { + message: "To Color is required", + path: ["toColor"], + }, + ), + transformations: [ + { + label: "From Color", + examples: ["FFFFFF", "FF0000"], + name: "fromColor", + fieldType: "color-picker", + isTransformation: false, + fieldProps: { + hideOpacity: true, + showHexAlpha: false, + }, + transformationGroup: "colorReplace", + helpText: + "Select the source color you want to replace (optional - if not specified, dominant color will be replaced).", + }, + { + label: "Tolerance", + name: "tolerance", + fieldType: "slider", + isTransformation: false, + transformationGroup: "colorReplace", + helpText: + "Set the tolerance for the color replacement. Use a number between 0 and 100. Lower values are more precise, but may not work for all colors. Higher values are more forgiving, but may introduce more color variations.", + fieldProps: { + defaultValue: 35, + min: 0, + max: 100, + step: 1, + }, + }, + { + label: "To Color", + name: "toColor", + fieldType: "color-picker", + examples: ["FFFFFF", "FF0000"], + fieldProps: { + hideOpacity: true, + showHexAlpha: false, + }, + isTransformation: false, + transformationGroup: "colorReplace", + helpText: "Select the target color to replace with.", + }, + ], + }, + { + key: "adjust-sharpen", + name: "Sharpen", + description: + "Sharpen the image to highlight the edges and finer details within an image.", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#sharpen---e-sharpen", + defaultTransformation: {}, + schema: z + .object({ + sharpenEnabled: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + sharpen: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .int() + .min(1) + .max(99) + .optional(), + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false + }, + { + message: "At least one value is required", + path: [], + }, + ), + transformations: [ + { + label: "Sharpen Image", + name: "sharpenEnabled", + fieldType: "switch", + isTransformation: false, + transformationGroup: "sharpen", + helpText: + "Toggle to sharpen the image to highlight the edges and finer details within an image.", + }, + { + label: "Threshold", + name: "sharpen", + fieldType: "slider", + isTransformation: false, + transformationGroup: "sharpen", + helpText: + "Sharpen the image to highlight the edges and finer details within an image. Control the intensity of this effect using a threshold value between 1% and 99%.", + fieldProps: { + min: 1, + max: 99, + step: 1, + defaultValue: 50, + }, + isVisible: ({ sharpenEnabled }) => sharpenEnabled === true, + }, + ], + }, + { + key: "adjust-unsharpen-mask", + name: "Unsharpen Mask", + description: + "Image sharpening technique that enhances edge contrast to make details appear clearer. Amplifies differences between neighboring pixels without significantly affecting smooth areas.", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#unsharp-mask---e-usm", + defaultTransformation: {}, + schema: z.object({ + unsharpenMaskRadius: z.coerce.number().positive({ message: "Should be a positive floating point number." }), + unsharpenMaskSigma: z.coerce.number().positive({ message: "Should be a positive floating point number." }), + unsharpenMaskAmount: z.coerce.number().positive({ message: "Should be a positive floating point number." }), + unsharpenMaskThreshold: z.coerce.number().positive({ message: "Should be a positive floating point number." }), + }) + .refine( + (val) => { + if (Object.values(val).some((v) => v !== undefined && v !== null)) { + return true + } + return false + }), + transformations: [ + { + name: "unsharpenMaskRadius", + fieldType: "input", + label: "Radius", + isTransformation: false, + transformationGroup: "unsharpenMask", + helpText: + "Controls how wide the sharpening effect spreads from each edge. Larger values affect broader areas; smaller values focus on fine details.", + fieldProps: { + defaultValue: "", + }, + examples: ["1", "8", "15"], + }, + { + name: "unsharpenMaskSigma", + fieldType: "input", + label: "Sigma", + isTransformation: false, + transformationGroup: "unsharpenMask", + helpText: + "Defines the amount of blur used to detect edges before sharpening. Higher values smooth more before sharpening; lower values preserve fine textures.", + fieldProps: { + defaultValue: "", + }, + examples: ["1", "5", "6"], + }, + { + name: "unsharpenMaskAmount", + fieldType: "input", + label: "Amount", + isTransformation: false, + transformationGroup: "unsharpenMask", + helpText: + "Sets the strength of the sharpening effect.", + fieldProps: { + defaultValue: "", + }, + examples: ["0.1", "2", "0.8"], + }, + { + name: "unsharpenMaskThreshold", + fieldType: "input", + label: "Threshold", + isTransformation: false, + transformationGroup: "unsharpenMask", + helpText: + "Set the threshold value for the unsharpen mask.", + fieldProps: { + defaultValue: "", + }, + examples: ["0.1", "2", "0.8"], + }, + ] + } ], }, { @@ -1584,7 +1928,6 @@ export const transformationSchema: TransformationSchema[] = [ { key: "ai-removedotbg", name: "Remove Background using Remove.bg", - // This option removes the background using the third-party remove.bg service. description: "Remove the background of the image using Remove.bg (external service). This isolates the subject and makes the background transparent.", docsLink: @@ -2431,11 +2774,19 @@ export const transformationSchema: TransformationSchema[] = [ invalid_type_error: "Should be a number.", }) .optional(), - trim: z.coerce + trimEnabled: z.coerce .boolean({ invalid_type_error: "Should be a boolean.", }) .optional(), + trimThreshold: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .int() + .min(1) + .max(99) + .optional(), quality: z.coerce .number({ invalid_type_error: "Should be a number.", @@ -2446,7 +2797,27 @@ export const transformationSchema: TransformationSchema[] = [ invalid_type_error: "Should be a number.", }) .optional(), - }) + borderWidth: z.union([widthValidator, heightValidator]).optional(), + borderColor: colorValidator.optional(), + sharpenEnabled: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + sharpen: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .int() + .min(1) + .max(99) + .optional(), + unsharpenMask: z.coerce.boolean().optional(), + unsharpenMaskRadius: optionalPositiveFloatNumberValidator.optional(), + unsharpenMaskSigma: optionalPositiveFloatNumberValidator.optional(), + unsharpenMaskAmount: optionalPositiveFloatNumberValidator.optional(), + unsharpenMaskThreshold: optionalPositiveFloatNumberValidator.optional(), + }).superRefine(refineUnsharpenMask) .refine( (val) => { return Object.values(val).some( @@ -2606,16 +2977,33 @@ export const transformationSchema: TransformationSchema[] = [ }, { label: "Trim", - name: "trim", + name: "trimEnabled", fieldType: "switch", isTransformation: true, - transformationKey: "trim", + transformationKey: "trimEnabled", transformationGroup: "imageLayer", helpText: "Control trimming of the overlay image.", fieldProps: { defaultValue: true, }, }, + { + label: "Trim Threshold", + name: "trimThreshold", + fieldType: "slider", + isTransformation: true, + transformationKey: "trimThreshold", + transformationGroup: "imageLayer", + helpText: + "Control the intensity of this effect using a threshold value between 1% and 99%.", + fieldProps: { + min: 1, + max: 99, + step: 1, + defaultValue: 10, + }, + isVisible: ({ trimEnabled }) => trimEnabled === true, + }, { label: "Quality", name: "quality", @@ -2646,6 +3034,133 @@ export const transformationSchema: TransformationSchema[] = [ defaultValue: "0", }, }, + { + label: "Border Width", + name: "borderWidth", + fieldType: "input", + isTransformation: false, + transformationKey: "borderWidth", + transformationGroup: "imageLayer", + fieldProps: { + defaultValue: "", + }, + helpText: "Enter the width of the border or expression of the overlay image.", + examples: ["10", "ch_div_2"], + }, + { + label: "Border Color", + name: "borderColor", + fieldType: "color-picker", + isTransformation: false, + transformationKey: "borderColor", + transformationGroup: "imageLayer", + isVisible: ({ borderWidth }) => (borderWidth as string) !== "", + helpText: "Select the color of the border of the overlay image.", + fieldProps: { + hideOpacity: true, + showHexAlpha: false, + defaultValue: "#000000", + }, + }, + { + label: "Sharpen Overlay", + name: "sharpenEnabled", + fieldType: "switch", + isTransformation: false, + transformationKey: "sharpenEnabled", + transformationGroup: "imageLayer", + helpText: + "Toggle to sharpen the overlay image to highlight edges and fine details.", + fieldProps: { + defaultValue: false, + }, + }, + { + label: "Sharpen Threshold", + name: "sharpen", + fieldType: "slider", + isTransformation: false, + transformationKey: "sharpen", + transformationGroup: "imageLayer", + helpText: + "Sharpen the overlay image. Control the intensity of this effect using a threshold value between 1% and 99%.", + fieldProps: { + min: 1, + defaultValue: 50, + max: 99, + step: 1, + }, + isVisible: ({ sharpenEnabled }) => sharpenEnabled === true, + }, + { + name: "unsharpenMask", + fieldType: "switch", + isTransformation: false, + transformationGroup: "imageLayer", + helpText: + "Toggle to unsharpen the overlay image to remove the edges and finer details within an image.", + fieldProps: { + defaultValue: false, + }, + label: "Unsharpen Mask", + }, + { + name: "unsharpenMaskRadius", + fieldType: "input", + label: "Radius", + isTransformation: false, + transformationGroup: "imageLayer", + helpText: + "Controls how wide the sharpening effect spreads from each edge. Larger values affect broader areas; smaller values focus on fine details.", + fieldProps: { + defaultValue: "", + }, + examples: ["1", "8", "15"], + isVisible: ({ unsharpenMask }) => unsharpenMask === true, + }, + { + name: "unsharpenMaskSigma", + fieldType: "input", + label: "Sigma", + isTransformation: false, + transformationGroup: "imageLayer", + helpText: + "Defines the amount of blur used to detect edges before sharpening. Higher values smooth more before sharpening; lower values preserve fine textures.", + fieldProps: { + defaultValue: "", + }, + examples: ["1", "5", "6"], + isVisible: ({ unsharpenMask }) => unsharpenMask === true, + }, + { + name: "unsharpenMaskAmount", + fieldType: "input", + label: "Amount", + isTransformation: false, + transformationGroup: "imageLayer", + helpText: + "Sets the strength of the sharpening effect.", + fieldProps: { + defaultValue: "", + }, + examples: ["0.1", "2", "0.8"], + isVisible: ({ unsharpenMask }) => unsharpenMask === true, + }, + { + name: "unsharpenMaskThreshold", + fieldType: "input", + label: "Threshold", + isTransformation: false, + transformationGroup: "imageLayer", + helpText: + "Set the threshold value for the unsharpen mask.", + fieldProps: { + defaultValue: "", + }, + examples: ["0.1", "2", "0.8"], + isVisible: ({ unsharpenMask }) => unsharpenMask === true, + }, + ], }, ], @@ -2795,10 +3310,11 @@ export const transformationFormatters: Record< shadowOffsetX !== null && shadowOffsetX !== "" ) { - if (shadowOffsetX < 0) { - params.push(`x-N${Math.abs(shadowOffsetX)}`) + const offsetX = Number(shadowOffsetX) + if (!Number.isNaN(offsetX) && offsetX < 0) { + params.push(`x-N${Math.abs(offsetX)}`) } else { - params.push(`x-${shadowOffsetX}`) + params.push(`x-${offsetX}`) } } // Vertical offset; negative values should include N prefix as part of the value @@ -2807,10 +3323,11 @@ export const transformationFormatters: Record< shadowOffsetY !== null && shadowOffsetY !== "" ) { - if (shadowOffsetY < 0) { - params.push(`y-N${Math.abs(shadowOffsetY)}`) + const offsetY = Number(shadowOffsetY) + if (!Number.isNaN(offsetY) && offsetY < 0) { + params.push(`y-N${Math.abs(offsetY)}`) } else { - params.push(`y-${shadowOffsetY}`) + params.push(`y-${offsetY}`) } } // Compose the final transform string @@ -3012,9 +3529,11 @@ export const transformationFormatters: Record< if (values.rotation) { overlayTransform.rotation = values.rotation } - - if (typeof values.trim === "boolean") { - overlayTransform.trim = values.trim + if ( + values.trimEnabled === true && + typeof values.trimThreshold === "number" + ) { + overlayTransform.t = values.trimThreshold } if (values.quality) { @@ -3025,6 +3544,20 @@ export const transformationFormatters: Record< overlayTransform.blur = values.blur } + if (values.sharpenEnabled === true) { + if (values.sharpen === 50) { + overlayTransform.sharpen = "" + } else { + overlayTransform.sharpen = values.sharpen + } + } + if ( + values.borderWidth && + values.borderColor && typeof values.borderColor === "string" + ) { + overlayTransform.b = `${values.borderWidth}_${values.borderColor.replace(/^#/, "")}` + } + if (Object.keys(overlayTransform).length > 0) { overlay.transformation = [overlayTransform] } @@ -3044,6 +3577,11 @@ export const transformationFormatters: Record< // Assign overlay to transforms transforms.overlay = overlay + if (values.unsharpenMask === true) { + overlayTransform["e-usm"] = `${values.unsharpenMaskRadius}-${values.unsharpenMaskSigma}-${values.unsharpenMaskAmount}-${values.unsharpenMaskThreshold}` + } + + }, flip: (values, transforms) => { if ((values.flip as Array)?.length) { @@ -3058,6 +3596,21 @@ export const transformationFormatters: Record< transforms.flip = flip.join("_") } }, + trim: (values, transforms) => { + const { trimEnabled, trim } = values as { + trimEnabled?: boolean + trim?: "default" | number + } + if (!trimEnabled) return + + // Numeric threshold 1–99 + if (typeof trim === "number") { + const threshold = Math.trunc(trim) + if (threshold >= 1 && threshold <= 99) { + transforms.trim = threshold + } + } + }, aiChangeBackground: (values, transforms) => { if (values.changebg) { if (SIMPLE_OVERLAY_TEXT_REGEX.test(values.changebg as string)) { @@ -3078,4 +3631,60 @@ export const transformationFormatters: Record< transforms.rotation = "auto" } }, + colorReplace: (values, transforms) => { + const { fromColor, toColor, tolerance } = values as { + fromColor?: string + toColor?: string + tolerance?: number + } + + // Color replace requires at least toColor + if (!toColor || toColor === "") return + + const params: string[] = [] + + // Remove # from colors if present + const cleanToColor = (toColor as string).replace(/^#/, "") + params.push(cleanToColor) + if (tolerance !== undefined && tolerance !== null) { + params.push(String(tolerance)) + } + // Check if fromColor is provided and not empty + if (fromColor && fromColor !== "") { + const cleanFromColor = (fromColor as string).replace(/^#/, "") + params.push(cleanFromColor) + } + + transforms.cr = params.join("_") + }, + border: (values, transforms) => { + const { borderWidth, borderColor } = values as { + borderWidth?: string + borderColor?: string + } + if (!borderWidth || !borderColor) return + const cleanBorderColor = borderColor.replace(/^#/, "") + transforms.b = `${borderWidth}_${cleanBorderColor}` + }, + sharpen: (values, transforms) => { + const { sharpenEnabled, sharpen } = values as { + sharpenEnabled?: boolean + sharpen: number + } + if (!sharpenEnabled) return + if (sharpen === 50) { + transforms.sharpen = "" + } else { + transforms.sharpen = sharpen + } + }, + unsharpenMask: (values, transforms) => { + const { unsharpenMaskRadius, unsharpenMaskSigma, unsharpenMaskAmount, unsharpenMaskThreshold } = values as { + unsharpenMaskRadius: number + unsharpenMaskSigma: number + unsharpenMaskAmount: number + unsharpenMaskThreshold: number + } + transforms["e-usm"] = `${unsharpenMaskRadius}-${unsharpenMaskSigma}-${unsharpenMaskAmount}-${unsharpenMaskThreshold}` + }, } diff --git a/packages/imagekit-editor-dev/src/schema/transformation.ts b/packages/imagekit-editor-dev/src/schema/transformation.ts index 78eb69c..745c75d 100644 --- a/packages/imagekit-editor-dev/src/schema/transformation.ts +++ b/packages/imagekit-editor-dev/src/schema/transformation.ts @@ -124,3 +124,42 @@ export const layerYValidator = z.any().superRefine((val, ctx) => { message: "Layer Y must be a positive number or a valid expression string.", }) }) + + +export const optionalPositiveFloatNumberValidator = z.preprocess( + (val) => (val === "" || val === undefined || val === null) ? undefined : val, + z.coerce.number().positive({ message: "Should be a positive floating point number." }).optional() +) + +export const refineUnsharpenMask = (val: any, ctx: z.RefinementCtx) => { + if (val.unsharpenMask === true) { + if (!val.unsharpenMaskRadius) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Radius is required when Unsharpen Mask is enabled", + path: ["unsharpenMaskRadius"], + }) + } + if (!val.unsharpenMaskSigma) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Sigma is required when Unsharpen Mask is enabled", + path: ["unsharpenMaskSigma"], + }) + } + if (!val.unsharpenMaskAmount) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Amount is required when Unsharpen Mask is enabled", + path: ["unsharpenMaskAmount"], + }) + } + if (!val.unsharpenMaskThreshold) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Threshold is required when Unsharpen Mask is enabled", + path: ["unsharpenMaskThreshold"], + }) + } + } +} \ No newline at end of file