From 558f653380dfe62f36d159a0fbdc88bc24c974ca Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
+ This demo shows how to save and restore editor templates using the
+ editor's ref methods.
+
+ Transformations: {savedTemplate.length}
+
+ Schema Version: {TRANSFORMATION_STATE_VERSION}
+
+ Types:{" "}
+ {Array.from(
+ new Set(savedTemplate.map((t) => t.type))
+ ).join(", ")}
+
+ ๐พ Persistent Storage: Templates are saved to localStorage, so they persist across page reloads!
+
+ Note: Template IDs are automatically generated on load to ensure uniqueness and enable reusability.
+ ImageKit Editor - Template Management Demo
+
+ โ Saved Template
+
+
+ ๐ View Template JSON
+
+
+ {JSON.stringify(savedTemplate, null, 2)}
+
+ ๐ How to use Template Features:
+
+
+
Types:{" "} - {Array.from( - new Set(savedTemplate.map((t) => t.type)) - ).join(", ")} + {Array.from(new Set(savedTemplate.map((t) => t.type))).join( + ", ", + )}
๐ How to use Template Features:
- Click "Open ImageKit Editor" and apply some transformations
- - Click the "Save Template" button in the editor header
+ -
+ Click the "Save Template" button in the editor
+ header
+
- Close the editor
-
- Click "Load Saved Template" - it will open the editor with all transformations restored
+ Click "Load Saved Template" - it will open the
+ editor with all transformations restored
+
+ -
+ Use "Clear Template" to remove the saved template
- - Use "Clear Template" to remove the saved template
-
- ๐พ Persistent Storage: Templates are saved to localStorage, so they persist across page reloads!
+
+ ๐พ Persistent Storage: Templates are saved to
+ localStorage, so they persist across page reloads!
- Note: Template IDs are automatically generated on load to ensure uniqueness and enable reusability.
+ Note: Template IDs are automatically generated on
+ load to ensure uniqueness and enable reusability.
diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
index d9bdf1b..89689e7 100644
--- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
+++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
@@ -20,19 +20,19 @@ export interface ImageKitEditorRef {
* @param image - Image URL string or FileElement with metadata
*/
loadImage: (image: string | InputFileElement) => void
-
+
/**
* Loads multiple images into the editor
* @param images - Array of image URL strings or FileElements with metadata
*/
loadImages: (images: Array) => void
-
+
/**
* Switches the current active image
* @param imageSrc - URL of the image to set as current
*/
setCurrentImage: (imageSrc: string) => void
-
+
/**
* Gets the current editor template (transformation stack)
* @returns Array of transformation objects representing the template
@@ -46,7 +46,7 @@ export interface ImageKitEditorRef {
* ```
*/
getTemplate: () => Transformation[]
-
+
/**
* Loads a template (transformation stack) into the editor
* @param template - Array of transformation objects without the 'id' field
diff --git a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
index 4a644c0..8e1fbc2 100644
--- a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
+++ b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"
+import { transformationFormatters, transformationSchema } from "./schema"
import type { Transformation } from "./store"
import { TRANSFORMATION_STATE_VERSION } from "./store"
-import { transformationFormatters, transformationSchema } from "./schema"
/**
* V1 Template Fixtures
@@ -313,7 +313,7 @@ function validateTransformation(t: Omit): {
if (!result.success) {
result.error.errors.forEach((err) => {
errors.push(
- `Schema validation failed for '${err.path.join(".")}': ${err.message}`
+ `Schema validation failed for '${err.path.join(".")}': ${err.message}`,
)
})
}
@@ -611,7 +611,7 @@ describe("Backward Compatibility - V1 Templates", () => {
}
// Remove id for storage
- const { id, ...forStorage } = withId
+ const { id: _id, ...forStorage } = withId
expect(forStorage.id).toBeUndefined()
// Add id back when loading
@@ -662,11 +662,13 @@ describe("Backward Compatibility - V1 Templates", () => {
const result = validateTransformation(invalid)
expect(result.valid).toBe(false)
expect(result.errors.length).toBeGreaterThan(0)
- expect(result.errors.some((e) => e.includes("not found in current schema"))).toBe(true)
+ expect(
+ result.errors.some((e) => e.includes("not found in current schema")),
+ ).toBe(true)
})
it("should reject transformation with wrong type", () => {
- const invalid: any = {
+ const invalid: Record = {
key: "adjust-background",
name: "Background",
type: "wrong-type",
@@ -685,7 +687,7 @@ describe("Backward Compatibility - V1 Templates", () => {
name: "Corner Radius",
type: "transformation",
value: {
- radius: 999, // Should be an object with {radius: number}
+ radius: 999, // Should be an object with {radius: number}
},
version: "v1",
}
@@ -933,7 +935,12 @@ describe("Backward Compatibility - V1 Templates", () => {
key: "layers-text",
name: "Text",
type: "transformation",
- value: { text: "Hello", positionX: "bw_div_2", fontSize: 24, radius: 0 },
+ value: {
+ text: "Hello",
+ positionX: "bw_div_2",
+ fontSize: 24,
+ radius: 0,
+ },
version: "v1",
}
expect(validateTransformation(template).valid).toBe(true)
@@ -944,7 +951,12 @@ describe("Backward Compatibility - V1 Templates", () => {
key: "layers-text",
name: "Text",
type: "transformation",
- value: { text: "Hello", positionY: "bh_sub_100", fontSize: 24, radius: 0 },
+ value: {
+ text: "Hello",
+ positionY: "bh_sub_100",
+ fontSize: 24,
+ radius: 0,
+ },
version: "v1",
}
expect(validateTransformation(template).valid).toBe(true)
@@ -1227,7 +1239,7 @@ describe("Backward Compatibility - V1 Templates", () => {
unsharpenMaskAmount: 1.2,
unsharpenMaskThreshold: 0.1,
},
- version: "v1",
+ version: "v1",
}
const result = validateTransformation(template)
if (!result.valid) {
@@ -1561,7 +1573,12 @@ describe("Backward Compatibility - V1 Templates", () => {
key: "layers-text",
name: "Text",
type: "transformation",
- value: { text: "Hello", positionX: "invalid_expr", fontSize: 24, radius: 0 },
+ value: {
+ text: "Hello",
+ positionX: "invalid_expr",
+ fontSize: 24,
+ radius: 0,
+ },
version: "v1",
}
expect(validateTransformation(template).valid).toBe(false)
@@ -1605,7 +1622,12 @@ describe("Backward Compatibility - V1 Templates", () => {
key: "layers-text",
name: "Text",
type: "transformation",
- value: { text: "Hello", lineHeight: "ih_mul_1.5", fontSize: 24, radius: 0 },
+ value: {
+ text: "Hello",
+ lineHeight: "ih_mul_1.5",
+ fontSize: 24,
+ radius: 0,
+ },
version: "v1",
}
expect(validateTransformation(template).valid).toBe(true)
@@ -1627,7 +1649,12 @@ describe("Backward Compatibility - V1 Templates", () => {
key: "layers-text",
name: "Text",
type: "transformation",
- value: { text: "Hello", lineHeight: "not_valid", fontSize: 24, radius: 0 },
+ value: {
+ text: "Hello",
+ lineHeight: "not_valid",
+ fontSize: 24,
+ radius: 0,
+ },
version: "v1",
}
expect(validateTransformation(template).valid).toBe(false)
@@ -2138,7 +2165,11 @@ describe("Backward Compatibility - V1 Templates", () => {
})
it("should validate text layer with all alignment options", () => {
- const alignments: Array<"left" | "right" | "center"> = ["left", "right", "center"]
+ const alignments: Array<"left" | "right" | "center"> = [
+ "left",
+ "right",
+ "center",
+ ]
alignments.forEach((align) => {
const template: Omit = {
key: "layers-text",
diff --git a/packages/imagekit-editor-dev/src/store.ts b/packages/imagekit-editor-dev/src/store.ts
index e2c1628..2220a89 100644
--- a/packages/imagekit-editor-dev/src/store.ts
+++ b/packages/imagekit-editor-dev/src/store.ts
@@ -311,13 +311,13 @@ const useEditorStore = create()(
id: `transformation-${Date.now()}-${index}`,
version: TRANSFORMATION_STATE_VERSION,
}))
-
+
const visibleTransformations: Record = {}
transformationsWithIds.forEach((t) => {
visibleTransformations[t.id] = true
})
-
- set((state) => ({
+
+ set((state) => ({
transformations: transformationsWithIds,
visibleTransformations: {
...state.visibleTransformations,
From 9f91d943fa2d59bd02d197ed236540e9e90b5bde Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
Date: Tue, 10 Mar 2026 15:31:34 +0530
Subject: [PATCH 03/30] feat: add test script to package.json
---
package.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/package.json b/package.json
index 9c4303c..5046f3a 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"dev": "turbo run dev",
"start": "turbo run start",
"build": "turbo run build",
+ "test": "turbo run test",
"version": "yarn workspace @imagekit/editor version",
"package": "yarn build && shx cp README.md ./packages/imagekit-editor/ && yarn workspace @imagekit/editor pack --out ../../builds/imagekit-editor-%v.tgz",
"release": "yarn build && shx cp README.md ./packages/imagekit-editor/ && yarn workspace @imagekit/editor pack --out ../../builds/imagekit-editor-%v.tgz && yarn workspace @imagekit/editor publish",
From b6b2bdd2164addd2089c684b35b0d444e4e4502d Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
Date: Tue, 10 Mar 2026 16:59:13 +0530
Subject: [PATCH 04/30] test: add coverage tests for background and resize/crop
field visibility logic
---
.../src/backward-compatibility.test.ts | 445 ++++++++++++++++
.../src/schema/field-config.test.ts | 496 ++++++++++++++++++
packages/imagekit-editor-dev/vite.config.ts | 10 +-
3 files changed, 949 insertions(+), 2 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/schema/field-config.test.ts
diff --git a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
index 8e1fbc2..62799e9 100644
--- a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
+++ b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
@@ -2577,4 +2577,449 @@ describe("Backward Compatibility - V1 Templates", () => {
expect(validateTransformation(template).valid).toBe(false)
})
})
+
+ describe("Height Validator Coverage", () => {
+ it("should reject invalid height expression", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 100,
+ height: "invalid_height_expr",
+ mode: "cm-pad_resize",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+ })
+
+
+
+ describe("Unsharpen Mask Error Coverage", () => {
+ it("should require sigma when unsharpen mask is enabled", () => {
+ const template: Omit = {
+ key: "layers-image",
+ name: "Image Layer",
+ type: "transformation",
+ value: {
+ imageUrl: "overlay.png",
+ unsharpenMask: true,
+ unsharpenMaskRadius: 2,
+ // Missing sigma and other required fields
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should require amount when unsharpen mask is enabled", () => {
+ const template: Omit = {
+ key: "layers-image",
+ name: "Image Layer",
+ type: "transformation",
+ value: {
+ imageUrl: "overlay.png",
+ unsharpenMask: true,
+ unsharpenMaskRadius: 2,
+ unsharpenMaskSigma: 1.5,
+ // Missing amount
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should require threshold when unsharpen mask is enabled", () => {
+ const template: Omit = {
+ key: "layers-image",
+ name: "Image Layer",
+ type: "transformation",
+ value: {
+ imageUrl: "overlay.png",
+ unsharpenMask: true,
+ unsharpenMaskRadius: 2,
+ unsharpenMaskSigma: 1.5,
+ unsharpenMaskAmount: 1.2,
+ // Missing threshold
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+ })
+
+ describe("Background Gradient Auto Coverage", () => {
+ it("should validate background gradient with radial mode", () => {
+ const template: Omit = {
+ key: "adjust-background",
+ name: "Background",
+ type: "transformation",
+ value: {
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ backgroundGradientMode: "radial",
+ backgroundGradientPaletteSize: "2",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should validate background gradient with linear mode", () => {
+ const template: Omit = {
+ key: "adjust-background",
+ name: "Background",
+ type: "transformation",
+ value: {
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ backgroundGradientMode: "linear",
+ backgroundGradientPaletteSize: "4",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should validate manual background gradient", () => {
+ const template: Omit = {
+ key: "adjust-background",
+ name: "Background",
+ type: "transformation",
+ value: {
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: false,
+ backgroundGradient: {
+ type: "linear",
+ angle: "90",
+ stops: [
+ { color: "#FF0000", stopPoint: 0 },
+ { color: "#0000FF", stopPoint: 100 },
+ ],
+ },
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+ })
+
+ describe("Resize Mode Conversion Coverage", () => {
+ it("should validate c-at_max_enlarge mode", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ height: 600,
+ mode: "c-at_max_enlarge",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should validate c-force mode", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ height: 600,
+ mode: "c-force",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should validate c-at_max mode", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ mode: "c-at_max",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+ })
+
+
+
+
+
+ describe("Maintain Ratio Focus Validations", () => {
+ it("should validate maintain_ratio with anchor focus", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ mode: "c-maintain_ratio",
+ focus: "anchor",
+ focusAnchor: "center",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should require focusAnchor for maintain_ratio with anchor focus", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ mode: "c-maintain_ratio",
+ focus: "anchor",
+ // Missing focusAnchor
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should validate maintain_ratio with object focus", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ mode: "c-maintain_ratio",
+ focus: "object",
+ focusObject: "person",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should require focusObject for maintain_ratio with object focus", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ mode: "c-maintain_ratio",
+ focus: "object",
+ // Missing focusObject
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+ })
+
+ describe("Pad Resize Background Validation Errors", () => {
+ it("should require width when using blurred background", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ // width missing
+ height: 600,
+ mode: "cm-pad_resize",
+ backgroundType: "blurred",
+ backgroundBlurIntensity: 10,
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should require height when using blurred background", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ // height missing
+ mode: "cm-pad_resize",
+ backgroundType: "blurred",
+ backgroundBlurIntensity: 10,
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should require width when using generative fill", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ // width missing
+ height: 600,
+ mode: "cm-pad_resize",
+ backgroundType: "generative_fill",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should require height when using generative fill", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ // height missing
+ mode: "cm-pad_resize",
+ backgroundType: "generative_fill",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should pass validation with both dimensions for blurred background", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ height: 600,
+ mode: "cm-pad_resize",
+ backgroundType: "blurred",
+ backgroundBlurIntensity: 10,
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should pass validation with both dimensions for generative fill", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ height: 600,
+ mode: "cm-pad_resize",
+ backgroundType: "generative_fill",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+ })
+
+ describe("Final Coverage Gaps - Missing Validations", () => {
+ it("should reject aspect ratio without width or height", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ aspectRatio: "16-9",
+ // No width or height
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should accept aspect ratio with width", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ aspectRatio: "16-9",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should require at least one center coordinate for cm-extract with coordinates", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ height: 600,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "center",
+ // Missing both xc and yc
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should accept center coordinates with at least xc for cm-extract", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ height: 600,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "center",
+ xc: "400",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should reject unsharpen mask with threshold = 0 as invalid", () => {
+ const template: Omit = {
+ key: "adjust-unsharpen-mask",
+ name: "Unsharpen Mask",
+ type: "transformation",
+ value: {
+ unsharpenMask: true,
+ unsharpenMaskRadius: 2,
+ unsharpenMaskSigma: 1,
+ unsharpenMaskAmount: 0.5,
+ unsharpenMaskThreshold: 0, // Falsy value
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should accept unsharpen mask with valid positive threshold", () => {
+ const template: Omit = {
+ key: "adjust-unsharpen-mask",
+ name: "Unsharpen Mask",
+ type: "transformation",
+ value: {
+ unsharpenMask: true,
+ unsharpenMaskRadius: 2,
+ unsharpenMaskSigma: 1,
+ unsharpenMaskAmount: 0.5,
+ unsharpenMaskThreshold: 0.05,
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+ })
})
diff --git a/packages/imagekit-editor-dev/src/schema/field-config.test.ts b/packages/imagekit-editor-dev/src/schema/field-config.test.ts
new file mode 100644
index 0000000..4d7748e
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/schema/field-config.test.ts
@@ -0,0 +1,496 @@
+import { describe, expect, it } from "vitest"
+import { backgroundTransformations } from "./background"
+import {
+ getDefaultTransformationFromMode,
+ resizeAndCropTransformations,
+} from "./resizeAndCrop"
+
+describe("Field Configuration Tests", () => {
+ describe("Background Fields - Visibility Logic", () => {
+ describe("background field (color picker)", () => {
+ it("should be visible for root_image when type is color and auto is off", () => {
+ const field = backgroundTransformations.background({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "color",
+ backgroundDominantAuto: false,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should be hidden for root_image when auto dominant is enabled", () => {
+ const field = backgroundTransformations.background({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "color",
+ backgroundDominantAuto: true,
+ })
+
+ expect(visible).toBe(false)
+ })
+
+ it("should be visible for pad_resize when type is color and auto is off", () => {
+ const field = backgroundTransformations.background({
+ transformationGroup: "background",
+ context: "pad_resize",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "color",
+ backgroundDominantAuto: false,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should be visible for pad_extract when type is color and auto is off", () => {
+ const field = backgroundTransformations.background({
+ transformationGroup: "background",
+ context: "pad_extract",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "color",
+ backgroundDominantAuto: false,
+ })
+
+ expect(visible).toBe(true)
+ })
+ })
+
+ describe("backgroundGradientMode field", () => {
+ it("should be visible when type is gradient and auto dominant is true", () => {
+ const field = backgroundTransformations.backgroundGradientMode({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should be hidden when auto dominant is false", () => {
+ const field = backgroundTransformations.backgroundGradientMode({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: false,
+ })
+
+ expect(visible).toBe(false)
+ })
+ })
+
+ describe("backgroundGradientPaletteSize field", () => {
+ it("should be visible when type is gradient and auto dominant is true", () => {
+ const field = backgroundTransformations.backgroundGradientPaletteSize({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should be hidden when background type is not gradient", () => {
+ const field = backgroundTransformations.backgroundGradientPaletteSize({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "color",
+ backgroundGradientAutoDominant: true,
+ })
+
+ expect(visible).toBe(false)
+ })
+ })
+
+ describe("backgroundGradient field (manual gradient)", () => {
+ it("should be visible when type is gradient and auto dominant is false", () => {
+ const field = backgroundTransformations.backgroundGradient({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: false,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should be hidden when auto dominant is true", () => {
+ const field = backgroundTransformations.backgroundGradient({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ })
+
+ expect(visible).toBe(false)
+ })
+ })
+ })
+
+ describe("Resize and Crop Fields - Visibility and Helpers", () => {
+ describe("coordinate field visibility", () => {
+ it("should show x field for topleft coordinates in extract mode", () => {
+ const xField = resizeAndCropTransformations.find((f) => f.name === "x")
+
+ const visible = xField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "topleft",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should hide x field when coordinate method is not topleft", () => {
+ const xField = resizeAndCropTransformations.find((f) => f.name === "x")
+
+ const visible = xField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "center",
+ })
+
+ expect(visible).toBe(false)
+ })
+
+ it("should show y field for topleft coordinates in extract mode", () => {
+ const yField = resizeAndCropTransformations.find((f) => f.name === "y")
+
+ const visible = yField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "topleft",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should show xc field for center coordinates in extract mode", () => {
+ const xcField = resizeAndCropTransformations.find((f) => f.name === "xc")
+
+ const visible = xcField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "center",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should show yc field for center coordinates in extract mode", () => {
+ const ycField = resizeAndCropTransformations.find((f) => f.name === "yc")
+
+ const visible = ycField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "center",
+ })
+
+ expect(visible).toBe(true)
+ })
+ })
+
+ describe("focus field visibility", () => {
+ it("should show focusAnchor when focus is anchor", () => {
+ const focusAnchorField = resizeAndCropTransformations.find(
+ (f) => f.name === "focusAnchor",
+ )
+
+ const visible = focusAnchorField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "anchor",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should show focusObject when focus is object", () => {
+ const focusObjectField = resizeAndCropTransformations.find(
+ (f) => f.name === "focusObject",
+ )
+
+ const visible = focusObjectField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "object",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should show coordinateMethod when focus is coordinates", () => {
+ const coordinateMethodField = resizeAndCropTransformations.find(
+ (f) => f.name === "coordinateMethod",
+ )
+
+ const visible = coordinateMethodField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "coordinates",
+ })
+
+ expect(visible).toBe(true)
+ })
+ })
+
+ describe("mode-specific field visibility", () => {
+ it("should show focus field for extract mode", () => {
+ const focusFields = resizeAndCropTransformations.filter(
+ (f) => f.name === "focus",
+ )
+ // Find the one for extract mode
+ const extractFocusField = focusFields.find((f) =>
+ f.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ }),
+ )
+
+ expect(extractFocusField).toBeDefined()
+ })
+
+ it("should show focus field for maintain_ratio crop", () => {
+ const focusFields = resizeAndCropTransformations.filter(
+ (f) => f.name === "focus",
+ )
+ // Find the one for maintain_ratio mode
+ const maintainRatioFocusField = focusFields.find((f) =>
+ f.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "c-maintain_ratio",
+ }),
+ )
+
+ expect(maintainRatioFocusField).toBeDefined()
+ })
+ })
+ })
+
+ describe("Helper Functions - getDefaultTransformationfromMode", () => {
+ it("should return cropMode pad_resize for cm-pad_resize", () => {
+ const result = getDefaultTransformationFromMode("cm-pad_resize")
+ expect(result).toEqual({ cropMode: "pad_resize" })
+ })
+
+ it("should return cropMode extract for cm-extract", () => {
+ const result = getDefaultTransformationFromMode("cm-extract")
+ expect(result).toEqual({ cropMode: "extract" })
+ })
+
+ it("should return cropMode pad_extract for cm-pad_extract", () => {
+ const result = getDefaultTransformationFromMode("cm-pad_extract")
+ expect(result).toEqual({ cropMode: "pad_extract" })
+ })
+
+ it("should return crop maintain_ratio for c-maintain_ratio", () => {
+ const result = getDefaultTransformationFromMode("c-maintain_ratio")
+ expect(result).toEqual({ crop: "maintain_ratio" })
+ })
+
+ it("should return crop force for c-force", () => {
+ const result = getDefaultTransformationFromMode("c-force")
+ expect(result).toEqual({ crop: "force" })
+ })
+
+ it("should return crop at_max for c-at_max", () => {
+ const result = getDefaultTransformationFromMode("c-at_max")
+ expect(result).toEqual({ crop: "at_max" })
+ })
+
+ it("should return crop at_max_enlarge for c-at_max_enlarge", () => {
+ const result = getDefaultTransformationFromMode("c-at_max_enlarge")
+ expect(result).toEqual({ crop: "at_max_enlarge" })
+ })
+
+ it("should return crop at_least for c-at_least", () => {
+ const result = getDefaultTransformationFromMode("c-at_least")
+ expect(result).toEqual({ crop: "at_least" })
+ })
+
+ it("should return empty object for unknown mode", () => {
+ const result = getDefaultTransformationFromMode("unknown-mode")
+ expect(result).toEqual({})
+ })
+ })
+
+ describe("Additional Field Visibility Coverage", () => {
+ it("should show DPR field when enabled and width exists", () => {
+ const dprField = resizeAndCropTransformations.find((f) => f.name === "dpr")
+
+ const visible = dprField?.isVisible?.({
+ dprEnabled: true,
+ width: 100,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should show DPR field when enabled and height exists", () => {
+ const dprField = resizeAndCropTransformations.find((f) => f.name === "dpr")
+
+ const visible = dprField?.isVisible?.({
+ dprEnabled: true,
+ height: 100,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should hide DPR field when not enabled", () => {
+ const dprField = resizeAndCropTransformations.find((f) => f.name === "dpr")
+
+ const visible = dprField?.isVisible?.({
+ dprEnabled: false,
+ width: 100,
+ })
+
+ expect(visible).toBe(false)
+ })
+
+ it("should show zoom field for face focus in extract mode", () => {
+ const zoomField = resizeAndCropTransformations.find((f) => f.name === "zoom")
+
+ const visible = zoomField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "face",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should show zoom field for object focus in maintain_ratio", () => {
+ const zoomField = resizeAndCropTransformations.find((f) => f.name === "zoom")
+
+ const visible = zoomField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "c-maintain_ratio",
+ focus: "object",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should hide zoom field for anchor focus", () => {
+ const zoomField = resizeAndCropTransformations.find((f) => f.name === "zoom")
+
+ const visible = zoomField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "anchor",
+ })
+
+ expect(visible).toBe(false)
+ })
+
+ it("should show focus field for c-force mode", () => {
+ const focusFields = resizeAndCropTransformations.filter(
+ (f) => f.name === "focus",
+ )
+ // Find the one for force mode (has only auto option)
+ const forceField = focusFields.find((f) =>
+ f.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "c-force",
+ }),
+ )
+
+ expect(forceField).toBeDefined()
+ expect(forceField?.fieldProps?.options).toHaveLength(1)
+ expect(forceField?.fieldProps?.options?.[0].value).toBe("auto")
+ })
+
+ it("should test pad_resize background field wrapper", () => {
+ // Find background fields that are visible for pad_resize
+ const backgroundFields = resizeAndCropTransformations.filter(
+ (f) =>
+ f.transformationGroup === "background" ||
+ f.name === "backgroundType" ||
+ f.name === "background",
+ )
+
+ // At least one should be visible for pad_resize with dimensions
+ const visibleForPadResize = backgroundFields.some((f) =>
+ f.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-pad_resize",
+ backgroundType: "color",
+ }),
+ )
+
+ expect(visibleForPadResize).toBe(true)
+ })
+
+ it("should test pad_extract background field wrapper", () => {
+ // Find background fields that are visible for pad_extract
+ const backgroundFields = resizeAndCropTransformations.filter(
+ (f) =>
+ f.transformationGroup === "background" ||
+ f.name === "backgroundType" ||
+ f.name === "background",
+ )
+
+ // At least one should be visible for pad_extract with dimensions
+ const visibleForPadExtract = backgroundFields.some((f) =>
+ f.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-pad_extract",
+ backgroundType: "color",
+ }),
+ )
+
+ expect(visibleForPadExtract).toBe(true)
+ })
+ })
+})
diff --git a/packages/imagekit-editor-dev/vite.config.ts b/packages/imagekit-editor-dev/vite.config.ts
index 15d7a68..70de203 100644
--- a/packages/imagekit-editor-dev/vite.config.ts
+++ b/packages/imagekit-editor-dev/vite.config.ts
@@ -24,12 +24,18 @@ export default defineConfig({
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
- include: ["src/**/*.{ts,tsx}"],
+ include: ["src/schema/**/*.{ts,tsx}"],
exclude: [
"src/**/*.{test,spec}.{ts,tsx}",
- "src/index.tsx",
"node_modules/**",
],
+ thresholds: {
+ // Only enforced on src/schema files - focusing on validation logic
+ lines: 85, // Realistic threshold given UI visibility code
+ branches: 85,
+ statements: 85,
+ perFile: false, // Global threshold across all schema files
+ },
},
},
build: {
From e7044a2f30fac5aa008ab604d975c96e56b7d4cf Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
Date: Tue, 10 Mar 2026 16:59:29 +0530
Subject: [PATCH 05/30] ci: update test command to run coverage tests
---
.github/workflows/ci.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 0756460..3ae2a6c 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -26,7 +26,7 @@ jobs:
run: |
yarn install --frozen-lockfile
yarn lint
- yarn test
+ yarn test:coverage
yarn package
env:
CI: true
From aee07842deaae4aa1a7f3b0d6ad885c51e9026fb Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
Date: Tue, 10 Mar 2026 17:07:34 +0530
Subject: [PATCH 06/30] test: add validation tests for backward compatibility
and transformation formatters
---
.../src/backward-compatibility.test.ts | 83 +++++
.../src/schema/formatters.test.ts | 320 ++++++++++++++++++
.../src/schema/transformation.ts | 19 --
3 files changed, 403 insertions(+), 19 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/schema/formatters.test.ts
diff --git a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
index 62799e9..eb4eb29 100644
--- a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
+++ b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
@@ -3005,6 +3005,25 @@ describe("Backward Compatibility - V1 Templates", () => {
expect(validateTransformation(template).valid).toBe(false)
})
+ it("should reject unsharpen mask with missing threshold", () => {
+ const template: Omit = {
+ key: "adjust-unsharpen-mask",
+ name: "Unsharpen Mask",
+ type: "transformation",
+ value: {
+ unsharpenMask: true,
+ unsharpenMaskRadius: 2,
+ unsharpenMaskSigma: 1,
+ unsharpenMaskAmount: 0.5,
+ // Missing unsharpenMaskThreshold entirely
+ },
+ version: "v1",
+ }
+ const result = validateTransformation(template)
+ expect(result.valid).toBe(false)
+ expect(result.errors?.some(e => e.includes("Threshold"))).toBe(true)
+ })
+
it("should accept unsharpen mask with valid positive threshold", () => {
const template: Omit = {
key: "adjust-unsharpen-mask",
@@ -3022,4 +3041,68 @@ describe("Backward Compatibility - V1 Templates", () => {
expect(validateTransformation(template).valid).toBe(true)
})
})
+
+ describe("Empty Transformation Validation - At Least One Value Required", () => {
+ it("should reject contrast transformation with no values", () => {
+ const template: Omit = {
+ key: "adjust-contrast",
+ name: "Contrast",
+ type: "transformation",
+ value: {},
+ version: "v1",
+ }
+ const result = validateTransformation(template)
+ expect(result.valid).toBe(false)
+ expect(result.errors?.some(e => e.includes("At least one value"))).toBe(true)
+ })
+
+ it("should reject shadow transformation with no values", () => {
+ const template: Omit = {
+ key: "adjust-shadow",
+ name: "Shadow",
+ type: "transformation",
+ value: {},
+ version: "v1",
+ }
+ const result = validateTransformation(template)
+ expect(result.valid).toBe(false)
+ expect(result.errors?.some(e => e.includes("At least one value"))).toBe(true)
+ })
+
+ it("should reject grayscale transformation with no values", () => {
+ const template: Omit = {
+ key: "adjust-grayscale",
+ name: "Grayscale",
+ type: "transformation",
+ value: {},
+ version: "v1",
+ }
+ const result = validateTransformation(template)
+ expect(result.valid).toBe(false)
+ })
+
+ it("should reject radius transformation with no values", () => {
+ const template: Omit = {
+ key: "adjust-radius",
+ name: "Radius",
+ type: "transformation",
+ value: {},
+ version: "v1",
+ }
+ const result = validateTransformation(template)
+ expect(result.valid).toBe(false)
+ })
+
+ it("should reject trim transformation with no values", () => {
+ const template: Omit = {
+ key: "adjust-trim",
+ name: "Trim",
+ type: "transformation",
+ value: {},
+ version: "v1",
+ }
+ const result = validateTransformation(template)
+ expect(result.valid).toBe(false)
+ })
+ })
})
diff --git a/packages/imagekit-editor-dev/src/schema/formatters.test.ts b/packages/imagekit-editor-dev/src/schema/formatters.test.ts
new file mode 100644
index 0000000..557fc95
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/schema/formatters.test.ts
@@ -0,0 +1,320 @@
+import { describe, expect, it } from "vitest"
+import { transformationFormatters } from "./index"
+
+describe("Transformation Formatters", () => {
+ describe("background formatter", () => {
+ it("should format color background with dominant auto", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "color",
+ backgroundDominantAuto: true,
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("dominant")
+ })
+
+ it("should format gradient background with auto dominant", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ backgroundGradientPaletteSize: "4",
+ backgroundGradientMode: "contrast",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("gradient_contrast_4")
+ })
+
+ it("should format gradient background with default values", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("gradient_dominant_2")
+ })
+
+ it("should format manual gradient background", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: false,
+ backgroundGradient: {
+ from: "#FF0000",
+ to: "#0000FF",
+ direction: "top",
+ stopPoint: 50,
+ },
+ },
+ transforms,
+ )
+ expect(transforms.raw).toContain("e-gradient")
+ expect(transforms.raw).toContain("from-FF0000")
+ expect(transforms.raw).toContain("to-0000FF")
+ })
+
+ it("should format blurred background with auto intensity", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "blurred",
+ backgroundBlurIntensity: "auto",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("blurred_auto")
+ })
+
+ it("should format blurred background with auto intensity and brightness", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "blurred",
+ backgroundBlurIntensity: "auto",
+ backgroundBlurBrightness: "50",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("blurred_auto_50")
+ })
+
+ it("should format blurred background with numeric intensity", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "blurred",
+ backgroundBlurIntensity: "10",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("blurred_10")
+ })
+
+ it("should format blurred background with intensity and brightness", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "blurred",
+ backgroundBlurIntensity: "10",
+ backgroundBlurBrightness: "20",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("blurred_10_20")
+ })
+
+ it("should handle negative blur brightness", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "blurred",
+ backgroundBlurIntensity: "10",
+ backgroundBlurBrightness: "-20",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("blurred_10_N20")
+ })
+
+ it("should format generative fill background without prompt", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "generative_fill",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("genfill")
+ })
+
+ it("should format generative fill with simple text prompt", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "generative_fill",
+ backgroundGenerativeFill: "beach",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("genfill-prompt-beach")
+ })
+
+ it("should format generative fill with complex prompt", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "generative_fill",
+ backgroundGenerativeFill: "beach with palm trees!",
+ },
+ transforms,
+ )
+ expect(transforms.background).toContain("genfill-prompte-")
+ })
+
+ it("should format color background with manual color", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "color",
+ backgroundDominantAuto: false,
+ background: "#FF5733",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("FF5733")
+ })
+
+ it("should default to blurred when intensity is invalid", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "blurred",
+ backgroundBlurIntensity: "invalid",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("blurred")
+ })
+ })
+
+ describe("focus formatter", () => {
+ it("should format focus with anchor", () => {
+ const transforms: Record = {}
+ transformationFormatters.focus(
+ {
+ focus: "anchor",
+ focusAnchor: "top_left",
+ },
+ transforms,
+ )
+ expect(transforms.focus).toBe("top_left")
+ })
+
+ it("should format focus with object", () => {
+ const transforms: Record = {}
+ transformationFormatters.focus(
+ {
+ focus: "object",
+ focusObject: "face",
+ },
+ transforms,
+ )
+ expect(transforms.focus).toBe("face")
+ })
+
+ it("should format focus with auto", () => {
+ const transforms: Record = {}
+ transformationFormatters.focus(
+ {
+ focus: "auto",
+ },
+ transforms,
+ )
+ expect(transforms.focus).toBe("auto")
+ })
+
+ it("should format focus with center coordinates", () => {
+ const transforms: Record = {}
+ transformationFormatters.focus(
+ {
+ focus: "coordinates",
+ coordinateMethod: "center",
+ xc: "100",
+ yc: "200",
+ },
+ transforms,
+ )
+ expect(transforms.xc).toBe("100")
+ expect(transforms.yc).toBe("200")
+ })
+
+ it("should format focus with topleft coordinates", () => {
+ const transforms: Record = {}
+ transformationFormatters.focus(
+ {
+ focus: "coordinates",
+ coordinateMethod: "topleft",
+ x: "50",
+ y: "75",
+ },
+ transforms,
+ )
+ expect(transforms.x).toBe("50")
+ expect(transforms.y).toBe("75")
+ })
+
+ it("should format focus with zoom", () => {
+ const transforms: Record = {}
+ transformationFormatters.focus(
+ {
+ focus: "auto",
+ zoom: 150,
+ },
+ transforms,
+ )
+ expect(transforms.zoom).toBe(1.5)
+ })
+ })
+
+ describe("shadow formatter", () => {
+ it("should format shadow with all parameters", () => {
+ const transforms: Record = {}
+ transformationFormatters.shadow(
+ {
+ shadow: true,
+ shadowBlur: 10,
+ shadowSaturation: 50,
+ shadowOffsetX: 5,
+ shadowOffsetY: 8,
+ },
+ transforms,
+ )
+ expect(transforms.shadow).toBe("bl-10_st-50_x-5_y-8")
+ })
+
+ it("should skip shadow when disabled", () => {
+ const transforms: Record = {}
+ transformationFormatters.shadow(
+ {
+ shadow: false,
+ },
+ transforms,
+ )
+ expect(transforms.shadow).toBeUndefined()
+ })
+
+ it("should handle negative shadow offsets", () => {
+ const transforms: Record = {}
+ transformationFormatters.shadow(
+ {
+ shadow: true,
+ shadowOffsetX: -5,
+ shadowOffsetY: -10,
+ },
+ transforms,
+ )
+ expect(transforms.shadow).toContain("x-N5")
+ expect(transforms.shadow).toContain("y-N10")
+ })
+
+ it("should format shadow with only blur", () => {
+ const transforms: Record = {}
+ transformationFormatters.shadow(
+ {
+ shadow: true,
+ shadowBlur: 15,
+ },
+ transforms,
+ )
+ expect(transforms.shadow).toBe("bl-15")
+ })
+ })
+})
diff --git a/packages/imagekit-editor-dev/src/schema/transformation.ts b/packages/imagekit-editor-dev/src/schema/transformation.ts
index e188df5..6b05c4e 100644
--- a/packages/imagekit-editor-dev/src/schema/transformation.ts
+++ b/packages/imagekit-editor-dev/src/schema/transformation.ts
@@ -146,25 +146,6 @@ export const commonNumberAndExpressionValidator = z
})
})
-const overlayBlockExpr = z
- .string()
- .regex(/^(?:bh|bw|bar)_(?:add|sub|mul|div|mod|pow)_(?:\d+(\.\d{1,2})?)$/, {
- message: "String must be a valid expression string.",
- })
-
-export const overlayBlockExprValidator = z.any().superRefine((val, ctx) => {
- if (commonNumber.safeParse(val).success) {
- return
- }
- if (overlayBlockExpr.safeParse(val).success) {
- return
- }
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: "Must be a positive number or a valid expression string.",
- })
-})
-
const lineHeightInteger = z.coerce.string().regex(/^\d+$/)
const lineHeightExpr = z
From ed70e312f8ea969b40b3778bbe24976350322797 Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
Date: Tue, 10 Mar 2026 17:08:04 +0530
Subject: [PATCH 07/30] refactor: clean up code formatting and improve
readability in tests and configuration
---
.../src/backward-compatibility.test.ts | 16 ++++------
.../src/schema/field-config.test.ts | 32 ++++++++++++++-----
packages/imagekit-editor-dev/vite.config.ts | 5 +--
3 files changed, 32 insertions(+), 21 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
index eb4eb29..7807c90 100644
--- a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
+++ b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
@@ -2595,8 +2595,6 @@ describe("Backward Compatibility - V1 Templates", () => {
})
})
-
-
describe("Unsharpen Mask Error Coverage", () => {
it("should require sigma when unsharpen mask is enabled", () => {
const template: Omit = {
@@ -2752,10 +2750,6 @@ describe("Backward Compatibility - V1 Templates", () => {
})
})
-
-
-
-
describe("Maintain Ratio Focus Validations", () => {
it("should validate maintain_ratio with anchor focus", () => {
const template: Omit = {
@@ -3021,7 +3015,7 @@ describe("Backward Compatibility - V1 Templates", () => {
}
const result = validateTransformation(template)
expect(result.valid).toBe(false)
- expect(result.errors?.some(e => e.includes("Threshold"))).toBe(true)
+ expect(result.errors?.some((e) => e.includes("Threshold"))).toBe(true)
})
it("should accept unsharpen mask with valid positive threshold", () => {
@@ -3053,7 +3047,9 @@ describe("Backward Compatibility - V1 Templates", () => {
}
const result = validateTransformation(template)
expect(result.valid).toBe(false)
- expect(result.errors?.some(e => e.includes("At least one value"))).toBe(true)
+ expect(result.errors?.some((e) => e.includes("At least one value"))).toBe(
+ true,
+ )
})
it("should reject shadow transformation with no values", () => {
@@ -3066,7 +3062,9 @@ describe("Backward Compatibility - V1 Templates", () => {
}
const result = validateTransformation(template)
expect(result.valid).toBe(false)
- expect(result.errors?.some(e => e.includes("At least one value"))).toBe(true)
+ expect(result.errors?.some((e) => e.includes("At least one value"))).toBe(
+ true,
+ )
})
it("should reject grayscale transformation with no values", () => {
diff --git a/packages/imagekit-editor-dev/src/schema/field-config.test.ts b/packages/imagekit-editor-dev/src/schema/field-config.test.ts
index 4d7748e..80882eb 100644
--- a/packages/imagekit-editor-dev/src/schema/field-config.test.ts
+++ b/packages/imagekit-editor-dev/src/schema/field-config.test.ts
@@ -201,7 +201,9 @@ describe("Field Configuration Tests", () => {
})
it("should show xc field for center coordinates in extract mode", () => {
- const xcField = resizeAndCropTransformations.find((f) => f.name === "xc")
+ const xcField = resizeAndCropTransformations.find(
+ (f) => f.name === "xc",
+ )
const visible = xcField?.isVisible?.({
width: 100,
@@ -215,7 +217,9 @@ describe("Field Configuration Tests", () => {
})
it("should show yc field for center coordinates in extract mode", () => {
- const ycField = resizeAndCropTransformations.find((f) => f.name === "yc")
+ const ycField = resizeAndCropTransformations.find(
+ (f) => f.name === "yc",
+ )
const visible = ycField?.isVisible?.({
width: 100,
@@ -360,7 +364,9 @@ describe("Field Configuration Tests", () => {
describe("Additional Field Visibility Coverage", () => {
it("should show DPR field when enabled and width exists", () => {
- const dprField = resizeAndCropTransformations.find((f) => f.name === "dpr")
+ const dprField = resizeAndCropTransformations.find(
+ (f) => f.name === "dpr",
+ )
const visible = dprField?.isVisible?.({
dprEnabled: true,
@@ -371,7 +377,9 @@ describe("Field Configuration Tests", () => {
})
it("should show DPR field when enabled and height exists", () => {
- const dprField = resizeAndCropTransformations.find((f) => f.name === "dpr")
+ const dprField = resizeAndCropTransformations.find(
+ (f) => f.name === "dpr",
+ )
const visible = dprField?.isVisible?.({
dprEnabled: true,
@@ -382,7 +390,9 @@ describe("Field Configuration Tests", () => {
})
it("should hide DPR field when not enabled", () => {
- const dprField = resizeAndCropTransformations.find((f) => f.name === "dpr")
+ const dprField = resizeAndCropTransformations.find(
+ (f) => f.name === "dpr",
+ )
const visible = dprField?.isVisible?.({
dprEnabled: false,
@@ -393,7 +403,9 @@ describe("Field Configuration Tests", () => {
})
it("should show zoom field for face focus in extract mode", () => {
- const zoomField = resizeAndCropTransformations.find((f) => f.name === "zoom")
+ const zoomField = resizeAndCropTransformations.find(
+ (f) => f.name === "zoom",
+ )
const visible = zoomField?.isVisible?.({
width: 100,
@@ -406,7 +418,9 @@ describe("Field Configuration Tests", () => {
})
it("should show zoom field for object focus in maintain_ratio", () => {
- const zoomField = resizeAndCropTransformations.find((f) => f.name === "zoom")
+ const zoomField = resizeAndCropTransformations.find(
+ (f) => f.name === "zoom",
+ )
const visible = zoomField?.isVisible?.({
width: 100,
@@ -419,7 +433,9 @@ describe("Field Configuration Tests", () => {
})
it("should hide zoom field for anchor focus", () => {
- const zoomField = resizeAndCropTransformations.find((f) => f.name === "zoom")
+ const zoomField = resizeAndCropTransformations.find(
+ (f) => f.name === "zoom",
+ )
const visible = zoomField?.isVisible?.({
width: 100,
diff --git a/packages/imagekit-editor-dev/vite.config.ts b/packages/imagekit-editor-dev/vite.config.ts
index 70de203..84f1f2d 100644
--- a/packages/imagekit-editor-dev/vite.config.ts
+++ b/packages/imagekit-editor-dev/vite.config.ts
@@ -25,10 +25,7 @@ export default defineConfig({
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/schema/**/*.{ts,tsx}"],
- exclude: [
- "src/**/*.{test,spec}.{ts,tsx}",
- "node_modules/**",
- ],
+ exclude: ["src/**/*.{test,spec}.{ts,tsx}", "node_modules/**"],
thresholds: {
// Only enforced on src/schema files - focusing on validation logic
lines: 85, // Realistic threshold given UI visibility code
From 2aa122fb596687ef6999874c74ec75236faf8755 Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
Date: Tue, 10 Mar 2026 17:09:20 +0530
Subject: [PATCH 08/30] fix: increase coverage thresholds for lines, branches,
and statements in Vite config
---
packages/imagekit-editor-dev/vite.config.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/imagekit-editor-dev/vite.config.ts b/packages/imagekit-editor-dev/vite.config.ts
index 84f1f2d..40053ea 100644
--- a/packages/imagekit-editor-dev/vite.config.ts
+++ b/packages/imagekit-editor-dev/vite.config.ts
@@ -28,9 +28,9 @@ export default defineConfig({
exclude: ["src/**/*.{test,spec}.{ts,tsx}", "node_modules/**"],
thresholds: {
// Only enforced on src/schema files - focusing on validation logic
- lines: 85, // Realistic threshold given UI visibility code
- branches: 85,
- statements: 85,
+ lines: 90, // Realistic threshold given UI visibility code
+ branches: 90,
+ statements: 90,
perFile: false, // Global threshold across all schema files
},
},
From 1dd73d4028dec77563f997c193d6b997a690a596 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Mon, 16 Mar 2026 17:41:26 +0530
Subject: [PATCH 09/30] feat: working commit for auto-save templates with
storage provider interfaces
---
examples/react-example/src/index.tsx | 37 +-
.../src/ImageKitEditor.tsx | 92 ++++-
.../components/common/CheckboxCardField.tsx | 15 +-
.../src/components/editor/layout.tsx | 5 +
.../components/header/TemplateNameInput.tsx | 96 +++++
.../src/components/header/TemplateStatus.tsx | 173 +++++++++
.../components/header/TemplatesDropdown.tsx | 351 ++++++++++++++++++
.../src/components/header/index.tsx | 38 +-
.../src/context/TemplateStorageContext.tsx | 24 ++
.../src/hooks/useAutoSaveTemplate.ts | 85 +++++
.../src/hooks/useSaveTemplate.ts | 48 +++
packages/imagekit-editor-dev/src/index.tsx | 6 +
.../imagekit-editor-dev/src/schema/index.ts | 23 +-
.../imagekit-editor-dev/src/storage/index.ts | 6 +
.../src/storage/localStorage-provider.ts | 91 +++++
.../imagekit-editor-dev/src/storage/types.ts | 21 ++
packages/imagekit-editor-dev/src/store.ts | 93 ++++-
17 files changed, 1116 insertions(+), 88 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
create mode 100644 packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
create mode 100644 packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
create mode 100644 packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx
create mode 100644 packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
create mode 100644 packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts
create mode 100644 packages/imagekit-editor-dev/src/storage/index.ts
create mode 100644 packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
create mode 100644 packages/imagekit-editor-dev/src/storage/types.ts
diff --git a/examples/react-example/src/index.tsx b/examples/react-example/src/index.tsx
index a1b3bef..bd21603 100644
--- a/examples/react-example/src/index.tsx
+++ b/examples/react-example/src/index.tsx
@@ -4,7 +4,6 @@ import {
type ImageKitEditorProps,
type ImageKitEditorRef,
TRANSFORMATION_STATE_VERSION,
- type Transformation,
} from "@imagekit/editor"
import { PiDownload } from "@react-icons/all-files/pi/PiDownload"
import React, { useCallback, useEffect } from "react"
@@ -43,28 +42,6 @@ function App() {
}
}, [open, shouldLoadTemplate, savedTemplate])
- /**
- * Save the current editor template
- */
- const handleSaveTemplate = useCallback(() => {
- const template = ref.current?.getTemplate()
- if (template) {
- // Remove the 'id' field from each transformation for storage
- const templateToSave = template.map(
- ({ id, ...rest }: Transformation) => rest,
- )
- setSavedTemplate(templateToSave)
- // Also save to localStorage for persistence
- localStorage.setItem("editorTemplate", JSON.stringify(templateToSave))
- console.log("Saved template:", templateToSave)
- alert(
- `โ
Saved template with ${templateToSave.length} transformation(s)!`,
- )
- } else {
- alert("โ ๏ธ No transformations to save")
- }
- }, [])
-
/**
* Load previously saved template
*/
@@ -141,22 +118,13 @@ function App() {
exportOptions: [
{
type: "button",
- label: "Export Images",
+ label: "Export",
icon: ,
isVisible: true,
onClick: (images, currentImage) => {
console.log("Export images:", images, currentImage)
},
},
- {
- type: "button",
- label: "Save Template",
- icon: ,
- isVisible: true,
- onClick: () => {
- handleSaveTemplate()
- },
- },
// {
// type: "menu",
// label: "Export",
@@ -180,8 +148,9 @@ function App() {
console.log("Signed URL", request.url)
return Promise.resolve(request.url)
},
+ storageProvider: "localStorage",
})
- }, [handleAddImage, handleSaveTemplate])
+ }, [handleAddImage])
const toggle = () => {
setOpen((prev: boolean) => !prev)
diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
index 89689e7..4f0b836 100644
--- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
+++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
@@ -1,9 +1,20 @@
import { ChakraProvider, theme as defaultTheme } from "@chakra-ui/react"
import type { Dict } from "@chakra-ui/utils"
import merge from "lodash/merge"
-import React, { forwardRef, useImperativeHandle } from "react"
+import React, {
+ forwardRef,
+ useCallback,
+ useImperativeHandle,
+ useMemo,
+} from "react"
import { EditorLayout, EditorWrapper } from "./components/editor"
import type { HeaderProps } from "./components/header"
+import { TemplateStorageContextProvider } from "./context/TemplateStorageContext"
+import {
+ createLocalStorageProvider,
+ type LocalStorageProviderOptions,
+ type TemplateStorageProvider,
+} from "./storage"
import {
type FocusObjects,
type InputFileElement,
@@ -57,6 +68,12 @@ export interface ImageKitEditorRef {
* ```
*/
loadTemplate: (template: Omit[]) => void
+
+ /**
+ * Explicitly saves the current template to the configured storage provider.
+ * No-op if no storage provider is configured.
+ */
+ saveTemplate: () => Promise
}
interface EditorProps {
@@ -67,13 +84,24 @@ interface EditorProps {
exportOptions?: HeaderProps["exportOptions"]
focusObjects?: ReadonlyArray
onClose: (args: { dirty: boolean; destroy: () => void }) => void
+ storageProvider?: "localStorage" | "library"
+ libraryStorage?: TemplateStorageProvider
+ localStorageKeys?: LocalStorageProviderOptions
}
function ImageKitEditorImpl(
props: EditorProps,
ref: React.Ref,
) {
- const { theme, initialImages, signer, focusObjects } = props
+ const {
+ theme,
+ initialImages,
+ signer,
+ focusObjects,
+ storageProvider,
+ libraryStorage,
+ localStorageKeys,
+ } = props
const {
addImage,
addImages,
@@ -84,6 +112,40 @@ function ImageKitEditorImpl(
loadTemplate,
} = useEditorStore()
+ const resolvedProvider = useMemo(() => {
+ if (storageProvider === "localStorage") {
+ return createLocalStorageProvider(localStorageKeys)
+ }
+ if (storageProvider === "library" && libraryStorage) {
+ return libraryStorage
+ }
+ return null
+ }, [storageProvider, libraryStorage, localStorageKeys])
+
+ const saveTemplateImperative = useCallback(async () => {
+ if (!resolvedProvider) return
+ const state = useEditorStore.getState()
+ const { setSyncStatus, setTemplateId, setTemplateName } = state
+ setSyncStatus("saving")
+ try {
+ const saved = await resolvedProvider.saveTemplate({
+ id: state.templateId ?? undefined,
+ name: state.templateName,
+ transformations: state.transformations.map(
+ ({ id: _id, ...rest }) => rest,
+ ),
+ })
+ setTemplateId(saved.id)
+ setTemplateName(saved.name)
+ setSyncStatus("saved")
+ } catch (err) {
+ setSyncStatus(
+ "error",
+ err instanceof Error ? err.message : "Failed to save template",
+ )
+ }
+ }, [resolvedProvider])
+
const handleOnClose = () => {
const dirty = transformations.length > 0
props.onClose({ dirty, destroy })
@@ -116,8 +178,16 @@ function ImageKitEditorImpl(
setCurrentImage,
getTemplate: () => transformations,
loadTemplate,
+ saveTemplate: saveTemplateImperative,
}),
- [addImage, addImages, setCurrentImage, transformations, loadTemplate],
+ [
+ addImage,
+ addImages,
+ setCurrentImage,
+ transformations,
+ loadTemplate,
+ saveTemplateImperative,
+ ],
)
const mergedThemes = merge(defaultTheme, themeOverrides, theme)
@@ -125,13 +195,15 @@ function ImageKitEditorImpl(
return (
-
-
-
+
+
+
+
+
)
diff --git a/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx b/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx
index 9e043d8..6f56a95 100644
--- a/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx
+++ b/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx
@@ -30,13 +30,15 @@ const toggleValue = (
v: string,
max?: number,
): string[] => {
- const set = new Set(current)
+ // Guard: a stored string must never be spread into characters via new Set(string).
+ const currentArray = Array.isArray(current) ? current : []
+ const set = new Set(currentArray)
if (set.has(v)) {
set.delete(v)
return Array.from(set)
}
// add
- if (typeof max === "number" && current.length >= max) return current
+ if (typeof max === "number" && currentArray.length >= max) return currentArray
set.add(v)
return Array.from(set)
}
@@ -52,8 +54,9 @@ export const CheckboxCardField: React.FC = ({
const selectedBg = useColorModeValue("blue.50", "blue.900")
const selectedBorder = useColorModeValue("blue.400", "blue.300")
const hoverBg = useColorModeValue("gray.50", "whiteAlpha.100")
+ const safeValue = Array.isArray(value) ? value : []
const isMaxed =
- typeof maxSelections === "number" && value.length >= maxSelections
+ typeof maxSelections === "number" && safeValue.length >= maxSelections
const handleKeyDown = (
e: React.KeyboardEvent,
@@ -63,7 +66,7 @@ export const CheckboxCardField: React.FC = ({
if (disabled) return
if (e.key === " " || e.key === "Enter") {
e.preventDefault()
- onChange(toggleValue(value, v, maxSelections))
+ onChange(toggleValue(safeValue, v, maxSelections))
}
}
@@ -84,7 +87,7 @@ export const CheckboxCardField: React.FC = ({
}}
>
{options.map((opt) => {
- const isChecked = value.includes(opt.value)
+ const isChecked = safeValue.includes(opt.value)
const disabled = opt.isDisabled || (!isChecked && isMaxed)
return (
// biome-ignore lint/a11y/useSemanticElements:
@@ -97,7 +100,7 @@ export const CheckboxCardField: React.FC = ({
tabIndex={disabled ? -1 : 0}
onClick={() => {
if (disabled) return
- onChange(toggleValue(value, opt.value, maxSelections))
+ onChange(toggleValue(safeValue, opt.value, maxSelections))
}}
onKeyDown={(e) => handleKeyDown(e, opt.value, disabled)}
cursor={disabled ? "not-allowed" : "pointer"}
diff --git a/packages/imagekit-editor-dev/src/components/editor/layout.tsx b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
index c724450..f0a0fec 100644
--- a/packages/imagekit-editor-dev/src/components/editor/layout.tsx
+++ b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
@@ -1,5 +1,7 @@
import { Flex } from "@chakra-ui/react"
import { useState } from "react"
+import { useAutoSaveTemplate } from "../../hooks/useAutoSaveTemplate"
+import { useSaveTemplate } from "../../hooks/useSaveTemplate"
import { Header, type HeaderProps } from "../header"
import { Sidebar } from "../sidebar"
import { ActionBar } from "./ActionBar"
@@ -16,6 +18,9 @@ export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) {
const [viewMode, setViewMode] = useState<"list" | "grid">("list")
const [gridImageSize, setGridImageSize] = useState(300)
+ useAutoSaveTemplate()
+ useSaveTemplate()
+
return (
<>
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
new file mode 100644
index 0000000..1304299
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
@@ -0,0 +1,96 @@
+import { Input } from "@chakra-ui/react"
+import React, { useEffect, useRef, useState } from "react"
+import { useEditorStore } from "../../store"
+
+const UNTITLED = "Untitled Template"
+
+export function TemplateNameInput() {
+ const templateName = useEditorStore((s) => s.templateName)
+ const isPristine = useEditorStore((s) => s.isPristine)
+ const setTemplateName = useEditorStore((s) => s.setTemplateName)
+
+ const [localValue, setLocalValue] = useState(templateName)
+ const localValueRef = useRef(localValue)
+ const inputRef = useRef(null)
+ const isFocusedRef = useRef(false)
+ const prevIsPristineRef = useRef(isPristine)
+
+ localValueRef.current = localValue
+
+ // Sync from store when not focused so external changes (e.g. loading a
+ // template from the dropdown) update the input without overwriting in-progress edits.
+ useEffect(() => {
+ if (!isFocusedRef.current) {
+ setLocalValue(templateName)
+ }
+ }, [templateName])
+
+ // Focus the input whenever a new template is created (isPristine transitions
+ // false โ true, which only happens via resetToNewTemplate).
+ useEffect(() => {
+ const wasPristine = prevIsPristineRef.current
+ prevIsPristineRef.current = isPristine
+ if (isPristine && !wasPristine) {
+ inputRef.current?.focus()
+ }
+ }, [isPristine])
+
+ const commit = () => {
+ const trimmed = localValueRef.current.trim()
+ const finalName = trimmed || UNTITLED
+ if (!trimmed) {
+ setLocalValue(UNTITLED)
+ }
+ // setTemplateName only marks isPristine:false when the name actually changed,
+ // which is what gates the auto-save in useAutoSaveTemplate.
+ setTemplateName(finalName)
+ }
+
+ const handleFocus = () => {
+ isFocusedRef.current = true
+ inputRef.current?.select()
+ }
+
+ const handleBlur = () => {
+ isFocusedRef.current = false
+ commit()
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ inputRef.current?.blur()
+ }
+ if (e.key === "Escape") {
+ setLocalValue(templateName)
+ inputRef.current?.blur()
+ }
+ }
+
+ const isDefault = localValue === UNTITLED
+
+ return (
+ setLocalValue(e.target.value)}
+ onBlur={handleBlur}
+ onFocus={handleFocus}
+ onKeyDown={handleKeyDown}
+ variant="unstyled"
+ fontWeight="medium"
+ fontSize="md"
+ color={isDefault ? "editorBattleshipGrey.500" : "editorBattleshipGrey.900"}
+ placeholder={UNTITLED}
+ _placeholder={{ color: "editorBattleshipGrey.500" }}
+ width="auto"
+ minW="10rem"
+ maxW="22rem"
+ px="2"
+ py="1"
+ borderRadius="md"
+ _hover={{ bg: "editorGray.200" }}
+ _focus={{ bg: "editorGray.200", outline: "none", boxShadow: "none" }}
+ cursor="text"
+ />
+ )
+}
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
new file mode 100644
index 0000000..07656e4
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
@@ -0,0 +1,173 @@
+import {
+ Box,
+ Flex,
+ Icon,
+ Popover,
+ PopoverArrow,
+ PopoverBody,
+ PopoverContent,
+ PopoverTrigger,
+ Text,
+ Tooltip,
+} from "@chakra-ui/react"
+import { IoMdCloudDone } from "@react-icons/all-files/io/IoMdCloudDone"
+import { MdSync } from "@react-icons/all-files/md/MdSync"
+import { MdSyncProblem } from "@react-icons/all-files/md/MdSyncProblem"
+import React, { useEffect, useRef, useState } from "react"
+import { useTemplateStorage } from "../../context/TemplateStorageContext"
+import { useEditorStore } from "../../store"
+
+const NOTIFICATION_DURATION_MS = 3000
+
+export function TemplateStatus() {
+ const syncStatus = useEditorStore((s) => s.syncStatus)
+ const storageError = useEditorStore((s) => s.storageError)
+ const isPristine = useEditorStore((s) => s.isPristine)
+ const provider = useTemplateStorage()
+
+ const [notificationVisible, setNotificationVisible] = useState(false)
+ const [lastSyncResult, setLastSyncResult] = useState<
+ "success" | "error" | null
+ >(null)
+ const timerRef = useRef | null>(null)
+
+ useEffect(() => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+
+ if (syncStatus === "saving") {
+ setNotificationVisible(true)
+ } else if (syncStatus === "saved") {
+ setLastSyncResult("success")
+ setNotificationVisible(true)
+ timerRef.current = setTimeout(
+ () => setNotificationVisible(false),
+ NOTIFICATION_DURATION_MS,
+ )
+ } else if (syncStatus === "unsaved") {
+ setNotificationVisible(true)
+ timerRef.current = setTimeout(
+ () => setNotificationVisible(false),
+ NOTIFICATION_DURATION_MS,
+ )
+ } else if (syncStatus === "error") {
+ setLastSyncResult("error")
+ setNotificationVisible(true)
+ timerRef.current = setTimeout(
+ () => setNotificationVisible(false),
+ NOTIFICATION_DURATION_MS,
+ )
+ }
+
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ }
+ }, [syncStatus])
+
+ if (!provider || isPristine) return null
+
+ const providerName = provider.getProviderName()
+
+ // "Savingโฆ" is a transient text-only state โ no icon yet
+ if (notificationVisible && syncStatus === "saving") {
+ return (
+
+ Savingโฆ
+
+ )
+ }
+
+ // Resolve the icon and label for the current state.
+ // When notification is visible, we show the icon + inline text.
+ // When notification fades, we show the icon alone (persistent/interactive).
+ // The icon wrapper is always structurally identical so its position never shifts.
+ let activeIcon: typeof IoMdCloudDone
+ let activeColor: string
+ let notifText: string | null = null
+ let isInteractive = false
+
+ if (notificationVisible) {
+ if (syncStatus === "saved") {
+ activeIcon = IoMdCloudDone
+ activeColor = "green.500"
+ notifText = `Saved to ${providerName}`
+ } else if (syncStatus === "unsaved") {
+ activeIcon = MdSync
+ activeColor = "editorBattleshipGrey.500"
+ notifText = "Unsaved local changes"
+ } else if (syncStatus === "error") {
+ activeIcon = MdSyncProblem
+ activeColor = "yellow.600"
+ notifText = "Save failed"
+ } else {
+ return null
+ }
+ } else {
+ if (lastSyncResult === null) return null
+ activeIcon = lastSyncResult === "success" ? IoMdCloudDone : MdSyncProblem
+ activeColor = lastSyncResult === "success" ? "green.500" : "yellow.600"
+ isInteractive = true
+ }
+
+ const popupTitle =
+ lastSyncResult === "success" ? "All changes saved" : "Sync failed"
+ const popupBody =
+ lastSyncResult === "success"
+ ? `Your changes are synced to ${providerName} successfully.`
+ : (storageError ?? "Failed to save changes. Please try again.")
+
+ return (
+
+ {/*
+ * The icon is always inside this same Box so its screen position never
+ * changes when the notification text appears or disappears.
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {popupTitle}
+
+
+ {popupBody}
+
+
+
+
+
+ {notifText && (
+
+ {notifText}
+
+ )}
+
+ )
+}
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
new file mode 100644
index 0000000..90398ef
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -0,0 +1,351 @@
+import {
+ AlertDialog,
+ AlertDialogBody,
+ AlertDialogContent,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogOverlay,
+ Badge,
+ Box,
+ Button,
+ Divider,
+ Flex,
+ Icon,
+ Input,
+ InputGroup,
+ InputLeftElement,
+ Popover,
+ PopoverBody,
+ PopoverContent,
+ PopoverTrigger,
+ Text,
+ useDisclosure,
+} from "@chakra-ui/react"
+import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown"
+import { PiMagnifyingGlass } from "@react-icons/all-files/pi/PiMagnifyingGlass"
+import { PiPlus } from "@react-icons/all-files/pi/PiPlus"
+import React, { useCallback, useEffect, useRef, useState } from "react"
+import { useTemplateStorage } from "../../context/TemplateStorageContext"
+import type { TemplateRecord } from "../../storage"
+import { useEditorStore } from "../../store"
+
+const MAX_VISIBLE = 8
+
+export function TemplatesDropdown() {
+ const provider = useTemplateStorage()
+ const { isOpen, onOpen, onClose } = useDisclosure()
+ const [templates, setTemplates] = useState([])
+ const [search, setSearch] = useState("")
+ const searchRef = useRef(null)
+ const cancelRef = useRef(null)
+
+ const [pendingTemplate, setPendingTemplate] = useState(
+ null,
+ )
+
+ const { loadTemplate, setTemplateName, setTemplateId, resetToNewTemplate } =
+ useEditorStore()
+ const templateId = useEditorStore((s) => s.templateId)
+ const templateName = useEditorStore((s) => s.templateName)
+ const transformations = useEditorStore((s) => s.transformations)
+ const syncStatus = useEditorStore((s) => s.syncStatus)
+ const isPristine = useEditorStore((s) => s.isPristine)
+ const setSyncStatus = useEditorStore((s) => s.setSyncStatus)
+
+ const fetchTemplates = useCallback(async () => {
+ if (!provider) return
+ const list = await provider.listTemplates()
+ setTemplates(list)
+ }, [provider])
+
+ useEffect(() => {
+ if (isOpen) {
+ setSearch("")
+ fetchTemplates()
+ setTimeout(() => searchRef.current?.focus(), 50)
+ }
+ }, [isOpen, fetchTemplates])
+
+ if (!provider) return null
+
+ const activeTemplate = templateId
+ ? (templates.find((t) => t.id === templateId) ?? null)
+ : null
+
+ // Show a "Current" row whenever the editor has live (non-pristine) state,
+ // regardless of whether the template has been saved to the provider yet.
+ const shouldShowCurrent = !isPristine
+
+ // Prefer server-side transformation count when available; fall back to store.
+ const currentTransformCount = activeTemplate
+ ? activeTemplate.transformations.length
+ : transformations.length
+
+ const filtered = templates
+ .filter((t) => t.id !== templateId)
+ .filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
+ .slice(0, MAX_VISIBLE)
+
+ const doLoadTemplate = (record: TemplateRecord) => {
+ loadTemplate(record.transformations)
+ setTemplateName(record.name)
+ setTemplateId(record.id)
+ onClose()
+ setPendingTemplate(null)
+ }
+
+ const handleSelect = (record: TemplateRecord) => {
+ if (isPristine || syncStatus === "saved") {
+ doLoadTemplate(record)
+ } else {
+ setPendingTemplate(record)
+ onClose()
+ }
+ }
+
+ const handleNewTemplate = () => {
+ resetToNewTemplate()
+ onClose()
+ }
+
+ const handleSaveAndContinue = async () => {
+ if (!provider || !pendingTemplate) return
+ const state = useEditorStore.getState()
+ setSyncStatus("saving")
+ try {
+ await provider.saveTemplate({
+ id: state.templateId ?? undefined,
+ name: state.templateName,
+ transformations: state.transformations.map(
+ ({ id: _id, ...rest }) => rest,
+ ),
+ })
+ setSyncStatus("saved")
+ } catch (err) {
+ setSyncStatus(
+ "error",
+ err instanceof Error ? err.message : "Failed to save",
+ )
+ }
+ doLoadTemplate(pendingTemplate)
+ }
+
+ const handleContinueWithoutSaving = () => {
+ if (pendingTemplate) doLoadTemplate(pendingTemplate)
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ variant="filled"
+ bg="editorGray.200"
+ _focus={{ bg: "editorGray.200" }}
+ borderRadius="md"
+ fontSize="sm"
+ />
+
+ }
+ onClick={handleNewTemplate}
+ variant="ghost"
+ colorScheme="blue"
+ flexShrink={0}
+ fontWeight="normal"
+ >
+ New
+
+
+
+
+ {shouldShowCurrent && (
+
+
+
+
+ {templateName}
+
+
+ Current
+
+
+
+ {currentTransformCount} transformation
+ {currentTransformCount !== 1 ? "s" : ""}
+
+
+
+ )}
+
+ {filtered.length === 0 && !shouldShowCurrent ? (
+
+
+ {search ? "No templates found" : "No saved templates yet"}
+
+
+ ) : filtered.length === 0 && shouldShowCurrent ? (
+
+
+ {search
+ ? "No other templates found"
+ : "No other saved templates"}
+
+
+ ) : (
+ filtered.map((record) => (
+ handleSelect(record)}
+ >
+
+
+ {record.name}
+
+
+ {record.transformations.length} transformation
+ {record.transformations.length !== 1 ? "s" : ""}
+
+
+
+ ))
+ )}
+
+
+ {templates.length > MAX_VISIBLE + (shouldShowCurrent ? 1 : 0) && (
+ <>
+
+
+
+ View all templates
+
+
+ >
+ )}
+
+
+
+
+ setPendingTemplate(null)}
+ isCentered
+ >
+
+
+
+ Unsaved changes
+
+
+ Your current changes haven't been saved yet. What would you like
+ to do before switching to{" "}
+
+ {pendingTemplate?.name}
+
+ ?
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index 9180329..94d330f 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -8,17 +8,14 @@ import {
MenuItem,
MenuList,
Spacer,
- Text,
} from "@chakra-ui/react"
-import { PiImageSquare } from "@react-icons/all-files/pi/PiImageSquare"
-import { PiImagesSquare } from "@react-icons/all-files/pi/PiImagesSquare"
import { PiX } from "@react-icons/all-files/pi/PiX"
-import React, { useMemo } from "react"
-import {
- type FileElement,
- type RequiredMetadata,
- useEditorStore,
-} from "../../store"
+import React from "react"
+import { useTemplateStorage } from "../../context/TemplateStorageContext"
+import { type FileElement, type RequiredMetadata, useEditorStore } from "../../store"
+import { TemplateNameInput } from "./TemplateNameInput"
+import { TemplateStatus } from "./TemplateStatus"
+import { TemplatesDropdown } from "./TemplatesDropdown"
interface ExportOptionButton<
Metadata extends RequiredMetadata = RequiredMetadata,
@@ -54,15 +51,7 @@ export interface HeaderProps<
export const Header = ({ onClose, exportOptions }: HeaderProps) => {
const { imageList, originalImageList, currentImage } = useEditorStore()
-
- const headerText = useMemo(() => {
- if (imageList.length === 1) {
- return decodeURIComponent(
- currentImage?.split("/").pop()?.split("?")?.[0] || "",
- )
- }
- return `${imageList.length} Images`
- }, [imageList, currentImage])
+ const provider = useTemplateStorage()
return (
{
borderBottomColor="editorBattleshipGrey.100"
flexShrink={0}
>
-
- {headerText}
+ {provider ? (
+
+
+
+
+ ) : null}
+
{exportOptions
?.filter((exportOption) =>
diff --git a/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx b/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx
new file mode 100644
index 0000000..c591f83
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx
@@ -0,0 +1,24 @@
+import React, { createContext, useContext } from "react"
+import type { TemplateStorageProvider } from "../storage"
+
+const TemplateStorageContext = createContext(
+ null,
+)
+
+export function TemplateStorageContextProvider({
+ provider,
+ children,
+}: {
+ provider: TemplateStorageProvider | null
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function useTemplateStorage(): TemplateStorageProvider | null {
+ return useContext(TemplateStorageContext)
+}
diff --git a/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
new file mode 100644
index 0000000..3bc3620
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
@@ -0,0 +1,85 @@
+import { useEffect, useRef } from "react"
+import { useTemplateStorage } from "../context/TemplateStorageContext"
+import { useEditorStore } from "../store"
+
+const DEBOUNCE_MS = 600
+
+/**
+ * Automatically persists the template to the storage provider whenever
+ * transformations or the template name change. Uses saveTemplate() so the
+ * record is immediately visible in listTemplates().
+ *
+ * Why transformationToEdit is NOT in the subscribed slice
+ * --------------------------------------------------------
+ * The Zustand store only holds committed transformation state.
+ * updateTransformation / addTransformation are called on "Apply", not on
+ * every keystroke โ react-hook-form owns the live form state and never touches
+ * the store. So whether a config sidebar is open is irrelevant to whether the
+ * store data is ready to save. Including transformationToEdit as a guard
+ * causes exactly the bug it was meant to prevent: "Apply" without closing the
+ * form leaves transformationToEdit non-null, so the subscription callback
+ * returns early and the change is never persisted.
+ *
+ * Why templateId is NOT in the subscribed slice
+ * -----------------------------------------------
+ * setTemplateId() is called on every save success. Including it would
+ * re-trigger the subscription and cause an infinite save loop.
+ */
+export function useAutoSaveTemplate() {
+ const provider = useTemplateStorage()
+ const timerRef = useRef | null>(null)
+
+ useEffect(() => {
+ if (!provider) return
+
+ const unsubscribe = useEditorStore.subscribe(
+ (state) => ({
+ transformations: state.transformations,
+ templateName: state.templateName,
+ isPristine: state.isPristine,
+ }),
+ (slice) => {
+ if (slice.isPristine) return
+
+ if (timerRef.current) clearTimeout(timerRef.current)
+
+ timerRef.current = setTimeout(async () => {
+ // Re-read fresh state at fire time: the slice snapshot can be up to
+ // DEBOUNCE_MS stale by the time the timer fires.
+ const state = useEditorStore.getState()
+ if (state.isPristine) return
+
+ const { setSyncStatus, setTemplateId } = state
+ setSyncStatus("saving")
+ try {
+ const saved = await provider.saveTemplate({
+ id: state.templateId ?? undefined,
+ name: state.templateName,
+ transformations: state.transformations.map(
+ ({ id: _id, ...rest }) => rest,
+ ),
+ })
+ setTemplateId(saved.id)
+ setSyncStatus("saved")
+ } catch (err) {
+ setSyncStatus(
+ "error",
+ err instanceof Error ? err.message : "Failed to auto-save template",
+ )
+ }
+ }, DEBOUNCE_MS)
+ },
+ {
+ equalityFn: (a, b) =>
+ a.transformations === b.transformations &&
+ a.templateName === b.templateName &&
+ a.isPristine === b.isPristine,
+ },
+ )
+
+ return () => {
+ unsubscribe()
+ if (timerRef.current) clearTimeout(timerRef.current)
+ }
+ }, [provider])
+}
diff --git a/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts b/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts
new file mode 100644
index 0000000..de03ed2
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts
@@ -0,0 +1,48 @@
+import { useCallback, useEffect } from "react"
+import { useTemplateStorage } from "../context/TemplateStorageContext"
+import { useEditorStore } from "../store"
+
+export function useSaveTemplate() {
+ const provider = useTemplateStorage()
+ const { setSyncStatus, setTemplateId, setTemplateName } = useEditorStore()
+
+ const save = useCallback(async () => {
+ if (!provider) return
+
+ const state = useEditorStore.getState()
+ const { transformations, templateName, templateId } = state
+
+ setSyncStatus("saving")
+ try {
+ const saved = await provider.saveTemplate({
+ id: templateId ?? undefined,
+ name: templateName,
+ transformations: transformations.map(({ id: _id, ...rest }) => rest),
+ })
+ setTemplateId(saved.id)
+ setTemplateName(saved.name)
+ setSyncStatus("saved")
+ } catch (err) {
+ setSyncStatus(
+ "error",
+ err instanceof Error ? err.message : "Failed to save template",
+ )
+ }
+ }, [provider, setSyncStatus, setTemplateId, setTemplateName])
+
+ useEffect(() => {
+ if (!provider) return
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === "s") {
+ e.preventDefault()
+ save()
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [provider, save])
+
+ return { save }
+}
diff --git a/packages/imagekit-editor-dev/src/index.tsx b/packages/imagekit-editor-dev/src/index.tsx
index 18ce1c9..fb34fbd 100644
--- a/packages/imagekit-editor-dev/src/index.tsx
+++ b/packages/imagekit-editor-dev/src/index.tsx
@@ -1,5 +1,11 @@
export type { ImageKitEditorProps, ImageKitEditorRef } from "./ImageKitEditor"
export { ImageKitEditor } from "./ImageKitEditor"
export { DEFAULT_FOCUS_OBJECTS } from "./schema"
+export type {
+ LocalStorageProviderOptions,
+ TemplateRecord,
+ TemplateStorageProvider,
+} from "./storage"
+export { createLocalStorageProvider } from "./storage"
export type { FileElement, Signer, Transformation } from "./store"
export { TRANSFORMATION_STATE_VERSION } from "./store"
diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts
index 9ec8a4f..de2470f 100644
--- a/packages/imagekit-editor-dev/src/schema/index.ts
+++ b/packages/imagekit-editor-dev/src/schema/index.ts
@@ -706,11 +706,24 @@ const baseTransformationSchema: TransformationSchema[] = [
defaultTransformation: {},
schema: z
.object({
- flip: z.coerce
- .string({
- invalid_type_error: "Should be a string.",
- })
- .optional(),
+ // z.preprocess normalises legacy string values that were coerced
+ // from the array before this fix (e.g. "horizontal",
+ // "horizontal,vertical", or corrupted "h,,,o,r,i,z,n,t,a,l,...").
+ flip: z.preprocess(
+ (val) => {
+ if (Array.isArray(val)) return val
+ if (typeof val === "string" && val) {
+ return val
+ .split(",")
+ .map((s) => s.trim())
+ .filter(
+ (s) => s === "horizontal" || s === "vertical",
+ )
+ }
+ return []
+ },
+ z.array(z.enum(["horizontal", "vertical"])).optional(),
+ ),
})
.refine(
(val) => {
diff --git a/packages/imagekit-editor-dev/src/storage/index.ts b/packages/imagekit-editor-dev/src/storage/index.ts
new file mode 100644
index 0000000..25b0bd6
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/storage/index.ts
@@ -0,0 +1,6 @@
+export { createLocalStorageProvider } from "./localStorage-provider"
+export type {
+ LocalStorageProviderOptions,
+ TemplateRecord,
+ TemplateStorageProvider,
+} from "./types"
diff --git a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
new file mode 100644
index 0000000..da92aaf
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
@@ -0,0 +1,91 @@
+import type {
+ LocalStorageProviderOptions,
+ TemplateRecord,
+ TemplateStorageProvider,
+} from "./types"
+
+const DEFAULT_TEMPLATES_KEY = "ik-editor-templates"
+
+function generateId(): string {
+ return `template-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
+}
+
+export function createLocalStorageProvider(
+ options: LocalStorageProviderOptions = {},
+): TemplateStorageProvider {
+ const templatesKey = options.templatesKey ?? DEFAULT_TEMPLATES_KEY
+
+ function readTemplates(): TemplateRecord[] {
+ try {
+ const raw = localStorage.getItem(templatesKey)
+ if (!raw) return []
+ return JSON.parse(raw) as TemplateRecord[]
+ } catch {
+ return []
+ }
+ }
+
+ function writeTemplates(templates: TemplateRecord[]): void {
+ localStorage.setItem(templatesKey, JSON.stringify(templates))
+ }
+
+ return {
+ getProviderName() {
+ return "localStorage"
+ },
+
+ async listTemplates(): Promise {
+ const templates = readTemplates()
+ return [...templates].sort((a, b) => {
+ const aTime = a.lastUsedAt ?? a.updatedAt
+ const bTime = b.lastUsedAt ?? b.updatedAt
+ return bTime - aTime
+ })
+ },
+
+ async getTemplate(id: string): Promise {
+ const templates = readTemplates()
+ return templates.find((t) => t.id === id) ?? null
+ },
+
+ async saveTemplate(
+ record: Omit & {
+ id?: string
+ },
+ ): Promise {
+ await new Promise((resolve) => setTimeout(resolve, 1500))
+ const templates = readTemplates()
+ const now = Date.now()
+
+ if (record.id) {
+ const index = templates.findIndex((t) => t.id === record.id)
+ if (index !== -1) {
+ const updated: TemplateRecord = {
+ ...templates[index],
+ name: record.name,
+ transformations: record.transformations,
+ updatedAt: now,
+ }
+ templates[index] = updated
+ writeTemplates(templates)
+ return updated
+ }
+ }
+
+ const newRecord: TemplateRecord = {
+ id: record.id ?? generateId(),
+ name: record.name,
+ transformations: record.transformations,
+ updatedAt: now,
+ }
+ templates.push(newRecord)
+ writeTemplates(templates)
+ return newRecord
+ },
+
+ async deleteTemplate(id: string): Promise {
+ const templates = readTemplates().filter((t) => t.id !== id)
+ writeTemplates(templates)
+ },
+ }
+}
diff --git a/packages/imagekit-editor-dev/src/storage/types.ts b/packages/imagekit-editor-dev/src/storage/types.ts
new file mode 100644
index 0000000..b625329
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/storage/types.ts
@@ -0,0 +1,21 @@
+import type { Transformation } from "../store"
+
+export interface TemplateRecord {
+ id: string
+ name: string
+ transformations: Omit[]
+ updatedAt: number
+ lastUsedAt?: number
+}
+
+export interface TemplateStorageProvider {
+ listTemplates(): Promise
+ getTemplate(id: string): Promise
+ saveTemplate(record: Omit & { id?: string }): Promise
+ deleteTemplate?(id: string): Promise
+ getProviderName(): string
+}
+
+export interface LocalStorageProviderOptions {
+ templatesKey?: string
+}
diff --git a/packages/imagekit-editor-dev/src/store.ts b/packages/imagekit-editor-dev/src/store.ts
index 2220a89..ac306d6 100644
--- a/packages/imagekit-editor-dev/src/store.ts
+++ b/packages/imagekit-editor-dev/src/store.ts
@@ -24,6 +24,8 @@ export interface Transformation {
type: "transformation"
value: IKTransformation
version?: typeof TRANSFORMATION_STATE_VERSION
+ /** Persisted visibility flag. Absent or true = visible; false = hidden. */
+ enabled?: boolean
}
export type RequiredMetadata = { requireSignedUrl: boolean }
@@ -72,6 +74,8 @@ export type FocusObjects =
| (typeof DEFAULT_FOCUS_OBJECTS)[number]
| (string & {})
+export type SyncStatus = "unsaved" | "saving" | "saved" | "error"
+
export interface EditorState<
Metadata extends RequiredMetadata = RequiredMetadata,
> {
@@ -88,6 +92,11 @@ export interface EditorState<
currentTransformKey: string
focusObjects?: ReadonlyArray
_internalState: InternalState
+ templateName: string
+ templateId: string | null
+ syncStatus: SyncStatus
+ storageError?: string
+ isPristine: boolean
}
export type EditorActions<
@@ -97,6 +106,8 @@ export type EditorActions<
imageList?: Array>
signer?: Signer
focusObjects?: ReadonlyArray
+ templateName?: string
+ templateId?: string
}) => void
destroy: () => void
setCurrentImage: (imageSrc: string | undefined) => void
@@ -123,6 +134,10 @@ export type EditorActions<
updatedTransformation: Omit,
) => void
setShowOriginal: (showOriginal: boolean) => void
+ setTemplateName: (name: string) => void
+ setTemplateId: (id: string | null) => void
+ setSyncStatus: (status: SyncStatus, error?: string) => void
+ resetToNewTemplate: () => void
_setSidebarState: (state: "none" | "type" | "config") => void
_setSelectedTransformationKey: (key: string | null) => void
@@ -184,6 +199,11 @@ const DEFAULT_STATE: EditorState = {
selectedTransformationKey: null,
transformationToEdit: null,
},
+ templateName: "Untitled Template",
+ templateId: null,
+ syncStatus: "unsaved",
+ storageError: undefined,
+ isPristine: true,
}
const useEditorStore = create()(
@@ -204,6 +224,14 @@ const useEditorStore = create()(
if (initialData?.focusObjects) {
updates.focusObjects = initialData.focusObjects
}
+ if (initialData?.templateName) {
+ updates.templateName = initialData.templateName
+ updates.isPristine = false
+ }
+ if (initialData?.templateId) {
+ updates.templateId = initialData.templateId
+ updates.isPristine = false
+ }
if (Object.keys(updates).length > 0) {
set(updates as EditorState)
}
@@ -314,7 +342,8 @@ const useEditorStore = create()(
const visibleTransformations: Record = {}
transformationsWithIds.forEach((t) => {
- visibleTransformations[t.id] = true
+ // enabled absent or true โ visible; false โ hidden
+ visibleTransformations[t.id] = t.enabled !== false
})
set((state) => ({
@@ -328,6 +357,7 @@ const useEditorStore = create()(
selectedTransformationKey: null,
transformationToEdit: null,
},
+ isPristine: false,
}))
},
@@ -343,24 +373,33 @@ const useEditorStore = create()(
)
if (oldIndex !== -1 && newIndex !== -1) {
- // Create a new array with the moved item
const updatedTransformations = [...state.transformations]
const [removed] = updatedTransformations.splice(oldIndex, 1)
updatedTransformations.splice(newIndex, 0, removed)
- return { transformations: updatedTransformations }
+ return { transformations: updatedTransformations, isPristine: false }
}
return { transformations: state.transformations }
})
},
toggleTransformationVisibility: (id) => {
- set((state) => ({
- visibleTransformations: {
- ...state.visibleTransformations,
- [id]: !state.visibleTransformations[id],
- },
- }))
+ set((state) => {
+ const newVisible = !state.visibleTransformations[id]
+ return {
+ visibleTransformations: {
+ ...state.visibleTransformations,
+ [id]: newVisible,
+ },
+ // Sync enabled into the transformations array so the auto-save
+ // subscription (which watches `transformations`) fires, and so the
+ // visibility state is persisted alongside the transformation data.
+ transformations: state.transformations.map((t) =>
+ t.id === id ? { ...t, enabled: newVisible } : t,
+ ),
+ isPristine: false,
+ }
+ })
},
addTransformation: (transformation, position) => {
@@ -376,6 +415,7 @@ const useEditorStore = create()(
...state.visibleTransformations,
[id]: true,
},
+ isPristine: false,
}
})
@@ -392,6 +432,7 @@ const useEditorStore = create()(
...state.visibleTransformations,
[id]: true,
},
+ isPristine: false,
}
})
@@ -403,6 +444,7 @@ const useEditorStore = create()(
transformations: state.transformations.filter(
(transformation) => transformation.id !== id,
),
+ isPristine: false,
}))
},
@@ -414,6 +456,7 @@ const useEditorStore = create()(
transformations: state.transformations.map((t) =>
t.id === id ? { ...updatedTransformation, id } : t,
),
+ isPristine: false,
}))
},
@@ -423,6 +466,38 @@ const useEditorStore = create()(
}))
},
+ setTemplateName: (name) => {
+ set((state) => ({
+ templateName: name,
+ isPristine: state.templateName === name ? state.isPristine : false,
+ }))
+ },
+
+ setTemplateId: (id) => {
+ set({ templateId: id })
+ },
+
+ setSyncStatus: (status, error?) => {
+ set({ syncStatus: status, storageError: error })
+ },
+
+ resetToNewTemplate: () => {
+ set({
+ transformations: [],
+ visibleTransformations: {},
+ templateName: "Untitled Template",
+ templateId: null,
+ syncStatus: "unsaved",
+ storageError: undefined,
+ isPristine: true,
+ _internalState: {
+ sidebarState: "none",
+ selectedTransformationKey: null,
+ transformationToEdit: null,
+ },
+ })
+ },
+
_setSidebarState: (sidebarState) => {
set((state) => ({
_internalState: { ...state._internalState, sidebarState },
From ff641630d7bffb7942545d8a1accd7fd7cd95940 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 15:17:02 +0530
Subject: [PATCH 10/30] feat: working commit for view all templates component
---
package.json | 2 +
.../components/common/FilterChipsField.tsx | 121 ++++
.../common/MultiSelectListField.tsx | 194 ++++++
.../src/components/editor/layout.tsx | 52 +-
.../components/header/TemplatesDropdown.tsx | 37 +-
.../src/components/header/index.tsx | 5 +-
.../templates/TemplatesLibraryView.tsx | 560 ++++++++++++++++++
.../imagekit-editor-dev/src/storage/index.ts | 2 +
.../src/storage/localStorage-provider.ts | 45 +-
.../imagekit-editor-dev/src/storage/types.ts | 26 +-
packages/imagekit-editor-dev/src/theme.ts | 18 +
yarn.lock | 16 +
12 files changed, 1040 insertions(+), 38 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx
create mode 100644 packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
create mode 100644 packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
diff --git a/package.json b/package.json
index 5046f3a..cd06372 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.1.1",
+ "@types/human-date": "^1",
"@types/jsdom": "^28",
"@vitest/coverage-v8": "^4.0.18",
"husky": "^9.1.7",
@@ -44,6 +45,7 @@
]
},
"dependencies": {
+ "human-date": "^1.4.0",
"react-select": "^5.2.1"
}
}
diff --git a/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx b/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx
new file mode 100644
index 0000000..58ba8a9
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx
@@ -0,0 +1,121 @@
+import {
+ type As,
+ Box,
+ Flex,
+ HStack,
+ Icon,
+ Text,
+ useColorModeValue,
+} from "@chakra-ui/react"
+import type * as React from "react"
+
+type FilterChipsOption = {
+ label: string
+ value: string
+ icon?: React.ReactNode
+}
+
+type FilterChipsFieldProps = {
+ id?: string
+ value?: string[]
+ options: FilterChipsOption[]
+ onChange: (values: string[]) => void
+ maxSelections?: number
+}
+
+const toggleValue = (
+ current: string[] = [],
+ v: string,
+ max?: number,
+): string[] => {
+ const currentArray = Array.isArray(current) ? current : []
+ const set = new Set(currentArray)
+ if (set.has(v)) {
+ set.delete(v)
+ return Array.from(set)
+ }
+ if (typeof max === "number" && currentArray.length >= max) return currentArray
+ set.add(v)
+ return Array.from(set)
+}
+
+export const FilterChipsField: React.FC = ({
+ id,
+ value = [],
+ options,
+ onChange,
+ maxSelections,
+}) => {
+ const selectedBg = useColorModeValue("blue.50", "blue.900")
+ const selectedBorder = useColorModeValue("blue.400", "blue.300")
+ const hoverBg = useColorModeValue("gray.50", "whiteAlpha.100")
+ const safeValue = Array.isArray(value) ? value : []
+ const isMaxed =
+ typeof maxSelections === "number" && safeValue.length >= maxSelections
+
+ const handleKeyDown = (
+ e: React.KeyboardEvent,
+ v: string,
+ disabled?: boolean,
+ ) => {
+ if (disabled) return
+ if (e.key === " " || e.key === "Enter") {
+ e.preventDefault()
+ onChange(toggleValue(safeValue, v, maxSelections))
+ }
+ }
+
+ return (
+
+ {options.map((opt) => {
+ const isChecked = safeValue.includes(opt.value)
+ const disabled = opt.isDisabled || (!isChecked && isMaxed)
+ return (
+ {
+ if (disabled) return
+ onChange(toggleValue(safeValue, opt.value, maxSelections))
+ }}
+ onKeyDown={(e) => handleKeyDown(e, opt.value, disabled)}
+ cursor={disabled ? "not-allowed" : "pointer"}
+ opacity={disabled ? 0.5 : 1}
+ borderWidth="1px"
+ borderRadius="md"
+ p="2"
+ transition="all 0.12s ease-in-out"
+ borderColor={isChecked ? selectedBorder : "gray.200"}
+ bg={isChecked ? selectedBg : "transparent"}
+ _hover={{
+ bg: disabled ? undefined : isChecked ? selectedBg : hoverBg,
+ }}
+ _focusVisible={{
+ boxShadow: "0 0 0 2px var(--chakra-colors-blue-400)",
+ outline: "none",
+ }}
+ >
+
+ {opt.icon ? : null}
+
+ {opt.label}
+
+
+
+ )
+ })}
+
+ )
+}
+
+export default FilterChipsField
diff --git a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
new file mode 100644
index 0000000..f25b533
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
@@ -0,0 +1,194 @@
+import {
+ Avatar,
+ Box,
+ Checkbox,
+ Divider,
+ Flex,
+ HStack,
+ Icon,
+ Input,
+ InputGroup,
+ InputLeftElement,
+ Text,
+} from "@chakra-ui/react"
+import { PiMagnifyingGlass } from "@react-icons/all-files/pi/PiMagnifyingGlass"
+import type * as React from "react"
+import { useMemo, useState } from "react"
+
+export type MultiSelectListOption = {
+ label: string
+ value: string
+ avatar?: string
+ email?: string
+ isDisabled?: boolean
+}
+
+type MultiSelectListFieldProps = {
+ id?: string
+ value?: string[]
+ options: MultiSelectListOption[]
+ onChange: (values: string[]) => void
+ maxHeight?: string
+ isSearchable?: boolean
+ searchPlaceholder?: string
+ selectedFirst?: boolean
+ showSelectedSeparator?: boolean
+}
+
+export const MultiSelectListField: React.FC = ({
+ id,
+ value = [],
+ options,
+ onChange,
+ maxHeight = "300px",
+ isSearchable = false,
+ searchPlaceholder = "Search...",
+ selectedFirst = false,
+ showSelectedSeparator = false,
+}) => {
+ const safeValue = Array.isArray(value) ? value : []
+ const [query, setQuery] = useState("")
+
+ const toggleValue = (v: string) => {
+ const set = new Set(safeValue)
+ if (set.has(v)) {
+ set.delete(v)
+ } else {
+ set.add(v)
+ }
+ onChange(Array.from(set))
+ }
+
+ const { selected, other } = useMemo(() => {
+ const q = query.trim().toLowerCase()
+ const filtered =
+ q.length === 0
+ ? options
+ : options.filter((o) => {
+ const haystack = `${o.label} ${o.email ?? ""}`.toLowerCase()
+ return haystack.includes(q)
+ })
+
+ if (!selectedFirst) return { selected: filtered, other: [] }
+
+ const selectedOptions: MultiSelectListOption[] = []
+ const otherOptions: MultiSelectListOption[] = []
+ const selectedSet = new Set(safeValue)
+ for (const opt of filtered) {
+ ;(selectedSet.has(opt.value) ? selectedOptions : otherOptions).push(opt)
+ }
+ return { selected: selectedOptions, other: otherOptions }
+ }, [options, query, safeValue, selectedFirst])
+
+ const shouldRenderSeparator =
+ selectedFirst && showSelectedSeparator && selected.length > 0 && other.length > 0
+
+ const renderOption = (opt: MultiSelectListOption, idx: number, arrLen: number) => {
+ const isChecked = safeValue.includes(opt.value)
+ const disabled = opt.isDisabled
+
+ return (
+ {
+ if (!disabled) toggleValue(opt.value)
+ }}
+ _hover={{
+ bg: disabled ? undefined : "gray.50",
+ }}
+ borderBottomWidth={idx < arrLen - 1 ? "1px" : "0"}
+ borderBottomColor="gray.100"
+ transition="background-color 0.12s ease-in-out"
+ >
+ {
+ if (!disabled) toggleValue(opt.value)
+ }}
+ pointerEvents="none"
+ flexShrink={0}
+ />
+
+
+
+
+
+ {opt.label}
+
+ {opt.email && (
+
+ {opt.email}
+
+ )}
+
+
+ )
+ }
+
+ const renderedCount = selectedFirst ? selected.length + other.length : selected.length
+
+ return (
+
+ {isSearchable ? (
+
+
+
+
+
+ setQuery(e.target.value)}
+ bg="gray.50"
+ borderColor="gray.200"
+ _hover={{ borderColor: "gray.300" }}
+ _focus={{
+ borderColor: "blue.500",
+ boxShadow: "0 0 0 1px #3182ce",
+ }}
+ />
+
+
+ ) : null}
+
+
+ {selectedFirst ? (
+ <>
+ {selected.map((opt, idx) => renderOption(opt, idx, selected.length))}
+ {shouldRenderSeparator ? : null}
+ {other.map((opt, idx) => renderOption(opt, idx, other.length))}
+ >
+ ) : (
+ selected.map((opt, idx) => renderOption(opt, idx, selected.length))
+ )}
+
+ {renderedCount === 0 && (
+
+ {query.trim() ? "No matches found" : "No items available"}
+
+ )}
+
+
+ )
+}
+
+export default MultiSelectListField
diff --git a/packages/imagekit-editor-dev/src/components/editor/layout.tsx b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
index f0a0fec..693c190 100644
--- a/packages/imagekit-editor-dev/src/components/editor/layout.tsx
+++ b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
@@ -4,6 +4,7 @@ import { useAutoSaveTemplate } from "../../hooks/useAutoSaveTemplate"
import { useSaveTemplate } from "../../hooks/useSaveTemplate"
import { Header, type HeaderProps } from "../header"
import { Sidebar } from "../sidebar"
+import { TemplatesLibraryView } from "../templates/TemplatesLibraryView"
import { ActionBar } from "./ActionBar"
import { GridView } from "./GridView"
import { ListView } from "./ListView"
@@ -17,33 +18,44 @@ interface Props {
export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) {
const [viewMode, setViewMode] = useState<"list" | "grid">("list")
const [gridImageSize, setGridImageSize] = useState(300)
+ const [layoutMode, setLayoutMode] = useState<"editor" | "templates">(
+ "templates",
+ )
useAutoSaveTemplate()
useSaveTemplate()
return (
<>
-
-
-
-
-
- {viewMode === "list" && }
- {viewMode === "grid" && (
-
- )}
+ setLayoutMode("templates")}
+ />
+ {layoutMode === "templates" ? (
+ setLayoutMode("editor")} />
+ ) : (
+
+
+
+
+ {viewMode === "list" && }
+ {viewMode === "grid" && (
+
+ )}
+
-
+ )}
>
)
}
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index 90398ef..e94cbe5 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -31,7 +31,11 @@ import { useEditorStore } from "../../store"
const MAX_VISIBLE = 8
-export function TemplatesDropdown() {
+interface TemplatesDropdownProps {
+ onViewAllTemplates?: () => void
+}
+
+export function TemplatesDropdown({ onViewAllTemplates }: TemplatesDropdownProps) {
const provider = useTemplateStorage()
const { isOpen, onOpen, onClose } = useDisclosure()
const [templates, setTemplates] = useState([])
@@ -165,7 +169,9 @@ export function TemplatesDropdown() {
shadow="lg"
p="0"
overflow="hidden"
- _focus={{ boxShadow: "lg" }}
+ borderWidth="0"
+ outline="none"
+ _focus={{ boxShadow: "lg", outline: "none", borderColor: "transparent" }}
>
- {templates.length > MAX_VISIBLE + (shouldShowCurrent ? 1 : 0) && (
+ {/* Always show "View all templates" when callback is provided, or when there are more templates than visible */}
+ {onViewAllTemplates ? (
<>
- {
+ onClose()
+ // Defer to next tick to allow popover to close cleanly
+ setTimeout(() => onViewAllTemplates?.(), 0)
+ }}
>
View all templates
+
+
+ >
+ ) : templates.length > MAX_VISIBLE + (shouldShowCurrent ? 1 : 0) ? (
+ <>
+
+
+
+ {templates.length} templates total
>
- )}
+ ) : null}
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index 94d330f..279ca57 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -47,9 +47,10 @@ export interface HeaderProps<
exportOptions?: Array<
ExportOptionButton | ExportOptionMenu
>
+ onViewAllTemplates?: () => void
}
-export const Header = ({ onClose, exportOptions }: HeaderProps) => {
+export const Header = ({ onClose, exportOptions, onViewAllTemplates }: HeaderProps) => {
const { imageList, originalImageList, currentImage } = useEditorStore()
const provider = useTemplateStorage()
@@ -69,7 +70,7 @@ export const Header = ({ onClose, exportOptions }: HeaderProps) => {
{provider ? (
-
+
) : null}
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
new file mode 100644
index 0000000..7758469
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -0,0 +1,560 @@
+import {
+ Avatar,
+ Badge,
+ Box,
+ Button,
+ Divider,
+ Flex,
+ Icon,
+ Input,
+ InputGroup,
+ InputLeftElement,
+ Popover,
+ PopoverBody,
+ PopoverContent,
+ PopoverTrigger,
+ Spinner,
+ Text,
+} from "@chakra-ui/react"
+import { PiArrowLeft } from "@react-icons/all-files/pi/PiArrowLeft"
+import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown"
+import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
+import { PiLock } from "@react-icons/all-files/pi/PiLock"
+import { PiMagnifyingGlass } from "@react-icons/all-files/pi/PiMagnifyingGlass"
+import { PiPushPin } from "@react-icons/all-files/pi/PiPushPin"
+import { PiPushPinFill } from "@react-icons/all-files/pi/PiPushPinFill"
+import humanDate from "human-date"
+import { useCallback, useEffect, useMemo, useState } from "react"
+import { useTemplateStorage } from "../../context/TemplateStorageContext"
+import { useDebounce } from "../../hooks/useDebounce"
+import type { TemplateRecord } from "../../storage"
+import { useEditorStore } from "../../store"
+import FilterChipsField from "../common/FilterChipsField"
+import MultiSelectListField from "../common/MultiSelectListField"
+
+interface Props {
+ onBack(): void
+}
+
+function formatRelativeTime(ts: number): string {
+ return humanDate.relativeTime(new Date(ts))
+}
+
+export function TemplatesLibraryView({ onBack }: Props) {
+ const provider = useTemplateStorage()
+ const [templates, setTemplates] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [searchInput, setSearchInput] = useState("")
+ const search = useDebounce(searchInput, 200)
+ const [visibilityFilter, setVisibilityFilter] = useState([])
+ const [creatorFilter, setCreatorFilter] = useState([])
+
+ const { loadTemplate, setTemplateName, setTemplateId } = useEditorStore()
+ const templateId = useEditorStore((s) => s.templateId)
+ const templateName = useEditorStore((s) => s.templateName)
+ const transformations = useEditorStore((s) => s.transformations)
+ const isPristine = useEditorStore((s) => s.isPristine)
+ const syncStatus = useEditorStore((s) => s.syncStatus)
+
+ const fetchTemplates = useCallback(async () => {
+ if (!provider) return
+ setLoading(true)
+ try {
+ const list = await provider.listTemplates()
+ setTemplates(list)
+ } finally {
+ setLoading(false)
+ }
+ }, [provider])
+
+ useEffect(() => {
+ fetchTemplates()
+ }, [fetchTemplates])
+
+ const shouldShowCurrent = !isPristine
+
+ const activeTemplate = templateId
+ ? (templates.find((t) => t.id === templateId) ?? null)
+ : null
+
+ const currentTransformCount = activeTemplate
+ ? activeTemplate.transformations.length
+ : transformations.length
+
+ const uniqueCreators = useMemo(() => {
+ const seen = new Map()
+ for (const t of templates) {
+ if (!seen.has(t.createdBy.userId)) {
+ seen.set(t.createdBy.userId, {
+ name: t.createdBy.name || t.createdBy.email,
+ email: t.createdBy.email,
+ })
+ }
+ }
+ return Array.from(seen.entries()).map(([userId, { name, email }]) => ({
+ userId,
+ name,
+ email,
+ }))
+ }, [templates])
+
+ const filtered = useMemo(() => {
+ return templates
+ .filter((t) => t.id !== templateId)
+ .filter((t) =>
+ search
+ ? t.name.toLowerCase().includes(search.toLowerCase()) ||
+ t.createdBy.name.toLowerCase().includes(search.toLowerCase()) ||
+ t.createdBy.email.toLowerCase().includes(search.toLowerCase())
+ : true,
+ )
+ .filter((t) => {
+ if (visibilityFilter.length === 0) return true
+ if (visibilityFilter.includes("private")) return t.isPrivate
+ if (visibilityFilter.includes("shared")) return !t.isPrivate
+ return true
+ })
+ .filter((t) =>
+ creatorFilter.length > 0
+ ? creatorFilter.includes(t.createdBy.userId)
+ : true,
+ )
+ }, [templates, templateId, search, visibilityFilter, creatorFilter])
+
+ const handleSelect = (record: TemplateRecord) => {
+ if (isPristine || syncStatus === "saved") {
+ loadTemplate(record.transformations)
+ setTemplateName(record.name)
+ setTemplateId(record.id)
+ onBack()
+ }
+ }
+
+ const handleTogglePin = async (record: TemplateRecord) => {
+ if (!provider) return
+
+ // For the local storage provider we only have a single logical user.
+ const currentUserId = "local"
+ const isPinned = record.pinnedBy.includes(currentUserId)
+ const nextPinnedBy = isPinned
+ ? record.pinnedBy.filter((id) => id !== currentUserId)
+ : [...record.pinnedBy, currentUserId]
+
+ try {
+ const updated = await provider.saveTemplate({
+ id: record.id,
+ name: record.name,
+ transformations: record.transformations,
+ clientNumber: record.clientNumber,
+ isPrivate: record.isPrivate,
+ pinnedBy: nextPinnedBy,
+ createdBy: record.createdBy,
+ updatedBy: record.updatedBy,
+ createdAt: record.createdAt,
+ })
+
+ setTemplates((prev) =>
+ prev.map((t) => (t.id === updated.id ? updated : t)),
+ )
+ } catch {
+ // Silently ignore pin failures in this view
+ }
+ }
+
+ return (
+
+ {/* Static top section: back button, title, filters */}
+
+
+ {/* Page header */}
+ }
+ color="editorBattleshipGrey.500"
+ _hover={{ color: "editorBattleshipGrey.700", bg: "transparent" }}
+ px="0"
+ >
+ Go back
+
+
+
+
+ All templates
+
+
+ Browse and load templates shared with you or created by your team.
+
+
+
+ {/* Controls bar */}
+
+
+
+
+
+ setSearchInput(e.target.value)}
+ bg="white"
+ borderColor="gray.200"
+ borderRadius="md"
+ px="2"
+ py="2"
+ fontSize="sm"
+ fontWeight="400"
+ _placeholder={{ fontWeight: "400" }}
+ _hover={{ borderColor: "gray.300" }}
+ _focus={{
+ borderColor: "blue.500",
+ boxShadow: "0 0 0 1px #3182ce",
+ }}
+ />
+
+
+
+
+
+
+
+
+ 0 ? 1 : 0.5}
+ >
+ Created by
+
+ {creatorFilter.length > 0 && (
+
+ {creatorFilter.length}
+
+ )}
+
+
+
+
+
+
+ ({
+ label: name,
+ value: userId,
+ email: email || undefined,
+ }))}
+ value={creatorFilter}
+ onChange={setCreatorFilter}
+ isSearchable
+ selectedFirst
+ showSelectedSeparator
+ />
+
+
+
+
+
+
+
+
+
+ {/* Scrollable table area */}
+
+
+ {loading ? (
+
+
+
+ ) : (
+ <>
+ {/* Table header */}
+
+
+ Name
+
+
+ Created by
+
+
+ Visibility
+
+
+ Last updated
+
+
+
+
+ {/* Current row */}
+ {shouldShowCurrent && (
+
+
+
+
+ {templateName}
+
+
+ Current
+
+
+
+ {currentTransformCount} transformation
+ {currentTransformCount !== 1 ? "s" : ""}
+
+
+
+ )}
+
+ {/* Filtered templates */}
+ {filtered.length === 0 ? (
+
+
+ {search ||
+ visibilityFilter.length > 0 ||
+ creatorFilter.length > 0
+ ? "No templates match your filters"
+ : shouldShowCurrent
+ ? "No other saved templates"
+ : "No saved templates yet"}
+
+
+ ) : (
+ filtered.map((record) => (
+
+ ))
+ )}
+ >
+ )}
+
+
+
+ )
+}
+
+interface TemplateRowProps {
+ record: TemplateRecord
+ onSelect(record: TemplateRecord): void
+ onTogglePin(record: TemplateRecord): void
+}
+
+function TemplateRow({ record, onSelect, onTogglePin }: TemplateRowProps) {
+ return (
+ onSelect(record)}
+ >
+ {/* Name + transform count */}
+
+
+ {record.name}
+
+
+ {record.transformations.length} transformation
+ {record.transformations.length !== 1 ? "s" : ""}
+
+
+
+ {/* Creator */}
+
+
+
+
+ {record.createdBy.name || record.createdBy.email}
+
+
+ {record.createdBy.email}
+
+
+
+
+ {/* Visibility */}
+
+
+
+
+ {record.isPrivate ? "Only to me" : "Shared with everyone"}
+
+
+
+
+ {/* Last updated */}
+
+
+ {formatRelativeTime(record.updatedAt)}
+
+
+
+ {/* Pin */}
+
+ {
+ e.stopPropagation()
+ onTogglePin(record)
+ }}
+ >
+
+
+
+
+ )
+}
diff --git a/packages/imagekit-editor-dev/src/storage/index.ts b/packages/imagekit-editor-dev/src/storage/index.ts
index 25b0bd6..fbfd074 100644
--- a/packages/imagekit-editor-dev/src/storage/index.ts
+++ b/packages/imagekit-editor-dev/src/storage/index.ts
@@ -1,6 +1,8 @@
export { createLocalStorageProvider } from "./localStorage-provider"
export type {
LocalStorageProviderOptions,
+ SaveTemplateInput,
+ TemplateCreator,
TemplateRecord,
TemplateStorageProvider,
} from "./types"
diff --git a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
index da92aaf..f39516f 100644
--- a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
+++ b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
@@ -1,15 +1,39 @@
import type {
LocalStorageProviderOptions,
+ SaveTemplateInput,
+ TemplateCreator,
TemplateRecord,
TemplateStorageProvider,
} from "./types"
const DEFAULT_TEMPLATES_KEY = "ik-editor-templates"
+const LOCAL_USER: TemplateCreator = { userId: "local", name: "You", email: "" }
+
function generateId(): string {
return `template-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
+function normalizeRecord(raw: Record): TemplateRecord {
+ const now = Date.now()
+ const updatedAt = (raw.updatedAt as number) || now
+ return {
+ id: (raw.id as string) || generateId(),
+ clientNumber: (raw.clientNumber as string) || "local",
+ isPrivate:
+ raw.isPrivate !== undefined ? (raw.isPrivate as boolean) : true,
+ name: (raw.name as string) || "",
+ transformations:
+ (raw.transformations as TemplateRecord["transformations"]) || [],
+ pinnedBy: (raw.pinnedBy as string[]) || [],
+ createdBy: (raw.createdBy as TemplateCreator) || LOCAL_USER,
+ updatedBy: (raw.updatedBy as TemplateCreator) || LOCAL_USER,
+ createdAt: (raw.createdAt as number) || updatedAt,
+ updatedAt,
+ lastUsedAt: raw.lastUsedAt as number | undefined,
+ }
+}
+
export function createLocalStorageProvider(
options: LocalStorageProviderOptions = {},
): TemplateStorageProvider {
@@ -19,7 +43,8 @@ export function createLocalStorageProvider(
try {
const raw = localStorage.getItem(templatesKey)
if (!raw) return []
- return JSON.parse(raw) as TemplateRecord[]
+ const parsed = JSON.parse(raw) as Record[]
+ return parsed.map(normalizeRecord)
} catch {
return []
}
@@ -48,11 +73,7 @@ export function createLocalStorageProvider(
return templates.find((t) => t.id === id) ?? null
},
- async saveTemplate(
- record: Omit & {
- id?: string
- },
- ): Promise {
+ async saveTemplate(record: SaveTemplateInput): Promise {
await new Promise((resolve) => setTimeout(resolve, 1500))
const templates = readTemplates()
const now = Date.now()
@@ -60,11 +81,15 @@ export function createLocalStorageProvider(
if (record.id) {
const index = templates.findIndex((t) => t.id === record.id)
if (index !== -1) {
+ const existing = templates[index]
const updated: TemplateRecord = {
- ...templates[index],
+ ...existing,
name: record.name,
transformations: record.transformations,
+ isPrivate: record.isPrivate ?? existing.isPrivate,
+ pinnedBy: record.pinnedBy ?? existing.pinnedBy,
updatedAt: now,
+ updatedBy: record.updatedBy ?? LOCAL_USER,
}
templates[index] = updated
writeTemplates(templates)
@@ -74,8 +99,14 @@ export function createLocalStorageProvider(
const newRecord: TemplateRecord = {
id: record.id ?? generateId(),
+ clientNumber: record.clientNumber ?? "local",
+ isPrivate: record.isPrivate ?? true,
name: record.name,
transformations: record.transformations,
+ pinnedBy: record.pinnedBy ?? [],
+ createdBy: record.createdBy ?? LOCAL_USER,
+ updatedBy: record.updatedBy ?? record.createdBy ?? LOCAL_USER,
+ createdAt: record.createdAt ?? now,
updatedAt: now,
}
templates.push(newRecord)
diff --git a/packages/imagekit-editor-dev/src/storage/types.ts b/packages/imagekit-editor-dev/src/storage/types.ts
index b625329..efe8b44 100644
--- a/packages/imagekit-editor-dev/src/storage/types.ts
+++ b/packages/imagekit-editor-dev/src/storage/types.ts
@@ -1,17 +1,41 @@
import type { Transformation } from "../store"
+export interface TemplateCreator {
+ userId: string
+ name: string
+ email: string
+}
+
export interface TemplateRecord {
id: string
+ clientNumber: string
+ isPrivate: boolean
name: string
transformations: Omit[]
+ pinnedBy: string[]
+ createdBy: TemplateCreator
+ updatedBy: TemplateCreator
+ createdAt: number
updatedAt: number
lastUsedAt?: number
}
+export type SaveTemplateInput = {
+ id?: string
+ name: string
+ transformations: Omit[]
+ clientNumber?: string
+ isPrivate?: boolean
+ pinnedBy?: string[]
+ createdBy?: TemplateCreator
+ updatedBy?: TemplateCreator
+ createdAt?: number
+}
+
export interface TemplateStorageProvider {
listTemplates(): Promise
getTemplate(id: string): Promise
- saveTemplate(record: Omit & { id?: string }): Promise
+ saveTemplate(record: SaveTemplateInput): Promise
deleteTemplate?(id: string): Promise
getProviderName(): string
}
diff --git a/packages/imagekit-editor-dev/src/theme.ts b/packages/imagekit-editor-dev/src/theme.ts
index b5f4e2e..3f6213a 100644
--- a/packages/imagekit-editor-dev/src/theme.ts
+++ b/packages/imagekit-editor-dev/src/theme.ts
@@ -1,4 +1,22 @@
export const themeOverrides = {
+ styles: {
+ global: {
+ "#ik-editor *": {
+ scrollbarWidth: "thin",
+ },
+ "#ik-editor *::-webkit-scrollbar": {
+ width: "6px",
+ height: "6px",
+ },
+ "#ik-editor *::-webkit-scrollbar-thumb": {
+ background: "rgba(160, 174, 192, 0.8)",
+ borderRadius: "999px",
+ },
+ "#ik-editor *::-webkit-scrollbar-track": {
+ background: "transparent",
+ },
+ },
+ },
colors: {
editorBattleshipGrey: {
"50": "#f9f6fd",
diff --git a/yarn.lock b/yarn.lock
index ee5faf0..8dc8d5c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2771,6 +2771,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/human-date@npm:^1":
+ version: 1.4.5
+ resolution: "@types/human-date@npm:1.4.5"
+ checksum: 10c0/e3a72ceaa539e96673a0f562f21a3bde72acdcd8128ef465d385802afa2f14e8548088c6cac56a3b074a9c2add83356de5df74ec1eb49f12c4ce3953acb2bfac
+ languageName: node
+ linkType: hard
+
"@types/jsdom@npm:^28":
version: 28.0.0
resolution: "@types/jsdom@npm:28.0.0"
@@ -4399,6 +4406,13 @@ __metadata:
languageName: node
linkType: hard
+"human-date@npm:^1.4.0":
+ version: 1.4.0
+ resolution: "human-date@npm:1.4.0"
+ checksum: 10c0/4548555e36f5f6b7759a23ec7b7b4882d1b14614a653c5171235828e0b4e4ce3360da5f901b3383413b488711782c8575b8d74d7234c12e0871c7c73fe7f203f
+ languageName: node
+ linkType: hard
+
"husky@npm:^9.1.7":
version: 9.1.7
resolution: "husky@npm:9.1.7"
@@ -4468,8 +4482,10 @@ __metadata:
resolution: "imagekit-editor@workspace:."
dependencies:
"@biomejs/biome": "npm:2.1.1"
+ "@types/human-date": "npm:^1"
"@types/jsdom": "npm:^28"
"@vitest/coverage-v8": "npm:^4.0.18"
+ human-date: "npm:^1.4.0"
husky: "npm:^9.1.7"
jsdom: "npm:^28.1.0"
lint-staged: "npm:^16.1.2"
From 96b912ee06a2f517bc9a85f025c1a6388aa983fd Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 15:47:15 +0530
Subject: [PATCH 11/30] feat: brought the view templates component into a modal
overlay
---
.../common/MultiSelectListField.tsx | 17 +--
.../src/components/editor/layout.tsx | 108 +++++++++++++-----
.../templates/TemplatesLibraryView.tsx | 27 +----
3 files changed, 97 insertions(+), 55 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
index f25b533..1497f4b 100644
--- a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
+++ b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
@@ -142,11 +142,10 @@ export const MultiSelectListField: React.FC = ({
{isSearchable ? (
@@ -158,12 +157,14 @@ export const MultiSelectListField: React.FC = ({
placeholder={searchPlaceholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
- bg="gray.50"
- borderColor="gray.200"
- _hover={{ borderColor: "gray.300" }}
+ variant="unstyled"
+ pl="8"
+ bg="transparent"
+ borderColor="transparent"
+ _hover={{ borderColor: "transparent" }}
_focus={{
- borderColor: "blue.500",
- boxShadow: "0 0 0 1px #3182ce",
+ borderColor: "transparent",
+ boxShadow: "none",
}}
/>
diff --git a/packages/imagekit-editor-dev/src/components/editor/layout.tsx b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
index 693c190..4150f82 100644
--- a/packages/imagekit-editor-dev/src/components/editor/layout.tsx
+++ b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
@@ -1,5 +1,6 @@
-import { Flex } from "@chakra-ui/react"
-import { useState } from "react"
+import { Box, Flex, IconButton } from "@chakra-ui/react"
+import { PiX } from "@react-icons/all-files/pi/PiX"
+import { useEffect, useState } from "react"
import { useAutoSaveTemplate } from "../../hooks/useAutoSaveTemplate"
import { useSaveTemplate } from "../../hooks/useSaveTemplate"
import { Header, type HeaderProps } from "../header"
@@ -18,9 +19,22 @@ interface Props {
export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) {
const [viewMode, setViewMode] = useState<"list" | "grid">("list")
const [gridImageSize, setGridImageSize] = useState(300)
- const [layoutMode, setLayoutMode] = useState<"editor" | "templates">(
- "templates",
- )
+ const [isTemplatesOpen, setIsTemplatesOpen] = useState(false)
+
+ // Close templates modal on Escape while it's open
+ useEffect(() => {
+ if (!isTemplatesOpen) return
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.stopPropagation()
+ setIsTemplatesOpen(false)
+ }
+ }
+ window.addEventListener("keydown", handleKeyDown)
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown)
+ }
+ }, [isTemplatesOpen])
useAutoSaveTemplate()
useSaveTemplate()
@@ -30,32 +44,74 @@ export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) {
setLayoutMode("templates")}
+ onViewAllTemplates={() => setIsTemplatesOpen(true)}
/>
- {layoutMode === "templates" ? (
- setLayoutMode("editor")} />
- ) : (
-
-
-
+
+
+
+ {viewMode === "list" && }
+ {viewMode === "grid" && (
+
+ )}
+
+
+ {isTemplatesOpen ? (
+
+
- }
+ size="sm"
+ variant="ghost"
+ position="absolute"
+ top="3"
+ right="3"
+ zIndex={1}
+ onClick={() => setIsTemplatesOpen(false)}
/>
- {viewMode === "list" && }
- {viewMode === "grid" && (
-
- )}
-
-
- )}
+
+ setIsTemplatesOpen(false)} />
+
+
+
+ ) : null}
>
)
}
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 7758469..01f85b4 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -16,7 +16,6 @@ import {
Spinner,
Text,
} from "@chakra-ui/react"
-import { PiArrowLeft } from "@react-icons/all-files/pi/PiArrowLeft"
import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown"
import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
import { PiLock } from "@react-icons/all-files/pi/PiLock"
@@ -33,14 +32,14 @@ import FilterChipsField from "../common/FilterChipsField"
import MultiSelectListField from "../common/MultiSelectListField"
interface Props {
- onBack(): void
+ onClose(): void
}
function formatRelativeTime(ts: number): string {
return humanDate.relativeTime(new Date(ts))
}
-export function TemplatesLibraryView({ onBack }: Props) {
+export function TemplatesLibraryView({ onClose }: Props) {
const provider = useTemplateStorage()
const [templates, setTemplates] = useState([])
const [loading, setLoading] = useState(true)
@@ -126,7 +125,7 @@ export function TemplatesLibraryView({ onBack }: Props) {
loadTemplate(record.transformations)
setTemplateName(record.name)
setTemplateId(record.id)
- onBack()
+ onClose()
}
}
@@ -170,29 +169,15 @@ export function TemplatesLibraryView({ onBack }: Props) {
background="white"
overflowY="hidden"
>
- {/* Static top section: back button, title, filters */}
+ {/* Static top section: title, filters */}
- {/* Page header */}
- }
- color="editorBattleshipGrey.500"
- _hover={{ color: "editorBattleshipGrey.700", bg: "transparent" }}
- px="0"
- >
- Go back
-
-
Date: Tue, 17 Mar 2026 16:16:01 +0530
Subject: [PATCH 12/30] feat: ui fixes for created by filter
---
.../common/MultiSelectListField.tsx | 56 ++++++++++++-------
.../templates/TemplatesLibraryView.tsx | 9 ++-
2 files changed, 43 insertions(+), 22 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
index 1497f4b..e7e4c5e 100644
--- a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
+++ b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
@@ -81,9 +81,16 @@ export const MultiSelectListField: React.FC = ({
}, [options, query, safeValue, selectedFirst])
const shouldRenderSeparator =
- selectedFirst && showSelectedSeparator && selected.length > 0 && other.length > 0
-
- const renderOption = (opt: MultiSelectListOption, idx: number, arrLen: number) => {
+ selectedFirst &&
+ showSelectedSeparator &&
+ selected.length > 0 &&
+ other.length > 0
+
+ const renderOption = (
+ opt: MultiSelectListOption,
+ idx: number,
+ arrLen: number,
+ ) => {
const isChecked = safeValue.includes(opt.value)
const disabled = opt.isDisabled
@@ -92,7 +99,8 @@ export const MultiSelectListField: React.FC = ({
key={opt.value}
px="3"
py="2.5"
- spacing="3"
+ spacing="2"
+ alignItems="center"
cursor={disabled ? "not-allowed" : "pointer"}
opacity={disabled ? 0.5 : 1}
onClick={() => {
@@ -104,6 +112,7 @@ export const MultiSelectListField: React.FC = ({
borderBottomWidth={idx < arrLen - 1 ? "1px" : "0"}
borderBottomColor="gray.100"
transition="background-color 0.12s ease-in-out"
+ margin="2"
>
= ({
}}
pointerEvents="none"
flexShrink={0}
+ borderColor="gray.300"
+ bg="white"
+ mr="2"
/>
-
+
@@ -136,7 +143,9 @@ export const MultiSelectListField: React.FC = ({
)
}
- const renderedCount = selectedFirst ? selected.length + other.length : selected.length
+ const renderedCount = selectedFirst
+ ? selected.length + other.length
+ : selected.length
return (
= ({
bg="transparent"
>
{isSearchable ? (
-
-
-
-
-
+
+
+
setQuery(e.target.value)}
variant="unstyled"
- pl="8"
bg="transparent"
borderColor="transparent"
_hover={{ borderColor: "transparent" }}
@@ -167,14 +179,16 @@ export const MultiSelectListField: React.FC = ({
boxShadow: "none",
}}
/>
-
+
) : null}
{selectedFirst ? (
<>
- {selected.map((opt, idx) => renderOption(opt, idx, selected.length))}
+ {selected.map((opt, idx) =>
+ renderOption(opt, idx, selected.length),
+ )}
{shouldRenderSeparator ? : null}
{other.map((opt, idx) => renderOption(opt, idx, other.length))}
>
@@ -184,7 +198,9 @@ export const MultiSelectListField: React.FC = ({
{renderedCount === 0 && (
- {query.trim() ? "No matches found" : "No items available"}
+
+ {query.trim() ? "No matches found" : "No items available"}
+
)}
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 01f85b4..6f2759e 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -286,7 +286,12 @@ export function TemplatesLibraryView({ onClose }: Props) {
p="0"
outline="none"
boxShadow="md"
- _focus={{ outline: "none", boxShadow: "md" }}
+ borderWidth="0"
+ _focus={{
+ outline: "none",
+ boxShadow: "md",
+ borderColor: "transparent",
+ }}
>
-
+
{opt.email && (
- {opt.email}
+ ({opt.email})
)}
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 6f2759e..f21a506 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -200,7 +200,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
w="100%"
flexWrap="wrap"
>
-
+
@@ -211,8 +211,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
bg="white"
borderColor="gray.200"
borderRadius="md"
- px="2"
- py="2"
+ px="3"
fontSize="sm"
fontWeight="400"
_placeholder={{ fontWeight: "400" }}
@@ -472,7 +471,12 @@ function TemplateRow({ record, onSelect, onTogglePin }: TemplateRowProps) {
>
{/* Name + transform count */}
-
+
{record.name}
From 8c6480e955e504bdbdfe50e9bcf57939b4f1d407 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 17:06:33 +0530
Subject: [PATCH 14/30] feat: template library working commit
---
.../templates/TemplatesLibraryView.tsx | 161 +++++++++++++++---
1 file changed, 139 insertions(+), 22 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index f21a506..71be793 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -9,6 +9,10 @@ import {
Input,
InputGroup,
InputLeftElement,
+ Menu,
+ MenuButton,
+ MenuItem,
+ MenuList,
Popover,
PopoverBody,
PopoverContent,
@@ -16,12 +20,14 @@ import {
Spinner,
Text,
} from "@chakra-ui/react"
+import { BsThreeDots } from "@react-icons/all-files/bs/BsThreeDots"
import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown"
import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
import { PiLock } from "@react-icons/all-files/pi/PiLock"
import { PiMagnifyingGlass } from "@react-icons/all-files/pi/PiMagnifyingGlass"
import { PiPushPin } from "@react-icons/all-files/pi/PiPushPin"
import { PiPushPinFill } from "@react-icons/all-files/pi/PiPushPinFill"
+import { PiTrash } from "@react-icons/all-files/pi/PiTrash"
import humanDate from "human-date"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
@@ -440,6 +446,12 @@ export function TemplatesLibraryView({ onClose }: Props) {
record={record}
onSelect={handleSelect}
onTogglePin={handleTogglePin}
+ onDelete={async (r) => {
+ if (!provider) return
+ if (!provider.deleteTemplate) return
+ await provider.deleteTemplate(r.id)
+ setTemplates((prev) => prev.filter((t) => t.id !== r.id))
+ }}
/>
))
)}
@@ -455,9 +467,16 @@ interface TemplateRowProps {
record: TemplateRecord
onSelect(record: TemplateRecord): void
onTogglePin(record: TemplateRecord): void
+ onDelete(record: TemplateRecord): void
}
-function TemplateRow({ record, onSelect, onTogglePin }: TemplateRowProps) {
+function TemplateRow({
+ record,
+ onSelect,
+ onTogglePin,
+ onDelete,
+}: TemplateRowProps) {
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
return (
onSelect(record)}
>
+ {/* Pin */}
+
+ {
+ e.stopPropagation()
+ onTogglePin(record)
+ }}
+ >
+
+
+
+
{/* Name + transform count */}
-
+
- {/* Pin */}
-
- {
- e.stopPropagation()
- onTogglePin(record)
- }}
+ {/* Row actions menu + delete confirmation popup */}
+ setShowDeleteConfirm(false)}
+ placement="bottom-end"
+ closeOnBlur
+ >
+
+ e.stopPropagation()}
+ >
+
+
+
+ e.stopPropagation()}
>
-
-
-
+
+
+ Are you sure you want to delete this template? This action is
+ irreversible.
+
+
+
+ }
+ onClick={() => {
+ setShowDeleteConfirm(false)
+ onDelete(record)
+ }}
+ >
+ Delete
+
+
+
+
+
)
}
From ce5575aba40ef56194dcdadf92752111a2e0e5c5 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 17:38:56 +0530
Subject: [PATCH 15/30] feat: ui fixes
---
.../templates/TemplatesLibraryView.tsx | 170 +++++++++++-------
1 file changed, 110 insertions(+), 60 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 71be793..ae792fb 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -42,7 +42,13 @@ interface Props {
}
function formatRelativeTime(ts: number): string {
- return humanDate.relativeTime(new Date(ts))
+ const now = Date.now()
+ // If the timestamp is within 10 seconds of now, show "Just now"
+ if (Math.abs(now - ts) < 10 * 1000) {
+ return "Just now"
+ }
+ const tsDate = new Date(ts)
+ return humanDate.relativeTime(tsDate)
}
export function TemplatesLibraryView({ onClose }: Props) {
@@ -53,6 +59,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
const search = useDebounce(searchInput, 200)
const [visibilityFilter, setVisibilityFilter] = useState([])
const [creatorFilter, setCreatorFilter] = useState([])
+ const [pinningId, setPinningId] = useState(null)
const { loadTemplate, setTemplateName, setTemplateId } = useEditorStore()
const templateId = useEditorStore((s) => s.templateId)
@@ -104,7 +111,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
}, [templates])
const filtered = useMemo(() => {
- return templates
+ const base = templates
.filter((t) => t.id !== templateId)
.filter((t) =>
search
@@ -124,6 +131,20 @@ export function TemplatesLibraryView({ onClose }: Props) {
? creatorFilter.includes(t.createdBy.userId)
: true,
)
+
+ // Sort so that pinned templates (for the local user) come first,
+ // then all others by most recently used / updated.
+ return [...base].sort((a, b) => {
+ const aPinned = a.pinnedBy.includes("local") ? 1 : 0
+ const bPinned = b.pinnedBy.includes("local") ? 1 : 0
+ if (aPinned !== bPinned) {
+ return bPinned - aPinned
+ }
+
+ const aTime = a.lastUsedAt ?? a.updatedAt
+ const bTime = b.lastUsedAt ?? b.updatedAt
+ return bTime - aTime
+ })
}, [templates, templateId, search, visibilityFilter, creatorFilter])
const handleSelect = (record: TemplateRecord) => {
@@ -146,6 +167,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
: [...record.pinnedBy, currentUserId]
try {
+ setPinningId(record.id)
const updated = await provider.saveTemplate({
id: record.id,
name: record.name,
@@ -163,6 +185,8 @@ export function TemplatesLibraryView({ onClose }: Props) {
)
} catch {
// Silently ignore pin failures in this view
+ } finally {
+ setPinningId((current) => (current === record.id ? null : current))
}
}
@@ -387,37 +411,20 @@ export function TemplatesLibraryView({ onClose }: Props) {
{/* Current row */}
- {shouldShowCurrent && (
-
-
-
-
- {templateName}
-
-
- Current
-
-
-
- {currentTransformCount} transformation
- {currentTransformCount !== 1 ? "s" : ""}
-
-
-
+ {shouldShowCurrent && activeTemplate && (
+ {
+ // Current row is informational; selecting it is a no-op.
+ }}
+ onTogglePin={handleTogglePin}
+ isPinning={pinningId === activeTemplate.id}
+ onDelete={() => {
+ // Deletion for current row is disabled via props.
+ }}
+ isCurrent
+ canDelete={false}
+ />
)}
{/* Filtered templates */}
@@ -446,6 +453,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
record={record}
onSelect={handleSelect}
onTogglePin={handleTogglePin}
+ isPinning={pinningId === record.id}
onDelete={async (r) => {
if (!provider) return
if (!provider.deleteTemplate) return
@@ -468,6 +476,9 @@ interface TemplateRowProps {
onSelect(record: TemplateRecord): void
onTogglePin(record: TemplateRecord): void
onDelete(record: TemplateRecord): void
+ isPinning: boolean
+ isCurrent?: boolean
+ canDelete?: boolean
}
function TemplateRow({
@@ -475,6 +486,9 @@ function TemplateRow({
onSelect,
onTogglePin,
onDelete,
+ isPinning,
+ isCurrent = false,
+ canDelete = true,
}: TemplateRowProps) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
return (
@@ -482,45 +496,64 @@ function TemplateRow({
px="5"
py="4"
alignItems="center"
- cursor="pointer"
+ cursor={isCurrent ? "default" : "pointer"}
borderBottomWidth="1px"
borderColor="editorGray.200"
- _hover={{ bg: "editorGray.50" }}
- onClick={() => onSelect(record)}
+ bg={isCurrent ? "blue.50" : "transparent"}
+ _hover={isCurrent ? undefined : { bg: "editorGray.50" }}
+ onClick={() => {
+ if (!isCurrent) onSelect(record)
+ }}
>
{/* Pin */}
{
e.stopPropagation()
onTogglePin(record)
}}
>
-
+ {isPinning ? (
+
+ ) : (
+
+ )}
{/* Name + transform count */}
+
+
+ {record.name}
+
+ {isCurrent && (
+
+ Current
+
+ )}
+
- {record.name}
-
-
{record.transformations.length} transformation
{record.transformations.length !== 1 ? "s" : ""}
@@ -529,20 +562,24 @@ function TemplateRow({
{/* Creator */}
{record.createdBy.name || record.createdBy.email}
-
+
{record.createdBy.email}
@@ -554,9 +591,12 @@ function TemplateRow({
-
+
{record.isPrivate ? "Only to me" : "Shared with everyone"}
@@ -564,7 +604,10 @@ function TemplateRow({
{/* Last updated */}
-
+
{formatRelativeTime(record.updatedAt)}
@@ -613,11 +656,13 @@ function TemplateRow({
>
}
- color="red.500"
+ color={canDelete ? "red.500" : "gray.400"}
display="flex"
alignItems="center"
- _hover={{ bg: "red.50" }}
+ _hover={{ bg: canDelete ? "red.50" : "transparent" }}
+ isDisabled={!canDelete}
onClick={(e) => {
+ if (!canDelete) return
e.stopPropagation()
setShowDeleteConfirm(true)
}}
@@ -648,6 +693,11 @@ function TemplateRow({
size="md"
variant="ghost"
onClick={() => setShowDeleteConfirm(false)}
+ color="editorBattleshipGrey.500"
+ _hover={{
+ color: "editorBattleshipGrey.800",
+ bg: "editorGray.50",
+ }}
>
Cancel
From e7a2276038fc7aad7ad2269851c07205f2beea17 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 17:43:28 +0530
Subject: [PATCH 16/30] fix: localStorage provider pinning/unpinning should not
update updatedAt
---
.../src/components/templates/TemplatesLibraryView.tsx | 1 +
.../imagekit-editor-dev/src/storage/localStorage-provider.ts | 2 +-
packages/imagekit-editor-dev/src/storage/types.ts | 5 +++++
3 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index ae792fb..a4631be 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -178,6 +178,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
createdBy: record.createdBy,
updatedBy: record.updatedBy,
createdAt: record.createdAt,
+ updatedAt: record.updatedAt,
})
setTemplates((prev) =>
diff --git a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
index f39516f..dc6ff09 100644
--- a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
+++ b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
@@ -88,7 +88,7 @@ export function createLocalStorageProvider(
transformations: record.transformations,
isPrivate: record.isPrivate ?? existing.isPrivate,
pinnedBy: record.pinnedBy ?? existing.pinnedBy,
- updatedAt: now,
+ updatedAt: record.updatedAt ?? now,
updatedBy: record.updatedBy ?? LOCAL_USER,
}
templates[index] = updated
diff --git a/packages/imagekit-editor-dev/src/storage/types.ts b/packages/imagekit-editor-dev/src/storage/types.ts
index efe8b44..ab9fc02 100644
--- a/packages/imagekit-editor-dev/src/storage/types.ts
+++ b/packages/imagekit-editor-dev/src/storage/types.ts
@@ -30,6 +30,11 @@ export type SaveTemplateInput = {
createdBy?: TemplateCreator
updatedBy?: TemplateCreator
createdAt?: number
+ /**
+ * Optional override for updatedAt. When provided, the local storage provider
+ * will respect this value instead of always touching updatedAt.
+ */
+ updatedAt?: number
}
export interface TemplateStorageProvider {
From 8c637d2eb223cf6c476f1fc07024ded814c2548a Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 17:45:01 +0530
Subject: [PATCH 17/30] fix: lint fixes
---
.../components/header/TemplateNameInput.tsx | 4 ++-
.../components/header/TemplatesDropdown.tsx | 10 ++++++--
.../src/components/header/index.tsx | 12 +++++++--
.../src/hooks/useAutoSaveTemplate.ts | 4 ++-
.../imagekit-editor-dev/src/schema/index.ts | 25 ++++++++-----------
.../src/storage/localStorage-provider.ts | 3 +--
6 files changed, 35 insertions(+), 23 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
index 1304299..0006e76 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
@@ -79,7 +79,9 @@ export function TemplateNameInput() {
variant="unstyled"
fontWeight="medium"
fontSize="md"
- color={isDefault ? "editorBattleshipGrey.500" : "editorBattleshipGrey.900"}
+ color={
+ isDefault ? "editorBattleshipGrey.500" : "editorBattleshipGrey.900"
+ }
placeholder={UNTITLED}
_placeholder={{ color: "editorBattleshipGrey.500" }}
width="auto"
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index e94cbe5..639c5fa 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -35,7 +35,9 @@ interface TemplatesDropdownProps {
onViewAllTemplates?: () => void
}
-export function TemplatesDropdown({ onViewAllTemplates }: TemplatesDropdownProps) {
+export function TemplatesDropdown({
+ onViewAllTemplates,
+}: TemplatesDropdownProps) {
const provider = useTemplateStorage()
const { isOpen, onOpen, onClose } = useDisclosure()
const [templates, setTemplates] = useState([])
@@ -171,7 +173,11 @@ export function TemplatesDropdown({ onViewAllTemplates }: TemplatesDropdownProps
overflow="hidden"
borderWidth="0"
outline="none"
- _focus={{ boxShadow: "lg", outline: "none", borderColor: "transparent" }}
+ _focus={{
+ boxShadow: "lg",
+ outline: "none",
+ borderColor: "transparent",
+ }}
>
void
}
-export const Header = ({ onClose, exportOptions, onViewAllTemplates }: HeaderProps) => {
+export const Header = ({
+ onClose,
+ exportOptions,
+ onViewAllTemplates,
+}: HeaderProps) => {
const { imageList, originalImageList, currentImage } = useEditorStore()
const provider = useTemplateStorage()
diff --git a/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
index 3bc3620..3d0f36e 100644
--- a/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
+++ b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
@@ -64,7 +64,9 @@ export function useAutoSaveTemplate() {
} catch (err) {
setSyncStatus(
"error",
- err instanceof Error ? err.message : "Failed to auto-save template",
+ err instanceof Error
+ ? err.message
+ : "Failed to auto-save template",
)
}
}, DEBOUNCE_MS)
diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts
index de2470f..1455b1e 100644
--- a/packages/imagekit-editor-dev/src/schema/index.ts
+++ b/packages/imagekit-editor-dev/src/schema/index.ts
@@ -709,21 +709,16 @@ const baseTransformationSchema: TransformationSchema[] = [
// z.preprocess normalises legacy string values that were coerced
// from the array before this fix (e.g. "horizontal",
// "horizontal,vertical", or corrupted "h,,,o,r,i,z,n,t,a,l,...").
- flip: z.preprocess(
- (val) => {
- if (Array.isArray(val)) return val
- if (typeof val === "string" && val) {
- return val
- .split(",")
- .map((s) => s.trim())
- .filter(
- (s) => s === "horizontal" || s === "vertical",
- )
- }
- return []
- },
- z.array(z.enum(["horizontal", "vertical"])).optional(),
- ),
+ flip: z.preprocess((val) => {
+ if (Array.isArray(val)) return val
+ if (typeof val === "string" && val) {
+ return val
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => s === "horizontal" || s === "vertical")
+ }
+ return []
+ }, z.array(z.enum(["horizontal", "vertical"])).optional()),
})
.refine(
(val) => {
diff --git a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
index dc6ff09..f40cad9 100644
--- a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
+++ b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
@@ -20,8 +20,7 @@ function normalizeRecord(raw: Record): TemplateRecord {
return {
id: (raw.id as string) || generateId(),
clientNumber: (raw.clientNumber as string) || "local",
- isPrivate:
- raw.isPrivate !== undefined ? (raw.isPrivate as boolean) : true,
+ isPrivate: raw.isPrivate !== undefined ? (raw.isPrivate as boolean) : true,
name: (raw.name as string) || "",
transformations:
(raw.transformations as TemplateRecord["transformations"]) || [],
From cf0e15174681899e33f0a76da330117d3178d11f Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 18:21:36 +0530
Subject: [PATCH 18/30] feat: polished template library ui blocks
---
.../common/MultiSelectListField.tsx | 2 +-
.../src/components/editor/layout.tsx | 14 +----
.../templates/TemplatesLibraryView.tsx | 54 ++++++++++++++-----
3 files changed, 44 insertions(+), 26 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
index 8bc331b..c32ff37 100644
--- a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
+++ b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
@@ -130,7 +130,7 @@ export const MultiSelectListField: React.FC = ({
-
+
{opt.label}
{opt.email && (
diff --git a/packages/imagekit-editor-dev/src/components/editor/layout.tsx b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
index 4150f82..fd8189f 100644
--- a/packages/imagekit-editor-dev/src/components/editor/layout.tsx
+++ b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
@@ -1,5 +1,4 @@
-import { Box, Flex, IconButton } from "@chakra-ui/react"
-import { PiX } from "@react-icons/all-files/pi/PiX"
+import { Box, Flex } from "@chakra-ui/react"
import { useEffect, useState } from "react"
import { useAutoSaveTemplate } from "../../hooks/useAutoSaveTemplate"
import { useSaveTemplate } from "../../hooks/useSaveTemplate"
@@ -89,17 +88,6 @@ export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) {
flexDirection="column"
position="relative"
>
- }
- size="sm"
- variant="ghost"
- position="absolute"
- top="3"
- right="3"
- zIndex={1}
- onClick={() => setIsTemplatesOpen(false)}
- />
([])
const [pinningId, setPinningId] = useState(null)
- const { loadTemplate, setTemplateName, setTemplateId } = useEditorStore()
+ const { loadTemplate, setTemplateName, setTemplateId, resetToNewTemplate } =
+ useEditorStore()
const templateId = useEditorStore((s) => s.templateId)
const templateName = useEditorStore((s) => s.templateName)
const transformations = useEditorStore((s) => s.transformations)
@@ -209,18 +212,45 @@ export function TemplatesLibraryView({ onClose }: Props) {
flexDirection="column"
gap="4"
>
-
- }
+ color="editorBattleshipGrey.500"
+ _hover={{ color: "editorBattleshipGrey.700", bg: "transparent" }}
+ px="0"
+ >
+ Go back
+
+
+
+
+
+ All templates
+
+
+ Browse and load templates created by you or shared with you.
+
+
+ }
+ px="4"
+ onClick={() => {
+ resetToNewTemplate()
+ onClose()
+ }}
>
- All templates
-
-
- Browse and load templates shared with you or created by your team.
-
-
+ New template
+
+
{/* Controls bar */}
Date: Tue, 17 Mar 2026 19:00:10 +0530
Subject: [PATCH 19/30] feat: compact layout for templates dropdown in the
navbar
---
.../components/header/TemplatesDropdown.tsx | 229 ++++++++++++++----
1 file changed, 187 insertions(+), 42 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index 639c5fa..864d37f 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -5,6 +5,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
+ Avatar,
Badge,
Box,
Button,
@@ -18,18 +19,23 @@ import {
PopoverBody,
PopoverContent,
PopoverTrigger,
+ Spinner,
Text,
useDisclosure,
} from "@chakra-ui/react"
import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown"
+import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
+import { PiLock } from "@react-icons/all-files/pi/PiLock"
import { PiMagnifyingGlass } from "@react-icons/all-files/pi/PiMagnifyingGlass"
import { PiPlus } from "@react-icons/all-files/pi/PiPlus"
-import React, { useCallback, useEffect, useRef, useState } from "react"
+import { PiPushPin } from "@react-icons/all-files/pi/PiPushPin"
+import { PiPushPinFill } from "@react-icons/all-files/pi/PiPushPinFill"
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
import type { TemplateRecord } from "../../storage"
import { useEditorStore } from "../../store"
-const MAX_VISIBLE = 8
+const MAX_VISIBLE = 5
interface TemplatesDropdownProps {
onViewAllTemplates?: () => void
@@ -42,6 +48,7 @@ export function TemplatesDropdown({
const { isOpen, onOpen, onClose } = useDisclosure()
const [templates, setTemplates] = useState([])
const [search, setSearch] = useState("")
+ const [pinningId, setPinningId] = useState(null)
const searchRef = useRef(null)
const cancelRef = useRef(null)
@@ -87,10 +94,26 @@ export function TemplatesDropdown({
? activeTemplate.transformations.length
: transformations.length
- const filtered = templates
- .filter((t) => t.id !== templateId)
- .filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
- .slice(0, MAX_VISIBLE)
+ const filtered = useMemo(() => {
+ const base = templates
+ .filter((t) => t.id !== templateId)
+ .filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
+
+ // Sort by: pinned first, then by most recently used/updated
+ return [...base]
+ .sort((a, b) => {
+ const aPinned = a.pinnedBy.includes("local") ? 1 : 0
+ const bPinned = b.pinnedBy.includes("local") ? 1 : 0
+ if (aPinned !== bPinned) {
+ return bPinned - aPinned
+ }
+
+ const aTime = a.lastUsedAt ?? a.updatedAt
+ const bTime = b.lastUsedAt ?? b.updatedAt
+ return bTime - aTime
+ })
+ .slice(0, MAX_VISIBLE)
+ }, [templates, templateId, search])
const doLoadTemplate = (record: TemplateRecord) => {
loadTemplate(record.transformations)
@@ -114,6 +137,39 @@ export function TemplatesDropdown({
onClose()
}
+ const handleTogglePin = async (record: TemplateRecord) => {
+ if (!provider) return
+ const currentUserId = "local"
+ const isPinned = record.pinnedBy.includes(currentUserId)
+ const nextPinnedBy = isPinned
+ ? record.pinnedBy.filter((id) => id !== currentUserId)
+ : [...record.pinnedBy, currentUserId]
+
+ try {
+ setPinningId(record.id)
+ const updated = await provider.saveTemplate({
+ id: record.id,
+ name: record.name,
+ transformations: record.transformations,
+ clientNumber: record.clientNumber,
+ isPrivate: record.isPrivate,
+ pinnedBy: nextPinnedBy,
+ createdBy: record.createdBy,
+ updatedBy: record.updatedBy,
+ createdAt: record.createdAt,
+ updatedAt: record.updatedAt,
+ })
+
+ setTemplates((prev) =>
+ prev.map((t) => (t.id === updated.id ? updated : t)),
+ )
+ } catch {
+ // ignore pin failures in dropdown
+ } finally {
+ setPinningId((current) => (current === record.id ? null : current))
+ }
+ }
+
const handleSaveAndContinue = async () => {
if (!provider || !pendingTemplate) return
const state = useEditorStore.getState()
@@ -167,7 +223,7 @@ export function TemplatesDropdown({
-
-
-
+
+
+
setSearch(e.target.value)}
- variant="filled"
- bg="editorGray.200"
- _focus={{ bg: "editorGray.200" }}
+ bg="white"
+ borderColor="gray.200"
borderRadius="md"
+ px="3"
fontSize="sm"
+ fontWeight="400"
+ _placeholder={{ fontWeight: "400" }}
+ _hover={{ borderColor: "gray.300" }}
+ _focus={{
+ borderColor: "blue.500",
+ boxShadow: "0 0 0 1px #3182ce",
+ }}
/>
}
- onClick={handleNewTemplate}
- variant="ghost"
+ size="md"
colorScheme="blue"
+ variant="outline"
+ leftIcon={ }
+ px="4"
flexShrink={0}
fontWeight="normal"
+ onClick={handleNewTemplate}
>
New
@@ -225,17 +285,27 @@ export function TemplatesDropdown({
{shouldShowCurrent && (
-
-
+ {/* Visibility Icon (fallback to private when unknown) */}
+
+
+ {/* Name + badge */}
+
+
-
- {currentTransformCount} transformation
- {currentTransformCount !== 1 ? "s" : ""}
-
+
+ {/* Transform count on the right */}
+
+ {currentTransformCount} transformation
+ {currentTransformCount !== 1 ? "s" : ""}
+
)}
@@ -273,21 +345,94 @@ export function TemplatesDropdown({
handleSelect(record)}
+ transition="background-color 0.15s"
+ role="group"
>
-
-
- {record.name}
-
-
- {record.transformations.length} transformation
- {record.transformations.length !== 1 ? "s" : ""}
-
-
+ {/* Visibility Icon */}
+
+
+ {/* Template name */}
+
+ {record.name}
+
+
+ {/* Creator on hover + pin (always visible for pinned, hover for others) */}
+
+ {/* Creator: only on hover */}
+
+
+
+ {record.createdBy.name || record.createdBy.email}
+
+
+
+ {/* Pin */}
+ {
+ e.stopPropagation()
+ handleTogglePin(record)
+ }}
+ >
+ {pinningId === record.id ? (
+
+ ) : (
+
+ )}
+
+
))
)}
@@ -301,7 +446,7 @@ export function TemplatesDropdown({
}
px="4"
flexShrink={0}
@@ -442,10 +442,11 @@ export function TemplatesDropdown({
{onViewAllTemplates ? (
<>
-
+
}
color="editorGray.700"
fontWeight="normal"
onClick={() => {
From 50552da836eb122d9862e19fcb9761e67ac6a4a3 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 20:07:27 +0530
Subject: [PATCH 21/30] fix: font size increased for better visibility
---
.../templates/TemplatesLibraryView.tsx | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index d147fb6..9740421 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -548,11 +548,11 @@ function TemplateRow({
}}
>
{isPinning ? (
-
+
) : (
{record.createdBy.name || record.createdBy.email}
@@ -621,11 +621,11 @@ function TemplateRow({
{record.isPrivate ? "Only to me" : "Shared with everyone"}
@@ -636,7 +636,7 @@ function TemplateRow({
{/* Last updated */}
{formatRelativeTime(record.updatedAt)}
From e01ea30998264831dda5a0b6085ab502306335da Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 20:10:04 +0530
Subject: [PATCH 22/30] fix: table header and row cells justfied left
---
.../components/templates/TemplatesLibraryView.tsx | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 9740421..03de4c0 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -426,17 +426,19 @@ export function TemplatesLibraryView({ onClose }: Props) {
textTransform="uppercase"
letterSpacing="0.06em"
>
-
- Name
+ {/* Pin column spacer to align with row */}
+
+
+ Name
- Created by
+ Created by
- Visibility
+ Visibility
- Last updated
+ Last updated
From 428229b064f069936bdfdcd7b3e803ad13e9c0e9 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 20:15:08 +0530
Subject: [PATCH 23/30] fix: remove border from all changes saved popover
---
.../imagekit-editor-dev/src/components/header/TemplateStatus.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
index 07656e4..147f20e 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
@@ -149,6 +149,7 @@ export function TemplateStatus() {
width="auto"
maxW="xs"
shadow="lg"
+ border="none"
_focus={{ boxShadow: "lg" }}
>
From 99cbccf162a064be583c467f1313c9db2295c8a6 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 20:19:42 +0530
Subject: [PATCH 24/30] feat: added visibility icon next to the template name
---
.../src/components/header/index.tsx | 38 ++++++++++++++++++-
1 file changed, 37 insertions(+), 1 deletion(-)
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index c82fde2..0230cab 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -9,8 +9,10 @@ import {
MenuList,
Spacer,
} from "@chakra-ui/react"
+import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
+import { PiLock } from "@react-icons/all-files/pi/PiLock"
import { PiX } from "@react-icons/all-files/pi/PiX"
-import React from "react"
+import React, { useEffect, useState } from "react"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
import {
type FileElement,
@@ -60,8 +62,35 @@ export const Header = ({
onViewAllTemplates,
}: HeaderProps) => {
const { imageList, originalImageList, currentImage } = useEditorStore()
+ const templateId = useEditorStore((s) => s.templateId)
const provider = useTemplateStorage()
+ const [isPrivate, setIsPrivate] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+
+ if (!provider || !templateId) {
+ setIsPrivate(null)
+ return
+ }
+
+ provider
+ .getTemplate(templateId)
+ .then((record) => {
+ if (cancelled) return
+ setIsPrivate(record ? record.isPrivate : null)
+ })
+ .catch(() => {
+ if (cancelled) return
+ setIsPrivate(null)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [provider, templateId])
+
return (
{provider ? (
+ {templateId && (
+
+ )}
From 437402374aee413c74c06ff0708eef0bb89e6f6f Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Wed, 18 Mar 2026 15:13:41 +0530
Subject: [PATCH 25/30] feat: navbar ui improvement
---
.../components/header/TemplatesDropdown.tsx | 21 ++++++--
.../src/components/header/index.tsx | 54 ++++++++++++++-----
2 files changed, 60 insertions(+), 15 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index 27d2d66..49391d2 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -208,14 +208,29 @@ export function TemplatesDropdown({
>
+
+
+ Templates
+
{provider ? (
-
- {templateId && (
-
- )}
-
-
-
+ <>
+
+ {templateId && (
+
+ )}
+
+
+
+ }
+ variant="ghost"
+ height="full"
+ width="20"
+ borderRadius="0"
+ size="md"
+ color="editorBattleshipGrey.500"
+ />
+
+
+
+
+
+ >
) : null}
-
+
+
+
{exportOptions
?.filter((exportOption) =>
From 70d1b40bbbad67c90435911533eef889d2e4e81a Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Wed, 18 Mar 2026 15:18:24 +0530
Subject: [PATCH 26/30] fix: reduce margin left on the name
---
packages/imagekit-editor-dev/src/components/header/index.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index 6d6af4e..bb5509f 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -108,7 +108,7 @@ export const Header = ({
>
{provider ? (
<>
-
+
{templateId && (
Date: Thu, 19 Mar 2026 13:06:13 +0530
Subject: [PATCH 27/30] chore: working state commit
---
.../src/components/header/NavbarItem.tsx | 48 +++
.../src/components/header/SettingsModal.tsx | 290 ++++++++++++++++++
.../src/components/header/TemplateStatus.tsx | 14 +-
.../components/header/TemplatesDropdown.tsx | 15 +-
.../src/components/header/index.tsx | 112 +++----
5 files changed, 421 insertions(+), 58 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx
create mode 100644 packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
diff --git a/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx b/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx
new file mode 100644
index 0000000..08bb59a
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx
@@ -0,0 +1,48 @@
+import { Button, type ButtonProps, Icon, IconButton } from "@chakra-ui/react"
+import type React from "react"
+
+interface NavbarItemProps extends Omit {
+ icon?: React.ReactElement
+ label: string
+ variant?: "button" | "icon"
+}
+
+export const NavbarItem = ({
+ icon,
+ label,
+ variant = "button",
+ children,
+ ...props
+}: NavbarItemProps) => {
+ const commonStyles = {
+ variant: "ghost" as const,
+ borderRadius: "md" as const,
+ px: "4" as const,
+ py: "2" as const,
+ mx: "2" as const,
+ fontSize: "sm" as const,
+ fontWeight: "medium" as const,
+ _hover: {
+ bg: "editorBattleshipGrey.50",
+ },
+ }
+
+ // If only icon is provided (no children or label to display), use icon variant
+ if (variant === "icon" || (!children && icon && !label)) {
+ return (
+ : undefined}
+ color="editorBattleshipGrey.500"
+ {...commonStyles}
+ {...props}
+ />
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
new file mode 100644
index 0000000..2d44cfc
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
@@ -0,0 +1,290 @@
+import {
+ Box,
+ Button,
+ Flex,
+ FormControl,
+ FormLabel,
+ Icon,
+ IconButton,
+ Input,
+ Text,
+} from "@chakra-ui/react"
+import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
+import { PiLock } from "@react-icons/all-files/pi/PiLock"
+import { PiTrash } from "@react-icons/all-files/pi/PiTrash"
+import { PiX } from "@react-icons/all-files/pi/PiX"
+import { useEffect, useState } from "react"
+import Select from "react-select"
+import { useTemplateStorage } from "../../context/TemplateStorageContext"
+import { useEditorStore } from "../../store"
+
+interface SettingsModalProps {
+ onClose: () => void
+}
+
+export function SettingsModal({ onClose }: SettingsModalProps) {
+ const provider = useTemplateStorage()
+ const templateId = useEditorStore((s) => s.templateId)
+ const templateName = useEditorStore((s) => s.templateName)
+ const setTemplateName = useEditorStore((s) => s.setTemplateName)
+ const transformations = useEditorStore((s) => s.transformations)
+ const setSyncStatus = useEditorStore((s) => s.setSyncStatus)
+ const resetToNewTemplate = useEditorStore((s) => s.resetToNewTemplate)
+
+ const [localName, setLocalName] = useState(templateName)
+ const [localVisibility, setLocalVisibility] = useState<"everyone" | "onlyMe">(
+ "onlyMe",
+ )
+ const [isDeleting, setIsDeleting] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+
+ // Fetch current template visibility
+ useEffect(() => {
+ let cancelled = false
+
+ if (!provider || !templateId) {
+ return
+ }
+
+ provider
+ .getTemplate(templateId)
+ .then((record) => {
+ if (cancelled || !record) return
+ setLocalVisibility(record.isPrivate ? "onlyMe" : "everyone")
+ })
+ .catch(() => {
+ // Ignore errors
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [provider, templateId])
+
+ const handleSave = async () => {
+ if (!provider || !localName.trim()) return
+
+ setIsSaving(true)
+ setSyncStatus("saving")
+
+ try {
+ await provider.saveTemplate({
+ id: templateId ?? undefined,
+ name: localName.trim(),
+ transformations: transformations.map(({ id: _id, ...rest }) => rest),
+ isPrivate: localVisibility === "onlyMe",
+ })
+
+ setTemplateName(localName.trim())
+ setSyncStatus("saved")
+ onClose()
+ } catch (err) {
+ setSyncStatus(
+ "error",
+ err instanceof Error ? err.message : "Failed to save",
+ )
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!provider || !templateId) return
+
+ setIsDeleting(true)
+
+ try {
+ await provider.deleteTemplate(templateId)
+ resetToNewTemplate()
+ onClose()
+ } catch (err) {
+ console.error("Failed to delete template:", err)
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ // Close on Escape key
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.stopPropagation()
+ onClose()
+ }
+ }
+ window.addEventListener("keydown", handleKeyDown)
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown)
+ }
+ }, [onClose])
+
+ return (
+
+ e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+ Template Settings
+
+ }
+ aria-label="Close settings"
+ />
+
+
+ {/* Content */}
+
+
+ {/* Template Name */}
+
+
+ Template Name
+
+ setLocalName(e.target.value)}
+ placeholder="Enter template name"
+ fontSize="sm"
+ />
+
+
+ {/* Visibility */}
+
+
+ Visibility
+
+
+
+
+
+ {/* Footer */}
+
+ }
+ onClick={handleDelete}
+ isLoading={isDeleting}
+ isDisabled={!templateId || isSaving}
+ >
+ Delete
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
index 147f20e..7a4dc0b 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
@@ -70,7 +70,12 @@ export function TemplateStatus() {
// "Savingโฆ" is a transient text-only state โ no icon yet
if (notificationVisible && syncStatus === "saving") {
return (
-
+
Savingโฆ
)
@@ -165,7 +170,12 @@ export function TemplateStatus() {
{notifText && (
-
+
{notifText}
)}
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index 49391d2..3ef8c22 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -80,6 +80,13 @@ export function TemplatesDropdown({
}
}, [isOpen, fetchTemplates])
+ useEffect(() => {
+ // Refetch templates when sync status changes to "saved" to reflect updates
+ if (syncStatus === "saved") {
+ fetchTemplates()
+ }
+ }, [syncStatus, fetchTemplates])
+
if (!provider) return null
const activeTemplate = templateId
@@ -213,9 +220,11 @@ export function TemplatesDropdown({
alignItems="center"
gap="2"
cursor="pointer"
- height="full"
- px="4"
- _hover={{ bg: "editorGray.100" }}
+ borderRadius="md"
+ paddingX="4"
+ paddingY="2"
+ marginX="2"
+ _hover={{ bg: "editorGray.200" }}
transition="background-color 0.15s"
aria-label="Open templates dropdown"
>
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index bb5509f..d82d394 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -1,9 +1,7 @@
import {
- Button,
Divider,
Flex,
Icon,
- IconButton,
Menu,
MenuButton,
MenuItem,
@@ -21,6 +19,8 @@ import {
type RequiredMetadata,
useEditorStore,
} from "../../store"
+import { NavbarItem } from "./NavbarItem"
+import { SettingsModal } from "./SettingsModal"
import { TemplateNameInput } from "./TemplateNameInput"
import { TemplateStatus } from "./TemplateStatus"
import { TemplatesDropdown } from "./TemplatesDropdown"
@@ -65,9 +65,11 @@ export const Header = ({
}: HeaderProps) => {
const { imageList, originalImageList, currentImage } = useEditorStore()
const templateId = useEditorStore((s) => s.templateId)
+ const syncStatus = useEditorStore((s) => s.syncStatus)
const provider = useTemplateStorage()
const [isPrivate, setIsPrivate] = useState(null)
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false)
useEffect(() => {
let cancelled = false
@@ -93,6 +95,30 @@ export const Header = ({
}
}, [provider, templateId])
+ // Refetch template visibility when it's saved
+ useEffect(() => {
+ let cancelled = false
+
+ if (!provider || !templateId || syncStatus !== "saved") {
+ return
+ }
+
+ provider
+ .getTemplate(templateId)
+ .then((record) => {
+ if (cancelled) return
+ setIsPrivate(record ? record.isPrivate : null)
+ })
+ .catch(() => {
+ if (cancelled) return
+ setIsPrivate(null)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [provider, templateId, syncStatus])
+
return (
- }
- variant="ghost"
- height="full"
- width="20"
- borderRadius="0"
- size="md"
- color="editorBattleshipGrey.500"
+ }
+ variant="icon"
+ onClick={() => setIsSettingsOpen(true)}
/>
-
+
>
) : null}
@@ -157,21 +182,11 @@ export const Header = ({
)
.map((exportOption) => (
-
{exportOption.type === "button" ? (
-
+ />
) : (
))}
-
- }
- aria-label="Close Button"
+
+ }
+ label="Close Button"
onClick={onClose}
- variant="ghost"
- fontWeight="normal"
- height="full"
- borderRadius="0"
- px="8"
- size="sm"
- >
- Close
-
+ />
+ {isSettingsOpen && (
+ setIsSettingsOpen(false)} />
+ )}
)
}
From 6ff72a2294401292df23ce8da0ab0508b4c679f7 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 19 Mar 2026 13:07:25 +0530
Subject: [PATCH 28/30] fix: close button label
---
packages/imagekit-editor-dev/src/components/header/index.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index d82d394..312bf32 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -253,7 +253,7 @@ export const Header = ({
/>
}
- label="Close Button"
+ label="Close"
onClick={onClose}
/>
{isSettingsOpen && (
From 96e349f4553ed0f185c39860e84cac698dfa073b Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 19 Mar 2026 13:22:51 +0530
Subject: [PATCH 29/30] fix: all lint fixes
---
.../src/components/common/FilterChipsField.tsx | 10 +---------
.../src/components/common/MultiSelectListField.tsx | 3 ---
.../src/components/header/TemplateNameInput.tsx | 3 ++-
.../src/components/header/TemplateStatus.tsx | 2 +-
.../src/components/header/TemplatesDropdown.tsx | 7 +++----
.../src/components/templates/TemplatesLibraryView.tsx | 6 ------
.../src/context/TemplateStorageContext.tsx | 3 ++-
7 files changed, 9 insertions(+), 25 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx b/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx
index 58ba8a9..bf0e5e7 100644
--- a/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx
+++ b/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx
@@ -66,21 +66,13 @@ export const FilterChipsField: React.FC = ({
}
return (
-
+
{options.map((opt) => {
const isChecked = safeValue.includes(opt.value)
const disabled = opt.isDisabled || (!isChecked && isMaxed)
return (
= ({
return (
t.id === templateId) ?? null)
: null
@@ -123,6 +121,8 @@ export function TemplatesDropdown({
.slice(0, MAX_VISIBLE)
}, [templates, templateId, search])
+ if (!provider) return null
+
const doLoadTemplate = (record: TemplateRecord) => {
loadTemplate(record.transformations)
setTemplateName(record.name)
@@ -376,7 +376,6 @@ export function TemplatesDropdown({
_hover={{ bg: "editorGray.100" }}
onClick={() => handleSelect(record)}
transition="background-color 0.15s"
- role="group"
>
{/* Visibility Icon */}
s.templateId)
- const templateName = useEditorStore((s) => s.templateName)
- const transformations = useEditorStore((s) => s.transformations)
const isPristine = useEditorStore((s) => s.isPristine)
const syncStatus = useEditorStore((s) => s.syncStatus)
@@ -92,10 +90,6 @@ export function TemplatesLibraryView({ onClose }: Props) {
? (templates.find((t) => t.id === templateId) ?? null)
: null
- const currentTransformCount = activeTemplate
- ? activeTemplate.transformations.length
- : transformations.length
-
const uniqueCreators = useMemo(() => {
const seen = new Map()
for (const t of templates) {
diff --git a/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx b/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx
index c591f83..64aa75d 100644
--- a/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx
+++ b/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx
@@ -1,4 +1,5 @@
-import React, { createContext, useContext } from "react"
+import type React from "react"
+import { createContext, useContext } from "react"
import type { TemplateStorageProvider } from "../storage"
const TemplateStorageContext = createContext(
From 0dbc50da7e360fcf4c6487f923cac49aefdae77b Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 19 Mar 2026 13:39:33 +0530
Subject: [PATCH 30/30] chore: update husky hook
---
.husky/pre-commit | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/.husky/pre-commit b/.husky/pre-commit
index af5adff..6684d66 100644
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1 +1,5 @@
-lint-staged
\ No newline at end of file
+# Run lint autofixes
+yarn lint:fix
+
+# Check lint again
+yarn lint
\ No newline at end of file