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 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 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"] +} diff --git a/packages/imagekit-editor-dev/src/components/RetryableImage.tsx b/packages/imagekit-editor-dev/src/components/RetryableImage.tsx index 8b3b3ed..1f95585 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,11 @@ export default function RetryableImage(props: RetryableImageProps) { } return ( - + } + position="relative" + display="inline-block" + > {error ? (
= ({ 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/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/ColorPickerField.tsx b/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx index 76eee42..964a833 100644 --- a/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx +++ b/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx @@ -7,20 +7,79 @@ import { PopoverTrigger, } from "@chakra-ui/react" import { memo, useEffect, useState } from "react" -import ColorPicker from "react-best-gradient-color-picker" +import ColorPicker, { + type 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) + /** + * @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) ?? [] @@ -35,12 +94,12 @@ 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 - const alphaInt = clamp8(Math.round(alphaDec * 255)) - setLocalValue(`#${rgbHex}${alphaInt.toString(16).padStart(2, "0")}`) + const alphaHex = alphaToHexForDownstream(alphaDec) + setLocalValue(`#${rgbHex}${alphaHex}`) } } @@ -50,6 +109,10 @@ const ColorPickerField = ({ setValue(fieldName, debouncedValue) }, [debouncedValue, fieldName, setValue]) + useEffect(() => { + setLocalValue(value) + }, [value]) + return ( @@ -84,7 +147,7 @@ const ColorPickerField = ({ height="10" align="center" justify="center" - bg={localValue} + bg={getPreviewColor(localValue)} borderWidth="1px" borderColor="gray.200" borderLeft="0" @@ -95,7 +158,7 @@ const ColorPickerField = ({ 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..dce5a0c --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/CornerRadiusInput.tsx @@ -0,0 +1,285 @@ +import { + Box, + Flex, + HStack, + Icon, + IconButton, + Input, + InputGroup, + InputLeftElement, + Text, + Tooltip, + useColorModeValue, +} from "@chakra-ui/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 { TbBorderCorners } from "@react-icons/all-files/tb/TbBorderCorners" +import { set } from "lodash" +import type * as React from "react" +import { useEffect, useState } from "react" + +type RadiusMode = "uniform" | "individual" + +export type RadiusState = { + mode: RadiusMode + radius: RadiusObject | string +} + +export type RadiusObject = { + topLeft: string | "max" + topRight: string | "max" + bottomRight: string | "max" + 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", +): 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") + + // biome-ignore lint/correctness/useExhaustiveDependencies: + 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 ( + // biome-ignore lint/a11y/useSemanticElements: + + + {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} + + + ) : ( + // 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, + }, + ].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/common/DistortPerspectiveInput.tsx b/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx new file mode 100644 index 0000000..d6594f4 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx @@ -0,0 +1,184 @@ +import { + Box, + FormLabel, + HStack, + Icon, + Input, + InputGroup, + InputLeftAddon, + 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 +} + +type ErrorObject = { + message: string +} + +type CornerErrors = { + [key in keyof PerspectiveObject]?: ErrorObject +} & ErrorObject + +export type PerspectiveErrors = Record + +type DistorPerspectiveFieldProps = { + name: string + id?: string + onChange: (value: PerspectiveObject) => void + errors?: PerspectiveErrors + value?: PerspectiveObject +} + +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?.toUpperCase(), + })) + } + } + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + onChange(perspective) + }, [perspective]) + + return ( + // biome-ignore lint/a11y/useSemanticElements: + + {[ + { + 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 + + + + + + {x.toUpperCase()} + + + + { + errors?.[propertyName]?.[x as keyof PerspectiveObject] + ?.message + } + + + + + + {y.toUpperCase()} + + + + { + errors?.[propertyName]?.[y as keyof PerspectiveObject] + ?.message + } + + + + + ))} + + ) +} + +export default DistortPerspectiveInput 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..aa4be82 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx @@ -0,0 +1,375 @@ +import { + Box, + Flex, + FormLabel, + Input, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Text, + useColorModeValue, +} from "@chakra-ui/react" +import { BsArrowsMove } from "@react-icons/all-files/bs/BsArrowsMove" +import { TbAngle } from "@react-icons/all-files/tb/TbAngle" +import { memo, useEffect, useState } from "react" +import ColorPicker, { useColorPicker } from "react-best-gradient-color-picker" +import type { FieldErrors } from "react-hook-form" +import { useDebounce } from "../../hooks/useDebounce" +import AnchorField from "./AnchorField" +import RadioCardField from "./RadioCardField" + +export type GradientPickerState = { + from: string + to: string + direction: number | string + stopPoint: number | string +} + +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, + errors, +}: { + 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 (!Number.isNaN(dirInt)) { + direction = `${dirInt}deg` + } else { + 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}%)` + } + + 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 | string { + const dirInt = Number(localValue.direction as string) + if (!Number.isNaN(dirInt)) { + return dirInt || "" + } + 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] || "" + } + + function getDirectionValue(): string { + const dirInt = Number(localValue.direction as string) + if (Number.isNaN(dirInt)) { + return String(localValue.direction) + } + const nearestAngle = Math.round(dirInt / 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 cleanedGradient = gradientVal.replace(/NaNdeg\s*,/, "") + let gradientObj: ReturnType + try { + gradientObj = getGradientObject(cleanedGradient) + } catch (e) { + console.error("Failed to parse gradient:", e) + return + } + + if (!gradientObj || !gradientObj.isGradient) return + + 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 + + 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]) + + const errorRed = useColorModeValue("red.500", "red.300") + + 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" + /> + + {errors?.[fieldName]?.from?.message} + + + + + + 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" + /> + + {errors?.[fieldName]?.to?.message} + + + + + + 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.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="0" + borderRadius="4px" + /> + )} + + {errors?.[fieldName]?.direction?.message} + + + + + + Stop Point (%) + + { + const newValue = e.target.value.trim() + if (newValue === "") { + applyGradientInputChanges({ ...localValue, stopPoint: "" }) + return + } + const intVal = Number(newValue) + if (intVal < 1 || intVal > 100) return + applyGradientInputChanges({ + ...localValue, + stopPoint: intVal, + }) + }} + borderColor="gray.200" + placeholder="100" + borderRadius="4px" + /> + + {errors?.[fieldName]?.stopPoint?.message} + + + + ) +} + +export default memo(GradientPickerField) diff --git a/packages/imagekit-editor-dev/src/components/common/Hover.tsx b/packages/imagekit-editor-dev/src/components/common/Hover.tsx index f3cfd1d..f7fbc6e 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 { useCallback, useEffect, useRef, useState } from "react" interface FlexHoverProps extends FlexProps { children(isHover: boolean): JSX.Element @@ -15,6 +15,40 @@ 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 => { + 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/common/PaddingInput.tsx b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx new file mode 100644 index 0000000..b2a8470 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx @@ -0,0 +1,282 @@ +import { + Box, + Flex, + HStack, + Icon, + IconButton, + Input, + InputGroup, + InputLeftElement, + Text, + Tooltip, + useColorModeValue, +} from "@chakra-ui/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 { TbBoxPadding } from "@react-icons/all-files/tb/TbBoxPadding" +import { set } from "lodash" +import type * as React from "react" +import { useEffect, useState } from "react" + +type PaddingMode = "uniform" | "individual" + +type PaddingDirection = "top" | "right" | "bottom" | "left" + +export type PaddingState = { + mode: PaddingMode + padding: number | PaddingObject | null | string +} + +export type PaddingObject = { + top: number | null + right: number | null + bottom: number | null + left: number | null +} + +type ErrorObject = { + message: string +} + +type SidesErrors = { + [key in keyof PaddingObject]?: ErrorObject +} & ErrorObject + +export type PaddingErrors = Record< + string, + { + padding?: SidesErrors + } +> + +type PaddingInputFieldProps = { + id?: string + onChange: (value: PaddingState) => void + errors?: PaddingErrors + name: string + value?: Partial +} + +function getUpdatedPaddingValue( + current: number | PaddingObject | null | string, + side: PaddingDirection | "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, + value, +}) => { + 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") + + // biome-ignore lint/correctness/useExhaustiveDependencies: + 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({ mode: paddingMode, padding: formattedValue }) + }, [paddingValue, paddingMode]) + + return ( + // biome-ignore lint/a11y/useSemanticElements: + + + {paddingMode === "uniform" ? ( + + { + const val = e.target.value + setPaddingValue( + getUpdatedPaddingValue(paddingValue, "all", val, paddingMode), + ) + }} + value={ + ["number", "string"].includes(typeof paddingValue) + ? (paddingValue as string | number) + : "" + } + placeholder="Uniform Padding" + isInvalid={!!errors?.[propertyName]?.padding} + fontSize="sm" + /> + + {errors?.[propertyName]?.padding?.message} + + + ) : ( + // biome-ignore lint/complexity/noUselessFragments: + <> + {[ + { 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 + } + + + ))} + + )} + + + } + padding="0.05em" + onClick={() => { + const newPaddingMode = + paddingMode === "uniform" ? "individual" : "uniform" + setPaddingValue( + getUpdatedPaddingValue( + paddingValue, + "all", + JSON.stringify(paddingValue), + newPaddingMode, + ), + ) + setPaddingMode(newPaddingMode) + }} + variant="outline" + color={paddingMode === "individual" ? activeColor : inactiveColor} + /> + + + ) +} + +export default PaddingInputField 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/common/ZoomInput.tsx b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx new file mode 100644 index 0000000..4cc77b8 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx @@ -0,0 +1,128 @@ +import { + ButtonGroup, + HStack, + IconButton, + Input, + InputGroup, + InputRightElement, + Text, +} from "@chakra-ui/react" +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 + onChange: (value: number) => void + defaultValue?: number + value?: number +} + +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 +} + +/** + * Calculate the next zoom value when zooming out + * Rounds down to the previous step value + */ +function calculateZoomOut(currentValue: number): number { + return Math.ceil(currentValue / STEP_SIZE) * STEP_SIZE - STEP_SIZE +} + +export const ZoomInput: React.FC = ({ + id, + onChange, + defaultValue = 100, + value, +}) => { + const [zoomValue, setZoomValue] = useState( + value ?? (defaultValue as number), + ) + const [inputValue, setInputValue] = useState( + (value ?? (defaultValue as number)).toString(), + ) + + // biome-ignore lint/correctness/useExhaustiveDependencies: + 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 (!Number.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 ( + // biome-ignore lint/a11y/useSemanticElements: + + + + + + % + + + + + + } + onClick={handleZoomOut} + /> + } + onClick={handleZoomIn} + /> + + + ) +} + +export default ZoomInput 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 6bca676..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,18 +1,25 @@ import { Box, + Flex, HStack, Icon, + IconButton, + Input, Menu, MenuButton, MenuItem, MenuList, + Tag, Text, Tooltip, + useColorModeValue, } 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" @@ -20,7 +27,10 @@ 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" @@ -54,6 +64,8 @@ export const SortableTransformationItem = ({ _setSelectedTransformationKey, _setTransformationToEdit, _internalState, + addTransformation, + updateTransformation, } = useEditorStore() const style = transform @@ -70,6 +82,26 @@ export const SortableTransformationItem = ({ _internalState.transformationToEdit?.position === "inplace" && _internalState.transformationToEdit?.transformationId === transformation.id + const [isRenaming, setIsRenaming] = useState(false) + const renameInputRef = 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) + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => { + document.removeEventListener("mousedown", handleClickOutside) + } + }, []) + return ( {(isHover) => ( @@ -87,15 +119,19 @@ export const SortableTransformationItem = ({ minH="8" alignItems="center" style={style} - onClick={() => { + onClick={(_e) => { _setSidebarState("config") _setSelectedTransformationKey(transformation.key) _setTransformationToEdit(transformation.id, "inplace") }} + onDoubleClick={(e) => { + e.stopPropagation() + setIsRenaming(true) + }} {...attributes} {...listeners} > - {isHover ? ( + {isHover && !isRenaming ? ( )} - - {transformation.name} - + {isRenaming ? ( + + + { + 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) + } + }} + 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} + + )} - {isHover && ( + {isHover && !isRenaming && ( 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 +311,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..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 @@ -39,18 +39,35 @@ import { PiInfo } from "@react-icons/all-files/pi/PiInfo" import { PiX } from "@react-icons/all-files/pi/PiX" import startCase from "lodash/startCase" import { useEffect, useMemo } from "react" +import type { ColorPickerProps } from "react-best-gradient-color-picker" import { Controller, type SubmitHandler, useForm } from "react-hook-form" 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" import CheckboxCardField from "../common/CheckboxCardField" import ColorPickerField from "../common/ColorPickerField" +import RadiusInputField, { + type RadiusErrors, + type RadiusState, +} from "../common/CornerRadiusInput" +import DistortPerspectiveInput, { + type PerspectiveErrors, + type PerspectiveObject, +} from "../common/DistortPerspectiveInput" +import GradientPicker, { + type GradientPickerState, +} from "../common/GradientPicker" +import PaddingInputField, { + type PaddingErrors, + type PaddingState, +} from "../common/PaddingInput" import RadioCardField from "../common/RadioCardField" +import ZoomInput from "../common/ZoomInput" import { SidebarBody } from "./sidebar-body" import { SidebarFooter } from "./sidebar-footer" import { SidebarHeader } from "./sidebar-header" @@ -81,16 +98,23 @@ export const TransformationConfigSidebar: React.FC = () => { ) }, [_internalState.selectedTransformationKey]) - const transformationToEdit = _internalState.transformationToEdit + const transformationToEdit = _internalState.transformationToEdit as { + transformationId: string + position: "inplace" + } - 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 && @@ -131,6 +155,7 @@ export const TransformationConfigSidebar: React.FC = () => { watch, setValue, control, + trigger, } = useForm>({ resolver: zodResolver(selectedTransformation?.schema ?? z.object({})), defaultValues: defaultValues, @@ -162,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: selectedTransformation.name, + name: finalName, key: selectedTransformation.key, value: data, }) @@ -182,7 +234,7 @@ export const TransformationConfigSidebar: React.FC = () => { const transformationId = addTransformation( { type: "transformation", - name: selectedTransformation.name, + name: displayName, key: selectedTransformation.key, value: data, }, @@ -193,7 +245,7 @@ export const TransformationConfigSidebar: React.FC = () => { } else { const transformationId = addTransformation({ type: "transformation", - name: selectedTransformation.name, + name: displayName, key: selectedTransformation.key, value: data, }) @@ -282,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) { @@ -290,11 +347,19 @@ export const TransformationConfigSidebar: React.FC = () => { return true }) .map((field: TransformationField) => ( - - - {field.label} - - {field.fieldType === "select" ? ( + field.fieldType === type, + ) + } + > + + {field.label} + + {field.fieldType === "select" ? ( { })) const isCreatable = field.fieldProps?.isCreatable === true + const isClearable: boolean = field.fieldProps?.isClearable ?? false const SelectComponent = isCreatable ? CreateableSelect : Select @@ -339,6 +405,7 @@ export const TransformationConfigSidebar: React.FC = () => { formatCreateLabel={(inputValue) => `Use "${inputValue}"` } + isClearable={isClearable} placeholder="Select" menuPlacement="auto" options={selectOptions} @@ -413,6 +480,21 @@ export const TransformationConfigSidebar: React.FC = () => { id={field.name} fontSize="sm" {...register(field.name)} + {...(field.fieldProps ?? {})} + defaultValue={ + field.fieldProps?.defaultValue as + | string + | number + | 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" ? ( @@ -435,30 +517,51 @@ 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, "") @@ -476,18 +579,23 @@ 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 { @@ -513,9 +621,19 @@ 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())} @@ -524,7 +642,7 @@ export const TransformationConfigSidebar: React.FC = () => { - + ) : null} @@ -533,6 +651,15 @@ export const TransformationConfigSidebar: React.FC = () => { fieldName={field.name} value={watch(field.name) as string} setValue={setValue} + fieldProps={field.fieldProps as ColorPickerProps} + /> + ) : null} + {field.fieldType === "gradient-picker" ? ( + ) : null} {field.fieldType === "anchor" ? ( @@ -558,13 +685,67 @@ export const TransformationConfigSidebar: React.FC = () => { {...field.fieldProps} /> ) : null} + {field.fieldType === "padding-input" ? ( + { + setValue(field.name, value) + trigger(field.name) + }} + errors={errors as PaddingErrors} + name={field.name} + {...field.fieldProps} + value={watch(field.name) as Partial} + /> + ) : null} + {field.fieldType === "zoom" ? ( + setValue(field.name, value)} + {...field.fieldProps} + /> + ) : null} + {field.fieldType === "distort-perspective-input" ? ( + { + setValue(field.name, value) + trigger(field.name) + }} + errors={errors as PerspectiveErrors} + name={field.name} + value={watch(field.name) as PerspectiveObject} + {...field.fieldProps} + /> + ) : null} + {field.fieldType === "radius-input" ? ( + { + setValue(field.name, value) + trigger(field.name) + }} + errors={errors as RadiusErrors} + name={field.name} + value={watch(field.name) as Partial} + {...field.fieldProps} + /> + ) : null} {String( errors[field.name as keyof typeof errors]?.message ?? "", )} {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/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/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 94fec2b..c35a017 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -11,16 +11,31 @@ 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 { 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, layerXValidator, layerYValidator, + optionalPositiveFloatNumberValidator, + refineUnsharpenMask, 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 = [ @@ -133,6 +148,7 @@ export interface TransformationField { }[] autoOption?: boolean isCreatable?: boolean + isClearable?: boolean min?: number max?: number step?: number @@ -152,51 +168,977 @@ export interface TransformationSchema { } export const transformationSchema: TransformationSchema[] = [ + // 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" }), + // ], + // }, + // ], + // }, { - key: "resize", - name: "Resize", + key: "adjust", + name: "Adjust", 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. + 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", + // Contrast stretch automatically expands the tonal range of the image + // making dark areas darker and light areas lighter. This toggle applies + // ImageKit's e-contrast effect. 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.", + "Enhance the tonal range of the image automatically by stretching the contrast. Dark areas become darker and light areas become lighter.", docsLink: - "https://imagekit.io/docs/image-resize-and-crop#pad-resize-crop-strategy---cm-pad_resize", - defaultTransformation: { cropMode: "pad_resize" }, + "https://imagekit.io/docs/effects-and-enhancements#contrast-stretch---e-contrast", + defaultTransformation: {}, schema: z .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), - backgroundType: z.string().optional(), - background: z - .union([z.literal("").transform(() => ""), colorValidator]) + contrast: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) .optional(), - backgroundBlurIntensity: z.coerce - .string({ - invalid_type_error: - "Should be a number between 1 and 100 or auto.", + }) + .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: "Contrast", + name: "contrast", + fieldType: "switch", + isTransformation: true, + transformationKey: "contrastStretch", + helpText: + "Toggle to automatically stretch and enhance image contrast.", + }, + ], + }, + { + key: "adjust-shadow", + name: "Shadow", + // Adds a non-AI shadow beneath objects in images with a transparent background. You can adjust blur, saturation and positional offsets. + description: + "Add a non-AI shadow beneath objects in images with a transparent background. Use blur, saturation and offset controls to customise the shadow.", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#shadow---e-shadow", + defaultTransformation: {}, + // Schema allows toggling the shadow effect and specifying optional blur, saturation and X/Y offsets. + schema: z + .object({ + shadow: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", }) .optional(), - backgroundBlurBrightness: z.coerce - .string({ - invalid_type_error: "Should be a number between -255 and 255.", + 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(), - backgroundGenerativeFill: z.string().optional(), - focus: z.string().optional(), }) .refine( (val) => { if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) + Object.values(val).some((v) => v !== undefined && v !== null) ) { return true } @@ -206,178 +1148,102 @@ export const transformationSchema: TransformationSchema[] = [ 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", + label: "Shadow", + name: "shadow", + fieldType: "switch", isTransformation: true, - transformationKey: "width", + transformationGroup: "shadow", 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"], + "Toggle to add a non-AI shadow under objects in the image.", }, { - label: "Height", - name: "height", - fieldType: "input", + label: "Blur", + name: "shadowBlur", + fieldType: "slider", isTransformation: true, - transformationKey: "height", + transformationGroup: "shadow", 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"], - }, - { - label: "Background Type", - name: "backgroundType", - fieldType: "select", - isTransformation: false, - transformationGroup: "background", + "Set the blur radius for the shadow. Higher values create a softer shadow.", fieldProps: { - options: [ - { label: "Color", value: "color" }, - { label: "Blurred", value: "blurred" }, - { label: "Generative Fill", value: "generative_fill" }, - ], + min: 0, + max: 15, + step: 1, + defaultValue: 10, }, + isVisible: ({ shadow }) => shadow === true, }, { - label: "Background Color", - name: "background", - fieldType: "color-picker", - transformationGroup: "background", - isTransformation: true, - isVisible: ({ backgroundType }) => backgroundType === "color", - }, - { - label: "Background Blur Intensity", - name: "backgroundBlurIntensity", + label: "Saturation", + name: "shadowSaturation", 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", + transformationGroup: "shadow", + helpText: + "Adjust the saturation of the shadow. Higher values produce a darker shadow.", fieldProps: { - defaultValue: "auto", min: 0, max: 100, step: 1, - autoOption: true, + defaultValue: 30, }, - isVisible: ({ backgroundType }) => backgroundType === "blurred", + isVisible: ({ shadow }) => shadow === true, }, { - label: "Background Blur Brightness", - name: "backgroundBlurBrightness", + label: "X Offset", + name: "shadowOffsetX", fieldType: "slider", + isTransformation: true, + transformationGroup: "shadow", helpText: - "Adjust the brightness of a blurred background. Use a number between −255 (darker) and 255 (brighter).", - isTransformation: false, - transformationGroup: "background", + "Enter the horizontal offset as a percentage of the image width.", + isVisible: ({ shadow }) => shadow === true, fieldProps: { - defaultValue: "0", - min: -255, - max: 255, - step: 5, + min: -100, + max: 100, + step: 1, + defaultValue: 2, }, - 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", - }, - { - label: "Focus", - name: "focus", - fieldType: "anchor", + label: "Y Offset", + name: "shadowOffsetY", + fieldType: "slider", isTransformation: true, - transformationKey: "focus", + transformationGroup: "shadow", + helpText: + "Enter the vertical offset as a percentage of the image height.", + isVisible: ({ shadow }) => shadow === true, fieldProps: { - positions: ["center", "top", "bottom", "left", "right"], + min: -100, + max: 100, + step: 1, + defaultValue: 2, }, }, ], }, { - 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.", + key: "adjust-grayscale", + name: "Grayscale", + description: "Convert the image to grayscale (black and white).", docsLink: - "https://imagekit.io/docs/image-resize-and-crop#maintain-ratio-crop-strategy---c-maintain_ratio", - defaultTransformation: { crop: "maintain_ratio" }, + "https://imagekit.io/docs/effects-and-enhancements#grayscale---e-grayscale", + defaultTransformation: {}, schema: z .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), - aspectRatio: aspectRatioValidator.optional(), - focus: z.string().optional(), - focusAnchor: z.string().optional(), - focusObject: z.string().optional(), + grayscale: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), }) .refine( (val) => { if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) + Object.values(val).some((v) => v !== undefined && v !== null) ) { return true } @@ -387,145 +1253,129 @@ export const transformationSchema: TransformationSchema[] = [ 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", + label: "Grayscale", + name: "grayscale", + fieldType: "switch", 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"], + transformationKey: "grayscale", + helpText: "Toggle to convert the image to grayscale.", }, - { - 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" }, - ], + ], + }, + { + key: "adjust-gradient", + name: "Gradient", + description: "Add gradient overlay over the image.", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#gradient---e-gradient", + 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.", + }), + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false }, - }, - { - 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" }, - ], + { + message: "At least one value is required", + path: [], }, - isVisible: ({ focus }) => focus === "anchor", + ), + transformations: [ + { + label: "Gradient", + name: "gradientSwitch", + fieldType: "switch", + isTransformation: false, + transformationGroup: "gradient", + helpText: "Toggle to add a gradient overlay over the image.", }, { - label: "Focus Object", - name: "focusObject", - fieldType: "select", + label: "Apply Gradient", + name: "gradient", + fieldType: "gradient-picker", isTransformation: true, - transformationGroup: "focus", + transformationKey: "gradient", + transformationGroup: "gradient", + isVisible: ({ gradientSwitch }) => gradientSwitch === true, fieldProps: { - isCreatable: false, + defaultValue: { + from: "#FFFFFFFF", + to: "#00000000", + direction: "bottom", + stopPoint: 100, + }, }, - helpText: - "Select an object to focus on. The crop will center on this object.", - isVisible: ({ focus }) => focus === "object", }, ], }, { - 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.", + key: "adjust-distort", + name: "Distort", + description: "Distort the image.", docsLink: - "https://imagekit.io/docs/image-resize-and-crop#forced-crop-strategy---c-force", - defaultTransformation: { crop: "force" }, + "https://imagekit.io/docs/effects-and-enhancements#distort---e-distort", + defaultTransformation: {}, schema: z .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), - focus: z.string().optional(), - focusAnchor: z.string().optional(), - focusObject: z.string().optional(), + 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(), }) .refine( (val) => { if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) + Object.values(val).some((v) => v !== undefined && v !== null) ) { return true } @@ -535,102 +1385,95 @@ export const transformationSchema: TransformationSchema[] = [ message: "At least one value is required", path: [], }, - ), + ) + .superRefine((val, ctx) => { + validatePerspectiveDistort(val, ctx) + }), 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: "Distort", + name: "distort", + fieldType: "switch", + isTransformation: false, + transformationGroup: "distort", + helpText: "Toggle to apply distortion to the image.", }, { - label: "Focus", - name: "focus", - fieldType: "select", - isTransformation: true, - transformationGroup: "focus", + label: "Distortion Type", + name: "distortType", + fieldType: "radio-card", + isTransformation: false, + transformationGroup: "distort", + isVisible: ({ distort }) => distort === true, fieldProps: { options: [ - { label: "Auto", value: "auto" }, - { label: "Anchor", value: "anchor" }, - { label: "Face", value: "face" }, - { label: "Object", value: "object" }, + { label: "Perspective", value: "perspective" }, + { label: "Arc", value: "arc" }, ], + defaultValue: "perspective", }, }, { - label: "Focus Anchor", - name: "focusAnchor", - fieldType: "anchor", - isTransformation: true, - transformationGroup: "focus", + label: "Distortion Perspective", + name: "distortPerspective", + fieldType: "distort-perspective-input", + isTransformation: false, + transformationGroup: "distort", + isVisible: ({ distort, distortType }) => + distort === true && distortType === "perspective", 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" }, - ], + defaultValue: { + x1: "", + y1: "", + x2: "", + y2: "", + x3: "", + y3: "", + x4: "", + y4: "", + }, }, - isVisible: ({ focus }) => focus === "anchor", }, { - label: "Focus Object", - name: "focusObject", - fieldType: "select", + label: "Distortion Arc Degrees", + name: "distortArcDegree", + fieldType: "slider", isTransformation: true, - transformationGroup: "focus", + transformationGroup: "distort", + isVisible: ({ distort, distortType }) => + distort === true && distortType === "arc", + helpText: "Enter the arc degree for the arc distortion effect.", + examples: ["15", "30", "-45", "N50"], fieldProps: { - isCreatable: false, + min: -360, + max: 360, + step: 5, + defaultValue: "0", + inputType: "text", + skipStepCheck: true, }, - helpText: - "Select an object to focus on. The crop will center on this object.", - 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. + key: "adjust-blur", + name: "Blur", 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" }, + "Apply a Gaussian blur to the image. Higher values create a stronger blur effect.", + docsLink: "https://imagekit.io/docs/effects-and-enhancements#blur---bl", + defaultTransformation: {}, schema: z .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), + blur: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .optional(), }) .refine( (val) => { if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) + Object.values(val).some((v) => v !== undefined && v !== null) ) { return true } @@ -643,50 +1486,46 @@ export const transformationSchema: TransformationSchema[] = [ ), 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", + label: "Blur", + name: "blur", + fieldType: "slider", isTransformation: true, - transformationKey: "height", + transformationKey: "blur", 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"], + "Enter a blur radius to control the intensity of the Gaussian blur. Possible values include integers between 1 and 100.", + examples: ["10"], + fieldProps: { + min: 0, + max: 100, + step: 1, + defaultValue: 10, + }, }, ], }, { - 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. + key: "adjust-rotate", + name: "Rotate", 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.", + "Rotate the image by a specified number of degrees clockwise or counter-clockwise, or automatically rotate based on EXIF orientation.", docsLink: - "https://imagekit.io/docs/image-resize-and-crop#max-size-enlarge-cropping-strategy---c-at_max_enlarge", - defaultTransformation: { crop: "at_max_enlarge" }, + "https://imagekit.io/docs/effects-and-enhancements#rotate---rt", + defaultTransformation: {}, schema: z .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), + rotate: z + .union([ + z.literal("auto"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }), + ]) + .optional(), }) .refine( (val) => { if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) + Object.values(val).some((v) => v !== undefined && v !== null) ) { return true } @@ -699,50 +1538,42 @@ export const transformationSchema: TransformationSchema[] = [ ), 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", + label: "Rotate", + name: "rotate", + fieldType: "slider", isTransformation: true, - transformationKey: "height", + transformationKey: "rt", + transformationGroup: "rotate", 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"], + "Specify rotation angle in degrees (positive for clockwise, negative for counter-clockwise). Select 'auto' to use the image's EXIF orientation data.", + fieldProps: { + min: -180, + max: 180, + step: 1, + defaultValue: "auto", + autoOption: true, + }, }, ], }, { - 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" }, + key: "adjust-flip", + name: "Flip", + description: "Flip the image horizontally, vertically, or both.", + docsLink: "https://imagekit.io/docs/effects-and-enhancements#flip---fl", + defaultTransformation: {}, schema: z .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), + flip: z.coerce + .string({ + invalid_type_error: "Should be a string.", + }) + .optional(), }) .refine( (val) => { if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) + Object.values(val).some((v) => v !== undefined && v !== null) ) { return true } @@ -755,64 +1586,107 @@ export const transformationSchema: TransformationSchema[] = [ ), 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", + label: "Flip", + name: "flip", + fieldType: "checkbox-card", isTransformation: true, - transformationKey: "height", + transformationKey: "fl", + transformationGroup: "flip", 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"], + "Choose how to flip the image: horizontally, vertically, or both.", + fieldProps: { + options: [ + { + label: "Horizontal", + icon: PiFlipHorizontalFill, + value: "horizontal", + }, + { + label: "Vertical", + icon: PiFlipVerticalFill, + value: "vertical", + }, + ], + columns: 2, + defaultValue: [], + }, }, ], }, - ], - }, - { - 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. + key: "adjust-radius", + name: "Radius", 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.", + "Round the corners of the image. Specify a radius value to control how rounded the corners are, or use 'max' to make the image circular.", docsLink: - "https://imagekit.io/docs/image-resize-and-crop#extract-crop-strategy---cm-extract", - defaultTransformation: { cropMode: "extract" }, + "https://imagekit.io/docs/effects-and-enhancements#radius---r", + defaultTransformation: {}, 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(), + 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) => { if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) + Object.values(val).some((v) => v !== undefined && v !== null) ) { return true } @@ -822,313 +1696,151 @@ 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") { - 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({ - 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", + label: "Radius", + name: "radius", + fieldType: "radius-input", isTransformation: true, - transformationKey: "height", + transformationGroup: "radius", 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", + "Enter a positive integer for rounded corners or 'max' for a fully circular output.", + examples: ["10", "max"], 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" }, - ], + defaultValue: {}, }, - 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" }, - ], + ], + }, + { + key: "adjust-opacity", + name: "Opacity", + description: + "Adjust the opacity of the image to make it more or less transparent.", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#opacity---o", + defaultTransformation: {}, + schema: z + .object({ + opacity: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .optional(), + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false }, - isVisible: ({ focus }) => focus === "anchor", - }, - { - label: "Focus Object", - name: "focusObject", - fieldType: "select", - isTransformation: true, - transformationGroup: "focus", - fieldProps: { - isCreatable: false, + { + message: "At least one value is required", + path: [], }, - helpText: - "Select an object to focus on during extraction. The crop will center on this object.", - isVisible: ({ focus }) => focus === "object", - }, + ), + transformations: [ { - label: "Coordinate Method", - name: "coordinateMethod", - fieldType: "radio-card", - isTransformation: false, - transformationGroup: "focus", + label: "Opacity", + name: "opacity", + fieldType: "slider", + isTransformation: true, + transformationKey: "opacity", + helpText: "Enter an opacity percentage between 0 and 100.", + examples: ["50"], fieldProps: { - options: [ - { label: "Top-left (x, y)", value: "topleft" }, - { label: "Center (xc, yc)", value: "center" }, - ], - defaultValue: "topleft", + min: 0, + max: 100, + step: 1, }, - 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", }, ], }, { - 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. + key: "adjust-border", + name: "Border", 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.", + "Add a border to the image. Specify a border width and color.", docsLink: - "https://imagekit.io/docs/image-resize-and-crop#pad-extract-crop-strategy---cm-pad_extract", - defaultTransformation: { cropMode: "pad_extract" }, + "https://imagekit.io/docs/effects-and-enhancements#border---b", + defaultTransformation: {}, schema: z .object({ - width: widthValidator.optional(), - height: heightValidator.optional(), - backgroundType: z.string().optional(), - background: z - .union([z.literal("").transform(() => ""), colorValidator]) - .optional(), - backgroundGenerativeFill: z.string().optional(), + borderWidth: commonNumberAndExpressionValidator.optional(), + borderColor: colorValidator, }) .refine( (val) => { if ( - Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) + Object.values(val).some((v) => v !== undefined && v !== null) ) { return true } return false }, { - message: "At least one value is required", + message: "Border width and color are 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", + label: "Border Width", + name: "borderWidth", 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"], - }, - { - label: "Background Type", - name: "backgroundType", - fieldType: "select", isTransformation: false, - transformationGroup: "background", + transformationGroup: "border", + helpText: "Enter a border width", fieldProps: { - options: [ - { label: "Color", value: "color" }, - { label: "Generative Fill", value: "generative_fill" }, - ], + defaultValue: 1, + min: 1, + max: 99, + step: 1, }, }, { - label: "Background Color", - name: "background", + label: "Border Color", + name: "borderColor", 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", + isTransformation: false, + transformationGroup: "border", + helpText: "Select the color of the border.", + fieldProps: { + hideOpacity: true, + showHexAlpha: false, + defaultValue: "#000000", + }, }, ], }, - ], - }, - { - key: "adjust", - name: "Adjust", - items: [ { - key: "adjust-contrast", - name: "Contrast", - // Contrast stretch automatically expands the tonal range of the image - // making dark areas darker and light areas lighter. This toggle applies - // ImageKit's e-contrast effect. + key: "adjust-trim", + name: "Trim", description: - "Enhance the tonal range of the image automatically by stretching the contrast. Dark areas become darker and light areas become lighter.", + "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#contrast-stretch---e-contrast", + "https://imagekit.io/docs/effects-and-enhancements#trim-edges---t", defaultTransformation: {}, schema: z .object({ - contrast: z.coerce + 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) => { @@ -1146,52 +1858,130 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Contrast", - name: "contrast", + label: "Enable Trim", + name: "trimEnabled", fieldType: "switch", - isTransformation: true, - transformationKey: "contrastStretch", + isTransformation: false, + transformationGroup: "trim", helpText: - "Toggle to automatically stretch and enhance image contrast.", + "Toggle to trim background edges for images with solid or near-solid backgrounds.", + }, + { + label: "Threshold", + name: "trim", + fieldType: "slider", + isTransformation: false, + transformationGroup: "trim", + helpText: + "Trim edges for images with solid or near-solid backgrounds. Use a threshold between 1 and 99.", + fieldProps: { + defaultValue: 10, + min: 1, + max: 99, + step: 1, + }, + isVisible: ({ trimEnabled }) => trimEnabled === true, }, ], }, { - key: "adjust-shadow", - name: "Shadow", - // Adds a non-AI shadow beneath objects in images with a transparent background. You can adjust blur, saturation and positional offsets. + key: "adjust-color-replace", + name: "Color Replace", description: - "Add a non-AI shadow beneath objects in images with a transparent background. Use blur, saturation and offset controls to customise the shadow.", + "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#shadow---e-shadow", + "https://imagekit.io/docs/effects-and-enhancements#color-replace---cr", defaultTransformation: {}, - // Schema allows toggling the shadow effect and specifying optional blur, saturation and X/Y offsets. schema: z .object({ - 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 + toColor: colorValidator, + tolerance: z.coerce .number({ invalid_type_error: "Should be a number.", }) + .int() + .min(0) + .max(100) .optional(), - shadowOffsetX: z.coerce - .number({ - invalid_type_error: "Should be a number.", + fromColor: z.union([colorValidator, z.literal("")]).optional(), + }) + .refine( + (val) => { + // At least toColor must be provided + return val.toColor !== undefined && val.toColor !== "" + }, + { + message: "To Color is required", + path: ["toColor"], + }, + ), + transformations: [ + { + label: "From Color", + examples: ["FFFFFF", "FF0000"], + name: "fromColor", + fieldType: "color-picker", + isTransformation: false, + fieldProps: { + hideOpacity: true, + showHexAlpha: false, + }, + transformationGroup: "colorReplace", + helpText: + "Select the source color you want to replace (optional - if not specified, dominant color will be replaced).", + }, + { + label: "Tolerance", + name: "tolerance", + fieldType: "slider", + isTransformation: false, + transformationGroup: "colorReplace", + helpText: + "Set the tolerance for the color replacement. Use a number between 0 and 100. Lower values are more precise, but may not work for all colors. Higher values are more forgiving, but may introduce more color variations.", + fieldProps: { + defaultValue: 35, + min: 0, + max: 100, + step: 1, + }, + }, + { + label: "To Color", + name: "toColor", + fieldType: "color-picker", + examples: ["FFFFFF", "FF0000"], + fieldProps: { + hideOpacity: true, + showHexAlpha: false, + }, + isTransformation: false, + transformationGroup: "colorReplace", + helpText: "Select the target color to replace with.", + }, + ], + }, + { + key: "adjust-sharpen", + name: "Sharpen", + description: + "Sharpen the image to highlight the edges and finer details within an image.", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#sharpen---e-sharpen", + defaultTransformation: {}, + schema: z + .object({ + sharpenEnabled: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", }) .optional(), - shadowOffsetY: z.coerce + sharpen: z.coerce .number({ invalid_type_error: "Should be a number.", }) + .int() + .min(1) + .max(99) .optional(), }) .refine( @@ -1210,90 +2000,131 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Shadow", - name: "shadow", + label: "Sharpen Image", + name: "sharpenEnabled", fieldType: "switch", - isTransformation: true, - transformationGroup: "shadow", + isTransformation: false, + transformationGroup: "sharpen", helpText: - "Toggle to add a non-AI shadow under objects in the image.", + "Toggle to sharpen the image to highlight the edges and finer details within an image.", }, { - label: "Blur", - name: "shadowBlur", + label: "Threshold", + name: "sharpen", fieldType: "slider", - isTransformation: true, - transformationGroup: "shadow", + isTransformation: false, + transformationGroup: "sharpen", helpText: - "Set the blur radius for the shadow. Higher values create a softer shadow.", + "Sharpen the image to highlight the edges and finer details within an image. Control the intensity of this effect using a threshold value between 1% and 99%.", fieldProps: { - min: 0, - max: 15, + min: 1, + max: 99, step: 1, - defaultValue: 10, + defaultValue: 50, }, - isVisible: ({ shadow }) => shadow === true, + isVisible: ({ sharpenEnabled }) => sharpenEnabled === true, }, + ], + }, + { + key: "adjust-unsharpen-mask", + name: "Unsharpen Mask", + description: + "Image sharpening technique that enhances edge contrast to make details appear clearer. Amplifies differences between neighboring pixels without significantly affecting smooth areas.", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#unsharp-mask---e-usm", + defaultTransformation: {}, + schema: z + .object({ + unsharpenMaskRadius: z.coerce.number().positive({ + message: "Should be a positive floating point number.", + }), + unsharpenMaskSigma: z.coerce.number().positive({ + message: "Should be a positive floating point number.", + }), + unsharpenMaskAmount: z.coerce.number().positive({ + message: "Should be a positive floating point number.", + }), + unsharpenMaskThreshold: z.coerce.number().positive({ + message: "Should be a positive floating point number.", + }), + }) + .refine((val) => { + if (Object.values(val).some((v) => v !== undefined && v !== null)) { + return true + } + return false + }), + transformations: [ { - label: "Saturation", - name: "shadowSaturation", - fieldType: "slider", - isTransformation: true, - transformationGroup: "shadow", + name: "unsharpenMaskRadius", + fieldType: "input", + label: "Radius", + isTransformation: false, + transformationGroup: "unsharpenMask", helpText: - "Adjust the saturation of the shadow. Higher values produce a darker shadow.", + "Controls how wide the sharpening effect spreads from each edge. Larger values affect broader areas; smaller values focus on fine details.", fieldProps: { - min: 0, - max: 100, - step: 1, - defaultValue: 30, + defaultValue: "", }, - isVisible: ({ shadow }) => shadow === true, + examples: ["1", "8", "15"], }, { - label: "X Offset", - name: "shadowOffsetX", - fieldType: "slider", - isTransformation: true, - transformationGroup: "shadow", + name: "unsharpenMaskSigma", + fieldType: "input", + label: "Sigma", + isTransformation: false, + transformationGroup: "unsharpenMask", helpText: - "Enter the horizontal offset as a percentage of the image width.", - isVisible: ({ shadow }) => shadow === true, + "Defines the amount of blur used to detect edges before sharpening. Higher values smooth more before sharpening; lower values preserve fine textures.", fieldProps: { - min: -100, - max: 100, - step: 1, - defaultValue: 2, + defaultValue: "", }, + examples: ["1", "5", "6"], }, { - label: "Y Offset", - name: "shadowOffsetY", - fieldType: "slider", - isTransformation: true, - transformationGroup: "shadow", - helpText: - "Enter the vertical offset as a percentage of the image height.", - isVisible: ({ shadow }) => shadow === true, + name: "unsharpenMaskAmount", + fieldType: "input", + label: "Amount", + isTransformation: false, + transformationGroup: "unsharpenMask", + helpText: "Sets the strength of the sharpening effect.", fieldProps: { - min: -100, - max: 100, - step: 1, - defaultValue: 2, + 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"], }, ], }, + ], + }, + { + key: "ai", + name: "AI Transformations", + items: [ { - key: "adjust-grayscale", - name: "Grayscale", - description: "Convert the image to grayscale (black and white).", + 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/effects-and-enhancements#grayscale---e-grayscale", + "https://imagekit.io/docs/ai-transformations#background-removal-e-removedotbg", defaultTransformation: {}, schema: z .object({ - grayscale: z.coerce + removedotbg: z.coerce .boolean({ invalid_type_error: "Should be a boolean.", }) @@ -1315,27 +2146,34 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Grayscale", - name: "grayscale", + label: "Remove Background using Remove.bg", + name: "removedotbg", fieldType: "switch", isTransformation: true, - transformationKey: "grayscale", - helpText: "Toggle to convert the image to grayscale.", + 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: "adjust-blur", - name: "Blur", + key: "ai-bgremove", + name: "Remove Background using ImageKit AI", description: - "Apply a Gaussian blur to the image. Higher values create a stronger blur effect.", - docsLink: "https://imagekit.io/docs/effects-and-enhancements#blur---bl", + "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({ - blur: z.coerce - .number({ - invalid_type_error: "Should be a number.", + bgremove: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", }) .optional(), }) @@ -1355,41 +2193,32 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Blur", - name: "blur", - fieldType: "slider", + label: "Remove Background using ImageKit AI", + name: "bgremove", + fieldType: "switch", isTransformation: true, - transformationKey: "blur", + transformationKey: "aiRemoveBackground", helpText: - "Enter a blur radius to control the intensity of the Gaussian blur. Possible values include integers between 1 and 100.", - examples: ["10"], - fieldProps: { - min: 0, - max: 100, - step: 1, - defaultValue: 10, - }, + "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: "adjust-rotate", - name: "Rotate", + key: "ai-changebg", + name: "Change Background", description: - "Rotate the image by a specified number of degrees clockwise or counter-clockwise, or automatically rotate based on EXIF orientation.", + "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/effects-and-enhancements#rotate---rt", + "https://imagekit.io/docs/ai-transformations#change-background-e-changebg", defaultTransformation: {}, schema: z .object({ - rotate: z - .union([ - z.literal("auto"), - z.coerce.number({ - invalid_type_error: "Should be a number.", - }), - ]) - .optional(), + changebg: z.string().optional(), }) .refine( (val) => { @@ -1407,37 +2236,33 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Rotate", - name: "rotate", - fieldType: "slider", + label: "Change Background", + name: "changebg", + fieldType: "input", isTransformation: true, - transformationKey: "rt", - transformationGroup: "rotate", - helpText: - "Specify rotation angle in degrees (positive for clockwise, negative for counter-clockwise). Select 'auto' to use the image's EXIF orientation data.", - fieldProps: { - min: -180, - max: 180, - step: 1, - defaultValue: "auto", - autoOption: 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: "adjust-flip", - name: "Flip", - description: "Flip the image horizontally, vertically, or both.", - docsLink: "https://imagekit.io/docs/effects-and-enhancements#flip---fl", + 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({ - flip: z.coerce - .string({ - invalid_type_error: "Should be a string.", - }) - .optional(), + edit: z.string().optional(), }) .refine( (val) => { @@ -1455,51 +2280,36 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Flip", - name: "flip", - fieldType: "checkbox-card", + label: "Edit Image using AI", + name: "edit", + fieldType: "input", isTransformation: true, - transformationKey: "fl", - transformationGroup: "flip", - helpText: - "Choose how to flip the image: horizontally, vertically, or both.", - fieldProps: { - options: [ - { - label: "Horizontal", - icon: PiFlipHorizontalFill, - value: "horizontal", - }, - { - label: "Vertical", - icon: PiFlipVerticalFill, - value: "vertical", - }, - ], - columns: 2, - defaultValue: [], - }, + 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: "adjust-radius", - name: "Radius", + key: "ai-dropshadow", + name: "Drop Shadow", description: - "Round the corners of the image. Specify a radius value to control how rounded the corners are, or use 'max' to make the image circular.", + "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/effects-and-enhancements#radius---r", + "https://imagekit.io/docs/ai-transformations#ai-drop-shadow-e-dropshadow", defaultTransformation: {}, schema: z .object({ - radius: z.union([ - z.literal("max"), - z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .min(0), - ]), + dropshadow: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), }) .refine( (val) => { @@ -1517,30 +2327,33 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Radius", - name: "radius", - fieldType: "input", + label: "Drop Shadow", + name: "dropshadow", + fieldType: "switch", isTransformation: true, - transformationKey: "r", + transformationKey: "aiDropShadow", helpText: - "Enter a positive integer for rounded corners or 'max' for a fully circular output.", - examples: ["10", "max"], + "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: "adjust-opacity", - name: "Opacity", - description: - "Adjust the opacity of the image to make it more or less transparent.", + key: "ai-retouch", + name: "Retouch", + description: "Improve the quality of the image using AI retouching.", docsLink: - "https://imagekit.io/docs/effects-and-enhancements#opacity---o", + "https://imagekit.io/docs/ai-transformations#retouch-e-retouch", defaultTransformation: {}, schema: z .object({ - opacity: z.coerce - .number({ - invalid_type_error: "Should be a number.", + retouch: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", }) .optional(), }) @@ -1560,39 +2373,32 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Opacity", - name: "opacity", - fieldType: "slider", + label: "Retouch", + name: "retouch", + fieldType: "switch", isTransformation: true, - transformationKey: "opacity", - helpText: "Enter an opacity percentage between 0 and 100.", - examples: ["50"], - fieldProps: { - min: 0, - max: 100, - step: 1, - }, + 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", - name: "AI Transformations", - items: [ { - key: "ai-removedotbg", - name: "Remove Background using Remove.bg", - // This option removes the background using the third-party remove.bg service. + key: "ai-upscale", + name: "Upscale", description: - "Remove the background of the image using Remove.bg (external service). This isolates the subject and makes the background transparent.", + "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#background-removal-e-removedotbg", + "https://imagekit.io/docs/ai-transformations#upscale-e-upscale", defaultTransformation: {}, schema: z .object({ - removedotbg: z.coerce + upscale: z.coerce .boolean({ invalid_type_error: "Should be a boolean.", }) @@ -1614,32 +2420,32 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Remove Background using Remove.bg", - name: "removedotbg", + label: "Upscale", + name: "upscale", fieldType: "switch", isTransformation: true, - transformationKey: "aiRemoveBackgroundExternal", + transformationKey: "aiUpscale", helpText: - "Toggle to remove the background using Remove.bg. Processing may take a few seconds depending on image complexity.", + "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 Remove Background using Remove.bg to {imageList.length} items. ", + "You are about to apply Upscale to {imageList.length} items. ", }, }, { - key: "ai-bgremove", - name: "Remove Background using ImageKit AI", + key: "ai-genvar", + name: "Generate Variations", description: - "Remove the background using ImageKit's built-in background removal model. This method is cost-effective compared to Remove.bg.", + "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#imagekit-background-removal-e-bgremove", + "https://imagekit.io/docs/ai-transformations#generate-variations-of-an-image-e-genvar", defaultTransformation: {}, schema: z .object({ - bgremove: z.coerce + genvar: z.coerce .boolean({ invalid_type_error: "Should be a boolean.", }) @@ -1661,32 +2467,37 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Remove Background using ImageKit AI", - name: "bgremove", + label: "Generate Variations", + name: "genvar", fieldType: "switch", isTransformation: true, - transformationKey: "aiRemoveBackground", + transformationKey: "aiVariation", helpText: - "Toggle to remove the background using ImageKit's own background removal.", + "Toggle to generate a new variation of the image using AI.", }, ], warning: { heading: "This action consumes AI credits.", message: - "You are about to apply Remove Background using ImageKit AI to {imageList.length} items. ", + "You are about to generate variations of {imageList.length} items. ", }, }, + ], + }, + { + key: "delivery", + name: "Delivery", + items: [ { - key: "ai-changebg", - name: "Change Background", + key: "delivery-format", + name: "Format", 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", + "Specify the output format for the image. Converting formats can reduce file size or improve compatibility.", + docsLink: "https://imagekit.io/docs/image-optimization#format---f", defaultTransformation: {}, schema: z .object({ - changebg: z.string().optional(), + format: z.string().optional(), }) .refine( (val) => { @@ -1704,33 +2515,36 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Change Background", - name: "changebg", - fieldType: "input", + label: "Format", + name: "format", + fieldType: "select", + fieldProps: { + options: [ + { label: "JPG", value: "jpg" }, + { label: "PNG", value: "png" }, + { label: "WEBP", value: "webp" }, + { label: "AVIF", value: "avif" }, + ], + }, isTransformation: true, - transformationKey: "aiChangeBackground", - transformationGroup: "aiChangeBackground", - helpText: "Enter a descriptive prompt for the new background.", - examples: ["snowy mountains", "sunset beach"], + transformationKey: "format", }, ], - 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", + key: "delivery-quality", + name: "Quality", 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", + "Control the compression quality of the output image. Lower values reduce file size but may introduce artefacts; higher values preserve more detail.", + docsLink: "https://imagekit.io/docs/image-optimization#quality---q", defaultTransformation: {}, schema: z .object({ - edit: z.string().optional(), + quality: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .optional(), }) .refine( (val) => { @@ -1748,35 +2562,38 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Edit Image using AI", - name: "edit", - fieldType: "input", + label: "Quality", + name: "quality", + fieldType: "slider", isTransformation: true, - transformationKey: "e-edit-prompt", - helpText: "Enter a prompt describing how to edit the image.", - examples: ["add sunglasses", "make the sky blue"], + transformationKey: "quality", + fieldProps: { + defaultValue: 80, + min: 0, + max: 100, + step: 1, + }, }, ], - 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", + key: "delivery-dpr", + name: "DPR", 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", + "Set the device pixel ratio (DPR) to deliver images optimised for high-resolution displays. A higher DPR increases the pixel density of the delivered image.", + docsLink: "https://imagekit.io/docs/image-resize-and-crop#dpr---dpr", defaultTransformation: {}, schema: z .object({ - dropshadow: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) + dpr: z + .union([ + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .optional(), + z.literal("auto"), + ]) .optional(), }) .refine( @@ -1795,91 +2612,137 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Drop Shadow", - name: "dropshadow", - fieldType: "switch", - isTransformation: true, - transformationKey: "aiDropShadow", + label: "DPR", + name: "dpr", 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: [ - { - label: "Retouch", - name: "retouch", - fieldType: "switch", + "Set this value to deliver images optimised for high-resolution displays. The value can be between 0.1 and 5.", + fieldType: "slider", isTransformation: true, - transformationKey: "aiRetouch", - helpText: - "Toggle to apply AI retouching and enhance image quality.", + transformationKey: "dpr", + fieldProps: { + defaultValue: 1, + autoOption: true, + min: 0.1, + max: 5, + step: 0.1, + }, }, ], - warning: { - heading: "This action consumes AI credits.", - message: - "You are about to apply Retouch to {imageList.length} items. ", - }, }, + ], + }, + // New Layers section: allows adding text and image overlays as layers + { + key: "layers", + name: "Layers", + items: [ { - key: "ai-upscale", - name: "Upscale", + key: "layers-text", + name: "Text Layer", description: - "Increase the resolution of low-resolution images using AI upscaling. The output can be up to 16 MP.", + "Add a text overlay on top of the base image. Specify text content, font, size, color, position and optional background or padding.", docsLink: - "https://imagekit.io/docs/ai-transformations#upscale-e-upscale", + "https://imagekit.io/docs/add-overlays-on-images#add-text-over-image", defaultTransformation: {}, schema: z .object({ - upscale: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", + text: z.string(), + width: widthValidator.optional(), + positionX: layerXValidator.optional(), + positionY: layerYValidator.optional(), + fontSize: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .optional(), + fontFamily: z.string().optional(), + color: z.string().optional(), + 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(), + }) + .optional(), + opacity: z + .union([ + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(1) + .max(10), + z.literal(""), + ]) + .optional(), + typography: z + .array(z.enum(["bold", "italic"]).optional()) + .optional(), + backgroundColor: z.string().optional(), + radius: z.union([ + z.literal("max"), + z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .min(0), + ]), + flip: z + .array(z.enum(["horizontal", "vertical"]).optional()) + .optional(), + rotation: z.coerce + .number({ + invalid_type_error: "Should be a number.", }) .optional(), }) .refine( (val) => { - if ( - Object.values(val).some((v) => v !== undefined && v !== null) - ) { - return true - } - return false + return Object.values(val).some( + (v) => v !== undefined && v !== null && v !== "", + ) }, { message: "At least one value is required", @@ -1888,308 +2751,24 @@ export const transformationSchema: TransformationSchema[] = [ ), transformations: [ { - label: "Upscale", - name: "upscale", - fieldType: "switch", + label: "Text", + name: "text", + fieldType: "input", isTransformation: true, - transformationKey: "aiUpscale", - helpText: - "Toggle to increase resolution of the image using AI upscaling (max 16 MP input).", + transformationKey: "text", + transformationGroup: "textLayer", + helpText: "Enter the text to overlay on the image.", + examples: ["Hello World"], }, - ], - 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", + label: "Width", + name: "width", + fieldType: "input", 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. ", - }, - }, - ], - }, - { - key: "delivery", - name: "Delivery", - items: [ - { - key: "delivery-format", - name: "Format", - description: - "Specify the output format for the image. Converting formats can reduce file size or improve compatibility.", - docsLink: "https://imagekit.io/docs/image-optimization#format---f", - defaultTransformation: {}, - schema: z - .object({ - format: 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: "Format", - name: "format", - fieldType: "select", - fieldProps: { - options: [ - { label: "JPG", value: "jpg" }, - { label: "PNG", value: "png" }, - { label: "WEBP", value: "webp" }, - { label: "AVIF", value: "avif" }, - ], - }, - isTransformation: true, - transformationKey: "format", - }, - ], - }, - { - key: "delivery-quality", - name: "Quality", - description: - "Control the compression quality of the output image. Lower values reduce file size but may introduce artefacts; higher values preserve more detail.", - docsLink: "https://imagekit.io/docs/image-optimization#quality---q", - defaultTransformation: {}, - schema: z - .object({ - quality: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .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: "Quality", - name: "quality", - fieldType: "slider", - isTransformation: true, - transformationKey: "quality", - fieldProps: { - defaultValue: 80, - min: 0, - max: 100, - step: 1, - }, - }, - ], - }, - { - key: "delivery-dpr", - name: "DPR", - description: - "Set the device pixel ratio (DPR) to deliver images optimised for high-resolution displays. A higher DPR increases the pixel density of the delivered image.", - docsLink: "https://imagekit.io/docs/image-resize-and-crop#dpr---dpr", - defaultTransformation: {}, - schema: z - .object({ - dpr: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .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: "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, - transformationKey: "dpr", - fieldProps: { - defaultValue: 1, - min: 0.1, - max: 5, - step: 0.1, - }, - }, - ], - }, - ], - }, - // New Layers section: allows adding text and image overlays as layers - { - key: "layers", - name: "Layers", - items: [ - { - key: "layers-text", - name: "Text Layer", - description: - "Add a text overlay on top of the base image. Specify text content, font, size, color, position and optional background or padding.", - docsLink: - "https://imagekit.io/docs/add-overlays-on-images#add-text-over-image", - defaultTransformation: {}, - schema: z - .object({ - text: z.string(), - width: widthValidator.optional(), - positionX: layerXValidator.optional(), - positionY: layerYValidator.optional(), - fontSize: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .optional(), - fontFamily: z.string().optional(), - color: z.string().optional(), - innerAlignment: z - .enum(["left", "right", "center"]) - .default("center"), - padding: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .optional(), - opacity: z - .union([ - z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .min(1) - .max(10), - z.literal(""), - ]) - .optional(), - typography: z - .array(z.enum(["bold", "italic"]).optional()) - .optional(), - backgroundColor: z.string().optional(), - radius: z.union([ - z.literal("max"), - z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .min(0), - ]), - flip: z - .array(z.enum(["horizontal", "vertical"]).optional()) - .optional(), - rotation: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .optional(), - }) - .refine( - (val) => { - return Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) - }, - { - message: "At least one value is required", - path: [], - }, - ), - transformations: [ - { - label: "Text", - name: "text", - fieldType: "input", - isTransformation: true, - transformationKey: "text", - transformationGroup: "textLayer", - helpText: "Enter the text to overlay on the image.", - examples: ["Hello World"], - }, - { - label: "Width", - name: "width", - fieldType: "input", - isTransformation: true, - transformationKey: "width", - transformationGroup: "textLayer", - helpText: "Specify the width of the overlaid text.", - examples: ["300", "bw_div_2"], + transformationKey: "width", + transformationGroup: "textLayer", + helpText: "Specify the width of the overlaid text.", + examples: ["300", "bw_div_2"], }, { label: "Position X", @@ -2199,7 +2778,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 +2788,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", @@ -2298,55 +2877,685 @@ export const transformationSchema: TransformationSchema[] = [ examples: ["FFFFFF", "FF0000"], }, { - label: "Inner Alignment", - name: "innerAlignment", - fieldType: "radio-card", - isTransformation: true, - transformationKey: "innerAlignment", - transformationGroup: "textLayer", - helpText: "Choose the alignment of the text within the text box.", + label: "Inner Alignment", + name: "innerAlignment", + fieldType: "radio-card", + isTransformation: true, + transformationKey: "innerAlignment", + transformationGroup: "textLayer", + helpText: "Choose the alignment of the text within the text box.", + fieldProps: { + options: [ + { label: "Left", icon: RxTextAlignLeft, value: "left" }, + { label: "Center", icon: RxTextAlignCenter, value: "center" }, + { label: "Right", icon: RxTextAlignRight, value: "right" }, + ], + defaultValue: "center", + }, + }, + { + label: "Padding", + name: "padding", + fieldType: "padding-input", + isTransformation: true, + transformationKey: "padding", + transformationGroup: "textLayer", + 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", + fieldType: "slider", + isTransformation: true, + transformationGroup: "textLayer", + helpText: "Set opacity for the text overlay (0-10).", + fieldProps: { + min: 1, + max: 10, + step: 1, + defaultValue: 10, + }, + }, + { + label: "Radius", + name: "radius", + fieldType: "input", + isTransformation: true, + transformationKey: "radius", + transformationGroup: "textLayer", + helpText: + "Set the radius for the corner of the text overlay. Set to 'max' for circle or oval.", + }, + { + label: "Flip", + name: "flip", + fieldType: "checkbox-card", + isTransformation: true, + transformationKey: "flip", + transformationGroup: "textLayer", + helpText: "Flip the text overlay horizontally or vertically.", + fieldProps: { + options: [ + { + label: "Horizontal", + icon: PiFlipHorizontalFill, + value: "horizontal", + }, + { + label: "Vertical", + icon: PiFlipVerticalFill, + value: "vertical", + }, + ], + columns: 2, + defaultValue: [], + }, + }, + { + label: "Rotation", + name: "rotation", + fieldType: "slider", + isTransformation: true, + transformationKey: "rotation", + transformationGroup: "textLayer", + helpText: "Rotate the text overlay (in degrees).", + fieldProps: { + min: -180, + max: 180, + step: 1, + defaultValue: "0", + }, + }, + ], + }, + { + key: "layers-image", + name: "Image Layer", + description: + "Overlay another image on top of the base image. Position, resize and set opacity for the overlaid image.", + docsLink: + "https://imagekit.io/docs/add-overlays-on-images#add-images-over-image", + defaultTransformation: {}, + schema: z + .object({ + imageUrl: z.string().optional(), + width: widthValidator.optional(), + height: heightValidator.optional(), + crop: z.string().optional(), + positionX: layerXValidator.optional(), + positionY: layerYValidator.optional(), + anchor: z.string().optional(), + opacity: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .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(), + flip: z + .array(z.enum(["horizontal", "vertical"]).optional()) + .optional(), + rotation: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .optional(), + 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.", + }) + .optional(), + blur: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .optional(), + borderWidth: commonNumberAndExpressionValidator.optional(), + borderColor: colorValidator.optional(), + // Focus + Zoom properties + 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(), + + // Gradient properties + 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(), + + // 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(), + + // 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(), + + // 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(), + sharpenEnabled: z.coerce + .boolean({ + invalid_type_error: "Should be a boolean.", + }) + .optional(), + sharpen: z.coerce + .number({ + invalid_type_error: "Should be a number.", + }) + .int() + .min(1) + .max(99) + .optional(), + unsharpenMask: z.coerce.boolean().optional(), + unsharpenMaskRadius: + optionalPositiveFloatNumberValidator.optional(), + unsharpenMaskSigma: optionalPositiveFloatNumberValidator.optional(), + unsharpenMaskAmount: + optionalPositiveFloatNumberValidator.optional(), + unsharpenMaskThreshold: + optionalPositiveFloatNumberValidator.optional(), + }) + .superRefine((val, ctx) => refineUnsharpenMask(val, ctx)) + .refine( + (val) => { + return Object.values(val).some( + (v) => v !== undefined && v !== null && v !== "", + ) + }, + { + 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: [], + }) + } + } + } + + validatePerspectiveDistort(val, ctx) + }), + transformations: [ + { + label: "Image URL", + name: "imageUrl", + fieldType: "input", + isTransformation: true, + transformationKey: "input", + transformationGroup: "imageLayer", + helpText: "Enter the URL or path of the overlay image.", + examples: ["overlay.png"], + }, + { + label: "Width", + name: "width", + fieldType: "input", + isTransformation: true, + transformationKey: "width", + transformationGroup: "imageLayer", + helpText: "Specify the width of the overlay image.", + examples: ["100", "iw_div_2"], + }, + { + label: "Height", + name: "height", + fieldType: "input", + isTransformation: true, + transformationKey: "height", + transformationGroup: "imageLayer", + helpText: "Specify the height of the overlay image.", + examples: ["100", "ih_div_2"], + }, + { + label: "Crop", + name: "crop", + fieldType: "select", + isTransformation: true, + transformationKey: "crop", + transformationGroup: "imageLayer", + helpText: "Crop the overlay image.", + fieldProps: { + options: [ + { label: "Select one", value: "" }, + { label: "Force", value: "c-force" }, + { label: "At max", value: "c-at_max" }, + { label: "At least", value: "c-at_least" }, + { label: "Extract", value: "cm-extract" }, + { label: "Pad Resize", value: "cm-pad_resize" }, + ], + 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: "Zoom", + name: "zoom", + fieldType: "zoom", + isTransformation: true, + transformationGroup: "imageLayer", + 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", + }, + { + label: "Position X", + name: "positionX", + fieldType: "input", + isTransformation: true, + transformationKey: "x", + transformationGroup: "imageLayer", + helpText: "Specify the horizontal offset for the overlay image.", + examples: ["10", "-20", "N30", "bw_div_2"], + }, + { + label: "Position Y", + name: "positionY", + fieldType: "input", + isTransformation: true, + transformationKey: "y", + transformationGroup: "imageLayer", + helpText: "Specify the vertical offset for the overlay image.", + examples: ["10", "-20", "N30", "bh_div_2"], + }, + { + label: "Adjust DPR", + name: "dprEnabled", + fieldType: "switch", + isTransformation: false, + transformationGroup: "imageLayer", + transformationKey: "dprEnabled", + helpText: "Adjust the DPR of the overlay image.", fieldProps: { - options: [ - { label: "Left", icon: RxTextAlignLeft, value: "left" }, - { label: "Center", icon: RxTextAlignCenter, value: "center" }, - { label: "Right", icon: RxTextAlignRight, value: "right" }, - ], - defaultValue: "center", + defaultValue: false, }, }, { - label: "Padding", - name: "padding", - fieldType: "input", + 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, - transformationKey: "padding", - transformationGroup: "textLayer", - helpText: "Specify padding around the text (in pixels).", - examples: ["10", "20"], + 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", fieldType: "slider", isTransformation: true, - transformationGroup: "textLayer", - helpText: "Set opacity for the text overlay (0-10).", + transformationKey: "opacity", + transformationGroup: "imageLayer", + helpText: "Set the opacity for the overlay image (0-100).", + examples: ["80"], fieldProps: { - min: 1, - max: 10, + min: 0, + max: 100, step: 1, - defaultValue: 10, + defaultValue: 100, }, }, + { + label: "Background Color", + name: "backgroundColor", + fieldType: "color-picker", + isTransformation: true, + transformationKey: "background", + transformationGroup: "imageLayer", + helpText: "Set a background color for the overlay image.", + }, { label: "Radius", name: "radius", - fieldType: "input", + fieldType: "radius-input", isTransformation: true, - transformationKey: "radius", - transformationGroup: "textLayer", + transformationGroup: "imageLayer", helpText: - "Set the radius for the corner of the text overlay. Set to 'max' for circle or oval.", + "Set the corner radius for the overlay image. Use 'max' for a circle or oval.", + examples: ["10", "max"], + fieldProps: { + defaultValue: {}, + }, }, { label: "Flip", @@ -2354,8 +3563,8 @@ export const transformationSchema: TransformationSchema[] = [ fieldType: "checkbox-card", isTransformation: true, transformationKey: "flip", - transformationGroup: "textLayer", - helpText: "Flip the text overlay horizontally or vertically.", + transformationGroup: "imageLayer", + helpText: "Flip the overlay image horizontally or vertically.", fieldProps: { options: [ { @@ -2379,8 +3588,8 @@ export const transformationSchema: TransformationSchema[] = [ fieldType: "slider", isTransformation: true, transformationKey: "rotation", - transformationGroup: "textLayer", - helpText: "Rotate the text overlay (in degrees).", + transformationGroup: "imageLayer", + helpText: "Rotate the overlay image (in degrees).", fieldProps: { min: -180, max: 180, @@ -2388,262 +3597,360 @@ export const transformationSchema: TransformationSchema[] = [ defaultValue: "0", }, }, - ], - }, - { - key: "layers-image", - name: "Image Layer", - description: - "Overlay another image on top of the base image. Position, resize and set opacity for the overlaid image.", - docsLink: - "https://imagekit.io/docs/add-overlays-on-images#add-images-over-image", - defaultTransformation: {}, - schema: z - .object({ - imageUrl: z.string().optional(), - width: widthValidator.optional(), - height: heightValidator.optional(), - crop: z.string().optional(), - positionX: layerXValidator.optional(), - positionY: layerYValidator.optional(), - anchor: z.string().optional(), - opacity: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .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(), - rotation: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .optional(), - trim: z.coerce - .boolean({ - invalid_type_error: "Should be a boolean.", - }) - .optional(), - quality: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .optional(), - blur: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .optional(), - }) - .refine( - (val) => { - return Object.values(val).some( - (v) => v !== undefined && v !== null && v !== "", - ) + { + label: "Trim", + name: "trimEnabled", + fieldType: "switch", + isTransformation: false, + transformationKey: "trimEnabled", + transformationGroup: "imageLayer", + helpText: "Control trimming of the overlay image.", + fieldProps: { + defaultValue: true, }, - { - message: "At least one value is required", - path: [], + }, + { + 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, }, - ), - transformations: [ + isVisible: ({ trimEnabled }) => trimEnabled === true, + }, { - label: "Image URL", - name: "imageUrl", - fieldType: "input", + label: "Quality", + name: "quality", + fieldType: "slider", isTransformation: true, - transformationKey: "input", + transformationKey: "quality", transformationGroup: "imageLayer", - helpText: "Enter the URL or path of the overlay image.", - examples: ["overlay.png"], + helpText: "Set the compression quality of the overlay image.", + fieldProps: { + min: 0, + max: 100, + step: 1, + defaultValue: 80, + }, }, { - label: "Width", - name: "width", - fieldType: "input", + label: "Blur", + name: "blur", + fieldType: "slider", isTransformation: true, - transformationKey: "width", + transformationKey: "blur", transformationGroup: "imageLayer", - helpText: "Specify the width of the overlay image.", - examples: ["100", "iw_div_2"], + helpText: "Apply a Gaussian blur to the overlay image.", + fieldProps: { + min: 1, + max: 100, + step: 1, + defaultValue: "0", + }, + }, + { + label: "Border Width", + name: "borderWidth", + fieldType: "input", + isTransformation: false, + transformationKey: "borderWidth", + transformationGroup: "imageLayer", + fieldProps: { + defaultValue: "", + }, + helpText: + "Enter the width of the border or expression of the overlay image.", + examples: ["10", "ch_div_2"], + }, + { + label: "Border Color", + name: "borderColor", + fieldType: "color-picker", + isTransformation: false, + transformationKey: "borderColor", + transformationGroup: "imageLayer", + isVisible: ({ borderWidth }) => (borderWidth as string) !== "", + helpText: "Select the color of the border of the overlay image.", + fieldProps: { + hideOpacity: true, + showHexAlpha: false, + defaultValue: "#000000", + }, + }, + { + label: "Sharpen Overlay", + name: "sharpenEnabled", + fieldType: "switch", + isTransformation: false, + transformationKey: "sharpenEnabled", + transformationGroup: "imageLayer", + helpText: + "Toggle to sharpen the overlay image to highlight edges and fine details.", + fieldProps: { + defaultValue: false, + }, + }, + { + label: "Sharpen Threshold", + name: "sharpen", + fieldType: "slider", + isTransformation: false, + transformationKey: "sharpen", + transformationGroup: "imageLayer", + helpText: + "Sharpen the overlay image. Control the intensity of this effect using a threshold value between 1% and 99%.", + fieldProps: { + min: 1, + defaultValue: 50, + max: 99, + step: 1, + }, + isVisible: ({ sharpenEnabled }) => sharpenEnabled === true, + }, + { + name: "unsharpenMask", + fieldType: "switch", + isTransformation: false, + transformationGroup: "imageLayer", + helpText: + "Toggle to unsharpen the overlay image to remove the edges and finer details within an image.", + fieldProps: { + defaultValue: false, + }, + label: "Unsharpen Mask", + }, + { + name: "unsharpenMaskRadius", + fieldType: "input", + label: "Radius", + isTransformation: false, + transformationGroup: "imageLayer", + helpText: + "Controls how wide the sharpening effect spreads from each edge. Larger values affect broader areas; smaller values focus on fine details.", + fieldProps: { + defaultValue: "", + }, + examples: ["1", "8", "15"], + isVisible: ({ unsharpenMask }) => unsharpenMask === true, + }, + { + name: "unsharpenMaskSigma", + fieldType: "input", + label: "Sigma", + isTransformation: false, + transformationGroup: "imageLayer", + helpText: + "Defines the amount of blur used to detect edges before sharpening. Higher values smooth more before sharpening; lower values preserve fine textures.", + fieldProps: { + defaultValue: "", + }, + examples: ["1", "5", "6"], + isVisible: ({ unsharpenMask }) => unsharpenMask === true, }, { - label: "Height", - name: "height", + name: "unsharpenMaskAmount", fieldType: "input", - isTransformation: true, - transformationKey: "height", + label: "Amount", + isTransformation: false, transformationGroup: "imageLayer", - helpText: "Specify the height of the overlay image.", - examples: ["100", "ih_div_2"], + helpText: "Sets the strength of the sharpening effect.", + fieldProps: { + defaultValue: "", + }, + examples: ["0.1", "2", "0.8"], + isVisible: ({ unsharpenMask }) => unsharpenMask === true, }, { - label: "Crop", - name: "crop", - fieldType: "select", - isTransformation: true, - transformationKey: "crop", + name: "unsharpenMaskThreshold", + fieldType: "input", + label: "Threshold", + isTransformation: false, transformationGroup: "imageLayer", - helpText: "Crop the overlay image.", + helpText: "Set the threshold value for the unsharpen mask.", fieldProps: { - options: [ - { label: "Select one", value: "" }, - { label: "Force", value: "c-force" }, - { label: "At max", value: "c-at_max" }, - { label: "At least", value: "c-at_least" }, - { label: "Extract", value: "cm-extract" }, - { label: "Pad Resize", value: "cm-pad_resize" }, - ], defaultValue: "", }, + examples: ["0.1", "2", "0.8"], + isVisible: ({ unsharpenMask }) => unsharpenMask === true, }, + { - label: "Position X", - name: "positionX", - fieldType: "input", + 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: "x", + transformationKey: "gradient", transformationGroup: "imageLayer", - helpText: "Specify the horizontal offset for the overlay image.", - examples: ["10"], + isVisible: ({ gradientSwitch }) => gradientSwitch === true, + fieldProps: { + defaultValue: { + from: "#FFFFFFFF", + to: "#00000000", + direction: "bottom", + stopPoint: 100, + }, + }, }, { - label: "Position Y", - name: "positionY", - fieldType: "input", + label: "Shadow", + name: "shadow", + fieldType: "switch", isTransformation: true, - transformationKey: "y", transformationGroup: "imageLayer", - helpText: "Specify the vertical offset for the overlay image.", - examples: ["10"], + helpText: + "Toggle to add a non-AI shadow under objects in the overlay image.", }, { - label: "Opacity", - name: "opacity", + label: "Blur", + name: "shadowBlur", fieldType: "slider", isTransformation: true, - transformationKey: "opacity", transformationGroup: "imageLayer", - helpText: "Set the opacity for the overlay image (0-100).", - examples: ["80"], + helpText: + "Set the blur radius for the shadow. Higher values create a softer shadow.", fieldProps: { min: 0, - max: 100, + max: 15, step: 1, - defaultValue: 100, + defaultValue: 10, }, + isVisible: ({ shadow }) => shadow === true, }, { - label: "Background Color", - name: "backgroundColor", - fieldType: "color-picker", - isTransformation: true, - transformationKey: "background", - transformationGroup: "imageLayer", - helpText: "Set a background color for the overlay image.", - }, - { - label: "Radius", - name: "radius", - fieldType: "input", + label: "Saturation", + name: "shadowSaturation", + fieldType: "slider", isTransformation: true, - transformationKey: "radius", transformationGroup: "imageLayer", helpText: - "Set the corner radius for the overlay image. Use 'max' for a circle or oval.", + "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: "Flip", - name: "flip", - fieldType: "checkbox-card", + label: "X Offset", + name: "shadowOffsetX", + fieldType: "slider", isTransformation: true, - transformationKey: "flip", transformationGroup: "imageLayer", - helpText: "Flip the overlay image horizontally or vertically.", + helpText: + "Enter the horizontal offset as a percentage of the overlay image width.", + isVisible: ({ shadow }) => shadow === true, fieldProps: { - options: [ - { - label: "Horizontal", - icon: PiFlipHorizontalFill, - value: "horizontal", - }, - { - label: "Vertical", - icon: PiFlipVerticalFill, - value: "vertical", - }, - ], - columns: 2, - defaultValue: [], + min: -100, + max: 100, + step: 1, + defaultValue: 2, }, }, { - label: "Rotation", - name: "rotation", + label: "Y Offset", + name: "shadowOffsetY", fieldType: "slider", isTransformation: true, - transformationKey: "rotation", transformationGroup: "imageLayer", - helpText: "Rotate the overlay image (in degrees).", + helpText: + "Enter the vertical offset as a percentage of the overlay image height.", + isVisible: ({ shadow }) => shadow === true, fieldProps: { - min: -180, - max: 180, + min: -100, + max: 100, step: 1, - defaultValue: "0", + defaultValue: 2, }, }, { - label: "Trim", - name: "trim", + label: "Grayscale", + name: "grayscale", fieldType: "switch", isTransformation: true, - transformationKey: "trim", + transformationKey: "grayscale", transformationGroup: "imageLayer", - helpText: "Control trimming of the overlay image.", + 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: { - defaultValue: true, + options: [ + { label: "Perspective", value: "perspective" }, + { label: "Arc", value: "arc" }, + ], + defaultValue: "perspective", }, }, { - label: "Quality", - name: "quality", - fieldType: "slider", - isTransformation: true, - transformationKey: "quality", + label: "Distortion Perspective", + name: "distortPerspective", + fieldType: "distort-perspective-input", + isTransformation: false, transformationGroup: "imageLayer", - helpText: "Set the compression quality of the overlay image.", + isVisible: ({ distort, distortType }) => + distort === true && distortType === "perspective", fieldProps: { - min: 0, - max: 100, - step: 1, - defaultValue: 80, + defaultValue: { + x1: "", + y1: "", + x2: "", + y2: "", + x3: "", + y3: "", + x4: "", + y4: "", + }, }, }, { - label: "Blur", - name: "blur", + label: "Distortion Arc Degrees", + name: "distortArcDegree", fieldType: "slider", isTransformation: true, - transformationKey: "blur", transformationGroup: "imageLayer", - helpText: "Apply a Gaussian blur to the overlay image.", + isVisible: ({ distort, distortType }) => + distort === true && distortType === "arc", + helpText: "Enter the arc degree for the arc distortion effect.", + examples: ["15", "30", "-45", "N50"], fieldProps: { - min: 1, - max: 100, - step: 1, + min: -360, + max: 360, + step: 5, defaultValue: "0", + inputType: "text", + skipStepCheck: true, }, }, ], @@ -2703,15 +4010,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" @@ -2746,7 +4078,17 @@ 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, + zoom, + } = values if (focus === "auto" || focus === "face") { transforms.focus = focus @@ -2759,10 +4101,21 @@ 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 + } + } + if ( + zoom !== undefined && + zoom !== null && + !Number.isNaN(Number(zoom)) && + zoom !== 0 + ) { + transforms.zoom = (zoom as number) / 100 } }, shadow: (values, transforms) => { @@ -2793,24 +4146,28 @@ export const transformationFormatters: Record< if ( shadowOffsetX !== undefined && shadowOffsetX !== null && - shadowOffsetX !== "" + shadowOffsetX !== "" && + typeof shadowOffsetX === "number" ) { - 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 if ( shadowOffsetY !== undefined && shadowOffsetY !== null && - shadowOffsetY !== "" + shadowOffsetY !== "" && + typeof shadowOffsetY === "number" ) { - 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 @@ -2856,11 +4213,38 @@ export const transformationFormatters: Record< const bg = (values.backgroundColor as string).replace(/^#/, "") overlayTransform.background = bg } + const { padding, mode } = values.padding as Record + if ( + mode === "uniform" && + (typeof padding === "number" || typeof padding === "string") + ) { + 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 + 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 ( - typeof values.padding === "number" || - typeof values.padding === "string" + typeof values.lineHeight === "number" || + typeof values.lineHeight === "string" ) { - overlayTransform.padding = values.padding + overlayTransform.lineHeight = values.lineHeight } if (Array.isArray(values.flip) && values.flip.length > 0) { @@ -2926,13 +4310,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 @@ -2983,12 +4367,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")) { @@ -3013,10 +4391,19 @@ export const transformationFormatters: Record< overlayTransform.rotation = values.rotation } - if (typeof values.trim === "boolean") { - overlayTransform.trim = values.trim + if (values.unsharpenMask === true) { + overlayTransform["e-usm"] = + `${values.unsharpenMaskRadius}-${values.unsharpenMaskSigma}-${values.unsharpenMaskAmount}-${values.unsharpenMaskThreshold}` + } + if ( + values.trimEnabled === true && + typeof values.trimThreshold === "number" + ) { + overlayTransform.t = values.trimThreshold + } + if (values.dpr && values.dprEnabled === true) { + overlayTransform.dpr = values.dpr } - if (values.quality) { overlayTransform.quality = values.quality } @@ -3025,6 +4412,36 @@ export const transformationFormatters: Record< overlayTransform.blur = values.blur } + if (values.sharpenEnabled === true) { + if (values.sharpen === 50) { + overlayTransform.sharpen = "" + } else { + overlayTransform.sharpen = values.sharpen + } + } + if ( + values.borderWidth && + values.borderColor && + typeof values.borderColor === "string" + ) { + overlayTransform.b = `${values.borderWidth}_${values.borderColor.replace(/^#/, "")}` + } + const { crop, focusAnchor } = values + + transformationFormatters.focus(values, overlayTransform) + if (crop === "cm-pad_resize") { + overlayTransform.focus = focusAnchor + } + + transformationFormatters.gradient(values, overlayTransform) + transformationFormatters.shadow(values, overlayTransform) + transformationFormatters.distort(values, overlayTransform) + transformationFormatters.radius(values, overlayTransform) + + if (values.grayscale) { + overlayTransform.grayscale = true + } + if (Object.keys(overlayTransform).length > 0) { overlay.transformation = [overlayTransform] } @@ -3032,10 +4449,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) { @@ -3058,6 +4475,21 @@ export const transformationFormatters: Record< transforms.flip = flip.join("_") } }, + trim: (values, transforms) => { + const { trimEnabled, trim } = values as { + trimEnabled?: boolean + trim?: "default" | number + } + if (!trimEnabled) return + + // Numeric threshold 1–99 + if (typeof trim === "number") { + const threshold = Math.trunc(trim) + if (threshold >= 1 && threshold <= 99) { + transforms.trim = threshold + } + } + }, aiChangeBackground: (values, transforms) => { if (values.changebg) { if (SIMPLE_OVERLAY_TEXT_REGEX.test(values.changebg as string)) { @@ -3078,4 +4510,198 @@ export const transformationFormatters: Record< transforms.rotation = "auto" } }, + colorReplace: (values, transforms) => { + const { fromColor, toColor, tolerance } = values as { + fromColor?: string + toColor?: string + tolerance?: number + } + + // Color replace requires at least toColor + if (!toColor || toColor === "") return + + const params: string[] = [] + + // Remove # from colors if present + const cleanToColor = (toColor as string).replace(/^#/, "") + params.push(cleanToColor) + if (tolerance !== undefined && tolerance !== null) { + params.push(String(tolerance)) + } + // Check if fromColor is provided and not empty + if (fromColor && fromColor !== "") { + const cleanFromColor = (fromColor as string).replace(/^#/, "") + params.push(cleanFromColor) + } + + transforms.cr = params.join("_") + }, + border: (values, transforms) => { + const { borderWidth, borderColor } = values as { + borderWidth?: string + borderColor?: string + } + if (!borderWidth || !borderColor) return + const cleanBorderColor = borderColor.replace(/^#/, "") + transforms.b = `${borderWidth}_${cleanBorderColor}` + }, + sharpen: (values, transforms) => { + const { sharpenEnabled, sharpen } = values as { + sharpenEnabled?: boolean + sharpen: number + } + if (!sharpenEnabled) return + if (sharpen === 50) { + transforms.sharpen = "" + } else { + transforms.sharpen = sharpen + } + }, + unsharpenMask: (values, transforms) => { + const { + unsharpenMaskRadius, + unsharpenMaskSigma, + unsharpenMaskAmount, + unsharpenMaskThreshold, + } = values as { + unsharpenMaskRadius: number + unsharpenMaskSigma: number + unsharpenMaskAmount: number + unsharpenMaskThreshold: number + } + transforms["e-usm"] = + `${unsharpenMaskRadius}-${unsharpenMaskSigma}-${unsharpenMaskAmount}-${unsharpenMaskThreshold}` + }, + gradient: (values, transforms) => { + 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" && + (direction === "bottom" || direction === 180) && + stopPoint === 100 + if (isDefaultGradient) { + transforms.gradient = "" + } else { + const fromColor = from.replace("#", "") + const toColor = to.replace("#", "") + const stopPointDecimal = (stopPoint as number) / 100 + const 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< + 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") + ) { + 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}` + } + } + } + }, +} + +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 = JSON.parse( + JSON.stringify(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 { 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"], + }) + } + } + } } 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/schema/transformation.ts b/packages/imagekit-editor-dev/src/schema/transformation.ts index 78eb69c..100d55f 100644 --- a/packages/imagekit-editor-dev/src/schema/transformation.ts +++ b/packages/imagekit-editor-dev/src/schema/transformation.ts @@ -77,11 +77,7 @@ 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.", - }) +const layerXNumber = z.coerce.string().regex(/^[N-]?\d+(\.\d{1,2})?$/) const layerXExpr = z .string() @@ -97,15 +93,11 @@ 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.", - }) +const layerYNumber = z.coerce.string().regex(/^[N-]?\d+(\.\d{1,2})?$/) const layerYExpr = z .string() @@ -121,6 +113,104 @@ 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.", + }) +}) + +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(), +) + +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({ + 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"], + }) + } + } +} diff --git a/packages/imagekit-editor-dev/src/store.ts b/packages/imagekit-editor-dev/src/store.ts index a66ca1f..9756b1c 100644 --- a/packages/imagekit-editor-dev/src/store.ts +++ b/packages/imagekit-editor-dev/src/store.ts @@ -11,7 +11,9 @@ import { type TransformationField, transformationFormatters, transformationSchema, + getDefaultTransformationFromMode, } from "./schema" +import { extractImagePath } from "./utils" export interface Transformation { id: string @@ -461,6 +463,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[], @@ -558,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, } }) @@ -576,9 +605,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 +623,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] || '' + } +}