From 1a67bca39ddd7fc9c0b28996777629ad45ccfe9b Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Fri, 16 Jan 2026 16:39:14 +0530 Subject: [PATCH 01/48] Support negative values in position x and y for image and text layer --- packages/imagekit-editor-dev/src/schema/index.ts | 16 ++++++++-------- .../src/schema/transformation.ts | 16 ++++++---------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 94fec2b..3bbe634 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -2199,7 +2199,7 @@ export const transformationSchema: TransformationSchema[] = [ transformationKey: "x", transformationGroup: "textLayer", helpText: "Specify horizontal offset for the text.", - examples: ["10", "bw_div_2"], + examples: ["10", "-20", "N30", "bw_div_2"], }, { label: "Position Y", @@ -2209,7 +2209,7 @@ export const transformationSchema: TransformationSchema[] = [ transformationKey: "y", transformationGroup: "textLayer", helpText: "Specify vertical offset for the text.", - examples: ["10", "bh_div_2"], + examples: ["10", "-20", "N30", "bh_div_2"], }, { label: "Font Size", @@ -2517,7 +2517,7 @@ export const transformationSchema: TransformationSchema[] = [ transformationKey: "x", transformationGroup: "imageLayer", helpText: "Specify the horizontal offset for the overlay image.", - examples: ["10"], + examples: ["10", "-20", "N30", "bw_div_2"], }, { label: "Position Y", @@ -2527,7 +2527,7 @@ export const transformationSchema: TransformationSchema[] = [ transformationKey: "y", transformationGroup: "imageLayer", helpText: "Specify the vertical offset for the overlay image.", - examples: ["10"], + examples: ["10", "-20", "N30", "bh_div_2"], }, { label: "Opacity", @@ -2926,13 +2926,13 @@ export const transformationFormatters: Record< typeof values.positionX === "number" || typeof values.positionX === "string" ) { - position.x = values.positionX + position.x = values.positionX.toString().replace(/^-/,"N") } if ( typeof values.positionY === "number" || typeof values.positionY === "string" ) { - position.y = values.positionY + position.y = values.positionY.toString().replace(/^-/,"N") } if (Object.keys(position).length > 0) { overlay.position = position @@ -3032,10 +3032,10 @@ export const transformationFormatters: Record< // Positioning via x/y or focus anchor const position: Record = {} if (values.positionX) { - position.x = values.positionX + position.x = values.positionX.toString().replace(/^-/,"N") } if (values.positionY) { - position.y = values.positionY + position.y = values.positionY.toString().replace(/^-/,"N") } if (Object.keys(position).length > 0) { diff --git a/packages/imagekit-editor-dev/src/schema/transformation.ts b/packages/imagekit-editor-dev/src/schema/transformation.ts index 78eb69c..8a798d4 100644 --- a/packages/imagekit-editor-dev/src/schema/transformation.ts +++ b/packages/imagekit-editor-dev/src/schema/transformation.ts @@ -78,10 +78,8 @@ export const aspectRatioValidator = z.any().superRefine((val, ctx) => { }) const layerXNumber = z.coerce - .number({ invalid_type_error: "Should be a number." }) - .min(0, { - message: "Layer X must be a positive number.", - }) + .string() + .regex(/^[N-]?\d+(\.\d{1,2})?$/) const layerXExpr = z .string() @@ -97,15 +95,13 @@ export const layerXValidator = z.any().superRefine((val, ctx) => { } ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Layer X must be a positive number or a valid expression string.", + message: "Layer X must be a number or a valid expression string.", }) }) const layerYNumber = z.coerce - .number({ invalid_type_error: "Should be a number." }) - .min(0, { - message: "Layer Y must be a positive number.", - }) + .string() + .regex(/^[N-]?\d+(\.\d{1,2})?$/) const layerYExpr = z .string() @@ -121,6 +117,6 @@ export const layerYValidator = z.any().superRefine((val, ctx) => { } ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Layer Y must be a positive number or a valid expression string.", + message: "Layer Y must be a number or a valid expression string.", }) }) From 560cdbaf98e34c0c000c395e97bd3d0eedb3b6fa Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Mon, 19 Jan 2026 14:00:09 +0530 Subject: [PATCH 02/48] Add support for duplicate and rename in L1 sidebar --- .../src/components/common/Hover.tsx | 38 +++++++- .../sidebar/sortable-transformation-item.tsx | 91 ++++++++++++++++++- .../sidebar/transformation-config-sidebar.tsx | 8 +- 3 files changed, 131 insertions(+), 6 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/Hover.tsx b/packages/imagekit-editor-dev/src/components/common/Hover.tsx index f3cfd1d..d97adec 100644 --- a/packages/imagekit-editor-dev/src/components/common/Hover.tsx +++ b/packages/imagekit-editor-dev/src/components/common/Hover.tsx @@ -1,5 +1,5 @@ import { Box, type BoxProps, Flex, type FlexProps } from "@chakra-ui/react" -import { useState } from "react" +import { useState, useEffect, useRef, useCallback } from "react" interface FlexHoverProps extends FlexProps { children(isHover: boolean): JSX.Element @@ -15,6 +15,41 @@ const Hover = ({ }: BoxHoverProps | FlexHoverProps): JSX.Element => { const [isHover, setIsHover] = useState(false) + const hoverAreaRef = useRef(null) + const debounceTimerRef = useRef(null) + + const handleClickOutside = useCallback((event: MouseEvent): void => { + console.log('handleClickOutside called') + const hoverArea = hoverAreaRef.current + if ( + hoverArea && + !hoverArea.contains(event.target as Node) + ) { + setIsHover(false) + } + }, []) + + const debouncedHandleClickOutside = useCallback((event: MouseEvent): void => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + debounceTimerRef.current = setTimeout(() => { + handleClickOutside(event) + }, 100) + }, [handleClickOutside]) + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('mouseover', debouncedHandleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('mouseover', debouncedHandleClickOutside) + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + } + }, [handleClickOutside, debouncedHandleClickOutside]) + if (props.display === "flex") { return ( { setIsHover(false) }} + ref={hoverAreaRef} > {children(isHover)} diff --git a/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx b/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx index 6bca676..ba4122f 100644 --- a/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx +++ b/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx @@ -8,7 +8,10 @@ import { MenuList, Text, Tooltip, + Input, + Tag } from "@chakra-ui/react" +import { useState, useEffect, useRef } from "react" import { useSortable } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" import { PiArrowDown } from "@react-icons/all-files/pi/PiArrowDown" @@ -21,6 +24,8 @@ import { PiPencilSimple } from "@react-icons/all-files/pi/PiPencilSimple" import { PiPlus } from "@react-icons/all-files/pi/PiPlus" import { PiTrash } from "@react-icons/all-files/pi/PiTrash" import { RxTransform } from "@react-icons/all-files/rx/RxTransform" +import { PiCopy } from "@react-icons/all-files/pi/PiCopy" +import { PiCursorText } from "@react-icons/all-files/pi/PiCursorText" import { type Transformation, useEditorStore } from "../../store" import Hover from "../common/Hover" @@ -54,6 +59,8 @@ export const SortableTransformationItem = ({ _setSelectedTransformationKey, _setTransformationToEdit, _internalState, + addTransformation, + updateTransformation, } = useEditorStore() const style = transform @@ -70,6 +77,27 @@ export const SortableTransformationItem = ({ _internalState.transformationToEdit?.position === "inplace" && _internalState.transformationToEdit?.transformationId === transformation.id + const [isRenaming, setIsRenaming] = useState(false); + + const renamingBoxRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent): void => { + const renamingBox = renamingBoxRef.current + if ( + renamingBox && + !renamingBox.contains(event.target as Node) + ) { + setIsRenaming(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + return ( {(isHover) => ( @@ -87,7 +115,13 @@ export const SortableTransformationItem = ({ minH="8" alignItems="center" style={style} - onClick={() => { + onClick={(e) => { + // Triple click to rename + if (e.detail === 3) { + e.stopPropagation() + setIsRenaming(true); + return + } _setSidebarState("config") _setSelectedTransformationKey(transformation.key) _setTransformationToEdit(transformation.id, "inplace") @@ -116,9 +150,34 @@ export const SortableTransformationItem = ({ )} + {isRenaming ? ( + + { + if (e.key === "Enter") { + const newName = e.target.value.trim() + if (newName.length > 0) { + updateTransformation(transformation.id, { ...transformation, name: newName }); + } + setIsRenaming(false) + } else if (e.key === "Escape") { + setIsRenaming(false) + } + }} + /> + + Press { + navigator.platform.toLowerCase().includes('mac') ? 'Return' : 'Enter' + } to save, Esc to cancel + + + ) : ( {transformation.name} - + )} {isHover && ( @@ -181,6 +240,22 @@ export const SortableTransformationItem = ({ > Add transformation after + } + onClick={(e) => { + e.stopPropagation() + const currentIndex = transformations.findIndex( + (t) => t.id === transformation.id, + ) + const transformationId = addTransformation({ + ...transformation, + }, currentIndex + 1); + _setSidebarState("config") + _setTransformationToEdit(transformationId, "inplace") + }} + > + Duplicate + } onClick={(e) => { @@ -192,6 +267,18 @@ export const SortableTransformationItem = ({ > Edit transformation + } + onClick={(e) => { + e.stopPropagation() + setIsRenaming(true); + _setSidebarState("config") + _setSelectedTransformationKey(transformation.key) + _setTransformationToEdit(transformation.id, "inplace") + }} + > + Rename + } onClick={(e) => { 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..af58c02 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 @@ -83,14 +83,16 @@ export const TransformationConfigSidebar: React.FC = () => { const transformationToEdit = _internalState.transformationToEdit - const editedTransformationValue = useMemo(() => { + const editedTransformation = useMemo(() => { if (!transformationToEdit) return undefined return transformations.find( (transformation) => transformation.id === transformationToEdit.transformationId, - )?.value as Record | undefined + ) }, [transformations, transformationToEdit]) + const editedTransformationValue = editedTransformation?.value as Record | undefined + const defaultValues = useMemo(() => { if ( transformationToEdit && @@ -166,7 +168,7 @@ export const TransformationConfigSidebar: React.FC = () => { if (transformationToEdit && transformationToEdit.position === "inplace") { updateTransformation(transformationToEdit.transformationId, { type: "transformation", - name: selectedTransformation.name, + name: editedTransformation?.name ?? selectedTransformation.name, key: selectedTransformation.key, value: data, }) From 5ad6ab7cb209e8dc0676777e3e9ab9e5f3ee3646 Mon Sep 17 00:00:00 2001 From: Piyush Aryan Date: Tue, 20 Jan 2026 17:23:11 +0530 Subject: [PATCH 03/48] feat: add trim transformation support --- .../imagekit-editor-dev/src/schema/index.ts | 107 +++++++++++++++++- 1 file changed, 101 insertions(+), 6 deletions(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 94fec2b..e93f2b0 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -1575,6 +1575,76 @@ export const transformationSchema: TransformationSchema[] = [ }, ], }, + { + 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", + defaultTransformation: {}, + schema: z + .object({ + trimEnabled: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + trim: z + .union([ + z.literal("auto"), + 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: "auto", + min: 1, + max: 99, + step: 1, + autoOption: true, + }, + isVisible: ({ trimEnabled }) => trimEnabled === true, + }, + ], + }, ], }, { @@ -2795,10 +2865,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 +2878,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 @@ -3058,6 +3130,29 @@ export const transformationFormatters: Record< transforms.flip = flip.join("_") } }, + trim: (values, transforms) => { + const { trimEnabled, trim } = values as { + trimEnabled?: boolean + trim?: "auto" | number + } + + // If not enabled, don't apply trim at all + if (!trimEnabled) return + + // Auto mode (similar to rotate's "auto"): send boolean true + if (trim === "auto" || typeof trim === "undefined") { + transforms.trim = true + 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)) { From 645fd1d8231142f5788f4c0da29a766d771818ee Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Tue, 20 Jan 2026 18:03:58 +0530 Subject: [PATCH 04/48] Add advanced padding input support --- .../src/components/common/PaddingInput.tsx | 267 ++++++++++++++++++ .../sidebar/transformation-config-sidebar.tsx | 15 +- .../imagekit-editor-dev/src/schema/index.ts | 51 +++- 3 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx diff --git a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx new file mode 100644 index 0000000..1f347cb --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx @@ -0,0 +1,267 @@ +import { + Box, + Flex, + HStack, + Icon, + Text, + Input, + InputGroup, + InputLeftElement, + IconButton, + FormErrorMessage, + useColorModeValue, +} from "@chakra-ui/react" +import { set } from "lodash" +import type * as React from "react" +import { useState, useEffect } from "react" +import { LuArrowLeftToLine } from "@react-icons/all-files/lu/LuArrowLeftToLine" +import { LuArrowRightToLine } from "@react-icons/all-files/lu/LuArrowRightToLine" +import { LuArrowUpToLine } from "@react-icons/all-files/lu/LuArrowUpToLine" +import { LuArrowDownToLine } from "@react-icons/all-files/lu/LuArrowDownToLine" +import { TbBoxPadding } from "@react-icons/all-files/tb/TbBoxPadding" +import { MdOutlinePadding } from "@react-icons/all-files/md/MdOutlinePadding" +import { FieldErrors } from "react-hook-form" + + +type PaddingInputFieldProps = { + id?: string + onChange: (value: number | PaddingObject | string) => void + errors?: FieldErrors> + name: string +} + +type PaddingObject = { + top: number | null + right: number | null + bottom: number | null + left: number | null +} + +function getUpdatedPaddingValue( + current: number | PaddingObject | null | string, + side: "top" | "right" | "bottom" | "left" | "all", + value: string, + mode: "uniform" | "individual" +): number | PaddingObject | null | string { + let inputValue: number | PaddingObject | null | string + try { + inputValue = JSON.parse(value) + } catch { + inputValue = value + } + if (mode === "uniform") { + if (typeof inputValue === "number") { + return inputValue + } else if (inputValue === null) { + return null + } else if (typeof inputValue === "string") { + return inputValue + } else { + const { top, right, bottom, left } = inputValue + if (top === right && top === bottom && top === left) { + return top + } else { + return null + } + } + } else { + let commonValue: number | null = null + if (typeof inputValue === "number") { + commonValue = inputValue + } + const updatedPadding = current && typeof current === "object" + ? { ...current } + : { top: commonValue, right: commonValue, bottom: commonValue, left: commonValue } + if (side !== "all") { + set(updatedPadding, side, inputValue) + } + return updatedPadding + } +} + +export const PaddingInputField: React.FC = ({ + id, + onChange, + errors, + name: propertyName, +}) => { + const [paddingMode, setPaddingMode] = useState<"uniform" | "individual">("uniform") + const [paddingValue, setPaddingValue] = useState("") + const errorRed = useColorModeValue("red.500", "red.300") + + useEffect(() => { + const formatPaddingValue = (value: number | PaddingObject | null | string): string | PaddingObject => { + if (value === null) return "" + if (typeof value === "number") { + return value.toString() + } else if (typeof value === "string") { + return value + } else { + return value; + } + } + const formattedValue = formatPaddingValue(paddingValue) + onChange(formattedValue) + }, [paddingValue]) + + + return ( + + + { paddingMode === "uniform" ? ( + + { + const val = e.target.value + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + "all", + val, + paddingMode + )) + }} + value={["number", "string"].includes(typeof paddingValue) ? paddingValue : ""} + placeholder="Uniform Padding" + isInvalid={!!errors?.[propertyName]} + /> + {errors?.[propertyName]?.message} + + ) : ( + <> + + + + + + { + const val = e.target.value + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + "top", + val, + paddingMode + )) + }} + value={typeof paddingValue === "object" ? paddingValue?.top ?? "" : ""} + placeholder="Top" + isInvalid={!!errors?.[propertyName]?.top} + /> + + {errors?.[propertyName]?.top?.message} + + + + + + + + { + const val = e.target.value + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + "right", + val, + paddingMode + )) + }} + value={typeof paddingValue === "object" ? paddingValue?.right ?? "" : ""} + placeholder="Right" + isInvalid={!!errors?.[propertyName]?.right} + /> + + {errors?.[propertyName]?.right?.message} + + + + + + + + { + const val = e.target.value + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + "bottom", + val, + paddingMode + )) + }} + value={typeof paddingValue === "object" ? paddingValue?.bottom ?? "" : ""} + placeholder="Bottom" + isInvalid={!!errors?.[propertyName]?.bottom} + /> + + {errors?.[propertyName]?.bottom?.message} + + + + + + + + { + const val = e.target.value + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + "left", + val, + paddingMode + )) + }} + value={typeof paddingValue === "object" ? paddingValue?.left ?? "" : ""} + placeholder="Left" + isInvalid={!!errors?.[propertyName]?.left} + /> + + {errors?.[propertyName]?.left?.message} + + + + ) } + + + : } + onClick={() => { + const newPaddingMode = paddingMode === "uniform" ? "individual" : "uniform" + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + "all", + JSON.stringify(paddingValue), + newPaddingMode + )) + setPaddingMode(newPaddingMode) + }} + mb={2} + variant="ghost" + /> + + + ) +} + +export default PaddingInputField 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 af58c02..68577f5 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 PaddingInputField from "../common/PaddingInput" export const TransformationConfigSidebar: React.FC = () => { const { @@ -133,6 +134,7 @@ export const TransformationConfigSidebar: React.FC = () => { watch, setValue, control, + trigger, } = useForm>({ resolver: zodResolver(selectedTransformation?.schema ?? z.object({})), defaultValues: defaultValues, @@ -292,7 +294,7 @@ export const TransformationConfigSidebar: React.FC = () => { return true }) .map((field: TransformationField) => ( - + {field.label} @@ -560,6 +562,17 @@ export const TransformationConfigSidebar: React.FC = () => { {...field.fieldProps} /> ) : null} + {field.fieldType === "padding-input" ? ( + { + setValue(field.name, value) + trigger(field.name) + }} + errors={errors} + name={field.name} + {...field.fieldProps} + /> + ) : null} {String( errors[field.name as keyof typeof errors]?.message ?? "", diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 3bbe634..df35cbd 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -2122,11 +2122,35 @@ export const transformationSchema: TransformationSchema[] = [ innerAlignment: z .enum(["left", "right", "center"]) .default("center"), - padding: z.coerce - .number({ + padding: z.union([ + z.coerce.number({ invalid_type_error: "Should be a number.", - }) - .optional(), + }).min(0, { + message: "Negative values are not allowed.", + }), + z.object({ + top: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + right: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + bottom: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + left: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + }), + ]).optional(), opacity: z .union([ z.coerce @@ -2317,7 +2341,7 @@ export const transformationSchema: TransformationSchema[] = [ { label: "Padding", name: "padding", - fieldType: "input", + fieldType: "padding-input", isTransformation: true, transformationKey: "padding", transformationGroup: "textLayer", @@ -2861,8 +2885,25 @@ export const transformationFormatters: Record< typeof values.padding === "string" ) { overlayTransform.padding = values.padding + } else if (typeof values.padding === "object" && values.padding !== null) { + const { top, right, bottom, left } = values.padding as { + top: number + right: number + bottom: number + left: number + } + let paddingString: string; + if (top === right && top === bottom && top === left) { + paddingString = String(top) + } else if (top === bottom && right === left) { + paddingString = `${top}_${right}` + } else { + paddingString = `${top}_${right}_${bottom}_${left}` + } + overlayTransform.padding = paddingString } + if (Array.isArray(values.flip) && values.flip.length > 0) { const flip = [] if (values.flip.includes("horizontal")) { From 117c4a79fdc6d809eaccc344f919bd9d03936233 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Tue, 20 Jan 2026 18:04:06 +0530 Subject: [PATCH 05/48] Remove unwanted console logs --- packages/imagekit-editor-dev/src/components/common/Hover.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/imagekit-editor-dev/src/components/common/Hover.tsx b/packages/imagekit-editor-dev/src/components/common/Hover.tsx index d97adec..09f7929 100644 --- a/packages/imagekit-editor-dev/src/components/common/Hover.tsx +++ b/packages/imagekit-editor-dev/src/components/common/Hover.tsx @@ -19,7 +19,6 @@ const Hover = ({ const debounceTimerRef = useRef(null) const handleClickOutside = useCallback((event: MouseEvent): void => { - console.log('handleClickOutside called') const hoverArea = hoverAreaRef.current if ( hoverArea && From 5a13741b7b80c928b341ebc7ca03016bbb70b0ae Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Wed, 21 Jan 2026 16:47:50 +0530 Subject: [PATCH 06/48] Fix Icon Button in padding input --- .../src/components/common/PaddingInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx index 1f347cb..f6e5ea1 100644 --- a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx @@ -245,7 +245,7 @@ export const PaddingInputField: React.FC = ({ : } + icon={paddingMode === "uniform" ? : } onClick={() => { const newPaddingMode = paddingMode === "uniform" ? "individual" : "uniform" setPaddingValue(getUpdatedPaddingValue( @@ -257,7 +257,7 @@ export const PaddingInputField: React.FC = ({ setPaddingMode(newPaddingMode) }} mb={2} - variant="ghost" + variant="outline" /> From 7663b96035032432404e0918b952d072b9bc100d Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Wed, 21 Jan 2026 19:25:07 +0530 Subject: [PATCH 07/48] Add focus support in image overlay --- .../src/components/common/AnchorField.tsx | 7 +- .../imagekit-editor-dev/src/schema/index.ts | 217 ++++++++++++++++-- 2 files changed, 206 insertions(+), 18 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/AnchorField.tsx b/packages/imagekit-editor-dev/src/components/common/AnchorField.tsx index 885f919..792814b 100644 --- a/packages/imagekit-editor-dev/src/components/common/AnchorField.tsx +++ b/packages/imagekit-editor-dev/src/components/common/AnchorField.tsx @@ -78,7 +78,12 @@ const AnchorField: React.FC = ({ minWidth="0" p="0" isDisabled={!positions.includes(position.value)} - onClick={() => onChange(position.value)} + onClick={() => { + if (value === position.value) { + return onChange("") + } + onChange(position.value) + }} borderRadius="md" border={ value === position.value diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index df35cbd..d2ae7bb 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -839,17 +839,6 @@ export const transformationSchema: TransformationSchema[] = [ }) } if (val.focus === "coordinates") { - const hasXY = val.x || val.y - const hasXCYC = val.xc || val.yc - - if (hasXY && hasXCYC) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Choose either x/y or xc/yc, not both", - path: [], - }) - } - if (val.coordinateMethod === "topleft") { if (!val.x && !val.y) { ctx.addIssue({ @@ -2470,6 +2459,14 @@ export const transformationSchema: TransformationSchema[] = [ invalid_type_error: "Should be a number.", }) .optional(), + focus: z.string().optional(), + focusAnchor: z.string().optional(), + focusObject: z.string().optional(), + coordinateMethod: z.string().optional(), + x: z.string().optional(), + y: z.string().optional(), + xc: z.string().optional(), + yc: z.string().optional(), }) .refine( (val) => { @@ -2481,7 +2478,42 @@ export const transformationSchema: TransformationSchema[] = [ message: "At least one value is required", path: [], }, - ), + ) + .superRefine((val, ctx) => { + if (val.focus === "object" && !val.focusObject) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Focus object is required", + path: ["focusObject"], + }) + } + if (val.focus === "anchor" && !val.focusAnchor) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Focus anchor is required", + path: ["focusAnchor"], + }) + } + if (val.focus === "coordinates") { + if (val.coordinateMethod === "topleft") { + if (!val.x && !val.y) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one coordinate (x or y) is required", + path: [], + }) + } + } else if (val.coordinateMethod === "center") { + if (!val.xc && !val.yc) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one coordinate (xc or yc) is required", + path: [], + }) + } + } + } + }), transformations: [ { label: "Image URL", @@ -2533,6 +2565,132 @@ export const transformationSchema: TransformationSchema[] = [ defaultValue: "", }, }, + { + label: "Focus", + name: "focus", + fieldType: "select", + isTransformation: true, + transformationGroup: "imageLayer", + fieldProps: { + options: [ + { label: "Select one", value: "" }, + { label: "Auto", value: "auto" }, + { label: "Anchor", value: "anchor" }, + { label: "Face", value: "face" }, + { label: "Object", value: "object" }, + { label: "Custom", value: "custom" }, + { label: "Coordinates", value: "coordinates" }, + ], + }, + helpText: + "Choose how to position the extracted region in overlay image. Custom uses a saved focus area from Media Library.", + isVisible: ({ crop }) => crop === "cm-extract", + }, + // Only for extract crop mode + { + label: "Focus Anchor", + name: "focusAnchor", + fieldType: "anchor", + isTransformation: true, + transformationGroup: "imageLayer", + fieldProps: { + positions: [ + "center", "top", "bottom", "left", "right", "top_left", "top_right", "bottom_left", "bottom_right", + ], + }, + isVisible: ({ focus, crop }) => focus === "anchor" && crop === "cm-extract", + }, + // Only for pad_resize crop mode + { + label: "Focus", + name: "focusAnchor", + fieldType: "anchor", + isTransformation: true, + transformationGroup: "imageLayer", + fieldProps: { + positions: [ + "center", "top", "bottom", "left", "right", + ], + }, + isVisible: ({ crop }) => crop === "cm-pad_resize", + }, + { + label: "Focus Object", + name: "focusObject", + fieldType: "select", + isTransformation: true, + transformationGroup: "imageLayer", + fieldProps: { + isCreatable: false, + }, + helpText: + "Select an object to focus on in the overlay image during extraction. The crop will center on this object.", + isVisible: ({ focus }) => focus === "object", + }, + { + label: "Coordinate Method", + name: "coordinateMethod", + fieldType: "radio-card", + isTransformation: false, + transformationGroup: "imageLayer", + fieldProps: { + options: [ + { label: "Top-left (x, y)", value: "topleft" }, + { label: "Center (xc, yc)", value: "center" }, + ], + defaultValue: "topleft", + }, + helpText: + "Choose whether coordinates are relative to the top-left corner or the center of the overlay image.", + isVisible: ({ focus }) => focus === "coordinates", + }, + { + label: "X (Horizontal)", + name: "x", + fieldType: "input", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Horizontal position from the top-left of the overlay image. Use an integer or expression.", + examples: ["100", "iw_mul_0.4"], + isVisible: ({ focus, coordinateMethod }) => + focus === "coordinates" && coordinateMethod === "topleft", + }, + { + label: "Y (Vertical)", + name: "y", + fieldType: "input", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Vertical position from the top-left of the overlay image. Use an integer or expression.", + examples: ["100", "ih_mul_0.4"], + isVisible: ({ focus, coordinateMethod }) => + focus === "coordinates" && coordinateMethod === "topleft", + }, + { + label: "XC (Horizontal Center)", + name: "xc", + fieldType: "input", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Horizontal center position of the overlay image. Use an integer or expression.", + examples: ["200", "iw_mul_0.5"], + isVisible: ({ focus, coordinateMethod }) => + focus === "coordinates" && coordinateMethod === "center", + }, + { + label: "YC (Vertical Center)", + name: "yc", + fieldType: "input", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: "Vertical center position of the overlay image. Use an integer or expression.", + examples: ["200", "ih_mul_0.5"], + isVisible: ({ focus, coordinateMethod }) => + focus === "coordinates" && coordinateMethod === "center", + }, { label: "Position X", name: "positionX", @@ -2770,7 +2928,7 @@ export const transformationFormatters: Record< } }, focus: (values, transforms) => { - const { focus, focusAnchor, focusObject, x, y, xc, yc } = values + const { focus, focusAnchor, focusObject, x, y, xc, yc, coordinateMethod } = values if (focus === "auto" || focus === "face") { transforms.focus = focus @@ -2783,10 +2941,13 @@ export const transformationFormatters: Record< } else if (focus === "coordinates") { // Handle coordinate-based focus // x/y are top-left coordinates, xc/yc are center coordinates - if (x) transforms.x = x - if (y) transforms.y = y - if (xc) transforms.xc = xc - if (yc) transforms.yc = yc + if (coordinateMethod === "topleft") { + if (x) transforms.x = x + if (y) transforms.y = y + } else if (coordinateMethod === "center") { + if (xc) transforms.xc = xc + if (yc) transforms.yc = yc + } } }, shadow: (values, transforms) => { @@ -3066,6 +3227,28 @@ export const transformationFormatters: Record< overlayTransform.blur = values.blur } + const { focus, crop, focusAnchor, focusObject, x, y, xc, yc, coordinateMethod } = values + + if (focus === "auto" || focus === "face") { + overlayTransform.focus = focus + } else if (focus === "anchor" || crop === "cm-pad_resize") { + overlayTransform.focus = focusAnchor + } else if (focus === "object") { + overlayTransform.focus = focusObject + } else if (focus === "custom") { + overlayTransform.focus = "custom" + } else if (focus === "coordinates") { + // Handle coordinate-based focus + // x/y are top-left coordinates, xc/yc are center coordinates + if (coordinateMethod === "topleft") { + if (x) overlayTransform.x = x + if (y) overlayTransform.y = y + } else if (coordinateMethod === "center") { + if (xc) overlayTransform.xc = xc + if (yc) overlayTransform.yc = yc + } + } + if (Object.keys(overlayTransform).length > 0) { overlay.transformation = [overlayTransform] } From 6293e838a82a70a3cd098e393347811b90b21221 Mon Sep 17 00:00:00 2001 From: Piyush Aryan Date: Wed, 21 Jan 2026 23:08:34 +0530 Subject: [PATCH 08/48] fix: update trim transformation schema and default values for improved functionality --- .../imagekit-editor-dev/src/schema/index.ts | 50 +++++++------------ 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index e93f2b0..e68ae88 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -1580,28 +1580,25 @@ export const transformationSchema: TransformationSchema[] = [ 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", + 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 - .union([ - z.literal("auto"), - z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .int() - .min(1) - .max(99), - ]) - .optional(), - }) + .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 ( @@ -1635,11 +1632,10 @@ export const transformationSchema: TransformationSchema[] = [ helpText: "Trim edges for images with solid or near-solid backgrounds. Use a threshold between 1 and 99.", fieldProps: { - defaultValue: "auto", + defaultValue: 10, min: 1, max: 99, step: 1, - autoOption: true, }, isVisible: ({ trimEnabled }) => trimEnabled === true, }, @@ -3133,18 +3129,10 @@ export const transformationFormatters: Record< trim: (values, transforms) => { const { trimEnabled, trim } = values as { trimEnabled?: boolean - trim?: "auto" | number + trim?: "default" | number } - - // If not enabled, don't apply trim at all if (!trimEnabled) return - // Auto mode (similar to rotate's "auto"): send boolean true - if (trim === "auto" || typeof trim === "undefined") { - transforms.trim = true - return - } - // Numeric threshold 1–99 if (typeof trim === "number") { const threshold = Math.trunc(trim) From d7fdd83ad630ccb5abd8242d8568ae76b2aaf0f3 Mon Sep 17 00:00:00 2001 From: Piyush Aryan Date: Thu, 22 Jan 2026 17:03:37 +0530 Subject: [PATCH 09/48] feat: add color replace transformation with validation and integration into schema --- .../components/common/ColorPickerField.tsx | 8 +- .../sidebar/transformation-config-sidebar.tsx | 2 + .../imagekit-editor-dev/src/schema/index.ts | 104 ++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx b/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx index 76eee42..afb84c4 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 + // pass color picker props @ts-ignore + {...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 e68ae88..01783f2 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -1641,6 +1641,83 @@ export const transformationSchema: TransformationSchema[] = [ }, ], }, + { + 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: "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.", + }, { + 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: "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).", + }, + + + ], + }, ], }, { @@ -3161,4 +3238,31 @@ 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("_") + }, } From 937b21d30601acdb6a7ce5a85478e5c4b55dac9d Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Thu, 22 Jan 2026 20:21:13 +0530 Subject: [PATCH 10/48] Add tooltip and better UX in padding input --- .../src/components/common/PaddingInput.tsx | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx index f6e5ea1..0244dac 100644 --- a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx @@ -8,12 +8,13 @@ import { InputGroup, InputLeftElement, IconButton, - FormErrorMessage, + IconButtonProps, useColorModeValue, + Tooltip, } from "@chakra-ui/react" import { set } from "lodash" import type * as React from "react" -import { useState, useEffect } from "react" +import { useState, useEffect, forwardRef } from "react" import { LuArrowLeftToLine } from "@react-icons/all-files/lu/LuArrowLeftToLine" import { LuArrowRightToLine } from "@react-icons/all-files/lu/LuArrowRightToLine" import { LuArrowUpToLine } from "@react-icons/all-files/lu/LuArrowUpToLine" @@ -22,7 +23,6 @@ import { TbBoxPadding } from "@react-icons/all-files/tb/TbBoxPadding" import { MdOutlinePadding } from "@react-icons/all-files/md/MdOutlinePadding" import { FieldErrors } from "react-hook-form" - type PaddingInputFieldProps = { id?: string onChange: (value: number | PaddingObject | string) => void @@ -88,6 +88,8 @@ export const PaddingInputField: React.FC = ({ const [paddingMode, setPaddingMode] = useState<"uniform" | "individual">("uniform") const [paddingValue, setPaddingValue] = useState("") const errorRed = useColorModeValue("red.500", "red.300") + const activeColor = useColorModeValue("blue.500", "blue.600") + const inactiveColor = useColorModeValue("gray.600", "gray.400") useEffect(() => { const formatPaddingValue = (value: number | PaddingObject | null | string): string | PaddingObject => { @@ -242,10 +244,25 @@ export const PaddingInputField: React.FC = ({ ) } - + : } + aria-pressed={paddingMode === "individual"} + icon={} onClick={() => { const newPaddingMode = paddingMode === "uniform" ? "individual" : "uniform" setPaddingValue(getUpdatedPaddingValue( @@ -256,10 +273,10 @@ export const PaddingInputField: React.FC = ({ )) setPaddingMode(newPaddingMode) }} - mb={2} variant="outline" + color={paddingMode === "individual" ? activeColor : inactiveColor} /> - + ) } From c36c4dd44b04e4df49f78b11aa97212d51205335 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Thu, 22 Jan 2026 20:25:12 +0530 Subject: [PATCH 11/48] Add zoom support with fo face and object in base image and image overlay --- .../src/components/common/ZoomInput.tsx | 141 ++++++++++++++++++ .../sidebar/transformation-config-sidebar.tsx | 9 ++ .../imagekit-editor-dev/src/schema/index.ts | 67 ++++++++- 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx diff --git a/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx new file mode 100644 index 0000000..7dd7023 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx @@ -0,0 +1,141 @@ +import { + HStack, + Input, + InputGroup, + InputRightElement, + IconButton, + ButtonGroup, + Text, + useColorModeValue, +} from "@chakra-ui/react" +import type * as React from "react" +import { useState, useEffect } from "react" +import { AiOutlinePlus } from "@react-icons/all-files/ai/AiOutlinePlus" +import { AiOutlineMinus } from "@react-icons/all-files/ai/AiOutlineMinus" + +type ZoomInputFieldProps = { + id?: string + onChange: (value: number) => void + defaultValue?: number +} + +/** + * Calculate the step size based on the current zoom value + * If zoom >= 100: step = 50 + * If zoom < 100: step = 10 + */ +function getStepSize(value: number, zoomMode: "in" | "out"): number { + if (zoomMode === "in") { + return value >= 100 ? 50 : 10 + } else { + return value > 100 ? 50 : 10 + } +} + +/** + * Calculate the next zoom value when zooming in + * Rounds up to the next step value + */ +function calculateZoomIn(currentValue: number): number { + const step = getStepSize(currentValue, "in") + return (Math.floor(currentValue / step) * step) + step +} + +/** + * Calculate the next zoom value when zooming out + * Rounds down to the previous step value + */ +function calculateZoomOut(currentValue: number): number { + const step = getStepSize(currentValue, "out") + return (Math.ceil(currentValue / step) * step) - step +} + +export const ZoomInputField: React.FC = ({ + id, + onChange, + defaultValue = 100, +}) => { + const [zoomValue, setZoomValue] = useState(defaultValue) + const [inputValue, setInputValue] = useState(defaultValue.toString()) + + useEffect(() => { + onChange(zoomValue) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [zoomValue]) + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value + setInputValue(value) + + const numValue = Number(value) + if (!isNaN(numValue) && numValue >= 0) { + setZoomValue(numValue) + } + } + + const handleInputBlur = () => { + // Sync input value with zoom value on blur + setInputValue(zoomValue.toString()) + } + + const handleZoomIn = () => { + const newValue = calculateZoomIn(zoomValue) + setZoomValue(newValue) + setInputValue(newValue.toString()) + } + + const handleZoomOut = () => { + const newValue = calculateZoomOut(zoomValue) + // Prevent going below 0 + if (newValue >= 0) { + setZoomValue(newValue) + setInputValue(newValue.toString()) + } else { + setZoomValue(0) + setInputValue("0") + } + } + + return ( + + + + + + % + + + + + + } + onClick={handleZoomOut} + /> + } + onClick={handleZoomIn} + /> + + + + ) +} + +export default ZoomInputField 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 68577f5..747d7b2 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 @@ -56,6 +56,7 @@ import { SidebarFooter } from "./sidebar-footer" import { SidebarHeader } from "./sidebar-header" import { SidebarRoot } from "./sidebar-root" import PaddingInputField from "../common/PaddingInput" +import ZoomInputField from "../common/ZoomInput" export const TransformationConfigSidebar: React.FC = () => { const { @@ -573,6 +574,14 @@ export const TransformationConfigSidebar: React.FC = () => { {...field.fieldProps} /> ) : null} + {field.fieldType === "zoom" ? ( + setValue(field.name, value)} + defaultValue={field.fieldProps?.defaultValue as number ?? 0} + {...field.fieldProps} + /> + ) : null} {String( errors[field.name as keyof typeof errors]?.message ?? "", diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index d2ae7bb..319afae 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -371,6 +371,7 @@ export const transformationSchema: TransformationSchema[] = [ focus: z.string().optional(), focusAnchor: z.string().optional(), focusObject: z.string().optional(), + zoom: z.coerce.number().optional(), }) .refine( (val) => { @@ -499,6 +500,19 @@ export const transformationSchema: TransformationSchema[] = [ "Select an object to focus on. The crop will center on this object.", isVisible: ({ focus }) => focus === "object", }, + { + label: "Zoom", + name: "zoom", + fieldType: "zoom", + isTransformation: true, + transformationGroup: "focus", + fieldProps: { + isCreatable: false, + }, + helpText: + "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", + isVisible: ({ focus }) => focus === "object" || focus === "face", + }, ], }, { @@ -519,6 +533,7 @@ export const transformationSchema: TransformationSchema[] = [ focus: z.string().optional(), focusAnchor: z.string().optional(), focusObject: z.string().optional(), + zoom: z.coerce.number().optional(), }) .refine( (val) => { @@ -606,6 +621,19 @@ export const transformationSchema: TransformationSchema[] = [ "Select an object to focus on. The crop will center on this object.", isVisible: ({ focus }) => focus === "object", }, + { + label: "Zoom", + name: "zoom", + fieldType: "zoom", + isTransformation: true, + transformationGroup: "focus", + fieldProps: { + isCreatable: false, + }, + helpText: + "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", + isVisible: ({ focus }) => focus === "object", + }, ], }, { @@ -806,6 +834,7 @@ export const transformationSchema: TransformationSchema[] = [ y: z.string().optional(), xc: z.string().optional(), yc: z.string().optional(), + zoom: z.coerce.number().optional(), }) .refine( (val) => { @@ -996,6 +1025,19 @@ export const transformationSchema: TransformationSchema[] = [ isVisible: ({ focus, coordinateMethod }) => focus === "coordinates" && coordinateMethod === "center", }, + { + label: "Zoom", + name: "zoom", + fieldType: "zoom", + isTransformation: true, + transformationGroup: "focus", + fieldProps: { + isCreatable: false, + }, + helpText: + "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", + isVisible: ({ focus }) => focus === "object" || focus === "face", + }, ], }, { @@ -2467,6 +2509,7 @@ export const transformationSchema: TransformationSchema[] = [ y: z.string().optional(), xc: z.string().optional(), yc: z.string().optional(), + zoom: z.coerce.number().optional(), }) .refine( (val) => { @@ -2691,6 +2734,19 @@ export const transformationSchema: TransformationSchema[] = [ isVisible: ({ focus, coordinateMethod }) => focus === "coordinates" && coordinateMethod === "center", }, + { + label: "Zoom", + name: "zoom", + fieldType: "zoom", + isTransformation: true, + transformationGroup: "imageLayer", + fieldProps: { + isCreatable: false, + }, + helpText: + "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", + isVisible: ({ focus }) => focus === "object" || focus === "face", + }, { label: "Position X", name: "positionX", @@ -2928,7 +2984,7 @@ export const transformationFormatters: Record< } }, focus: (values, transforms) => { - const { focus, focusAnchor, focusObject, x, y, xc, yc, coordinateMethod } = values + const { focus, focusAnchor, focusObject, x, y, xc, yc, coordinateMethod, zoom } = values if (focus === "auto" || focus === "face") { transforms.focus = focus @@ -2949,6 +3005,9 @@ export const transformationFormatters: Record< if (yc) transforms.yc = yc } } + if (zoom !== undefined && zoom !== null && !isNaN(Number(zoom)) && zoom !== 0) { + transforms.zoom = (zoom as number) / 100 + } }, shadow: (values, transforms) => { const { @@ -3227,7 +3286,7 @@ export const transformationFormatters: Record< overlayTransform.blur = values.blur } - const { focus, crop, focusAnchor, focusObject, x, y, xc, yc, coordinateMethod } = values + const { focus, crop, focusAnchor, focusObject, x, y, xc, yc, coordinateMethod, zoom } = values if (focus === "auto" || focus === "face") { overlayTransform.focus = focus @@ -3249,6 +3308,10 @@ export const transformationFormatters: Record< } } + if (zoom !== undefined && zoom !== null && !isNaN(Number(zoom)) && zoom !== 0) { + overlayTransform.zoom = (zoom as number) / 100 + } + if (Object.keys(overlayTransform).length > 0) { overlay.transformation = [overlayTransform] } From 56834fed4d899e40b722ec99057ee610eea71c01 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Fri, 23 Jan 2026 14:59:32 +0530 Subject: [PATCH 12/48] Fix zoom input step size and default value --- .../src/components/common/ZoomInput.tsx | 24 +++++-------------- .../sidebar/transformation-config-sidebar.tsx | 6 ++--- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx index 7dd7023..843036d 100644 --- a/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx @@ -19,26 +19,15 @@ type ZoomInputFieldProps = { defaultValue?: number } -/** - * Calculate the step size based on the current zoom value - * If zoom >= 100: step = 50 - * If zoom < 100: step = 10 - */ -function getStepSize(value: number, zoomMode: "in" | "out"): number { - if (zoomMode === "in") { - return value >= 100 ? 50 : 10 - } else { - return value > 100 ? 50 : 10 - } -} +const STEP_SIZE = 10 + /** * Calculate the next zoom value when zooming in * Rounds up to the next step value */ function calculateZoomIn(currentValue: number): number { - const step = getStepSize(currentValue, "in") - return (Math.floor(currentValue / step) * step) + step + return (Math.floor(currentValue / STEP_SIZE) * STEP_SIZE) + STEP_SIZE } /** @@ -46,11 +35,10 @@ function calculateZoomIn(currentValue: number): number { * Rounds down to the previous step value */ function calculateZoomOut(currentValue: number): number { - const step = getStepSize(currentValue, "out") - return (Math.ceil(currentValue / step) * step) - step + return (Math.ceil(currentValue / STEP_SIZE) * STEP_SIZE) - STEP_SIZE } -export const ZoomInputField: React.FC = ({ +export const ZoomInput: React.FC = ({ id, onChange, defaultValue = 100, @@ -138,4 +126,4 @@ export const ZoomInputField: React.FC = ({ ) } -export default ZoomInputField +export default ZoomInput 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 747d7b2..6845ee4 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 @@ -56,7 +56,7 @@ import { SidebarFooter } from "./sidebar-footer" import { SidebarHeader } from "./sidebar-header" import { SidebarRoot } from "./sidebar-root" import PaddingInputField from "../common/PaddingInput" -import ZoomInputField from "../common/ZoomInput" +import ZoomInput from "../common/ZoomInput" export const TransformationConfigSidebar: React.FC = () => { const { @@ -575,10 +575,10 @@ export const TransformationConfigSidebar: React.FC = () => { /> ) : null} {field.fieldType === "zoom" ? ( - setValue(field.name, value)} - defaultValue={field.fieldProps?.defaultValue as number ?? 0} + defaultValue={field.fieldProps?.defaultValue as number ?? 100} {...field.fieldProps} /> ) : null} From 90c2b1bda135bfe99a23635cebd272e726245ba1 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Fri, 23 Jan 2026 15:10:56 +0530 Subject: [PATCH 13/48] Fix padding toggle button style --- .../imagekit-editor-dev/src/components/common/PaddingInput.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx index 0244dac..fa33481 100644 --- a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx @@ -263,6 +263,7 @@ export const PaddingInputField: React.FC = ({ aria-label={paddingMode === "uniform" ? "Switch to individual padding" : "Switch to uniform padding"} aria-pressed={paddingMode === "individual"} icon={} + padding="0.05em" onClick={() => { const newPaddingMode = paddingMode === "uniform" ? "individual" : "uniform" setPaddingValue(getUpdatedPaddingValue( From bc2da353e87793c4a91899ffdf259c80c74bc197 Mon Sep 17 00:00:00 2001 From: Piyush Aryan Date: Tue, 27 Jan 2026 15:03:43 +0530 Subject: [PATCH 14/48] feat: add border and sharpen transformations to image processing schema with validation and integration --- .../imagekit-editor-dev/src/schema/index.ts | 256 +++++++++++++++++- 1 file changed, 251 insertions(+), 5 deletions(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 01783f2..5c19bec 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -1575,6 +1575,70 @@ 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.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .int() + .min(1) + .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", @@ -1713,9 +1777,77 @@ export const transformationSchema: TransformationSchema[] = [ transformationGroup: "colorReplace", helpText: "Select the source color you want to replace (optional - if not specified, dominant color will be replaced).", + }, + ], + }, + { + 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.union([ + z.literal("auto"), + 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. Use a threshold between 1 and 99.", + fieldProps: { + autoOption: true, + defaultValue: "auto", + min: 1, + max: 99, + step: 1, + }, + isVisible: ({ sharpenEnabled }) => sharpenEnabled === true, }, - - ], }, ], @@ -1723,11 +1855,9 @@ export const transformationSchema: TransformationSchema[] = [ { key: "ai", name: "AI Transformations", - items: [ - { + items: [ { 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: @@ -2589,6 +2719,29 @@ export const transformationSchema: TransformationSchema[] = [ invalid_type_error: "Should be a number.", }) .optional(), + borderWidth: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .optional(), + borderColor: colorValidator.optional(), + sharpenEnabled: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + sharpen: z + .union([ + z.literal("auto"), + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .int() + .min(1) + .max(99), + ]) + .optional(), }) .refine( (val) => { @@ -2789,6 +2942,66 @@ export const transformationSchema: TransformationSchema[] = [ defaultValue: "0", }, }, + { + label: "Border Width", + name: "borderWidth", + fieldType: "input", + isTransformation: false, + transformationKey: "borderWidth", + transformationGroup: "imageLayer", + fieldProps: { + defaultValue: 0, + }, + helpText: + "Enter the width of the border of the overlay image.", + }, + { + label: "Border Color", + name: "borderColor", + fieldType: "color-picker", + isTransformation: false, + transformationKey: "borderColor", + transformationGroup: "imageLayer", + isVisible: ({ borderWidth }) => borderWidth as number > 0, + 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: true, + 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: true, + transformationKey: "sharpen", + transformationGroup: "imageLayer", + helpText: + "Sharpen the overlay image. Use a threshold between 1 and 99 (or auto).", + fieldProps: { + autoOption: true, + defaultValue: "auto", + min: 1, + max: 99, + step: 1, + }, + isVisible: ({ sharpenEnabled }) => sharpenEnabled === true, + } ], }, ], @@ -3170,6 +3383,18 @@ export const transformationFormatters: Record< overlayTransform.blur = values.blur } + // Sharpen overlay (same semantics as base-image sharpen: auto => empty string) + if (values.sharpenEnabled === true) { + if (values.sharpen === "auto") { + overlayTransform.sharpen = "" + } else if (typeof values.sharpen === "number") { + overlayTransform.sharpen = values.sharpen + } + } + if ((values.borderWidth && typeof values.borderWidth === "number" && values.borderWidth > 0) && (values.borderColor && typeof values.borderColor === "string")) { + overlayTransform.b = `${values.borderWidth}_${values.borderColor.replace(/^#/, "")}` + } + if (Object.keys(overlayTransform).length > 0) { overlay.transformation = [overlayTransform] } @@ -3265,4 +3490,25 @@ export const transformationFormatters: Record< transforms.cr = params.join("_") }, + border: (values, transforms) => { + const { borderWidth, borderColor } = values as { + borderWidth?: number + 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?: "auto" | number + } + if(!sharpenEnabled) return + if(sharpen === "auto") { + transforms.sharpen = "" + } else { + transforms.sharpen = sharpen + } + }, } From 0cac58cd6060dad5460161630c8d16078270c678 Mon Sep 17 00:00:00 2001 From: Piyush Aryan Date: Tue, 27 Jan 2026 16:21:31 +0530 Subject: [PATCH 15/48] refactor: improve transformation schema for border, trim, and sharpen with enhanced validation and default values --- .../imagekit-editor-dev/src/schema/index.ts | 899 +++++++++--------- 1 file changed, 457 insertions(+), 442 deletions(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 5c19bec..d404cd6 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -1583,16 +1583,16 @@ export const transformationSchema: TransformationSchema[] = [ docsLink: "https://imagekit.io/docs/effects-and-enhancements#border---b", defaultTransformation: {}, schema: z - .object({ - borderWidth: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .int() - .min(1) - .optional(), - borderColor: colorValidator - }) + .object({ + borderWidth: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .int() + .min(1) + .optional(), + borderColor: colorValidator + }) .refine( (val) => { if ( @@ -1631,7 +1631,7 @@ export const transformationSchema: TransformationSchema[] = [ transformationGroup: "border", helpText: "Select the color of the border.", - fieldProps:{ + fieldProps: { hideOpacity: true, showHexAlpha: false, defaultValue: "#000000", @@ -1647,13 +1647,13 @@ export const transformationSchema: TransformationSchema[] = [ 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: + .object({ + trimEnabled: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + trim: z.coerce .number({ invalid_type_error: "Should be a number.", @@ -1661,8 +1661,8 @@ export const transformationSchema: TransformationSchema[] = [ .int() .min(1) .max(99) - .optional(), - }) + .optional(), + }) .refine( (val) => { if ( @@ -1713,50 +1713,51 @@ export const transformationSchema: TransformationSchema[] = [ 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"], - }, - ), + .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: "To Color", - name: "toColor", - fieldType: "color-picker", + label: "From Color", examples: ["FFFFFF", "FF0000"], - fieldProps:{ + name: "fromColor", + fieldType: "color-picker", + isTransformation: false, + fieldProps: { hideOpacity: true, showHexAlpha: false, }, - isTransformation: false, transformationGroup: "colorReplace", helpText: - "Select the target color to replace with.", - }, { + "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.", + "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, @@ -1765,19 +1766,19 @@ export const transformationSchema: TransformationSchema[] = [ }, }, { - label: "From Color", - examples: ["FFFFFF", "FF0000"], - name: "fromColor", + label: "To Color", + name: "toColor", fieldType: "color-picker", - isTransformation: false, - fieldProps:{ + examples: ["FFFFFF", "FF0000"], + fieldProps: { hideOpacity: true, showHexAlpha: false, }, + isTransformation: false, transformationGroup: "colorReplace", helpText: - "Select the source color you want to replace (optional - if not specified, dominant color will be replaced).", - }, + "Select the target color to replace with.", + }, ], }, { @@ -1788,25 +1789,22 @@ export const transformationSchema: TransformationSchema[] = [ 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.union([ - z.literal("auto"), - z.coerce + .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(), - }) + .optional(), + }) .refine( (val) => { if ( @@ -1838,13 +1836,12 @@ export const transformationSchema: TransformationSchema[] = [ isTransformation: false, transformationGroup: "sharpen", helpText: - "Sharpen the image to highlight the edges and finer details within an image. Use a threshold between 1 and 99.", + "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: { - autoOption: true, - defaultValue: "auto", min: 1, max: 99, step: 1, + defaultValue: 50, }, isVisible: ({ sharpenEnabled }) => sharpenEnabled === true, }, @@ -1855,374 +1852,374 @@ export const transformationSchema: TransformationSchema[] = [ { key: "ai", name: "AI Transformations", - items: [ { - key: "ai-removedotbg", - name: "Remove Background using Remove.bg", - description: - "Remove the background of the image using Remove.bg (external service). This isolates the subject and makes the background transparent.", - docsLink: - "https://imagekit.io/docs/ai-transformations#background-removal-e-removedotbg", - defaultTransformation: {}, - schema: z - .object({ - removedotbg: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) - .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: [ + items: [{ + key: "ai-removedotbg", + name: "Remove Background using Remove.bg", + description: + "Remove the background of the image using Remove.bg (external service). This isolates the subject and makes the background transparent.", + docsLink: + "https://imagekit.io/docs/ai-transformations#background-removal-e-removedotbg", + defaultTransformation: {}, + schema: z + .object({ + removedotbg: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false + }, { - label: "Remove Background using Remove.bg", - name: "removedotbg", - fieldType: "switch", - isTransformation: true, - transformationKey: "aiRemoveBackgroundExternal", - helpText: - "Toggle to remove the background using Remove.bg. Processing may take a few seconds depending on image complexity.", + message: "At least one value is required", + path: [], }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Remove Background using Remove.bg to {imageList.length} items. ", + ), + transformations: [ + { + label: "Remove Background using Remove.bg", + name: "removedotbg", + fieldType: "switch", + isTransformation: true, + transformationKey: "aiRemoveBackgroundExternal", + helpText: + "Toggle to remove the background using Remove.bg. Processing may take a few seconds depending on image complexity.", }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Remove Background using Remove.bg to {imageList.length} items. ", }, - { - key: "ai-bgremove", - name: "Remove Background using ImageKit AI", - description: - "Remove the background using ImageKit's built-in background removal model. This method is cost-effective compared to Remove.bg.", - docsLink: - "https://imagekit.io/docs/ai-transformations#imagekit-background-removal-e-bgremove", - defaultTransformation: {}, - schema: z - .object({ - bgremove: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) - .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: [ + }, + { + key: "ai-bgremove", + name: "Remove Background using ImageKit AI", + description: + "Remove the background using ImageKit's built-in background removal model. This method is cost-effective compared to Remove.bg.", + docsLink: + "https://imagekit.io/docs/ai-transformations#imagekit-background-removal-e-bgremove", + defaultTransformation: {}, + schema: z + .object({ + bgremove: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false + }, { - label: "Remove Background using ImageKit AI", - name: "bgremove", - fieldType: "switch", - isTransformation: true, - transformationKey: "aiRemoveBackground", - helpText: - "Toggle to remove the background using ImageKit's own background removal.", + message: "At least one value is required", + path: [], }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Remove Background using ImageKit AI to {imageList.length} items. ", + ), + transformations: [ + { + label: "Remove Background using ImageKit AI", + name: "bgremove", + fieldType: "switch", + isTransformation: true, + transformationKey: "aiRemoveBackground", + helpText: + "Toggle to remove the background using ImageKit's own background removal.", }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Remove Background using ImageKit AI to {imageList.length} items. ", }, - { - key: "ai-changebg", - name: "Change Background", - description: - "Replace the background of the image with a new scene described by a text prompt. Use AI to generate a new background.", - docsLink: - "https://imagekit.io/docs/ai-transformations#change-background-e-changebg", - defaultTransformation: {}, - schema: z - .object({ - changebg: z.string().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: [ + }, + { + key: "ai-changebg", + name: "Change Background", + description: + "Replace the background of the image with a new scene described by a text prompt. Use AI to generate a new background.", + docsLink: + "https://imagekit.io/docs/ai-transformations#change-background-e-changebg", + defaultTransformation: {}, + schema: z + .object({ + changebg: z.string().optional(), + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false + }, { - label: "Change Background", - name: "changebg", - fieldType: "input", - isTransformation: true, - transformationKey: "aiChangeBackground", - transformationGroup: "aiChangeBackground", - helpText: "Enter a descriptive prompt for the new background.", - examples: ["snowy mountains", "sunset beach"], + message: "At least one value is required", + path: [], }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Change Background to {imageList.length} items. ", + ), + transformations: [ + { + label: "Change Background", + name: "changebg", + fieldType: "input", + isTransformation: true, + transformationKey: "aiChangeBackground", + transformationGroup: "aiChangeBackground", + helpText: "Enter a descriptive prompt for the new background.", + examples: ["snowy mountains", "sunset beach"], }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Change Background to {imageList.length} items. ", }, - { - key: "ai-edit", - name: "Edit Image using AI", - description: - "Use AI to modify the image based on a descriptive prompt. Add or remove objects or alter colors and textures.", - docsLink: - "https://imagekit.io/docs/ai-transformations#edit-image-e-edit", - defaultTransformation: {}, - schema: z - .object({ - edit: z.string().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: [ + }, + { + key: "ai-edit", + name: "Edit Image using AI", + description: + "Use AI to modify the image based on a descriptive prompt. Add or remove objects or alter colors and textures.", + docsLink: + "https://imagekit.io/docs/ai-transformations#edit-image-e-edit", + defaultTransformation: {}, + schema: z + .object({ + edit: z.string().optional(), + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false + }, { - label: "Edit Image using AI", - name: "edit", - fieldType: "input", - isTransformation: true, - transformationKey: "e-edit-prompt", - helpText: "Enter a prompt describing how to edit the image.", - examples: ["add sunglasses", "make the sky blue"], + message: "At least one value is required", + path: [], }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Edit Image using AI to {imageList.length} items. ", + ), + transformations: [ + { + label: "Edit Image using AI", + name: "edit", + fieldType: "input", + isTransformation: true, + transformationKey: "e-edit-prompt", + helpText: "Enter a prompt describing how to edit the image.", + examples: ["add sunglasses", "make the sky blue"], }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Edit Image using AI to {imageList.length} items. ", }, - { - key: "ai-dropshadow", - name: "Drop Shadow", - description: - "Add a realistic AI-generated drop shadow around the object. Requires a transparent background; remove the background first for best results.", - docsLink: - "https://imagekit.io/docs/ai-transformations#ai-drop-shadow-e-dropshadow", - defaultTransformation: {}, - schema: z - .object({ - dropshadow: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) - .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: [ + }, + { + key: "ai-dropshadow", + name: "Drop Shadow", + description: + "Add a realistic AI-generated drop shadow around the object. Requires a transparent background; remove the background first for best results.", + docsLink: + "https://imagekit.io/docs/ai-transformations#ai-drop-shadow-e-dropshadow", + defaultTransformation: {}, + schema: z + .object({ + dropshadow: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false + }, { - label: "Drop Shadow", - name: "dropshadow", - fieldType: "switch", - isTransformation: true, - transformationKey: "aiDropShadow", - helpText: - "Toggle to add an AI-generated drop shadow. Requires transparent background.", + message: "At least one value is required", + path: [], }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Drop Shadow to {imageList.length} items. ", + ), + transformations: [ + { + label: "Drop Shadow", + name: "dropshadow", + fieldType: "switch", + isTransformation: true, + transformationKey: "aiDropShadow", + helpText: + "Toggle to add an AI-generated drop shadow. Requires transparent background.", }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Drop Shadow to {imageList.length} items. ", }, - { - key: "ai-retouch", - name: "Retouch", - description: "Improve the quality of the image using AI retouching.", - docsLink: - "https://imagekit.io/docs/ai-transformations#retouch-e-retouch", - defaultTransformation: {}, - schema: z - .object({ - retouch: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) - .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: [ + }, + { + key: "ai-retouch", + name: "Retouch", + description: "Improve the quality of the image using AI retouching.", + docsLink: + "https://imagekit.io/docs/ai-transformations#retouch-e-retouch", + defaultTransformation: {}, + schema: z + .object({ + retouch: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false + }, { - label: "Retouch", - name: "retouch", - fieldType: "switch", - isTransformation: true, - transformationKey: "aiRetouch", - helpText: - "Toggle to apply AI retouching and enhance image quality.", + message: "At least one value is required", + path: [], }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Retouch to {imageList.length} items. ", + ), + transformations: [ + { + label: "Retouch", + name: "retouch", + fieldType: "switch", + isTransformation: true, + transformationKey: "aiRetouch", + helpText: + "Toggle to apply AI retouching and enhance image quality.", }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Retouch to {imageList.length} items. ", }, - { - key: "ai-upscale", - name: "Upscale", - description: - "Increase the resolution of low-resolution images using AI upscaling. The output can be up to 16 MP.", - docsLink: - "https://imagekit.io/docs/ai-transformations#upscale-e-upscale", - defaultTransformation: {}, - schema: z - .object({ - upscale: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) - .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: [ + }, + { + key: "ai-upscale", + name: "Upscale", + description: + "Increase the resolution of low-resolution images using AI upscaling. The output can be up to 16 MP.", + docsLink: + "https://imagekit.io/docs/ai-transformations#upscale-e-upscale", + defaultTransformation: {}, + schema: z + .object({ + upscale: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false + }, { - label: "Upscale", - name: "upscale", - fieldType: "switch", - isTransformation: true, - transformationKey: "aiUpscale", - helpText: - "Toggle to increase resolution of the image using AI upscaling (max 16 MP input).", + message: "At least one value is required", + path: [], }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Upscale to {imageList.length} items. ", + ), + transformations: [ + { + label: "Upscale", + name: "upscale", + fieldType: "switch", + isTransformation: true, + transformationKey: "aiUpscale", + helpText: + "Toggle to increase resolution of the image using AI upscaling (max 16 MP input).", }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Upscale to {imageList.length} items. ", }, - { - key: "ai-genvar", - name: "Generate Variations", - description: - "Create a new variation of the original image using AI, altering colors and textures while preserving the structure.", - docsLink: - "https://imagekit.io/docs/ai-transformations#generate-variations-of-an-image-e-genvar", - defaultTransformation: {}, - schema: z - .object({ - genvar: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) - .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: [ + }, + { + key: "ai-genvar", + name: "Generate Variations", + description: + "Create a new variation of the original image using AI, altering colors and textures while preserving the structure.", + docsLink: + "https://imagekit.io/docs/ai-transformations#generate-variations-of-an-image-e-genvar", + defaultTransformation: {}, + schema: z + .object({ + genvar: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false + }, { - label: "Generate Variations", - name: "genvar", - fieldType: "switch", - isTransformation: true, - transformationKey: "aiVariation", - helpText: - "Toggle to generate a new variation of the image using AI.", + message: "At least one value is required", + path: [], }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to generate variations of {imageList.length} items. ", + ), + transformations: [ + { + label: "Generate Variations", + name: "genvar", + fieldType: "switch", + isTransformation: true, + transformationKey: "aiVariation", + helpText: + "Toggle to generate a new variation of the image using AI.", }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to generate variations of {imageList.length} items. ", }, + }, ], }, { @@ -2704,11 +2701,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.", @@ -2730,18 +2735,15 @@ export const transformationSchema: TransformationSchema[] = [ invalid_type_error: "Should be a boolean.", }) .optional(), - sharpen: z - .union([ - z.literal("auto"), - z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .int() - .min(1) - .max(99), - ]) - .optional(), + sharpen: + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .int() + .min(1) + .max(99) + .optional(), }) .refine( (val) => { @@ -2902,16 +2904,32 @@ 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", @@ -2975,7 +2993,7 @@ export const transformationSchema: TransformationSchema[] = [ label: "Sharpen Overlay", name: "sharpenEnabled", fieldType: "switch", - isTransformation: true, + isTransformation: false, transformationKey: "sharpenEnabled", transformationGroup: "imageLayer", helpText: @@ -2988,15 +3006,14 @@ export const transformationSchema: TransformationSchema[] = [ label: "Sharpen Threshold", name: "sharpen", fieldType: "slider", - isTransformation: true, + isTransformation: false, transformationKey: "sharpen", transformationGroup: "imageLayer", helpText: - "Sharpen the overlay image. Use a threshold between 1 and 99 (or auto).", + "Sharpen the overlay image. Control the intensity of this effect using a threshold value between 1% and 99%.", fieldProps: { - autoOption: true, - defaultValue: "auto", min: 1, + defaultValue: 50, max: 99, step: 1, }, @@ -3370,9 +3387,8 @@ 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) { @@ -3383,11 +3399,10 @@ export const transformationFormatters: Record< overlayTransform.blur = values.blur } - // Sharpen overlay (same semantics as base-image sharpen: auto => empty string) if (values.sharpenEnabled === true) { - if (values.sharpen === "auto") { + if (values.sharpen === 50) { overlayTransform.sharpen = "" - } else if (typeof values.sharpen === "number") { + } else { overlayTransform.sharpen = values.sharpen } } @@ -3474,7 +3489,7 @@ export const transformationFormatters: Record< if (!toColor || toColor === "") return const params: string[] = [] - + // Remove # from colors if present const cleanToColor = (toColor as string).replace(/^#/, "") params.push(cleanToColor) @@ -3486,8 +3501,8 @@ export const transformationFormatters: Record< const cleanFromColor = (fromColor as string).replace(/^#/, "") params.push(cleanFromColor) } - - + + transforms.cr = params.join("_") }, border: (values, transforms) => { @@ -3495,17 +3510,17 @@ export const transformationFormatters: Record< borderWidth?: number borderColor?: string } - if(!borderWidth || !borderColor) return + if (!borderWidth || !borderColor) return const cleanBorderColor = borderColor.replace(/^#/, "") transforms.b = `${borderWidth}_${cleanBorderColor}` }, sharpen: (values, transforms) => { const { sharpenEnabled, sharpen } = values as { sharpenEnabled?: boolean - sharpen?: "auto" | number + sharpen: number } - if(!sharpenEnabled) return - if(sharpen === "auto") { + if (!sharpenEnabled) return + if (sharpen === 50) { transforms.sharpen = "" } else { transforms.sharpen = sharpen From a5e7fefc3b7b4a6797b8f1345d906d331bb6b19e Mon Sep 17 00:00:00 2001 From: Piyush Aryan <105715267+piyushryn@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:54:08 +0530 Subject: [PATCH 16/48] Update packages/imagekit-editor-dev/src/schema/index.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/imagekit-editor-dev/src/schema/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index d404cd6..0e3b477 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -1750,7 +1750,7 @@ export const transformationSchema: TransformationSchema[] = [ helpText: "Select the source color you want to replace (optional - if not specified, dominant color will be replaced).", }, - { + { label: "Tolerance", name: "tolerance", fieldType: "slider", From cc2aeb3b3c15cbeabf323d59bf3235f8d336eaa1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:24:31 +0000 Subject: [PATCH 17/48] Initial plan From 87a681a39b8e09e04d646d7453ff3beaae0e94ea Mon Sep 17 00:00:00 2001 From: Piyush Aryan <105715267+piyushryn@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:55:30 +0530 Subject: [PATCH 18/48] Update packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/components/common/ColorPickerField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx b/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx index afb84c4..278de52 100644 --- a/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx +++ b/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx @@ -109,7 +109,7 @@ const ColorPickerField = ({ hideInputs hideAdvancedSliders hideColorGuide - // pass color picker props @ts-ignore + // @ts-expect-error - fieldProps may include props not declared in ColorPickerProps, but they are intentionally forwarded {...fieldProps} /> From d04269d25c04d14beb57462662e254563052d173 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:27:19 +0000 Subject: [PATCH 19/48] fix: correct formatting for items array opening bracket at line 1855 Co-authored-by: piyushryn <105715267+piyushryn@users.noreply.github.com> --- .../imagekit-editor-dev/src/schema/index.ts | 806 +++++++++--------- 1 file changed, 406 insertions(+), 400 deletions(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 0e3b477..c8885bb 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -1580,7 +1580,8 @@ export const transformationSchema: TransformationSchema[] = [ 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", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#border---b", defaultTransformation: {}, schema: z .object({ @@ -1591,7 +1592,7 @@ export const transformationSchema: TransformationSchema[] = [ .int() .min(1) .optional(), - borderColor: colorValidator + borderColor: colorValidator, }) .refine( (val) => { @@ -1614,8 +1615,7 @@ export const transformationSchema: TransformationSchema[] = [ fieldType: "input", isTransformation: false, transformationGroup: "border", - helpText: - "Enter a border width", + helpText: "Enter a border width", fieldProps: { defaultValue: 1, min: 1, @@ -1629,8 +1629,7 @@ export const transformationSchema: TransformationSchema[] = [ fieldType: "color-picker", isTransformation: false, transformationGroup: "border", - helpText: - "Select the color of the border.", + helpText: "Select the color of the border.", fieldProps: { hideOpacity: true, showHexAlpha: false, @@ -1644,7 +1643,8 @@ export const transformationSchema: TransformationSchema[] = [ 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", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#trim-edges---t", defaultTransformation: {}, schema: z .object({ @@ -1653,15 +1653,14 @@ export const transformationSchema: TransformationSchema[] = [ 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(), + trim: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .int() + .min(1) + .max(99) + .optional(), }) .refine( (val) => { @@ -1710,7 +1709,8 @@ export const transformationSchema: TransformationSchema[] = [ 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", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#color-replace---cr", defaultTransformation: {}, schema: z .object({ @@ -1776,8 +1776,7 @@ export const transformationSchema: TransformationSchema[] = [ }, isTransformation: false, transformationGroup: "colorReplace", - helpText: - "Select the target color to replace with.", + helpText: "Select the target color to replace with.", }, ], }, @@ -1786,7 +1785,8 @@ export const transformationSchema: TransformationSchema[] = [ 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", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#sharpen---e-sharpen", defaultTransformation: {}, schema: z .object({ @@ -1795,15 +1795,14 @@ export const transformationSchema: TransformationSchema[] = [ 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(), + sharpen: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .int() + .min(1) + .max(99) + .optional(), }) .refine( (val) => { @@ -1852,374 +1851,375 @@ export const transformationSchema: TransformationSchema[] = [ { key: "ai", name: "AI Transformations", - items: [{ - key: "ai-removedotbg", - name: "Remove Background using Remove.bg", - description: - "Remove the background of the image using Remove.bg (external service). This isolates the subject and makes the background transparent.", - docsLink: - "https://imagekit.io/docs/ai-transformations#background-removal-e-removedotbg", - defaultTransformation: {}, - schema: z - .object({ - removedotbg: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) - .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: "Remove Background using Remove.bg", - name: "removedotbg", - fieldType: "switch", - isTransformation: true, - transformationKey: "aiRemoveBackgroundExternal", - helpText: - "Toggle to remove the background using Remove.bg. Processing may take a few seconds depending on image complexity.", + items: [ + { + key: "ai-removedotbg", + name: "Remove Background using Remove.bg", + description: + "Remove the background of the image using Remove.bg (external service). This isolates the subject and makes the background transparent.", + docsLink: + "https://imagekit.io/docs/ai-transformations#background-removal-e-removedotbg", + defaultTransformation: {}, + schema: z + .object({ + removedotbg: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .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: "Remove Background using Remove.bg", + name: "removedotbg", + fieldType: "switch", + isTransformation: true, + transformationKey: "aiRemoveBackgroundExternal", + helpText: + "Toggle to remove the background using Remove.bg. Processing may take a few seconds depending on image complexity.", + }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Remove Background using Remove.bg to {imageList.length} items. ", }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Remove Background using Remove.bg to {imageList.length} items. ", }, - }, - { - key: "ai-bgremove", - name: "Remove Background using ImageKit AI", - description: - "Remove the background using ImageKit's built-in background removal model. This method is cost-effective compared to Remove.bg.", - docsLink: - "https://imagekit.io/docs/ai-transformations#imagekit-background-removal-e-bgremove", - defaultTransformation: {}, - schema: z - .object({ - bgremove: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) - .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: "Remove Background using ImageKit AI", - name: "bgremove", - fieldType: "switch", - isTransformation: true, - transformationKey: "aiRemoveBackground", - helpText: - "Toggle to remove the background using ImageKit's own background removal.", + { + key: "ai-bgremove", + name: "Remove Background using ImageKit AI", + description: + "Remove the background using ImageKit's built-in background removal model. This method is cost-effective compared to Remove.bg.", + docsLink: + "https://imagekit.io/docs/ai-transformations#imagekit-background-removal-e-bgremove", + defaultTransformation: {}, + schema: z + .object({ + bgremove: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .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: "Remove Background using ImageKit AI", + name: "bgremove", + fieldType: "switch", + isTransformation: true, + transformationKey: "aiRemoveBackground", + helpText: + "Toggle to remove the background using ImageKit's own background removal.", + }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Remove Background using ImageKit AI to {imageList.length} items. ", }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Remove Background using ImageKit AI to {imageList.length} items. ", }, - }, - { - key: "ai-changebg", - name: "Change Background", - description: - "Replace the background of the image with a new scene described by a text prompt. Use AI to generate a new background.", - docsLink: - "https://imagekit.io/docs/ai-transformations#change-background-e-changebg", - defaultTransformation: {}, - schema: z - .object({ - changebg: z.string().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: "Change Background", - name: "changebg", - fieldType: "input", - isTransformation: true, - transformationKey: "aiChangeBackground", - transformationGroup: "aiChangeBackground", - helpText: "Enter a descriptive prompt for the new background.", - examples: ["snowy mountains", "sunset beach"], + { + key: "ai-changebg", + name: "Change Background", + description: + "Replace the background of the image with a new scene described by a text prompt. Use AI to generate a new background.", + docsLink: + "https://imagekit.io/docs/ai-transformations#change-background-e-changebg", + defaultTransformation: {}, + schema: z + .object({ + changebg: z.string().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: "Change Background", + name: "changebg", + fieldType: "input", + isTransformation: true, + transformationKey: "aiChangeBackground", + transformationGroup: "aiChangeBackground", + helpText: "Enter a descriptive prompt for the new background.", + examples: ["snowy mountains", "sunset beach"], + }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Change Background to {imageList.length} items. ", }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Change Background to {imageList.length} items. ", }, - }, - { - key: "ai-edit", - name: "Edit Image using AI", - description: - "Use AI to modify the image based on a descriptive prompt. Add or remove objects or alter colors and textures.", - docsLink: - "https://imagekit.io/docs/ai-transformations#edit-image-e-edit", - defaultTransformation: {}, - schema: z - .object({ - edit: z.string().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: "Edit Image using AI", - name: "edit", - fieldType: "input", - isTransformation: true, - transformationKey: "e-edit-prompt", - helpText: "Enter a prompt describing how to edit the image.", - examples: ["add sunglasses", "make the sky blue"], + { + key: "ai-edit", + name: "Edit Image using AI", + description: + "Use AI to modify the image based on a descriptive prompt. Add or remove objects or alter colors and textures.", + docsLink: + "https://imagekit.io/docs/ai-transformations#edit-image-e-edit", + defaultTransformation: {}, + schema: z + .object({ + edit: z.string().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: "Edit Image using AI", + name: "edit", + fieldType: "input", + isTransformation: true, + transformationKey: "e-edit-prompt", + helpText: "Enter a prompt describing how to edit the image.", + examples: ["add sunglasses", "make the sky blue"], + }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Edit Image using AI to {imageList.length} items. ", }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Edit Image using AI to {imageList.length} items. ", }, - }, - { - key: "ai-dropshadow", - name: "Drop Shadow", - description: - "Add a realistic AI-generated drop shadow around the object. Requires a transparent background; remove the background first for best results.", - docsLink: - "https://imagekit.io/docs/ai-transformations#ai-drop-shadow-e-dropshadow", - defaultTransformation: {}, - schema: z - .object({ - dropshadow: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) - .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: "Drop Shadow", - name: "dropshadow", - fieldType: "switch", - isTransformation: true, - transformationKey: "aiDropShadow", - helpText: - "Toggle to add an AI-generated drop shadow. Requires transparent background.", + { + key: "ai-dropshadow", + name: "Drop Shadow", + description: + "Add a realistic AI-generated drop shadow around the object. Requires a transparent background; remove the background first for best results.", + docsLink: + "https://imagekit.io/docs/ai-transformations#ai-drop-shadow-e-dropshadow", + defaultTransformation: {}, + schema: z + .object({ + dropshadow: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .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: "Drop Shadow", + name: "dropshadow", + fieldType: "switch", + isTransformation: true, + transformationKey: "aiDropShadow", + helpText: + "Toggle to add an AI-generated drop shadow. Requires transparent background.", + }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Drop Shadow to {imageList.length} items. ", }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Drop Shadow to {imageList.length} items. ", }, - }, - { - key: "ai-retouch", - name: "Retouch", - description: "Improve the quality of the image using AI retouching.", - docsLink: - "https://imagekit.io/docs/ai-transformations#retouch-e-retouch", - defaultTransformation: {}, - schema: z - .object({ - retouch: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) - .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: "Retouch", - name: "retouch", - fieldType: "switch", - isTransformation: true, - transformationKey: "aiRetouch", - helpText: - "Toggle to apply AI retouching and enhance image quality.", + { + key: "ai-retouch", + name: "Retouch", + description: "Improve the quality of the image using AI retouching.", + docsLink: + "https://imagekit.io/docs/ai-transformations#retouch-e-retouch", + defaultTransformation: {}, + schema: z + .object({ + retouch: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .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: "Retouch", + name: "retouch", + fieldType: "switch", + isTransformation: true, + transformationKey: "aiRetouch", + helpText: + "Toggle to apply AI retouching and enhance image quality.", + }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Retouch to {imageList.length} items. ", }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Retouch to {imageList.length} items. ", }, - }, - { - key: "ai-upscale", - name: "Upscale", - description: - "Increase the resolution of low-resolution images using AI upscaling. The output can be up to 16 MP.", - docsLink: - "https://imagekit.io/docs/ai-transformations#upscale-e-upscale", - defaultTransformation: {}, - schema: z - .object({ - upscale: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) - .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: "Upscale", - name: "upscale", - fieldType: "switch", - isTransformation: true, - transformationKey: "aiUpscale", - helpText: - "Toggle to increase resolution of the image using AI upscaling (max 16 MP input).", + { + key: "ai-upscale", + name: "Upscale", + description: + "Increase the resolution of low-resolution images using AI upscaling. The output can be up to 16 MP.", + docsLink: + "https://imagekit.io/docs/ai-transformations#upscale-e-upscale", + defaultTransformation: {}, + schema: z + .object({ + upscale: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .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: "Upscale", + name: "upscale", + fieldType: "switch", + isTransformation: true, + transformationKey: "aiUpscale", + helpText: + "Toggle to increase resolution of the image using AI upscaling (max 16 MP input).", + }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to apply Upscale to {imageList.length} items. ", }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Upscale to {imageList.length} items. ", }, - }, - { - key: "ai-genvar", - name: "Generate Variations", - description: - "Create a new variation of the original image using AI, altering colors and textures while preserving the structure.", - docsLink: - "https://imagekit.io/docs/ai-transformations#generate-variations-of-an-image-e-genvar", - defaultTransformation: {}, - schema: z - .object({ - genvar: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) - .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: "Generate Variations", - name: "genvar", - fieldType: "switch", - isTransformation: true, - transformationKey: "aiVariation", - helpText: - "Toggle to generate a new variation of the image using AI.", + { + key: "ai-genvar", + name: "Generate Variations", + description: + "Create a new variation of the original image using AI, altering colors and textures while preserving the structure.", + docsLink: + "https://imagekit.io/docs/ai-transformations#generate-variations-of-an-image-e-genvar", + defaultTransformation: {}, + schema: z + .object({ + genvar: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .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: "Generate Variations", + name: "genvar", + fieldType: "switch", + isTransformation: true, + transformationKey: "aiVariation", + helpText: + "Toggle to generate a new variation of the image using AI.", + }, + ], + warning: { + heading: "This action consumes AI credits.", + message: + "You are about to generate variations of {imageList.length} items. ", }, - ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to generate variations of {imageList.length} items. ", }, - }, ], }, { @@ -2735,15 +2735,14 @@ export const transformationSchema: TransformationSchema[] = [ 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(), + sharpen: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .int() + .min(1) + .max(99) + .optional(), }) .refine( (val) => { @@ -2921,7 +2920,8 @@ export const transformationSchema: TransformationSchema[] = [ isTransformation: true, transformationKey: "trimThreshold", transformationGroup: "imageLayer", - helpText: "Control the intensity of this effect using a threshold value between 1% and 99%.", + helpText: + "Control the intensity of this effect using a threshold value between 1% and 99%.", fieldProps: { min: 1, max: 99, @@ -2970,8 +2970,7 @@ export const transformationSchema: TransformationSchema[] = [ fieldProps: { defaultValue: 0, }, - helpText: - "Enter the width of the border of the overlay image.", + helpText: "Enter the width of the border of the overlay image.", }, { label: "Border Color", @@ -2980,9 +2979,8 @@ export const transformationSchema: TransformationSchema[] = [ isTransformation: false, transformationKey: "borderColor", transformationGroup: "imageLayer", - isVisible: ({ borderWidth }) => borderWidth as number > 0, - helpText: - "Select the color of the border of the overlay image.", + isVisible: ({ borderWidth }) => (borderWidth as number) > 0, + helpText: "Select the color of the border of the overlay image.", fieldProps: { hideOpacity: true, showHexAlpha: false, @@ -3018,7 +3016,7 @@ export const transformationSchema: TransformationSchema[] = [ step: 1, }, isVisible: ({ sharpenEnabled }) => sharpenEnabled === true, - } + }, ], }, ], @@ -3387,7 +3385,10 @@ export const transformationFormatters: Record< if (values.rotation) { overlayTransform.rotation = values.rotation } - if (values.trimEnabled === true && typeof values.trimThreshold === "number") { + if ( + values.trimEnabled === true && + typeof values.trimThreshold === "number" + ) { overlayTransform.t = values.trimThreshold } @@ -3406,7 +3407,13 @@ export const transformationFormatters: Record< overlayTransform.sharpen = values.sharpen } } - if ((values.borderWidth && typeof values.borderWidth === "number" && values.borderWidth > 0) && (values.borderColor && typeof values.borderColor === "string")) { + if ( + values.borderWidth && + typeof values.borderWidth === "number" && + values.borderWidth > 0 && + values.borderColor && + typeof values.borderColor === "string" + ) { overlayTransform.b = `${values.borderWidth}_${values.borderColor.replace(/^#/, "")}` } @@ -3502,7 +3509,6 @@ export const transformationFormatters: Record< params.push(cleanFromColor) } - transforms.cr = params.join("_") }, border: (values, transforms) => { From 85364a6ce5ab87e582c24405bc0fb4fbbb651e33 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Wed, 28 Jan 2026 11:16:07 +0530 Subject: [PATCH 20/48] Remove unused imports in PaddingInput component --- .../imagekit-editor-dev/src/components/common/PaddingInput.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx index fa33481..f2f93e6 100644 --- a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx @@ -8,7 +8,6 @@ import { InputGroup, InputLeftElement, IconButton, - IconButtonProps, useColorModeValue, Tooltip, } from "@chakra-ui/react" @@ -20,7 +19,6 @@ import { LuArrowRightToLine } from "@react-icons/all-files/lu/LuArrowRightToLine import { LuArrowUpToLine } from "@react-icons/all-files/lu/LuArrowUpToLine" import { LuArrowDownToLine } from "@react-icons/all-files/lu/LuArrowDownToLine" import { TbBoxPadding } from "@react-icons/all-files/tb/TbBoxPadding" -import { MdOutlinePadding } from "@react-icons/all-files/md/MdOutlinePadding" import { FieldErrors } from "react-hook-form" type PaddingInputFieldProps = { From 2054687604b459ec6d3eb7921ad80a564241e118 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Wed, 28 Jan 2026 12:02:40 +0530 Subject: [PATCH 21/48] Add gradient effect support in base and overlay images --- .../src/components/common/GradientPicker.tsx | 341 ++++++++++++++++++ .../sidebar/transformation-config-sidebar.tsx | 8 + .../imagekit-editor-dev/src/schema/index.ts | 135 +++++++ 3 files changed, 484 insertions(+) create mode 100644 packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx diff --git a/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx b/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx new file mode 100644 index 0000000..29b0e24 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx @@ -0,0 +1,341 @@ +import { + Flex, + Input, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + FormLabel, + Box, +} from "@chakra-ui/react"; +import { memo, useEffect, useState, useMemo } from "react"; +import ColorPicker, { useColorPicker } from "react-best-gradient-color-picker"; +import { useDebounce } from "../../hooks/useDebounce"; +import AnchorField from "./AnchorField"; +import RadioCardField from "./RadioCardField"; +import { TbAngle } from "@react-icons/all-files/tb/TbAngle"; +import { BsArrowsMove } from "@react-icons/all-files/bs/BsArrowsMove"; + +export type GradientPickerState = { + from: string; + to: string; + direction: number | string; + stopPoint: number; +}; + +type DirectionMode = "direction" | "degrees"; + +function rgbaToHex(rgba: string): string { + const parts = rgba.match(/[\d.]+/g)?.map(Number) ?? []; + + if (parts.length < 3) return "#000000"; + + const [r, g, b, a] = parts; + + const clamp8 = (v: number) => Math.max(0, Math.min(255, v)); + + const rgbHex = [r, g, b] + .map(clamp8) + .map((v) => v.toString(16).padStart(2, "0")) + .join(""); + + if (a === undefined) { + return `#${rgbHex}`; + } + const alphaDec = a > 1 ? a / 100 : a; + const alphaHex = Math.round(alphaDec * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `#${rgbHex}${alphaHex}`; +} + +const GradientPickerField = ({ + fieldName, + setValue, + value, +}: { + fieldName: string; + setValue: (name: string, value: GradientPickerState | string) => void; + value?: GradientPickerState | null; +}) => { + function getLinearGradientString(value: GradientPickerState): string { + let direction = ""; + if (typeof value.direction === "number") { + direction = `${value.direction}deg`; + } else { + direction = `to ${value.direction.split("_").join(" ")}`; + } + return `linear-gradient(${direction}, ${value.from} 0%, ${value.to} ${value.stopPoint}%)`; + } + + const [localValue, setLocalValue] = useState( + value ?? { + from: "#FFFFFFFF", + to: "#00000000", + direction: "bottom", + stopPoint: 100, + }, + ); + const [directionMode, setDirectionMode] = + useState("direction"); + + const [gradient, setGradient] = useState( + getLinearGradientString(localValue), + ); + + const { getGradientObject } = useColorPicker(gradient, setGradient); + + function getAngleValue(): number { + if (typeof localValue.direction === "number") { + return localValue.direction; + } + const direction = localValue.direction as string; + const directionMap: Record = { + top: 0, + top_right: 45, + right: 90, + bottom_right: 135, + bottom: 180, + bottom_left: 225, + left: 270, + top_left: 315, + }; + return directionMap[direction] || 180; + } + + function getDirectionValue(): string { + if (typeof localValue.direction === "string") { + return localValue.direction; + } + const angle = localValue.direction as number; + const nearestAngle = Math.round(angle / 45) * 45; + const angleMap: Record = { + 0: "top", + 45: "top_right", + 90: "right", + 135: "bottom_right", + 180: "bottom", + 225: "bottom_left", + 270: "left", + 315: "top_left", + }; + return angleMap[nearestAngle] || "bottom"; + } + + const debouncedValue = useDebounce(localValue, 500); + + function handleGradientChange(gradientVal: string) { + const gradientObj = getGradientObject(gradientVal); + console.log("Gradient Object:", gradientObj); + console.log("Gradient String:", gradientVal); + + if (!gradientObj || !gradientObj.isGradient) return; + + const { colors } = gradientObj; + if (colors.length !== 2) return; + if (colors[0].left !== 0) return; + setGradient(gradientVal); + + const fromColor = rgbaToHex(colors[0].value).toUpperCase(); + const toColor = rgbaToHex(colors[1].value).toUpperCase(); + const stopPoint = colors[1].left; + console.log({ stopPoint, fromColor, toColor }); + + if ( + fromColor !== localValue.from || + toColor !== localValue.to || + stopPoint !== localValue.stopPoint + ) { + setLocalValue({ + ...localValue, + from: fromColor, + to: toColor, + stopPoint: stopPoint, + }); + } + } + + function applyGradientInputChanges(newValue: GradientPickerState) { + const gradientString = getLinearGradientString(newValue); + setGradient(gradientString); + setLocalValue(newValue); + } + + useEffect(() => { + setValue(fieldName, debouncedValue); + }, [debouncedValue, fieldName, setValue]); + + return ( + + + + + + + + + + + + + + + From Color + + { + const newValue = e.target.value; + if (newValue.match(/^#[0-9A-Fa-f]{0,8}$/)) { + applyGradientInputChanges({ ...localValue, from: newValue }); + } else if (newValue === "") { + applyGradientInputChanges({ ...localValue, from: "" }); + } + }} + borderColor="gray.200" + placeholder="#FFFFFF" + fontFamily="mono" + borderRadius="4px" + /> + + + + + To Color + + { + const newValue = e.target.value; + if (newValue.match(/^#[0-9A-Fa-f]{0,8}$/)) { + applyGradientInputChanges({ ...localValue, to: newValue }); + } else if (newValue === "") { + applyGradientInputChanges({ ...localValue, to: "" }); + } + }} + borderColor="gray.200" + placeholder="#FFFFFF" + fontFamily="mono" + borderRadius="4px" + /> + + + + + Linear Direction + + + { + setDirectionMode((val || "direction") as DirectionMode); + const newDirection = + val === "direction" ? getDirectionValue() : getAngleValue(); + applyGradientInputChanges({ + ...localValue, + direction: newDirection, + }); + }} + /> + + {directionMode === "direction" ? ( + { + applyGradientInputChanges({ ...localValue, direction: val }); + }} + positions={[ + "top", + "bottom", + "left", + "right", + "top_left", + "top_right", + "bottom_left", + "bottom_right", + ]} + /> + ) : ( + { + const newValue = e.target.value; + const intVal = newValue === "" ? 0 : Number(newValue); + if (intVal < 0 || intVal > 359) return; + applyGradientInputChanges({ ...localValue, direction: intVal }); + }} + borderColor="gray.200" + placeholder="#FFFFFF" + fontFamily="mono" + borderRadius="4px" + /> + )} + + + + + Stop Point (%) + + { + const newValue = e.target.value; + const intVal = newValue === "" ? 1 : Number(newValue); + if (intVal < 1 || intVal > 100) return; + applyGradientInputChanges({ + ...localValue, + stopPoint: intVal, + }); + }} + borderColor="gray.200" + placeholder="#FFFFFF" + fontFamily="mono" + borderRadius="4px" + /> + + + ); +}; + +export default memo(GradientPickerField); 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 6845ee4..a9a3286 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 @@ -50,6 +50,7 @@ import { isStepAligned } from "../../utils" import AnchorField from "../common/AnchorField" import CheckboxCardField from "../common/CheckboxCardField" import ColorPickerField from "../common/ColorPickerField" +import GradientPicker, { GradientPickerState } from "../common/GradientPicker" import RadioCardField from "../common/RadioCardField" import { SidebarBody } from "./sidebar-body" import { SidebarFooter } from "./sidebar-footer" @@ -540,6 +541,13 @@ export const TransformationConfigSidebar: React.FC = () => { setValue={setValue} /> ) : null} + {field.fieldType === "gradient-picker" ? ( + + ) : null} {field.fieldType === "anchor" ? ( { + console.log("Received val", 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: "Gradient", + name: "gradientSwitch", + fieldType: "switch", + isTransformation: false, + transformationGroup: "gradient", + helpText: "Toggle to add a gradient overlay over the image.", + }, + { + label: "Apply Gradient", + name: "gradient", + fieldType: "gradient-picker", + isTransformation: true, + transformationKey: "gradient", + transformationGroup: "gradient", + isVisible: ({ gradientSwitch }) => gradientSwitch === true, + fieldProps: { + defaultValue: { + from: "#FFFFFFFF", + to: "#00000000", + direction: "bottom", + stopPoint: 100, + } + } + }, + ], + }, { key: "adjust-blur", name: "Blur", @@ -2510,6 +2581,23 @@ export const transformationSchema: TransformationSchema[] = [ xc: z.string().optional(), yc: z.string().optional(), zoom: z.coerce.number().optional(), + gradientSwitch: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }), + gradient: z.object({ + from: z.string().optional(), + to: z.string().optional(), + direction: z.union([ + z.coerce.number({ + invalid_type_error: "Should be a number.", + }), + z.string(), + ]).optional(), + stopPoint: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).optional(), + }).optional(), }) .refine( (val) => { @@ -2884,6 +2972,31 @@ export const transformationSchema: TransformationSchema[] = [ defaultValue: "0", }, }, + { + label: "Gradient", + name: "gradientSwitch", + fieldType: "switch", + isTransformation: false, + transformationGroup: "imageLayer", + helpText: "Toggle to add a gradient overlay over the overlay image.", + }, + { + label: "Apply Gradient", + name: "gradient", + fieldType: "gradient-picker", + isTransformation: true, + transformationKey: "gradient", + transformationGroup: "imageLayer", + isVisible: ({ gradientSwitch }) => gradientSwitch === true, + fieldProps: { + defaultValue: { + from: "#FFFFFFFF", + to: "#00000000", + direction: "bottom", + stopPoint: 100, + } + } + }, ], }, ], @@ -3308,6 +3421,8 @@ export const transformationFormatters: Record< } } + transformationFormatters.gradient(values, overlayTransform) + if (zoom !== undefined && zoom !== null && !isNaN(Number(zoom)) && zoom !== 0) { overlayTransform.zoom = (zoom as number) / 100 } @@ -3365,4 +3480,24 @@ export const transformationFormatters: Record< transforms.rotation = "auto" } }, + gradient: (values, transforms) => { + const { gradient, gradientSwitch } = values as { gradient: GradientPickerState; gradientSwitch: boolean } + console.log('gradient formatter called', values) + if (gradientSwitch && gradient) { + const { from, to, direction, stopPoint } = gradient + const isDefaultGradient = (from.toUpperCase() === "#FFFFFFFF" || from.toUpperCase() === "#FFFFFF") && + (to.toUpperCase() === "#00000000") && + (direction === "bottom" || direction === 180) && + stopPoint === 100 + if (isDefaultGradient) { + transforms.gradient = "" + } else { + const fromColor = from.replace("#", "") + const toColor = to.replace("#", "") + const stopPointDecimal = stopPoint / 100 + let gradientStr = `ld-${direction}_from-${fromColor}_to-${toColor}_sp-${stopPointDecimal}` + transforms.gradient = gradientStr + } + } + } } From 9ce5aa20f03abee5921e3f777987ebf83a831e94 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Wed, 28 Jan 2026 13:04:13 +0530 Subject: [PATCH 22/48] Improve renaming mode UX --- .../sidebar/sortable-transformation-item.tsx | 262 ++++++++++-------- 1 file changed, 153 insertions(+), 109 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx b/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx index ba4122f..74ec731 100644 --- a/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx +++ b/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx @@ -9,30 +9,35 @@ import { Text, Tooltip, Input, - Tag -} from "@chakra-ui/react" -import { useState, useEffect, useRef } from "react" -import { useSortable } from "@dnd-kit/sortable" -import { CSS } from "@dnd-kit/utilities" -import { PiArrowDown } from "@react-icons/all-files/pi/PiArrowDown" -import { PiArrowUp } from "@react-icons/all-files/pi/PiArrowUp" -import { PiDotsSixVerticalBold } from "@react-icons/all-files/pi/PiDotsSixVerticalBold" -import { PiDotsThreeVertical } from "@react-icons/all-files/pi/PiDotsThreeVertical" -import { PiEye } from "@react-icons/all-files/pi/PiEye" -import { PiEyeSlash } from "@react-icons/all-files/pi/PiEyeSlash" -import { PiPencilSimple } from "@react-icons/all-files/pi/PiPencilSimple" -import { PiPlus } from "@react-icons/all-files/pi/PiPlus" -import { PiTrash } from "@react-icons/all-files/pi/PiTrash" -import { RxTransform } from "@react-icons/all-files/rx/RxTransform" -import { PiCopy } from "@react-icons/all-files/pi/PiCopy" -import { PiCursorText } from "@react-icons/all-files/pi/PiCursorText" -import { type Transformation, useEditorStore } from "../../store" -import Hover from "../common/Hover" + Tag, + Flex, + IconButton, + useColorModeValue, +} from "@chakra-ui/react"; +import { useState, useEffect, useRef } from "react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { PiArrowDown } from "@react-icons/all-files/pi/PiArrowDown"; +import { PiArrowUp } from "@react-icons/all-files/pi/PiArrowUp"; +import { PiDotsSixVerticalBold } from "@react-icons/all-files/pi/PiDotsSixVerticalBold"; +import { PiDotsThreeVertical } from "@react-icons/all-files/pi/PiDotsThreeVertical"; +import { PiEye } from "@react-icons/all-files/pi/PiEye"; +import { PiEyeSlash } from "@react-icons/all-files/pi/PiEyeSlash"; +import { PiPencilSimple } from "@react-icons/all-files/pi/PiPencilSimple"; +import { PiPlus } from "@react-icons/all-files/pi/PiPlus"; +import { PiTrash } from "@react-icons/all-files/pi/PiTrash"; +import { RxTransform } from "@react-icons/all-files/rx/RxTransform"; +import { PiCopy } from "@react-icons/all-files/pi/PiCopy"; +import { PiCursorText } from "@react-icons/all-files/pi/PiCursorText"; +import { RiCheckFill } from "@react-icons/all-files/ri/RiCheckFill"; +import { RiCloseFill } from "@react-icons/all-files/ri/RiCloseFill"; +import { type Transformation, useEditorStore } from "../../store"; +import Hover from "../common/Hover"; -export type TransformationPosition = "inplace" | number +export type TransformationPosition = "inplace" | number; interface SortableTransformationItemProps { - transformation: Transformation + transformation: Transformation; } export const SortableTransformationItem = ({ @@ -47,7 +52,7 @@ export const SortableTransformationItem = ({ isDragging, } = useSortable({ id: transformation.id, - }) + }); const { transformations, @@ -61,7 +66,7 @@ export const SortableTransformationItem = ({ _internalState, addTransformation, updateTransformation, - } = useEditorStore() + } = useEditorStore(); const style = transform ? { @@ -69,34 +74,33 @@ export const SortableTransformationItem = ({ transition, opacity: isDragging ? 0.5 : 1, } - : undefined + : undefined; - const isVisible = visibleTransformations[transformation.id] + const isVisible = visibleTransformations[transformation.id]; const isEditting = _internalState.transformationToEdit?.position === "inplace" && - _internalState.transformationToEdit?.transformationId === transformation.id + _internalState.transformationToEdit?.transformationId === transformation.id; const [isRenaming, setIsRenaming] = useState(false); + const renameInputRef = useRef(null); + const renamingBoxRef = useRef(null); - const renamingBoxRef = useRef(null) + const baseIconColor = useColorModeValue("gray.600", "gray.300"); useEffect(() => { const handleClickOutside = (event: MouseEvent): void => { - const renamingBox = renamingBoxRef.current - if ( - renamingBox && - !renamingBox.contains(event.target as Node) - ) { - setIsRenaming(false) + const renamingBox = renamingBoxRef.current; + if (renamingBox && !renamingBox.contains(event.target as Node)) { + setIsRenaming(false); } - } + }; - document.addEventListener('mousedown', handleClickOutside) + document.addEventListener("mousedown", handleClickOutside); return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, []) + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); return ( @@ -116,20 +120,18 @@ export const SortableTransformationItem = ({ alignItems="center" style={style} onClick={(e) => { - // Triple click to rename - if (e.detail === 3) { - e.stopPropagation() - setIsRenaming(true); - return - } - _setSidebarState("config") - _setSelectedTransformationKey(transformation.key) - _setTransformationToEdit(transformation.id, "inplace") + _setSidebarState("config"); + _setSelectedTransformationKey(transformation.key); + _setTransformationToEdit(transformation.id, "inplace"); + }} + onDoubleClick={(e) => { + e.stopPropagation(); + setIsRenaming(true); }} {...attributes} {...listeners} > - {isHover ? ( + {(isHover && !isRenaming) ? ( - { - if (e.key === "Enter") { - const newName = e.target.value.trim() - if (newName.length > 0) { - updateTransformation(transformation.id, { ...transformation, name: newName }); + + { + if (e.key === "Enter") { + const newName = renameInputRef.current?.value.trim(); + if (newName && newName.length > 0) { + updateTransformation(transformation.id, { + ...transformation, + name: newName, + }); + } + setIsRenaming(false); + } else if (e.key === "Escape") { + setIsRenaming(false); } - setIsRenaming(false) - } else if (e.key === "Escape") { - setIsRenaming(false) - } - }} - /> - - Press { - navigator.platform.toLowerCase().includes('mac') ? 'Return' : 'Enter' - } to save, Esc to cancel + }} + variant="flushed" + /> + + } + variant="ghost" + color={baseIconColor} + onClick={() => { + const newName = renameInputRef.current?.value.trim(); + if (newName && newName.length > 0) { + updateTransformation(transformation.id, { + ...transformation, + name: newName, + }); + } + setIsRenaming(false); + }} + /> + } + variant="ghost" + color={baseIconColor} + onClick={() => { + setIsRenaming(false); + }} + /> + + + + Press{" "} + + {navigator.platform.toLowerCase().includes("mac") + ? "Return" + : "Enter"} + {" "} + to save, Esc to cancel ) : ( - - {transformation.name} - )} + + {transformation.name} + + )} - {isHover && ( + {isHover && !isRenaming && ( { - e.stopPropagation() - toggleTransformationVisibility(transformation.id) + e.stopPropagation(); + toggleTransformationVisibility(transformation.id); }} > } onClick={(e) => { - e.stopPropagation() - _setSidebarState("type") - _setTransformationToEdit(transformation.id, "above") + e.stopPropagation(); + _setSidebarState("type"); + _setTransformationToEdit(transformation.id, "above"); }} > Add transformation before @@ -233,9 +274,9 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation() - _setSidebarState("type") - _setTransformationToEdit(transformation.id, "below") + e.stopPropagation(); + _setSidebarState("type"); + _setTransformationToEdit(transformation.id, "below"); }} > Add transformation after @@ -243,15 +284,18 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation() + e.stopPropagation(); const currentIndex = transformations.findIndex( (t) => t.id === transformation.id, - ) - const transformationId = addTransformation({ - ...transformation, - }, currentIndex + 1); - _setSidebarState("config") - _setTransformationToEdit(transformationId, "inplace") + ); + const transformationId = addTransformation( + { + ...transformation, + }, + currentIndex + 1, + ); + _setSidebarState("config"); + _setTransformationToEdit(transformationId, "inplace"); }} > Duplicate @@ -259,10 +303,10 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation() - _setSidebarState("config") - _setSelectedTransformationKey(transformation.key) - _setTransformationToEdit(transformation.id, "inplace") + e.stopPropagation(); + _setSidebarState("config"); + _setSelectedTransformationKey(transformation.key); + _setTransformationToEdit(transformation.id, "inplace"); }} > Edit transformation @@ -270,11 +314,11 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation() + e.stopPropagation(); setIsRenaming(true); - _setSidebarState("config") - _setSelectedTransformationKey(transformation.key) - _setTransformationToEdit(transformation.id, "inplace") + _setSidebarState("config"); + _setSelectedTransformationKey(transformation.key); + _setTransformationToEdit(transformation.id, "inplace"); }} > Rename @@ -282,13 +326,13 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation() + e.stopPropagation(); const currentIndex = transformations.findIndex( (t) => t.id === transformation.id, - ) + ); if (currentIndex > 0) { - const targetId = transformations[currentIndex - 1].id - moveTransformation(transformation.id, targetId) + const targetId = transformations[currentIndex - 1].id; + moveTransformation(transformation.id, targetId); } }} isDisabled={ @@ -302,13 +346,13 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation() + e.stopPropagation(); const currentIndex = transformations.findIndex( (t) => t.id === transformation.id, - ) + ); if (currentIndex < transformations.length - 1) { - const targetId = transformations[currentIndex + 1].id - moveTransformation(transformation.id, targetId) + const targetId = transformations[currentIndex + 1].id; + moveTransformation(transformation.id, targetId); } }} isDisabled={ @@ -324,15 +368,15 @@ export const SortableTransformationItem = ({ icon={} color="red.500" onClick={(e) => { - e.stopPropagation() - removeTransformation(transformation.id) + e.stopPropagation(); + removeTransformation(transformation.id); if ( _internalState.selectedTransformationKey === transformation.key ) { - _setSidebarState("none") - _setSelectedTransformationKey(null) - _setTransformationToEdit(null) + _setSidebarState("none"); + _setSelectedTransformationKey(null); + _setTransformationToEdit(null); } }} > @@ -345,5 +389,5 @@ export const SortableTransformationItem = ({ )} - ) -} + ); +}; From f439563be0b21248c00e39177f5edbae2d2339b6 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Wed, 28 Jan 2026 18:45:00 +0530 Subject: [PATCH 23/48] Improve Gradient Picker implementation --- .../src/components/common/GradientPicker.tsx | 86 +++++++++++++------ .../sidebar/transformation-config-sidebar.tsx | 3 +- .../imagekit-editor-dev/src/schema/index.ts | 10 +-- 3 files changed, 67 insertions(+), 32 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx b/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx index 29b0e24..8cfb545 100644 --- a/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx +++ b/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx @@ -7,6 +7,8 @@ import { PopoverTrigger, FormLabel, Box, + Text, + useColorModeValue, } from "@chakra-ui/react"; import { memo, useEffect, useState, useMemo } from "react"; import ColorPicker, { useColorPicker } from "react-best-gradient-color-picker"; @@ -15,12 +17,13 @@ import AnchorField from "./AnchorField"; import RadioCardField from "./RadioCardField"; import { TbAngle } from "@react-icons/all-files/tb/TbAngle"; import { BsArrowsMove } from "@react-icons/all-files/bs/BsArrowsMove"; +import { FieldErrors } from "react-hook-form"; export type GradientPickerState = { from: string; to: string; direction: number | string; - stopPoint: number; + stopPoint: number | string; }; type DirectionMode = "direction" | "degrees"; @@ -54,19 +57,26 @@ const GradientPickerField = ({ fieldName, setValue, value, + errors, }: { fieldName: string; setValue: (name: string, value: GradientPickerState | string) => void; value?: GradientPickerState | null; + errors?: FieldErrors>; }) => { function getLinearGradientString(value: GradientPickerState): string { let direction = ""; - if (typeof value.direction === "number") { - direction = `${value.direction}deg`; + const dirInt = Number(value.direction as string); + if (!isNaN(dirInt)) { + direction = `${dirInt}deg`; } else { - direction = `to ${value.direction.split("_").join(" ")}`; + direction = `to ${String(value.direction).split("_").join(" ")}`; } - return `linear-gradient(${direction}, ${value.from} 0%, ${value.to} ${value.stopPoint}%)`; + const stopPoint = + typeof value.stopPoint === "number" + ? value.stopPoint + : Number(value.stopPoint); + return `linear-gradient(${direction}, ${value.from} 0%, ${value.to} ${stopPoint}%)`; } const [localValue, setLocalValue] = useState( @@ -86,9 +96,10 @@ const GradientPickerField = ({ const { getGradientObject } = useColorPicker(gradient, setGradient); - function getAngleValue(): number { - if (typeof localValue.direction === "number") { - return localValue.direction; + function getAngleValue(): number | string { + const dirInt = Number(localValue.direction as string); + if (!isNaN(dirInt)) { + return dirInt || ""; } const direction = localValue.direction as string; const directionMap: Record = { @@ -101,15 +112,15 @@ const GradientPickerField = ({ left: 270, top_left: 315, }; - return directionMap[direction] || 180; + return directionMap[direction] || ""; } function getDirectionValue(): string { - if (typeof localValue.direction === "string") { - return localValue.direction; + const dirInt = Number(localValue.direction as string); + if (isNaN(dirInt)) { + return String(localValue.direction); } - const angle = localValue.direction as number; - const nearestAngle = Math.round(angle / 45) * 45; + const nearestAngle = Math.round(dirInt / 45) * 45; const angleMap: Record = { 0: "top", 45: "top_right", @@ -126,21 +137,24 @@ const GradientPickerField = ({ const debouncedValue = useDebounce(localValue, 500); function handleGradientChange(gradientVal: string) { - const gradientObj = getGradientObject(gradientVal); - console.log("Gradient Object:", gradientObj); - console.log("Gradient String:", gradientVal); + const cleanedGradient = gradientVal.replace(/NaNdeg\s*,/, ""); + let gradientObj; + try { + gradientObj = getGradientObject(cleanedGradient); + } catch (error) { + return; + } if (!gradientObj || !gradientObj.isGradient) return; const { colors } = gradientObj; if (colors.length !== 2) return; if (colors[0].left !== 0) return; - setGradient(gradientVal); + setGradient(cleanedGradient); const fromColor = rgbaToHex(colors[0].value).toUpperCase(); const toColor = rgbaToHex(colors[1].value).toUpperCase(); const stopPoint = colors[1].left; - console.log({ stopPoint, fromColor, toColor }); if ( fromColor !== localValue.from || @@ -166,6 +180,8 @@ const GradientPickerField = ({ setValue(fieldName, debouncedValue); }, [debouncedValue, fieldName, setValue]); + const errorRed = useColorModeValue("red.500", "red.300"); + return ( + + {errors?.[fieldName]?.from?.message} + @@ -247,6 +266,9 @@ const GradientPickerField = ({ fontFamily="mono" borderRadius="4px" /> + + {errors?.[fieldName]?.to?.message} + @@ -296,17 +318,23 @@ const GradientPickerField = ({ min={0} max={359} onChange={(e) => { - const newValue = e.target.value; - const intVal = newValue === "" ? 0 : Number(newValue); + const newValue = e.target.value.trim(); + if (newValue === "") { + applyGradientInputChanges({ ...localValue, direction: "" }); + return; + } + const intVal = Number(newValue); if (intVal < 0 || intVal > 359) return; applyGradientInputChanges({ ...localValue, direction: intVal }); }} borderColor="gray.200" - placeholder="#FFFFFF" - fontFamily="mono" + placeholder="0" borderRadius="4px" /> )} + + {errors?.[fieldName]?.direction?.message} + @@ -320,8 +348,12 @@ const GradientPickerField = ({ min={1} max={100} onChange={(e) => { - const newValue = e.target.value; - const intVal = newValue === "" ? 1 : Number(newValue); + const newValue = e.target.value.trim(); + if (newValue === "") { + applyGradientInputChanges({ ...localValue, stopPoint: "" }); + return; + } + const intVal = Number(newValue); if (intVal < 1 || intVal > 100) return; applyGradientInputChanges({ ...localValue, @@ -329,10 +361,12 @@ const GradientPickerField = ({ }); }} borderColor="gray.200" - placeholder="#FFFFFF" - fontFamily="mono" + placeholder="100" borderRadius="4px" /> + + {errors?.[fieldName]?.stopPoint?.message} + ); 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 a9a3286..1276dd6 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 @@ -296,7 +296,7 @@ export const TransformationConfigSidebar: React.FC = () => { return true }) .map((field: TransformationField) => ( - + {field.label} @@ -546,6 +546,7 @@ export const TransformationConfigSidebar: React.FC = () => { fieldName={field.name} value={watch(field.name) as GradientPickerState} setValue={setValue} + errors={errors} /> ) : 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 729f4a9..32c9524 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -1371,12 +1371,12 @@ export const transformationSchema: TransformationSchema[] = [ direction: z.union([ z.coerce.number({ invalid_type_error: "Should be a number.", - }), + }).min(0).max(359), z.string(), ]).optional(), stopPoint: z.coerce.number({ invalid_type_error: "Should be a number.", - }).optional(), + }).min(1).max(100).optional(), }).optional(), gradientSwitch: z.coerce .boolean({ @@ -2584,19 +2584,19 @@ export const transformationSchema: TransformationSchema[] = [ gradientSwitch: z.coerce .boolean({ invalid_type_error: "Should be a boolean.", - }), + }).optional(), gradient: z.object({ from: z.string().optional(), to: z.string().optional(), direction: z.union([ z.coerce.number({ invalid_type_error: "Should be a number.", - }), + }).min(0).max(359), z.string(), ]).optional(), stopPoint: z.coerce.number({ invalid_type_error: "Should be a number.", - }).optional(), + }).min(1).max(100).optional(), }).optional(), }) .refine( From e00059e3bc34a557cda08ecad98c3b71e2d893b2 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Wed, 28 Jan 2026 18:46:48 +0530 Subject: [PATCH 24/48] Add shadow and grayscale support in image layer --- .../imagekit-editor-dev/src/schema/index.ts | 152 +++++++++++++++--- 1 file changed, 130 insertions(+), 22 deletions(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 32c9524..94d1d9e 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -2572,6 +2572,8 @@ export const transformationSchema: TransformationSchema[] = [ invalid_type_error: "Should be a number.", }) .optional(), + + // Focus + Zoom properties focus: z.string().optional(), focusAnchor: z.string().optional(), focusObject: z.string().optional(), @@ -2581,6 +2583,8 @@ export const transformationSchema: TransformationSchema[] = [ xc: z.string().optional(), yc: z.string().optional(), zoom: z.coerce.number().optional(), + + // Gradient properties gradientSwitch: z.coerce .boolean({ invalid_type_error: "Should be a boolean.", @@ -2598,6 +2602,40 @@ export const transformationSchema: TransformationSchema[] = [ invalid_type_error: "Should be a number.", }).min(1).max(100).optional(), }).optional(), + + // Shadow properties + shadow: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + shadowBlur: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .optional(), + shadowSaturation: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .optional(), + shadowOffsetX: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .optional(), + shadowOffsetY: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .optional(), + + // Grayscale + grayscale: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), }) .refine( (val) => { @@ -2997,6 +3035,88 @@ export const transformationSchema: TransformationSchema[] = [ } } }, + { + label: "Shadow", + name: "shadow", + fieldType: "switch", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Toggle to add a non-AI shadow under objects in the overlay image.", + }, + { + label: "Blur", + name: "shadowBlur", + fieldType: "slider", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Set the blur radius for the shadow. Higher values create a softer shadow.", + fieldProps: { + min: 0, + max: 15, + step: 1, + defaultValue: 10, + }, + isVisible: ({ shadow }) => shadow === true, + }, + { + label: "Saturation", + name: "shadowSaturation", + fieldType: "slider", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Adjust the saturation of the shadow. Higher values produce a darker shadow.", + fieldProps: { + min: 0, + max: 100, + step: 1, + defaultValue: 30, + }, + isVisible: ({ shadow }) => shadow === true, + }, + { + label: "X Offset", + name: "shadowOffsetX", + fieldType: "slider", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Enter the horizontal offset as a percentage of the overlay image width.", + isVisible: ({ shadow }) => shadow === true, + fieldProps: { + min: -100, + max: 100, + step: 1, + defaultValue: 2, + }, + }, + { + label: "Y Offset", + name: "shadowOffsetY", + fieldType: "slider", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Enter the vertical offset as a percentage of the overlay image height.", + isVisible: ({ shadow }) => shadow === true, + fieldProps: { + min: -100, + max: 100, + step: 1, + defaultValue: 2, + }, + }, + { + label: "Grayscale", + name: "grayscale", + fieldType: "switch", + isTransformation: true, + transformationKey: "grayscale", + transformationGroup: "imageLayer", + helpText: "Toggle to convert the overlay image to grayscale.", + }, ], }, ], @@ -3150,7 +3270,8 @@ export const transformationFormatters: Record< if ( shadowOffsetX !== undefined && shadowOffsetX !== null && - shadowOffsetX !== "" + shadowOffsetX !== "" && + typeof shadowOffsetX === "number" ) { if (shadowOffsetX < 0) { params.push(`x-N${Math.abs(shadowOffsetX)}`) @@ -3162,7 +3283,8 @@ export const transformationFormatters: Record< if ( shadowOffsetY !== undefined && shadowOffsetY !== null && - shadowOffsetY !== "" + shadowOffsetY !== "" && + typeof shadowOffsetY === "number" ) { if (shadowOffsetY < 0) { params.push(`y-N${Math.abs(shadowOffsetY)}`) @@ -3399,32 +3521,18 @@ export const transformationFormatters: Record< overlayTransform.blur = values.blur } - const { focus, crop, focusAnchor, focusObject, x, y, xc, yc, coordinateMethod, zoom } = values + const { crop, focusAnchor } = values - if (focus === "auto" || focus === "face") { - overlayTransform.focus = focus - } else if (focus === "anchor" || crop === "cm-pad_resize") { + transformationFormatters.focus(values, overlayTransform) + if (crop === "cm-pad_resize") { overlayTransform.focus = focusAnchor - } else if (focus === "object") { - overlayTransform.focus = focusObject - } else if (focus === "custom") { - overlayTransform.focus = "custom" - } else if (focus === "coordinates") { - // Handle coordinate-based focus - // x/y are top-left coordinates, xc/yc are center coordinates - if (coordinateMethod === "topleft") { - if (x) overlayTransform.x = x - if (y) overlayTransform.y = y - } else if (coordinateMethod === "center") { - if (xc) overlayTransform.xc = xc - if (yc) overlayTransform.yc = yc - } } transformationFormatters.gradient(values, overlayTransform) + transformationFormatters.shadow(values, overlayTransform) - if (zoom !== undefined && zoom !== null && !isNaN(Number(zoom)) && zoom !== 0) { - overlayTransform.zoom = (zoom as number) / 100 + if (values.grayscale) { + overlayTransform.grayscale = true } if (Object.keys(overlayTransform).length > 0) { From 16ec40aa2d7fef6fd5610470225b531bb97c3397 Mon Sep 17 00:00:00 2001 From: Piyush Aryan Date: Wed, 28 Jan 2026 23:17:53 +0530 Subject: [PATCH 25/48] refactor: update borderWidth validation in transformation schema to use union type and improve visibility logic --- .../imagekit-editor-dev/src/schema/index.ts | 31 ++++++------------- .../src/schema/transformation.ts | 2 +- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index c8885bb..908c441 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -1585,13 +1585,7 @@ export const transformationSchema: TransformationSchema[] = [ defaultTransformation: {}, schema: z .object({ - borderWidth: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .int() - .min(1) - .optional(), + borderWidth: z.union([widthValidator, heightValidator]).optional(), borderColor: colorValidator, }) .refine( @@ -1608,6 +1602,7 @@ export const transformationSchema: TransformationSchema[] = [ path: [], }, ), + transformations: [ { label: "Border Width", @@ -2724,11 +2719,7 @@ export const transformationSchema: TransformationSchema[] = [ invalid_type_error: "Should be a number.", }) .optional(), - borderWidth: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .optional(), + borderWidth: z.union([widthValidator, heightValidator]).optional(), borderColor: colorValidator.optional(), sharpenEnabled: z.coerce .boolean({ @@ -2968,9 +2959,10 @@ export const transformationSchema: TransformationSchema[] = [ transformationKey: "borderWidth", transformationGroup: "imageLayer", fieldProps: { - defaultValue: 0, + defaultValue: "", }, - helpText: "Enter the width of the border of the overlay image.", + helpText: "Enter the width of the border or expression of the overlay image.", + examples: ["10", "ch_div_2"], }, { label: "Border Color", @@ -2979,7 +2971,7 @@ export const transformationSchema: TransformationSchema[] = [ isTransformation: false, transformationKey: "borderColor", transformationGroup: "imageLayer", - isVisible: ({ borderWidth }) => (borderWidth as number) > 0, + isVisible: ({ borderWidth }) => (borderWidth as string) !== "", helpText: "Select the color of the border of the overlay image.", fieldProps: { hideOpacity: true, @@ -3408,11 +3400,8 @@ export const transformationFormatters: Record< } } if ( - values.borderWidth && - typeof values.borderWidth === "number" && - values.borderWidth > 0 && - values.borderColor && - typeof values.borderColor === "string" + values.borderWidth && + values.borderColor && typeof values.borderColor === "string" ) { overlayTransform.b = `${values.borderWidth}_${values.borderColor.replace(/^#/, "")}` } @@ -3513,7 +3502,7 @@ export const transformationFormatters: Record< }, border: (values, transforms) => { const { borderWidth, borderColor } = values as { - borderWidth?: number + borderWidth?: string borderColor?: string } if (!borderWidth || !borderColor) return diff --git a/packages/imagekit-editor-dev/src/schema/transformation.ts b/packages/imagekit-editor-dev/src/schema/transformation.ts index 78eb69c..7c1ca8e 100644 --- a/packages/imagekit-editor-dev/src/schema/transformation.ts +++ b/packages/imagekit-editor-dev/src/schema/transformation.ts @@ -123,4 +123,4 @@ export const layerYValidator = z.any().superRefine((val, ctx) => { code: z.ZodIssueCode.custom, message: "Layer Y must be a positive number or a valid expression string.", }) -}) +}) \ No newline at end of file From 825b3c4b8183bade07c77ad2b06b6e4d1a7bfff6 Mon Sep 17 00:00:00 2001 From: Piyush Aryan Date: Thu, 29 Jan 2026 04:30:01 +0530 Subject: [PATCH 26/48] feat: add unsharpen mask transformation to image processing schema with validation and integration --- .../imagekit-editor-dev/src/schema/index.ts | 172 +++++++++++++++++- .../src/schema/transformation.ts | 41 ++++- 2 files changed, 209 insertions(+), 4 deletions(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 908c441..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" @@ -1602,7 +1604,7 @@ export const transformationSchema: TransformationSchema[] = [ path: [], }, ), - + transformations: [ { label: "Border Width", @@ -1841,6 +1843,82 @@ export const transformationSchema: TransformationSchema[] = [ }, ], }, + { + 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"], + }, + ] + } ], }, { @@ -2734,7 +2812,12 @@ export const transformationSchema: TransformationSchema[] = [ .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( @@ -3009,6 +3092,75 @@ export const transformationSchema: TransformationSchema[] = [ }, 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, + }, + ], }, ], @@ -3400,7 +3552,7 @@ export const transformationFormatters: Record< } } if ( - values.borderWidth && + values.borderWidth && values.borderColor && typeof values.borderColor === "string" ) { overlayTransform.b = `${values.borderWidth}_${values.borderColor.replace(/^#/, "")}` @@ -3425,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) { @@ -3521,4 +3678,13 @@ export const transformationFormatters: Record< 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 7c1ca8e..745c75d 100644 --- a/packages/imagekit-editor-dev/src/schema/transformation.ts +++ b/packages/imagekit-editor-dev/src/schema/transformation.ts @@ -123,4 +123,43 @@ export const layerYValidator = z.any().superRefine((val, ctx) => { code: z.ZodIssueCode.custom, message: "Layer Y must be a positive number or a valid expression string.", }) -}) \ No newline at end of file +}) + + +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 From c0fa40b954230067d6e41828223948e8d322107c Mon Sep 17 00:00:00 2001 From: Piyush Aryan Date: Fri, 30 Jan 2026 02:15:00 +0530 Subject: [PATCH 27/48] feat: integrate unsharpen mask transformation into overlay processing logic --- packages/imagekit-editor-dev/src/schema/index.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 392109a..e48b58e 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -3529,6 +3529,10 @@ export const transformationFormatters: Record< if (values.rotation) { overlayTransform.rotation = values.rotation } + + if (values.unsharpenMask === true) { + overlayTransform["e-usm"] = `${values.unsharpenMaskRadius}-${values.unsharpenMaskSigma}-${values.unsharpenMaskAmount}-${values.unsharpenMaskThreshold}` + } if ( values.trimEnabled === true && typeof values.trimThreshold === "number" @@ -3577,11 +3581,6 @@ 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) { From a132fb9ab3e713ac62362658b8bf513b77317fdd Mon Sep 17 00:00:00 2001 From: Piyush Aryan Date: Fri, 30 Jan 2026 12:22:33 +0530 Subject: [PATCH 28/48] feat: enhance transformation schema with new lineHeight and dpr options, and improve validation logic --- .../imagekit-editor-dev/src/schema/index.ts | 78 +++++++++++++++++-- .../src/schema/transformation.ts | 49 ++++++++++++ 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index e48b58e..2c25edf 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -16,7 +16,9 @@ import { SIMPLE_OVERLAY_TEXT_REGEX, safeBtoa } from "../utils" import { aspectRatioValidator, colorValidator, + commonNumberAndExpressionValidator, heightValidator, + overlayBlockExprValidator, layerXValidator, layerYValidator, optionalPositiveFloatNumberValidator, @@ -1587,7 +1589,7 @@ export const transformationSchema: TransformationSchema[] = [ defaultTransformation: {}, schema: z .object({ - borderWidth: z.union([widthValidator, heightValidator]).optional(), + borderWidth: commonNumberAndExpressionValidator.optional(), borderColor: colorValidator, }) .refine( @@ -2396,11 +2398,15 @@ export const transformationSchema: TransformationSchema[] = [ defaultTransformation: {}, schema: z .object({ - dpr: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .optional(), + dpr: + z.union([ + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .optional(), + z.literal("auto"), + ]).optional(), }) .refine( (val) => { @@ -2427,6 +2433,7 @@ export const transformationSchema: TransformationSchema[] = [ transformationKey: "dpr", fieldProps: { defaultValue: 1, + autoOption: true, min: 0.1, max: 5, step: 0.1, @@ -2470,6 +2477,7 @@ export const transformationSchema: TransformationSchema[] = [ invalid_type_error: "Should be a number.", }) .optional(), + lineHeight: overlayBlockExprValidator.optional(), opacity: z .union([ z.coerce @@ -2667,6 +2675,16 @@ export const transformationSchema: TransformationSchema[] = [ helpText: "Specify padding around the text (in pixels).", examples: ["10", "20"], }, + { + label: "Line Height", + name: "lineHeight", + fieldType: "input", + isTransformation: true, + transformationKey: "lineHeight", + transformationGroup: "textLayer", + helpText: "Specify the line height for the text overlay.", + examples: ["1.5", "bh_div_2"], + }, { label: "Opacity", name: "opacity", @@ -2756,6 +2774,14 @@ export const transformationSchema: TransformationSchema[] = [ }) .optional(), backgroundColor: z.string().optional(), + dprEnabled: z.boolean().optional(), + dpr:z.union([ + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }), + z.literal("auto"), + ]).optional(), radius: z .union([ z.literal("max"), @@ -2797,7 +2823,7 @@ export const transformationSchema: TransformationSchema[] = [ invalid_type_error: "Should be a number.", }) .optional(), - borderWidth: z.union([widthValidator, heightValidator]).optional(), + borderWidth: commonNumberAndExpressionValidator.optional(), borderColor: colorValidator.optional(), sharpenEnabled: z.coerce .boolean({ @@ -2900,6 +2926,36 @@ export const transformationSchema: TransformationSchema[] = [ helpText: "Specify the vertical offset for the overlay image.", examples: ["10"], }, + { + label: "Adjust DPR", + name: "dprEnabled", + fieldType: "switch", + isTransformation: false, + transformationGroup: "imageLayer", + transformationKey: "dprEnabled", + helpText: "Adjust the DPR of the overlay image.", + fieldProps: { + defaultValue: false, + }, + }, + { + label: "DPR", + name: "dpr", + helpText: + "Set this value to deliver images optimised for high-resolution displays. The value can be between 0.1 and 5.", + fieldType: "slider", + isTransformation: true, + transformationGroup: "imageLayer", + transformationKey: "dpr", + fieldProps: { + defaultValue: "auto", + autoOption: true, + min: 0.1, + max: 5, + step: 0.1, + }, + isVisible: ({ dprEnabled }) => dprEnabled === true, + }, { label: "Opacity", name: "opacity", @@ -3379,6 +3435,9 @@ export const transformationFormatters: Record< ) { overlayTransform.padding = values.padding } + if ( typeof values.lineHeight === "number" || typeof values.lineHeight === "string" ) { + overlayTransform.lineHeight = values.lineHeight + } if (Array.isArray(values.flip) && values.flip.length > 0) { const flip = [] @@ -3539,7 +3598,10 @@ export const transformationFormatters: Record< ) { overlayTransform.t = values.trimThreshold } - + console.log({values, overlayTransform}) + if (values.dpr && values.dprEnabled === true) { + overlayTransform.dpr = values.dpr + } if (values.quality) { overlayTransform.quality = values.quality } diff --git a/packages/imagekit-editor-dev/src/schema/transformation.ts b/packages/imagekit-editor-dev/src/schema/transformation.ts index 745c75d..284ff7f 100644 --- a/packages/imagekit-editor-dev/src/schema/transformation.ts +++ b/packages/imagekit-editor-dev/src/schema/transformation.ts @@ -126,6 +126,55 @@ export const layerYValidator = z.any().superRefine((val, ctx) => { }) +const commonNumber = z.coerce + .number({ invalid_type_error: "Should be a number." }) + .min(0, { + message: "Must be a positive number.", + }) +const commonExpr = z + .string() + .regex(/^(?:ih|bh|ch|iw|bw|cw)_(?:add|sub|mul|div|mod|pow)_(?:\d+(\.\d{1,2})?)$/, { + message: "String must be a valid expression string.", + }) + + +export const commonNumberAndExpressionValidator = z.any().superRefine((val, ctx) => { + if (commonNumber.safeParse(val).success) { + return + } + if (commonExpr.safeParse(val).success) { + return + } + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Must be a positive number or a valid expression string.", + }) +}) + + +const overlayBlockExpr = z + .string() + .regex(/^(?:bh|bw|bar)_(?:add|sub|mul|div|mod|pow)_(?:\d+(\.\d{1,2})?)$/, { + message: "String must be a valid expression string.", + }) + + +export const overlayBlockExprValidator = z.any().superRefine((val, ctx) => { + if (commonNumber.safeParse(val).success) { + return + } + if (overlayBlockExpr.safeParse(val).success) { + return + } + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "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() From a878f98ec744b1285899dffb226cd4686c2305f3 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Fri, 30 Jan 2026 13:58:34 +0530 Subject: [PATCH 29/48] Retain Padding Input Value --- .../src/components/common/PaddingInput.tsx | 23 +++++-- .../sidebar/transformation-config-sidebar.tsx | 1 + .../imagekit-editor-dev/src/schema/index.ts | 65 ++++++++++--------- 3 files changed, 52 insertions(+), 37 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx index f2f93e6..de7ac98 100644 --- a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx @@ -21,14 +21,22 @@ import { LuArrowDownToLine } from "@react-icons/all-files/lu/LuArrowDownToLine" import { TbBoxPadding } from "@react-icons/all-files/tb/TbBoxPadding" import { FieldErrors } from "react-hook-form" +type PaddingMode = "uniform" | "individual" + +type PaddingState = { + mode: PaddingMode + padding: number | PaddingObject | null | string +} + type PaddingInputFieldProps = { id?: string - onChange: (value: number | PaddingObject | string) => void + onChange: (value: PaddingState) => void errors?: FieldErrors> - name: string + name: string, + value?: Partial } -type PaddingObject = { +export type PaddingObject = { top: number | null right: number | null bottom: number | null @@ -82,9 +90,10 @@ export const PaddingInputField: React.FC = ({ onChange, errors, name: propertyName, + value }) => { - const [paddingMode, setPaddingMode] = useState<"uniform" | "individual">("uniform") - const [paddingValue, setPaddingValue] = useState("") + const [paddingMode, setPaddingMode] = useState(value?.mode ?? "uniform") + const [paddingValue, setPaddingValue] = useState(value?.padding ?? "") const errorRed = useColorModeValue("red.500", "red.300") const activeColor = useColorModeValue("blue.500", "blue.600") const inactiveColor = useColorModeValue("gray.600", "gray.400") @@ -101,8 +110,8 @@ export const PaddingInputField: React.FC = ({ } } const formattedValue = formatPaddingValue(paddingValue) - onChange(formattedValue) - }, [paddingValue]) + onChange({ mode: paddingMode, padding: formattedValue }) + }, [paddingValue, paddingMode]) return ( 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 1276dd6..ac3fea6 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 @@ -581,6 +581,7 @@ export const TransformationConfigSidebar: React.FC = () => { errors={errors} name={field.name} {...field.fieldProps} + value={watch(field.name) as string | number | PaddingObject | null} /> ) : null} {field.fieldType === "zoom" ? ( diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 94d1d9e..59fd294 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -1385,7 +1385,6 @@ export const transformationSchema: TransformationSchema[] = [ }) .refine( (val) => { - console.log("Received val", val); if ( Object.values(val).some((v) => v !== undefined && v !== null) ) { @@ -2224,35 +2223,39 @@ export const transformationSchema: TransformationSchema[] = [ innerAlignment: z .enum(["left", "right", "center"]) .default("center"), - padding: z.union([ - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - z.object({ - top: z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - right: z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - bottom: z.coerce.number({ + padding: z.object({ + mode: z.enum(["uniform", "individual"]).optional(), + padding: z.union([ + z.coerce.number({ invalid_type_error: "Should be a number.", }).min(0, { message: "Negative values are not allowed.", }), - left: z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", + z.object({ + top: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + right: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + bottom: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + left: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), }), - }), - ]).optional(), + ]).optional(), + }) + .optional(), opacity: z .union([ z.coerce @@ -3335,13 +3338,15 @@ export const transformationFormatters: Record< const bg = (values.backgroundColor as string).replace(/^#/, "") overlayTransform.background = bg } + const { padding, mode } = values.padding as Record if ( - typeof values.padding === "number" || - typeof values.padding === "string" + mode === "uniform" && + (typeof padding === "number" || + typeof padding === "string") ) { - overlayTransform.padding = values.padding - } else if (typeof values.padding === "object" && values.padding !== null) { - const { top, right, bottom, left } = values.padding as { + overlayTransform.padding = padding + } else if (mode === "individual" && typeof padding === "object" && padding !== null) { + const { top, right, bottom, left } = padding as { top: number right: number bottom: number From 7d2180547387f18020fe7fac467d52dfd5eccf7e Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Fri, 30 Jan 2026 13:59:02 +0530 Subject: [PATCH 30/48] Retain Zoom Input Value --- .../imagekit-editor-dev/src/components/common/ZoomInput.tsx | 6 ++++-- .../components/sidebar/transformation-config-sidebar.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx index 843036d..3738483 100644 --- a/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx @@ -17,6 +17,7 @@ type ZoomInputFieldProps = { id?: string onChange: (value: number) => void defaultValue?: number + value?: number } const STEP_SIZE = 10 @@ -42,9 +43,10 @@ export const ZoomInput: React.FC = ({ id, onChange, defaultValue = 100, + value, }) => { - const [zoomValue, setZoomValue] = useState(defaultValue) - const [inputValue, setInputValue] = useState(defaultValue.toString()) + const [zoomValue, setZoomValue] = useState(value ?? defaultValue) + const [inputValue, setInputValue] = useState((value ?? defaultValue).toString()) useEffect(() => { onChange(zoomValue) 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 ac3fea6..8b9b946 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 @@ -586,7 +586,7 @@ export const TransformationConfigSidebar: React.FC = () => { ) : null} {field.fieldType === "zoom" ? ( setValue(field.name, value)} defaultValue={field.fieldProps?.defaultValue as number ?? 100} {...field.fieldProps} From 8839de556e81b9301de00108fff6985deea2a6f8 Mon Sep 17 00:00:00 2001 From: Piyush Aryan Date: Fri, 30 Jan 2026 14:13:14 +0530 Subject: [PATCH 31/48] fix: update trimEnabled transformation property to disable trimming and remove debug log --- packages/imagekit-editor-dev/src/schema/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 2c25edf..5016f24 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -3035,7 +3035,7 @@ export const transformationSchema: TransformationSchema[] = [ label: "Trim", name: "trimEnabled", fieldType: "switch", - isTransformation: true, + isTransformation: false, transformationKey: "trimEnabled", transformationGroup: "imageLayer", helpText: "Control trimming of the overlay image.", @@ -3598,7 +3598,6 @@ export const transformationFormatters: Record< ) { overlayTransform.t = values.trimThreshold } - console.log({values, overlayTransform}) if (values.dpr && values.dprEnabled === true) { overlayTransform.dpr = values.dpr } From 415b18ab6ccc676cafa0d5939cb0e285fa175c3a Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Fri, 30 Jan 2026 14:16:27 +0530 Subject: [PATCH 32/48] Add Distortion support in base and overlay image --- .../common/DistortPerspectiveInput.tsx | 245 ++++++++++++++++++ .../sidebar/transformation-config-sidebar.tsx | 16 +- .../imagekit-editor-dev/src/schema/index.ts | 186 ++++++++++++- 3 files changed, 444 insertions(+), 3 deletions(-) create mode 100644 packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx diff --git a/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx b/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx new file mode 100644 index 0000000..6ae4210 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx @@ -0,0 +1,245 @@ +import { + Box, + Flex, + HStack, + VStack, + Icon, + Text, + Input, + InputGroup, + InputLeftElement, + IconButton, + useColorModeValue, + Tooltip, +} from "@chakra-ui/react"; +import type * as React from "react"; +import { useState, useEffect } from "react"; +import { RxArrowTopLeft } from "@react-icons/all-files/rx/RxArrowTopLeft"; +import { RxArrowTopRight } from "@react-icons/all-files/rx/RxArrowTopRight"; +import { RxArrowBottomRight } from "@react-icons/all-files/rx/RxArrowBottomRight"; +import { RxArrowBottomLeft } from "@react-icons/all-files/rx/RxArrowBottomLeft"; +import { FieldErrors } from "react-hook-form"; + +type DistorPerspectiveFieldProps = { + name: string; + id?: string; + onChange: (value: PerspectiveObject) => void; + errors?: FieldErrors>; + value?: PerspectiveObject; +}; + +export type PerspectiveObject = { + x1: string; + y1: string; + x2: string; + y2: string; + x3: string; + y3: string; + x4: string; + y4: string; +}; + +export const DistortPerspectiveInput: React.FC = ({ + id, + onChange, + errors, + name: propertyName, + value, +}) => { + const [perspective, setPerspective] = useState(value ?? { + x1: "", + y1: "", + x2: "", + y2: "", + x3: "", + y3: "", + x4: "", + y4: "", + }); + const errorRed = useColorModeValue("red.500", "red.300"); + const leftAccessoryBackground = useColorModeValue("gray.100", "gray.700"); + + function handleFieldChange(fieldName: string) { + return (e: React.ChangeEvent) => { + const val = e.target.value.trim(); + setPerspective((prev) => ({ + ...prev, + [fieldName]: val, + })); + }; + } + + useEffect(() => { + onChange(perspective); + }, [perspective]); + + return ( + + + + + + + + + + + {[ + errors?.[propertyName]?.x1?.message, + errors?.[propertyName]?.y1?.message, + ] + .filter(Boolean) + .join(". ")} + + + + + + + + + + + + + {[ + errors?.[propertyName]?.x2?.message, + errors?.[propertyName]?.y2?.message, + ] + .filter(Boolean) + .join(". ")} + + + + + + + + + + + + + {[ + errors?.[propertyName]?.x3?.message, + errors?.[propertyName]?.y3?.message, + ] + .filter(Boolean) + .join(". ")} + + + + + + + + + + + + + {[ + errors?.[propertyName]?.x4?.message, + errors?.[propertyName]?.y4?.message, + ] + .filter(Boolean) + .join(". ")} + + + + ); +}; + +export default DistortPerspectiveInput; 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 8b9b946..ac050e4 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 @@ -56,8 +56,9 @@ import { SidebarBody } from "./sidebar-body" import { SidebarFooter } from "./sidebar-footer" import { SidebarHeader } from "./sidebar-header" import { SidebarRoot } from "./sidebar-root" -import PaddingInputField from "../common/PaddingInput" +import PaddingInputField, { PaddingObject } from "../common/PaddingInput" import ZoomInput from "../common/ZoomInput" +import DistortPerspectiveInput, { PerspectiveObject } from "../common/DistortPerspectiveInput" export const TransformationConfigSidebar: React.FC = () => { const { @@ -419,6 +420,7 @@ export const TransformationConfigSidebar: React.FC = () => { id={field.name} fontSize="sm" {...register(field.name)} + {...(field.fieldProps ?? {})} /> ) : null} {field.fieldType === "textarea" ? ( @@ -592,6 +594,18 @@ export const TransformationConfigSidebar: React.FC = () => { {...field.fieldProps} /> ) : null} + {field.fieldType === "distort-perspective-input" ? ( + { + setValue(field.name, value) + trigger(field.name) + }} + errors={errors} + name={field.name} + value={watch(field.name) as PerspectiveObject} + {...field.fieldProps} + /> + ) : null} {String( errors[field.name as keyof typeof errors]?.message ?? "", diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 59fd294..985b59c 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -1425,6 +1425,103 @@ export const transformationSchema: TransformationSchema[] = [ }, ], }, + { + key: "adjust-distort", + name: "Distort", + description: "Distort the image.", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#distort---e-distort", + defaultTransformation: {}, + schema: z + .object({ + distort: z.coerce.boolean(), + distortType: z.enum(["perspective", "arc"]).optional(), + distortPerspective: z.object({ + x1: z.union([z.literal(""), z.coerce.number()]), + y1: z.union([z.literal(""), z.coerce.number()]), + x2: z.union([z.literal(""), z.coerce.number()]), + y2: z.union([z.literal(""), z.coerce.number()]), + x3: z.union([z.literal(""), z.coerce.number()]), + y3: z.union([z.literal(""), z.coerce.number()]), + x4: z.union([z.literal(""), z.coerce.number()]), + y4: z.union([z.literal(""), z.coerce.number()]), + }).optional(), + distortArcDegree: z.coerce.number().min(-359).max(359).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: "Distort", + name: "distort", + fieldType: "switch", + isTransformation: false, + transformationGroup: "distort", + helpText: "Toggle to apply distortion to the image.", + }, + { + label: "Distortion Type", + name: "distortType", + fieldType: "radio-card", + isTransformation: false, + transformationGroup: "distort", + isVisible: ({ distort }) => distort === true, + fieldProps: { + options: [ + { label: "Perspective", value: "perspective" }, + { label: "Arc", value: "arc" }, + ], + defaultValue: "perspective", + }, + }, + { + label: "Distortion Perspective", + name: "distortPerspective", + fieldType: "distort-perspective-input", + isTransformation: false, + transformationGroup: "distort", + isVisible: ({ distort, distortType }) => distort === true && distortType === "perspective", + fieldProps: { + defaultValue: { + x1: "", + y1: "", + x2: "", + y2: "", + x3: "", + y3: "", + x4: "", + y4: "", + } + } + }, + { + label: "Distortion Arc Degrees", + name: "distortArcDegree", + fieldType: "input", + isTransformation: true, + transformationGroup: "distort", + isVisible: ({ distort, distortType }) => distort === true && distortType === "arc", + helpText: "Enter the arc degree for the arc distortion effect.", + examples: ["15", "30", "45"], + fieldProps: { + type: "number", + placeholder: "Arc Degrees", + } + } + ], + }, { key: "adjust-blur", name: "Blur", @@ -2639,6 +2736,21 @@ export const transformationSchema: TransformationSchema[] = [ invalid_type_error: "Should be a boolean.", }) .optional(), + + // Distort + distort: z.coerce.boolean(), + distortType: z.enum(["perspective", "arc"]).optional(), + distortPerspective: z.object({ + x1: z.union([z.literal(""), z.coerce.number()]), + y1: z.union([z.literal(""), z.coerce.number()]), + x2: z.union([z.literal(""), z.coerce.number()]), + y2: z.union([z.literal(""), z.coerce.number()]), + x3: z.union([z.literal(""), z.coerce.number()]), + y3: z.union([z.literal(""), z.coerce.number()]), + x4: z.union([z.literal(""), z.coerce.number()]), + y4: z.union([z.literal(""), z.coerce.number()]), + }).optional(), + distortArcDegree: z.coerce.number().min(-359).max(359).optional(), }) .refine( (val) => { @@ -3120,6 +3232,63 @@ export const transformationSchema: TransformationSchema[] = [ transformationGroup: "imageLayer", helpText: "Toggle to convert the overlay image to grayscale.", }, + { + label: "Distort", + name: "distort", + fieldType: "switch", + isTransformation: false, + transformationGroup: "imageLayer", + helpText: "Toggle to apply distortion to the overlay image.", + }, + { + label: "Distortion Type", + name: "distortType", + fieldType: "radio-card", + isTransformation: false, + transformationGroup: "imageLayer", + isVisible: ({ distort }) => distort === true, + fieldProps: { + options: [ + { label: "Perspective", value: "perspective" }, + { label: "Arc", value: "arc" }, + ], + defaultValue: "perspective", + }, + }, + { + label: "Distortion Perspective", + name: "distortPerspective", + fieldType: "distort-perspective-input", + isTransformation: false, + transformationGroup: "imageLayer", + isVisible: ({ distort, distortType }) => distort === true && distortType === "perspective", + fieldProps: { + defaultValue: { + x1: "", + y1: "", + x2: "", + y2: "", + x3: "", + y3: "", + x4: "", + y4: "", + } + } + }, + { + label: "Distortion Arc Degrees", + name: "distortArcDegree", + fieldType: "input", + isTransformation: true, + transformationGroup: "imageLayer", + isVisible: ({ distort, distortType }) => distort === true && distortType === "arc", + helpText: "Enter the arc degree for the arc distortion effect.", + examples: ["15", "30", "45"], + fieldProps: { + type: "number", + placeholder: "Arc Degrees", + } + } ], }, ], @@ -3535,6 +3704,7 @@ export const transformationFormatters: Record< transformationFormatters.gradient(values, overlayTransform) transformationFormatters.shadow(values, overlayTransform) + transformationFormatters.distort(values, overlayTransform) if (values.grayscale) { overlayTransform.grayscale = true @@ -3595,7 +3765,6 @@ export const transformationFormatters: Record< }, gradient: (values, transforms) => { const { gradient, gradientSwitch } = values as { gradient: GradientPickerState; gradientSwitch: boolean } - console.log('gradient formatter called', values) if (gradientSwitch && gradient) { const { from, to, direction, stopPoint } = gradient const isDefaultGradient = (from.toUpperCase() === "#FFFFFFFF" || from.toUpperCase() === "#FFFFFF") && @@ -3607,10 +3776,23 @@ export const transformationFormatters: Record< } else { const fromColor = from.replace("#", "") const toColor = to.replace("#", "") - const stopPointDecimal = stopPoint / 100 + const stopPointDecimal = (stopPoint as number) / 100 let gradientStr = `ld-${direction}_from-${fromColor}_to-${toColor}_sp-${stopPointDecimal}` transforms.gradient = gradientStr } } + }, + distort: (values, transforms) => { + if (values.distort) { + const { distortType, distortPerspective, distortArcDegree } = values + const distortPrefix = distortType === "perspective" ? "p" : "a" + if (distortType === "perspective" && distortPerspective) { + const { x1, y1, x2, y2, x3, y3, x4, y4 } = distortPerspective as Record + const formattedCoords = [x1, y1, x2, y2, x3, y3, x4, y4].map(coord => coord.toString().replace(/^-/,"N")) + transforms["e-distort"] = `${distortPrefix}-${formattedCoords.join("_")}` + } else if (distortType === "arc" && distortArcDegree !== undefined && distortArcDegree !== null) { + transforms["e-distort"] = `${distortPrefix}-${distortArcDegree.toString().replace(/^-/,"N")}` + } + } } } From 7e3c44f73c4f3fb3c8be4a8148eae65acd0998d9 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Fri, 30 Jan 2026 19:34:24 +0530 Subject: [PATCH 33/48] Refactor padding input --- .../src/components/common/PaddingInput.tsx | 154 +++++------------- .../sidebar/transformation-config-sidebar.tsx | 4 +- 2 files changed, 46 insertions(+), 112 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx index de7ac98..58ad486 100644 --- a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx @@ -23,7 +23,9 @@ import { FieldErrors } from "react-hook-form" type PaddingMode = "uniform" | "individual" -type PaddingState = { +type PaddingDirection = "top" | "right" | "bottom" | "left" + +export type PaddingState = { mode: PaddingMode padding: number | PaddingObject | null | string } @@ -45,7 +47,7 @@ export type PaddingObject = { function getUpdatedPaddingValue( current: number | PaddingObject | null | string, - side: "top" | "right" | "bottom" | "left" | "all", + side: PaddingDirection | "all", value: string, mode: "uniform" | "individual" ): number | PaddingObject | null | string { @@ -112,7 +114,7 @@ export const PaddingInputField: React.FC = ({ const formattedValue = formatPaddingValue(paddingValue) onChange({ mode: paddingMode, padding: formattedValue }) }, [paddingValue, paddingMode]) - + return ( = ({ justifyContent="space-between" > - { paddingMode === "uniform" ? ( + {paddingMode === "uniform" ? ( = ({ }} value={["number", "string"].includes(typeof paddingValue) ? paddingValue : ""} placeholder="Uniform Padding" - isInvalid={!!errors?.[propertyName]} + isInvalid={!!errors?.[propertyName]?.padding} + fontSize="sm" /> - {errors?.[propertyName]?.message} + {errors?.[propertyName]?.padding?.message} ) : ( <> - - - - - - { - const val = e.target.value - setPaddingValue(getUpdatedPaddingValue( - paddingValue, - "top", - val, - paddingMode - )) - }} - value={typeof paddingValue === "object" ? paddingValue?.top ?? "" : ""} - placeholder="Top" - isInvalid={!!errors?.[propertyName]?.top} - /> - - {errors?.[propertyName]?.top?.message} - - - - - - - - { - const val = e.target.value - setPaddingValue(getUpdatedPaddingValue( - paddingValue, - "right", - val, - paddingMode - )) - }} - value={typeof paddingValue === "object" ? paddingValue?.right ?? "" : ""} - placeholder="Right" - isInvalid={!!errors?.[propertyName]?.right} - /> - - {errors?.[propertyName]?.right?.message} - - - - - - - - { - const val = e.target.value - setPaddingValue(getUpdatedPaddingValue( - paddingValue, - "bottom", - val, - paddingMode - )) - }} - value={typeof paddingValue === "object" ? paddingValue?.bottom ?? "" : ""} - placeholder="Bottom" - isInvalid={!!errors?.[propertyName]?.bottom} - /> - - {errors?.[propertyName]?.bottom?.message} - - - - - - - - { - const val = e.target.value - setPaddingValue(getUpdatedPaddingValue( - paddingValue, - "left", - val, - paddingMode - )) - }} - value={typeof paddingValue === "object" ? paddingValue?.left ?? "" : ""} - placeholder="Left" - isInvalid={!!errors?.[propertyName]?.left} - /> - - {errors?.[propertyName]?.left?.message} - - + {[ + { name: "top", label: "Top", icon: LuArrowUpToLine }, + { name: "right", label: "Right", icon: LuArrowRightToLine }, + { name: "bottom", label: "Bottom", icon: LuArrowDownToLine }, + { name: "left", label: "Left", icon: LuArrowLeftToLine }, + ].map(({ name, label, icon }) => ( + + + + + + { + const val = e.target.value + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + name as PaddingDirection, + val, + paddingMode + )) + }} + value={typeof paddingValue === "object" ? paddingValue?.[name as PaddingDirection] ?? "" : ""} + placeholder={label} + isInvalid={!!errors?.[propertyName]?.padding?.[name as PaddingDirection]} + fontSize="sm" + /> + + {errors?.[propertyName]?.padding?.[name as PaddingDirection]?.message} + + )) + } - ) } + )} - { errors={errors} name={field.name} {...field.fieldProps} - value={watch(field.name) as string | number | PaddingObject | null} + value={watch(field.name) as Partial} /> ) : null} {field.fieldType === "zoom" ? ( From 3fb5b785dc60c51f99c795c607013977a164b2c6 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Fri, 30 Jan 2026 19:43:16 +0530 Subject: [PATCH 34/48] Add per corner radius support in base and overlay image --- .../components/common/CornerRadiusInput.tsx | 217 ++++++++++++++++++ .../sidebar/transformation-config-sidebar.tsx | 13 ++ .../imagekit-editor-dev/src/schema/index.ts | 151 +++++++++--- 3 files changed, 353 insertions(+), 28 deletions(-) create mode 100644 packages/imagekit-editor-dev/src/components/common/CornerRadiusInput.tsx diff --git a/packages/imagekit-editor-dev/src/components/common/CornerRadiusInput.tsx b/packages/imagekit-editor-dev/src/components/common/CornerRadiusInput.tsx new file mode 100644 index 0000000..22b2c8e --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/CornerRadiusInput.tsx @@ -0,0 +1,217 @@ +import { + Box, + Flex, + HStack, + Icon, + Text, + Input, + InputGroup, + InputLeftElement, + IconButton, + useColorModeValue, + Tooltip, +} from "@chakra-ui/react" +import { set } from "lodash" +import type * as React from "react" +import { useState, useEffect, forwardRef } from "react" +import { RxCornerTopLeft } from "@react-icons/all-files/rx/RxCornerTopLeft" +import { RxCornerTopRight } from "@react-icons/all-files/rx/RxCornerTopRight" +import { RxCornerBottomRight } from "@react-icons/all-files/rx/RxCornerBottomRight" +import { RxCornerBottomLeft } from "@react-icons/all-files/rx/RxCornerBottomLeft" +import { TbBorderCorners } from "@react-icons/all-files/tb/TbBorderCorners" +import { FieldErrors } from "react-hook-form" + +type RadiusMode = "uniform" | "individual" + +export type RadiusState = { + mode: RadiusMode + radius: RadiusObject | string +} + +type RadiusInputFieldProps = { + id?: string + onChange: (value: RadiusState) => void + errors?: FieldErrors> + name: string, + value?: Partial +} + +export type RadiusObject = { + topLeft: string | "max" + topRight: string | "max" + bottomRight: string | "max" + bottomLeft: string | "max" +} + +type RadiusDirection = "topLeft" | "topRight" | "bottomRight" | "bottomLeft" + +function getUpdatedRadiusValue( + current: RadiusObject | string, + corner: RadiusDirection | "all", + value: string, + mode: "uniform" | "individual" +): RadiusObject | string { + let inputValue: RadiusObject | number | string + try { + inputValue = JSON.parse(value) + } catch { + inputValue = value + } + if (mode === "uniform") { + if (inputValue === "") { + return "" + } else if (typeof inputValue === "string" || typeof inputValue === "number") { + return inputValue.toString() + } else { + const { topLeft, topRight, bottomRight, bottomLeft } = inputValue + if (topLeft === topRight && topLeft === bottomRight && topLeft === bottomLeft) { + return topLeft + } else { + return ""; + } + } + } else { + let commonValue: string = "" + if (typeof inputValue === "string" || typeof inputValue === "number") { + commonValue = inputValue.toString() + } + const updatedRadius = current && typeof current === "object" + ? { ...current } + : { topLeft: commonValue, topRight: commonValue, bottomRight: commonValue, bottomLeft: commonValue } + if (corner !== "all") { + set(updatedRadius, corner, inputValue.toString()) + } + return updatedRadius + } +} + +export const RadiusInputField: React.FC = ({ + id, + onChange, + errors, + name: propertyName, + value +}) => { + const [radiusMode, setRadiusMode] = useState(value?.mode ?? "uniform") + const [radiusValue, setRadiusValue] = useState(value?.radius ?? "") + const errorRed = useColorModeValue("red.500", "red.300") + const activeColor = useColorModeValue("blue.500", "blue.600") + const inactiveColor = useColorModeValue("gray.600", "gray.400") + + useEffect(() => { + const formatRadiusValue = (value: RadiusObject | string): string | RadiusObject => { + if (value === "") return "" + if (typeof value === "string") { + return value + } else { + return value; + } + } + const formattedValue = formatRadiusValue(radiusValue) + onChange({ mode: radiusMode, radius: formattedValue }) + }, [radiusValue, radiusMode]) + + + return ( + + + {radiusMode === "uniform" ? ( + + { + const val = e.target.value + setRadiusValue(getUpdatedRadiusValue( + radiusValue, + "all", + val, + radiusMode + )) + }} + value={typeof radiusValue === "string" ? radiusValue : ""} + placeholder="Uniform Radius" + isInvalid={!!errors?.[propertyName]?.radius} + fontSize="sm" + /> + {errors?.[propertyName]?.radius?.message} + + ) : ( + <> + {[ + { name: "topLeft", label: "Top Left", icon: RxCornerTopLeft }, + { name: "topRight", label: "Top Right", icon: RxCornerTopRight }, + { name: "bottomLeft", label: "Bottom Left", icon: RxCornerBottomLeft }, + { name: "bottomRight", label: "Bottom Right", icon: RxCornerBottomRight }, + ].map(({ name, label, icon }) => ( + + + + + + { + const val = e.target.value + setRadiusValue(getUpdatedRadiusValue( + radiusValue, + name as RadiusDirection, + val, + radiusMode + )) + }} + value={typeof radiusValue === "object" ? radiusValue?.[name as RadiusDirection] ?? "" : ""} + placeholder={label} + isInvalid={!!errors?.[propertyName]?.radius?.[name as RadiusDirection]} + fontSize="sm" + /> + + {errors?.[propertyName]?.radius?.[name as RadiusDirection]?.message} + + ))} + + )} + + + } + padding="0.05em" + onClick={() => { + const newRadiusMode = radiusMode === "uniform" ? "individual" : "uniform" + setRadiusValue(getUpdatedRadiusValue( + radiusValue, + "all", + JSON.stringify(radiusValue), + newRadiusMode + )) + setRadiusMode(newRadiusMode) + }} + variant="outline" + color={radiusMode === "individual" ? activeColor : inactiveColor} + /> + + + ) +} + +export default RadiusInputField 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 1bbdd3c..560c806 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 @@ -59,6 +59,7 @@ import { SidebarRoot } from "./sidebar-root" import PaddingInputField, { PaddingState } from "../common/PaddingInput" import ZoomInput from "../common/ZoomInput" import DistortPerspectiveInput, { PerspectiveObject } from "../common/DistortPerspectiveInput" +import RadiusInputField, { RadiusState } from "../common/CornerRadiusInput" export const TransformationConfigSidebar: React.FC = () => { const { @@ -606,6 +607,18 @@ export const TransformationConfigSidebar: React.FC = () => { {...field.fieldProps} /> ) : null} + {field.fieldType === "radius-input" ? ( + { + setValue(field.name, value) + trigger(field.name) + }} + errors={errors} + name={field.name} + value={watch(field.name) as Partial} + {...field.fieldProps} + /> + ) : null} {String( errors[field.name as keyof typeof errors]?.message ?? "", diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 985b59c..4901760 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -1690,14 +1690,51 @@ export const transformationSchema: TransformationSchema[] = [ defaultTransformation: {}, schema: z .object({ - radius: z.union([ - z.literal("max"), - z.coerce - .number({ + radius: z.object({ + mode: z.enum(["uniform", "individual"]).optional(), + radius: z.union([ + z.literal("max"), + z.coerce.number({ invalid_type_error: "Should be a number.", - }) - .min(0), - ]), + }).min(0, { + message: "Negative values are not allowed.", + }), + z.object({ + topLeft: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + topRight: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + bottomRight: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + bottomLeft: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + }), + ]).optional(), + }).optional(), }) .refine( (val) => { @@ -1717,12 +1754,15 @@ export const transformationSchema: TransformationSchema[] = [ { label: "Radius", name: "radius", - fieldType: "input", + fieldType: "radius-input", isTransformation: true, - transformationKey: "r", + transformationGroup: "radius", helpText: "Enter a positive integer for rounded corners or 'max' for a fully circular output.", examples: ["10", "max"], + fieldProps: { + defaultValue: {} + } }, ], }, @@ -2639,16 +2679,6 @@ export const transformationSchema: TransformationSchema[] = [ }) .optional(), backgroundColor: z.string().optional(), - radius: z - .union([ - z.literal("max"), - z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .min(0), - ]) - .optional(), flip: z .array(z.enum(["horizontal", "vertical"]).optional()) .optional(), @@ -2751,6 +2781,53 @@ export const transformationSchema: TransformationSchema[] = [ y4: z.union([z.literal(""), z.coerce.number()]), }).optional(), distortArcDegree: z.coerce.number().min(-359).max(359).optional(), + + // Radius + radius: z.object({ + mode: z.enum(["uniform", "individual"]).optional(), + radius: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + z.object({ + topLeft: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + topRight: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + bottomRight: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + bottomLeft: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + }), + ]).optional(), + }).optional(), }) .refine( (val) => { @@ -3036,12 +3113,15 @@ export const transformationSchema: TransformationSchema[] = [ { label: "Radius", name: "radius", - fieldType: "input", + fieldType: "radius-input", isTransformation: true, - transformationKey: "radius", transformationGroup: "imageLayer", helpText: "Set the corner radius for the overlay image. Use 'max' for a circle or oval.", + examples: ["10", "max"], + fieldProps: { + defaultValue: {} + } }, { label: "Flip", @@ -3288,7 +3368,7 @@ export const transformationSchema: TransformationSchema[] = [ type: "number", placeholder: "Arc Degrees", } - } + }, ], }, ], @@ -3653,12 +3733,6 @@ export const transformationFormatters: Record< overlayTransform.background = values.backgroundColor.replace(/^#/, "") } - if (values.radius === "max") { - overlayTransform.radius = "max" - } else if (values.radius as number) { - overlayTransform.radius = values.radius as number - } - if ((values.flip as Array)?.length) { const flip = [] if ((values.flip as Array).includes("horizontal")) { @@ -3705,6 +3779,7 @@ export const transformationFormatters: Record< transformationFormatters.gradient(values, overlayTransform) transformationFormatters.shadow(values, overlayTransform) transformationFormatters.distort(values, overlayTransform) + transformationFormatters.radius(values, overlayTransform) if (values.grayscale) { overlayTransform.grayscale = true @@ -3794,5 +3869,25 @@ export const transformationFormatters: Record< transforms["e-distort"] = `${distortPrefix}-${distortArcDegree.toString().replace(/^-/,"N")}` } } + }, + radius: (values, transforms) => { + if (values.radius) { + const { radius, mode } = values.radius as Record + if (mode === "uniform" && (typeof radius === "number" || typeof radius === "string")) { + transforms.radius = radius + } else if (mode === "individual" && typeof radius === "object" && radius !== null) { + const { topLeft, topRight, bottomRight, bottomLeft } = radius as { + topLeft: number | "max" + topRight: number | "max" + bottomRight: number | "max" + bottomLeft: number | "max" + } + if (topLeft === topRight && topLeft === bottomRight && topLeft === bottomLeft) { + transforms.radius = topLeft + } else { + transforms.radius = `${topLeft}_${topRight}_${bottomRight}_${bottomLeft}` + } + } + } } } From 06dee02d1f207e357008dbe1990a5d8e9f93e1dc Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Mon, 2 Feb 2026 20:46:07 +0530 Subject: [PATCH 35/48] fix: color picker alpha behavior consistent with the downstream service --- .../components/common/ColorPickerField.tsx | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx b/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx index 76eee42..21a208b 100644 --- a/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx +++ b/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx @@ -21,6 +21,61 @@ const ColorPickerField = ({ }) => { const [localValue, setLocalValue] = useState(value) + /** + * @note: This parsing behavior is not a bug, it has been mimicked to match the downstream service + * logic i.e. parseInt(hexAlpha, 10) / 100, which parses the hex digits as decimal, stopping at + * non-digit characters. + */ + const parseAlphaLikeDownstream = (hexAlpha: string): number => { + const parsed = parseInt(hexAlpha, 10) + return isNaN(parsed) ? 0 : parsed / 100 + } + + /** + * Helper function to convert alpha back to hex format that will parse correctly downstream. + * We need to find a hex value that, when parsed as decimal by downstream, gives us the desired alpha. + * + * For example: + * - If alpha is 0.99, we want downstream to get 99, so we need "99" in hex + * - If alpha is 0.5, we want downstream to get 50, so we need "50" in hex + */ + const alphaToHexForDownstream = (alpha: number): string => { + const targetDecimal = Math.round(alpha * 100) + const clampedDecimal = Math.max(0, Math.min(99, targetDecimal)) + return clampedDecimal.toString().padStart(2, "0") + } + + // Convert a color from downstream format to standard format for the color picker + const convertDownstreamToStandard = (color: string): string => { + if (!color || !color.startsWith('#') || color.length !== 9) { + return color + } + + const rgb = color.slice(1, 7) + const alphaHex = color.slice(7, 9) + const parsedAlpha = parseAlphaLikeDownstream(alphaHex) + + // Convert to standard 0-255 range + const standardAlphaInt = Math.round(parsedAlpha * 255) + const standardAlphaHex = standardAlphaInt.toString(16).padStart(2, "0") + + return `#${rgb}${standardAlphaHex}` + } + + // Get the preview color that shows what downstream will actually render + const getPreviewColor = (color: string): string => { + if (!color || !color.startsWith('#')) { + return color + } + + if (color.length === 9) { + // Has alpha channel - convert using downstream logic + return convertDownstreamToStandard(color) + } + + return color + } + const handleColorChange = (color: string) => { const parts = color.match(/[\d.]+/g)?.map(Number) ?? [] @@ -39,8 +94,8 @@ const ColorPickerField = ({ setLocalValue(`#${rgbHex}`) } else { const alphaDec = a > 1 ? a / 100 : a - const alphaInt = clamp8(Math.round(alphaDec * 255)) - setLocalValue(`#${rgbHex}${alphaInt.toString(16).padStart(2, "0")}`) + const alphaHex = alphaToHexForDownstream(alphaDec) + setLocalValue(`#${rgbHex}${alphaHex}`) } } @@ -50,6 +105,10 @@ const ColorPickerField = ({ setValue(fieldName, debouncedValue) }, [debouncedValue, fieldName, setValue]) + useEffect(() => { + setLocalValue(value) + }, [value]) + return ( @@ -84,7 +143,7 @@ const ColorPickerField = ({ height="10" align="center" justify="center" - bg={localValue} + bg={getPreviewColor(localValue)} borderWidth="1px" borderColor="gray.200" borderLeft="0" @@ -95,7 +154,7 @@ const ColorPickerField = ({ Date: Mon, 2 Feb 2026 20:46:26 +0530 Subject: [PATCH 36/48] chore: added .cursor to gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index eda5341..e1c4725 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ packages/imagekit-editor/*.tgz .turbo .yarn builds -packages/imagekit-editor/README.md \ No newline at end of file +packages/imagekit-editor/README.md +.cursor \ No newline at end of file From 4014c008956ba2d70d49f8a51c4d4b8b4dc17eff Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Wed, 4 Feb 2026 16:42:02 +0530 Subject: [PATCH 37/48] Improved distort perspective implementation --- .../common/DistortPerspectiveInput.tsx | 259 ++++++------------ .../sidebar/transformation-config-sidebar.tsx | 28 +- .../imagekit-editor-dev/src/schema/index.ts | 104 +++++-- 3 files changed, 183 insertions(+), 208 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx b/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx index 6ae4210..1a87d86 100644 --- a/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx @@ -1,16 +1,15 @@ import { Box, - Flex, HStack, VStack, Icon, Text, Input, InputGroup, - InputLeftElement, - IconButton, + useColorModeValue, - Tooltip, + InputLeftAddon, + FormLabel, } from "@chakra-ui/react"; import type * as React from "react"; import { useState, useEffect } from "react"; @@ -64,7 +63,7 @@ export const DistortPerspectiveInput: React.FC = ({ const val = e.target.value.trim(); setPerspective((prev) => ({ ...prev, - [fieldName]: val, + [fieldName]: val?.toUpperCase(), })); }; } @@ -74,170 +73,94 @@ export const DistortPerspectiveInput: React.FC = ({ }, [perspective]); return ( - - - - - - - - - - - {[ - errors?.[propertyName]?.x1?.message, - errors?.[propertyName]?.y1?.message, - ] - .filter(Boolean) - .join(". ")} - - - - - - - - - - - - - {[ - errors?.[propertyName]?.x2?.message, - errors?.[propertyName]?.y2?.message, - ] - .filter(Boolean) - .join(". ")} - - - - - - - - - - - - - {[ - errors?.[propertyName]?.x3?.message, - errors?.[propertyName]?.y3?.message, - ] - .filter(Boolean) - .join(". ")} - - + + {[ + { + label: "Top left", + name: "topLeft", + icon: RxArrowTopLeft, + x: "x1", + y: "y1", + }, + { + label: "Top right", + name: "topRight", + icon: RxArrowTopRight, + x: "x2", + y: "y2", + }, + { + label: "Bottom right", + name: "bottomRight", + icon: RxArrowBottomRight, + x: "x3", + y: "y3", + }, + { + label: "Bottom left", + name: "bottomLeft", + icon: RxArrowBottomLeft, + x: "x4", + y: "y4", + }, + ].map(({ label, name, icon, x, y }) => ( + + + + + + + {label} corner coordinates + + + + + + + + + + {errors?.[propertyName]?.[x]?.message} + + - - - - - - - - - - {[ - errors?.[propertyName]?.x4?.message, - errors?.[propertyName]?.y4?.message, - ] - .filter(Boolean) - .join(". ")} - - + + + + + + + {errors?.[propertyName]?.[y]?.message} + + + + + ))} ); }; 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 560c806..c0f6f04 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 @@ -444,30 +444,34 @@ export const TransformationConfigSidebar: React.FC = () => { { const raw = watch(field.name) - const n = Number(raw) + const n = Number(String(raw).toUpperCase().replace(/^N/, "-")) + const isNumberWithN = typeof raw === "string" && !Number.isNaN(n) && raw.toUpperCase().startsWith("N") if (!Number.isFinite(n)) return - const { step, min, max } = field.fieldProps ?? {} + const { step, min, max, skipStepCheck } = field.fieldProps ?? {} let v = n if (min !== undefined) v = Math.max(v, min) if (max !== undefined) v = Math.min(v, max) - if (step) { + if (!skipStepCheck && step) { v = Math.round(v / step) * step const dp = (String(step).split(".")[1] || "").length v = Number(v.toFixed(dp)) } - setValue(field.name, String(v)) + const finalValue = v < 0 && isNumberWithN ? `N${Math.abs(v)}` : String(v) + setValue(field.name, finalValue) }} onChange={(e) => { const val = e.target.value + const numSafeVal = String(val).toUpperCase().replace(/^N/, "-") + const isNumberWithN = typeof val === "string" && !Number.isNaN(Number(numSafeVal)) && val.toUpperCase().startsWith("N") if (val === "") { setValue(field.name, "") @@ -485,18 +489,20 @@ export const TransformationConfigSidebar: React.FC = () => { ) { setValue(field.name, "auto") } else if ( + !field.fieldProps?.skipStepCheck && field.fieldProps?.step && !isStepAligned(val, field.fieldProps?.step) ) { return } else if ( field.fieldProps?.min !== undefined && - Number(val) < field.fieldProps.min + Number(numSafeVal) < field.fieldProps.min ) { - setValue(field.name, field.fieldProps.min) + const finalVal = field.fieldProps.min < 0 && isNumberWithN ? `N${Math.abs(field.fieldProps.min)}` : String(field.fieldProps.min) + setValue(field.name, finalVal) } else if ( field.fieldProps?.max !== undefined && - Number(val) > field.fieldProps.max + Number(numSafeVal) > field.fieldProps.max ) { setValue(field.name, field.fieldProps.max) } else { @@ -522,9 +528,9 @@ export const TransformationConfigSidebar: React.FC = () => { max={field.fieldProps?.max || 100} step={field.fieldProps?.step || 1} value={ - Number.isNaN(Number(watch(field.name))) + Number.isNaN(Number(String(watch(field.name)).toUpperCase().replace(/^N/, "-"))) ? 0 - : Number(watch(field.name)) + : Number(String(watch(field.name)).toUpperCase().replace(/^N/, "-")) } defaultValue={field.fieldProps?.defaultValue as number} onChange={(val) => setValue(field.name, val.toString())} @@ -533,7 +539,7 @@ export const TransformationConfigSidebar: React.FC = () => { - + ) : null} diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 4901760..b5b3790 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -11,7 +11,7 @@ import { RxFontItalic } from "@react-icons/all-files/rx/RxFontItalic" import { RxTextAlignCenter } from "@react-icons/all-files/rx/RxTextAlignCenter" import { RxTextAlignLeft } from "@react-icons/all-files/rx/RxTextAlignLeft" import { RxTextAlignRight } from "@react-icons/all-files/rx/RxTextAlignRight" -import { z } from "zod/v3" +import { RefinementCtx, z } from "zod/v3" import { SIMPLE_OVERLAY_TEXT_REGEX, safeBtoa } from "../utils" import { aspectRatioValidator, @@ -22,6 +22,7 @@ import { widthValidator, } from "./transformation" import { GradientPickerState } from "../components/common/GradientPicker" +import { PerspectiveObject } from "../components/common/DistortPerspectiveInput" // Based on ImageKit's supported object list export const DEFAULT_FOCUS_OBJECTS = [ @@ -1437,16 +1438,16 @@ export const transformationSchema: TransformationSchema[] = [ distort: z.coerce.boolean(), distortType: z.enum(["perspective", "arc"]).optional(), distortPerspective: z.object({ - x1: z.union([z.literal(""), z.coerce.number()]), - y1: z.union([z.literal(""), z.coerce.number()]), - x2: z.union([z.literal(""), z.coerce.number()]), - y2: z.union([z.literal(""), z.coerce.number()]), - x3: z.union([z.literal(""), z.coerce.number()]), - y3: z.union([z.literal(""), z.coerce.number()]), - x4: z.union([z.literal(""), z.coerce.number()]), - y4: z.union([z.literal(""), z.coerce.number()]), + x1: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y1: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + x2: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y2: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + x3: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y3: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + x4: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y4: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), }).optional(), - distortArcDegree: z.coerce.number().min(-359).max(359).optional(), + distortArcDegree: z.string().regex(/^[-N]?\d+$/).optional(), }) .refine( (val) => { @@ -1461,7 +1462,10 @@ export const transformationSchema: TransformationSchema[] = [ message: "At least one value is required", path: [], }, - ), + ) + .superRefine((val, ctx) => { + validatePerspectiveDistort(val, ctx); + }), transformations: [ { label: "Distort", @@ -1509,15 +1513,19 @@ export const transformationSchema: TransformationSchema[] = [ { label: "Distortion Arc Degrees", name: "distortArcDegree", - fieldType: "input", + fieldType: "slider", isTransformation: true, transformationGroup: "distort", isVisible: ({ distort, distortType }) => distort === true && distortType === "arc", helpText: "Enter the arc degree for the arc distortion effect.", - examples: ["15", "30", "45"], + examples: ["15", "30", "-45", "N50"], fieldProps: { - type: "number", - placeholder: "Arc Degrees", + min: -360, + max: 360, + step: 5, + defaultValue: "0", + inputType: "text", + skipStepCheck: true, } } ], @@ -2771,16 +2779,16 @@ export const transformationSchema: TransformationSchema[] = [ distort: z.coerce.boolean(), distortType: z.enum(["perspective", "arc"]).optional(), distortPerspective: z.object({ - x1: z.union([z.literal(""), z.coerce.number()]), - y1: z.union([z.literal(""), z.coerce.number()]), - x2: z.union([z.literal(""), z.coerce.number()]), - y2: z.union([z.literal(""), z.coerce.number()]), - x3: z.union([z.literal(""), z.coerce.number()]), - y3: z.union([z.literal(""), z.coerce.number()]), - x4: z.union([z.literal(""), z.coerce.number()]), - y4: z.union([z.literal(""), z.coerce.number()]), + x1: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y1: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + x2: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y2: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + x3: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y3: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + x4: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y4: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), }).optional(), - distortArcDegree: z.coerce.number().min(-359).max(359).optional(), + distortArcDegree: z.string().regex(/^[-N]?\d+$/).optional(), // Radius radius: z.object({ @@ -2874,6 +2882,8 @@ export const transformationSchema: TransformationSchema[] = [ } } } + + validatePerspectiveDistort(val, ctx); }), transformations: [ { @@ -3358,15 +3368,19 @@ export const transformationSchema: TransformationSchema[] = [ { label: "Distortion Arc Degrees", name: "distortArcDegree", - fieldType: "input", + fieldType: "slider", isTransformation: true, transformationGroup: "imageLayer", isVisible: ({ distort, distortType }) => distort === true && distortType === "arc", helpText: "Enter the arc degree for the arc distortion effect.", - examples: ["15", "30", "45"], + examples: ["15", "30", "-45", "N50"], fieldProps: { - type: "number", - placeholder: "Arc Degrees", + min: -360, + max: 360, + step: 5, + defaultValue: "0", + inputType: "text", + skipStepCheck: true, } }, ], @@ -3862,7 +3876,7 @@ export const transformationFormatters: Record< const { distortType, distortPerspective, distortArcDegree } = values const distortPrefix = distortType === "perspective" ? "p" : "a" if (distortType === "perspective" && distortPerspective) { - const { x1, y1, x2, y2, x3, y3, x4, y4 } = distortPerspective as Record + const { x1, y1, x2, y2, x3, y3, x4, y4 } = distortPerspective as Record const formattedCoords = [x1, y1, x2, y2, x3, y3, x4, y4].map(coord => coord.toString().replace(/^-/,"N")) transforms["e-distort"] = `${distortPrefix}-${formattedCoords.join("_")}` } else if (distortType === "arc" && distortArcDegree !== undefined && distortArcDegree !== null) { @@ -3891,3 +3905,35 @@ export const transformationFormatters: Record< } } } + + +function validatePerspectiveDistort(value: {distortPerspective: PerspectiveObject, distort: boolean, distortType: string} & any, ctx: RefinementCtx) { + const {distort, distortType, distortPerspective} = value; + if (distort && distortType === "perspective" && distortPerspective) { + const perspective: PerspectiveObject = structuredClone(distortPerspective); + let { x1, y1, x2, y2, x3, y3, x4, y4 } = Object.keys(perspective).reduce((acc, key) => { + const value = perspective[key as keyof typeof perspective]; + if (!value) { + acc[key as keyof PerspectiveObject] = value; + } + const numString = value.toUpperCase().replace(/^N/, "-"); + acc[key as keyof PerspectiveObject] = parseInt(numString as string, 10); + return acc; + }, {} as Record); + const allValuesProvided = [x1, y1, x2, y2, x3, y3, x4, y4].every(v => v === 0 || Boolean(v)); + if (allValuesProvided) { + const isTopLeftValid = x1 < x2 && x1 < x3 && y1 < y3 && y1 < y4; + const isTopRightValid = x2 > x1 && x2 > x4 && y2 < y3 && y2 < y4; + const isBottomRightValid = x3 > x4 && x3 > x1 && y3 > y1 && y3 > y2; + const isBottomLeftValid = x4 < x3 && x4 < x2 && y4 > y1 && y4 > y2; + let isValid = isTopLeftValid && isTopRightValid && isBottomRightValid && isBottomLeftValid; + if (!isValid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Perspective coordinates are invalid.", + path: ["distortPerspective"] + }); + } + } + } +} \ No newline at end of file From 63b7d037b0e2e6f35271c0a196ea1f812e1c908f Mon Sep 17 00:00:00 2001 From: Abhinav Dhiman Date: Fri, 6 Feb 2026 11:02:54 +0530 Subject: [PATCH 38/48] chore: Add VSCode extension recommendations for TypeScript and Biome --- .vscode/extensions.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .vscode/extensions.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..2cbda18 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["ms-vscode.vscode-typescript-next", "biomejs.biome"] +} From a25aae479a5cc57fa546cd45416833a0a4828cab Mon Sep 17 00:00:00 2001 From: Abhinav Dhiman Date: Fri, 6 Feb 2026 11:04:43 +0530 Subject: [PATCH 39/48] chore(workflows): Add lint step to CI and publish --- .github/workflows/ci.yaml | 3 ++- .github/workflows/node-publish.yml | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2c36aa3..da9c2de 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,6 +25,7 @@ jobs: - name: 📦 Install deps, build, pack run: | yarn install --frozen-lockfile + yarn lint yarn package env: CI: true @@ -33,4 +34,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: imagekit-editor-package - path: builds/imagekit-editor-*.tgz \ No newline at end of file + path: builds/imagekit-editor-*.tgz diff --git a/.github/workflows/node-publish.yml b/.github/workflows/node-publish.yml index ec24b5e..0e45607 100644 --- a/.github/workflows/node-publish.yml +++ b/.github/workflows/node-publish.yml @@ -22,12 +22,14 @@ jobs: with: node-version: 20.x cache: yarn - registry-url: 'https://registry.npmjs.org' + registry-url: "https://registry.npmjs.org" - name: Build and Publish run: | yarn install --frozen-lockfile + yarn lint + yarn build npm whoami @@ -48,4 +50,4 @@ jobs: env: NODE_AUTH_TOKEN: ${{secrets.npm_token}} - CI: true \ No newline at end of file + CI: true From 50b44b533555fb978924849846ca10c58713e8c2 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Fri, 6 Feb 2026 11:28:22 +0530 Subject: [PATCH 40/48] Fix linting issues --- .../common/DistortPerspectiveInput.tsx | 128 +++++++------- .../src/components/common/PaddingInput.tsx | 166 ++++++++++++------ .../src/components/common/ZoomInput.tsx | 33 ++-- 3 files changed, 187 insertions(+), 140 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx b/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx index 1a87d86..36c7336 100644 --- a/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx @@ -1,42 +1,50 @@ import { Box, + FormLabel, HStack, - VStack, Icon, - Text, Input, InputGroup, - - useColorModeValue, InputLeftAddon, - FormLabel, -} from "@chakra-ui/react"; -import type * as React from "react"; -import { useState, useEffect } from "react"; -import { RxArrowTopLeft } from "@react-icons/all-files/rx/RxArrowTopLeft"; -import { RxArrowTopRight } from "@react-icons/all-files/rx/RxArrowTopRight"; -import { RxArrowBottomRight } from "@react-icons/all-files/rx/RxArrowBottomRight"; -import { RxArrowBottomLeft } from "@react-icons/all-files/rx/RxArrowBottomLeft"; -import { FieldErrors } from "react-hook-form"; - -type DistorPerspectiveFieldProps = { - name: string; - id?: string; - onChange: (value: PerspectiveObject) => void; - errors?: FieldErrors>; - value?: PerspectiveObject; -}; + Text, + useColorModeValue, + VStack, +} from "@chakra-ui/react" +import { RxArrowBottomLeft } from "@react-icons/all-files/rx/RxArrowBottomLeft" +import { RxArrowBottomRight } from "@react-icons/all-files/rx/RxArrowBottomRight" +import { RxArrowTopLeft } from "@react-icons/all-files/rx/RxArrowTopLeft" +import { RxArrowTopRight } from "@react-icons/all-files/rx/RxArrowTopRight" +import type * as React from "react" +import { useEffect, useState } from "react" export type PerspectiveObject = { - x1: string; - y1: string; - x2: string; - y2: string; - x3: string; - y3: string; - x4: string; - y4: string; -}; + x1: string + y1: string + x2: string + y2: string + x3: string + y3: string + x4: string + y4: string +} + +type ErrorObject = { + message: string +} + +type PerspectiveErrors = { + [key in keyof PerspectiveObject]?: ErrorObject +} & ErrorObject + +type AllErrors = Record + +type DistorPerspectiveFieldProps = { + name: string + id?: string + onChange: (value: PerspectiveObject) => void + errors?: AllErrors + value?: PerspectiveObject +} export const DistortPerspectiveInput: React.FC = ({ id, @@ -45,32 +53,34 @@ export const DistortPerspectiveInput: React.FC = ({ name: propertyName, value, }) => { - const [perspective, setPerspective] = useState(value ?? { - x1: "", - y1: "", - x2: "", - y2: "", - x3: "", - y3: "", - x4: "", - y4: "", - }); - const errorRed = useColorModeValue("red.500", "red.300"); - const leftAccessoryBackground = useColorModeValue("gray.100", "gray.700"); + const [perspective, setPerspective] = useState( + value ?? { + x1: "", + y1: "", + x2: "", + y2: "", + x3: "", + y3: "", + x4: "", + y4: "", + }, + ) + const errorRed = useColorModeValue("red.500", "red.300") + const leftAccessoryBackground = useColorModeValue("gray.100", "gray.700") function handleFieldChange(fieldName: string) { return (e: React.ChangeEvent) => { - const val = e.target.value.trim(); + const val = e.target.value.trim() setPerspective((prev) => ({ ...prev, [fieldName]: val?.toUpperCase(), - })); - }; + })) + } } useEffect(() => { - onChange(perspective); - }, [perspective]); + onChange(perspective) + }, [perspective]) return ( @@ -125,44 +135,38 @@ export const DistortPerspectiveInput: React.FC = ({ - + {x.toUpperCase()} - {errors?.[propertyName]?.[x]?.message} + {errors?.[propertyName]?.[x as keyof PerspectiveObject ]?.message} - + {y.toUpperCase()} - {errors?.[propertyName]?.[y]?.message} + {errors?.[propertyName]?.[y as keyof PerspectiveObject]?.message} ))} - ); -}; + ) +} -export default DistortPerspectiveInput; +export default DistortPerspectiveInput diff --git a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx index 58ad486..6908fb5 100644 --- a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx @@ -3,23 +3,22 @@ import { Flex, HStack, Icon, - Text, + IconButton, Input, InputGroup, InputLeftElement, - IconButton, - useColorModeValue, + Text, Tooltip, + useColorModeValue, } from "@chakra-ui/react" -import { set } from "lodash" -import type * as React from "react" -import { useState, useEffect, forwardRef } from "react" +import { LuArrowDownToLine } from "@react-icons/all-files/lu/LuArrowDownToLine" import { LuArrowLeftToLine } from "@react-icons/all-files/lu/LuArrowLeftToLine" import { LuArrowRightToLine } from "@react-icons/all-files/lu/LuArrowRightToLine" import { LuArrowUpToLine } from "@react-icons/all-files/lu/LuArrowUpToLine" -import { LuArrowDownToLine } from "@react-icons/all-files/lu/LuArrowDownToLine" import { TbBoxPadding } from "@react-icons/all-files/tb/TbBoxPadding" -import { FieldErrors } from "react-hook-form" +import { set } from "lodash" +import type * as React from "react" +import { useEffect, useState } from "react" type PaddingMode = "uniform" | "individual" @@ -30,14 +29,6 @@ export type PaddingState = { padding: number | PaddingObject | null | string } -type PaddingInputFieldProps = { - id?: string - onChange: (value: PaddingState) => void - errors?: FieldErrors> - name: string, - value?: Partial -} - export type PaddingObject = { top: number | null right: number | null @@ -45,11 +36,31 @@ export type PaddingObject = { left: number | null } +type ErrorObject = { + message: string +} + +type PaddingErrors = { + [key in keyof PaddingObject]?: ErrorObject +} & ErrorObject + +type AllErrors = Record + +type PaddingInputFieldProps = { + id?: string + onChange: (value: PaddingState) => void + errors?: AllErrors + name: string + value?: Partial +} + function getUpdatedPaddingValue( current: number | PaddingObject | null | string, side: PaddingDirection | "all", value: string, - mode: "uniform" | "individual" + mode: "uniform" | "individual", ): number | PaddingObject | null | string { let inputValue: number | PaddingObject | null | string try { @@ -77,9 +88,15 @@ function getUpdatedPaddingValue( if (typeof inputValue === "number") { commonValue = inputValue } - const updatedPadding = current && typeof current === "object" - ? { ...current } - : { top: commonValue, right: commonValue, bottom: commonValue, left: commonValue } + const updatedPadding = + current && typeof current === "object" + ? { ...current } + : { + top: commonValue, + right: commonValue, + bottom: commonValue, + left: commonValue, + } if (side !== "all") { set(updatedPadding, side, inputValue) } @@ -92,30 +109,35 @@ export const PaddingInputField: React.FC = ({ onChange, errors, name: propertyName, - value + value, }) => { - const [paddingMode, setPaddingMode] = useState(value?.mode ?? "uniform") - const [paddingValue, setPaddingValue] = useState(value?.padding ?? "") + const [paddingMode, setPaddingMode] = useState( + value?.mode ?? "uniform", + ) + const [paddingValue, setPaddingValue] = useState< + number | PaddingObject | null | string + >(value?.padding ?? "") const errorRed = useColorModeValue("red.500", "red.300") const activeColor = useColorModeValue("blue.500", "blue.600") const inactiveColor = useColorModeValue("gray.600", "gray.400") useEffect(() => { - const formatPaddingValue = (value: number | PaddingObject | null | string): string | PaddingObject => { + const formatPaddingValue = ( + value: number | PaddingObject | null | string, + ): string | PaddingObject => { if (value === null) return "" if (typeof value === "number") { return value.toString() } else if (typeof value === "string") { return value } else { - return value; + return value } } const formattedValue = formatPaddingValue(paddingValue) onChange({ mode: paddingMode, padding: formattedValue }) }, [paddingValue, paddingMode]) - return ( = ({ min={0} onChange={(e) => { const val = e.target.value - setPaddingValue(getUpdatedPaddingValue( - paddingValue, - "all", - val, - paddingMode - )) + setPaddingValue( + getUpdatedPaddingValue(paddingValue, "all", val, paddingMode), + ) }} - value={["number", "string"].includes(typeof paddingValue) ? paddingValue : ""} + value={ + ["number", "string"].includes(typeof paddingValue) + ? (paddingValue as string | number) + : "" + } placeholder="Uniform Padding" isInvalid={!!errors?.[propertyName]?.padding} fontSize="sm" /> - {errors?.[propertyName]?.padding?.message} + + {errors?.[propertyName]?.padding?.message} + ) : ( <> @@ -155,7 +180,7 @@ export const PaddingInputField: React.FC = ({ { name: "bottom", label: "Bottom", icon: LuArrowDownToLine }, { name: "left", label: "Left", icon: LuArrowLeftToLine }, ].map(({ name, label, icon }) => ( - + @@ -165,54 +190,79 @@ export const PaddingInputField: React.FC = ({ min={0} onChange={(e) => { const val = e.target.value - setPaddingValue(getUpdatedPaddingValue( - paddingValue, - name as PaddingDirection, - val, - paddingMode - )) + setPaddingValue( + getUpdatedPaddingValue( + paddingValue, + name as PaddingDirection, + val, + paddingMode, + ), + ) }} - value={typeof paddingValue === "object" ? paddingValue?.[name as PaddingDirection] ?? "" : ""} + value={ + typeof paddingValue === "object" + ? (paddingValue?.[name as PaddingDirection] ?? "") + : "" + } placeholder={label} - isInvalid={!!errors?.[propertyName]?.padding?.[name as PaddingDirection]} + isInvalid={ + !!errors?.[propertyName]?.padding?.[ + name as PaddingDirection + ] + } fontSize="sm" /> - {errors?.[propertyName]?.padding?.[name as PaddingDirection]?.message} + + { + errors?.[propertyName]?.padding?.[name as PaddingDirection] + ?.message + } + - )) - } + ))} )} } padding="0.05em" onClick={() => { - const newPaddingMode = paddingMode === "uniform" ? "individual" : "uniform" - setPaddingValue(getUpdatedPaddingValue( - paddingValue, - "all", - JSON.stringify(paddingValue), - newPaddingMode - )) + const newPaddingMode = + paddingMode === "uniform" ? "individual" : "uniform" + setPaddingValue( + getUpdatedPaddingValue( + paddingValue, + "all", + JSON.stringify(paddingValue), + newPaddingMode, + ), + ) setPaddingMode(newPaddingMode) }} variant="outline" diff --git a/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx index 3738483..9f364e7 100644 --- a/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx @@ -1,17 +1,16 @@ import { + ButtonGroup, HStack, + IconButton, Input, InputGroup, InputRightElement, - IconButton, - ButtonGroup, Text, - useColorModeValue, } from "@chakra-ui/react" -import type * as React from "react" -import { useState, useEffect } from "react" -import { AiOutlinePlus } from "@react-icons/all-files/ai/AiOutlinePlus" import { AiOutlineMinus } from "@react-icons/all-files/ai/AiOutlineMinus" +import { AiOutlinePlus } from "@react-icons/all-files/ai/AiOutlinePlus" +import type * as React from "react" +import { useEffect, useState } from "react" type ZoomInputFieldProps = { id?: string @@ -22,13 +21,12 @@ type ZoomInputFieldProps = { const STEP_SIZE = 10 - /** * Calculate the next zoom value when zooming in * Rounds up to the next step value */ function calculateZoomIn(currentValue: number): number { - return (Math.floor(currentValue / STEP_SIZE) * STEP_SIZE) + STEP_SIZE + return Math.floor(currentValue / STEP_SIZE) * STEP_SIZE + STEP_SIZE } /** @@ -36,7 +34,7 @@ function calculateZoomIn(currentValue: number): number { * Rounds down to the previous step value */ function calculateZoomOut(currentValue: number): number { - return (Math.ceil(currentValue / STEP_SIZE) * STEP_SIZE) - STEP_SIZE + return Math.ceil(currentValue / STEP_SIZE) * STEP_SIZE - STEP_SIZE } export const ZoomInput: React.FC = ({ @@ -46,7 +44,9 @@ export const ZoomInput: React.FC = ({ value, }) => { const [zoomValue, setZoomValue] = useState(value ?? defaultValue) - const [inputValue, setInputValue] = useState((value ?? defaultValue).toString()) + const [inputValue, setInputValue] = useState( + (value ?? defaultValue).toString(), + ) useEffect(() => { onChange(zoomValue) @@ -56,9 +56,9 @@ export const ZoomInput: React.FC = ({ const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value setInputValue(value) - + const numValue = Number(value) - if (!isNaN(numValue) && numValue >= 0) { + if (!Number.isNaN(numValue) && numValue >= 0) { setZoomValue(numValue) } } @@ -87,13 +87,7 @@ export const ZoomInput: React.FC = ({ } return ( - + = ({ onClick={handleZoomIn} /> - ) } From 199d437e0ec53008110f10a81b38bab5448261a9 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Mon, 9 Feb 2026 16:56:23 +0530 Subject: [PATCH 41/48] Fix type and linting issues --- .../components/common/CheckboxCardField.tsx | 5 +- .../components/common/CornerRadiusInput.tsx | 188 +++-- .../common/DistortPerspectiveInput.tsx | 26 +- .../src/components/common/PaddingInput.tsx | 16 +- .../src/components/common/ZoomInput.tsx | 8 +- .../sidebar/transformation-config-sidebar.tsx | 102 ++- .../imagekit-editor-dev/src/schema/index.ts | 746 +++++++++++------- 7 files changed, 704 insertions(+), 387 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx b/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx index 1a870bd..9e043d8 100644 --- a/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx +++ b/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx @@ -1,4 +1,5 @@ import { + type As, Box, Flex, HStack, @@ -67,6 +68,7 @@ export const CheckboxCardField: React.FC = ({ } return ( + // biome-ignore lint/a11y/useSemanticElements: = ({ const isChecked = value.includes(opt.value) const disabled = opt.isDisabled || (!isChecked && isMaxed) return ( + // biome-ignore lint/a11y/useSemanticElements: = ({ }} > - {opt.icon ? : null} + {opt.icon ? : null} {opt.label} diff --git a/packages/imagekit-editor-dev/src/components/common/CornerRadiusInput.tsx b/packages/imagekit-editor-dev/src/components/common/CornerRadiusInput.tsx index 22b2c8e..dce5a0c 100644 --- a/packages/imagekit-editor-dev/src/components/common/CornerRadiusInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/CornerRadiusInput.tsx @@ -3,23 +3,22 @@ import { Flex, HStack, Icon, - Text, + IconButton, Input, InputGroup, InputLeftElement, - IconButton, - useColorModeValue, + Text, Tooltip, + useColorModeValue, } from "@chakra-ui/react" -import { set } from "lodash" -import type * as React from "react" -import { useState, useEffect, forwardRef } from "react" +import { RxCornerBottomLeft } from "@react-icons/all-files/rx/RxCornerBottomLeft" +import { RxCornerBottomRight } from "@react-icons/all-files/rx/RxCornerBottomRight" import { RxCornerTopLeft } from "@react-icons/all-files/rx/RxCornerTopLeft" import { RxCornerTopRight } from "@react-icons/all-files/rx/RxCornerTopRight" -import { RxCornerBottomRight } from "@react-icons/all-files/rx/RxCornerBottomRight" -import { RxCornerBottomLeft } from "@react-icons/all-files/rx/RxCornerBottomLeft" import { TbBorderCorners } from "@react-icons/all-files/tb/TbBorderCorners" -import { FieldErrors } from "react-hook-form" +import { set } from "lodash" +import type * as React from "react" +import { useEffect, useState } from "react" type RadiusMode = "uniform" | "individual" @@ -28,14 +27,6 @@ export type RadiusState = { radius: RadiusObject | string } -type RadiusInputFieldProps = { - id?: string - onChange: (value: RadiusState) => void - errors?: FieldErrors> - name: string, - value?: Partial -} - export type RadiusObject = { topLeft: string | "max" topRight: string | "max" @@ -43,13 +34,36 @@ export type RadiusObject = { bottomLeft: string | "max" } +type ErrorObject = { + message: string +} + +type CornerErrors = { + [key in keyof RadiusObject]?: ErrorObject +} & ErrorObject + +export type RadiusErrors = Record< + string, + { + radius?: CornerErrors + } +> + +type RadiusInputFieldProps = { + id?: string + onChange: (value: RadiusState) => void + errors?: RadiusErrors + name: string + value?: Partial +} + type RadiusDirection = "topLeft" | "topRight" | "bottomRight" | "bottomLeft" function getUpdatedRadiusValue( current: RadiusObject | string, corner: RadiusDirection | "all", value: string, - mode: "uniform" | "individual" + mode: "uniform" | "individual", ): RadiusObject | string { let inputValue: RadiusObject | number | string try { @@ -60,14 +74,21 @@ function getUpdatedRadiusValue( if (mode === "uniform") { if (inputValue === "") { return "" - } else if (typeof inputValue === "string" || typeof inputValue === "number") { + } else if ( + typeof inputValue === "string" || + typeof inputValue === "number" + ) { return inputValue.toString() } else { const { topLeft, topRight, bottomRight, bottomLeft } = inputValue - if (topLeft === topRight && topLeft === bottomRight && topLeft === bottomLeft) { + if ( + topLeft === topRight && + topLeft === bottomRight && + topLeft === bottomLeft + ) { return topLeft } else { - return ""; + return "" } } } else { @@ -75,9 +96,15 @@ function getUpdatedRadiusValue( if (typeof inputValue === "string" || typeof inputValue === "number") { commonValue = inputValue.toString() } - const updatedRadius = current && typeof current === "object" - ? { ...current } - : { topLeft: commonValue, topRight: commonValue, bottomRight: commonValue, bottomLeft: commonValue } + const updatedRadius = + current && typeof current === "object" + ? { ...current } + : { + topLeft: commonValue, + topRight: commonValue, + bottomRight: commonValue, + bottomLeft: commonValue, + } if (corner !== "all") { set(updatedRadius, corner, inputValue.toString()) } @@ -90,29 +117,36 @@ export const RadiusInputField: React.FC = ({ onChange, errors, name: propertyName, - value + value, }) => { - const [radiusMode, setRadiusMode] = useState(value?.mode ?? "uniform") - const [radiusValue, setRadiusValue] = useState(value?.radius ?? "") + const [radiusMode, setRadiusMode] = useState( + value?.mode ?? "uniform", + ) + const [radiusValue, setRadiusValue] = useState( + value?.radius ?? "", + ) const errorRed = useColorModeValue("red.500", "red.300") const activeColor = useColorModeValue("blue.500", "blue.600") const inactiveColor = useColorModeValue("gray.600", "gray.400") + // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { - const formatRadiusValue = (value: RadiusObject | string): string | RadiusObject => { + const formatRadiusValue = ( + value: RadiusObject | string, + ): string | RadiusObject => { if (value === "") return "" if (typeof value === "string") { return value } else { - return value; + return value } } const formattedValue = formatRadiusValue(radiusValue) onChange({ mode: radiusMode, radius: formattedValue }) }, [radiusValue, radiusMode]) - return ( + // biome-ignore lint/a11y/useSemanticElements: = ({ { const val = e.target.value - setRadiusValue(getUpdatedRadiusValue( - radiusValue, - "all", - val, - radiusMode - )) + setRadiusValue( + getUpdatedRadiusValue(radiusValue, "all", val, radiusMode), + ) }} value={typeof radiusValue === "string" ? radiusValue : ""} placeholder="Uniform Radius" isInvalid={!!errors?.[propertyName]?.radius} fontSize="sm" /> - {errors?.[propertyName]?.radius?.message} + + {errors?.[propertyName]?.radius?.message} + ) : ( + // biome-ignore lint/complexity/noUselessFragments: <> {[ { name: "topLeft", label: "Top Left", icon: RxCornerTopLeft }, { name: "topRight", label: "Top Right", icon: RxCornerTopRight }, - { name: "bottomLeft", label: "Bottom Left", icon: RxCornerBottomLeft }, - { name: "bottomRight", label: "Bottom Right", icon: RxCornerBottomRight }, + { + name: "bottomLeft", + label: "Bottom Left", + icon: RxCornerBottomLeft, + }, + { + name: "bottomRight", + label: "Bottom Right", + icon: RxCornerBottomRight, + }, ].map(({ name, label, icon }) => ( @@ -157,20 +199,35 @@ export const RadiusInputField: React.FC = ({ { const val = e.target.value - setRadiusValue(getUpdatedRadiusValue( - radiusValue, - name as RadiusDirection, - val, - radiusMode - )) + setRadiusValue( + getUpdatedRadiusValue( + radiusValue, + name as RadiusDirection, + val, + radiusMode, + ), + ) }} - value={typeof radiusValue === "object" ? radiusValue?.[name as RadiusDirection] ?? "" : ""} + value={ + typeof radiusValue === "object" + ? (radiusValue?.[name as RadiusDirection] ?? "") + : "" + } placeholder={label} - isInvalid={!!errors?.[propertyName]?.radius?.[name as RadiusDirection]} + isInvalid={ + !!errors?.[propertyName]?.radius?.[ + name as RadiusDirection + ] + } fontSize="sm" /> - {errors?.[propertyName]?.radius?.[name as RadiusDirection]?.message} + + { + errors?.[propertyName]?.radius?.[name as RadiusDirection] + ?.message + } + ))} @@ -178,32 +235,43 @@ export const RadiusInputField: React.FC = ({ } padding="0.05em" onClick={() => { - const newRadiusMode = radiusMode === "uniform" ? "individual" : "uniform" - setRadiusValue(getUpdatedRadiusValue( - radiusValue, - "all", - JSON.stringify(radiusValue), - newRadiusMode - )) + const newRadiusMode = + radiusMode === "uniform" ? "individual" : "uniform" + setRadiusValue( + getUpdatedRadiusValue( + radiusValue, + "all", + JSON.stringify(radiusValue), + newRadiusMode, + ), + ) setRadiusMode(newRadiusMode) }} variant="outline" diff --git a/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx b/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx index 36c7336..d6594f4 100644 --- a/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx @@ -32,17 +32,17 @@ type ErrorObject = { message: string } -type PerspectiveErrors = { +type CornerErrors = { [key in keyof PerspectiveObject]?: ErrorObject } & ErrorObject -type AllErrors = Record +export type PerspectiveErrors = Record type DistorPerspectiveFieldProps = { name: string id?: string onChange: (value: PerspectiveObject) => void - errors?: AllErrors + errors?: PerspectiveErrors value?: PerspectiveObject } @@ -78,11 +78,13 @@ export const DistortPerspectiveInput: React.FC = ({ } } + // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { onChange(perspective) }, [perspective]) return ( + // biome-ignore lint/a11y/useSemanticElements: {[ { @@ -139,12 +141,17 @@ export const DistortPerspectiveInput: React.FC = ({ - {errors?.[propertyName]?.[x as keyof PerspectiveObject ]?.message} + { + errors?.[propertyName]?.[x as keyof PerspectiveObject] + ?.message + } @@ -154,12 +161,17 @@ export const DistortPerspectiveInput: React.FC = ({ - {errors?.[propertyName]?.[y as keyof PerspectiveObject]?.message} + { + errors?.[propertyName]?.[y as keyof PerspectiveObject] + ?.message + } diff --git a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx index 6908fb5..b2a8470 100644 --- a/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx @@ -40,18 +40,21 @@ type ErrorObject = { message: string } -type PaddingErrors = { +type SidesErrors = { [key in keyof PaddingObject]?: ErrorObject } & ErrorObject -type AllErrors = Record +export type PaddingErrors = Record< + string, + { + padding?: SidesErrors + } +> type PaddingInputFieldProps = { id?: string onChange: (value: PaddingState) => void - errors?: AllErrors + errors?: PaddingErrors name: string value?: Partial } @@ -121,6 +124,7 @@ export const PaddingInputField: React.FC = ({ const activeColor = useColorModeValue("blue.500", "blue.600") const inactiveColor = useColorModeValue("gray.600", "gray.400") + // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { const formatPaddingValue = ( value: number | PaddingObject | null | string, @@ -139,6 +143,7 @@ export const PaddingInputField: React.FC = ({ }, [paddingValue, paddingMode]) return ( + // biome-ignore lint/a11y/useSemanticElements: = ({ ) : ( + // biome-ignore lint/complexity/noUselessFragments: <> {[ { name: "top", label: "Top", icon: LuArrowUpToLine }, diff --git a/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx index 9f364e7..22561e6 100644 --- a/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx @@ -15,7 +15,7 @@ import { useEffect, useState } from "react" type ZoomInputFieldProps = { id?: string onChange: (value: number) => void - defaultValue?: number + defaultValue?: number | unknown value?: number } @@ -43,11 +43,12 @@ export const ZoomInput: React.FC = ({ defaultValue = 100, value, }) => { - const [zoomValue, setZoomValue] = useState(value ?? defaultValue) + const [zoomValue, setZoomValue] = useState(value ?? (defaultValue as number)) const [inputValue, setInputValue] = useState( - (value ?? defaultValue).toString(), + (value ?? (defaultValue as number)).toString(), ) + // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { onChange(zoomValue) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -87,6 +88,7 @@ export const ZoomInput: React.FC = ({ } return ( + // biome-ignore lint/a11y/useSemanticElements: { const { @@ -87,7 +98,10 @@ export const TransformationConfigSidebar: React.FC = () => { ) }, [_internalState.selectedTransformationKey]) - const transformationToEdit = _internalState.transformationToEdit + const transformationToEdit = _internalState.transformationToEdit as { + transformationId: string + position: "inplace" + } const editedTransformation = useMemo(() => { if (!transformationToEdit) return undefined @@ -97,7 +111,9 @@ export const TransformationConfigSidebar: React.FC = () => { ) }, [transformations, transformationToEdit]) - const editedTransformationValue = editedTransformation?.value as Record | undefined + const editedTransformationValue = editedTransformation?.value as + | Record + | undefined const defaultValues = useMemo(() => { if ( @@ -299,7 +315,15 @@ export const TransformationConfigSidebar: React.FC = () => { return true }) .map((field: TransformationField) => ( - + field.fieldType === type, + ) + } + > {field.label} @@ -423,6 +447,9 @@ export const TransformationConfigSidebar: React.FC = () => { fontSize="sm" {...register(field.name)} {...(field.fieldProps ?? {})} + defaultValue={ + field.fieldProps?.defaultValue as string | number | readonly string[] | undefined + } /> ) : null} {field.fieldType === "textarea" ? ( @@ -445,18 +472,29 @@ export const TransformationConfigSidebar: React.FC = () => { { const raw = watch(field.name) - const n = Number(String(raw).toUpperCase().replace(/^N/, "-")) - const isNumberWithN = typeof raw === "string" && !Number.isNaN(n) && raw.toUpperCase().startsWith("N") + const n = Number( + String(raw).toUpperCase().replace(/^N/, "-"), + ) + const isNumberWithN = + typeof raw === "string" && + !Number.isNaN(n) && + raw.toUpperCase().startsWith("N") if (!Number.isFinite(n)) return - const { step, min, max, skipStepCheck } = field.fieldProps ?? {} + const { step, min, max, skipStepCheck } = + field.fieldProps ?? {} let v = n if (min !== undefined) v = Math.max(v, min) @@ -466,13 +504,19 @@ export const TransformationConfigSidebar: React.FC = () => { const dp = (String(step).split(".")[1] || "").length v = Number(v.toFixed(dp)) } - const finalValue = v < 0 && isNumberWithN ? `N${Math.abs(v)}` : String(v) + const finalValue = + v < 0 && isNumberWithN ? `N${Math.abs(v)}` : String(v) setValue(field.name, finalValue) }} onChange={(e) => { const val = e.target.value - const numSafeVal = String(val).toUpperCase().replace(/^N/, "-") - const isNumberWithN = typeof val === "string" && !Number.isNaN(Number(numSafeVal)) && val.toUpperCase().startsWith("N") + const numSafeVal = String(val) + .toUpperCase() + .replace(/^N/, "-") + const isNumberWithN = + typeof val === "string" && + !Number.isNaN(Number(numSafeVal)) && + val.toUpperCase().startsWith("N") if (val === "") { setValue(field.name, "") @@ -499,7 +543,10 @@ export const TransformationConfigSidebar: React.FC = () => { field.fieldProps?.min !== undefined && Number(numSafeVal) < field.fieldProps.min ) { - const finalVal = field.fieldProps.min < 0 && isNumberWithN ? `N${Math.abs(field.fieldProps.min)}` : String(field.fieldProps.min) + const finalVal = + field.fieldProps.min < 0 && isNumberWithN + ? `N${Math.abs(field.fieldProps.min)}` + : String(field.fieldProps.min) setValue(field.name, finalVal) } else if ( field.fieldProps?.max !== undefined && @@ -529,9 +576,19 @@ export const TransformationConfigSidebar: React.FC = () => { max={field.fieldProps?.max || 100} step={field.fieldProps?.step || 1} value={ - Number.isNaN(Number(String(watch(field.name)).toUpperCase().replace(/^N/, "-"))) + Number.isNaN( + Number( + String(watch(field.name)) + .toUpperCase() + .replace(/^N/, "-"), + ), + ) ? 0 - : Number(String(watch(field.name)).toUpperCase().replace(/^N/, "-")) + : Number( + String(watch(field.name)) + .toUpperCase() + .replace(/^N/, "-"), + ) } defaultValue={field.fieldProps?.defaultValue as number} onChange={(val) => setValue(field.name, val.toString())} @@ -589,7 +646,7 @@ export const TransformationConfigSidebar: React.FC = () => { setValue(field.name, value) trigger(field.name) }} - errors={errors} + errors={errors as PaddingErrors} name={field.name} {...field.fieldProps} value={watch(field.name) as Partial} @@ -599,7 +656,6 @@ export const TransformationConfigSidebar: React.FC = () => { setValue(field.name, value)} - defaultValue={field.fieldProps?.defaultValue as number ?? 100} {...field.fieldProps} /> ) : null} @@ -609,7 +665,7 @@ export const TransformationConfigSidebar: React.FC = () => { setValue(field.name, value) trigger(field.name) }} - errors={errors} + errors={errors as PerspectiveErrors} name={field.name} value={watch(field.name) as PerspectiveObject} {...field.fieldProps} @@ -621,7 +677,7 @@ export const TransformationConfigSidebar: React.FC = () => { setValue(field.name, value) trigger(field.name) }} - errors={errors} + errors={errors as RadiusErrors} name={field.name} value={watch(field.name) as Partial} {...field.fieldProps} diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 1c42a0b..4703690 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -11,22 +11,21 @@ import { RxFontItalic } from "@react-icons/all-files/rx/RxFontItalic" import { RxTextAlignCenter } from "@react-icons/all-files/rx/RxTextAlignCenter" import { RxTextAlignLeft } from "@react-icons/all-files/rx/RxTextAlignLeft" import { RxTextAlignRight } from "@react-icons/all-files/rx/RxTextAlignRight" -import { RefinementCtx, z } from "zod/v3" +import { type RefinementCtx, z } from "zod/v3" +import type { PerspectiveObject } from "../components/common/DistortPerspectiveInput" +import type { GradientPickerState } from "../components/common/GradientPicker" import { SIMPLE_OVERLAY_TEXT_REGEX, safeBtoa } from "../utils" import { aspectRatioValidator, colorValidator, commonNumberAndExpressionValidator, heightValidator, - overlayBlockExprValidator, layerXValidator, layerYValidator, optionalPositiveFloatNumberValidator, refineUnsharpenMask, widthValidator, } from "./transformation" -import { GradientPickerState } from "../components/common/GradientPicker" -import { PerspectiveObject } from "../components/common/DistortPerspectiveInput" // Based on ImageKit's supported object list export const DEFAULT_FOCUS_OBJECTS = [ @@ -513,7 +512,7 @@ export const transformationSchema: TransformationSchema[] = [ isTransformation: true, transformationGroup: "focus", fieldProps: { - isCreatable: false, + defaultValue: 100, }, helpText: "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", @@ -634,7 +633,7 @@ export const transformationSchema: TransformationSchema[] = [ isTransformation: true, transformationGroup: "focus", fieldProps: { - isCreatable: false, + defaultValue: 100, }, helpText: "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", @@ -1038,7 +1037,7 @@ export const transformationSchema: TransformationSchema[] = [ isTransformation: true, transformationGroup: "focus", fieldProps: { - isCreatable: false, + defaultValue: 100, }, helpText: "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", @@ -1370,23 +1369,33 @@ export const transformationSchema: TransformationSchema[] = [ defaultTransformation: {}, schema: z .object({ - gradient: z.object({ - from: z.string().optional(), - to: z.string().optional(), - direction: z.union([ - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0).max(359), - z.string(), - ]).optional(), - stopPoint: z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(1).max(100).optional(), - }).optional(), - gradientSwitch: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", + gradient: z + .object({ + from: z.string().optional(), + to: z.string().optional(), + direction: z + .union([ + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0) + .max(359), + z.string(), + ]) + .optional(), + stopPoint: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(1) + .max(100) + .optional(), }) + .optional(), + gradientSwitch: z.coerce.boolean({ + invalid_type_error: "Should be a boolean.", + }), }) .refine( (val) => { @@ -1425,8 +1434,8 @@ export const transformationSchema: TransformationSchema[] = [ to: "#00000000", direction: "bottom", stopPoint: 100, - } - } + }, + }, }, ], }, @@ -1441,17 +1450,22 @@ export const transformationSchema: TransformationSchema[] = [ .object({ distort: z.coerce.boolean(), distortType: z.enum(["perspective", "arc"]).optional(), - distortPerspective: z.object({ - x1: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - y1: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - x2: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - y2: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - x3: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - y3: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - x4: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - y4: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - }).optional(), - distortArcDegree: z.string().regex(/^[-N]?\d+$/).optional(), + distortPerspective: z + .object({ + x1: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y1: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + x2: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y2: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + x3: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y3: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + x4: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y4: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + }) + .optional(), + distortArcDegree: z + .string() + .regex(/^[-N]?\d+$/) + .optional(), }) .refine( (val) => { @@ -1468,7 +1482,7 @@ export const transformationSchema: TransformationSchema[] = [ }, ) .superRefine((val, ctx) => { - validatePerspectiveDistort(val, ctx); + validatePerspectiveDistort(val, ctx) }), transformations: [ { @@ -1500,7 +1514,8 @@ export const transformationSchema: TransformationSchema[] = [ fieldType: "distort-perspective-input", isTransformation: false, transformationGroup: "distort", - isVisible: ({ distort, distortType }) => distort === true && distortType === "perspective", + isVisible: ({ distort, distortType }) => + distort === true && distortType === "perspective", fieldProps: { defaultValue: { x1: "", @@ -1511,8 +1526,8 @@ export const transformationSchema: TransformationSchema[] = [ y3: "", x4: "", y4: "", - } - } + }, + }, }, { label: "Distortion Arc Degrees", @@ -1520,7 +1535,8 @@ export const transformationSchema: TransformationSchema[] = [ fieldType: "slider", isTransformation: true, transformationGroup: "distort", - isVisible: ({ distort, distortType }) => distort === true && distortType === "arc", + isVisible: ({ distort, distortType }) => + distort === true && distortType === "arc", helpText: "Enter the arc degree for the arc distortion effect.", examples: ["15", "30", "-45", "N50"], fieldProps: { @@ -1530,8 +1546,8 @@ export const transformationSchema: TransformationSchema[] = [ defaultValue: "0", inputType: "text", skipStepCheck: true, - } - } + }, + }, ], }, { @@ -1702,51 +1718,65 @@ export const transformationSchema: TransformationSchema[] = [ defaultTransformation: {}, schema: z .object({ - radius: z.object({ - mode: z.enum(["uniform", "individual"]).optional(), - radius: z.union([ - z.literal("max"), - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - z.object({ - topLeft: z.union([ - z.literal("max"), - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - ]), - topRight: z.union([ - z.literal("max"), - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - ]), - bottomRight: z.union([ - z.literal("max"), - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - ]), - bottomLeft: z.union([ + radius: z + .object({ + mode: z.enum(["uniform", "individual"]).optional(), + radius: z + .union([ z.literal("max"), - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + z.object({ + topLeft: z.union([ + z.literal("max"), + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + ]), + topRight: z.union([ + z.literal("max"), + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + ]), + bottomRight: z.union([ + z.literal("max"), + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + ]), + bottomLeft: z.union([ + z.literal("max"), + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + ]), }), - ]), - }), - ]).optional(), - }).optional(), + ]) + .optional(), + }) + .optional(), }) .refine( (val) => { @@ -1773,8 +1803,8 @@ export const transformationSchema: TransformationSchema[] = [ "Enter a positive integer for rounded corners or 'max' for a fully circular output.", examples: ["10", "max"], fieldProps: { - defaultValue: {} - } + defaultValue: {}, + }, }, ], }, @@ -2099,19 +2129,27 @@ export const transformationSchema: TransformationSchema[] = [ 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 + 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", @@ -2145,8 +2183,7 @@ export const transformationSchema: TransformationSchema[] = [ label: "Amount", isTransformation: false, transformationGroup: "unsharpenMask", - helpText: - "Sets the strength of the sharpening effect.", + helpText: "Sets the strength of the sharpening effect.", fieldProps: { defaultValue: "", }, @@ -2158,15 +2195,14 @@ export const transformationSchema: TransformationSchema[] = [ label: "Threshold", isTransformation: false, transformationGroup: "unsharpenMask", - helpText: - "Set the threshold value for the unsharpen mask.", + helpText: "Set the threshold value for the unsharpen mask.", fieldProps: { defaultValue: "", }, examples: ["0.1", "2", "0.8"], }, - ] - } + ], + }, ], }, { @@ -2644,15 +2680,16 @@ export const transformationSchema: TransformationSchema[] = [ defaultTransformation: {}, schema: z .object({ - dpr: - z.union([ + dpr: z + .union([ z.coerce .number({ invalid_type_error: "Should be a number.", }) .optional(), z.literal("auto"), - ]).optional(), + ]) + .optional(), }) .refine( (val) => { @@ -2718,38 +2755,51 @@ export const transformationSchema: TransformationSchema[] = [ innerAlignment: z .enum(["left", "right", "center"]) .default("center"), - padding: z.object({ - mode: z.enum(["uniform", "individual"]).optional(), - padding: z.union([ - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - z.object({ - top: z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - right: z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - bottom: z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - left: z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - }), - ]).optional(), - }) + padding: z + .object({ + mode: z.enum(["uniform", "individual"]).optional(), + padding: z + .union([ + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + z.object({ + top: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + right: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + bottom: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + left: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + }), + ]) + .optional(), + }) .optional(), opacity: z .union([ @@ -3048,13 +3098,14 @@ export const transformationSchema: TransformationSchema[] = [ .optional(), backgroundColor: z.string().optional(), dprEnabled: z.boolean().optional(), - dpr: z.union([ - z.coerce - .number({ + dpr: z + .union([ + z.coerce.number({ invalid_type_error: "Should be a number.", }), - z.literal("auto"), - ]).optional(), + z.literal("auto"), + ]) + .optional(), flip: z .array(z.enum(["horizontal", "vertical"]).optional()) .optional(), @@ -3103,20 +3154,32 @@ export const transformationSchema: TransformationSchema[] = [ gradientSwitch: z.coerce .boolean({ invalid_type_error: "Should be a boolean.", - }).optional(), - gradient: z.object({ - from: z.string().optional(), - to: z.string().optional(), - direction: z.union([ - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0).max(359), - z.string(), - ]).optional(), - stopPoint: z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(1).max(100).optional(), - }).optional(), + }) + .optional(), + gradient: z + .object({ + from: z.string().optional(), + to: z.string().optional(), + direction: z + .union([ + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0) + .max(359), + z.string(), + ]) + .optional(), + stopPoint: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(1) + .max(100) + .optional(), + }) + .optional(), // Shadow properties shadow: z.coerce @@ -3155,64 +3218,83 @@ export const transformationSchema: TransformationSchema[] = [ // Distort distort: z.coerce.boolean(), distortType: z.enum(["perspective", "arc"]).optional(), - distortPerspective: z.object({ - x1: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - y1: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - x2: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - y2: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - x3: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - y3: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - x4: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - y4: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), - }).optional(), - distortArcDegree: z.string().regex(/^[-N]?\d+$/).optional(), + distortPerspective: z + .object({ + x1: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y1: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + x2: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y2: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + x3: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y3: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + x4: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + y4: z.union([z.literal(""), z.string().regex(/^[-N]?\d+$/)]), + }) + .optional(), + distortArcDegree: z + .string() + .regex(/^[-N]?\d+$/) + .optional(), // Radius - radius: z.object({ - mode: z.enum(["uniform", "individual"]).optional(), - radius: z.union([ - z.literal("max"), - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - z.object({ - topLeft: z.union([ - z.literal("max"), - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - ]), - topRight: z.union([ - z.literal("max"), - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - ]), - bottomRight: z.union([ - z.literal("max"), - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", - }), - ]), - bottomLeft: z.union([ + radius: z + .object({ + mode: z.enum(["uniform", "individual"]).optional(), + radius: z + .union([ z.literal("max"), - z.coerce.number({ - invalid_type_error: "Should be a number.", - }).min(0, { - message: "Negative values are not allowed.", + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + z.object({ + topLeft: z.union([ + z.literal("max"), + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + ]), + topRight: z.union([ + z.literal("max"), + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + ]), + bottomRight: z.union([ + z.literal("max"), + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + ]), + bottomLeft: z.union([ + z.literal("max"), + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0, { + message: "Negative values are not allowed.", + }), + ]), }), - ]), - }), - ]).optional(), - }).optional(), + ]) + .optional(), + }) + .optional(), sharpenEnabled: z.coerce .boolean({ invalid_type_error: "Should be a boolean.", @@ -3227,11 +3309,15 @@ export const transformationSchema: TransformationSchema[] = [ .max(99) .optional(), unsharpenMask: z.coerce.boolean().optional(), - unsharpenMaskRadius: optionalPositiveFloatNumberValidator.optional(), + unsharpenMaskRadius: + optionalPositiveFloatNumberValidator.optional(), unsharpenMaskSigma: optionalPositiveFloatNumberValidator.optional(), - unsharpenMaskAmount: optionalPositiveFloatNumberValidator.optional(), - unsharpenMaskThreshold: optionalPositiveFloatNumberValidator.optional(), - }).superRefine(refineUnsharpenMask) + unsharpenMaskAmount: + optionalPositiveFloatNumberValidator.optional(), + unsharpenMaskThreshold: + optionalPositiveFloatNumberValidator.optional(), + }) + .superRefine(refineUnsharpenMask) .refine( (val) => { return Object.values(val).some( @@ -3278,7 +3364,7 @@ export const transformationSchema: TransformationSchema[] = [ } } - validatePerspectiveDistort(val, ctx); + validatePerspectiveDistort(val, ctx) }), transformations: [ { @@ -3361,10 +3447,19 @@ export const transformationSchema: TransformationSchema[] = [ transformationGroup: "imageLayer", fieldProps: { positions: [ - "center", "top", "bottom", "left", "right", "top_left", "top_right", "bottom_left", "bottom_right", + "center", + "top", + "bottom", + "left", + "right", + "top_left", + "top_right", + "bottom_left", + "bottom_right", ], }, - isVisible: ({ focus, crop }) => focus === "anchor" && crop === "cm-extract", + isVisible: ({ focus, crop }) => + focus === "anchor" && crop === "cm-extract", }, // Only for pad_resize crop mode { @@ -3374,9 +3469,7 @@ export const transformationSchema: TransformationSchema[] = [ isTransformation: true, transformationGroup: "imageLayer", fieldProps: { - positions: [ - "center", "top", "bottom", "left", "right", - ], + positions: ["center", "top", "bottom", "left", "right"], }, isVisible: ({ crop }) => crop === "cm-pad_resize", }, @@ -3452,7 +3545,8 @@ export const transformationSchema: TransformationSchema[] = [ fieldType: "input", isTransformation: true, transformationGroup: "imageLayer", - helpText: "Vertical center position of the overlay image. Use an integer or expression.", + helpText: + "Vertical center position of the overlay image. Use an integer or expression.", examples: ["200", "ih_mul_0.5"], isVisible: ({ focus, coordinateMethod }) => focus === "coordinates" && coordinateMethod === "center", @@ -3464,7 +3558,7 @@ export const transformationSchema: TransformationSchema[] = [ isTransformation: true, transformationGroup: "imageLayer", fieldProps: { - isCreatable: false, + defaultValue: 100, }, helpText: "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", @@ -3555,8 +3649,8 @@ export const transformationSchema: TransformationSchema[] = [ "Set the corner radius for the overlay image. Use 'max' for a circle or oval.", examples: ["10", "max"], fieldProps: { - defaultValue: {} - } + defaultValue: {}, + }, }, { label: "Flip", @@ -3667,7 +3761,8 @@ export const transformationSchema: TransformationSchema[] = [ fieldProps: { defaultValue: "", }, - helpText: "Enter the width of the border or expression of the overlay image.", + helpText: + "Enter the width of the border or expression of the overlay image.", examples: ["10", "ch_div_2"], }, { @@ -3761,8 +3856,7 @@ export const transformationSchema: TransformationSchema[] = [ label: "Amount", isTransformation: false, transformationGroup: "imageLayer", - helpText: - "Sets the strength of the sharpening effect.", + helpText: "Sets the strength of the sharpening effect.", fieldProps: { defaultValue: "", }, @@ -3775,8 +3869,7 @@ export const transformationSchema: TransformationSchema[] = [ label: "Threshold", isTransformation: false, transformationGroup: "imageLayer", - helpText: - "Set the threshold value for the unsharpen mask.", + helpText: "Set the threshold value for the unsharpen mask.", fieldProps: { defaultValue: "", }, @@ -3790,7 +3883,8 @@ export const transformationSchema: TransformationSchema[] = [ fieldType: "switch", isTransformation: false, transformationGroup: "imageLayer", - helpText: "Toggle to add a gradient overlay over the overlay image.", + helpText: + "Toggle to add a gradient overlay over the overlay image.", }, { label: "Apply Gradient", @@ -3806,8 +3900,8 @@ export const transformationSchema: TransformationSchema[] = [ to: "#00000000", direction: "bottom", stopPoint: 100, - } - } + }, + }, }, { label: "Shadow", @@ -3920,7 +4014,8 @@ export const transformationSchema: TransformationSchema[] = [ fieldType: "distort-perspective-input", isTransformation: false, transformationGroup: "imageLayer", - isVisible: ({ distort, distortType }) => distort === true && distortType === "perspective", + isVisible: ({ distort, distortType }) => + distort === true && distortType === "perspective", fieldProps: { defaultValue: { x1: "", @@ -3931,8 +4026,8 @@ export const transformationSchema: TransformationSchema[] = [ y3: "", x4: "", y4: "", - } - } + }, + }, }, { label: "Distortion Arc Degrees", @@ -3940,7 +4035,8 @@ export const transformationSchema: TransformationSchema[] = [ fieldType: "slider", isTransformation: true, transformationGroup: "imageLayer", - isVisible: ({ distort, distortType }) => distort === true && distortType === "arc", + isVisible: ({ distort, distortType }) => + distort === true && distortType === "arc", helpText: "Enter the arc degree for the arc distortion effect.", examples: ["15", "30", "-45", "N50"], fieldProps: { @@ -3950,7 +4046,7 @@ export const transformationSchema: TransformationSchema[] = [ defaultValue: "0", inputType: "text", skipStepCheck: true, - } + }, }, ], }, @@ -4052,7 +4148,17 @@ export const transformationFormatters: Record< } }, focus: (values, transforms) => { - const { focus, focusAnchor, focusObject, x, y, xc, yc, coordinateMethod, zoom } = values + const { + focus, + focusAnchor, + focusObject, + x, + y, + xc, + yc, + coordinateMethod, + zoom, + } = values if (focus === "auto" || focus === "face") { transforms.focus = focus @@ -4073,7 +4179,12 @@ export const transformationFormatters: Record< if (yc) transforms.yc = yc } } - if (zoom !== undefined && zoom !== null && !isNaN(Number(zoom)) && zoom !== 0) { + if ( + zoom !== undefined && + zoom !== null && + !Number.isNaN(Number(zoom)) && + zoom !== 0 + ) { transforms.zoom = (zoom as number) / 100 } }, @@ -4175,18 +4286,21 @@ export const transformationFormatters: Record< const { padding, mode } = values.padding as Record if ( mode === "uniform" && - (typeof padding === "number" || - typeof padding === "string") + (typeof padding === "number" || typeof padding === "string") ) { overlayTransform.padding = padding - } else if (mode === "individual" && typeof padding === "object" && padding !== null) { + } else if ( + mode === "individual" && + typeof padding === "object" && + padding !== null + ) { const { top, right, bottom, left } = padding as { top: number right: number bottom: number left: number } - let paddingString: string; + let paddingString: string if (top === right && top === bottom && top === left) { paddingString = String(top) } else if (top === bottom && right === left) { @@ -4196,11 +4310,13 @@ export const transformationFormatters: Record< } overlayTransform.padding = paddingString } - if (typeof values.lineHeight === "number" || typeof values.lineHeight === "string") { + if ( + typeof values.lineHeight === "number" || + typeof values.lineHeight === "string" + ) { overlayTransform.lineHeight = values.lineHeight } - if (Array.isArray(values.flip) && values.flip.length > 0) { const flip = [] if (values.flip.includes("horizontal")) { @@ -4346,7 +4462,8 @@ export const transformationFormatters: Record< } if (values.unsharpenMask === true) { - overlayTransform["e-usm"] = `${values.unsharpenMaskRadius}-${values.unsharpenMaskSigma}-${values.unsharpenMaskAmount}-${values.unsharpenMaskThreshold}` + overlayTransform["e-usm"] = + `${values.unsharpenMaskRadius}-${values.unsharpenMaskSigma}-${values.unsharpenMaskAmount}-${values.unsharpenMaskThreshold}` } if ( values.trimEnabled === true && @@ -4374,7 +4491,8 @@ export const transformationFormatters: Record< } if ( values.borderWidth && - values.borderColor && typeof values.borderColor === "string" + values.borderColor && + typeof values.borderColor === "string" ) { overlayTransform.b = `${values.borderWidth}_${values.borderColor.replace(/^#/, "")}` } @@ -4510,20 +4628,31 @@ export const transformationFormatters: Record< } }, unsharpenMask: (values, transforms) => { - const { unsharpenMaskRadius, unsharpenMaskSigma, unsharpenMaskAmount, unsharpenMaskThreshold } = values as { + const { + unsharpenMaskRadius, + unsharpenMaskSigma, + unsharpenMaskAmount, + unsharpenMaskThreshold, + } = values as { unsharpenMaskRadius: number unsharpenMaskSigma: number unsharpenMaskAmount: number unsharpenMaskThreshold: number } - transforms["e-usm"] = `${unsharpenMaskRadius}-${unsharpenMaskSigma}-${unsharpenMaskAmount}-${unsharpenMaskThreshold}` + transforms["e-usm"] = + `${unsharpenMaskRadius}-${unsharpenMaskSigma}-${unsharpenMaskAmount}-${unsharpenMaskThreshold}` }, gradient: (values, transforms) => { - const { gradient, gradientSwitch } = values as { gradient: GradientPickerState; gradientSwitch: boolean } + const { gradient, gradientSwitch } = values as { + gradient: GradientPickerState + gradientSwitch: boolean + } if (gradientSwitch && gradient) { const { from, to, direction, stopPoint } = gradient - const isDefaultGradient = (from.toUpperCase() === "#FFFFFFFF" || from.toUpperCase() === "#FFFFFF") && - (to.toUpperCase() === "#00000000") && + const isDefaultGradient = + (from.toUpperCase() === "#FFFFFFFF" || + from.toUpperCase() === "#FFFFFF") && + to.toUpperCase() === "#00000000" && (direction === "bottom" || direction === 180) && stopPoint === 100 if (isDefaultGradient) { @@ -4532,7 +4661,7 @@ export const transformationFormatters: Record< const fromColor = from.replace("#", "") const toColor = to.replace("#", "") const stopPointDecimal = (stopPoint as number) / 100 - let gradientStr = `ld-${direction}_from-${fromColor}_to-${toColor}_sp-${stopPointDecimal}` + const gradientStr = `ld-${direction}_from-${fromColor}_to-${toColor}_sp-${stopPointDecimal}` transforms.gradient = gradientStr } } @@ -4542,64 +4671,105 @@ export const transformationFormatters: Record< const { distortType, distortPerspective, distortArcDegree } = values const distortPrefix = distortType === "perspective" ? "p" : "a" if (distortType === "perspective" && distortPerspective) { - const { x1, y1, x2, y2, x3, y3, x4, y4 } = distortPerspective as Record - const formattedCoords = [x1, y1, x2, y2, x3, y3, x4, y4].map(coord => coord.toString().replace(/^-/,"N")) - transforms["e-distort"] = `${distortPrefix}-${formattedCoords.join("_")}` - } else if (distortType === "arc" && distortArcDegree !== undefined && distortArcDegree !== null) { - transforms["e-distort"] = `${distortPrefix}-${distortArcDegree.toString().replace(/^-/, "N")}` + const { x1, y1, x2, y2, x3, y3, x4, y4 } = distortPerspective as Record< + string, + string + > + const formattedCoords = [x1, y1, x2, y2, x3, y3, x4, y4].map((coord) => + coord.toString().replace(/^-/, "N"), + ) + transforms["e-distort"] = + `${distortPrefix}-${formattedCoords.join("_")}` + } else if ( + distortType === "arc" && + distortArcDegree !== undefined && + distortArcDegree !== null + ) { + transforms["e-distort"] = + `${distortPrefix}-${distortArcDegree.toString().replace(/^-/, "N")}` } } }, radius: (values, transforms) => { if (values.radius) { const { radius, mode } = values.radius as Record - if (mode === "uniform" && (typeof radius === "number" || typeof radius === "string")) { + if ( + mode === "uniform" && + (typeof radius === "number" || typeof radius === "string") + ) { transforms.radius = radius - } else if (mode === "individual" && typeof radius === "object" && radius !== null) { + } else if ( + mode === "individual" && + typeof radius === "object" && + radius !== null + ) { const { topLeft, topRight, bottomRight, bottomLeft } = radius as { topLeft: number | "max" topRight: number | "max" bottomRight: number | "max" bottomLeft: number | "max" } - if (topLeft === topRight && topLeft === bottomRight && topLeft === bottomLeft) { + if ( + topLeft === topRight && + topLeft === bottomRight && + topLeft === bottomLeft + ) { transforms.radius = topLeft } else { transforms.radius = `${topLeft}_${topRight}_${bottomRight}_${bottomLeft}` } } } - } + }, } - -function validatePerspectiveDistort(value: {distortPerspective: PerspectiveObject, distort: boolean, distortType: string} & any, ctx: RefinementCtx) { - const {distort, distortType, distortPerspective} = value; +function validatePerspectiveDistort( + value: { + distortPerspective?: PerspectiveObject + distort?: boolean + distortType?: string + } & Record, + ctx: RefinementCtx, +) { + const { distort, distortType, distortPerspective } = value if (distort && distortType === "perspective" && distortPerspective) { - const perspective: PerspectiveObject = structuredClone(distortPerspective); - let { x1, y1, x2, y2, x3, y3, x4, y4 } = Object.keys(perspective).reduce((acc, key) => { - const value = perspective[key as keyof typeof perspective]; - if (!value) { - acc[key as keyof PerspectiveObject] = value; - } - const numString = value.toUpperCase().replace(/^N/, "-"); - acc[key as keyof PerspectiveObject] = parseInt(numString as string, 10); - return acc; - }, {} as Record); - const allValuesProvided = [x1, y1, x2, y2, x3, y3, x4, y4].every(v => v === 0 || Boolean(v)); + const perspective: PerspectiveObject = structuredClone(distortPerspective) + const coords = Object.keys(perspective).reduce( + (acc, key) => { + const value = perspective[key as keyof typeof perspective] + if (!value) { + acc[key as keyof PerspectiveObject] = value + } + const numString = value.toUpperCase().replace(/^N/, "-") + acc[key as keyof PerspectiveObject] = parseInt(numString as string, 10) + return acc + }, + {} as Record, + ) + const allValuesProvided = Object.values(coords).every( + (v) => typeof v === "number" && !Number.isNaN(v), + ) if (allValuesProvided) { - const isTopLeftValid = x1 < x2 && x1 < x3 && y1 < y3 && y1 < y4; - const isTopRightValid = x2 > x1 && x2 > x4 && y2 < y3 && y2 < y4; - const isBottomRightValid = x3 > x4 && x3 > x1 && y3 > y1 && y3 > y2; - const isBottomLeftValid = x4 < x3 && x4 < x2 && y4 > y1 && y4 > y2; - let isValid = isTopLeftValid && isTopRightValid && isBottomRightValid && isBottomLeftValid; + const { x1, y1, x2, y2, x3, y3, x4, y4 } = coords as Record< + keyof PerspectiveObject, + number + > + const isTopLeftValid = x1 < x2 && x1 < x3 && y1 < y3 && y1 < y4 + const isTopRightValid = x2 > x1 && x2 > x4 && y2 < y3 && y2 < y4 + const isBottomRightValid = x3 > x4 && x3 > x1 && y3 > y1 && y3 > y2 + const isBottomLeftValid = x4 < x3 && x4 < x2 && y4 > y1 && y4 > y2 + const isValid = + isTopLeftValid && + isTopRightValid && + isBottomRightValid && + isBottomLeftValid if (!isValid) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Perspective coordinates are invalid.", - path: ["distortPerspective"] - }); + path: ["distortPerspective"], + }) } } } -} \ No newline at end of file +} From 6f761cdc5835a7249e35d763d5f7a0e7dfa1a6fa Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Mon, 9 Feb 2026 17:21:00 +0530 Subject: [PATCH 42/48] Fix more linting errors --- .../src/components/RetryableImage.tsx | 6 +- .../components/common/ColorPickerField.tsx | 2 +- .../src/components/common/GradientPicker.tsx | 200 +++++++++--------- .../src/components/common/RadioCardField.tsx | 3 +- .../src/components/editor/GridView.tsx | 1 + .../src/components/editor/ListView.tsx | 1 + .../src/components/header/index.tsx | 4 + .../sidebar/sortable-transformation-item.tsx | 188 ++++++++-------- .../src/components/toolbar/toolbar.tsx | 1 + .../imagekit-editor-dev/src/schema/index.ts | 2 +- .../src/schema/transformation.ts | 8 +- 11 files changed, 215 insertions(+), 201 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/RetryableImage.tsx b/packages/imagekit-editor-dev/src/components/RetryableImage.tsx index 8b3b3ed..8ac1de1 100644 --- a/packages/imagekit-editor-dev/src/components/RetryableImage.tsx +++ b/packages/imagekit-editor-dev/src/components/RetryableImage.tsx @@ -11,7 +11,6 @@ import { } from "@chakra-ui/react" import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useVisibility } from "../hooks/useVisibility" -import { useEditorStore } from "../store" export interface RetryableImageProps extends ImageProps { maxRetries?: number @@ -105,11 +104,12 @@ export default function RetryableImage(props: RetryableImageProps) { setProbing(true) }, [currentSrcBase, src]) + // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { if (!src) return if (lazy && !visible) return setAttempt(0) - beginLoad(0) + beginLoad() }, [src, visible, lazy]) const scheduleRetry = useCallback(() => { @@ -156,7 +156,7 @@ export default function RetryableImage(props: RetryableImageProps) { } return ( - + } position="relative" display="inline-block"> {error ? (
Math.max(0, Math.min(255, v)); + const clamp8 = (v: number) => Math.max(0, Math.min(255, v)) const rgbHex = [r, g, b] .map(clamp8) .map((v) => v.toString(16).padStart(2, "0")) - .join(""); + .join("") if (a === undefined) { - return `#${rgbHex}`; + return `#${rgbHex}` } - const alphaDec = a > 1 ? a / 100 : a; + const alphaDec = a > 1 ? a / 100 : a const alphaHex = Math.round(alphaDec * 255) .toString(16) .padStart(2, "0") - .toUpperCase(); - return `#${rgbHex}${alphaHex}`; + .toUpperCase() + return `#${rgbHex}${alphaHex}` } const GradientPickerField = ({ @@ -59,24 +59,24 @@ const GradientPickerField = ({ value, errors, }: { - fieldName: string; - setValue: (name: string, value: GradientPickerState | string) => void; - value?: GradientPickerState | null; - errors?: FieldErrors>; + fieldName: string + setValue: (name: string, value: GradientPickerState | string) => void + value?: GradientPickerState | null + errors?: FieldErrors> }) => { function getLinearGradientString(value: GradientPickerState): string { - let direction = ""; - const dirInt = Number(value.direction as string); - if (!isNaN(dirInt)) { - direction = `${dirInt}deg`; + let direction = "" + const dirInt = Number(value.direction as string) + if (!Number.isNaN(dirInt)) { + direction = `${dirInt}deg` } else { - direction = `to ${String(value.direction).split("_").join(" ")}`; + direction = `to ${String(value.direction).split("_").join(" ")}` } const stopPoint = typeof value.stopPoint === "number" ? value.stopPoint - : Number(value.stopPoint); - return `linear-gradient(${direction}, ${value.from} 0%, ${value.to} ${stopPoint}%)`; + : Number(value.stopPoint) + return `linear-gradient(${direction}, ${value.from} 0%, ${value.to} ${stopPoint}%)` } const [localValue, setLocalValue] = useState( @@ -86,22 +86,21 @@ const GradientPickerField = ({ direction: "bottom", stopPoint: 100, }, - ); - const [directionMode, setDirectionMode] = - useState("direction"); + ) + const [directionMode, setDirectionMode] = useState("direction") const [gradient, setGradient] = useState( getLinearGradientString(localValue), - ); + ) - const { getGradientObject } = useColorPicker(gradient, setGradient); + const { getGradientObject } = useColorPicker(gradient, setGradient) function getAngleValue(): number | string { - const dirInt = Number(localValue.direction as string); - if (!isNaN(dirInt)) { - return dirInt || ""; + const dirInt = Number(localValue.direction as string) + if (!Number.isNaN(dirInt)) { + return dirInt || "" } - const direction = localValue.direction as string; + const direction = localValue.direction as string const directionMap: Record = { top: 0, top_right: 45, @@ -111,16 +110,16 @@ const GradientPickerField = ({ bottom_left: 225, left: 270, top_left: 315, - }; - return directionMap[direction] || ""; + } + return directionMap[direction] || "" } function getDirectionValue(): string { - const dirInt = Number(localValue.direction as string); - if (isNaN(dirInt)) { - return String(localValue.direction); + const dirInt = Number(localValue.direction as string) + if (Number.isNaN(dirInt)) { + return String(localValue.direction) } - const nearestAngle = Math.round(dirInt / 45) * 45; + const nearestAngle = Math.round(dirInt / 45) * 45 const angleMap: Record = { 0: "top", 45: "top_right", @@ -130,31 +129,32 @@ const GradientPickerField = ({ 225: "bottom_left", 270: "left", 315: "top_left", - }; - return angleMap[nearestAngle] || "bottom"; + } + return angleMap[nearestAngle] || "bottom" } - const debouncedValue = useDebounce(localValue, 500); + const debouncedValue = useDebounce(localValue, 500) function handleGradientChange(gradientVal: string) { - const cleanedGradient = gradientVal.replace(/NaNdeg\s*,/, ""); - let gradientObj; + const cleanedGradient = gradientVal.replace(/NaNdeg\s*,/, "") + let gradientObj: ReturnType try { - gradientObj = getGradientObject(cleanedGradient); - } catch (error) { - return; + gradientObj = getGradientObject(cleanedGradient) + } catch (e) { + console.error("Failed to parse gradient:", e) + return } - if (!gradientObj || !gradientObj.isGradient) return; + if (!gradientObj || !gradientObj.isGradient) return - const { colors } = gradientObj; - if (colors.length !== 2) return; - if (colors[0].left !== 0) return; - setGradient(cleanedGradient); + const { colors } = gradientObj + if (colors.length !== 2) return + if (colors[0].left !== 0) return + setGradient(cleanedGradient) - const fromColor = rgbaToHex(colors[0].value).toUpperCase(); - const toColor = rgbaToHex(colors[1].value).toUpperCase(); - const stopPoint = colors[1].left; + const fromColor = rgbaToHex(colors[0].value).toUpperCase() + const toColor = rgbaToHex(colors[1].value).toUpperCase() + const stopPoint = colors[1].left if ( fromColor !== localValue.from || @@ -166,21 +166,21 @@ const GradientPickerField = ({ from: fromColor, to: toColor, stopPoint: stopPoint, - }); + }) } } function applyGradientInputChanges(newValue: GradientPickerState) { - const gradientString = getLinearGradientString(newValue); - setGradient(gradientString); - setLocalValue(newValue); + const gradientString = getLinearGradientString(newValue) + setGradient(gradientString) + setLocalValue(newValue) } useEffect(() => { - setValue(fieldName, debouncedValue); - }, [debouncedValue, fieldName, setValue]); + setValue(fieldName, debouncedValue) + }, [debouncedValue, fieldName, setValue]) - const errorRed = useColorModeValue("red.500", "red.300"); + const errorRed = useColorModeValue("red.500", "red.300") return ( @@ -229,11 +229,11 @@ const GradientPickerField = ({ size="md" value={localValue.from} onChange={(e) => { - const newValue = e.target.value; + const newValue = e.target.value if (newValue.match(/^#[0-9A-Fa-f]{0,8}$/)) { - applyGradientInputChanges({ ...localValue, from: newValue }); + applyGradientInputChanges({ ...localValue, from: newValue }) } else if (newValue === "") { - applyGradientInputChanges({ ...localValue, from: "" }); + applyGradientInputChanges({ ...localValue, from: "" }) } }} borderColor="gray.200" @@ -254,11 +254,11 @@ const GradientPickerField = ({ size="md" value={localValue.to} onChange={(e) => { - const newValue = e.target.value; + const newValue = e.target.value if (newValue.match(/^#[0-9A-Fa-f]{0,8}$/)) { - applyGradientInputChanges({ ...localValue, to: newValue }); + applyGradientInputChanges({ ...localValue, to: newValue }) } else if (newValue === "") { - applyGradientInputChanges({ ...localValue, to: "" }); + applyGradientInputChanges({ ...localValue, to: "" }) } }} borderColor="gray.200" @@ -283,13 +283,13 @@ const GradientPickerField = ({ ]} value={directionMode} onChange={(val) => { - setDirectionMode((val || "direction") as DirectionMode); + setDirectionMode((val || "direction") as DirectionMode) const newDirection = - val === "direction" ? getDirectionValue() : getAngleValue(); + val === "direction" ? getDirectionValue() : getAngleValue() applyGradientInputChanges({ ...localValue, direction: newDirection, - }); + }) }} /> @@ -297,7 +297,7 @@ const GradientPickerField = ({ { - applyGradientInputChanges({ ...localValue, direction: val }); + applyGradientInputChanges({ ...localValue, direction: val }) }} positions={[ "top", @@ -318,14 +318,14 @@ const GradientPickerField = ({ min={0} max={359} onChange={(e) => { - const newValue = e.target.value.trim(); + const newValue = e.target.value.trim() if (newValue === "") { - applyGradientInputChanges({ ...localValue, direction: "" }); - return; + applyGradientInputChanges({ ...localValue, direction: "" }) + return } - const intVal = Number(newValue); - if (intVal < 0 || intVal > 359) return; - applyGradientInputChanges({ ...localValue, direction: intVal }); + const intVal = Number(newValue) + if (intVal < 0 || intVal > 359) return + applyGradientInputChanges({ ...localValue, direction: intVal }) }} borderColor="gray.200" placeholder="0" @@ -348,17 +348,17 @@ const GradientPickerField = ({ min={1} max={100} onChange={(e) => { - const newValue = e.target.value.trim(); + const newValue = e.target.value.trim() if (newValue === "") { - applyGradientInputChanges({ ...localValue, stopPoint: "" }); - return; + applyGradientInputChanges({ ...localValue, stopPoint: "" }) + return } - const intVal = Number(newValue); - if (intVal < 1 || intVal > 100) return; + const intVal = Number(newValue) + if (intVal < 1 || intVal > 100) return applyGradientInputChanges({ ...localValue, stopPoint: intVal, - }); + }) }} borderColor="gray.200" placeholder="100" @@ -369,7 +369,7 @@ const GradientPickerField = ({ - ); -}; + ) +} -export default memo(GradientPickerField); +export default memo(GradientPickerField) diff --git a/packages/imagekit-editor-dev/src/components/common/RadioCardField.tsx b/packages/imagekit-editor-dev/src/components/common/RadioCardField.tsx index 7f0d82e..3cdb7c8 100644 --- a/packages/imagekit-editor-dev/src/components/common/RadioCardField.tsx +++ b/packages/imagekit-editor-dev/src/components/common/RadioCardField.tsx @@ -58,6 +58,7 @@ export const RadioCardField: React.FC = ({ {options.map((opt) => { const isSelected = value === opt.value return ( + // biome-ignore lint/a11y/useSemanticElements: = ({ }} > - {opt.icon ? : null} + {opt.icon ? : null} {opt.label} diff --git a/packages/imagekit-editor-dev/src/components/editor/GridView.tsx b/packages/imagekit-editor-dev/src/components/editor/GridView.tsx index 2a8bc9b..7671b8e 100644 --- a/packages/imagekit-editor-dev/src/components/editor/GridView.tsx +++ b/packages/imagekit-editor-dev/src/components/editor/GridView.tsx @@ -159,6 +159,7 @@ export const GridView: FC = ({ imageSize, onAddImage }) => { } isLoading={isSigning} onLoad={(event) => { + // biome-ignore lint/style/noNonNullAssertion: setImageDimensions(originalImageList[index]!.url, { width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight, diff --git a/packages/imagekit-editor-dev/src/components/editor/ListView.tsx b/packages/imagekit-editor-dev/src/components/editor/ListView.tsx index dac8b90..57657ff 100644 --- a/packages/imagekit-editor-dev/src/components/editor/ListView.tsx +++ b/packages/imagekit-editor-dev/src/components/editor/ListView.tsx @@ -59,6 +59,7 @@ export const ListView: FC = ({ onAddImage }) => { if (!currentImage) return const idx = imageList.findIndex((img) => img === currentImage) if (idx === -1) return + // biome-ignore lint/style/noNonNullAssertion: setImageDimensions(originalImageList[idx]!.url, { width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight, diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx index 9e4cb91..9180329 100644 --- a/packages/imagekit-editor-dev/src/components/header/index.tsx +++ b/packages/imagekit-editor-dev/src/components/header/index.tsx @@ -116,7 +116,9 @@ export const Header = ({ onClose, exportOptions }: HeaderProps) => { (image) => image.url === currentImage, ) exportOption.onClick(images, { + // biome-ignore lint/style/noNonNullAssertion: url: cImage!.url, + // biome-ignore lint/style/noNonNullAssertion: file: cImage!.file, }) }} @@ -157,7 +159,9 @@ export const Header = ({ onClose, exportOptions }: HeaderProps) => { (image) => image.url === currentImage, ) option.onClick(images, { + // biome-ignore lint/style/noNonNullAssertion: url: cImage!.url, + // biome-ignore lint/style/noNonNullAssertion: file: cImage!.file, }) }} diff --git a/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx b/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx index 74ec731..427749f 100644 --- a/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx +++ b/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx @@ -1,43 +1,43 @@ import { Box, + Flex, HStack, Icon, + IconButton, + Input, Menu, MenuButton, MenuItem, MenuList, + Tag, Text, Tooltip, - Input, - Tag, - Flex, - IconButton, useColorModeValue, -} from "@chakra-ui/react"; -import { useState, useEffect, useRef } from "react"; -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { PiArrowDown } from "@react-icons/all-files/pi/PiArrowDown"; -import { PiArrowUp } from "@react-icons/all-files/pi/PiArrowUp"; -import { PiDotsSixVerticalBold } from "@react-icons/all-files/pi/PiDotsSixVerticalBold"; -import { PiDotsThreeVertical } from "@react-icons/all-files/pi/PiDotsThreeVertical"; -import { PiEye } from "@react-icons/all-files/pi/PiEye"; -import { PiEyeSlash } from "@react-icons/all-files/pi/PiEyeSlash"; -import { PiPencilSimple } from "@react-icons/all-files/pi/PiPencilSimple"; -import { PiPlus } from "@react-icons/all-files/pi/PiPlus"; -import { PiTrash } from "@react-icons/all-files/pi/PiTrash"; -import { RxTransform } from "@react-icons/all-files/rx/RxTransform"; -import { PiCopy } from "@react-icons/all-files/pi/PiCopy"; -import { PiCursorText } from "@react-icons/all-files/pi/PiCursorText"; -import { RiCheckFill } from "@react-icons/all-files/ri/RiCheckFill"; -import { RiCloseFill } from "@react-icons/all-files/ri/RiCloseFill"; -import { type Transformation, useEditorStore } from "../../store"; -import Hover from "../common/Hover"; +} from "@chakra-ui/react" +import { useSortable } from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { PiArrowDown } from "@react-icons/all-files/pi/PiArrowDown" +import { PiArrowUp } from "@react-icons/all-files/pi/PiArrowUp" +import { PiCopy } from "@react-icons/all-files/pi/PiCopy" +import { PiCursorText } from "@react-icons/all-files/pi/PiCursorText" +import { PiDotsSixVerticalBold } from "@react-icons/all-files/pi/PiDotsSixVerticalBold" +import { PiDotsThreeVertical } from "@react-icons/all-files/pi/PiDotsThreeVertical" +import { PiEye } from "@react-icons/all-files/pi/PiEye" +import { PiEyeSlash } from "@react-icons/all-files/pi/PiEyeSlash" +import { PiPencilSimple } from "@react-icons/all-files/pi/PiPencilSimple" +import { PiPlus } from "@react-icons/all-files/pi/PiPlus" +import { PiTrash } from "@react-icons/all-files/pi/PiTrash" +import { RiCheckFill } from "@react-icons/all-files/ri/RiCheckFill" +import { RiCloseFill } from "@react-icons/all-files/ri/RiCloseFill" +import { RxTransform } from "@react-icons/all-files/rx/RxTransform" +import { useEffect, useRef, useState } from "react" +import { type Transformation, useEditorStore } from "../../store" +import Hover from "../common/Hover" -export type TransformationPosition = "inplace" | number; +export type TransformationPosition = "inplace" | number interface SortableTransformationItemProps { - transformation: Transformation; + transformation: Transformation } export const SortableTransformationItem = ({ @@ -52,7 +52,7 @@ export const SortableTransformationItem = ({ isDragging, } = useSortable({ id: transformation.id, - }); + }) const { transformations, @@ -66,7 +66,7 @@ export const SortableTransformationItem = ({ _internalState, addTransformation, updateTransformation, - } = useEditorStore(); + } = useEditorStore() const style = transform ? { @@ -74,33 +74,33 @@ export const SortableTransformationItem = ({ transition, opacity: isDragging ? 0.5 : 1, } - : undefined; + : undefined - const isVisible = visibleTransformations[transformation.id]; + const isVisible = visibleTransformations[transformation.id] const isEditting = _internalState.transformationToEdit?.position === "inplace" && - _internalState.transformationToEdit?.transformationId === transformation.id; + _internalState.transformationToEdit?.transformationId === transformation.id - const [isRenaming, setIsRenaming] = useState(false); - const renameInputRef = useRef(null); - const renamingBoxRef = useRef(null); + const [isRenaming, setIsRenaming] = useState(false) + const renameInputRef = useRef(null) + const renamingBoxRef = useRef(null) - const baseIconColor = useColorModeValue("gray.600", "gray.300"); + const baseIconColor = useColorModeValue("gray.600", "gray.300") useEffect(() => { const handleClickOutside = (event: MouseEvent): void => { - const renamingBox = renamingBoxRef.current; + const renamingBox = renamingBoxRef.current if (renamingBox && !renamingBox.contains(event.target as Node)) { - setIsRenaming(false); + setIsRenaming(false) } - }; + } - document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("mousedown", handleClickOutside) return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, []); + document.removeEventListener("mousedown", handleClickOutside) + } + }, []) return ( @@ -119,19 +119,19 @@ export const SortableTransformationItem = ({ minH="8" alignItems="center" style={style} - onClick={(e) => { - _setSidebarState("config"); - _setSelectedTransformationKey(transformation.key); - _setTransformationToEdit(transformation.id, "inplace"); + onClick={(_e) => { + _setSidebarState("config") + _setSelectedTransformationKey(transformation.key) + _setTransformationToEdit(transformation.id, "inplace") }} onDoubleClick={(e) => { - e.stopPropagation(); - setIsRenaming(true); + e.stopPropagation() + setIsRenaming(true) }} {...attributes} {...listeners} > - {(isHover && !isRenaming) ? ( + {isHover && !isRenaming ? ( { if (e.key === "Enter") { - const newName = renameInputRef.current?.value.trim(); + const newName = renameInputRef.current?.value.trim() if (newName && newName.length > 0) { updateTransformation(transformation.id, { ...transformation, name: newName, - }); + }) } - setIsRenaming(false); + setIsRenaming(false) } else if (e.key === "Escape") { - setIsRenaming(false); + setIsRenaming(false) } }} variant="flushed" @@ -183,14 +183,14 @@ export const SortableTransformationItem = ({ variant="ghost" color={baseIconColor} onClick={() => { - const newName = renameInputRef.current?.value.trim(); + const newName = renameInputRef.current?.value.trim() if (newName && newName.length > 0) { updateTransformation(transformation.id, { ...transformation, name: newName, - }); + }) } - setIsRenaming(false); + setIsRenaming(false) }} /> { - setIsRenaming(false); + setIsRenaming(false) }} /> @@ -230,8 +230,8 @@ export const SortableTransformationItem = ({ > { - e.stopPropagation(); - toggleTransformationVisibility(transformation.id); + e.stopPropagation() + toggleTransformationVisibility(transformation.id) }} > } onClick={(e) => { - e.stopPropagation(); - _setSidebarState("type"); - _setTransformationToEdit(transformation.id, "above"); + e.stopPropagation() + _setSidebarState("type") + _setTransformationToEdit(transformation.id, "above") }} > Add transformation before @@ -274,9 +274,9 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation(); - _setSidebarState("type"); - _setTransformationToEdit(transformation.id, "below"); + e.stopPropagation() + _setSidebarState("type") + _setTransformationToEdit(transformation.id, "below") }} > Add transformation after @@ -284,18 +284,18 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation(); + e.stopPropagation() const currentIndex = transformations.findIndex( (t) => t.id === transformation.id, - ); + ) const transformationId = addTransformation( { ...transformation, }, currentIndex + 1, - ); - _setSidebarState("config"); - _setTransformationToEdit(transformationId, "inplace"); + ) + _setSidebarState("config") + _setTransformationToEdit(transformationId, "inplace") }} > Duplicate @@ -303,10 +303,10 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation(); - _setSidebarState("config"); - _setSelectedTransformationKey(transformation.key); - _setTransformationToEdit(transformation.id, "inplace"); + e.stopPropagation() + _setSidebarState("config") + _setSelectedTransformationKey(transformation.key) + _setTransformationToEdit(transformation.id, "inplace") }} > Edit transformation @@ -314,11 +314,11 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation(); - setIsRenaming(true); - _setSidebarState("config"); - _setSelectedTransformationKey(transformation.key); - _setTransformationToEdit(transformation.id, "inplace"); + e.stopPropagation() + setIsRenaming(true) + _setSidebarState("config") + _setSelectedTransformationKey(transformation.key) + _setTransformationToEdit(transformation.id, "inplace") }} > Rename @@ -326,13 +326,13 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation(); + e.stopPropagation() const currentIndex = transformations.findIndex( (t) => t.id === transformation.id, - ); + ) if (currentIndex > 0) { - const targetId = transformations[currentIndex - 1].id; - moveTransformation(transformation.id, targetId); + const targetId = transformations[currentIndex - 1].id + moveTransformation(transformation.id, targetId) } }} isDisabled={ @@ -346,13 +346,13 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation(); + e.stopPropagation() const currentIndex = transformations.findIndex( (t) => t.id === transformation.id, - ); + ) if (currentIndex < transformations.length - 1) { - const targetId = transformations[currentIndex + 1].id; - moveTransformation(transformation.id, targetId); + const targetId = transformations[currentIndex + 1].id + moveTransformation(transformation.id, targetId) } }} isDisabled={ @@ -368,15 +368,15 @@ export const SortableTransformationItem = ({ icon={} color="red.500" onClick={(e) => { - e.stopPropagation(); - removeTransformation(transformation.id); + e.stopPropagation() + removeTransformation(transformation.id) if ( _internalState.selectedTransformationKey === transformation.key ) { - _setSidebarState("none"); - _setSelectedTransformationKey(null); - _setTransformationToEdit(null); + _setSidebarState("none") + _setSelectedTransformationKey(null) + _setTransformationToEdit(null) } }} > @@ -389,5 +389,5 @@ export const SortableTransformationItem = ({ )} - ); -}; + ) +} diff --git a/packages/imagekit-editor-dev/src/components/toolbar/toolbar.tsx b/packages/imagekit-editor-dev/src/components/toolbar/toolbar.tsx index 67ac076..6e0eb8a 100644 --- a/packages/imagekit-editor-dev/src/components/toolbar/toolbar.tsx +++ b/packages/imagekit-editor-dev/src/components/toolbar/toolbar.tsx @@ -206,6 +206,7 @@ export const Toolbar: FC = ({ onAddImage, onSelectImage }) => { } isLoading={isSigning} onLoad={(event) => { + // biome-ignore lint/style/noNonNullAssertion: setImageDimensions(originalImageList[index]!.url, { width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight, diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 4703690..67c3d3a 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -3317,7 +3317,7 @@ export const transformationSchema: TransformationSchema[] = [ unsharpenMaskThreshold: optionalPositiveFloatNumberValidator.optional(), }) - .superRefine(refineUnsharpenMask) + .superRefine((val, ctx) => refineUnsharpenMask(val, ctx)) .refine( (val) => { return Object.values(val).some( diff --git a/packages/imagekit-editor-dev/src/schema/transformation.ts b/packages/imagekit-editor-dev/src/schema/transformation.ts index d721ed2..a95142b 100644 --- a/packages/imagekit-editor-dev/src/schema/transformation.ts +++ b/packages/imagekit-editor-dev/src/schema/transformation.ts @@ -176,7 +176,13 @@ export const optionalPositiveFloatNumberValidator = z.preprocess( z.coerce.number().positive({ message: "Should be a positive floating point number." }).optional() ) -export const refineUnsharpenMask = (val: any, ctx: z.RefinementCtx) => { +export const refineUnsharpenMask = (val: { + unsharpenMask?: boolean + unsharpenMaskRadius?: number + unsharpenMaskSigma?: number + unsharpenMaskAmount?: number + unsharpenMaskThreshold?: number +}, ctx: z.RefinementCtx) => { if (val.unsharpenMask === true) { if (!val.unsharpenMaskRadius) { ctx.addIssue({ From 9e33b570b24d2173546a412e9a0810a173f22046 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Mon, 9 Feb 2026 17:26:11 +0530 Subject: [PATCH 43/48] Fixed formatting issues --- .../src/components/RetryableImage.tsx | 6 +- .../components/common/ColorPickerField.tsx | 4 +- .../src/components/common/Hover.tsx | 34 ++++----- .../src/components/common/ZoomInput.tsx | 4 +- .../sidebar/transformation-config-sidebar.tsx | 6 +- .../src/schema/transformation.ts | 76 +++++++++---------- 6 files changed, 71 insertions(+), 59 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/RetryableImage.tsx b/packages/imagekit-editor-dev/src/components/RetryableImage.tsx index 8ac1de1..1f95585 100644 --- a/packages/imagekit-editor-dev/src/components/RetryableImage.tsx +++ b/packages/imagekit-editor-dev/src/components/RetryableImage.tsx @@ -156,7 +156,11 @@ export default function RetryableImage(props: RetryableImageProps) { } return ( - } position="relative" display="inline-block"> + } + position="relative" + display="inline-block" + > {error ? (
{ const hoverArea = hoverAreaRef.current - if ( - hoverArea && - !hoverArea.contains(event.target as Node) - ) { + if (hoverArea && !hoverArea.contains(event.target as Node)) { setIsHover(false) } }, []) - const debouncedHandleClickOutside = useCallback((event: MouseEvent): void => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current) - } - debounceTimerRef.current = setTimeout(() => { - handleClickOutside(event) - }, 100) - }, [handleClickOutside]) + const debouncedHandleClickOutside = useCallback( + (event: MouseEvent): void => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + debounceTimerRef.current = setTimeout(() => { + handleClickOutside(event) + }, 100) + }, + [handleClickOutside], + ) useEffect(() => { - document.addEventListener('mousedown', handleClickOutside) - document.addEventListener('mouseover', debouncedHandleClickOutside) + document.addEventListener("mousedown", handleClickOutside) + document.addEventListener("mouseover", debouncedHandleClickOutside) return () => { - document.removeEventListener('mousedown', handleClickOutside) - document.removeEventListener('mouseover', debouncedHandleClickOutside) + document.removeEventListener("mousedown", handleClickOutside) + document.removeEventListener("mouseover", debouncedHandleClickOutside) if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current) } diff --git a/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx index 22561e6..06c4c7e 100644 --- a/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx @@ -43,7 +43,9 @@ export const ZoomInput: React.FC = ({ defaultValue = 100, value, }) => { - const [zoomValue, setZoomValue] = useState(value ?? (defaultValue as number)) + const [zoomValue, setZoomValue] = useState( + value ?? (defaultValue as number), + ) const [inputValue, setInputValue] = useState( (value ?? (defaultValue as number)).toString(), ) 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 a6838fe..08e5856 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 @@ -448,7 +448,11 @@ export const TransformationConfigSidebar: React.FC = () => { {...register(field.name)} {...(field.fieldProps ?? {})} defaultValue={ - field.fieldProps?.defaultValue as string | number | readonly string[] | undefined + field.fieldProps?.defaultValue as + | string + | number + | readonly string[] + | undefined } /> ) : null} diff --git a/packages/imagekit-editor-dev/src/schema/transformation.ts b/packages/imagekit-editor-dev/src/schema/transformation.ts index a95142b..100d55f 100644 --- a/packages/imagekit-editor-dev/src/schema/transformation.ts +++ b/packages/imagekit-editor-dev/src/schema/transformation.ts @@ -77,9 +77,7 @@ export const aspectRatioValidator = z.any().superRefine((val, ctx) => { }) }) -const layerXNumber = z.coerce - .string() - .regex(/^[N-]?\d+(\.\d{1,2})?$/) +const layerXNumber = z.coerce.string().regex(/^[N-]?\d+(\.\d{1,2})?$/) const layerXExpr = z .string() @@ -99,9 +97,7 @@ export const layerXValidator = z.any().superRefine((val, ctx) => { }) }) -const layerYNumber = z.coerce - .string() - .regex(/^[N-]?\d+(\.\d{1,2})?$/) +const layerYNumber = z.coerce.string().regex(/^[N-]?\d+(\.\d{1,2})?$/) const layerYExpr = z .string() @@ -121,7 +117,6 @@ export const layerYValidator = z.any().superRefine((val, ctx) => { }) }) - const commonNumber = z.coerce .number({ invalid_type_error: "Should be a number." }) .min(0, { @@ -129,24 +124,27 @@ const commonNumber = z.coerce }) const commonExpr = z .string() - .regex(/^(?:ih|bh|ch|iw|bw|cw)_(?:add|sub|mul|div|mod|pow)_(?:\d+(\.\d{1,2})?)$/, { - message: "String must be a valid expression string.", - }) - - -export const commonNumberAndExpressionValidator = z.any().superRefine((val, ctx) => { - if (commonNumber.safeParse(val).success) { - return - } - if (commonExpr.safeParse(val).success) { - return - } - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Must be a positive number or a valid expression string.", + .regex( + /^(?:ih|bh|ch|iw|bw|cw)_(?:add|sub|mul|div|mod|pow)_(?:\d+(\.\d{1,2})?)$/, + { + message: "String must be a valid expression string.", + }, + ) + +export const commonNumberAndExpressionValidator = z + .any() + .superRefine((val, ctx) => { + if (commonNumber.safeParse(val).success) { + return + } + if (commonExpr.safeParse(val).success) { + return + } + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Must be a positive number or a valid expression string.", + }) }) -}) - const overlayBlockExpr = z .string() @@ -154,7 +152,6 @@ const overlayBlockExpr = z message: "String must be a valid expression string.", }) - export const overlayBlockExprValidator = z.any().superRefine((val, ctx) => { if (commonNumber.safeParse(val).success) { return @@ -168,21 +165,24 @@ export const overlayBlockExprValidator = z.any().superRefine((val, ctx) => { }) }) - - - 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() + (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: { - unsharpenMask?: boolean - unsharpenMaskRadius?: number - unsharpenMaskSigma?: number - unsharpenMaskAmount?: number - unsharpenMaskThreshold?: number -}, ctx: z.RefinementCtx) => { +export const refineUnsharpenMask = ( + val: { + unsharpenMask?: boolean + unsharpenMaskRadius?: number + unsharpenMaskSigma?: number + unsharpenMaskAmount?: number + unsharpenMaskThreshold?: number + }, + ctx: z.RefinementCtx, +) => { if (val.unsharpenMask === true) { if (!val.unsharpenMaskRadius) { ctx.addIssue({ @@ -213,4 +213,4 @@ export const refineUnsharpenMask = (val: { }) } } -} \ No newline at end of file +} From ae16d31837f40446054c444e868aa6b5bb570451 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Wed, 11 Feb 2026 15:15:45 +0530 Subject: [PATCH 44/48] Do not use structured clone --- packages/imagekit-editor-dev/src/schema/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 67c3d3a..bb755ea 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -4733,7 +4733,7 @@ function validatePerspectiveDistort( ) { const { distort, distortType, distortPerspective } = value if (distort && distortType === "perspective" && distortPerspective) { - const perspective: PerspectiveObject = structuredClone(distortPerspective) + const perspective: PerspectiveObject = JSON.parse(JSON.stringify(distortPerspective)) const coords = Object.keys(perspective).reduce( (acc, key) => { const value = perspective[key as keyof typeof perspective] From b99e7c970eefe09d6f345c048dc59c30f195afdd Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Wed, 11 Feb 2026 20:44:35 +0530 Subject: [PATCH 45/48] Fix formatting --- packages/imagekit-editor-dev/src/schema/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index bb755ea..b4d2f4a 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -4733,7 +4733,9 @@ function validatePerspectiveDistort( ) { const { distort, distortType, distortPerspective } = value if (distort && distortType === "perspective" && distortPerspective) { - const perspective: PerspectiveObject = JSON.parse(JSON.stringify(distortPerspective)) + const perspective: PerspectiveObject = JSON.parse( + JSON.stringify(distortPerspective), + ) const coords = Object.keys(perspective).reduce( (acc, key) => { const value = perspective[key as keyof typeof perspective] From 2deab3d41ea38a94dd9f13c7292d7588cded9f76 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Wed, 11 Feb 2026 20:46:50 +0530 Subject: [PATCH 46/48] Move to strict type --- .../imagekit-editor-dev/src/components/common/ZoomInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx index 06c4c7e..4cc77b8 100644 --- a/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx +++ b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx @@ -15,7 +15,7 @@ import { useEffect, useState } from "react" type ZoomInputFieldProps = { id?: string onChange: (value: number) => void - defaultValue?: number | unknown + defaultValue?: number value?: number } From ccb8520e66eb52cdd056b70b22ba40338c96aca1 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Mon, 16 Feb 2026 11:33:06 +0530 Subject: [PATCH 47/48] feat: complete ux revamp for backgrounds --- .../sidebar/transformation-config-sidebar.tsx | 2 + .../src/schema/background.ts | 366 ++++++++++++++++++ .../imagekit-editor-dev/src/schema/index.ts | 181 +++------ packages/imagekit-editor-dev/src/store.ts | 27 +- .../imagekit-editor-dev/src/utils/index.ts | 45 +++ 5 files changed, 488 insertions(+), 133 deletions(-) create mode 100644 packages/imagekit-editor-dev/src/schema/background.ts 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 08e5856..8bef9ce 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 @@ -347,6 +347,7 @@ export const TransformationConfigSidebar: React.FC = () => { })) const isCreatable = field.fieldProps?.isCreatable === true + const isClearable: boolean = field.fieldProps?.isClearable ?? false const SelectComponent = isCreatable ? CreateableSelect : Select @@ -372,6 +373,7 @@ export const TransformationConfigSidebar: React.FC = () => { formatCreateLabel={(inputValue) => `Use "${inputValue}"` } + isClearable={isClearable} placeholder="Select" menuPlacement="auto" options={selectOptions} diff --git a/packages/imagekit-editor-dev/src/schema/background.ts b/packages/imagekit-editor-dev/src/schema/background.ts new file mode 100644 index 0000000..54cb3cd --- /dev/null +++ b/packages/imagekit-editor-dev/src/schema/background.ts @@ -0,0 +1,366 @@ +import { z } from "zod/v3" +import { colorValidator } from "./transformation" +import type { TransformationField } from "." +import { GradientPickerState } from "../components/common/GradientPicker" + +export const SUPPORTED_BACKGROUND_TYPES: Record = { + // Solid color background - bg- and bg-dominant + color: { + label: "Color", + value: "color", + }, + // Gradient background - bg-gradient_dominant and e-gradient with layers + gradient: { + label: "Gradient", + value: "gradient", + }, + // Blurred background - bg-blurred + blurred: { + label: "Blurred", + value: "blurred", + }, + // AI Generative Fill - bg-genfill + genfill: { + label: "Generative Fill", + value: "generative_fill", + }, +} as const + +export type BackgroundContext = + "pad_extract" | + "pad_resize" | + "root_image" + +export const backgroundTransformations: + Record TransformationField> = { + backgroundType: ({ context, transformationGroup }) => { + // Filter background types based on context + let availableTypes = Object.values(SUPPORTED_BACKGROUND_TYPES); + + if (context === "root_image") { + // Global transformations: only color and gradient + availableTypes = availableTypes.filter(type => + type.value === "color" || type.value === "gradient" + ); + } else if (context === "pad_extract") { + // Pad extract: color, gradient, and generative fill (no blur) + availableTypes = availableTypes.filter(type => + type.value === "color" || type.value === "gradient" || type.value === "generative_fill" + ); + } + // For pad_resize, all types are available + + const transformation: TransformationField = { + label: "Background Type", + name: "backgroundType", + fieldType: "select", + isTransformation: false, + transformationGroup: transformationGroup, + fieldProps: { + options: availableTypes.map((type) => ({ + label: type.label, + value: type.value, + })), + isClearable: true, + }, + helpText: "Choose the type of background to apply to your image.", + } + + if (context === "root_image") { + transformation.fieldProps!.isClearable = false; + } + + return transformation; + }, + background: ({ context, transformationGroup }) => { + const transformation: TransformationField = { + label: "Background Color", + name: "background", + fieldType: "color-picker", + transformationGroup: transformationGroup, + isTransformation: true, + helpText: "Apply a solid color to the background.", + examples: ["FFFFFF", "FF0000"], + isVisible: ({ backgroundType }) => backgroundType === "color", + } + + switch (context) { + case "root_image": + // Root image: show color picker when type is color and auto pick is off + transformation.isVisible = ({ backgroundType, backgroundDominantAuto }) => + backgroundType === "color" && !backgroundDominantAuto; + break; + case "pad_resize": + case "pad_extract": + // Contexts that support bg-dominant: show color picker when type is color and auto pick is off + transformation.isVisible = ({ backgroundType, backgroundDominantAuto }) => + backgroundType === "color" && !backgroundDominantAuto; + break; + } + + return transformation; + }, + backgroundDominantAuto: ({ transformationGroup }) => { + const transformation: TransformationField = { + label: "Auto-pick dominant color from borders", + name: "backgroundDominantAuto", + fieldType: "switch", + isTransformation: false, + transformationGroup: transformationGroup, + fieldProps: { + defaultValue: false, + }, + helpText: "Automatically pick the most dominant color from the border pixels of the image. Useful when using pad_resize or pad_extract crop modes.", + isVisible: ({ backgroundType }) => backgroundType === "color", + } + + return transformation; + }, + backgroundGradientAutoDominant: ({ transformationGroup }) => { + const transformation: TransformationField = { + label: "Auto-detect gradient colors", + name: "backgroundGradientAutoDominant", + fieldType: "switch", + isTransformation: false, + transformationGroup: transformationGroup, + helpText: "Automatically pick the most dominant colors from the image to form a gradient background.", + isVisible: ({ backgroundType }) => backgroundType === "gradient", + } + + return transformation; + }, + backgroundGradientMode: ({ transformationGroup }) => { + const transformation: TransformationField = { + label: "Gradient Mode", + name: "backgroundGradientMode", + fieldType: "select", + isTransformation: false, + transformationGroup: transformationGroup, + fieldProps: { + options: [ + { label: "Dominant", value: "dominant" }, + ], + defaultValue: "dominant", + }, + helpText: "The method used to generate the gradient.", + isVisible: ({ backgroundType, backgroundGradientAutoDominant }) => + backgroundType === "gradient" && backgroundGradientAutoDominant === true, + } + + return transformation; + }, + backgroundGradientPaletteSize: ({ transformationGroup }) => { + const transformation: TransformationField = { + label: "Palette Size", + name: "backgroundGradientPaletteSize", + fieldType: "radio-card", + isTransformation: false, + transformationGroup: transformationGroup, + fieldProps: { + options: [ + { label: "2 Colors (Linear)", value: "2" }, + { label: "4 Colors (Corners)", value: "4" }, + ], + defaultValue: "2", + columns: 2, + }, + helpText: "Number of colors to pick from the image. 2 creates a linear gradient, 4 creates corner interpolation.", + isVisible: ({ backgroundType, backgroundGradientAutoDominant }) => + backgroundType === "gradient" && backgroundGradientAutoDominant === true, + } + + return transformation; + }, + backgroundBlurIntensity: ({ transformationGroup }) => { + const transformation: TransformationField = { + label: "Background Blur Intensity", + name: "backgroundBlurIntensity", + fieldType: "slider", + helpText: + "For blurred backgrounds, choose a blur radius or select 'auto' for a smart default. Width and height are required when using a blurred background.", + examples: ["auto", "30"], + isTransformation: true, + transformationGroup: transformationGroup, + fieldProps: { + defaultValue: "auto", + min: 0, + max: 100, + step: 1, + autoOption: true, + }, + isVisible: ({ backgroundType }) => backgroundType === "blurred", + } + + return transformation; + }, + backgroundBlurBrightness: ({ transformationGroup }) => { + const transformation: TransformationField = { + label: "Background Blur Brightness", + name: "backgroundBlurBrightness", + fieldType: "slider", + helpText: + "Adjust the brightness of a blurred background. Use a number between −255 (darker) and 255 (brighter).", + isTransformation: false, + transformationGroup: transformationGroup, + fieldProps: { + defaultValue: "0", + min: -255, + max: 255, + step: 5, + }, + isVisible: ({ backgroundType }) => backgroundType === "blurred", + } + + return transformation; + }, + backgroundGenerativeFill: ({ transformationGroup }) => { + const transformation: TransformationField = { + label: "Background Generative Fill", + name: "backgroundGenerativeFill", + fieldType: "input", + transformationGroup: transformationGroup, + isTransformation: true, + helpText: + "When using AI generative background, provide a text prompt describing what should be generated.", + examples: ["mountain landscape"], + isVisible: ({ backgroundType }) => + backgroundType === "generative_fill", + } + + return transformation; + }, + backgroundGradient: ({ transformationGroup }) => { + const transformation: TransformationField = { + label: "Background Gradient", + name: "backgroundGradient", + fieldType: "gradient-picker", + transformationGroup: transformationGroup, + isTransformation: true, + helpText: + "Create a custom gradient background with your chosen colors and direction.", + fieldProps: { + defaultValue: { + from: "#FFFFFFFF", + to: "#00000000", + direction: "bottom", + stopPoint: 100, + }, + }, + isVisible: ({ backgroundType, backgroundGradientAutoDominant }) => + backgroundType === "gradient" && backgroundGradientAutoDominant !== true, + } + + return transformation; + }, +} + +export const background = { + schemas: { + backgroundType: z.string().optional(), + background: z + .union([z.literal("").transform(() => ""), colorValidator]) + .optional(), + backgroundGenerativeFill: z.string().optional(), + backgroundBlurIntensity: z.coerce + .string({ + invalid_type_error: + "Should be a number between 1 and 100 or auto.", + }) + .optional(), + backgroundBlurBrightness: z.coerce + .string({ + invalid_type_error: "Should be a number between -255 and 255.", + }) + .optional(), + backgroundDominantAuto: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + backgroundGradientAutoDominant: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + backgroundGradientMode: z.string().optional(), + backgroundGradientPaletteSize: z.string().optional(), + backgroundGradient: z.object({ + from: z.string().optional(), + to: z.string().optional(), + direction: z.union([ + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0).max(359), + z.string(), + ]).optional(), + stopPoint: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(1).max(100).optional(), + }).optional(), + }, + getPropsFor: (context: BackgroundContext): { + schema: Record, + transformations: ({ transformationGroup }: { transformationGroup: string | undefined }) => TransformationField[], + } => { + let schemaToBeReturned: Record = { + backgroundType: background.schemas.backgroundType, + } + + switch (context) { + case "root_image": + // Global transformations: color + gradient support (no bg-dominant) + schemaToBeReturned = { + ...schemaToBeReturned, + background: background.schemas.background, + backgroundGradientAutoDominant: background.schemas.backgroundGradientAutoDominant, + backgroundGradientMode: background.schemas.backgroundGradientMode, + backgroundGradientPaletteSize: background.schemas.backgroundGradientPaletteSize, + backgroundGradient: background.schemas.backgroundGradient, + } + break; + case "pad_extract": + // Pad extract: color + gradient + generative fill (no blur) + schemaToBeReturned = { + ...schemaToBeReturned, + background: background.schemas.background, + backgroundDominantAuto: background.schemas.backgroundDominantAuto, + backgroundGradientAutoDominant: background.schemas.backgroundGradientAutoDominant, + backgroundGradientMode: background.schemas.backgroundGradientMode, + backgroundGradientPaletteSize: background.schemas.backgroundGradientPaletteSize, + backgroundGradient: background.schemas.backgroundGradient, + backgroundGenerativeFill: background.schemas.backgroundGenerativeFill, + } + break; + case "pad_resize": + // Pad resize: all background types supported + schemaToBeReturned = { + ...schemaToBeReturned, + background: background.schemas.background, + backgroundDominantAuto: background.schemas.backgroundDominantAuto, + backgroundGradientAutoDominant: background.schemas.backgroundGradientAutoDominant, + backgroundGradientMode: background.schemas.backgroundGradientMode, + backgroundGradientPaletteSize: background.schemas.backgroundGradientPaletteSize, + backgroundGradient: background.schemas.backgroundGradient, + backgroundBlurIntensity: background.schemas.backgroundBlurIntensity, + backgroundBlurBrightness: background.schemas.backgroundBlurBrightness, + backgroundGenerativeFill: background.schemas.backgroundGenerativeFill, + } + break; + } + + // Automatically pick the transformations based on the schema + const transformationsToBeReturned = (transformationGroup: string | undefined) => Object.entries(backgroundTransformations) + .filter(([transformationName]) => { + return schemaToBeReturned[transformationName] !== undefined; + }) + .map(([, transformationFactory]) => { + return transformationFactory({ context, transformationGroup }); + }); + + return { + schema: schemaToBeReturned, + transformations: ({ transformationGroup }: { transformationGroup: string }) => + transformationsToBeReturned(transformationGroup), + }; + } +} \ No newline at end of file diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index b4d2f4a..95b8591 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -26,6 +26,7 @@ import { refineUnsharpenMask, widthValidator, } from "./transformation" +import { background } from "./background" // Based on ImageKit's supported object list export const DEFAULT_FOCUS_OBJECTS = [ @@ -138,6 +139,7 @@ export interface TransformationField { }[] autoOption?: boolean isCreatable?: boolean + isClearable?: boolean min?: number max?: number step?: number @@ -178,22 +180,7 @@ export const transformationSchema: TransformationSchema[] = [ .object({ width: widthValidator.optional(), height: heightValidator.optional(), - backgroundType: z.string().optional(), - background: z - .union([z.literal("").transform(() => ""), colorValidator]) - .optional(), - backgroundBlurIntensity: z.coerce - .string({ - invalid_type_error: - "Should be a number between 1 and 100 or auto.", - }) - .optional(), - backgroundBlurBrightness: z.coerce - .string({ - invalid_type_error: "Should be a number between -255 and 255.", - }) - .optional(), - backgroundGenerativeFill: z.string().optional(), + ...background.getPropsFor("pad_resize").schema, focus: z.string().optional(), }) .refine( @@ -274,75 +261,7 @@ export const transformationSchema: TransformationSchema[] = [ "Specify the output height. Use a decimal between 0 and 1, an integer greater than 1, or an expression.", examples: ["0.5", "300", "ih_div_2"], }, - { - label: "Background Type", - name: "backgroundType", - fieldType: "select", - isTransformation: false, - transformationGroup: "background", - fieldProps: { - options: [ - { label: "Color", value: "color" }, - { label: "Blurred", value: "blurred" }, - { label: "Generative Fill", value: "generative_fill" }, - ], - }, - }, - { - label: "Background Color", - name: "background", - fieldType: "color-picker", - transformationGroup: "background", - isTransformation: true, - isVisible: ({ backgroundType }) => backgroundType === "color", - }, - { - label: "Background Blur Intensity", - name: "backgroundBlurIntensity", - fieldType: "slider", - helpText: - "For blurred backgrounds, choose a blur radius or select 'auto' for a smart default. Width and height are required when using a blurred background.", - examples: ["auto", "30"], - isTransformation: true, - transformationKey: "background", - transformationGroup: "background", - fieldProps: { - defaultValue: "auto", - min: 0, - max: 100, - step: 1, - autoOption: true, - }, - isVisible: ({ backgroundType }) => backgroundType === "blurred", - }, - { - label: "Background Blur Brightness", - name: "backgroundBlurBrightness", - fieldType: "slider", - helpText: - "Adjust the brightness of a blurred background. Use a number between −255 (darker) and 255 (brighter).", - isTransformation: false, - transformationGroup: "background", - fieldProps: { - defaultValue: "0", - min: -255, - max: 255, - step: 5, - }, - isVisible: ({ backgroundType }) => backgroundType === "blurred", - }, - { - label: "Background Generative Fill", - name: "backgroundGenerativeFill", - fieldType: "input", - helpText: - "When using a generative fill background, enter an optional text prompt describing what should fill the padded area. Width and height are required for generative fill.", - examples: ["snowy forest"], - isTransformation: true, - transformationGroup: "background", - isVisible: ({ backgroundType }) => - backgroundType === "generative_fill", - }, + ...background.getPropsFor("pad_resize").transformations({ transformationGroup: "background" }), { label: "Focus", name: "focus", @@ -1061,11 +980,7 @@ export const transformationSchema: TransformationSchema[] = [ .object({ width: widthValidator.optional(), height: heightValidator.optional(), - backgroundType: z.string().optional(), - background: z - .union([z.literal("").transform(() => ""), colorValidator]) - .optional(), - backgroundGenerativeFill: z.string().optional(), + ...background.getPropsFor("pad_extract").schema, }) .refine( (val) => { @@ -1104,41 +1019,7 @@ export const transformationSchema: TransformationSchema[] = [ "Specify the height of the extracted region. If the region is smaller than this height, padding will be added. Use a percentage, pixels, or an expression.", examples: ["0.5", "300", "ih_div_2"], }, - { - label: "Background Type", - name: "backgroundType", - fieldType: "select", - isTransformation: false, - transformationGroup: "background", - fieldProps: { - options: [ - { label: "Color", value: "color" }, - { label: "Generative Fill", value: "generative_fill" }, - ], - }, - }, - { - label: "Background Color", - name: "background", - fieldType: "color-picker", - transformationGroup: "background", - isTransformation: true, - helpText: "When using color padding, enter a hex code.", - examples: ["FFFFFF", "FF0000"], - isVisible: ({ backgroundType }) => backgroundType === "color", - }, - { - label: "Background Generative Fill", - name: "backgroundGenerativeFill", - fieldType: "input", - transformationGroup: "background", - isTransformation: true, - helpText: - "When using AI generative padding, provide a text prompt describing the fill.", - examples: ["mountain landscape"], - isVisible: ({ backgroundType }) => - backgroundType === "generative_fill", - }, + ...background.getPropsFor("pad_extract").transformations({ transformationGroup: "background" }), ], }, ], @@ -1147,6 +1028,19 @@ export const transformationSchema: TransformationSchema[] = [ key: "adjust", name: "Adjust", items: [ + { + key: "adjust-background", + name: "Background", + description: "Apply a solid color or a gradient background to the image.", + docsLink: "https://imagekit.io/docs/effects-and-enhancements#background---bg", + defaultTransformation: {}, + schema: z.object({ + ...background.getPropsFor("root_image").schema, + }), + transformations: [ + ...background.getPropsFor("root_image").transformations({ transformationGroup: "background" }), + ], + }, { key: "adjust-contrast", name: "Contrast", @@ -4105,15 +3999,40 @@ export const transformationFormatters: Record< ) => void > = { background: (values, transforms) => { - let { backgroundType, backgroundBlurIntensity, backgroundBlurBrightness } = - values as Record + let { backgroundType, backgroundBlurIntensity, backgroundBlurBrightness, backgroundDominantAuto, backgroundGradientAutoDominant, backgroundGradientMode, backgroundGradientPaletteSize, backgroundGradient } = + values as Record - if (backgroundBlurBrightness?.startsWith("-")) { - backgroundBlurBrightness = backgroundBlurBrightness.replace("-", "N") + if (backgroundBlurBrightness?.startsWith?.("-")) { + backgroundBlurBrightness = (backgroundBlurBrightness as string).replace("-", "N") } - if (backgroundType === "color" && values.background) { - transforms.background = (values.background as string).replace("#", "") + if (backgroundType === "color") { + if (backgroundDominantAuto) { + transforms.background = "dominant" + } else if (values.background) { + transforms.background = (values.background as string).replace("#", "") + } + } else if (backgroundType === "gradient") { + if (backgroundGradientAutoDominant) { + // Use gradient with dominant color detection + const paletteSize = backgroundGradientPaletteSize || "2" + const mode = backgroundGradientMode || "dominant" + transforms.background = `gradient_${mode}_${paletteSize}` + } else if (backgroundGradient) { + // Manual gradient using full layer syntax approach + const gradient = backgroundGradient as GradientPickerState + const { from, to, direction, stopPoint } = gradient + + // Build the gradient parameters + const fromColor = from?.replace("#", "") || "FFFFFF" + const toColor = to?.replace("#", "") || "000000" + const gradientDirection = direction || "bottom" + const stopPointDecimal = (stopPoint || 100) / 100 + + // Create the full layer syntax with placeholder for image path + // This will be replaced with actual image path in the store per image + transforms.raw = `e-gradient-ld-${gradientDirection}_from-${fromColor}_to-${toColor}_sp-${stopPointDecimal}:l-image,i-__IMAGE_PATH__,t-false,l-end` + } } else if (backgroundType === "blurred") { if (backgroundBlurIntensity === "auto" && !backgroundBlurBrightness) { transforms.background = "blurred_auto" diff --git a/packages/imagekit-editor-dev/src/store.ts b/packages/imagekit-editor-dev/src/store.ts index a66ca1f..362979f 100644 --- a/packages/imagekit-editor-dev/src/store.ts +++ b/packages/imagekit-editor-dev/src/store.ts @@ -12,6 +12,7 @@ import { transformationFormatters, transformationSchema, } from "./schema" +import { extractImagePath } from "./utils" export interface Transformation { id: string @@ -461,6 +462,21 @@ const useEditorStore = create()( })), ) +const replaceImagePathPlaceholders = ( + transformations: IKTransformation[], + imagePath: string, +): IKTransformation[] => { + return transformations.map((transformation) => { + const clonedTransformation = { ...transformation } + + if (typeof clonedTransformation.raw === 'string' && clonedTransformation.raw.includes('__IMAGE_PATH__')) { + clonedTransformation.raw = clonedTransformation.raw.replace(/__IMAGE_PATH__/g, imagePath) + } + + return clonedTransformation + }) +} + const calculateImageList = ( imageList: FileElement[], transformations: Transformation[], @@ -576,9 +592,15 @@ const calculateImageList = ( }> = [] imageList.forEach((img, index) => { + // Replace any __IMAGE_PATH__ placeholders with actual image path for this specific image + const imagePath = extractImagePath(img.url) + const transformationsForImage = showOriginal + ? [] + : replaceImagePathPlaceholders(IKTransformations, imagePath) + const req = { url: img.url, - transformation: showOriginal ? [] : IKTransformations, + transformation: transformationsForImage, metadata: img.metadata, } @@ -588,7 +610,8 @@ const calculateImageList = ( } if (req.metadata.requireSignedUrl && signer) { - const cacheKey = `${req.url}::${transformKey}` + const imageTransformKey = JSON.stringify(req.transformation) + const cacheKey = `${req.url}::${imageTransformKey}` const cached = signedUrlCache[cacheKey] if (cached) { imgs[index] = cached diff --git a/packages/imagekit-editor-dev/src/utils/index.ts b/packages/imagekit-editor-dev/src/utils/index.ts index cc879c6..9d70141 100644 --- a/packages/imagekit-editor-dev/src/utils/index.ts +++ b/packages/imagekit-editor-dev/src/utils/index.ts @@ -45,3 +45,48 @@ export const isStepAligned = (raw: string, step: number) => { const s = Math.round(step * m) return s !== 0 && v % s === 0 } + +/** + * Extracts the image path from a URL for use in ImageKit layer syntax (i- parameter). + * Removes ImageKit ID and converts folder paths to use @@ separator. + * @param imageUrl - The image URL (e.g., "https://ik.imagekit.io/cr29v1rbc/pikachu.png" or "https://ik.imagekit.io/cr29v1rbc/folder-name/pikachu.png") + * @returns The path for use in i- parameter (e.g., "pikachu.png" or "folder-name@@pikachu.png") + */ +export const extractImagePath = (imageUrl: string): string => { + try { + const urlWithoutQuery = imageUrl.split('?')[0] + + if (urlWithoutQuery.startsWith('http://') || urlWithoutQuery.startsWith('https://')) { + const url = new URL(urlWithoutQuery) + const pathname = url.pathname.replace(/^\//, '') + + const segments = pathname.split('/') + + if (segments.length > 1) { + const pathWithoutImageKitId = segments.slice(1).join('/') + return pathWithoutImageKitId.replace(/\//g, '@@') + } + + return segments[0] || '' + } + + const cleanPath = urlWithoutQuery.replace(/^\//, '') + const segments = cleanPath.split('/') + + if (segments.length > 1) { + const pathWithoutFirstSegment = segments.slice(1).join('/') + return pathWithoutFirstSegment.replace(/\//g, '@@') + } + + return segments[0] || '' + } catch (error) { + const cleanPath = imageUrl.split('?')[0].replace(/^\//, '') + const segments = cleanPath.split('/') + + if (segments.length > 1) { + return segments.slice(1).join('/').replace(/\//g, '@@') + } + + return segments[0] || '' + } +} From 85a2d14117287cd761909ab8ccd728fb6dea0eda Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Mon, 16 Feb 2026 15:57:22 +0530 Subject: [PATCH 48/48] feat: resize and crop mode revamp ux --- .../sidebar/transformation-config-sidebar.tsx | 84 +- .../imagekit-editor-dev/src/schema/index.ts | 1739 +++++++++-------- .../src/schema/resizeAndCrop.ts | 559 ++++++ packages/imagekit-editor-dev/src/store.ts | 15 +- 4 files changed, 1515 insertions(+), 882 deletions(-) create mode 100644 packages/imagekit-editor-dev/src/schema/resizeAndCrop.ts 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 8bef9ce..150c69d 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 @@ -45,7 +45,7 @@ import Select from "react-select" import CreateableSelect from "react-select/creatable" import { z } from "zod/v3" import type { TransformationField } from "../../schema" -import { DEFAULT_FOCUS_OBJECTS, transformationSchema } from "../../schema" +import { DEFAULT_FOCUS_OBJECTS, transformationSchema, RESIZE_CROP_MODES, RESIZE_CROP_HELP_TEXT } from "../../schema" import { useEditorStore } from "../../store" import { isStepAligned } from "../../utils" import AnchorField from "../common/AnchorField" @@ -187,11 +187,38 @@ export const TransformationConfigSidebar: React.FC = () => { } const transformationToEdit = _internalState.transformationToEdit + + // Generate display name for resize_and_crop transformation + let displayName = selectedTransformation.name + if (selectedTransformation.key === "resize_and_crop-resize_and_crop" && data.mode) { + const modeInfo = RESIZE_CROP_MODES.find((m) => m.value === data.mode) + if (modeInfo) { + displayName = `Resize and Crop (${modeInfo.label})` + } + } + + // Helper to check if a name is auto-generated for resize_and_crop + const isAutoGeneratedName = (name: string) => { + if (name === "Resize and Crop") return true + // Check if it matches pattern "Resize and Crop (ModeName)" + return RESIZE_CROP_MODES.some(mode => + name === `Resize and Crop (${mode.label})` + ) + } if (transformationToEdit && transformationToEdit.position === "inplace") { + // For resize_and_crop, only update name if it's still auto-generated + // If user has manually changed it, preserve their custom name + let finalName = editedTransformation?.name ?? displayName + if (selectedTransformation.key === "resize_and_crop-resize_and_crop" && editedTransformation?.name) { + if (isAutoGeneratedName(editedTransformation.name)) { + finalName = displayName + } + } + updateTransformation(transformationToEdit.transformationId, { type: "transformation", - name: editedTransformation?.name ?? selectedTransformation.name, + name: finalName, key: selectedTransformation.key, value: data, }) @@ -207,7 +234,7 @@ export const TransformationConfigSidebar: React.FC = () => { const transformationId = addTransformation( { type: "transformation", - name: selectedTransformation.name, + name: displayName, key: selectedTransformation.key, value: data, }, @@ -218,7 +245,7 @@ export const TransformationConfigSidebar: React.FC = () => { } else { const transformationId = addTransformation({ type: "transformation", - name: selectedTransformation.name, + name: displayName, key: selectedTransformation.key, value: data, }) @@ -307,6 +334,11 @@ export const TransformationConfigSidebar: React.FC = () => { + {selectedTransformation.key === "resize_and_crop-resize_and_crop" && ( + + {RESIZE_CROP_HELP_TEXT} + + )} {selectedTransformation.transformations .filter((field) => { if (field.isVisible) { @@ -316,18 +348,18 @@ export const TransformationConfigSidebar: React.FC = () => { }) .map((field: TransformationField) => ( field.fieldType === type, - ) - } - > - - {field.label} - - {field.fieldType === "select" ? ( + key={field.name} + isInvalid={ + !!errors[field.name] && + !["gradient-picker", "padding-input"].some( + (type) => field.fieldType === type, + ) + } + > + + {field.label} + + {field.fieldType === "select" ? ( { | readonly string[] | undefined } + disabled={ + // Disable aspect ratio when both width and height are set + selectedTransformation.key === "resize_and_crop-resize_and_crop" && + field.name === "aspectRatio" && + !!values.width && + !!values.height + } /> ) : null} {field.fieldType === "textarea" ? ( @@ -695,7 +734,18 @@ export const TransformationConfigSidebar: React.FC = () => { )} {field.helpText && ( - {field.helpText} + + {field.helpText} + {/* Additional help text for aspect ratio when both dimensions are set */} + {selectedTransformation.key === "resize_and_crop-resize_and_crop" && + field.name === "aspectRatio" && + values.width && + values.height && ( + + Note: Aspect ratio is ignored when both width and height are specified. + + )} + )} {field.examples && ( diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 95b8591..c35a017 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -27,6 +27,15 @@ import { widthValidator, } from "./transformation" import { background } from "./background" +import { + resizeAndCropCategory, + RESIZE_CROP_MODES, + RESIZE_CROP_HELP_TEXT, + getDefaultTransformationFromMode, +} from "./resizeAndCrop" + +// Re-export for use by store and components +export { RESIZE_CROP_MODES, RESIZE_CROP_HELP_TEXT, getDefaultTransformationFromMode } // Based on ImageKit's supported object list export const DEFAULT_FOCUS_OBJECTS = [ @@ -159,871 +168,873 @@ export interface TransformationSchema { } export const transformationSchema: TransformationSchema[] = [ - { - key: "resize", - name: "Resize", - items: [ - { - key: "resize-pad_resize", - name: "Pad Resize", - // When using the pad resize crop strategy, ImageKit resizes the image to the - // requested width and/or height while preserving the original aspect ratio. - // Any remaining space is filled with a background, which can be a solid - // color, a blurred version of the image or a generative fill. This - // strategy never crops the image content. - description: - "Resize an image to fit within the specified width and height while preserving its aspect ratio. Any extra space is padded with a background color, a blurred version of the image, or an AI-generated fill.", - docsLink: - "https://imagekit.io/docs/image-resize-and-crop#pad-resize-crop-strategy---cm-pad_resize", - defaultTransformation: { cropMode: "pad_resize" }, - schema: z - .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), - ...background.getPropsFor("pad_resize").schema, - focus: z.string().optional(), - }) - .refine( - (val) => { - if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) - ) { - return true - } - return false - }, - { - message: "At least one value is required", - path: [], - }, - ) - .superRefine((val, ctx) => { - if ( - val.backgroundType === "blurred" && - (!val.width || !val.height) - ) { - if (!val.width) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Required for blurred background", - path: ["width"], - }) - } - if (!val.height) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Required for blurred background", - path: ["height"], - }) - } - } + // Unified Resize and Crop transformation + resizeAndCropCategory, + // { + // key: "resize", + // name: "Resize", + // items: [ + // { + // key: "resize-pad_resize", + // name: "Pad Resize", + // // When using the pad resize crop strategy, ImageKit resizes the image to the + // // requested width and/or height while preserving the original aspect ratio. + // // Any remaining space is filled with a background, which can be a solid + // // color, a blurred version of the image or a generative fill. This + // // strategy never crops the image content. + // description: + // "Resize an image to fit within the specified width and height while preserving its aspect ratio. Any extra space is padded with a background color, a blurred version of the image, or an AI-generated fill.", + // docsLink: + // "https://imagekit.io/docs/image-resize-and-crop#pad-resize-crop-strategy---cm-pad_resize", + // defaultTransformation: { cropMode: "pad_resize" }, + // schema: z + // .object({ + // width: widthValidator.optional(), + // height: heightValidator.optional(), + // ...background.getPropsFor("pad_resize").schema, + // focus: z.string().optional(), + // }) + // .refine( + // (val) => { + // if ( + // Object.values(val).some( + // (v) => v !== undefined && v !== null && v !== "", + // ) + // ) { + // return true + // } + // return false + // }, + // { + // message: "At least one value is required", + // path: [], + // }, + // ) + // .superRefine((val, ctx) => { + // if ( + // val.backgroundType === "blurred" && + // (!val.width || !val.height) + // ) { + // if (!val.width) { + // ctx.addIssue({ + // code: z.ZodIssueCode.custom, + // message: "Required for blurred background", + // path: ["width"], + // }) + // } + // if (!val.height) { + // ctx.addIssue({ + // code: z.ZodIssueCode.custom, + // message: "Required for blurred background", + // path: ["height"], + // }) + // } + // } - if ( - val.backgroundType === "generative_fill" && - (!val.width || !val.height) - ) { - if (!val.width) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Required for generative fill background", - path: ["width"], - }) - } - if (!val.height) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Required for generative fill background", - path: ["height"], - }) - } - } - }), - transformations: [ - { - label: "Width", - name: "width", - fieldType: "input", - isTransformation: true, - transformationKey: "width", - helpText: - "Specify the output width. Use a decimal between 0 and 1, an integer greater than 1 for pixel units, or an expression.", - examples: ["0.5 (50%)", "300", "iw_div_2"], - }, - { - label: "Height", - name: "height", - fieldType: "input", - isTransformation: true, - transformationKey: "height", - helpText: - "Specify the output height. Use a decimal between 0 and 1, an integer greater than 1, or an expression.", - examples: ["0.5", "300", "ih_div_2"], - }, - ...background.getPropsFor("pad_resize").transformations({ transformationGroup: "background" }), - { - label: "Focus", - name: "focus", - fieldType: "anchor", - isTransformation: true, - transformationKey: "focus", - fieldProps: { - positions: ["center", "top", "bottom", "left", "right"], - }, - }, - ], - }, - { - key: "resize-maintain_aspect_ratio", - name: "Maintain Aspect Ratio", - // This strategy resizes and crops the image to fit the requested box while - // preserving the original aspect ratio. It may crop parts of the image - // (default centre crop) to achieve the final size. You can specify only - // one dimension (width or height) or an aspect ratio. Focus settings can - // be used to keep important content in view. - description: - "Resize an image to the requested dimensions while preserving its aspect ratio. The image is scaled and cropped as necessary; specify width, height or an aspect ratio, and optionally set a focus area.", - docsLink: - "https://imagekit.io/docs/image-resize-and-crop#maintain-ratio-crop-strategy---c-maintain_ratio", - defaultTransformation: { crop: "maintain_ratio" }, - schema: z - .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), - aspectRatio: aspectRatioValidator.optional(), - focus: z.string().optional(), - focusAnchor: z.string().optional(), - focusObject: z.string().optional(), - zoom: z.coerce.number().optional(), - }) - .refine( - (val) => { - if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) - ) { - return true - } - return false - }, - { - message: "At least one value is required", - path: [], - }, - ) - .superRefine((val, ctx) => { - if (val.width && val.height) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Width and height cannot be used together", - path: [], - }) - } - if (val.width && val.height && val.aspectRatio) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "Width, height and aspect ratio cannot be used together", - path: [], - }) - } - if (val.focus === "object" && !val.focusObject) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Focus object is required", - path: ["focusObject"], - }) - } - if (val.focus === "anchor" && !val.focusAnchor) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Focus anchor is required", - path: ["focusAnchor"], - }) - } - }), - transformations: [ - { - label: "Width", - name: "width", - fieldType: "input", - isTransformation: true, - transformationKey: "width", - helpText: - "Specify the target width. Width and height cannot be used together. Use a decimal, an integer, or an expression.", - examples: ["0.5", "300", "iw_div_2"], - }, - { - label: "Height", - name: "height", - fieldType: "input", - isTransformation: true, - transformationKey: "height", - helpText: - "Specify the target height. Height and width cannot be used together. Use a decimal, an integer, or an expression.", - examples: ["0.5", "300", "ih_div_2"], - }, - { - label: "Aspect Ratio", - name: "aspectRatio", - fieldType: "input", - isTransformation: true, - transformationKey: "aspectRatio", - helpText: - "Enter an aspect ratio as 'width-height' or an expression. Cannot be used alongside both width and height.", - examples: ["16-9", "4-3", "iar_mul_0.75"], - }, - { - label: "Focus", - name: "focus", - fieldType: "select", - isTransformation: true, - transformationGroup: "focus", - fieldProps: { - options: [ - { label: "Auto", value: "auto" }, - { label: "Anchor", value: "anchor" }, - { label: "Face", value: "face" }, - { label: "Object", value: "object" }, - ], - }, - }, - { - label: "Focus Anchor", - name: "focusAnchor", - fieldType: "anchor", - isTransformation: true, - transformationGroup: "focus", - fieldProps: { - options: [ - { label: "Center", value: "center" }, - { label: "Top", value: "top" }, - { label: "Bottom", value: "bottom" }, - { label: "Left", value: "left" }, - { label: "Right", value: "right" }, - { label: "Top Left", value: "top_left" }, - { label: "Top Right", value: "top_right" }, - { label: "Bottom Left", value: "bottom_left" }, - { label: "Bottom Right", value: "bottom_right" }, - ], - }, - isVisible: ({ focus }) => focus === "anchor", - }, - { - label: "Focus Object", - name: "focusObject", - fieldType: "select", - isTransformation: true, - transformationGroup: "focus", - fieldProps: { - isCreatable: false, - }, - helpText: - "Select an object to focus on. The crop will center on this object.", - isVisible: ({ focus }) => focus === "object", - }, - { - label: "Zoom", - name: "zoom", - fieldType: "zoom", - isTransformation: true, - transformationGroup: "focus", - fieldProps: { - defaultValue: 100, - }, - helpText: - "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", - isVisible: ({ focus }) => focus === "object" || focus === "face", - }, - ], - }, - { - key: "resize-forced_crop", - name: "Forced Crop", - // Forced crop squeezes the entire image into the requested width and height, - // ignoring the original aspect ratio. The image is not cropped; instead it - // is stretched or squashed to exactly fit the provided dimensions. - description: - "Resize an image to exactly the specified width and height, distorting the aspect ratio if necessary. The entire original image is preserved without cropping.", - docsLink: - "https://imagekit.io/docs/image-resize-and-crop#forced-crop-strategy---c-force", - defaultTransformation: { crop: "force" }, - schema: z - .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), - focus: z.string().optional(), - focusAnchor: z.string().optional(), - focusObject: z.string().optional(), - zoom: z.coerce.number().optional(), - }) - .refine( - (val) => { - if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) - ) { - return true - } - return false - }, - { - message: "At least one value is required", - path: [], - }, - ), - transformations: [ - { - label: "Width", - name: "width", - fieldType: "input", - isTransformation: true, - transformationKey: "width", - helpText: - "Specify the exact width of the output. The image will be squashed or stretched to fit this width if both width and height are provided. Use a decimal, integer, or expression.", - examples: ["0.5", "300", "iw_div_2"], - }, - { - label: "Height", - name: "height", - fieldType: "input", - isTransformation: true, - transformationKey: "height", - helpText: - "Specify the exact height of the output. The image will be squashed or stretched to fit this height if both width and height are provided. Use a decimal, integer, or expression.", - examples: ["0.5", "300", "ih_div_2"], - }, - { - label: "Focus", - name: "focus", - fieldType: "select", - isTransformation: true, - transformationGroup: "focus", - fieldProps: { - options: [ - { label: "Auto", value: "auto" }, - { label: "Anchor", value: "anchor" }, - { label: "Face", value: "face" }, - { label: "Object", value: "object" }, - ], - }, - }, - { - label: "Focus Anchor", - name: "focusAnchor", - fieldType: "anchor", - isTransformation: true, - transformationGroup: "focus", - fieldProps: { - options: [ - { label: "Center", value: "center" }, - { label: "Top", value: "top" }, - { label: "Bottom", value: "bottom" }, - { label: "Left", value: "left" }, - { label: "Right", value: "right" }, - { label: "Top Left", value: "top_left" }, - { label: "Top Right", value: "top_right" }, - { label: "Bottom Left", value: "bottom_left" }, - { label: "Bottom Right", value: "bottom_right" }, - ], - }, - isVisible: ({ focus }) => focus === "anchor", - }, - { - label: "Focus Object", - name: "focusObject", - fieldType: "select", - isTransformation: true, - transformationGroup: "focus", - fieldProps: { - isCreatable: false, - }, - helpText: - "Select an object to focus on. The crop will center on this object.", - isVisible: ({ focus }) => focus === "object", - }, - { - label: "Zoom", - name: "zoom", - fieldType: "zoom", - isTransformation: true, - transformationGroup: "focus", - fieldProps: { - defaultValue: 100, - }, - helpText: - "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", - isVisible: ({ focus }) => focus === "object", - }, - ], - }, - { - key: "resize-max_size", - name: "Max Size", - // Max size cropping preserves the aspect ratio and scales the image so - // that at least one dimension matches the requested size, while the other - // dimension is equal to or smaller than the requested dimension. It - // guarantees the output image will never be larger than the requested box. - description: - "Resize the image so that it fits within the specified width and/or height. The aspect ratio is preserved and at least one dimension will match the request while the other may be smaller.", - docsLink: - "https://imagekit.io/docs/image-resize-and-crop#max-size-cropping-strategy---c-at_max", - defaultTransformation: { crop: "at_max" }, - schema: z - .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), - }) - .refine( - (val) => { - if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) - ) { - return true - } - return false - }, - { - message: "At least one value is required", - path: [], - }, - ), - transformations: [ - { - label: "Width", - name: "width", - fieldType: "input", - isTransformation: true, - transformationKey: "width", - helpText: - "Specify the maximum width. The image will scale down to fit within this width while preserving aspect ratio. Use a percentage, pixels, or an expression.", - examples: ["0.5", "300", "iw_div_2"], - }, - { - label: "Height", - name: "height", - fieldType: "input", - isTransformation: true, - transformationKey: "height", - helpText: - "Specify the maximum height. The image will scale down to fit within this height while preserving aspect ratio. Use a percentage, pixels, or an expression.", - examples: ["0.5", "300", "ih_div_2"], - }, - ], - }, - { - key: "resize-max_size_enlarge", - name: "Max Size (Enlarge)", - // The max size (enlarge) strategy behaves like max size cropping but - // allows the image to be upscaled if the requested dimensions are larger - // than the original. Aspect ratio is preserved and at least one - // dimension will match the requested size. - description: - "Resize the image so that it fits within the specified dimensions, preserving aspect ratio. If the target size is larger than the original image, the image will be upscaled.", - docsLink: - "https://imagekit.io/docs/image-resize-and-crop#max-size-enlarge-cropping-strategy---c-at_max_enlarge", - defaultTransformation: { crop: "at_max_enlarge" }, - schema: z - .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), - }) - .refine( - (val) => { - if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) - ) { - return true - } - return false - }, - { - message: "At least one value is required", - path: [], - }, - ), - transformations: [ - { - label: "Width", - name: "width", - fieldType: "input", - isTransformation: true, - transformationKey: "width", - helpText: - "Specify the maximum width. The image will scale up or down to fit this width while preserving aspect ratio. Use a percentage, pixels, or an expression.", - examples: ["0.5", "300", "iw_div_2"], - }, - { - label: "Height", - name: "height", - fieldType: "input", - isTransformation: true, - transformationKey: "height", - helpText: - "Specify the maximum height. The image will scale up or down to fit this height while preserving aspect ratio. Use a percentage, pixels, or an expression.", - examples: ["0.5", "300", "ih_div_2"], - }, - ], - }, - { - key: "resize-at_least", - name: "Min Size", - // The min-size crop strategy resizes the image so that at least one - // dimension is equal to or greater than the requested dimension. The - // aspect ratio is preserved and the other dimension may exceed the - // requested value. - description: - "Resize the image so that it meets or exceeds the specified width and/or height. The aspect ratio is preserved and at least one dimension will match or exceed the request.", - docsLink: - "https://imagekit.io/docs/image-resize-and-crop#min-size-cropping-strategy---c-at_least", - defaultTransformation: { crop: "at_least" }, - schema: z - .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), - }) - .refine( - (val) => { - if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) - ) { - return true - } - return false - }, - { - message: "At least one value is required", - path: [], - }, - ), - transformations: [ - { - label: "Width", - name: "width", - fieldType: "input", - isTransformation: true, - transformationKey: "width", - helpText: - "Specify the minimum width. The image will scale so that the width is at least this value while preserving aspect ratio. Use a percentage, pixels, or an expression.", - examples: ["0.5", "300", "iw_div_2"], - }, - { - label: "Height", - name: "height", - fieldType: "input", - isTransformation: true, - transformationKey: "height", - helpText: - "Specify the minimum height. The image will scale so that the height is at least this value while preserving aspect ratio. Use a percentage, pixels, or an expression.", - examples: ["0.5", "300", "ih_div_2"], - }, - ], - }, - ], - }, - { - key: "crop_extract", - name: "Crop & Extract", - items: [ - { - key: "crop_extract-extract", - name: "Extract", - // Extract crop cuts out a region of the specified width and height from - // the original image without scaling. The crop can be centred by default - // or positioned using focus (anchor or object). If the specified crop - // area is larger than the original bounds, the operation will fail. - description: - "Extract a rectangular region from the original image without resizing. Specify width and height to define the area and optionally choose a focus point or object to position the crop.", - docsLink: - "https://imagekit.io/docs/image-resize-and-crop#extract-crop-strategy---cm-extract", - defaultTransformation: { cropMode: "extract" }, - schema: z - .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), - focus: z.string().optional(), - focusAnchor: z.string().optional(), - focusObject: z.string().optional(), - coordinateMethod: z.string().optional(), - x: z.string().optional(), - y: z.string().optional(), - xc: z.string().optional(), - yc: z.string().optional(), - zoom: z.coerce.number().optional(), - }) - .refine( - (val) => { - if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) - ) { - return true - } - return false - }, - { - message: "At least one value is required", - path: [], - }, - ) - .superRefine((val, ctx) => { - if (val.focus === "object" && !val.focusObject) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Focus object is required", - path: ["focusObject"], - }) - } - if (val.focus === "anchor" && !val.focusAnchor) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Focus anchor is required", - path: ["focusAnchor"], - }) - } - if (val.focus === "coordinates") { - if (val.coordinateMethod === "topleft") { - if (!val.x && !val.y) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "At least one coordinate (x or y) is required", - path: [], - }) - } - } else if (val.coordinateMethod === "center") { - if (!val.xc && !val.yc) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "At least one coordinate (xc or yc) is required", - path: [], - }) - } - } - } - }), - transformations: [ - { - label: "Width", - name: "width", - fieldType: "input", - isTransformation: true, - transformationKey: "width", - helpText: - "Specify the width of the region to extract. Use a decimal, an integer, or an expression. The image is not resized; only the specified region is returned.", - examples: ["0.5", "300", "iw_div_2"], - }, - { - label: "Height", - name: "height", - fieldType: "input", - isTransformation: true, - transformationKey: "height", - helpText: - "Specify the height of the region to extract. Use a decimal, an integer, or an expression. The image is not resized; only the specified region is returned.", - examples: ["0.5", "300", "ih_div_2"], - }, - { - label: "Focus", - name: "focus", - fieldType: "select", - isTransformation: true, - transformationGroup: "focus", - fieldProps: { - options: [ - { label: "Auto", value: "auto" }, - { label: "Anchor", value: "anchor" }, - { label: "Face", value: "face" }, - { label: "Object", value: "object" }, - { label: "Custom", value: "custom" }, - { label: "Coordinates", value: "coordinates" }, - ], - }, - helpText: - "Choose how to position the extracted region. Custom uses a saved focus area from Media Library.", - }, - { - label: "Focus Anchor", - name: "focusAnchor", - fieldType: "anchor", - isTransformation: true, - transformationGroup: "focus", - fieldProps: { - options: [ - { label: "Center", value: "center" }, - { label: "Top", value: "top" }, - { label: "Bottom", value: "bottom" }, - { label: "Left", value: "left" }, - { label: "Right", value: "right" }, - { label: "Top Left", value: "top_left" }, - { label: "Top Right", value: "top_right" }, - { label: "Bottom Left", value: "bottom_left" }, - { label: "Bottom Right", value: "bottom_right" }, - ], - }, - isVisible: ({ focus }) => focus === "anchor", - }, - { - label: "Focus Object", - name: "focusObject", - fieldType: "select", - isTransformation: true, - transformationGroup: "focus", - fieldProps: { - isCreatable: false, - }, - helpText: - "Select an object to focus on during extraction. The crop will center on this object.", - isVisible: ({ focus }) => focus === "object", - }, - { - label: "Coordinate Method", - name: "coordinateMethod", - fieldType: "radio-card", - isTransformation: false, - transformationGroup: "focus", - fieldProps: { - options: [ - { label: "Top-left (x, y)", value: "topleft" }, - { label: "Center (xc, yc)", value: "center" }, - ], - defaultValue: "topleft", - }, - helpText: - "Choose whether coordinates are relative to the top-left corner or the center of the image.", - isVisible: ({ focus }) => focus === "coordinates", - }, - { - label: "X (Horizontal)", - name: "x", - fieldType: "input", - isTransformation: true, - transformationGroup: "focus", - helpText: - "Horizontal position from the top-left. Use an integer or expression.", - examples: ["100", "iw_mul_0.4"], - isVisible: ({ focus, coordinateMethod }) => - focus === "coordinates" && coordinateMethod === "topleft", - }, - { - label: "Y (Vertical)", - name: "y", - fieldType: "input", - isTransformation: true, - transformationGroup: "focus", - helpText: - "Vertical position from the top-left. Use an integer or expression.", - examples: ["100", "ih_mul_0.4"], - isVisible: ({ focus, coordinateMethod }) => - focus === "coordinates" && coordinateMethod === "topleft", - }, - { - label: "XC (Horizontal Center)", - name: "xc", - fieldType: "input", - isTransformation: true, - transformationGroup: "focus", - helpText: - "Horizontal center position. Use an integer or expression.", - examples: ["200", "iw_mul_0.5"], - isVisible: ({ focus, coordinateMethod }) => - focus === "coordinates" && coordinateMethod === "center", - }, - { - label: "YC (Vertical Center)", - name: "yc", - fieldType: "input", - isTransformation: true, - transformationGroup: "focus", - helpText: "Vertical center position. Use an integer or expression.", - examples: ["200", "ih_mul_0.5"], - isVisible: ({ focus, coordinateMethod }) => - focus === "coordinates" && coordinateMethod === "center", - }, - { - label: "Zoom", - name: "zoom", - fieldType: "zoom", - isTransformation: true, - transformationGroup: "focus", - fieldProps: { - defaultValue: 100, - }, - helpText: - "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", - isVisible: ({ focus }) => focus === "object" || focus === "face", - }, - ], - }, - { - key: "crop_extract-pad_extract", - name: "Pad Extract", - // Pad extract crops a region from the image like extract, but if the - // cropped region is smaller than the requested dimensions it pads the - // remaining area. This allows you to centre or position a subject and - // fill unused space with a solid color or generative fill. - description: - "Extract a region from the image and pad it to match the requested dimensions. Use a solid color or an AI-generated fill for the padding and optionally set a focus point.", - docsLink: - "https://imagekit.io/docs/image-resize-and-crop#pad-extract-crop-strategy---cm-pad_extract", - defaultTransformation: { cropMode: "pad_extract" }, - schema: z - .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), - ...background.getPropsFor("pad_extract").schema, - }) - .refine( - (val) => { - if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) - ) { - return true - } - return false - }, - { - message: "At least one value is required", - path: [], - }, - ), - transformations: [ - { - label: "Width", - name: "width", - fieldType: "input", - isTransformation: true, - transformationKey: "width", - helpText: - "Specify the width of the extracted region. If the region is smaller than this width, padding will be added. Use a percentage, pixels, or an expression.", - examples: ["0.5", "300", "iw_div_2"], - }, - { - label: "Height", - name: "height", - fieldType: "input", - isTransformation: true, - transformationKey: "height", - helpText: - "Specify the height of the extracted region. If the region is smaller than this height, padding will be added. Use a percentage, pixels, or an expression.", - examples: ["0.5", "300", "ih_div_2"], - }, - ...background.getPropsFor("pad_extract").transformations({ transformationGroup: "background" }), - ], - }, - ], - }, + // if ( + // val.backgroundType === "generative_fill" && + // (!val.width || !val.height) + // ) { + // if (!val.width) { + // ctx.addIssue({ + // code: z.ZodIssueCode.custom, + // message: "Required for generative fill background", + // path: ["width"], + // }) + // } + // if (!val.height) { + // ctx.addIssue({ + // code: z.ZodIssueCode.custom, + // message: "Required for generative fill background", + // path: ["height"], + // }) + // } + // } + // }), + // transformations: [ + // { + // label: "Width", + // name: "width", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "width", + // helpText: + // "Specify the output width. Use a decimal between 0 and 1, an integer greater than 1 for pixel units, or an expression.", + // examples: ["0.5 (50%)", "300", "iw_div_2"], + // }, + // { + // label: "Height", + // name: "height", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "height", + // helpText: + // "Specify the output height. Use a decimal between 0 and 1, an integer greater than 1, or an expression.", + // examples: ["0.5", "300", "ih_div_2"], + // }, + // ...background.getPropsFor("pad_resize").transformations({ transformationGroup: "background" }), + // { + // label: "Focus", + // name: "focus", + // fieldType: "anchor", + // isTransformation: true, + // transformationKey: "focus", + // fieldProps: { + // positions: ["center", "top", "bottom", "left", "right"], + // }, + // }, + // ], + // }, + // { + // key: "resize-maintain_aspect_ratio", + // name: "Maintain Aspect Ratio", + // // This strategy resizes and crops the image to fit the requested box while + // // preserving the original aspect ratio. It may crop parts of the image + // // (default centre crop) to achieve the final size. You can specify only + // // one dimension (width or height) or an aspect ratio. Focus settings can + // // be used to keep important content in view. + // description: + // "Resize an image to the requested dimensions while preserving its aspect ratio. The image is scaled and cropped as necessary; specify width, height or an aspect ratio, and optionally set a focus area.", + // docsLink: + // "https://imagekit.io/docs/image-resize-and-crop#maintain-ratio-crop-strategy---c-maintain_ratio", + // defaultTransformation: { crop: "maintain_ratio" }, + // schema: z + // .object({ + // width: widthValidator.optional(), + // height: heightValidator.optional(), + // aspectRatio: aspectRatioValidator.optional(), + // focus: z.string().optional(), + // focusAnchor: z.string().optional(), + // focusObject: z.string().optional(), + // zoom: z.coerce.number().optional(), + // }) + // .refine( + // (val) => { + // if ( + // Object.values(val).some( + // (v) => v !== undefined && v !== null && v !== "", + // ) + // ) { + // return true + // } + // return false + // }, + // { + // message: "At least one value is required", + // path: [], + // }, + // ) + // .superRefine((val, ctx) => { + // if (val.width && val.height) { + // ctx.addIssue({ + // code: z.ZodIssueCode.custom, + // message: "Width and height cannot be used together", + // path: [], + // }) + // } + // if (val.width && val.height && val.aspectRatio) { + // ctx.addIssue({ + // code: z.ZodIssueCode.custom, + // message: + // "Width, height and aspect ratio cannot be used together", + // path: [], + // }) + // } + // if (val.focus === "object" && !val.focusObject) { + // ctx.addIssue({ + // code: z.ZodIssueCode.custom, + // message: "Focus object is required", + // path: ["focusObject"], + // }) + // } + // if (val.focus === "anchor" && !val.focusAnchor) { + // ctx.addIssue({ + // code: z.ZodIssueCode.custom, + // message: "Focus anchor is required", + // path: ["focusAnchor"], + // }) + // } + // }), + // transformations: [ + // { + // label: "Width", + // name: "width", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "width", + // helpText: + // "Specify the target width. Width and height cannot be used together. Use a decimal, an integer, or an expression.", + // examples: ["0.5", "300", "iw_div_2"], + // }, + // { + // label: "Height", + // name: "height", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "height", + // helpText: + // "Specify the target height. Height and width cannot be used together. Use a decimal, an integer, or an expression.", + // examples: ["0.5", "300", "ih_div_2"], + // }, + // { + // label: "Aspect Ratio", + // name: "aspectRatio", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "aspectRatio", + // helpText: + // "Enter an aspect ratio as 'width-height' or an expression. Cannot be used alongside both width and height.", + // examples: ["16-9", "4-3", "iar_mul_0.75"], + // }, + // { + // label: "Focus", + // name: "focus", + // fieldType: "select", + // isTransformation: true, + // transformationGroup: "focus", + // fieldProps: { + // options: [ + // { label: "Auto", value: "auto" }, + // { label: "Anchor", value: "anchor" }, + // { label: "Face", value: "face" }, + // { label: "Object", value: "object" }, + // ], + // }, + // }, + // { + // label: "Focus Anchor", + // name: "focusAnchor", + // fieldType: "anchor", + // isTransformation: true, + // transformationGroup: "focus", + // fieldProps: { + // options: [ + // { label: "Center", value: "center" }, + // { label: "Top", value: "top" }, + // { label: "Bottom", value: "bottom" }, + // { label: "Left", value: "left" }, + // { label: "Right", value: "right" }, + // { label: "Top Left", value: "top_left" }, + // { label: "Top Right", value: "top_right" }, + // { label: "Bottom Left", value: "bottom_left" }, + // { label: "Bottom Right", value: "bottom_right" }, + // ], + // }, + // isVisible: ({ focus }) => focus === "anchor", + // }, + // { + // label: "Focus Object", + // name: "focusObject", + // fieldType: "select", + // isTransformation: true, + // transformationGroup: "focus", + // fieldProps: { + // isCreatable: false, + // }, + // helpText: + // "Select an object to focus on. The crop will center on this object.", + // isVisible: ({ focus }) => focus === "object", + // }, + // { + // label: "Zoom", + // name: "zoom", + // fieldType: "zoom", + // isTransformation: true, + // transformationGroup: "focus", + // fieldProps: { + // defaultValue: 100, + // }, + // helpText: + // "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", + // isVisible: ({ focus }) => focus === "object" || focus === "face", + // }, + // ], + // }, + // { + // key: "resize-forced_crop", + // name: "Forced Crop", + // // Forced crop squeezes the entire image into the requested width and height, + // // ignoring the original aspect ratio. The image is not cropped; instead it + // // is stretched or squashed to exactly fit the provided dimensions. + // description: + // "Resize an image to exactly the specified width and height, distorting the aspect ratio if necessary. The entire original image is preserved without cropping.", + // docsLink: + // "https://imagekit.io/docs/image-resize-and-crop#forced-crop-strategy---c-force", + // defaultTransformation: { crop: "force" }, + // schema: z + // .object({ + // width: widthValidator.optional(), + // height: heightValidator.optional(), + // focus: z.string().optional(), + // focusAnchor: z.string().optional(), + // focusObject: z.string().optional(), + // zoom: z.coerce.number().optional(), + // }) + // .refine( + // (val) => { + // if ( + // Object.values(val).some( + // (v) => v !== undefined && v !== null && v !== "", + // ) + // ) { + // return true + // } + // return false + // }, + // { + // message: "At least one value is required", + // path: [], + // }, + // ), + // transformations: [ + // { + // label: "Width", + // name: "width", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "width", + // helpText: + // "Specify the exact width of the output. The image will be squashed or stretched to fit this width if both width and height are provided. Use a decimal, integer, or expression.", + // examples: ["0.5", "300", "iw_div_2"], + // }, + // { + // label: "Height", + // name: "height", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "height", + // helpText: + // "Specify the exact height of the output. The image will be squashed or stretched to fit this height if both width and height are provided. Use a decimal, integer, or expression.", + // examples: ["0.5", "300", "ih_div_2"], + // }, + // { + // label: "Focus", + // name: "focus", + // fieldType: "select", + // isTransformation: true, + // transformationGroup: "focus", + // fieldProps: { + // options: [ + // { label: "Auto", value: "auto" }, + // { label: "Anchor", value: "anchor" }, + // { label: "Face", value: "face" }, + // { label: "Object", value: "object" }, + // ], + // }, + // }, + // { + // label: "Focus Anchor", + // name: "focusAnchor", + // fieldType: "anchor", + // isTransformation: true, + // transformationGroup: "focus", + // fieldProps: { + // options: [ + // { label: "Center", value: "center" }, + // { label: "Top", value: "top" }, + // { label: "Bottom", value: "bottom" }, + // { label: "Left", value: "left" }, + // { label: "Right", value: "right" }, + // { label: "Top Left", value: "top_left" }, + // { label: "Top Right", value: "top_right" }, + // { label: "Bottom Left", value: "bottom_left" }, + // { label: "Bottom Right", value: "bottom_right" }, + // ], + // }, + // isVisible: ({ focus }) => focus === "anchor", + // }, + // { + // label: "Focus Object", + // name: "focusObject", + // fieldType: "select", + // isTransformation: true, + // transformationGroup: "focus", + // fieldProps: { + // isCreatable: false, + // }, + // helpText: + // "Select an object to focus on. The crop will center on this object.", + // isVisible: ({ focus }) => focus === "object", + // }, + // { + // label: "Zoom", + // name: "zoom", + // fieldType: "zoom", + // isTransformation: true, + // transformationGroup: "focus", + // fieldProps: { + // defaultValue: 100, + // }, + // helpText: + // "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", + // isVisible: ({ focus }) => focus === "object", + // }, + // ], + // }, + // { + // key: "resize-max_size", + // name: "Max Size", + // // Max size cropping preserves the aspect ratio and scales the image so + // // that at least one dimension matches the requested size, while the other + // // dimension is equal to or smaller than the requested dimension. It + // // guarantees the output image will never be larger than the requested box. + // description: + // "Resize the image so that it fits within the specified width and/or height. The aspect ratio is preserved and at least one dimension will match the request while the other may be smaller.", + // docsLink: + // "https://imagekit.io/docs/image-resize-and-crop#max-size-cropping-strategy---c-at_max", + // defaultTransformation: { crop: "at_max" }, + // schema: z + // .object({ + // width: widthValidator.optional(), + // height: heightValidator.optional(), + // }) + // .refine( + // (val) => { + // if ( + // Object.values(val).some( + // (v) => v !== undefined && v !== null && v !== "", + // ) + // ) { + // return true + // } + // return false + // }, + // { + // message: "At least one value is required", + // path: [], + // }, + // ), + // transformations: [ + // { + // label: "Width", + // name: "width", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "width", + // helpText: + // "Specify the maximum width. The image will scale down to fit within this width while preserving aspect ratio. Use a percentage, pixels, or an expression.", + // examples: ["0.5", "300", "iw_div_2"], + // }, + // { + // label: "Height", + // name: "height", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "height", + // helpText: + // "Specify the maximum height. The image will scale down to fit within this height while preserving aspect ratio. Use a percentage, pixels, or an expression.", + // examples: ["0.5", "300", "ih_div_2"], + // }, + // ], + // }, + // { + // key: "resize-max_size_enlarge", + // name: "Max Size (Enlarge)", + // // The max size (enlarge) strategy behaves like max size cropping but + // // allows the image to be upscaled if the requested dimensions are larger + // // than the original. Aspect ratio is preserved and at least one + // // dimension will match the requested size. + // description: + // "Resize the image so that it fits within the specified dimensions, preserving aspect ratio. If the target size is larger than the original image, the image will be upscaled.", + // docsLink: + // "https://imagekit.io/docs/image-resize-and-crop#max-size-enlarge-cropping-strategy---c-at_max_enlarge", + // defaultTransformation: { crop: "at_max_enlarge" }, + // schema: z + // .object({ + // width: widthValidator.optional(), + // height: heightValidator.optional(), + // }) + // .refine( + // (val) => { + // if ( + // Object.values(val).some( + // (v) => v !== undefined && v !== null && v !== "", + // ) + // ) { + // return true + // } + // return false + // }, + // { + // message: "At least one value is required", + // path: [], + // }, + // ), + // transformations: [ + // { + // label: "Width", + // name: "width", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "width", + // helpText: + // "Specify the maximum width. The image will scale up or down to fit this width while preserving aspect ratio. Use a percentage, pixels, or an expression.", + // examples: ["0.5", "300", "iw_div_2"], + // }, + // { + // label: "Height", + // name: "height", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "height", + // helpText: + // "Specify the maximum height. The image will scale up or down to fit this height while preserving aspect ratio. Use a percentage, pixels, or an expression.", + // examples: ["0.5", "300", "ih_div_2"], + // }, + // ], + // }, + // { + // key: "resize-at_least", + // name: "Min Size", + // // The min-size crop strategy resizes the image so that at least one + // // dimension is equal to or greater than the requested dimension. The + // // aspect ratio is preserved and the other dimension may exceed the + // // requested value. + // description: + // "Resize the image so that it meets or exceeds the specified width and/or height. The aspect ratio is preserved and at least one dimension will match or exceed the request.", + // docsLink: + // "https://imagekit.io/docs/image-resize-and-crop#min-size-cropping-strategy---c-at_least", + // defaultTransformation: { crop: "at_least" }, + // schema: z + // .object({ + // width: widthValidator.optional(), + // height: heightValidator.optional(), + // }) + // .refine( + // (val) => { + // if ( + // Object.values(val).some( + // (v) => v !== undefined && v !== null && v !== "", + // ) + // ) { + // return true + // } + // return false + // }, + // { + // message: "At least one value is required", + // path: [], + // }, + // ), + // transformations: [ + // { + // label: "Width", + // name: "width", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "width", + // helpText: + // "Specify the minimum width. The image will scale so that the width is at least this value while preserving aspect ratio. Use a percentage, pixels, or an expression.", + // examples: ["0.5", "300", "iw_div_2"], + // }, + // { + // label: "Height", + // name: "height", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "height", + // helpText: + // "Specify the minimum height. The image will scale so that the height is at least this value while preserving aspect ratio. Use a percentage, pixels, or an expression.", + // examples: ["0.5", "300", "ih_div_2"], + // }, + // ], + // }, + // ], + // }, + // { + // key: "crop_extract", + // name: "Crop & Extract", + // items: [ + // { + // key: "crop_extract-extract", + // name: "Extract", + // // Extract crop cuts out a region of the specified width and height from + // // the original image without scaling. The crop can be centred by default + // // or positioned using focus (anchor or object). If the specified crop + // // area is larger than the original bounds, the operation will fail. + // description: + // "Extract a rectangular region from the original image without resizing. Specify width and height to define the area and optionally choose a focus point or object to position the crop.", + // docsLink: + // "https://imagekit.io/docs/image-resize-and-crop#extract-crop-strategy---cm-extract", + // defaultTransformation: { cropMode: "extract" }, + // schema: z + // .object({ + // width: widthValidator.optional(), + // height: heightValidator.optional(), + // focus: z.string().optional(), + // focusAnchor: z.string().optional(), + // focusObject: z.string().optional(), + // coordinateMethod: z.string().optional(), + // x: z.string().optional(), + // y: z.string().optional(), + // xc: z.string().optional(), + // yc: z.string().optional(), + // zoom: z.coerce.number().optional(), + // }) + // .refine( + // (val) => { + // if ( + // Object.values(val).some( + // (v) => v !== undefined && v !== null && v !== "", + // ) + // ) { + // return true + // } + // return false + // }, + // { + // message: "At least one value is required", + // path: [], + // }, + // ) + // .superRefine((val, ctx) => { + // if (val.focus === "object" && !val.focusObject) { + // ctx.addIssue({ + // code: z.ZodIssueCode.custom, + // message: "Focus object is required", + // path: ["focusObject"], + // }) + // } + // if (val.focus === "anchor" && !val.focusAnchor) { + // ctx.addIssue({ + // code: z.ZodIssueCode.custom, + // message: "Focus anchor is required", + // path: ["focusAnchor"], + // }) + // } + // if (val.focus === "coordinates") { + // if (val.coordinateMethod === "topleft") { + // if (!val.x && !val.y) { + // ctx.addIssue({ + // code: z.ZodIssueCode.custom, + // message: "At least one coordinate (x or y) is required", + // path: [], + // }) + // } + // } else if (val.coordinateMethod === "center") { + // if (!val.xc && !val.yc) { + // ctx.addIssue({ + // code: z.ZodIssueCode.custom, + // message: "At least one coordinate (xc or yc) is required", + // path: [], + // }) + // } + // } + // } + // }), + // transformations: [ + // { + // label: "Width", + // name: "width", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "width", + // helpText: + // "Specify the width of the region to extract. Use a decimal, an integer, or an expression. The image is not resized; only the specified region is returned.", + // examples: ["0.5", "300", "iw_div_2"], + // }, + // { + // label: "Height", + // name: "height", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "height", + // helpText: + // "Specify the height of the region to extract. Use a decimal, an integer, or an expression. The image is not resized; only the specified region is returned.", + // examples: ["0.5", "300", "ih_div_2"], + // }, + // { + // label: "Focus", + // name: "focus", + // fieldType: "select", + // isTransformation: true, + // transformationGroup: "focus", + // fieldProps: { + // options: [ + // { label: "Auto", value: "auto" }, + // { label: "Anchor", value: "anchor" }, + // { label: "Face", value: "face" }, + // { label: "Object", value: "object" }, + // { label: "Custom", value: "custom" }, + // { label: "Coordinates", value: "coordinates" }, + // ], + // }, + // helpText: + // "Choose how to position the extracted region. Custom uses a saved focus area from Media Library.", + // }, + // { + // label: "Focus Anchor", + // name: "focusAnchor", + // fieldType: "anchor", + // isTransformation: true, + // transformationGroup: "focus", + // fieldProps: { + // options: [ + // { label: "Center", value: "center" }, + // { label: "Top", value: "top" }, + // { label: "Bottom", value: "bottom" }, + // { label: "Left", value: "left" }, + // { label: "Right", value: "right" }, + // { label: "Top Left", value: "top_left" }, + // { label: "Top Right", value: "top_right" }, + // { label: "Bottom Left", value: "bottom_left" }, + // { label: "Bottom Right", value: "bottom_right" }, + // ], + // }, + // isVisible: ({ focus }) => focus === "anchor", + // }, + // { + // label: "Focus Object", + // name: "focusObject", + // fieldType: "select", + // isTransformation: true, + // transformationGroup: "focus", + // fieldProps: { + // isCreatable: false, + // }, + // helpText: + // "Select an object to focus on during extraction. The crop will center on this object.", + // isVisible: ({ focus }) => focus === "object", + // }, + // { + // label: "Coordinate Method", + // name: "coordinateMethod", + // fieldType: "radio-card", + // isTransformation: false, + // transformationGroup: "focus", + // fieldProps: { + // options: [ + // { label: "Top-left (x, y)", value: "topleft" }, + // { label: "Center (xc, yc)", value: "center" }, + // ], + // defaultValue: "topleft", + // }, + // helpText: + // "Choose whether coordinates are relative to the top-left corner or the center of the image.", + // isVisible: ({ focus }) => focus === "coordinates", + // }, + // { + // label: "X (Horizontal)", + // name: "x", + // fieldType: "input", + // isTransformation: true, + // transformationGroup: "focus", + // helpText: + // "Horizontal position from the top-left. Use an integer or expression.", + // examples: ["100", "iw_mul_0.4"], + // isVisible: ({ focus, coordinateMethod }) => + // focus === "coordinates" && coordinateMethod === "topleft", + // }, + // { + // label: "Y (Vertical)", + // name: "y", + // fieldType: "input", + // isTransformation: true, + // transformationGroup: "focus", + // helpText: + // "Vertical position from the top-left. Use an integer or expression.", + // examples: ["100", "ih_mul_0.4"], + // isVisible: ({ focus, coordinateMethod }) => + // focus === "coordinates" && coordinateMethod === "topleft", + // }, + // { + // label: "XC (Horizontal Center)", + // name: "xc", + // fieldType: "input", + // isTransformation: true, + // transformationGroup: "focus", + // helpText: + // "Horizontal center position. Use an integer or expression.", + // examples: ["200", "iw_mul_0.5"], + // isVisible: ({ focus, coordinateMethod }) => + // focus === "coordinates" && coordinateMethod === "center", + // }, + // { + // label: "YC (Vertical Center)", + // name: "yc", + // fieldType: "input", + // isTransformation: true, + // transformationGroup: "focus", + // helpText: "Vertical center position. Use an integer or expression.", + // examples: ["200", "ih_mul_0.5"], + // isVisible: ({ focus, coordinateMethod }) => + // focus === "coordinates" && coordinateMethod === "center", + // }, + // { + // label: "Zoom", + // name: "zoom", + // fieldType: "zoom", + // isTransformation: true, + // transformationGroup: "focus", + // fieldProps: { + // defaultValue: 100, + // }, + // helpText: + // "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", + // isVisible: ({ focus }) => focus === "object" || focus === "face", + // }, + // ], + // }, + // { + // key: "crop_extract-pad_extract", + // name: "Pad Extract", + // // Pad extract crops a region from the image like extract, but if the + // // cropped region is smaller than the requested dimensions it pads the + // // remaining area. This allows you to centre or position a subject and + // // fill unused space with a solid color or generative fill. + // description: + // "Extract a region from the image and pad it to match the requested dimensions. Use a solid color or an AI-generated fill for the padding and optionally set a focus point.", + // docsLink: + // "https://imagekit.io/docs/image-resize-and-crop#pad-extract-crop-strategy---cm-pad_extract", + // defaultTransformation: { cropMode: "pad_extract" }, + // schema: z + // .object({ + // width: widthValidator.optional(), + // height: heightValidator.optional(), + // ...background.getPropsFor("pad_extract").schema, + // }) + // .refine( + // (val) => { + // if ( + // Object.values(val).some( + // (v) => v !== undefined && v !== null && v !== "", + // ) + // ) { + // return true + // } + // return false + // }, + // { + // message: "At least one value is required", + // path: [], + // }, + // ), + // transformations: [ + // { + // label: "Width", + // name: "width", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "width", + // helpText: + // "Specify the width of the extracted region. If the region is smaller than this width, padding will be added. Use a percentage, pixels, or an expression.", + // examples: ["0.5", "300", "iw_div_2"], + // }, + // { + // label: "Height", + // name: "height", + // fieldType: "input", + // isTransformation: true, + // transformationKey: "height", + // helpText: + // "Specify the height of the extracted region. If the region is smaller than this height, padding will be added. Use a percentage, pixels, or an expression.", + // examples: ["0.5", "300", "ih_div_2"], + // }, + // ...background.getPropsFor("pad_extract").transformations({ transformationGroup: "background" }), + // ], + // }, + // ], + // }, { key: "adjust", name: "Adjust", diff --git a/packages/imagekit-editor-dev/src/schema/resizeAndCrop.ts b/packages/imagekit-editor-dev/src/schema/resizeAndCrop.ts new file mode 100644 index 0000000..2b108c8 --- /dev/null +++ b/packages/imagekit-editor-dev/src/schema/resizeAndCrop.ts @@ -0,0 +1,559 @@ +import { z } from "zod/v3" +import type { TransformationField, TransformationSchema } from "." +import { background } from "./background" +import { + widthValidator, + heightValidator, + aspectRatioValidator, +} from "./transformation" + +// Help text explaining single dimension vs both dimensions behavior +export const RESIZE_CROP_HELP_TEXT = "If you specify only one dimension (width or height), the other will be adjusted automatically to preserve the aspect ratio and no cropping is applied. When you specify both dimensions, you'd need to choose a cropping strategy to control how the image is resized or cropped." + +// The 8 crop/resize modes available (c-maintain_ratio is default and first) +export const RESIZE_CROP_MODES = [ + { + value: "c-maintain_ratio", + label: "Resize, crop if needed", + paramLabel: "c-maintain_ratio", + }, + { + value: "cm-pad_resize", + label: "Resize, don't crop, add padding if needed", + paramLabel: "cm-pad_resize", + }, + { + value: "cm-extract", + label: "Extract a part of the image", + paramLabel: "cm-extract", + }, + { + value: "cm-pad_extract", + label: "Extract a region and pad to match dimensions", + paramLabel: "cm-pad_extract", + }, + { + value: "c-force", + label: "Resize, don't crop, squeeze if needed", + paramLabel: "c-force", + }, + { + value: "c-at_max", + label: "Resize to contain inside a box, don't crop", + paramLabel: "c-at_max", + }, + { + value: "c-at_max_enlarge", + label: "Resize to contain inside a box, enlarge image if needed, don't crop", + paramLabel: "c-at_max_enlarge", + }, + { + value: "c-at_least", + label: "Resize to be bigger than box, don't crop", + paramLabel: "c-at_least", + }, +] as const + +// Maps mode values to crop/cropMode parameters for URL building +export function getDefaultTransformationFromMode(mode: string): Record { + switch (mode) { + case "cm-pad_resize": + return { cropMode: "pad_resize" as const } + case "cm-extract": + return { cropMode: "extract" as const } + case "cm-pad_extract": + return { cropMode: "pad_extract" as const } + case "c-maintain_ratio": + return { crop: "maintain_ratio" as const } + case "c-force": + return { crop: "force" as const } + case "c-at_max": + return { crop: "at_max" as const } + case "c-at_max_enlarge": + return { crop: "at_max_enlarge" as const } + case "c-at_least": + return { crop: "at_least" as const } + default: + return {} + } +} + +// Schema with top-level validation and mode-specific refinements +export const resizeAndCropSchema = z + .object({ + // Mode dropdown - only visible when both dimensions are set + mode: z.string().optional(), + + // Dimensions - always visible + width: widthValidator.optional(), + height: heightValidator.optional(), + + // Aspect ratio - for maintain_ratio mode + aspectRatio: aspectRatioValidator.optional(), + + // Focus fields - for pad_resize, maintain_ratio, extract, force (auto only) + focus: z.string().optional(), + focusAnchor: z.string().optional(), + focusObject: z.string().optional(), + zoom: z.coerce.number().optional(), + + // Coordinates for extract mode + coordinateMethod: z.string().optional(), + x: z.string().optional(), + y: z.string().optional(), + xc: z.string().optional(), + yc: z.string().optional(), + + // Background fields for pad_resize and pad_extract + ...background.getPropsFor("pad_resize").schema, + }) + .refine( + (val) => { + // Top-level validation 1: At least one of width or height required + return val.width || val.height + }, + { + message: "At least one of width or height is required", + path: [], + } + ) + .refine( + (val) => { + // Top-level validation 2: When both dimensions are set, mode is required + if (val.width && val.height) { + return !!val.mode + } + return true + }, + { + message: "Mode is required when both width and height are specified", + path: ["mode"], + } + ) + .superRefine((val, ctx) => { + // Mode-specific validations (only when mode is set) + if (!val.mode) return + + // cm-pad_resize specific validations + if (val.mode === "cm-pad_resize") { + // If backgroundType is blurred or generative_fill, both dimensions required + const backgroundType = (val as any).backgroundType + if (backgroundType === "blurred" && (!val.width || !val.height)) { + if (!val.width) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Required for blurred background", + path: ["width"], + }) + } + if (!val.height) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Required for blurred background", + path: ["height"], + }) + } + } + if (backgroundType === "generative_fill" && (!val.width || !val.height)) { + if (!val.width) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Required for generative fill background", + path: ["width"], + }) + } + if (!val.height) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Required for generative fill background", + path: ["height"], + }) + } + } + } + + // cm-extract specific validations + if (val.mode === "cm-extract") { + if (val.focus === "object" && !val.focusObject) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Focus object is required", + path: ["focusObject"], + }) + } + if (val.focus === "anchor" && !val.focusAnchor) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Focus anchor is required", + path: ["focusAnchor"], + }) + } + if (val.focus === "coordinates") { + if (val.coordinateMethod === "topleft") { + if (!val.x && !val.y) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one coordinate (x or y) is required", + path: [], + }) + } + } else if (val.coordinateMethod === "center") { + if (!val.xc && !val.yc) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one coordinate (xc or yc) is required", + path: [], + }) + } + } + } + } + + // Aspect ratio validation (applies at top level, not mode-specific) + if (val.aspectRatio && !val.width && !val.height) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Aspect ratio must be used with either width or height", + path: ["aspectRatio"], + }) + } + + // c-maintain_ratio specific validations + if (val.mode === "c-maintain_ratio") { + // Focus validations + if (val.focus === "object" && !val.focusObject) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Focus object is required", + path: ["focusObject"], + }) + } + if (val.focus === "anchor" && !val.focusAnchor) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Focus anchor is required", + path: ["focusAnchor"], + }) + } + } + }) + +// Transformation fields array +export const resizeAndCropTransformations: TransformationField[] = [ + // 1. Width (always visible) + { + label: "Width", + name: "width", + fieldType: "input", + isTransformation: true, + transformationKey: "width", + helpText: + "Specify the output width. Use a decimal between 0 and 1, an integer greater than 1 for pixel units, or an expression.", + examples: ["0.5 (50%)", "300", "iw_div_2"], + }, + + // 2. Height (always visible) + { + label: "Height", + name: "height", + fieldType: "input", + isTransformation: true, + transformationKey: "height", + helpText: + "Specify the output height. Use a decimal between 0 and 1, an integer greater than 1, or an expression.", + examples: ["0.5", "300", "ih_div_2"], + }, + + // 3. Aspect Ratio (visible when at least one dimension is set) + { + label: "Aspect Ratio", + name: "aspectRatio", + fieldType: "input", + isTransformation: true, + transformationKey: "aspectRatio", + helpText: + "Enter an aspect ratio as 'width-height' or an expression. Must be used with either width or height. Note: If both width and height are specified, aspect ratio is ignored.", + examples: ["16-9", "4-3", "iar_mul_0.75"], + isVisible: ({ width, height }) => !!(width || height), + fieldProps: { + disabled: false, // Will be controlled by form logic + }, + }, + + // 4. Mode dropdown (visible only when both dimensions are set) + { + label: "What kind of output?", + name: "mode", + fieldType: "select", + isTransformation: false, + transformationGroup: "resize_crop_mode", + fieldProps: { + options: RESIZE_CROP_MODES.map((mode) => ({ + label: `${mode.label} (${mode.paramLabel})`, + value: mode.value, + })), + defaultValue: "c-maintain_ratio", + }, + helpText: "Choose how the image should be resized or cropped when both dimensions are specified.", + isVisible: ({ width, height }) => !!(width && height), + }, + + // 5. Focus (for pad_resize - anchor only for padding position) + { + label: "Focus", + name: "focus", + fieldType: "anchor", + isTransformation: true, + transformationKey: "focus", + fieldProps: { + positions: ["center", "top", "bottom", "left", "right"], + }, + helpText: "Position the image within the padded area.", + isVisible: ({ width, height, mode }) => + !!(width && height && mode === "cm-pad_resize"), + }, + + // 6. Focus select (for maintain_ratio - 4 options) + { + label: "Focus", + name: "focus", + fieldType: "select", + isTransformation: true, + transformationGroup: "focus", + fieldProps: { + options: [ + { label: "Auto", value: "auto" }, + { label: "Anchor", value: "anchor" }, + { label: "Face", value: "face" }, + { label: "Object", value: "object" }, + ], + }, + helpText: + "Choose how to position the crop. Auto detects the most important part, anchor uses fixed positions, face/object focuses on detected subjects.", + isVisible: ({ width, height, mode }) => + !!(width && height && mode === "c-maintain_ratio"), + }, + + // 7. Focus select (for extract - 6 options including Custom and Coordinates) + { + label: "Focus", + name: "focus", + fieldType: "select", + isTransformation: true, + transformationGroup: "focus", + fieldProps: { + options: [ + { label: "Auto", value: "auto" }, + { label: "Anchor", value: "anchor" }, + { label: "Face", value: "face" }, + { label: "Object", value: "object" }, + { label: "Custom", value: "custom" }, + { label: "Coordinates", value: "coordinates" }, + ], + }, + helpText: + "Choose how to position the extracted region. Custom uses a saved focus area from Media Library.", + isVisible: ({ width, height, mode }) => + !!(width && height && mode === "cm-extract"), + }, + + // 8. Focus select for force (auto only) + { + label: "Focus", + name: "focus", + fieldType: "select", + isTransformation: true, + transformationGroup: "focus", + fieldProps: { + options: [ + { label: "Auto", value: "auto" }, + ], + }, + helpText: + "Automatically detect the most important part of the image.", + isVisible: ({ width, height, mode }) => + !!(width && height && mode === "c-force"), + }, + + // 9. Focus Anchor (for extract and maintain_ratio) + { + label: "Focus Anchor", + name: "focusAnchor", + fieldType: "anchor", + isTransformation: true, + transformationGroup: "focus", + fieldProps: { + options: [ + { label: "Center", value: "center" }, + { label: "Top", value: "top" }, + { label: "Bottom", value: "bottom" }, + { label: "Left", value: "left" }, + { label: "Right", value: "right" }, + { label: "Top Left", value: "top_left" }, + { label: "Top Right", value: "top_right" }, + { label: "Bottom Left", value: "bottom_left" }, + { label: "Bottom Right", value: "bottom_right" }, + ], + }, + isVisible: ({ width, height, mode, focus }) => + !!(width && height && (mode === "cm-extract" || mode === "c-maintain_ratio") && focus === "anchor"), + }, + + // 10. Focus Object (for extract and maintain_ratio) + { + label: "Focus Object", + name: "focusObject", + fieldType: "select", + isTransformation: true, + transformationGroup: "focus", + fieldProps: { + isCreatable: false, + }, + helpText: + "Select an object to focus on. The crop will center on this object.", + isVisible: ({ width, height, mode, focus }) => + !!(width && height && (mode === "cm-extract" || mode === "c-maintain_ratio") && focus === "object"), + }, + + // 11. Zoom (for face/object focus in extract and maintain_ratio) + { + label: "Zoom", + name: "zoom", + fieldType: "zoom", + isTransformation: true, + transformationGroup: "focus", + fieldProps: { + defaultValue: 100, + }, + helpText: + "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", + isVisible: ({ width, height, mode, focus }) => + !!(width && height && (mode === "cm-extract" || mode === "c-maintain_ratio") && (focus === "object" || focus === "face")), + }, + + // 12. Coordinate Method (for extract with coordinates) + { + label: "Coordinate Method", + name: "coordinateMethod", + fieldType: "radio-card", + isTransformation: false, + transformationGroup: "focus", + fieldProps: { + options: [ + { label: "Top-left (x, y)", value: "topleft" }, + { label: "Center (xc, yc)", value: "center" }, + ], + defaultValue: "topleft", + }, + helpText: + "Choose whether coordinates are relative to the top-left corner or the center of the image.", + isVisible: ({ width, height, mode, focus }) => + !!(width && height && mode === "cm-extract" && focus === "coordinates"), + }, + + // 13-16. Coordinate fields (x, y, xc, yc for extract) + { + label: "X (Horizontal)", + name: "x", + fieldType: "input", + isTransformation: true, + transformationGroup: "focus", + helpText: + "Horizontal position from the top-left. Use an integer or expression.", + examples: ["100", "iw_mul_0.4"], + isVisible: ({ width, height, mode, focus, coordinateMethod }) => + !!(width && height && mode === "cm-extract" && focus === "coordinates" && coordinateMethod === "topleft"), + }, + { + label: "Y (Vertical)", + name: "y", + fieldType: "input", + isTransformation: true, + transformationGroup: "focus", + helpText: + "Vertical position from the top-left. Use an integer or expression.", + examples: ["100", "ih_mul_0.4"], + isVisible: ({ width, height, mode, focus, coordinateMethod }) => + !!(width && height && mode === "cm-extract" && focus === "coordinates" && coordinateMethod === "topleft"), + }, + { + label: "XC (Horizontal Center)", + name: "xc", + fieldType: "input", + isTransformation: true, + transformationGroup: "focus", + helpText: + "Horizontal center position. Use an integer or expression.", + examples: ["200", "iw_mul_0.5"], + isVisible: ({ width, height, mode, focus, coordinateMethod }) => + !!(width && height && mode === "cm-extract" && focus === "coordinates" && coordinateMethod === "center"), + }, + { + label: "YC (Vertical Center)", + name: "yc", + fieldType: "input", + isTransformation: true, + transformationGroup: "focus", + helpText: "Vertical center position. Use an integer or expression.", + examples: ["200", "ih_mul_0.5"], + isVisible: ({ width, height, mode, focus, coordinateMethod }) => + !!(width && height && mode === "cm-extract" && focus === "coordinates" && coordinateMethod === "center"), + }, + + // 17. Background fields (for pad_resize mode) + // These will be spread from background.getPropsFor("pad_resize") + // We need to add them with isVisible checks for pad_resize mode +] + +// Add background fields for pad_resize with mode visibility +const padResizeBackgroundFields = background.getPropsFor("pad_resize").transformations({ transformationGroup: "background" }) +padResizeBackgroundFields.forEach((field) => { + const originalIsVisible = field.isVisible + resizeAndCropTransformations.push({ + ...field, + isVisible: (values: Record) => { + const { width, height, mode } = values + if (!width || !height || mode !== "cm-pad_resize") return false + if (originalIsVisible) { + return originalIsVisible(values) + } + return true + }, + }) +}) + +// Add background fields for pad_extract with mode visibility +const padExtractBackgroundFields = background.getPropsFor("pad_extract").transformations({ transformationGroup: "background" }) +padExtractBackgroundFields.forEach((field) => { + const originalIsVisible = field.isVisible + resizeAndCropTransformations.push({ + ...field, + isVisible: (values: Record) => { + const { width, height, mode } = values + if (!width || !height || mode !== "cm-pad_extract") return false + if (originalIsVisible) { + return originalIsVisible(values) + } + return true + }, + }) +}) + +// Export the category +export const resizeAndCropCategory: TransformationSchema = { + key: "resize_and_crop", + name: "Resize and Crop", + items: [ + { + key: "resize_and_crop-resize_and_crop", + name: "Resize and Crop", + description: + "Resize and crop images with flexible options. Specify one dimension to auto-scale, or both dimensions with a cropping strategy for precise control.", + docsLink: + "https://imagekit.io/docs/image-resize-and-crop#crop-crop-modes--focus", + defaultTransformation: {}, + schema: resizeAndCropSchema, + transformations: resizeAndCropTransformations, + }, + ], +} diff --git a/packages/imagekit-editor-dev/src/store.ts b/packages/imagekit-editor-dev/src/store.ts index 362979f..9756b1c 100644 --- a/packages/imagekit-editor-dev/src/store.ts +++ b/packages/imagekit-editor-dev/src/store.ts @@ -11,6 +11,7 @@ import { type TransformationField, transformationFormatters, transformationSchema, + getDefaultTransformationFromMode, } from "./schema" import { extractImagePath } from "./utils" @@ -574,8 +575,20 @@ const calculateImageList = ( } } + // Special handling for resize_and_crop transformation + let defaultTransformation: any = t?.defaultTransformation || {} + if (transformation.key === "resize_and_crop-resize_and_crop") { + const value = transformation.value as Record + // Only add crop/cropMode when both width and height and mode are set + if (value.width && value.height && value.mode) { + defaultTransformation = getDefaultTransformationFromMode(value.mode as string) + } else { + defaultTransformation = {} + } + } + return { - ...t?.defaultTransformation, + ...defaultTransformation, ...transforms, } })