Skip to content

Commit 76e2472

Browse files
committed
feat: add VariablesListPopover component and integrate variable display in ActionBar
1 parent 8419b8c commit 76e2472

4 files changed

Lines changed: 246 additions & 28 deletions

File tree

packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { ExternalLinkIcon } from "@chakra-ui/icons"
22
import {
3-
Badge,
43
Box,
54
Button,
65
Divider,
@@ -15,14 +14,17 @@ import {
1514
Text,
1615
Tooltip,
1716
} from "@chakra-ui/react"
18-
import { PiBracketsCurly } from "@react-icons/all-files/pi/PiBracketsCurly"
1917
import { PiGridFour } from "@react-icons/all-files/pi/PiGridFour"
2018
import { PiImageSquare } from "@react-icons/all-files/pi/PiImageSquare"
2119
import { PiListBullets } from "@react-icons/all-files/pi/PiListBullets"
2220
import { type FC, useMemo } from "react"
23-
import { useEditorStore } from "../../store"
21+
import { findTransformationDeep, useEditorStore } from "../../store"
2422
import { listVariables } from "../../variables/listVariables"
2523
import { CanvasSettingsPopover } from "./CanvasSettingsPopover"
24+
import {
25+
VariablesListPopover,
26+
type VariableListEntry,
27+
} from "./VariablesListPopover"
2628

2729
interface ActionBarProps {
2830
viewMode: "list" | "grid"
@@ -51,10 +53,28 @@ export const ActionBar: FC<ActionBarProps> = ({
5153
// Variables are a canvas-mode-only feature; the count badge is the only
5254
// affordance in the action bar (per-field hover affordances live in the
5355
// sidebar). Skip the work entirely outside canvas mode.
54-
const variableCount = useMemo(
55-
() => (isCanvas ? listVariables(transformations).length : 0),
56+
const variables = useMemo(
57+
() => (isCanvas ? listVariables(transformations) : []),
5658
[isCanvas, transformations],
5759
)
60+
// Resolve each variable's owning step name once so the popover doesn't
61+
// re-walk the (potentially nested) transformation tree on every render.
62+
// Explicit return type keeps the Chakra style-prop unions inside
63+
// ActionBar's JSX from blowing past TypeScript's inference budget.
64+
const variableEntries = useMemo<VariableListEntry[]>(
65+
() =>
66+
variables.map((v) => ({
67+
name: v.name,
68+
label: v.label,
69+
defaultValue: v.defaultValue,
70+
description: v.description,
71+
fieldLabel: v.field.label,
72+
stepName:
73+
findTransformationDeep(transformations, v.transformationId)?.name ??
74+
"Unknown step",
75+
})),
76+
[variables, transformations],
77+
)
5878

5979
const imageDimensions = useMemo(() => {
6080
const idx = imageList.findIndex((img) => img === currentImage)
@@ -128,28 +148,7 @@ export const ActionBar: FC<ActionBarProps> = ({
128148
h="6"
129149
borderColor="editorBattleshipGrey.200"
130150
/>
131-
<Tooltip
132-
label={
133-
variableCount === 0
134-
? "Hover any field label in the sidebar and click {} to make it a variable"
135-
: `${variableCount} template variable${variableCount === 1 ? "" : "s"} defined`
136-
}
137-
placement="bottom"
138-
>
139-
<HStack spacing="1" px="2" color="gray.700">
140-
<Icon as={PiBracketsCurly} boxSize={4} />
141-
<Text fontSize="sm" fontWeight="medium">
142-
Variables
143-
</Text>
144-
<Badge
145-
colorScheme={variableCount > 0 ? "purple" : "gray"}
146-
borderRadius="full"
147-
px="2"
148-
>
149-
{variableCount}
150-
</Badge>
151-
</HStack>
152-
</Tooltip>
151+
<VariablesListPopover entries={variableEntries} />
153152
</>
154153
)}
155154

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import {
2+
Badge,
3+
Box,
4+
Divider,
5+
Flex,
6+
HStack,
7+
Icon,
8+
Popover,
9+
PopoverArrow,
10+
PopoverBody,
11+
PopoverContent,
12+
PopoverHeader,
13+
PopoverTrigger,
14+
Text,
15+
VStack,
16+
} from "@chakra-ui/react"
17+
import { PiBracketsCurly } from "@react-icons/all-files/pi/PiBracketsCurly"
18+
import type { FC } from "react"
19+
20+
/**
21+
* The popover's row shape is intentionally narrow (no schema field types)
22+
* so importing it into `ActionBar.tsx` doesn't drag the large
23+
* `TransformationField` union into that file's JSX inference and trip
24+
* "union type too complex to represent".
25+
*/
26+
export interface VariableListEntry {
27+
name: string
28+
label: string
29+
defaultValue?: unknown
30+
description?: string
31+
stepName: string
32+
fieldLabel: string
33+
}
34+
35+
interface VariablesListPopoverProps {
36+
entries: VariableListEntry[]
37+
}
38+
39+
/**
40+
* Read-only popover that lists every template variable defined in the
41+
* current canvas. Lives in its own file so the (already large) Chakra
42+
* style-prop unions in `ActionBar.tsx` don't blow past TypeScript's
43+
* inference budget when this UI is added.
44+
*/
45+
export const VariablesListPopover: FC<VariablesListPopoverProps> = ({
46+
entries,
47+
}) => {
48+
const count = entries.length
49+
return (
50+
<Popover placement="bottom-start" isLazy>
51+
<PopoverTrigger>
52+
<HStack
53+
as="button"
54+
type="button"
55+
spacing="1"
56+
px="2"
57+
py="1"
58+
borderRadius="md"
59+
color="gray.700"
60+
cursor="pointer"
61+
_hover={{ bg: "gray.100" }}
62+
aria-label={
63+
count === 0
64+
? "No template variables defined"
65+
: `View ${count} template variable${count === 1 ? "" : "s"}`
66+
}
67+
>
68+
<Icon as={PiBracketsCurly} boxSize={4} />
69+
<Text fontSize="sm" fontWeight="medium">
70+
Variables
71+
</Text>
72+
<Badge
73+
colorScheme={count > 0 ? "purple" : "gray"}
74+
borderRadius="full"
75+
px="2"
76+
>
77+
{count}
78+
</Badge>
79+
</HStack>
80+
</PopoverTrigger>
81+
<PopoverContent width="360px" maxH="400px" overflow="hidden">
82+
<PopoverArrow />
83+
<PopoverHeader fontSize="sm" fontWeight="semibold">
84+
Template variables{" "}
85+
<Text as="span" color="gray.500" fontWeight="normal">
86+
({count})
87+
</Text>
88+
</PopoverHeader>
89+
<PopoverBody p="0" overflowY="auto" maxH="340px">
90+
{count === 0 ? (
91+
<Box p="4">
92+
<Text fontSize="sm" color="gray.600">
93+
No variables yet. Hover any field label in the sidebar and
94+
click{" "}
95+
<Text as="span" fontFamily="mono">
96+
{"{}"}
97+
</Text>{" "}
98+
to make it a variable.
99+
</Text>
100+
</Box>
101+
) : (
102+
<VStack align="stretch" spacing="0" divider={<Divider />}>
103+
{entries.map((v) => {
104+
// Render the marker's default in a way that's safe for any
105+
// value the user may have stored — strings, numbers, and
106+
// JSON-serialisable composites all become readable text.
107+
const hasDefault =
108+
v.defaultValue !== undefined &&
109+
v.defaultValue !== null &&
110+
v.defaultValue !== ""
111+
const defaultPreview = hasDefault
112+
? typeof v.defaultValue === "string"
113+
? v.defaultValue
114+
: JSON.stringify(v.defaultValue)
115+
: null
116+
return (
117+
<Box key={v.name} px="3" py="2">
118+
<Flex align="baseline" justify="space-between" gap="2">
119+
<Text
120+
fontSize="sm"
121+
fontWeight="medium"
122+
noOfLines={1}
123+
>
124+
{v.label}
125+
</Text>
126+
<Text
127+
fontSize="xs"
128+
fontFamily="mono"
129+
color="purple.600"
130+
flexShrink={0}
131+
>
132+
${v.name}
133+
</Text>
134+
</Flex>
135+
<Text fontSize="xs" color="gray.600" mt="0.5">
136+
{v.stepName} · {v.fieldLabel}
137+
</Text>
138+
{defaultPreview !== null && (
139+
<Text
140+
fontSize="xs"
141+
color="gray.700"
142+
mt="1"
143+
noOfLines={1}
144+
>
145+
<Text as="span" color="gray.500">
146+
Default:{" "}
147+
</Text>
148+
<Text as="span" fontFamily="mono">
149+
{defaultPreview}
150+
</Text>
151+
</Text>
152+
)}
153+
{v.description && (
154+
<Text
155+
fontSize="xs"
156+
color="gray.600"
157+
mt="1"
158+
noOfLines={2}
159+
>
160+
{v.description}
161+
</Text>
162+
)}
163+
</Box>
164+
)
165+
})}
166+
</VStack>
167+
)}
168+
</PopoverBody>
169+
</PopoverContent>
170+
</Popover>
171+
)
172+
}

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,14 @@ import { PiTrash } from "@react-icons/all-files/pi/PiTrash"
3030
import { RiCheckFill } from "@react-icons/all-files/ri/RiCheckFill"
3131
import { RiCloseFill } from "@react-icons/all-files/ri/RiCloseFill"
3232
import { RxTransform } from "@react-icons/all-files/rx/RxTransform"
33-
import { useEffect, useRef, useState } from "react"
33+
import { useEffect, useMemo, useRef, useState } from "react"
3434
import {
3535
isLayerKey,
3636
MAX_LAYER_NEST_DEPTH,
3737
type Transformation,
3838
useEditorStore,
3939
} from "../../store"
40+
import { walkVariableRefs } from "../../variables"
4041
import Hover from "../common/Hover"
4142

4243
export type TransformationPosition = "inplace" | number
@@ -112,6 +113,18 @@ export const SortableTransformationItem = ({
112113

113114
const baseIconColor = useColorModeValue("gray.600", "gray.300")
114115

116+
// Variables on this step only (children carry their own counts on their
117+
// own rows). Walks `t.value` directly so deeply nested marker positions
118+
// — e.g. inside padding-input or distort-perspective composites — are
119+
// counted just like top-level field markers.
120+
const variableCount = useMemo(() => {
121+
let n = 0
122+
walkVariableRefs(transformation.value, () => {
123+
n++
124+
})
125+
return n
126+
}, [transformation.value])
127+
115128
useEffect(() => {
116129
const handleClickOutside = (event: MouseEvent): void => {
117130
const renamingBox = renamingBoxRef.current
@@ -255,6 +268,34 @@ export const SortableTransformationItem = ({
255268
</Text>
256269
)}
257270
<Box flex={1} />
271+
{/*
272+
* Variable indicator floats over the right edge of the row so it
273+
* doesn't participate in the flex flow — that way swapping it for
274+
* the hover-action icons (which reserve their own width via
275+
* visibility:hidden) causes zero layout shift, and long step
276+
* names aren't squeezed by an extra inline child.
277+
*/}
278+
{!isRenaming && !isHover && variableCount > 0 && (
279+
<Tooltip
280+
label={`${variableCount} variable${variableCount === 1 ? "" : "s"} on this step`}
281+
placement="top"
282+
>
283+
<Text
284+
as="span"
285+
position="absolute"
286+
right={4}
287+
top="50%"
288+
transform="translateY(-50%)"
289+
fontSize="xs"
290+
fontFamily="mono"
291+
color="purple.500"
292+
lineHeight="1"
293+
pointerEvents="none"
294+
>
295+
{`{${variableCount}}`}
296+
</Text>
297+
</Tooltip>
298+
)}
258299
{!isRenaming && (
259300
<HStack
260301
spacing={3}

packages/imagekit-editor-dev/src/variables/listVariables.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ import { z } from "zod"
3838
export interface VariableDescriptor {
3939
name: string
4040
label: string
41+
/** Inline default carried by the marker; may be undefined for legacy refs. */
42+
defaultValue?: unknown
43+
/** Optional human note about what the variable controls. */
44+
description?: string
4145
transformationId: string
4246
fieldName: string
4347
field: TransformationField
@@ -102,6 +106,8 @@ export function listVariables(
102106
out.push({
103107
name: ref.$var,
104108
label: ref.label,
109+
defaultValue: ref.defaultValue,
110+
description: ref.description,
105111
transformationId: t.id,
106112
fieldName,
107113
field,

0 commit comments

Comments
 (0)