@@ -4,17 +4,21 @@ import merge from "lodash/merge"
44import React , {
55 forwardRef ,
66 useCallback ,
7+ useEffect ,
78 useImperativeHandle ,
89 useMemo ,
10+ useRef ,
911} from "react"
1012import { EditorLayout , EditorWrapper } from "./components/editor"
1113import type { HeaderProps } from "./components/header"
1214import type { GetTemplatePermissions } from "./context/TemplatePermissionsContext"
1315import { TemplatePermissionsContextProvider } from "./context/TemplatePermissionsContext"
1416import { TemplateStorageContextProvider } from "./context/TemplateStorageContext"
1517import {
18+ type DynamicVariableDefinition ,
1619 isTemplateAccessDeniedError ,
1720 type TemplateStorageProvider ,
21+ type VariableAssetResolver ,
1822} from "./storage"
1923import {
2024 type FocusObjects ,
@@ -25,6 +29,7 @@ import {
2529 useEditorStore ,
2630} from "./store"
2731import { themeOverrides } from "./theme"
32+ import { buildUrlTemplate } from "./utils/buildUrlTemplate"
2833
2934export 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
101145function 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