Skip to content

Commit c86d617

Browse files
committed
feat: enhance transformation handling with nested layer support and child key validation
1 parent 76e2472 commit c86d617

5 files changed

Lines changed: 370 additions & 46 deletions

File tree

packages/imagekit-editor-dev/src/backward-compatibility.test.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,6 +1468,196 @@ describe("Backward Compatibility - V1 Templates", () => {
14681468
expect(findTransformationDeep(tree, "deep")?.name).toBe("Deep")
14691469
expect(findTransformationDeep(tree, "missing")).toBeUndefined()
14701470
})
1471+
1472+
it("non-layer child (ai-removedotbg) is appended as a chained step inside the parent layer", async () => {
1473+
const { buildSrc } = await import("@imagekit/javascript")
1474+
const { convertTransformationToIK } = await import(
1475+
"./transformationConverter"
1476+
)
1477+
// Parent has multiple own-params (image url + width + trim) so the SDK
1478+
// serializes the child with an explicit `:` chain separator. With a
1479+
// single-param parent the SDK collapses to a `,` joiner — equivalent
1480+
// ImageKit syntax, but less obvious that the child is a chained step.
1481+
const parent: Transformation = {
1482+
id: "p",
1483+
key: "layers-image",
1484+
name: "Image Layer",
1485+
type: "transformation",
1486+
value: {
1487+
imageUrl: "photo.jpg",
1488+
width: "13",
1489+
trimEnabled: true,
1490+
trimThreshold: 10,
1491+
},
1492+
children: [
1493+
{
1494+
id: "c",
1495+
key: "ai-removedotbg",
1496+
name: "Remove Background",
1497+
type: "transformation",
1498+
value: { removedotbg: true },
1499+
},
1500+
],
1501+
}
1502+
const url = buildSrc({
1503+
urlEndpoint: "https://ik.imagekit.io/demo",
1504+
src: "/base.jpg",
1505+
transformation: [convertTransformationToIK(parent)],
1506+
})
1507+
expect(url).toBe(
1508+
"https://ik.imagekit.io/demo/base.jpg?tr=l-image,i-photo.jpg,w-13,t-10:e-removedotbg,l-end",
1509+
)
1510+
})
1511+
1512+
it("mixes non-layer and nested-layer children in declaration order", async () => {
1513+
const { buildSrc } = await import("@imagekit/javascript")
1514+
const { convertTransformationToIK } = await import(
1515+
"./transformationConverter"
1516+
)
1517+
const parent: Transformation = {
1518+
id: "p",
1519+
key: "layers-image",
1520+
name: "Image Layer",
1521+
type: "transformation",
1522+
value: { imageUrl: "photo.jpg" },
1523+
children: [
1524+
{
1525+
id: "c1",
1526+
key: "adjust-blur",
1527+
name: "Blur",
1528+
type: "transformation",
1529+
value: { blur: 5 },
1530+
},
1531+
{
1532+
id: "c2",
1533+
key: "layers-text",
1534+
name: "Caption",
1535+
type: "transformation",
1536+
value: { text: "Sale", radius: 0 },
1537+
},
1538+
],
1539+
}
1540+
const url = buildSrc({
1541+
urlEndpoint: "https://ik.imagekit.io/demo",
1542+
src: "/base.jpg",
1543+
transformation: [convertTransformationToIK(parent)],
1544+
})
1545+
expect(url).toBe(
1546+
"https://ik.imagekit.io/demo/base.jpg?tr=l-image,i-photo.jpg,bl-5:l-text,i-Sale,r-0,l-end,l-end",
1547+
)
1548+
})
1549+
1550+
it("hidden non-layer child is skipped from the URL", async () => {
1551+
const { buildSrc } = await import("@imagekit/javascript")
1552+
const { convertTransformationToIK } = await import(
1553+
"./transformationConverter"
1554+
)
1555+
const parent: Transformation = {
1556+
id: "p",
1557+
key: "layers-image",
1558+
name: "Image Layer",
1559+
type: "transformation",
1560+
value: { imageUrl: "photo.jpg" },
1561+
children: [
1562+
{
1563+
id: "c1",
1564+
key: "adjust-blur",
1565+
name: "Blur",
1566+
type: "transformation",
1567+
value: { blur: 5 },
1568+
enabled: false,
1569+
},
1570+
],
1571+
}
1572+
const url = buildSrc({
1573+
urlEndpoint: "https://ik.imagekit.io/demo",
1574+
src: "/base.jpg",
1575+
transformation: [convertTransformationToIK(parent)],
1576+
})
1577+
expect(url).toBe(
1578+
"https://ik.imagekit.io/demo/base.jpg?tr=l-image,i-photo.jpg,l-end",
1579+
)
1580+
})
1581+
1582+
it("isAllowedChildKey enforces per-parent allow lists", async () => {
1583+
const { isAllowedChildKey } = await import("./store")
1584+
1585+
// Image layer: liberal allow list including AI + adjust + nested layers.
1586+
expect(isAllowedChildKey("layers-image", "ai-removedotbg")).toBe(true)
1587+
expect(isAllowedChildKey("layers-image", "adjust-blur")).toBe(true)
1588+
expect(isAllowedChildKey("layers-image", "layers-text")).toBe(true)
1589+
// Delivery transforms are output-only; never valid inside a layer block.
1590+
expect(isAllowedChildKey("layers-image", "delivery-format")).toBe(false)
1591+
1592+
// Canvas layer: tighter list (no blur/AI), but layers still allowed.
1593+
expect(isAllowedChildKey("layers-canvas", "adjust-radius")).toBe(true)
1594+
expect(isAllowedChildKey("layers-canvas", "adjust-blur")).toBe(false)
1595+
expect(isAllowedChildKey("layers-canvas", "ai-removedotbg")).toBe(false)
1596+
expect(isAllowedChildKey("layers-canvas", "layers-image")).toBe(true)
1597+
1598+
// Text layers are leaves: nothing is allowed, including other layers.
1599+
expect(isAllowedChildKey("layers-text", "adjust-blur")).toBe(false)
1600+
expect(isAllowedChildKey("layers-text", "adjust-shadow")).toBe(false)
1601+
expect(isAllowedChildKey("layers-text", "layers-image")).toBe(true)
1602+
// ^ Note: the layer-keys short-circuit returns true here. The picker
1603+
// additionally gates on canHostLayerChildren, which excludes text.
1604+
})
1605+
1606+
it("canHostLayerChildren only lets image/canvas host children", async () => {
1607+
const { canHostLayerChildren } = await import("./store")
1608+
expect(canHostLayerChildren("layers-image")).toBe(true)
1609+
expect(canHostLayerChildren("layers-canvas")).toBe(true)
1610+
expect(canHostLayerChildren("layers-text")).toBe(false)
1611+
expect(canHostLayerChildren("adjust-blur")).toBe(false)
1612+
})
1613+
1614+
it("getLayerDepth counts only layer ancestors, not non-layer ones", async () => {
1615+
const { getLayerDepth } = await import("./store")
1616+
const tree: Transformation[] = [
1617+
{
1618+
id: "root",
1619+
key: "layers-image",
1620+
name: "Root",
1621+
type: "transformation",
1622+
value: { imageUrl: "a.png" },
1623+
children: [
1624+
{
1625+
// Non-layer child of root layer — itself at depth 0 (it has
1626+
// zero *layer* ancestors above its parent slot).
1627+
id: "blur",
1628+
key: "adjust-blur",
1629+
name: "Blur",
1630+
type: "transformation",
1631+
value: { blur: 4 },
1632+
},
1633+
{
1634+
// Nested layer — depth 1.
1635+
id: "child",
1636+
key: "layers-image",
1637+
name: "Child",
1638+
type: "transformation",
1639+
value: { imageUrl: "b.png" },
1640+
children: [
1641+
{
1642+
id: "grand",
1643+
key: "layers-text",
1644+
name: "Grand",
1645+
type: "transformation",
1646+
value: { text: "hi", radius: 0 },
1647+
},
1648+
],
1649+
},
1650+
],
1651+
},
1652+
]
1653+
expect(getLayerDepth(tree, "root")).toBe(0)
1654+
// Non-layer children inherit the parent's depth (they don't open a
1655+
// new l-...,l-end scope).
1656+
expect(getLayerDepth(tree, "blur")).toBe(1)
1657+
expect(getLayerDepth(tree, "child")).toBe(1)
1658+
expect(getLayerDepth(tree, "grand")).toBe(2)
1659+
expect(getLayerDepth(tree, "missing")).toBeUndefined()
1660+
})
14711661
})
14721662

14731663
describe("Resize & Crop Complex Validations", () => {

packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,7 @@ import { RiCloseFill } from "@react-icons/all-files/ri/RiCloseFill"
3232
import { RxTransform } from "@react-icons/all-files/rx/RxTransform"
3333
import { useEffect, useMemo, useRef, useState } from "react"
3434
import {
35-
isLayerKey,
36-
MAX_LAYER_NEST_DEPTH,
35+
canHostLayerChildren,
3736
type Transformation,
3837
useEditorStore,
3938
} from "../../store"
@@ -86,12 +85,10 @@ export const SortableTransformationItem = ({
8685
} = useEditorStore()
8786

8887
const isRoot = depth === 0
89-
// Text layers cannot nest anything (per docs). Image and canvas layers can
90-
// host nested image/text/canvas children up to MAX_LAYER_NEST_DEPTH.
91-
const canHostChildren =
92-
isLayerKey(transformation.key) &&
93-
transformation.key !== "layers-text" &&
94-
depth < MAX_LAYER_NEST_DEPTH
88+
// Image and canvas layers can host children (nested layers gated by
89+
// MAX_LAYER_NEST_DEPTH inside the type picker, plus per-parent allow-listed
90+
// non-layer transforms). Text layers are leaves per ImageKit docs.
91+
const canHostChildren = canHostLayerChildren(transformation.key)
9592

9693
const style = transform
9794
? {
@@ -283,7 +280,11 @@ export const SortableTransformationItem = ({
283280
<Text
284281
as="span"
285282
position="absolute"
286-
right={4}
283+
// Compensate for the HStack's `ml` indent on nested rows: it
284+
// shifts the HStack's right edge past the parent panel by the
285+
// same amount, so the badge would otherwise clip. `right` is
286+
// relative to the HStack, so add the indent back here.
287+
right={depth > 0 ? 8 : 4}
287288
top="50%"
288289
transform="translateY(-50%)"
289290
fontSize="xs"

packages/imagekit-editor-dev/src/components/sidebar/transformation-type-sidebar.tsx

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@ import { PiX } from "@react-icons/all-files/pi/PiX"
2020
import { RiImageEditLine } from "@react-icons/all-files/ri/RiImageEditLine"
2121
import * as React from "react"
2222
import { transformationSchema } from "../../schema"
23-
import { useEditorStore } from "../../store"
23+
import {
24+
findTransformationDeep,
25+
getLayerDepth,
26+
isAllowedChildKey,
27+
isLayerKey,
28+
MAX_LAYER_NEST_DEPTH,
29+
useEditorStore,
30+
} from "../../store"
2431
import { SidebarBody } from "./sidebar-body"
2532
import { SidebarHeader } from "./sidebar-header"
2633
import { SidebarRoot } from "./sidebar-root"
@@ -36,10 +43,28 @@ export const TransformationTypeSidebar: React.FC = () => {
3643
const [searchQuery, setSearchQuery] = React.useState("")
3744

3845
// When the user opened the picker via the "+" on a layer row, the next
39-
// pick must be added as a nested child of that layer. In that mode we
40-
// restrict the picker to layer transformations only \u2014 nothing else can
41-
// legally nest inside a layer\u2019s transformation chain.
42-
const isChildAddMode = _internalState.parentForChild !== null
46+
// pick must be added as a nested child of that layer. In that mode the
47+
// picker shows: (a) other layer types, gated by MAX_LAYER_NEST_DEPTH, plus
48+
// (b) the parent layer's allow list of non-layer transformations (e.g.
49+
// adjust-*, ai-*) that the SDK accepts inside that layer's URL block.
50+
const parentForChildId = _internalState.parentForChild
51+
const isChildAddMode = parentForChildId !== null
52+
53+
const parentForChild = React.useMemo(
54+
() =>
55+
parentForChildId
56+
? findTransformationDeep(transformations, parentForChildId)
57+
: undefined,
58+
[transformations, parentForChildId],
59+
)
60+
61+
// Layer depth of the *parent* (root layer = 0). A new nested-layer child
62+
// would land at parentDepth + 1, so layer items are only offered while
63+
// `parentDepth + 1 <= MAX_LAYER_NEST_DEPTH`.
64+
const parentLayerDepth = React.useMemo(() => {
65+
if (!parentForChildId) return undefined
66+
return getLayerDepth(transformations, parentForChildId)
67+
}, [transformations, parentForChildId])
4368

4469
const onClose = () => {
4570
_setSidebarState("none")
@@ -55,19 +80,22 @@ export const TransformationTypeSidebar: React.FC = () => {
5580
)
5681

5782
const filteredTransformationSchema = React.useMemo(() => {
58-
const base = isChildAddMode
59-
? transformationSchema
60-
.map((category) => ({
61-
...category,
62-
items: category.items.filter(
63-
(item) =>
64-
item.key === "layers-text" ||
65-
item.key === "layers-image" ||
66-
item.key === "layers-canvas",
67-
),
68-
}))
69-
.filter((category) => category.items.length > 0)
70-
: transformationSchema
83+
let base = transformationSchema
84+
if (isChildAddMode && parentForChild) {
85+
const parentKey = parentForChild.key
86+
const canHostMoreLayers =
87+
parentLayerDepth !== undefined &&
88+
parentLayerDepth + 1 <= MAX_LAYER_NEST_DEPTH
89+
base = transformationSchema
90+
.map((category) => ({
91+
...category,
92+
items: category.items.filter((item) => {
93+
if (isLayerKey(item.key)) return canHostMoreLayers
94+
return isAllowedChildKey(parentKey, item.key)
95+
}),
96+
}))
97+
.filter((category) => category.items.length > 0)
98+
}
7199

72100
if (!searchQuery.trim()) {
73101
return base
@@ -81,7 +109,7 @@ export const TransformationTypeSidebar: React.FC = () => {
81109
),
82110
}))
83111
.filter((category) => category.items.length > 0)
84-
}, [searchQuery, isChildAddMode])
112+
}, [searchQuery, isChildAddMode, parentForChild, parentLayerDepth])
85113

86114
const handleSelectTransformation = (key: string) => {
87115
const transformation = transformationSchema
@@ -100,7 +128,7 @@ export const TransformationTypeSidebar: React.FC = () => {
100128
<SidebarRoot>
101129
<SidebarHeader justifyContent="space-between">
102130
<Text fontSize="md" fontWeight="normal" mt={0}>
103-
{isChildAddMode ? "Add Nested Layer" : "Add Transformation"}
131+
{isChildAddMode ? "Add Nested Transformation" : "Add Transformation"}
104132
</Text>
105133
{hasTransformations && (
106134
<IconButton

0 commit comments

Comments
 (0)