Skip to content

Commit beb5af2

Browse files
committed
feat: add dynamic variables support with custom metadata fields and bulk generation
1 parent 25363e2 commit beb5af2

20 files changed

Lines changed: 2972 additions & 612 deletions

File tree

examples/react-example/src/index.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import {
2+
type CustomMetadataFieldDefinition,
3+
type DynamicVariableDefinition,
24
ImageKitEditor,
35
type ImageKitEditorProps,
46
type ImageKitEditorRef,
57
type TemplateStorageProvider,
68
TRANSFORMATION_STATE_VERSION,
79
type Transformation,
10+
type VariableAssetResolver,
811
} from "@imagekit/editor"
912
import React, { useCallback, useEffect } from "react"
1013
import ReactDOM from "react-dom"
@@ -18,6 +21,8 @@ type StoredTemplateRecord = {
1821
isPinned: boolean
1922
name: string
2023
transformations: Omit<Transformation, "id">[]
24+
variables?: DynamicVariableDefinition[]
25+
urlTemplate?: string
2126
createdBy: { userId: string; name: string; email: string }
2227
updatedBy: { userId: string; name: string; email: string }
2328
createdAt: number
@@ -70,6 +75,8 @@ function createLocalTemplateStorage(): TemplateStorageProvider {
7075
isPinned: input.isPinned ?? existing?.isPinned ?? false,
7176
name: input.name,
7277
transformations: input.transformations,
78+
variables: input.variables ?? existing?.variables,
79+
urlTemplate: input.urlTemplate,
7380
createdBy: input.createdBy ??
7481
existing?.createdBy ?? {
7582
userId: session.userId,
@@ -112,6 +119,65 @@ function createLocalTemplateStorage(): TemplateStorageProvider {
112119
}
113120
}
114121

122+
const SAMPLE_CUSTOM_METADATA_FIELDS: CustomMetadataFieldDefinition[] = [
123+
{
124+
id: "1",
125+
name: "brand",
126+
label: "Brand",
127+
schema: { type: "Text", maxLength: 100 },
128+
},
129+
{
130+
id: "2",
131+
name: "category",
132+
label: "Category",
133+
schema: {
134+
type: "SingleSelect",
135+
selectOptions: ["shirts", "pants", "shoes", "accessories"],
136+
},
137+
},
138+
{
139+
id: "3",
140+
name: "price",
141+
label: "Price",
142+
schema: { type: "Number", minValue: 0 },
143+
},
144+
{
145+
id: "4",
146+
name: "isActive",
147+
label: "Active",
148+
schema: { type: "Boolean" },
149+
},
150+
{
151+
id: "5",
152+
name: "publishDate",
153+
label: "Publish Date",
154+
schema: { type: "Date" },
155+
},
156+
{
157+
id: "6",
158+
name: "colors",
159+
label: "Colors",
160+
schema: {
161+
type: "MultiSelect",
162+
selectOptions: ["red", "blue", "green", "black", "white"],
163+
},
164+
},
165+
]
166+
167+
const sampleVariableAssetResolver: VariableAssetResolver = async (request) => {
168+
console.log(
169+
"[variableAssetResolver] variable:",
170+
request.variable.name,
171+
"query:",
172+
request.query,
173+
)
174+
await new Promise((resolve) => setTimeout(resolve, 500))
175+
const mockPath = request.query.path
176+
? `${request.query.path.replace(/\/$/, "")}/sample.jpg`
177+
: "/sample.jpg"
178+
return { value: mockPath }
179+
}
180+
115181
function App() {
116182
const [open, setOpen] = React.useState(true)
117183
const [editorProps, setEditorProps] =
@@ -254,6 +320,14 @@ function App() {
254320
return Promise.resolve(request.url)
255321
},
256322
templateStorage: createLocalTemplateStorage(),
323+
customMetadataFields: SAMPLE_CUSTOM_METADATA_FIELDS,
324+
variableAssetResolver: sampleVariableAssetResolver,
325+
onBulkGenerate: (template) => {
326+
console.log("Bulk generate requested for template:", template)
327+
alert(
328+
`Bulk Generate with CSV for "${template.name}" (id: ${template.id})`,
329+
)
330+
},
257331
})
258332
}, [handleAddImage])
259333

packages/imagekit-editor-dev/src/ImageKitEditor.tsx

Lines changed: 164 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,21 @@ import merge from "lodash/merge"
44
import React, {
55
forwardRef,
66
useCallback,
7+
useEffect,
78
useImperativeHandle,
89
useMemo,
10+
useRef,
911
} from "react"
1012
import { EditorLayout, EditorWrapper } from "./components/editor"
1113
import type { HeaderProps } from "./components/header"
1214
import type { GetTemplatePermissions } from "./context/TemplatePermissionsContext"
1315
import { TemplatePermissionsContextProvider } from "./context/TemplatePermissionsContext"
1416
import { TemplateStorageContextProvider } from "./context/TemplateStorageContext"
1517
import {
18+
type DynamicVariableDefinition,
1619
isTemplateAccessDeniedError,
1720
type TemplateStorageProvider,
21+
type VariableAssetResolver,
1822
} from "./storage"
1923
import {
2024
type FocusObjects,
@@ -25,6 +29,7 @@ import {
2529
useEditorStore,
2630
} from "./store"
2731
import { themeOverrides } from "./theme"
32+
import { buildUrlTemplate } from "./utils/buildUrlTemplate"
2833

2934
export interface ImageKitEditorRef {
3035
/**
@@ -46,29 +51,45 @@ export interface ImageKitEditorRef {
4651
setCurrentImage: (imageSrc: string) => void
4752

4853
/**
49-
* Gets the current editor template (transformation stack)
50-
* @returns Array of transformation objects representing the template
54+
* Gets the current editor template including transformations and variables.
55+
* @returns Object with transformations and variables
5156
* @example
5257
* ```tsx
53-
* const template = editorRef.current?.getTemplate()
58+
* const { transformations, variables } = editorRef.current?.getTemplate()
5459
* // Save to localStorage or backend
55-
* localStorage.setItem('editorTemplate', JSON.stringify(
56-
* template.map(({ id, ...rest }) => rest)
57-
* ))
60+
* localStorage.setItem('editorTemplate', JSON.stringify({ transformations, variables }))
5861
* ```
5962
*/
60-
getTemplate: () => Transformation[]
63+
getTemplate: () => {
64+
transformations: Transformation[]
65+
variables: DynamicVariableDefinition[]
66+
/**
67+
* URL transformation string with `{{variableName}}` placeholders.
68+
* Empty string when no transformations are active.
69+
*/
70+
urlTemplate: string
71+
}
6172

6273
/**
63-
* Loads a template (transformation stack) into the editor
64-
* @param template - Array of transformation objects without the 'id' field
74+
* Loads a template (transformations + variables) into the editor.
75+
* Accepts either a full payload or a legacy transformations-only array.
6576
* @example
6677
* ```tsx
67-
* const saved = JSON.parse(localStorage.getItem('editorTemplate'))
68-
* editorRef.current?.loadTemplate(saved)
78+
* // Full payload (recommended)
79+
* editorRef.current?.loadTemplate({ transformations, variables })
80+
*
81+
* // Legacy: transformations-only array
82+
* editorRef.current?.loadTemplate(savedTransformations)
6983
* ```
7084
*/
71-
loadTemplate: (template: Omit<Transformation, "id">[]) => void
85+
loadTemplate: (
86+
template:
87+
| Omit<Transformation, "id">[]
88+
| {
89+
transformations: Omit<Transformation, "id">[]
90+
variables?: DynamicVariableDefinition[]
91+
},
92+
) => void
7293

7394
/**
7495
* Explicitly saves the current template to the configured storage provider.
@@ -91,11 +112,34 @@ interface EditorProps<Metadata extends RequiredMetadata = RequiredMetadata> {
91112
* Omit or pass `null` to disable template sync UI.
92113
*/
93114
templateStorage?: TemplateStorageProvider | null
115+
variableAssetResolver?: VariableAssetResolver
116+
/**
117+
* Custom metadata field definitions from the host app (matches the shape of
118+
* the ImageKit Get Custom Metadata Fields API response). When provided, the
119+
* Variables modal renders typed form fields for each definition instead of
120+
* falling back to free-form key-value pairs.
121+
*/
122+
customMetadataFields?: import("./variables/types").CustomMetadataFieldDefinition[]
94123
/**
95124
* Host-controlled, per-template permissions for template management UI.
96125
* If omitted, the editor defaults to allowing all actions.
97126
*/
98127
getTemplatePermissions?: GetTemplatePermissions
128+
/**
129+
* Called whenever dynamic variables change (add, update, remove).
130+
* Use this to persist variables to your backend or localStorage.
131+
*/
132+
onVariablesChange?: (variables: DynamicVariableDefinition[]) => void
133+
/**
134+
* Called when the user clicks "Bulk Generate" on a template row.
135+
* The host app handles CSV upload and batch URL generation.
136+
*/
137+
onBulkGenerate?: (template: { id: string; name: string }) => void
138+
/**
139+
* When provided, the editor fetches this template from `templateStorage`
140+
* on mount and loads its transformations and variables.
141+
*/
142+
templateId?: string
99143
}
100144

101145
function ImageKitEditorImpl<M extends RequiredMetadata>(
@@ -108,16 +152,18 @@ function ImageKitEditorImpl<M extends RequiredMetadata>(
108152
signer,
109153
focusObjects,
110154
templateStorage,
155+
variableAssetResolver,
156+
customMetadataFields,
111157
getTemplatePermissions,
112158
} = props
113159
const {
114160
addImage,
115161
addImages,
116162
setCurrentImage,
117-
transformations,
118163
initialize,
119164
destroy,
120165
loadTemplate,
166+
loadTemplatePayload,
121167
} = useEditorStore()
122168

123169
const resolvedProvider = useMemo<TemplateStorageProvider | null>(
@@ -140,6 +186,12 @@ function ImageKitEditorImpl<M extends RequiredMetadata>(
140186
transformations: state.transformations.map(
141187
({ id: _id, ...rest }) => rest,
142188
),
189+
variables: state.dynamicVariables,
190+
urlTemplate: buildUrlTemplate(
191+
state.transformations,
192+
state.visibleTransformations,
193+
state.dynamicVariables,
194+
),
143195
...(state.templateIsPrivate !== null
144196
? { isPrivate: state.templateIsPrivate }
145197
: {}),
@@ -204,25 +256,120 @@ function ImageKitEditorImpl<M extends RequiredMetadata>(
204256
imageList: initialImages,
205257
signer,
206258
focusObjects,
259+
variableAssetResolver,
260+
customMetadataFields,
261+
onBulkGenerate: props.onBulkGenerate,
262+
onAddImage: props.onAddImage,
263+
})
264+
// Trigger onAddImage if initial image list is empty
265+
if (!initialImages || initialImages.length === 0) {
266+
props.onAddImage?.()
267+
}
268+
}, [
269+
initialImages,
270+
signer,
271+
focusObjects,
272+
variableAssetResolver,
273+
customMetadataFields,
274+
initialize,
275+
])
276+
277+
// Keep onBulkGenerate in sync with latest prop value
278+
useEffect(() => {
279+
useEditorStore.setState({ onBulkGenerate: props.onBulkGenerate })
280+
}, [props.onBulkGenerate])
281+
282+
// Keep onAddImage in sync with latest prop value
283+
useEffect(() => {
284+
useEditorStore.setState({ onAddImage: props.onAddImage })
285+
}, [props.onAddImage])
286+
287+
// Trigger onAddImage when image list becomes empty
288+
useEffect(() => {
289+
return useEditorStore.subscribe(
290+
(state) => state.originalImageList.length,
291+
(count, prevCount) => {
292+
// Trigger when transitioning to empty (0 images) and not on initial mount (prevCount > 0)
293+
if (count === 0 && prevCount > 0) {
294+
useEditorStore.getState().onAddImage?.()
295+
}
296+
},
297+
)
298+
}, [])
299+
300+
// Load template by ID from storage on mount
301+
const templateIdProp = props.templateId
302+
const hasLoadedTemplateRef = useRef(false)
303+
useEffect(() => {
304+
if (!templateIdProp || !resolvedProvider || hasLoadedTemplateRef.current)
305+
return
306+
hasLoadedTemplateRef.current = true
307+
resolvedProvider.getTemplate(templateIdProp).then((record) => {
308+
if (!record) return
309+
loadTemplatePayload({
310+
transformations: record.transformations,
311+
variables: record.variables,
312+
})
313+
useEditorStore.getState().hydrateTemplateMetadata({
314+
templateId: record.id,
315+
templateName: record.name,
316+
templateIsPrivate: record.isPrivate,
317+
})
207318
})
208-
}, [initialImages, signer, focusObjects, initialize])
319+
}, [templateIdProp, resolvedProvider, loadTemplatePayload])
320+
321+
// Fire onVariablesChange callback when dynamic variables change
322+
const onVariablesChangeRef = useRef(props.onVariablesChange)
323+
onVariablesChangeRef.current = props.onVariablesChange
324+
useEffect(() => {
325+
return useEditorStore.subscribe(
326+
(state) => state.dynamicVariables,
327+
(variables) => {
328+
onVariablesChangeRef.current?.(variables)
329+
},
330+
)
331+
}, [])
209332

210333
useImperativeHandle(
211334
ref,
212335
() => ({
213336
loadImage: addImage,
214337
loadImages: addImages,
215338
setCurrentImage,
216-
getTemplate: () => transformations,
217-
loadTemplate,
339+
getTemplate: () => {
340+
const state = useEditorStore.getState()
341+
return {
342+
transformations: state.transformations,
343+
variables: state.dynamicVariables,
344+
urlTemplate: buildUrlTemplate(
345+
state.transformations,
346+
state.visibleTransformations,
347+
state.dynamicVariables,
348+
),
349+
}
350+
},
351+
loadTemplate: (
352+
template:
353+
| Omit<Transformation, "id">[]
354+
| {
355+
transformations: Omit<Transformation, "id">[]
356+
variables?: DynamicVariableDefinition[]
357+
},
358+
) => {
359+
if (Array.isArray(template)) {
360+
loadTemplate(template)
361+
} else {
362+
loadTemplatePayload(template)
363+
}
364+
},
218365
saveTemplate: saveTemplateImperative,
219366
}),
220367
[
221368
addImage,
222369
addImages,
223370
setCurrentImage,
224-
transformations,
225371
loadTemplate,
372+
loadTemplatePayload,
226373
saveTemplateImperative,
227374
],
228375
)

0 commit comments

Comments
 (0)