Skip to content

Commit bee1904

Browse files
committed
feat: creative automation
1 parent 561d2e2 commit bee1904

9 files changed

Lines changed: 464 additions & 145 deletions

File tree

examples/react-example/src/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,9 @@ function App() {
254254
return Promise.resolve(request.url)
255255
},
256256
templateStorage: createLocalTemplateStorage(),
257+
initialTemplateId:
258+
new URLSearchParams(window.location.search).get("templateId") ??
259+
undefined,
257260
})
258261
}, [handleAddImage])
259262

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,36 @@ function ImageKitEditorImpl<M extends RequiredMetadata>(
222222
})
223223
}, [initialImages, signer, focusObjects, initialize])
224224

225+
// Load template by ID when initialTemplateId is provided or changes
226+
const lastLoadedTemplateIdRef = React.useRef<string | null>(null)
227+
React.useEffect(() => {
228+
if (!props.initialTemplateId || !resolvedProvider) return
229+
// Skip if we already loaded this exact template
230+
if (lastLoadedTemplateIdRef.current === props.initialTemplateId) return
231+
lastLoadedTemplateIdRef.current = props.initialTemplateId
232+
233+
let cancelled = false
234+
resolvedProvider
235+
.getTemplate(props.initialTemplateId)
236+
.then((record) => {
237+
if (cancelled || !record) return
238+
loadTemplate(record.transformations)
239+
useEditorStore.getState().hydrateTemplateMetadata({
240+
templateId: record.id,
241+
templateName: record.name,
242+
templateIsPrivate:
243+
typeof record.isPrivate === "boolean" ? record.isPrivate : null,
244+
})
245+
})
246+
.catch((err) => {
247+
if (cancelled) return
248+
console.error("Failed to load initial template:", err)
249+
})
250+
return () => {
251+
cancelled = true
252+
}
253+
}, [props.initialTemplateId, resolvedProvider, loadTemplate])
254+
225255
useImperativeHandle(
226256
ref,
227257
() => ({
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import {
2+
Badge,
3+
Box,
4+
Flex,
5+
IconButton,
6+
Input,
7+
Text,
8+
Tooltip,
9+
} from "@chakra-ui/react"
10+
import { PiBracketsCurly } from "@react-icons/all-files/pi/PiBracketsCurly"
11+
import { PiX } from "@react-icons/all-files/pi/PiX"
12+
import { type FC } from "react"
13+
14+
interface Props {
15+
/** The variable label (human-readable). */
16+
label: string
17+
/** The variable's stable name (`$var`). Shown for reference. */
18+
name: string
19+
/** Optional default value the variable resolves to when no override exists. */
20+
defaultValue: unknown
21+
/** Called with the new default value when the user edits it inline. */
22+
onDefaultValueChange: (next: string) => void
23+
/** Remove the variable marker and restore the field to a plain input. */
24+
onRemove: () => void
25+
}
26+
27+
/**
28+
* Renders in place of a regular field input when the field has been bound to
29+
* a template variable. Shows the variable label/name, lets the user edit the
30+
* default value inline, and offers a "remove variable" affordance.
31+
*/
32+
export const VariableChip: FC<Props> = ({
33+
label,
34+
name,
35+
defaultValue,
36+
onDefaultValueChange,
37+
onRemove,
38+
}) => {
39+
const defaultStr =
40+
defaultValue == null || typeof defaultValue === "object"
41+
? ""
42+
: String(defaultValue)
43+
44+
return (
45+
<Box
46+
borderWidth="1px"
47+
borderColor="editorBlue.300"
48+
bg="editorBlue.50"
49+
borderRadius="md"
50+
px={2}
51+
py={2}
52+
>
53+
<Flex align="center" justify="space-between" mb={1}>
54+
<Flex align="center" gap={1.5} minW={0}>
55+
<PiBracketsCurly />
56+
<Tooltip label={`{{${name}}}`} hasArrow placement="top">
57+
<Badge
58+
colorScheme="blue"
59+
fontSize="xs"
60+
textTransform="none"
61+
maxW="180px"
62+
overflow="hidden"
63+
textOverflow="ellipsis"
64+
whiteSpace="nowrap"
65+
>
66+
{label}
67+
</Badge>
68+
</Tooltip>
69+
</Flex>
70+
<Tooltip label="Remove variable" hasArrow placement="top">
71+
<IconButton
72+
aria-label="Remove variable"
73+
icon={<PiX />}
74+
size="xs"
75+
variant="ghost"
76+
onClick={onRemove}
77+
/>
78+
</Tooltip>
79+
</Flex>
80+
<Text fontSize="xs" color="gray.600" mb={1}>
81+
Default value
82+
</Text>
83+
<Input
84+
size="sm"
85+
value={defaultStr}
86+
onChange={(e) => onDefaultValueChange(e.target.value)}
87+
placeholder="No default — leave empty"
88+
bg="white"
89+
/>
90+
</Box>
91+
)
92+
}

packages/imagekit-editor-dev/src/components/sidebar/VariableMarkerButton.tsx

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,31 +41,44 @@ export function canBeVariable(fieldType: string | undefined): boolean {
4141
interface Props {
4242
fieldLabel: string
4343
takenNames: Iterable<string>
44-
onCreate: (variable: { name: string; label: string }) => void
44+
/** Current value of the field, used to seed the default-value input. */
45+
currentValue?: unknown
46+
onCreate: (variable: {
47+
name: string
48+
label: string
49+
defaultValue: string
50+
}) => void
4551
}
4652

4753
/**
4854
* A small button shown next to field labels in the sidebar. Clicking opens
49-
* a popover to name the variable. On save, it generates a collision-free
50-
* name and calls onCreate.
55+
* a popover to name the variable and (optionally) supply a default value
56+
* rendered when no row override exists.
5157
*/
5258
export const VariableMarkerButton: FC<Props> = ({
5359
fieldLabel,
5460
takenNames,
61+
currentValue,
5562
onCreate,
5663
}) => {
5764
const { isOpen, onOpen, onClose } = useDisclosure()
5865
const [label, setLabel] = useState(fieldLabel)
66+
const [defaultValue, setDefaultValue] = useState("")
5967

6068
const handleOpen = useCallback(() => {
6169
setLabel(fieldLabel)
70+
setDefaultValue(
71+
currentValue == null || typeof currentValue === "object"
72+
? ""
73+
: String(currentValue),
74+
)
6275
onOpen()
63-
}, [fieldLabel, onOpen])
76+
}, [fieldLabel, currentValue, onOpen])
6477

6578
const handleSave = () => {
6679
const trimmed = label.trim() || fieldLabel
6780
const name = generateVariableName(trimmed, takenNames)
68-
onCreate({ name, label: trimmed })
81+
onCreate({ name, label: trimmed, defaultValue: defaultValue.trim() })
6982
onClose()
7083
}
7184

@@ -82,7 +95,7 @@ export const VariableMarkerButton: FC<Props> = ({
8295
onClick={handleOpen}
8396
/>
8497
</PopoverTrigger>
85-
<PopoverContent w="260px">
98+
<PopoverContent w="280px">
8699
<PopoverBody p={3}>
87100
<Flex justify="space-between" align="center" mb={2}>
88101
<Text fontSize="sm" fontWeight="600">
@@ -96,7 +109,7 @@ export const VariableMarkerButton: FC<Props> = ({
96109
onClick={onClose}
97110
/>
98111
</Flex>
99-
<FormControl>
112+
<FormControl mb={2}>
100113
<FormLabel fontSize="xs">Label</FormLabel>
101114
<Input
102115
size="sm"
@@ -108,6 +121,18 @@ export const VariableMarkerButton: FC<Props> = ({
108121
}}
109122
/>
110123
</FormControl>
124+
<FormControl>
125+
<FormLabel fontSize="xs">Default value</FormLabel>
126+
<Input
127+
size="sm"
128+
value={defaultValue}
129+
onChange={(e) => setDefaultValue(e.target.value)}
130+
placeholder="Optional — used when no row override is supplied"
131+
onKeyDown={(e) => {
132+
if (e.key === "Enter") handleSave()
133+
}}
134+
/>
135+
</FormControl>
111136
<Button
112137
size="sm"
113138
colorScheme="blue"

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

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,13 @@ import {
5454
} from "../../schema"
5555
import { type SyncStatus, useEditorStore } from "../../store"
5656
import { isStepAligned } from "../../utils"
57-
import { walkVariableRefs } from "../../variables"
57+
import { isVariableRef, walkVariableRefs, type VariableRef } from "../../variables"
58+
import { makeVariableAwareResolver } from "../../variables/resolver"
5859
import AnchorField from "../common/AnchorField"
5960
import CheckboxCardField from "../common/CheckboxCardField"
6061
import ColorPickerField from "../common/ColorPickerField"
6162
import { canBeVariable, VariableMarkerButton } from "./VariableMarkerButton"
63+
import { VariableChip } from "./VariableChip"
6264
import RadiusInputField, {
6365
type RadiusErrors,
6466
type RadiusState,
@@ -248,7 +250,9 @@ export const TransformationConfigSidebar: React.FC = () => {
248250
control,
249251
trigger,
250252
} = useForm<Record<string, unknown>>({
251-
resolver: zodResolver(selectedTransformation?.schema ?? z.object({})),
253+
resolver: makeVariableAwareResolver(
254+
zodResolver(selectedTransformation?.schema ?? z.object({})),
255+
),
252256
defaultValues: defaultValues,
253257
})
254258

@@ -579,17 +583,55 @@ export const TransformationConfigSidebar: React.FC = () => {
579583
<FormLabel htmlFor={field.name} fontSize="sm" mb={0}>
580584
{field.label}
581585
</FormLabel>
582-
{canBeVariable(field.fieldType) && (
586+
{canBeVariable(field.fieldType) && !isVariableRef(values[field.name]) && (
583587
<VariableMarkerButton
584588
fieldLabel={field.label}
585589
takenNames={takenVariableNames}
590+
currentValue={values[field.name]}
586591
onCreate={(variable) => {
587-
setValue(field.name, { $var: variable.name, label: variable.label } as any, { shouldDirty: true })
592+
setValue(
593+
field.name,
594+
{
595+
$var: variable.name,
596+
label: variable.label,
597+
...(variable.defaultValue
598+
? { defaultValue: variable.defaultValue }
599+
: {}),
600+
} as any,
601+
{ shouldDirty: true },
602+
)
588603
}}
589604
/>
590605
)}
591606
</HStack>
592-
{field.fieldType === "select" ? (
607+
{isVariableRef(values[field.name]) ? (
608+
<VariableChip
609+
label={(values[field.name] as VariableRef).label}
610+
name={(values[field.name] as VariableRef).$var}
611+
defaultValue={(values[field.name] as VariableRef).defaultValue}
612+
onDefaultValueChange={(next) => {
613+
const ref = values[field.name] as VariableRef
614+
setValue(
615+
field.name,
616+
{
617+
$var: ref.$var,
618+
label: ref.label,
619+
...(next ? { defaultValue: next } : {}),
620+
} as any,
621+
{ shouldDirty: true },
622+
)
623+
}}
624+
onRemove={() => {
625+
const ref = values[field.name] as VariableRef
626+
const restored =
627+
ref.defaultValue != null && typeof ref.defaultValue !== "object"
628+
? ref.defaultValue
629+
: (field.fieldProps?.defaultValue ?? "")
630+
setValue(field.name, restored as any, { shouldDirty: true })
631+
}}
632+
/>
633+
) : null}
634+
{!isVariableRef(values[field.name]) && field.fieldType === "select" ? (
593635
<Controller
594636
name={field.name}
595637
control={control}
@@ -692,7 +734,7 @@ export const TransformationConfigSidebar: React.FC = () => {
692734
}}
693735
/>
694736
) : null}
695-
{field.fieldType === "select-creatable" ? (
737+
{!isVariableRef(values[field.name]) && field.fieldType === "select-creatable" ? (
696738
<Controller
697739
name={field.name}
698740
control={control}
@@ -732,7 +774,7 @@ export const TransformationConfigSidebar: React.FC = () => {
732774
)}
733775
/>
734776
) : null}
735-
{field.fieldType === "input" ? (
777+
{!isVariableRef(values[field.name]) && field.fieldType === "input" ? (
736778
<Input
737779
id={field.name}
738780
fontSize="sm"
@@ -755,22 +797,22 @@ export const TransformationConfigSidebar: React.FC = () => {
755797
}
756798
/>
757799
) : null}
758-
{field.fieldType === "textarea" ? (
800+
{!isVariableRef(values[field.name]) && field.fieldType === "textarea" ? (
759801
<Textarea
760802
id={field.name}
761803
fontSize="sm"
762804
{...register(field.name)}
763805
/>
764806
) : null}
765-
{field.fieldType === "switch" ? (
807+
{!isVariableRef(values[field.name]) && field.fieldType === "switch" ? (
766808
<Switch
767809
id={field.name}
768810
fontSize="sm"
769811
isChecked={watch(field.name) === true}
770812
{...register(field.name)}
771813
/>
772814
) : null}
773-
{field.fieldType === "slider" ? (
815+
{!isVariableRef(values[field.name]) && field.fieldType === "slider" ? (
774816
<Box pt={2} pb={2}>
775817
<Flex justify="space-between" mb={1}>
776818
<Input
@@ -935,7 +977,7 @@ export const TransformationConfigSidebar: React.FC = () => {
935977
</Slider>
936978
</Box>
937979
) : null}
938-
{field.fieldType === "color-picker" ? (
980+
{!isVariableRef(values[field.name]) && field.fieldType === "color-picker" ? (
939981
<ColorPickerField
940982
fieldName={field.name}
941983
value={watch(field.name) as string}
@@ -974,7 +1016,7 @@ export const TransformationConfigSidebar: React.FC = () => {
9741016
}
9751017
/>
9761018
) : null}
977-
{field.fieldType === "radio-card" ? (
1019+
{!isVariableRef(values[field.name]) && field.fieldType === "radio-card" ? (
9781020
<RadioCardField
9791021
value={watch(field.name) as string}
9801022
options={field.fieldProps?.options ?? []}
@@ -987,7 +1029,7 @@ export const TransformationConfigSidebar: React.FC = () => {
9871029
{...field.fieldProps}
9881030
/>
9891031
) : null}
990-
{field.fieldType === "checkbox-card" ? (
1032+
{!isVariableRef(values[field.name]) && field.fieldType === "checkbox-card" ? (
9911033
<CheckboxCardField
9921034
value={watch(field.name) as string[]}
9931035
options={field.fieldProps?.options ?? []}

0 commit comments

Comments
 (0)