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/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..f2f93e6 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/PaddingInput.tsx @@ -0,0 +1,283 @@ +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 PaddingInputFieldProps = { + id?: string + onChange: (value: number | PaddingObject | string) => void + errors?: FieldErrors> + name: string +} + +type PaddingObject = { + top: number | null + right: number | null + bottom: number | null + left: number | null +} + +function getUpdatedPaddingValue( + current: number | PaddingObject | null | string, + side: "top" | "right" | "bottom" | "left" | "all", + value: string, + mode: "uniform" | "individual" +): number | PaddingObject | null | string { + let inputValue: number | PaddingObject | null | string + try { + inputValue = JSON.parse(value) + } catch { + inputValue = value + } + if (mode === "uniform") { + if (typeof inputValue === "number") { + return inputValue + } else if (inputValue === null) { + return null + } else if (typeof inputValue === "string") { + return inputValue + } else { + const { top, right, bottom, left } = inputValue + if (top === right && top === bottom && top === left) { + return top + } else { + return null + } + } + } else { + let commonValue: number | null = null + if (typeof inputValue === "number") { + commonValue = inputValue + } + const updatedPadding = current && typeof current === "object" + ? { ...current } + : { top: commonValue, right: commonValue, bottom: commonValue, left: commonValue } + if (side !== "all") { + set(updatedPadding, side, inputValue) + } + return updatedPadding + } +} + +export const PaddingInputField: React.FC = ({ + id, + onChange, + errors, + name: propertyName, +}) => { + const [paddingMode, setPaddingMode] = useState<"uniform" | "individual">("uniform") + const [paddingValue, setPaddingValue] = useState("") + const errorRed = useColorModeValue("red.500", "red.300") + 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(formattedValue) + }, [paddingValue]) + + + return ( + + + { paddingMode === "uniform" ? ( + + { + const val = e.target.value + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + "all", + val, + paddingMode + )) + }} + value={["number", "string"].includes(typeof paddingValue) ? paddingValue : ""} + placeholder="Uniform Padding" + isInvalid={!!errors?.[propertyName]} + /> + {errors?.[propertyName]?.message} + + ) : ( + <> + + + + + + { + const val = e.target.value + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + "top", + val, + paddingMode + )) + }} + value={typeof paddingValue === "object" ? paddingValue?.top ?? "" : ""} + placeholder="Top" + isInvalid={!!errors?.[propertyName]?.top} + /> + + {errors?.[propertyName]?.top?.message} + + + + + + + + { + const val = e.target.value + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + "right", + val, + paddingMode + )) + }} + value={typeof paddingValue === "object" ? paddingValue?.right ?? "" : ""} + placeholder="Right" + isInvalid={!!errors?.[propertyName]?.right} + /> + + {errors?.[propertyName]?.right?.message} + + + + + + + + { + const val = e.target.value + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + "bottom", + val, + paddingMode + )) + }} + value={typeof paddingValue === "object" ? paddingValue?.bottom ?? "" : ""} + placeholder="Bottom" + isInvalid={!!errors?.[propertyName]?.bottom} + /> + + {errors?.[propertyName]?.bottom?.message} + + + + + + + + { + const val = e.target.value + setPaddingValue(getUpdatedPaddingValue( + paddingValue, + "left", + val, + paddingMode + )) + }} + value={typeof paddingValue === "object" ? paddingValue?.left ?? "" : ""} + placeholder="Left" + isInvalid={!!errors?.[propertyName]?.left} + /> + + {errors?.[propertyName]?.left?.message} + + + + ) } + + + } + 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..843036d --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/ZoomInput.tsx @@ -0,0 +1,129 @@ +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 +} + +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, +}) => { + const [zoomValue, setZoomValue] = useState(defaultValue) + const [inputValue, setInputValue] = useState(defaultValue.toString()) + + useEffect(() => { + onChange(zoomValue) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [zoomValue]) + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value + setInputValue(value) + + const numValue = Number(value) + if (!isNaN(numValue) && numValue >= 0) { + setZoomValue(numValue) + } + } + + const handleInputBlur = () => { + // Sync input value with zoom value on blur + setInputValue(zoomValue.toString()) + } + + const handleZoomIn = () => { + const newValue = calculateZoomIn(zoomValue) + setZoomValue(newValue) + setInputValue(newValue.toString()) + } + + const handleZoomOut = () => { + const newValue = calculateZoomOut(zoomValue) + // Prevent going below 0 + if (newValue >= 0) { + setZoomValue(newValue) + setInputValue(newValue.toString()) + } else { + setZoomValue(0) + setInputValue("0") + } + } + + return ( + + + + + + % + + + + + + } + onClick={handleZoomOut} + /> + } + onClick={handleZoomIn} + /> + + + + ) +} + +export default 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..1276dd6 100644 --- a/packages/imagekit-editor-dev/src/components/sidebar/transformation-config-sidebar.tsx +++ b/packages/imagekit-editor-dev/src/components/sidebar/transformation-config-sidebar.tsx @@ -50,11 +50,14 @@ 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 from "../common/PaddingInput" +import ZoomInput from "../common/ZoomInput" export const TransformationConfigSidebar: React.FC = () => { const { @@ -83,14 +86,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 +136,7 @@ export const TransformationConfigSidebar: React.FC = () => { watch, setValue, control, + trigger, } = useForm>({ resolver: zodResolver(selectedTransformation?.schema ?? z.object({})), defaultValues: defaultValues, @@ -166,7 +172,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 +296,7 @@ export const TransformationConfigSidebar: React.FC = () => { return true }) .map((field: TransformationField) => ( - + {field.label} @@ -535,6 +541,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} + /> + ) : null} + {field.fieldType === "zoom" ? ( + setValue(field.name, value)} + defaultValue={field.fieldProps?.defaultValue as number ?? 100} + {...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..94d1d9e 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,76 @@ 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) => { + console.log("Received val", val); + if ( + Object.values(val).some((v) => v !== undefined && v !== null) + ) { + return true + } + return false + }, + { + message: "At least one value is required", + path: [], + }, + ), + transformations: [ + { + label: "Gradient", + name: "gradientSwitch", + fieldType: "switch", + isTransformation: false, + transformationGroup: "gradient", + helpText: "Toggle to add a gradient overlay over the image.", + }, + { + label: "Apply Gradient", + name: "gradient", + fieldType: "gradient-picker", + isTransformation: true, + transformationKey: "gradient", + transformationGroup: "gradient", + isVisible: ({ gradientSwitch }) => gradientSwitch === true, + fieldProps: { + defaultValue: { + from: "#FFFFFFFF", + to: "#00000000", + direction: "bottom", + stopPoint: 100, + } + } + }, + ], + }, { key: "adjust-blur", name: "Blur", @@ -2122,11 +2224,35 @@ export const transformationSchema: TransformationSchema[] = [ innerAlignment: z .enum(["left", "right", "center"]) .default("center"), - padding: z.coerce - .number({ + padding: z.union([ + z.coerce.number({ invalid_type_error: "Should be a number.", - }) - .optional(), + }).min(0, { + message: "Negative values are not allowed.", + }), + z.object({ + top: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + right: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + bottom: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + left: z.coerce.number({ + invalid_type_error: "Should be a number.", + }).min(0, { + message: "Negative values are not allowed.", + }), + }), + ]).optional(), opacity: z .union([ z.coerce @@ -2199,7 +2325,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 +2335,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 +2443,7 @@ export const transformationSchema: TransformationSchema[] = [ { label: "Padding", name: "padding", - fieldType: "input", + fieldType: "padding-input", isTransformation: true, transformationKey: "padding", transformationGroup: "textLayer", @@ -2446,6 +2572,70 @@ 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(), }) .refine( (val) => { @@ -2457,7 +2647,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", @@ -2509,6 +2734,145 @@ export const transformationSchema: TransformationSchema[] = [ defaultValue: "", }, }, + { + label: "Focus", + name: "focus", + fieldType: "select", + isTransformation: true, + transformationGroup: "imageLayer", + fieldProps: { + options: [ + { label: "Select one", value: "" }, + { label: "Auto", value: "auto" }, + { label: "Anchor", value: "anchor" }, + { label: "Face", value: "face" }, + { label: "Object", value: "object" }, + { label: "Custom", value: "custom" }, + { label: "Coordinates", value: "coordinates" }, + ], + }, + helpText: + "Choose how to position the extracted region in overlay image. Custom uses a saved focus area from Media Library.", + isVisible: ({ crop }) => crop === "cm-extract", + }, + // Only for extract crop mode + { + label: "Focus Anchor", + name: "focusAnchor", + fieldType: "anchor", + isTransformation: true, + transformationGroup: "imageLayer", + fieldProps: { + positions: [ + "center", "top", "bottom", "left", "right", "top_left", "top_right", "bottom_left", "bottom_right", + ], + }, + isVisible: ({ focus, crop }) => focus === "anchor" && crop === "cm-extract", + }, + // Only for pad_resize crop mode + { + label: "Focus", + name: "focusAnchor", + fieldType: "anchor", + isTransformation: true, + transformationGroup: "imageLayer", + fieldProps: { + positions: [ + "center", "top", "bottom", "left", "right", + ], + }, + isVisible: ({ crop }) => crop === "cm-pad_resize", + }, + { + label: "Focus Object", + name: "focusObject", + fieldType: "select", + isTransformation: true, + transformationGroup: "imageLayer", + fieldProps: { + isCreatable: false, + }, + helpText: + "Select an object to focus on in the overlay image during extraction. The crop will center on this object.", + isVisible: ({ focus }) => focus === "object", + }, + { + label: "Coordinate Method", + name: "coordinateMethod", + fieldType: "radio-card", + isTransformation: false, + transformationGroup: "imageLayer", + fieldProps: { + options: [ + { label: "Top-left (x, y)", value: "topleft" }, + { label: "Center (xc, yc)", value: "center" }, + ], + defaultValue: "topleft", + }, + helpText: + "Choose whether coordinates are relative to the top-left corner or the center of the overlay image.", + isVisible: ({ focus }) => focus === "coordinates", + }, + { + label: "X (Horizontal)", + name: "x", + fieldType: "input", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Horizontal position from the top-left of the overlay image. Use an integer or expression.", + examples: ["100", "iw_mul_0.4"], + isVisible: ({ focus, coordinateMethod }) => + focus === "coordinates" && coordinateMethod === "topleft", + }, + { + label: "Y (Vertical)", + name: "y", + fieldType: "input", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Vertical position from the top-left of the overlay image. Use an integer or expression.", + examples: ["100", "ih_mul_0.4"], + isVisible: ({ focus, coordinateMethod }) => + focus === "coordinates" && coordinateMethod === "topleft", + }, + { + label: "XC (Horizontal Center)", + name: "xc", + fieldType: "input", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: + "Horizontal center position of the overlay image. Use an integer or expression.", + examples: ["200", "iw_mul_0.5"], + isVisible: ({ focus, coordinateMethod }) => + focus === "coordinates" && coordinateMethod === "center", + }, + { + label: "YC (Vertical Center)", + name: "yc", + fieldType: "input", + isTransformation: true, + transformationGroup: "imageLayer", + helpText: "Vertical center position of the overlay image. Use an integer or expression.", + examples: ["200", "ih_mul_0.5"], + isVisible: ({ focus, coordinateMethod }) => + focus === "coordinates" && coordinateMethod === "center", + }, + { + label: "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", @@ -2517,7 +2881,7 @@ export const transformationSchema: TransformationSchema[] = [ transformationKey: "x", transformationGroup: "imageLayer", helpText: "Specify the horizontal offset for the overlay image.", - examples: ["10"], + examples: ["10", "-20", "N30", "bw_div_2"], }, { label: "Position Y", @@ -2527,7 +2891,7 @@ export const transformationSchema: TransformationSchema[] = [ transformationKey: "y", transformationGroup: "imageLayer", helpText: "Specify the vertical offset for the overlay image.", - examples: ["10"], + examples: ["10", "-20", "N30", "bh_div_2"], }, { label: "Opacity", @@ -2646,6 +3010,113 @@ 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.", + }, ], }, ], @@ -2746,7 +3217,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 +3230,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 +3270,8 @@ export const transformationFormatters: Record< if ( shadowOffsetX !== undefined && shadowOffsetX !== null && - shadowOffsetX !== "" + shadowOffsetX !== "" && + typeof shadowOffsetX === "number" ) { if (shadowOffsetX < 0) { params.push(`x-N${Math.abs(shadowOffsetX)}`) @@ -2805,7 +3283,8 @@ export const transformationFormatters: Record< if ( shadowOffsetY !== undefined && shadowOffsetY !== null && - shadowOffsetY !== "" + shadowOffsetY !== "" && + typeof shadowOffsetY === "number" ) { if (shadowOffsetY < 0) { params.push(`y-N${Math.abs(shadowOffsetY)}`) @@ -2861,8 +3340,25 @@ export const transformationFormatters: Record< typeof values.padding === "string" ) { overlayTransform.padding = values.padding + } else if (typeof values.padding === "object" && values.padding !== null) { + const { top, right, bottom, left } = values.padding as { + top: number + right: number + bottom: number + left: number + } + let paddingString: string; + if (top === right && top === bottom && top === left) { + paddingString = String(top) + } else if (top === bottom && right === left) { + paddingString = `${top}_${right}` + } else { + paddingString = `${top}_${right}_${bottom}_${left}` + } + overlayTransform.padding = paddingString } + if (Array.isArray(values.flip) && values.flip.length > 0) { const flip = [] if (values.flip.includes("horizontal")) { @@ -2926,13 +3422,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 @@ -3025,6 +3521,20 @@ 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) + + if (values.grayscale) { + overlayTransform.grayscale = true + } + if (Object.keys(overlayTransform).length > 0) { overlay.transformation = [overlayTransform] } @@ -3032,10 +3542,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 +3588,24 @@ export const transformationFormatters: Record< transforms.rotation = "auto" } }, + gradient: (values, transforms) => { + const { gradient, gradientSwitch } = values as { gradient: GradientPickerState; gradientSwitch: boolean } + console.log('gradient formatter called', values) + if (gradientSwitch && gradient) { + const { from, to, direction, stopPoint } = gradient + const isDefaultGradient = (from.toUpperCase() === "#FFFFFFFF" || from.toUpperCase() === "#FFFFFF") && + (to.toUpperCase() === "#00000000") && + (direction === "bottom" || direction === 180) && + stopPoint === 100 + if (isDefaultGradient) { + transforms.gradient = "" + } else { + const fromColor = from.replace("#", "") + const toColor = to.replace("#", "") + const stopPointDecimal = stopPoint / 100 + let gradientStr = `ld-${direction}_from-${fromColor}_to-${toColor}_sp-${stopPointDecimal}` + transforms.gradient = gradientStr + } + } + } } 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.", }) })