diff --git a/packages/imagekit-editor-dev/src/components/common/AnchorField.tsx b/packages/imagekit-editor-dev/src/components/common/AnchorField.tsx index 885f919..792814b 100644 --- a/packages/imagekit-editor-dev/src/components/common/AnchorField.tsx +++ b/packages/imagekit-editor-dev/src/components/common/AnchorField.tsx @@ -78,7 +78,12 @@ const AnchorField: React.FC = ({ minWidth="0" p="0" isDisabled={!positions.includes(position.value)} - onClick={() => onChange(position.value)} + onClick={() => { + if (value === position.value) { + return onChange("") + } + onChange(position.value) + }} borderRadius="md" border={ value === position.value diff --git a/packages/imagekit-editor-dev/src/components/common/CornerRadiusInput.tsx b/packages/imagekit-editor-dev/src/components/common/CornerRadiusInput.tsx new file mode 100644 index 0000000..22b2c8e --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/CornerRadiusInput.tsx @@ -0,0 +1,217 @@ +import { + Box, + Flex, + HStack, + Icon, + Text, + Input, + InputGroup, + InputLeftElement, + IconButton, + useColorModeValue, + Tooltip, +} from "@chakra-ui/react" +import { set } from "lodash" +import type * as React from "react" +import { useState, useEffect, forwardRef } from "react" +import { RxCornerTopLeft } from "@react-icons/all-files/rx/RxCornerTopLeft" +import { RxCornerTopRight } from "@react-icons/all-files/rx/RxCornerTopRight" +import { RxCornerBottomRight } from "@react-icons/all-files/rx/RxCornerBottomRight" +import { RxCornerBottomLeft } from "@react-icons/all-files/rx/RxCornerBottomLeft" +import { TbBorderCorners } from "@react-icons/all-files/tb/TbBorderCorners" +import { FieldErrors } from "react-hook-form" + +type RadiusMode = "uniform" | "individual" + +export type RadiusState = { + mode: RadiusMode + radius: RadiusObject | string +} + +type RadiusInputFieldProps = { + id?: string + onChange: (value: RadiusState) => void + errors?: FieldErrors> + name: string, + value?: Partial +} + +export type RadiusObject = { + topLeft: string | "max" + topRight: string | "max" + bottomRight: string | "max" + bottomLeft: string | "max" +} + +type RadiusDirection = "topLeft" | "topRight" | "bottomRight" | "bottomLeft" + +function getUpdatedRadiusValue( + current: RadiusObject | string, + corner: RadiusDirection | "all", + value: string, + mode: "uniform" | "individual" +): RadiusObject | string { + let inputValue: RadiusObject | number | string + try { + inputValue = JSON.parse(value) + } catch { + inputValue = value + } + if (mode === "uniform") { + if (inputValue === "") { + return "" + } else if (typeof inputValue === "string" || typeof inputValue === "number") { + return inputValue.toString() + } else { + const { topLeft, topRight, bottomRight, bottomLeft } = inputValue + if (topLeft === topRight && topLeft === bottomRight && topLeft === bottomLeft) { + return topLeft + } else { + return ""; + } + } + } else { + let commonValue: string = "" + if (typeof inputValue === "string" || typeof inputValue === "number") { + commonValue = inputValue.toString() + } + const updatedRadius = current && typeof current === "object" + ? { ...current } + : { topLeft: commonValue, topRight: commonValue, bottomRight: commonValue, bottomLeft: commonValue } + if (corner !== "all") { + set(updatedRadius, corner, inputValue.toString()) + } + return updatedRadius + } +} + +export const RadiusInputField: React.FC = ({ + id, + onChange, + errors, + name: propertyName, + value +}) => { + const [radiusMode, setRadiusMode] = useState(value?.mode ?? "uniform") + const [radiusValue, setRadiusValue] = useState(value?.radius ?? "") + const errorRed = useColorModeValue("red.500", "red.300") + const activeColor = useColorModeValue("blue.500", "blue.600") + const inactiveColor = useColorModeValue("gray.600", "gray.400") + + useEffect(() => { + const formatRadiusValue = (value: RadiusObject | string): string | RadiusObject => { + if (value === "") return "" + if (typeof value === "string") { + return value + } else { + return value; + } + } + const formattedValue = formatRadiusValue(radiusValue) + onChange({ mode: radiusMode, radius: formattedValue }) + }, [radiusValue, radiusMode]) + + + return ( + + + {radiusMode === "uniform" ? ( + + { + const val = e.target.value + setRadiusValue(getUpdatedRadiusValue( + radiusValue, + "all", + val, + radiusMode + )) + }} + value={typeof radiusValue === "string" ? radiusValue : ""} + placeholder="Uniform Radius" + isInvalid={!!errors?.[propertyName]?.radius} + fontSize="sm" + /> + {errors?.[propertyName]?.radius?.message} + + ) : ( + <> + {[ + { name: "topLeft", label: "Top Left", icon: RxCornerTopLeft }, + { name: "topRight", label: "Top Right", icon: RxCornerTopRight }, + { name: "bottomLeft", label: "Bottom Left", icon: RxCornerBottomLeft }, + { name: "bottomRight", label: "Bottom Right", icon: RxCornerBottomRight }, + ].map(({ name, label, icon }) => ( + + + + + + { + const val = e.target.value + setRadiusValue(getUpdatedRadiusValue( + radiusValue, + name as RadiusDirection, + val, + radiusMode + )) + }} + value={typeof radiusValue === "object" ? radiusValue?.[name as RadiusDirection] ?? "" : ""} + placeholder={label} + isInvalid={!!errors?.[propertyName]?.radius?.[name as RadiusDirection]} + fontSize="sm" + /> + + {errors?.[propertyName]?.radius?.[name as RadiusDirection]?.message} + + ))} + + )} + + + } + padding="0.05em" + onClick={() => { + const newRadiusMode = radiusMode === "uniform" ? "individual" : "uniform" + setRadiusValue(getUpdatedRadiusValue( + radiusValue, + "all", + JSON.stringify(radiusValue), + newRadiusMode + )) + setRadiusMode(newRadiusMode) + }} + variant="outline" + color={radiusMode === "individual" ? activeColor : inactiveColor} + /> + + + ) +} + +export default RadiusInputField diff --git a/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx b/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx new file mode 100644 index 0000000..6ae4210 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/DistortPerspectiveInput.tsx @@ -0,0 +1,245 @@ +import { + Box, + Flex, + HStack, + VStack, + Icon, + Text, + Input, + InputGroup, + InputLeftElement, + IconButton, + useColorModeValue, + Tooltip, +} from "@chakra-ui/react"; +import type * as React from "react"; +import { useState, useEffect } from "react"; +import { RxArrowTopLeft } from "@react-icons/all-files/rx/RxArrowTopLeft"; +import { RxArrowTopRight } from "@react-icons/all-files/rx/RxArrowTopRight"; +import { RxArrowBottomRight } from "@react-icons/all-files/rx/RxArrowBottomRight"; +import { RxArrowBottomLeft } from "@react-icons/all-files/rx/RxArrowBottomLeft"; +import { FieldErrors } from "react-hook-form"; + +type DistorPerspectiveFieldProps = { + name: string; + id?: string; + onChange: (value: PerspectiveObject) => void; + errors?: FieldErrors>; + value?: PerspectiveObject; +}; + +export type PerspectiveObject = { + x1: string; + y1: string; + x2: string; + y2: string; + x3: string; + y3: string; + x4: string; + y4: string; +}; + +export const DistortPerspectiveInput: React.FC = ({ + id, + onChange, + errors, + name: propertyName, + value, +}) => { + const [perspective, setPerspective] = useState(value ?? { + x1: "", + y1: "", + x2: "", + y2: "", + x3: "", + y3: "", + x4: "", + y4: "", + }); + const errorRed = useColorModeValue("red.500", "red.300"); + const leftAccessoryBackground = useColorModeValue("gray.100", "gray.700"); + + function handleFieldChange(fieldName: string) { + return (e: React.ChangeEvent) => { + const val = e.target.value.trim(); + setPerspective((prev) => ({ + ...prev, + [fieldName]: val, + })); + }; + } + + useEffect(() => { + onChange(perspective); + }, [perspective]); + + return ( + + + + + + + + + + + {[ + errors?.[propertyName]?.x1?.message, + errors?.[propertyName]?.y1?.message, + ] + .filter(Boolean) + .join(". ")} + + + + + + + + + + + + + {[ + errors?.[propertyName]?.x2?.message, + errors?.[propertyName]?.y2?.message, + ] + .filter(Boolean) + .join(". ")} + + + + + + + + + + + + + {[ + errors?.[propertyName]?.x3?.message, + errors?.[propertyName]?.y3?.message, + ] + .filter(Boolean) + .join(". ")} + + + + + + + + + + + + + {[ + errors?.[propertyName]?.x4?.message, + errors?.[propertyName]?.y4?.message, + ] + .filter(Boolean) + .join(". ")} + + + + ); +}; + +export default DistortPerspectiveInput; diff --git a/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx b/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx new file mode 100644 index 0000000..8cfb545 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx @@ -0,0 +1,375 @@ +import { + Flex, + Input, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + FormLabel, + Box, + Text, + useColorModeValue, +} from "@chakra-ui/react"; +import { memo, useEffect, useState, useMemo } from "react"; +import ColorPicker, { useColorPicker } from "react-best-gradient-color-picker"; +import { useDebounce } from "../../hooks/useDebounce"; +import AnchorField from "./AnchorField"; +import RadioCardField from "./RadioCardField"; +import { TbAngle } from "@react-icons/all-files/tb/TbAngle"; +import { BsArrowsMove } from "@react-icons/all-files/bs/BsArrowsMove"; +import { FieldErrors } from "react-hook-form"; + +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 (!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 (!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 (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; + try { + gradientObj = getGradientObject(cleanedGradient); + } catch (error) { + return; + } + + if (!gradientObj || !gradientObj.isGradient) return; + + const { colors } = gradientObj; + if (colors.length !== 2) return; + if (colors[0].left !== 0) return; + setGradient(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..09f7929 100644 --- a/packages/imagekit-editor-dev/src/components/common/Hover.tsx +++ b/packages/imagekit-editor-dev/src/components/common/Hover.tsx @@ -1,5 +1,5 @@ import { Box, type BoxProps, Flex, type FlexProps } from "@chakra-ui/react" -import { useState } from "react" +import { useState, useEffect, useRef, useCallback } from "react" interface FlexHoverProps extends FlexProps { children(isHover: boolean): JSX.Element @@ -15,6 +15,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..58ad486 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx @@ -0,0 +1,226 @@ +import { + Box, + Flex, + HStack, + Icon, + Text, + Input, + InputGroup, + InputLeftElement, + IconButton, + useColorModeValue, + Tooltip, +} from "@chakra-ui/react" +import { set } from "lodash" +import type * as React from "react" +import { useState, useEffect, forwardRef } from "react" +import { LuArrowLeftToLine } from "@react-icons/all-files/lu/LuArrowLeftToLine" +import { LuArrowRightToLine } from "@react-icons/all-files/lu/LuArrowRightToLine" +import { LuArrowUpToLine } from "@react-icons/all-files/lu/LuArrowUpToLine" +import { LuArrowDownToLine } from "@react-icons/all-files/lu/LuArrowDownToLine" +import { TbBoxPadding } from "@react-icons/all-files/tb/TbBoxPadding" +import { FieldErrors } from "react-hook-form" + +type PaddingMode = "uniform" | "individual" + +type PaddingDirection = "top" | "right" | "bottom" | "left" + +export type PaddingState = { + mode: PaddingMode + padding: number | PaddingObject | null | string +} + +type PaddingInputFieldProps = { + id?: string + onChange: (value: PaddingState) => void + errors?: FieldErrors> + name: string, + value?: Partial +} + +export type PaddingObject = { + top: number | null + right: number | null + bottom: number | null + left: number | null +} + +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(value?.padding ?? "") + const errorRed = useColorModeValue("red.500", "red.300") + const activeColor = useColorModeValue("blue.500", "blue.600") + const inactiveColor = useColorModeValue("gray.600", "gray.400") + + useEffect(() => { + const formatPaddingValue = (value: number | PaddingObject | null | string): string | PaddingObject => { + 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 ( + + + {paddingMode === "uniform" ? ( + + { + const val = e.target.value + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + "all", + val, + paddingMode + )) + }} + value={["number", "string"].includes(typeof paddingValue) ? paddingValue : ""} + placeholder="Uniform Padding" + isInvalid={!!errors?.[propertyName]?.padding} + fontSize="sm" + /> + {errors?.[propertyName]?.padding?.message} + + ) : ( + <> + {[ + { name: "top", label: "Top", icon: LuArrowUpToLine }, + { name: "right", label: "Right", icon: LuArrowRightToLine }, + { name: "bottom", label: "Bottom", icon: LuArrowDownToLine }, + { name: "left", label: "Left", icon: LuArrowLeftToLine }, + ].map(({ name, label, icon }) => ( + + + + + + { + const val = e.target.value + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + name as PaddingDirection, + val, + paddingMode + )) + }} + value={typeof paddingValue === "object" ? paddingValue?.[name as PaddingDirection] ?? "" : ""} + placeholder={label} + isInvalid={!!errors?.[propertyName]?.padding?.[name as PaddingDirection]} + fontSize="sm" + /> + + {errors?.[propertyName]?.padding?.[name as PaddingDirection]?.message} + + )) + } + + )} + + + } + 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/ZoomInput.tsx b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx new file mode 100644 index 0000000..3738483 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx @@ -0,0 +1,131 @@ +import { + HStack, + Input, + InputGroup, + InputRightElement, + IconButton, + ButtonGroup, + Text, + useColorModeValue, +} from "@chakra-ui/react" +import type * as React from "react" +import { useState, useEffect } from "react" +import { AiOutlinePlus } from "@react-icons/all-files/ai/AiOutlinePlus" +import { AiOutlineMinus } from "@react-icons/all-files/ai/AiOutlineMinus" + +type ZoomInputFieldProps = { + id?: string + onChange: (value: number) => void + defaultValue?: number + 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) + const [inputValue, setInputValue] = useState((value ?? defaultValue).toString()) + + useEffect(() => { + onChange(zoomValue) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [zoomValue]) + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value + setInputValue(value) + + const numValue = Number(value) + if (!isNaN(numValue) && numValue >= 0) { + setZoomValue(numValue) + } + } + + const handleInputBlur = () => { + // Sync input value with zoom value on blur + setInputValue(zoomValue.toString()) + } + + const handleZoomIn = () => { + const newValue = calculateZoomIn(zoomValue) + setZoomValue(newValue) + setInputValue(newValue.toString()) + } + + const handleZoomOut = () => { + const newValue = calculateZoomOut(zoomValue) + // Prevent going below 0 + if (newValue >= 0) { + setZoomValue(newValue) + setInputValue(newValue.toString()) + } else { + setZoomValue(0) + setInputValue("0") + } + } + + return ( + + + + + + % + + + + + + } + onClick={handleZoomOut} + /> + } + onClick={handleZoomIn} + /> + + + + ) +} + +export default ZoomInput 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..74ec731 100644 --- a/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx +++ b/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx @@ -8,26 +8,36 @@ import { MenuList, Text, Tooltip, -} 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 { PiDotsSixVerticalBold } from "@react-icons/all-files/pi/PiDotsSixVerticalBold" -import { PiDotsThreeVertical } from "@react-icons/all-files/pi/PiDotsThreeVertical" -import { PiEye } from "@react-icons/all-files/pi/PiEye" -import { PiEyeSlash } from "@react-icons/all-files/pi/PiEyeSlash" -import { PiPencilSimple } from "@react-icons/all-files/pi/PiPencilSimple" -import { PiPlus } from "@react-icons/all-files/pi/PiPlus" -import { PiTrash } from "@react-icons/all-files/pi/PiTrash" -import { RxTransform } from "@react-icons/all-files/rx/RxTransform" -import { type Transformation, useEditorStore } from "../../store" -import Hover from "../common/Hover" + Input, + Tag, + Flex, + IconButton, + useColorModeValue, +} from "@chakra-ui/react"; +import { useState, useEffect, useRef } from "react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { PiArrowDown } from "@react-icons/all-files/pi/PiArrowDown"; +import { PiArrowUp } from "@react-icons/all-files/pi/PiArrowUp"; +import { PiDotsSixVerticalBold } from "@react-icons/all-files/pi/PiDotsSixVerticalBold"; +import { PiDotsThreeVertical } from "@react-icons/all-files/pi/PiDotsThreeVertical"; +import { PiEye } from "@react-icons/all-files/pi/PiEye"; +import { PiEyeSlash } from "@react-icons/all-files/pi/PiEyeSlash"; +import { PiPencilSimple } from "@react-icons/all-files/pi/PiPencilSimple"; +import { PiPlus } from "@react-icons/all-files/pi/PiPlus"; +import { PiTrash } from "@react-icons/all-files/pi/PiTrash"; +import { RxTransform } from "@react-icons/all-files/rx/RxTransform"; +import { PiCopy } from "@react-icons/all-files/pi/PiCopy"; +import { PiCursorText } from "@react-icons/all-files/pi/PiCursorText"; +import { RiCheckFill } from "@react-icons/all-files/ri/RiCheckFill"; +import { RiCloseFill } from "@react-icons/all-files/ri/RiCloseFill"; +import { type Transformation, useEditorStore } from "../../store"; +import Hover from "../common/Hover"; -export type TransformationPosition = "inplace" | number +export type TransformationPosition = "inplace" | number; interface SortableTransformationItemProps { - transformation: Transformation + transformation: Transformation; } export const SortableTransformationItem = ({ @@ -42,7 +52,7 @@ export const SortableTransformationItem = ({ isDragging, } = useSortable({ id: transformation.id, - }) + }); const { transformations, @@ -54,7 +64,9 @@ export const SortableTransformationItem = ({ _setSelectedTransformationKey, _setTransformationToEdit, _internalState, - } = useEditorStore() + addTransformation, + updateTransformation, + } = useEditorStore(); const style = transform ? { @@ -62,13 +74,33 @@ export const SortableTransformationItem = ({ transition, opacity: isDragging ? 0.5 : 1, } - : undefined + : undefined; - const isVisible = visibleTransformations[transformation.id] + const isVisible = visibleTransformations[transformation.id]; const isEditting = _internalState.transformationToEdit?.position === "inplace" && - _internalState.transformationToEdit?.transformationId === transformation.id + _internalState.transformationToEdit?.transformationId === transformation.id; + + const [isRenaming, setIsRenaming] = useState(false); + const renameInputRef = useRef(null); + const renamingBoxRef = useRef(null); + + const 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 ( @@ -87,15 +119,19 @@ export const SortableTransformationItem = ({ minH="8" alignItems="center" style={style} - onClick={() => { - _setSidebarState("config") - _setSelectedTransformationKey(transformation.key) - _setTransformationToEdit(transformation.id, "inplace") + onClick={(e) => { + _setSidebarState("config"); + _setSelectedTransformationKey(transformation.key); + _setTransformationToEdit(transformation.id, "inplace"); + }} + onDoubleClick={(e) => { + e.stopPropagation(); + setIsRenaming(true); }} {...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 && ( { - e.stopPropagation() - toggleTransformationVisibility(transformation.id) + e.stopPropagation(); + toggleTransformationVisibility(transformation.id); }} > } onClick={(e) => { - e.stopPropagation() - _setSidebarState("type") - _setTransformationToEdit(transformation.id, "above") + e.stopPropagation(); + _setSidebarState("type"); + _setTransformationToEdit(transformation.id, "above"); }} > Add transformation before @@ -174,34 +274,65 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation() - _setSidebarState("type") - _setTransformationToEdit(transformation.id, "below") + e.stopPropagation(); + _setSidebarState("type"); + _setTransformationToEdit(transformation.id, "below"); }} > 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) => { - e.stopPropagation() - _setSidebarState("config") - _setSelectedTransformationKey(transformation.key) - _setTransformationToEdit(transformation.id, "inplace") + e.stopPropagation(); + _setSidebarState("config"); + _setSelectedTransformationKey(transformation.key); + _setTransformationToEdit(transformation.id, "inplace"); }} > Edit transformation + } + onClick={(e) => { + e.stopPropagation(); + setIsRenaming(true); + _setSidebarState("config"); + _setSelectedTransformationKey(transformation.key); + _setTransformationToEdit(transformation.id, "inplace"); + }} + > + Rename + } onClick={(e) => { - e.stopPropagation() + e.stopPropagation(); const currentIndex = transformations.findIndex( (t) => t.id === transformation.id, - ) + ); if (currentIndex > 0) { - const targetId = transformations[currentIndex - 1].id - moveTransformation(transformation.id, targetId) + const targetId = transformations[currentIndex - 1].id; + moveTransformation(transformation.id, targetId); } }} isDisabled={ @@ -215,13 +346,13 @@ export const SortableTransformationItem = ({ } onClick={(e) => { - e.stopPropagation() + e.stopPropagation(); const currentIndex = transformations.findIndex( (t) => t.id === transformation.id, - ) + ); if (currentIndex < transformations.length - 1) { - const targetId = transformations[currentIndex + 1].id - moveTransformation(transformation.id, targetId) + const targetId = transformations[currentIndex + 1].id; + moveTransformation(transformation.id, targetId); } }} isDisabled={ @@ -237,15 +368,15 @@ export const SortableTransformationItem = ({ icon={} color="red.500" onClick={(e) => { - e.stopPropagation() - removeTransformation(transformation.id) + e.stopPropagation(); + removeTransformation(transformation.id); if ( _internalState.selectedTransformationKey === transformation.key ) { - _setSidebarState("none") - _setSelectedTransformationKey(null) - _setTransformationToEdit(null) + _setSidebarState("none"); + _setSelectedTransformationKey(null); + _setTransformationToEdit(null); } }} > @@ -258,5 +389,5 @@ export const SortableTransformationItem = ({ )} - ) -} + ); +}; 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..560c806 100644 --- a/packages/imagekit-editor-dev/src/components/sidebar/transformation-config-sidebar.tsx +++ b/packages/imagekit-editor-dev/src/components/sidebar/transformation-config-sidebar.tsx @@ -50,11 +50,16 @@ import { isStepAligned } from "../../utils" import AnchorField from "../common/AnchorField" import CheckboxCardField from "../common/CheckboxCardField" import ColorPickerField from "../common/ColorPickerField" +import GradientPicker, { GradientPickerState } from "../common/GradientPicker" import RadioCardField from "../common/RadioCardField" import { SidebarBody } from "./sidebar-body" import { SidebarFooter } from "./sidebar-footer" import { SidebarHeader } from "./sidebar-header" import { SidebarRoot } from "./sidebar-root" +import PaddingInputField, { PaddingState } from "../common/PaddingInput" +import ZoomInput from "../common/ZoomInput" +import DistortPerspectiveInput, { PerspectiveObject } from "../common/DistortPerspectiveInput" +import RadiusInputField, { RadiusState } from "../common/CornerRadiusInput" export const TransformationConfigSidebar: React.FC = () => { const { @@ -83,14 +88,16 @@ export const TransformationConfigSidebar: React.FC = () => { const transformationToEdit = _internalState.transformationToEdit - const editedTransformationValue = useMemo(() => { + const editedTransformation = useMemo(() => { if (!transformationToEdit) return undefined return transformations.find( (transformation) => transformation.id === transformationToEdit.transformationId, - )?.value as Record | undefined + ) }, [transformations, transformationToEdit]) + const editedTransformationValue = editedTransformation?.value as Record | undefined + const defaultValues = useMemo(() => { if ( transformationToEdit && @@ -131,6 +138,7 @@ export const TransformationConfigSidebar: React.FC = () => { watch, setValue, control, + trigger, } = useForm>({ resolver: zodResolver(selectedTransformation?.schema ?? z.object({})), defaultValues: defaultValues, @@ -166,7 +174,7 @@ export const TransformationConfigSidebar: React.FC = () => { if (transformationToEdit && transformationToEdit.position === "inplace") { updateTransformation(transformationToEdit.transformationId, { type: "transformation", - name: selectedTransformation.name, + name: editedTransformation?.name ?? selectedTransformation.name, key: selectedTransformation.key, value: data, }) @@ -290,7 +298,7 @@ export const TransformationConfigSidebar: React.FC = () => { return true }) .map((field: TransformationField) => ( - + {field.label} @@ -413,6 +421,7 @@ export const TransformationConfigSidebar: React.FC = () => { id={field.name} fontSize="sm" {...register(field.name)} + {...(field.fieldProps ?? {})} /> ) : null} {field.fieldType === "textarea" ? ( @@ -535,6 +544,14 @@ export const TransformationConfigSidebar: React.FC = () => { setValue={setValue} /> ) : null} + {field.fieldType === "gradient-picker" ? ( + + ) : null} {field.fieldType === "anchor" ? ( { {...field.fieldProps} /> ) : null} + {field.fieldType === "padding-input" ? ( + { + setValue(field.name, value) + trigger(field.name) + }} + errors={errors} + name={field.name} + {...field.fieldProps} + value={watch(field.name) as Partial} + /> + ) : null} + {field.fieldType === "zoom" ? ( + setValue(field.name, value)} + defaultValue={field.fieldProps?.defaultValue as number ?? 100} + {...field.fieldProps} + /> + ) : null} + {field.fieldType === "distort-perspective-input" ? ( + { + setValue(field.name, value) + trigger(field.name) + }} + errors={errors} + name={field.name} + value={watch(field.name) as PerspectiveObject} + {...field.fieldProps} + /> + ) : null} + {field.fieldType === "radius-input" ? ( + { + setValue(field.name, value) + trigger(field.name) + }} + errors={errors} + name={field.name} + value={watch(field.name) as Partial} + {...field.fieldProps} + /> + ) : null} {String( errors[field.name as keyof typeof errors]?.message ?? "", diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 94fec2b..4901760 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -21,6 +21,7 @@ import { layerYValidator, widthValidator, } from "./transformation" +import { GradientPickerState } from "../components/common/GradientPicker" // Based on ImageKit's supported object list export const DEFAULT_FOCUS_OBJECTS = [ @@ -371,6 +372,7 @@ export const transformationSchema: TransformationSchema[] = [ focus: z.string().optional(), focusAnchor: z.string().optional(), focusObject: z.string().optional(), + zoom: z.coerce.number().optional(), }) .refine( (val) => { @@ -499,6 +501,19 @@ export const transformationSchema: TransformationSchema[] = [ "Select an object to focus on. The crop will center on this object.", isVisible: ({ focus }) => focus === "object", }, + { + label: "Zoom", + name: "zoom", + fieldType: "zoom", + isTransformation: true, + transformationGroup: "focus", + fieldProps: { + isCreatable: false, + }, + helpText: + "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", + isVisible: ({ focus }) => focus === "object" || focus === "face", + }, ], }, { @@ -519,6 +534,7 @@ export const transformationSchema: TransformationSchema[] = [ focus: z.string().optional(), focusAnchor: z.string().optional(), focusObject: z.string().optional(), + zoom: z.coerce.number().optional(), }) .refine( (val) => { @@ -606,6 +622,19 @@ export const transformationSchema: TransformationSchema[] = [ "Select an object to focus on. The crop will center on this object.", isVisible: ({ focus }) => focus === "object", }, + { + label: "Zoom", + name: "zoom", + fieldType: "zoom", + isTransformation: true, + transformationGroup: "focus", + fieldProps: { + isCreatable: false, + }, + helpText: + "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", + isVisible: ({ focus }) => focus === "object", + }, ], }, { @@ -806,6 +835,7 @@ export const transformationSchema: TransformationSchema[] = [ y: z.string().optional(), xc: z.string().optional(), yc: z.string().optional(), + zoom: z.coerce.number().optional(), }) .refine( (val) => { @@ -839,17 +869,6 @@ export const transformationSchema: TransformationSchema[] = [ }) } if (val.focus === "coordinates") { - const hasXY = val.x || val.y - const hasXCYC = val.xc || val.yc - - if (hasXY && hasXCYC) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Choose either x/y or xc/yc, not both", - path: [], - }) - } - if (val.coordinateMethod === "topleft") { if (!val.x && !val.y) { ctx.addIssue({ @@ -1007,6 +1026,19 @@ export const transformationSchema: TransformationSchema[] = [ isVisible: ({ focus, coordinateMethod }) => focus === "coordinates" && coordinateMethod === "center", }, + { + label: "Zoom", + name: "zoom", + fieldType: "zoom", + isTransformation: true, + transformationGroup: "focus", + fieldProps: { + isCreatable: false, + }, + helpText: + "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", + isVisible: ({ focus }) => focus === "object" || focus === "face", + }, ], }, { @@ -1324,6 +1356,172 @@ export const transformationSchema: TransformationSchema[] = [ }, ], }, + { + 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 + }, + { + message: "At least one value is required", + path: [], + }, + ), + transformations: [ + { + label: "Gradient", + name: "gradientSwitch", + fieldType: "switch", + isTransformation: false, + transformationGroup: "gradient", + helpText: "Toggle to add a gradient overlay over the image.", + }, + { + label: "Apply Gradient", + name: "gradient", + fieldType: "gradient-picker", + isTransformation: true, + transformationKey: "gradient", + transformationGroup: "gradient", + isVisible: ({ gradientSwitch }) => gradientSwitch === true, + fieldProps: { + defaultValue: { + from: "#FFFFFFFF", + to: "#00000000", + direction: "bottom", + stopPoint: 100, + } + } + }, + ], + }, + { + key: "adjust-distort", + name: "Distort", + description: "Distort the image.", + docsLink: + "https://imagekit.io/docs/effects-and-enhancements#distort---e-distort", + defaultTransformation: {}, + schema: z + .object({ + distort: z.coerce.boolean(), + distortType: z.enum(["perspective", "arc"]).optional(), + distortPerspective: z.object({ + x1: z.union([z.literal(""), z.coerce.number()]), + y1: z.union([z.literal(""), z.coerce.number()]), + x2: z.union([z.literal(""), z.coerce.number()]), + y2: z.union([z.literal(""), z.coerce.number()]), + x3: z.union([z.literal(""), z.coerce.number()]), + y3: z.union([z.literal(""), z.coerce.number()]), + x4: z.union([z.literal(""), z.coerce.number()]), + y4: z.union([z.literal(""), z.coerce.number()]), + }).optional(), + distortArcDegree: z.coerce.number().min(-359).max(359).optional(), + }) + .refine( + (val) => { + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false + }, + { + message: "At least one value is required", + path: [], + }, + ), + transformations: [ + { + label: "Distort", + name: "distort", + fieldType: "switch", + isTransformation: false, + transformationGroup: "distort", + helpText: "Toggle to apply distortion to the image.", + }, + { + label: "Distortion Type", + name: "distortType", + fieldType: "radio-card", + isTransformation: false, + transformationGroup: "distort", + isVisible: ({ distort }) => distort === true, + fieldProps: { + options: [ + { label: "Perspective", value: "perspective" }, + { label: "Arc", value: "arc" }, + ], + defaultValue: "perspective", + }, + }, + { + label: "Distortion Perspective", + name: "distortPerspective", + fieldType: "distort-perspective-input", + isTransformation: false, + transformationGroup: "distort", + isVisible: ({ distort, distortType }) => distort === true && distortType === "perspective", + fieldProps: { + defaultValue: { + x1: "", + y1: "", + x2: "", + y2: "", + x3: "", + y3: "", + x4: "", + y4: "", + } + } + }, + { + label: "Distortion Arc Degrees", + name: "distortArcDegree", + fieldType: "input", + isTransformation: true, + transformationGroup: "distort", + isVisible: ({ distort, distortType }) => distort === true && distortType === "arc", + helpText: "Enter the arc degree for the arc distortion effect.", + examples: ["15", "30", "45"], + fieldProps: { + type: "number", + placeholder: "Arc Degrees", + } + } + ], + }, { key: "adjust-blur", name: "Blur", @@ -1492,14 +1690,51 @@ export const transformationSchema: TransformationSchema[] = [ defaultTransformation: {}, schema: z .object({ - radius: z.union([ - z.literal("max"), - z.coerce - .number({ + radius: z.object({ + mode: z.enum(["uniform", "individual"]).optional(), + radius: z.union([ + z.literal("max"), + z.coerce.number({ invalid_type_error: "Should be a number.", - }) - .min(0), - ]), + }).min(0, { + message: "Negative values are not allowed.", + }), + z.object({ + topLeft: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + topRight: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + bottomRight: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + bottomLeft: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + }), + ]).optional(), + }).optional(), }) .refine( (val) => { @@ -1519,12 +1754,15 @@ export const transformationSchema: TransformationSchema[] = [ { label: "Radius", name: "radius", - fieldType: "input", + fieldType: "radius-input", isTransformation: true, - transformationKey: "r", + transformationGroup: "radius", helpText: "Enter a positive integer for rounded corners or 'max' for a fully circular output.", examples: ["10", "max"], + fieldProps: { + defaultValue: {} + } }, ], }, @@ -2122,11 +2360,39 @@ export const transformationSchema: TransformationSchema[] = [ innerAlignment: z .enum(["left", "right", "center"]) .default("center"), - padding: z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .optional(), + padding: z.object({ + mode: z.enum(["uniform", "individual"]).optional(), + padding: z.union([ + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + z.object({ + top: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + right: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + bottom: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + left: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + }), + ]).optional(), + }) + .optional(), opacity: z .union([ z.coerce @@ -2199,7 +2465,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 +2475,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", @@ -2317,7 +2583,7 @@ export const transformationSchema: TransformationSchema[] = [ { label: "Padding", name: "padding", - fieldType: "input", + fieldType: "padding-input", isTransformation: true, transformationKey: "padding", transformationGroup: "textLayer", @@ -2413,16 +2679,6 @@ export const transformationSchema: TransformationSchema[] = [ }) .optional(), backgroundColor: z.string().optional(), - radius: z - .union([ - z.literal("max"), - z.coerce - .number({ - invalid_type_error: "Should be a number.", - }) - .min(0), - ]) - .optional(), flip: z .array(z.enum(["horizontal", "vertical"]).optional()) .optional(), @@ -2446,6 +2702,132 @@ export const transformationSchema: TransformationSchema[] = [ invalid_type_error: "Should be a number.", }) .optional(), + + // Focus + Zoom properties + focus: z.string().optional(), + focusAnchor: z.string().optional(), + focusObject: z.string().optional(), + 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.coerce.number()]), + y1: z.union([z.literal(""), z.coerce.number()]), + x2: z.union([z.literal(""), z.coerce.number()]), + y2: z.union([z.literal(""), z.coerce.number()]), + x3: z.union([z.literal(""), z.coerce.number()]), + y3: z.union([z.literal(""), z.coerce.number()]), + x4: z.union([z.literal(""), z.coerce.number()]), + y4: z.union([z.literal(""), z.coerce.number()]), + }).optional(), + distortArcDegree: z.coerce.number().min(-359).max(359).optional(), + + // Radius + radius: z.object({ + mode: z.enum(["uniform", "individual"]).optional(), + radius: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + z.object({ + topLeft: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + topRight: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + bottomRight: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + bottomLeft: z.union([ + z.literal("max"), + z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + ]), + }), + ]).optional(), + }).optional(), }) .refine( (val) => { @@ -2457,7 +2839,42 @@ export const transformationSchema: TransformationSchema[] = [ message: "At least one value is required", path: [], }, - ), + ) + .superRefine((val, ctx) => { + if (val.focus === "object" && !val.focusObject) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Focus object is required", + path: ["focusObject"], + }) + } + if (val.focus === "anchor" && !val.focusAnchor) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Focus anchor is required", + path: ["focusAnchor"], + }) + } + if (val.focus === "coordinates") { + if (val.coordinateMethod === "topleft") { + if (!val.x && !val.y) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one coordinate (x or y) is required", + path: [], + }) + } + } else if (val.coordinateMethod === "center") { + if (!val.xc && !val.yc) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one coordinate (xc or yc) is required", + path: [], + }) + } + } + } + }), transformations: [ { label: "Image URL", @@ -2510,24 +2927,163 @@ export const transformationSchema: TransformationSchema[] = [ }, }, { - label: "Position X", - name: "positionX", - fieldType: "input", + label: "Focus", + name: "focus", + fieldType: "select", isTransformation: true, - transformationKey: "x", transformationGroup: "imageLayer", - helpText: "Specify the horizontal offset for the overlay image.", - examples: ["10"], - }, - { - label: "Position Y", - name: "positionY", - fieldType: "input", + 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: { + isCreatable: false, + }, + helpText: + "Select the zoom level for the focus area. Higher zoom levels crop closer to the focus point.", + isVisible: ({ focus }) => focus === "object" || focus === "face", + }, + { + label: "Position X", + name: "positionX", + 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"], + examples: ["10", "-20", "N30", "bh_div_2"], }, { label: "Opacity", @@ -2557,12 +3113,15 @@ export const transformationSchema: TransformationSchema[] = [ { label: "Radius", name: "radius", - fieldType: "input", + fieldType: "radius-input", isTransformation: true, - transformationKey: "radius", transformationGroup: "imageLayer", helpText: "Set the corner radius for the overlay image. Use 'max' for a circle or oval.", + examples: ["10", "max"], + fieldProps: { + defaultValue: {} + } }, { label: "Flip", @@ -2646,6 +3205,170 @@ export const transformationSchema: TransformationSchema[] = [ defaultValue: "0", }, }, + { + label: "Gradient", + name: "gradientSwitch", + fieldType: "switch", + isTransformation: false, + transformationGroup: "imageLayer", + helpText: "Toggle to add a gradient overlay over the overlay image.", + }, + { + label: "Apply Gradient", + name: "gradient", + fieldType: "gradient-picker", + isTransformation: true, + transformationKey: "gradient", + transformationGroup: "imageLayer", + isVisible: ({ gradientSwitch }) => gradientSwitch === true, + fieldProps: { + defaultValue: { + from: "#FFFFFFFF", + to: "#00000000", + direction: "bottom", + stopPoint: 100, + } + } + }, + { + label: "Shadow", + name: "shadow", + fieldType: "switch", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Toggle to add a non-AI shadow under objects in the overlay image.", + }, + { + label: "Blur", + name: "shadowBlur", + fieldType: "slider", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Set the blur radius for the shadow. Higher values create a softer shadow.", + fieldProps: { + min: 0, + max: 15, + step: 1, + defaultValue: 10, + }, + isVisible: ({ shadow }) => shadow === true, + }, + { + label: "Saturation", + name: "shadowSaturation", + fieldType: "slider", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Adjust the saturation of the shadow. Higher values produce a darker shadow.", + fieldProps: { + min: 0, + max: 100, + step: 1, + defaultValue: 30, + }, + isVisible: ({ shadow }) => shadow === true, + }, + { + label: "X Offset", + name: "shadowOffsetX", + fieldType: "slider", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Enter the horizontal offset as a percentage of the overlay image width.", + isVisible: ({ shadow }) => shadow === true, + fieldProps: { + min: -100, + max: 100, + step: 1, + defaultValue: 2, + }, + }, + { + label: "Y Offset", + name: "shadowOffsetY", + fieldType: "slider", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Enter the vertical offset as a percentage of the overlay image height.", + isVisible: ({ shadow }) => shadow === true, + fieldProps: { + min: -100, + max: 100, + step: 1, + defaultValue: 2, + }, + }, + { + label: "Grayscale", + name: "grayscale", + fieldType: "switch", + isTransformation: true, + transformationKey: "grayscale", + transformationGroup: "imageLayer", + helpText: "Toggle to convert the overlay image to grayscale.", + }, + { + label: "Distort", + name: "distort", + fieldType: "switch", + isTransformation: false, + transformationGroup: "imageLayer", + helpText: "Toggle to apply distortion to the overlay image.", + }, + { + label: "Distortion Type", + name: "distortType", + fieldType: "radio-card", + isTransformation: false, + transformationGroup: "imageLayer", + isVisible: ({ distort }) => distort === true, + fieldProps: { + options: [ + { label: "Perspective", value: "perspective" }, + { label: "Arc", value: "arc" }, + ], + defaultValue: "perspective", + }, + }, + { + label: "Distortion Perspective", + name: "distortPerspective", + fieldType: "distort-perspective-input", + isTransformation: false, + transformationGroup: "imageLayer", + isVisible: ({ distort, distortType }) => distort === true && distortType === "perspective", + fieldProps: { + defaultValue: { + x1: "", + y1: "", + x2: "", + y2: "", + x3: "", + y3: "", + x4: "", + y4: "", + } + } + }, + { + label: "Distortion Arc Degrees", + name: "distortArcDegree", + fieldType: "input", + isTransformation: true, + transformationGroup: "imageLayer", + isVisible: ({ distort, distortType }) => distort === true && distortType === "arc", + helpText: "Enter the arc degree for the arc distortion effect.", + examples: ["15", "30", "45"], + fieldProps: { + type: "number", + placeholder: "Arc Degrees", + } + }, ], }, ], @@ -2746,7 +3469,7 @@ export const transformationFormatters: Record< } }, focus: (values, transforms) => { - const { focus, focusAnchor, focusObject, x, y, xc, yc } = values + const { focus, focusAnchor, focusObject, x, y, xc, yc, coordinateMethod, zoom } = values if (focus === "auto" || focus === "face") { transforms.focus = focus @@ -2759,10 +3482,16 @@ 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 && !isNaN(Number(zoom)) && zoom !== 0) { + transforms.zoom = (zoom as number) / 100 } }, shadow: (values, transforms) => { @@ -2793,7 +3522,8 @@ export const transformationFormatters: Record< if ( shadowOffsetX !== undefined && shadowOffsetX !== null && - shadowOffsetX !== "" + shadowOffsetX !== "" && + typeof shadowOffsetX === "number" ) { if (shadowOffsetX < 0) { params.push(`x-N${Math.abs(shadowOffsetX)}`) @@ -2805,7 +3535,8 @@ export const transformationFormatters: Record< if ( shadowOffsetY !== undefined && shadowOffsetY !== null && - shadowOffsetY !== "" + shadowOffsetY !== "" && + typeof shadowOffsetY === "number" ) { if (shadowOffsetY < 0) { params.push(`y-N${Math.abs(shadowOffsetY)}`) @@ -2856,13 +3587,32 @@ export const transformationFormatters: Record< const bg = (values.backgroundColor as string).replace(/^#/, "") overlayTransform.background = bg } + const { padding, mode } = values.padding as Record if ( - typeof values.padding === "number" || - typeof values.padding === "string" + mode === "uniform" && + (typeof padding === "number" || + typeof padding === "string") ) { - overlayTransform.padding = values.padding + 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 (Array.isArray(values.flip) && values.flip.length > 0) { const flip = [] if (values.flip.includes("horizontal")) { @@ -2926,13 +3676,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 +3733,6 @@ export const transformationFormatters: Record< overlayTransform.background = values.backgroundColor.replace(/^#/, "") } - if (values.radius === "max") { - overlayTransform.radius = "max" - } else if (values.radius as number) { - overlayTransform.radius = values.radius as number - } - if ((values.flip as Array)?.length) { const flip = [] if ((values.flip as Array).includes("horizontal")) { @@ -3025,6 +3769,22 @@ export const transformationFormatters: Record< overlayTransform.blur = values.blur } + 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 +3792,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) { @@ -3078,4 +3838,56 @@ export const transformationFormatters: Record< transforms.rotation = "auto" } }, + 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 + let gradientStr = `ld-${direction}_from-${fromColor}_to-${toColor}_sp-${stopPointDecimal}` + transforms.gradient = gradientStr + } + } + }, + distort: (values, transforms) => { + if (values.distort) { + const { distortType, distortPerspective, distortArcDegree } = values + const distortPrefix = distortType === "perspective" ? "p" : "a" + if (distortType === "perspective" && distortPerspective) { + const { x1, y1, x2, y2, x3, y3, x4, y4 } = distortPerspective as Record + const formattedCoords = [x1, y1, x2, y2, x3, y3, x4, y4].map(coord => coord.toString().replace(/^-/,"N")) + transforms["e-distort"] = `${distortPrefix}-${formattedCoords.join("_")}` + } else if (distortType === "arc" && distortArcDegree !== undefined && distortArcDegree !== null) { + transforms["e-distort"] = `${distortPrefix}-${distortArcDegree.toString().replace(/^-/,"N")}` + } + } + }, + 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}` + } + } + } + } } diff --git a/packages/imagekit-editor-dev/src/schema/transformation.ts b/packages/imagekit-editor-dev/src/schema/transformation.ts index 78eb69c..8a798d4 100644 --- a/packages/imagekit-editor-dev/src/schema/transformation.ts +++ b/packages/imagekit-editor-dev/src/schema/transformation.ts @@ -78,10 +78,8 @@ export const aspectRatioValidator = z.any().superRefine((val, ctx) => { }) const layerXNumber = z.coerce - .number({ invalid_type_error: "Should be a number." }) - .min(0, { - message: "Layer X must be a positive number.", - }) + .string() + .regex(/^[N-]?\d+(\.\d{1,2})?$/) const layerXExpr = z .string() @@ -97,15 +95,13 @@ export const layerXValidator = z.any().superRefine((val, ctx) => { } ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Layer X must be a positive number or a valid expression string.", + message: "Layer X must be a number or a valid expression string.", }) }) const layerYNumber = z.coerce - .number({ invalid_type_error: "Should be a number." }) - .min(0, { - message: "Layer Y must be a positive number.", - }) + .string() + .regex(/^[N-]?\d+(\.\d{1,2})?$/) const layerYExpr = z .string() @@ -121,6 +117,6 @@ export const layerYValidator = z.any().superRefine((val, ctx) => { } ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Layer Y must be a positive number or a valid expression string.", + message: "Layer Y must be a number or a valid expression string.", }) })