From 1a67bca39ddd7fc9c0b28996777629ad45ccfe9b Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Fri, 16 Jan 2026 16:39:14 +0530 Subject: [PATCH 01/15] 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/15] 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 645fd1d8231142f5788f4c0da29a766d771818ee Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Tue, 20 Jan 2026 18:03:58 +0530 Subject: [PATCH 03/15] 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 04/15] 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 05/15] 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 06/15] 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 937b21d30601acdb6a7ce5a85478e5c4b55dac9d Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Thu, 22 Jan 2026 20:21:13 +0530 Subject: [PATCH 07/15] 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 08/15] 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 09/15] 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 10/15] 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 85364a6ce5ab87e582c24405bc0fb4fbbb651e33 Mon Sep 17 00:00:00 2001 From: Swarnim Doegar Date: Wed, 28 Jan 2026 11:16:07 +0530 Subject: [PATCH 11/15] 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 12/15] 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 13/15] 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 14/15] 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 15/15] 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) {