From 558f653380dfe62f36d159a0fbdc88bc24c974ca Mon Sep 17 00:00:00 2001 From: Manu Chaudhary Date: Tue, 10 Mar 2026 15:21:24 +0530 Subject: [PATCH 01/30] feat(templates): add template save/load functionality with versioning Add template management API to save and restore editor transformation stacks: - Add getTemplate() and loadTemplate() methods to ImageKitEditorRef - Implement v1 versioning system for backward compatibility - Export TRANSFORMATION_STATE_VERSION constant Add comprehensive testing infrastructure: - Add backward compatibility test suite with v1 template fixtures - Configure Vitest with coverage reporting - Add test step to CI workflow Update documentation: - Add template management guide with examples - Document version compatibility approach - Add TypeScript usage examples Other changes: - Bump version from 2.1.0 to 2.2.0 - Add coverage directory to .gitignore - Update React example with template save/load demo --- .github/workflows/ci.yaml | 3 +- .gitignore | 3 +- README.md | 157 +- examples/react-example/package.json | 5 +- examples/react-example/src/index.tsx | 237 +- package.json | 3 + packages/imagekit-editor-dev/package.json | 13 +- .../src/ImageKitEditor.tsx | 45 +- .../src/backward-compatibility.test.ts | 2549 +++++++++++++++++ packages/imagekit-editor-dev/src/index.tsx | 3 +- packages/imagekit-editor-dev/src/store.ts | 31 +- packages/imagekit-editor-dev/vite.config.ts | 16 + packages/imagekit-editor/package.json | 5 +- turbo.json | 4 + yarn.lock | 1730 ++++++++++- 15 files changed, 4767 insertions(+), 37 deletions(-) create mode 100644 packages/imagekit-editor-dev/src/backward-compatibility.test.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index da9c2de..0756460 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,10 +22,11 @@ jobs: node-version: 20.x cache: yarn - - name: ๐Ÿ“ฆ Install deps, build, pack + - name: ๐Ÿ“ฆ Install deps, lint, test, build, pack run: | yarn install --frozen-lockfile yarn lint + yarn test yarn package env: CI: true diff --git a/.gitignore b/.gitignore index e1c4725..d208200 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ packages/imagekit-editor/*.tgz .yarn builds packages/imagekit-editor/README.md -.cursor \ No newline at end of file +.cursor +coverage \ No newline at end of file diff --git a/README.md b/README.md index 1358919..9a3d936 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A powerful, React-based image editor component powered by ImageKit transformatio - ๐Ÿ–ผ๏ธ **Visual Image Editor**: Interactive UI for applying ImageKit transformations - ๐Ÿ“ **Transformation History**: Track and manage applied transformations using ImageKit's chain transformations +- ๐Ÿ’พ **Template Management**: Save and restore editor templates with built-in serialization support - ๐ŸŽจ **Multiple Transformation Types**: Support for resize, crop, focus, quality adjustments, and more - ๐Ÿ–ฅ๏ธ **Desktop Interface**: Modern interface built with Chakra UI for desktop environments - ๐Ÿ”ง **TypeScript Support**: Full TypeScript support with comprehensive type definitions @@ -141,9 +142,15 @@ interface ImageKitEditorRef { loadImage: (image: string | FileElement) => void; loadImages: (images: Array) => void; setCurrentImage: (imageSrc: string) => void; + getTemplate: () => Transformation[]; + loadTemplate: (template: Omit[]) => void; } ``` +**Template Management Methods:** +- `getTemplate()` - Returns the current editor template (transformation stack) +- `loadTemplate(template)` - Loads a previously saved template into the editor + ### Export Options You can configure export functionality in two ways: @@ -212,6 +219,124 @@ The `metadata` object can contain any contextual information your application ne ## Advanced Usage +### Template Management + +You can save and restore editor templates, enabling features like: +- Template library +- Preset transformation stacks +- Collaborative editing workflows +- Quick application of common transformations + +**Template Versioning:** All templates are versioned (currently `v1`) to ensure backward compatibility and safe schema evolution. + +#### Saving a Template + +```tsx +import { useRef } from 'react'; +import { ImageKitEditor, type ImageKitEditorRef, type Transformation } from '@imagekit/editor'; + +function MyComponent() { + const editorRef = useRef(null); + + const handleSaveTemplate = () => { + const template = editorRef.current?.getTemplate(); + if (template) { + // Remove the auto-generated 'id' field before saving + const templateToSave = template.map(({ id, ...rest }) => rest); + + // Save to localStorage + localStorage.setItem('editorTemplate', JSON.stringify(templateToSave)); + + // Or save to your backend + await api.saveTemplate(templateToSave); + } + }; + + return ( + + ); +} +``` + +#### Loading a Template + +```tsx +const handleLoadTemplate = () => { + // Load from localStorage + const saved = localStorage.getItem('editorTemplate'); + + // Or load from your backend + // const saved = await api.getTemplate(); + + if (saved) { + const template = JSON.parse(saved); + editorRef.current?.loadTemplate(template); + } +}; +``` + +#### Template Structure + +A template is an array of transformation objects with version information: + +```tsx +interface Transformation { + id: string; // Auto-generated, omit when saving + key: string; // e.g., 'adjust-background' + name: string; // e.g., 'Background' + type: 'transformation'; + value: Record; // Transformation parameters + version?: 'v1'; // Template version for compatibility +} + +// Version constant +import { TRANSFORMATION_STATE_VERSION } from '@imagekit/editor'; +console.log(TRANSFORMATION_STATE_VERSION); // 'v1' +``` + +**Example template:** +```json +[ + { + "key": "adjust-background", + "name": "Background", + "type": "transformation", + "value": { + "backgroundType": "color", + "background": "#FFFFFF" + }, + "version": "v1" + }, + { + "key": "resize_and_crop-resize_and_crop", + "name": "Resize and Crop", + "type": "transformation", + "value": { + "width": 800, + "height": 600, + "mode": "pad_resize" + }, + "version": "v1" + } +] +``` + +**Version Compatibility:** +- `v1` - Current version with all transformation features +- The `version` field is optional for backward compatibility +- Future versions will maintain backward compatibility where possible + ### Signed URLs For private images that require signed URLs, you can pass file metadata that will be available in the signer function: @@ -269,10 +394,40 @@ import type { ImageKitEditorProps, ImageKitEditorRef, FileElement, - Signer + Signer, + Transformation // For template management } from '@imagekit/editor'; + +// Version constant for template compatibility +import { TRANSFORMATION_STATE_VERSION } from '@imagekit/editor'; ``` +## Testing + +The package includes comprehensive tests to ensure schema stability and API consistency. Run tests: + +```bash +# Run tests once +yarn test + +# Watch mode +yarn test:watch + +# With UI +yarn test:ui +``` + +### Schema Versioning + +The transformation schema is locked down with tests to ensure: +- All transformation categories exist and are stable +- All transformation items have required properties +- Schemas validate correctly +- Template serialization/deserialization works consistently +- Version compatibility is maintained + +Current schema version: **v1** + ## Contributing We welcome contributions! Please see our [contributing guidelines](./CONTRIBUTING.md) for more details. diff --git a/examples/react-example/package.json b/examples/react-example/package.json index 7fac272..6930172 100644 --- a/examples/react-example/package.json +++ b/examples/react-example/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@imagekit/editor": "2.1.0", + "@imagekit/editor": "workspace:*", "@types/node": "^20.11.24", "@types/react": "^17.0.2", "@types/react-dom": "^17.0.2", @@ -18,7 +18,8 @@ "scripts": { "dev": "vite --port 3000", "start": "vite --port 3000", - "preview": "vite preview" + "preview": "vite preview", + "test": "echo \"No tests in this example\"" }, "eslintConfig": { "extends": [ diff --git a/examples/react-example/src/index.tsx b/examples/react-example/src/index.tsx index a083ba6..c68b0a5 100644 --- a/examples/react-example/src/index.tsx +++ b/examples/react-example/src/index.tsx @@ -1,6 +1,11 @@ import { Icon } from "@chakra-ui/react" -import { ImageKitEditor, type ImageKitEditorProps } from "@imagekit/editor" -import type { ImageKitEditorRef } from "@imagekit/editor/dist/ImageKitEditor" +import { + ImageKitEditor, + type ImageKitEditorProps, + type ImageKitEditorRef, + type Transformation, + TRANSFORMATION_STATE_VERSION, +} from "@imagekit/editor" import { PiDownload } from "@react-icons/all-files/pi/PiDownload" import React, { useCallback, useEffect } from "react" import ReactDOM from "react-dom" @@ -12,6 +17,10 @@ function App() { ImageKitEditorProps<{ requireSignedUrl: boolean; fileName: string }> >() const ref = React.useRef(null) + const [savedTemplate, setSavedTemplate] = React.useState< + Omit[] | null + >(null) + const [shouldLoadTemplate, setShouldLoadTemplate] = React.useState(false) /** * Function moved from EditorLayout component @@ -23,6 +32,75 @@ function App() { ref.current?.loadImage(randomImage) }, []) + /** + * Load template when editor becomes available + */ + React.useEffect(() => { + if (open && shouldLoadTemplate && ref.current && savedTemplate) { + ref.current.loadTemplate(savedTemplate) + console.log("Loaded template:", savedTemplate) + setShouldLoadTemplate(false) + } + }, [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 + */ + const handleLoadTemplate = useCallback(() => { + if (savedTemplate) { + // Flag to load template and open editor + setShouldLoadTemplate(true) + setOpen(true) + } else { + // Try to load from localStorage + const stored = localStorage.getItem("editorTemplate") + if (stored) { + try { + const parsed = JSON.parse(stored) + setSavedTemplate(parsed) + setShouldLoadTemplate(true) + setOpen(true) + console.log("Loaded template from localStorage:", parsed) + } catch (e) { + console.error("Failed to parse saved template:", e) + alert("โŒ Failed to load saved template - invalid JSON") + } + } else { + alert("โš ๏ธ No saved template found") + } + } + }, [savedTemplate]) + + /** + * Clear the saved template + */ + const handleClearTemplate = useCallback(() => { + if (confirm("Are you sure you want to clear the saved template?")) { + setSavedTemplate(null) + localStorage.removeItem("editorTemplate") + console.log("Cleared saved template") + alert("๐Ÿ—‘๏ธ Template cleared!") + } + }, []) + useEffect(() => { setEditorProps({ initialImages: [ @@ -59,11 +137,20 @@ function App() { exportOptions: [ { type: "button", - label: "Export", + label: "Export Images", icon: , isVisible: true, onClick: (images, currentImage) => { - console.log(images, currentImage) + console.log("Export images:", images, currentImage) + }, + }, + { + type: "button", + label: "Save Template", + icon: , + isVisible: true, + onClick: () => { + handleSaveTemplate() }, }, // { @@ -89,18 +176,148 @@ function App() { console.log("Signed URL", request.url) return Promise.resolve(request.url) }, - }) - }, [handleAddImage]) + }) }, [handleAddImage, handleSaveTemplate]) const toggle = () => { - setOpen((prev) => !prev) + setOpen((prev: boolean) => !prev) } return ( <> - +
+

ImageKit Editor - Template Management Demo

+

+ This demo shows how to save and restore editor templates using the + editor's ref methods. +

+ +
+ + + + + {savedTemplate && ( + + )} + + {savedTemplate && ( +
+

+ โœ“ Saved Template +

+

+ Transformations: {savedTemplate.length} +

+

+ Schema Version: {TRANSFORMATION_STATE_VERSION} +

+

+ Types:{" "} + {Array.from( + new Set(savedTemplate.map((t) => t.type)) + ).join(", ")} +

+
+ + ๐Ÿ“‹ View Template JSON + +
+                  {JSON.stringify(savedTemplate, null, 2)}
+                
+
+
+ )} +
+ +
+

๐Ÿ“– How to use Template Features:

+
    +
  1. Click "Open ImageKit Editor" and apply some transformations
  2. +
  3. Click the "Save Template" button in the editor header
  4. +
  5. Close the editor
  6. +
  7. + Click "Load Saved Template" - it will open the editor with all transformations restored +
  8. +
  9. Use "Clear Template" to remove the saved template
  10. +
+

+ ๐Ÿ’พ 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. +

+
+
+ {open && editorProps && } ) diff --git a/package.json b/package.json index 68cf98d..9c4303c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,10 @@ }, "devDependencies": { "@biomejs/biome": "2.1.1", + "@types/jsdom": "^28", + "@vitest/coverage-v8": "^4.0.18", "husky": "^9.1.7", + "jsdom": "^28.1.0", "lint-staged": "^16.1.2", "shx": "^0.4.0", "turbo": "^2.0.1" diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json index 45bbed6..d1bd98f 100644 --- a/packages/imagekit-editor-dev/package.json +++ b/packages/imagekit-editor-dev/package.json @@ -1,6 +1,6 @@ { "name": "imagekit-editor-dev", - "version": "0.0.0", + "version": "2.2.0", "description": "AI Image Editor powered by ImageKit", "scripts": { "prepack": "yarn build", @@ -8,7 +8,11 @@ "dev": "DEBUG=* vite build --watch", "start": "vite build --watch", "analyze": "vite build --mode analyze", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage" }, "keywords": [], "author": { @@ -29,13 +33,16 @@ "@types/react-color": "^2", "@types/react-dom": "^17.0.2", "@vitejs/plugin-react": "^4.5.2", + "@vitest/coverage-v8": "^2.1.9", + "@vitest/ui": "^2.0.0", "react": "^17.0.2", "react-dom": "^17.0.2", "rollup-plugin-visualizer": "^5.12.0", "terser": "^5.43.1", "typescript": "4.9.3", "vite": "^6.3.5", - "vite-plugin-dts": "5.0.0-beta.3" + "vite-plugin-dts": "5.0.0-beta.3", + "vitest": "^2.0.0" }, "dependencies": { "@chakra-ui/icons": "1.1.1", diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx index 675f583..d9bdf1b 100644 --- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx +++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx @@ -9,14 +9,54 @@ import { type InputFileElement, type RequiredMetadata, type Signer, + type Transformation, useEditorStore, } from "./store" import { themeOverrides } from "./theme" export interface ImageKitEditorRef { + /** + * Loads a single image into the editor + * @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 + * @example + * ```tsx + * const template = editorRef.current?.getTemplate() + * // Save to localStorage or backend + * localStorage.setItem('editorTemplate', JSON.stringify( + * template.map(({ id, ...rest }) => rest) + * )) + * ``` + */ + getTemplate: () => Transformation[] + + /** + * Loads a template (transformation stack) into the editor + * @param template - Array of transformation objects without the 'id' field + * @example + * ```tsx + * const saved = JSON.parse(localStorage.getItem('editorTemplate')) + * editorRef.current?.loadTemplate(saved) + * ``` + */ + loadTemplate: (template: Omit[]) => void } interface EditorProps { @@ -41,6 +81,7 @@ function ImageKitEditorImpl( transformations, initialize, destroy, + loadTemplate, } = useEditorStore() const handleOnClose = () => { @@ -73,8 +114,10 @@ function ImageKitEditorImpl( loadImage: addImage, loadImages: addImages, setCurrentImage, + getTemplate: () => transformations, + loadTemplate, }), - [addImage, addImages, setCurrentImage], + [addImage, addImages, setCurrentImage, transformations, loadTemplate], ) const mergedThemes = merge(defaultTheme, themeOverrides, theme) diff --git a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts new file mode 100644 index 0000000..4a644c0 --- /dev/null +++ b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts @@ -0,0 +1,2549 @@ +import { describe, expect, it } from "vitest" +import type { Transformation } from "./store" +import { TRANSFORMATION_STATE_VERSION } from "./store" +import { transformationFormatters, transformationSchema } from "./schema" + +/** + * V1 Template Fixtures + * These represent real saved templates from v1 of the editor. + * Tests ensure these templates continue to work even after UI/schema changes. + */ + +// Simple single transformation template +const V1_BASIC_TEMPLATE: Omit[] = [ + { + key: "adjust-background", + name: "Background", + type: "transformation", + value: { + backgroundType: "color", + background: "#FFFFFF", + }, + version: "v1", + }, +] + +// Multiple common transformations +const V1_COMMON_TEMPLATE: Omit[] = [ + { + key: "resize_and_crop-resize_and_crop", + name: "Resize and Crop", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "pad_resize", + }, + version: "v1", + }, + { + key: "adjust-background", + name: "Background", + type: "transformation", + value: { + backgroundType: "color", + background: "#E8E8E8", + }, + version: "v1", + }, + { + key: "adjust-rotate", + name: "Rotate", + type: "transformation", + value: { + rotate: 90, + }, + version: "v1", + }, +] + +// Complex template with gradient background +const V1_GRADIENT_TEMPLATE: Omit[] = [ + { + key: "adjust-background", + name: "Background", + type: "transformation", + value: { + backgroundType: "gradient", + backgroundGradient: { + from: "#FFFFFFFF", + to: "#00000000", + direction: "bottom", + stopPoint: 100, + }, + }, + version: "v1", + }, +] + +// AI transformations +const V1_AI_TEMPLATE: Omit[] = [ + { + key: "ai-bgremove", + name: "Remove Background", + type: "transformation", + value: { + bgremove: true, + }, + version: "v1", + }, + { + key: "ai-changebg", + name: "Change Background", + type: "transformation", + value: { + changebg: "beach sunset", + }, + version: "v1", + }, +] + +// Delivery optimizations +const V1_DELIVERY_TEMPLATE: Omit[] = [ + { + key: "delivery-quality", + name: "Quality", + type: "transformation", + value: { + quality: 80, + }, + version: "v1", + }, + { + key: "delivery-format", + name: "Format", + type: "transformation", + value: { + format: "webp", + }, + version: "v1", + }, +] + +// Layer transformations +const V1_LAYER_TEXT_TEMPLATE: Omit[] = [ + { + key: "layers-text", + name: "Text", + type: "transformation", + value: { + text: "Hello World", + fontSize: 48, + fontColor: "#000000", + x: 50, + y: 50, + fontFamily: "arial", + }, + version: "v1", + }, +] + +// Advanced adjustments +const V1_ADVANCED_TEMPLATE: Omit[] = [ + { + key: "adjust-contrast", + name: "Contrast", + type: "transformation", + value: { + contrast: true, + }, + version: "v1", + }, + { + key: "adjust-blur", + name: "Blur", + type: "transformation", + value: { + blur: 10, + }, + version: "v1", + }, + { + key: "adjust-radius", + name: "Corner Radius", + type: "transformation", + value: { + radius: { + radius: 20, + }, + }, + version: "v1", + }, +] + +// Comprehensive template with many transformations +const V1_COMPREHENSIVE_TEMPLATE: Omit[] = [ + { + key: "resize_and_crop-resize_and_crop", + name: "Resize and Crop", + type: "transformation", + value: { + width: 1200, + height: 800, + mode: "pad_resize", + }, + version: "v1", + }, + { + key: "adjust-background", + name: "Background", + type: "transformation", + value: { + backgroundType: "color", + background: "#FAFAFA", + }, + version: "v1", + }, + { + key: "adjust-radius", + name: "Corner Radius", + type: "transformation", + value: { + radius: { + radius: 15, + }, + }, + version: "v1", + }, + { + key: "adjust-border", + name: "Border", + type: "transformation", + value: { + border: 5, + borderColor: "#333333", + }, + version: "v1", + }, + { + key: "delivery-quality", + name: "Quality", + type: "transformation", + value: { + quality: 85, + }, + version: "v1", + }, + { + key: "delivery-format", + name: "Format", + type: "transformation", + value: { + format: "webp", + }, + version: "v1", + }, +] + +// Template without version field (backward compatibility) +const V1_UNVERSIONED_TEMPLATE: Omit[] = [ + { + key: "adjust-background", + name: "Background", + type: "transformation", + value: { + backgroundType: "color", + background: "#FFFFFF", + }, + }, + { + key: "adjust-rotate", + name: "Rotate", + type: "transformation", + value: { + rotate: 180, + }, + }, +] + +/** + * Helper to find a transformation schema by key + */ +function findTransformationSchema(key: string) { + for (const category of transformationSchema) { + const item = category.items.find((item) => item.key === key) + if (item) { + return item + } + } + return null +} + +/** + * Helper to check if a transformation key exists in the schema + */ +function isTransformationKeyValid(key: string): boolean { + return findTransformationSchema(key) !== null +} + +/** + * Validates that a transformation can be processed by the editor + * - Key must exist in current schema + * - Structure must be valid (name, type, value) + * - Value must pass Zod schema validation + * Returns validation result with details + */ +function validateTransformation(t: Omit): { + valid: boolean + errors: string[] +} { + const errors: string[] = [] + + // Check basic structure + if (!t.name) { + errors.push("Missing 'name' field") + } + if (t.type !== "transformation") { + errors.push(`Invalid type: expected 'transformation', got '${t.type}'`) + } + if (!t.value) { + errors.push("Missing 'value' field") + } + + // Check if key exists in schema + const schemaItem = findTransformationSchema(t.key) + if (!schemaItem) { + errors.push(`Transformation key '${t.key}' not found in current schema`) + return { valid: false, errors } + } + + // Validate value against Zod schema + try { + const result = schemaItem.schema.safeParse(t.value) + if (!result.success) { + result.error.errors.forEach((err) => { + errors.push( + `Schema validation failed for '${err.path.join(".")}': ${err.message}` + ) + }) + } + } catch (error) { + errors.push(`Schema validation error: ${error}`) + } + + return { + valid: errors.length === 0, + errors, + } +} + +describe("Backward Compatibility - V1 Templates", () => { + describe("Version Constant", () => { + it("should have v1 as current version", () => { + expect(TRANSFORMATION_STATE_VERSION).toBe("v1") + }) + }) + + describe("V1 Basic Template", () => { + it("should parse basic template as valid JSON", () => { + const json = JSON.stringify(V1_BASIC_TEMPLATE) + const parsed = JSON.parse(json) + expect(Array.isArray(parsed)).toBe(true) + expect(parsed.length).toBe(1) + }) + + it("should have valid transformation keys", () => { + V1_BASIC_TEMPLATE.forEach((t) => { + expect(isTransformationKeyValid(t.key)).toBe(true) + }) + }) + + it("should have version field set to v1", () => { + V1_BASIC_TEMPLATE.forEach((t) => { + expect(t.version).toBe("v1") + }) + }) + + it("should pass Zod schema validation", () => { + V1_BASIC_TEMPLATE.forEach((t) => { + const result = validateTransformation(t) + if (!result.valid) { + console.error(`Validation errors for ${t.key}:`, result.errors) + } + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + }) + }) + + describe("V1 Common Template", () => { + it("should parse template as valid JSON", () => { + const json = JSON.stringify(V1_COMMON_TEMPLATE) + const parsed = JSON.parse(json) + expect(Array.isArray(parsed)).toBe(true) + expect(parsed.length).toBe(3) + }) + + it("should have all valid transformation keys", () => { + V1_COMMON_TEMPLATE.forEach((t) => { + expect(isTransformationKeyValid(t.key)).toBe(true) + }) + }) + + it("should pass Zod schema validation", () => { + V1_COMMON_TEMPLATE.forEach((t) => { + const result = validateTransformation(t) + if (!result.valid) { + console.error(`Validation errors for ${t.key}:`, result.errors) + } + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + }) + }) + + describe("V1 Gradient Template", () => { + it("should parse template as valid JSON", () => { + const json = JSON.stringify(V1_GRADIENT_TEMPLATE) + const parsed = JSON.parse(json) + expect(Array.isArray(parsed)).toBe(true) + }) + + it("should preserve complex gradient values", () => { + const json = JSON.stringify(V1_GRADIENT_TEMPLATE) + const parsed = JSON.parse(json) + const gradient = parsed[0].value.backgroundGradient + expect(gradient.from).toBe("#FFFFFFFF") + expect(gradient.to).toBe("#00000000") + expect(gradient.direction).toBe("bottom") + expect(gradient.stopPoint).toBe(100) + }) + + it("should pass Zod schema validation", () => { + V1_GRADIENT_TEMPLATE.forEach((t) => { + const result = validateTransformation(t) + if (!result.valid) { + console.error(`Validation errors for ${t.key}:`, result.errors) + } + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + }) + }) + + describe("V1 AI Template", () => { + it("should parse AI transformations as valid JSON", () => { + const json = JSON.stringify(V1_AI_TEMPLATE) + const parsed = JSON.parse(json) + expect(Array.isArray(parsed)).toBe(true) + expect(parsed.length).toBe(2) + }) + + it("should have valid AI transformation keys", () => { + V1_AI_TEMPLATE.forEach((t) => { + expect(isTransformationKeyValid(t.key)).toBe(true) + }) + }) + + it("should pass Zod schema validation", () => { + V1_AI_TEMPLATE.forEach((t) => { + const result = validateTransformation(t) + if (!result.valid) { + console.error(`Validation errors for ${t.key}:`, result.errors) + } + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + }) + }) + + describe("V1 Delivery Template", () => { + it("should parse delivery optimizations as valid JSON", () => { + const json = JSON.stringify(V1_DELIVERY_TEMPLATE) + const parsed = JSON.parse(json) + expect(Array.isArray(parsed)).toBe(true) + expect(parsed.length).toBe(2) + }) + + it("should have valid delivery transformation keys", () => { + V1_DELIVERY_TEMPLATE.forEach((t) => { + expect(isTransformationKeyValid(t.key)).toBe(true) + }) + }) + + it("should pass Zod schema validation", () => { + V1_DELIVERY_TEMPLATE.forEach((t) => { + const result = validateTransformation(t) + if (!result.valid) { + console.error(`Validation errors for ${t.key}:`, result.errors) + } + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + }) + }) + + describe("V1 Layer Text Template", () => { + it("should parse text layer as valid JSON", () => { + const json = JSON.stringify(V1_LAYER_TEXT_TEMPLATE) + const parsed = JSON.parse(json) + expect(Array.isArray(parsed)).toBe(true) + }) + + it("should preserve text layer values", () => { + const json = JSON.stringify(V1_LAYER_TEXT_TEMPLATE) + const parsed = JSON.parse(json) + expect(parsed[0].value.text).toBe("Hello World") + expect(parsed[0].value.fontSize).toBe(48) + expect(parsed[0].value.fontColor).toBe("#000000") + }) + + it("should have valid text layer key", () => { + expect(isTransformationKeyValid(V1_LAYER_TEXT_TEMPLATE[0].key)).toBe(true) + }) + }) + + describe("V1 Advanced Template", () => { + it("should parse advanced adjustments as valid JSON", () => { + const json = JSON.stringify(V1_ADVANCED_TEMPLATE) + const parsed = JSON.parse(json) + expect(Array.isArray(parsed)).toBe(true) + expect(parsed.length).toBe(3) + }) + + it("should have all valid transformation keys", () => { + V1_ADVANCED_TEMPLATE.forEach((t) => { + expect(isTransformationKeyValid(t.key)).toBe(true) + }) + }) + + it("should pass Zod schema validation", () => { + V1_ADVANCED_TEMPLATE.forEach((t) => { + const result = validateTransformation(t) + if (!result.valid) { + console.error(`Validation errors for ${t.key}:`, result.errors) + } + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + }) + }) + + describe("V1 Comprehensive Template", () => { + it("should parse template with many transforms", () => { + const json = JSON.stringify(V1_COMPREHENSIVE_TEMPLATE) + const parsed = JSON.parse(json) + expect(Array.isArray(parsed)).toBe(true) + expect(parsed.length).toBe(6) + }) + + it("should have all valid transformation keys", () => { + V1_COMPREHENSIVE_TEMPLATE.forEach((t) => { + expect(isTransformationKeyValid(t.key)).toBe(true) + }) + }) + + it("should pass Zod schema validation", () => { + V1_COMPREHENSIVE_TEMPLATE.forEach((t) => { + const result = validateTransformation(t) + if (!result.valid) { + console.error(`Validation errors for ${t.key}:`, result.errors) + } + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + }) + }) + + describe("V1 Unversioned Template (Backward Compatibility)", () => { + it("should parse template as valid JSON", () => { + const json = JSON.stringify(V1_UNVERSIONED_TEMPLATE) + const parsed = JSON.parse(json) + expect(Array.isArray(parsed)).toBe(true) + expect(parsed.length).toBe(2) + }) + + it("should handle missing version field", () => { + V1_UNVERSIONED_TEMPLATE.forEach((t) => { + expect(t.version).toBeUndefined() + }) + }) + + it("should have valid transformation keys even without version", () => { + V1_UNVERSIONED_TEMPLATE.forEach((t) => { + expect(isTransformationKeyValid(t.key)).toBe(true) + }) + }) + + it("should pass Zod schema validation", () => { + V1_UNVERSIONED_TEMPLATE.forEach((t) => { + const result = validateTransformation(t as Omit) + if (!result.valid) { + console.error(`Validation errors for ${t.key}:`, result.errors) + } + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + }) + + it("should be able to add version to unversioned state", () => { + const withVersion = V1_UNVERSIONED_TEMPLATE.map((t) => ({ + ...t, + version: TRANSFORMATION_STATE_VERSION, + })) + + withVersion.forEach((t) => { + expect(t.version).toBe("v1") + }) + }) + }) + + describe("Template Serialization Consistency", () => { + it("should preserve all properties during JSON round-trip", () => { + const original = V1_COMPREHENSIVE_TEMPLATE + const json = JSON.stringify(original) + const parsed = JSON.parse(json) + + expect(parsed.length).toBe(original.length) + parsed.forEach((t: Omit, i: number) => { + expect(t.key).toBe(original[i].key) + expect(t.name).toBe(original[i].name) + expect(t.type).toBe(original[i].type) + expect(t.version).toBe(original[i].version) + expect(JSON.stringify(t.value)).toBe(JSON.stringify(original[i].value)) + }) + }) + + it("should handle removal and addition of id field", () => { + const withId: Transformation = { + id: "test-123", + ...V1_BASIC_TEMPLATE[0], + } + + // Remove id for storage + const { id, ...forStorage } = withId + expect(forStorage.id).toBeUndefined() + + // Add id back when loading + const loaded = { + ...forStorage, + id: "new-id-456", + } + expect(loaded.id).toBe("new-id-456") + }) + }) + + describe("Schema Key Validation", () => { + it("should validate all v1 fixture keys exist in current schema", () => { + const allFixtures = [ + ...V1_BASIC_TEMPLATE, + ...V1_COMMON_TEMPLATE, + ...V1_GRADIENT_TEMPLATE, + ...V1_AI_TEMPLATE, + ...V1_DELIVERY_TEMPLATE, + ...V1_LAYER_TEXT_TEMPLATE, + ...V1_ADVANCED_TEMPLATE, + ...V1_COMPREHENSIVE_TEMPLATE, + ] + + const uniqueKeys = new Set(allFixtures.map((t) => t.key)) + const missingKeys: string[] = [] + + uniqueKeys.forEach((key) => { + if (!isTransformationKeyValid(key)) { + missingKeys.push(key) + } + }) + + expect(missingKeys).toEqual([]) + }) + }) + + describe("Validation Actually Works (Negative Tests)", () => { + it("should reject transformation with invalid key", () => { + const invalid: Omit = { + key: "nonexistent-transformation", + name: "Invalid", + type: "transformation", + value: {}, + version: "v1", + } + + 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) + }) + + it("should reject transformation with wrong type", () => { + const invalid: any = { + key: "adjust-background", + name: "Background", + type: "wrong-type", + value: { background: "#FFF" }, + version: "v1", + } + + const result = validateTransformation(invalid) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.includes("Invalid type"))).toBe(true) + }) + + it("should reject transformation with invalid value structure", () => { + const invalid: Omit = { + key: "adjust-radius", + name: "Corner Radius", + type: "transformation", + value: { + radius: 999, // Should be an object with {radius: number} + }, + version: "v1", + } + + const result = validateTransformation(invalid) + expect(result.valid).toBe(false) + expect(result.errors.length).toBeGreaterThan(0) + }) + + it("should reject transformation with missing required fields", () => { + const invalid: Omit = { + key: "adjust-rotate", + name: "Rotate", + type: "transformation", + value: {}, // Missing required 'rotate' field + version: "v1", + } + + const result = validateTransformation(invalid) + expect(result.valid).toBe(false) + expect(result.errors.length).toBeGreaterThan(0) + }) + + it("should reject transformation with invalid data types", () => { + const invalid: Omit = { + key: "adjust-rotate", + name: "Rotate", + type: "transformation", + value: { + rotate: "not-a-number", // Should be a number + }, + version: "v1", + } + + const result = validateTransformation(invalid) + expect(result.valid).toBe(false) + expect(result.errors.length).toBeGreaterThan(0) + }) + }) + + /** + * Deep Schema Validation Tests + * These tests exercise custom validators and complex schema logic to achieve high coverage + */ + describe("Schema Validators - Width & Height", () => { + it("should validate width as number", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: 800, height: 600, mode: "c-force" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate width as decimal", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: 0.5, height: 600, mode: "c-force" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate width as expression", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: "iw_div_2", height: 600, mode: "c-force" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate height as expression", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: 800, height: "ih_mul_1.5", mode: "c-force" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should reject invalid width expression", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: "invalid_expr", height: 600, mode: "c-force" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + }) + + describe("Schema Validators - Color", () => { + it("should validate 6-digit hex color", () => { + const template: Omit = { + key: "adjust-background", + name: "Background", + type: "transformation", + value: { backgroundType: "color", background: "#FF5533" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate 3-digit hex color", () => { + const template: Omit = { + key: "adjust-background", + name: "Background", + type: "transformation", + value: { backgroundType: "color", background: "#F53" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate 8-digit hex color with alpha", () => { + const template: Omit = { + key: "adjust-background", + name: "Background", + type: "transformation", + value: { backgroundType: "color", background: "#FF5533AA" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate hex without # prefix", () => { + const template: Omit = { + key: "adjust-background", + name: "Background", + type: "transformation", + value: { backgroundType: "color", background: "FF5533" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should reject invalid hex color", () => { + const template: Omit = { + key: "adjust-background", + name: "Background", + type: "transformation", + value: { backgroundType: "color", background: "#GGGGGG" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + }) + + describe("Schema Validators - Aspect Ratio", () => { + it("should validate aspect ratio value format", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: 800, aspectRatio: "16-9" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate aspect ratio with decimals", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: 800, aspectRatio: "16.5-9.5" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate aspect ratio expression", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: 800, aspectRatio: "iar_mul_1.5" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should reject invalid aspect ratio", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: 800, aspectRatio: "16:9" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should reject aspect ratio without width or height", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { aspectRatio: "16-9" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + }) + + describe("Schema Validators - Layer Positioning", () => { + it("should validate layer X as number", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { text: "Hello", positionX: "100", fontSize: 24, radius: 0 }, + version: "v1", + } + const result = validateTransformation(template) + if (!result.valid) { + console.log("Layer X validation errors:", result.errors) + } + expect(result.valid).toBe(true) + }) + + it("should validate layer X as negative number", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { text: "Hello", positionX: "-50", fontSize: 24, radius: 0 }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate layer X as expression", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { text: "Hello", positionX: "bw_div_2", fontSize: 24, radius: 0 }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate layer Y as expression", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { text: "Hello", positionY: "bh_sub_100", fontSize: 24, radius: 0 }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + }) + + describe("Resize & Crop Complex Validations", () => { + it("should require mode when both width and height are specified", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: 800, height: 600 }, // Missing mode + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate cm-pad_resize mode", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "cm-pad_resize", + backgroundType: "color", + background: "#FFFFFF", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should require both dimensions for blurred background in pad_resize", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + mode: "cm-pad_resize", + backgroundType: "blurred", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should require both dimensions for generative_fill background", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + height: 600, + mode: "cm-pad_resize", + backgroundType: "generative_fill", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate cm-extract mode with focus object", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "cm-extract", + focus: "object", + focusObject: "person", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should require focusObject when extract mode has object focus", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "cm-extract", + focus: "object", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should require focusAnchor when extract mode has anchor focus", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "cm-extract", + focus: "anchor", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate extract mode with topleft coordinates", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "cm-extract", + focus: "coordinates", + coordinateMethod: "topleft", + x: "100", + y: "100", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should require coordinates when extract uses coordinates focus", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "cm-extract", + focus: "coordinates", + coordinateMethod: "topleft", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate extract mode with center coordinates", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "cm-extract", + focus: "coordinates", + coordinateMethod: "center", + xc: "400", + yc: "300", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate DPR with width", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + dprEnabled: true, + dpr: 2, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate DPR as auto", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + dprEnabled: true, + dpr: "auto", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should reject DPR without width or height", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + dpr: 2, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate c-maintain_ratio with focus anchor", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "c-maintain_ratio", + focus: "anchor", + focusAnchor: "center", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should require focusAnchor in maintain_ratio with anchor focus", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "c-maintain_ratio", + focus: "anchor", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate all resize modes", () => { + const modes = [ + "c-maintain_ratio", + "cm-pad_resize", + "cm-extract", + "cm-pad_extract", + "c-force", + "c-at_max", + "c-at_max_enlarge", + "c-at_least", + ] + + modes.forEach((mode) => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: 800, height: 600, mode }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + }) + }) + + describe("Unsharpen Mask Validation", () => { + it("should validate complete unsharpen mask", () => { + const template: Omit = { + key: "adjust-unsharpen-mask", + name: "Unsharpen Mask", + type: "transformation", + value: { + unsharpenMaskRadius: 2, + unsharpenMaskSigma: 1.5, + unsharpenMaskAmount: 1.2, + unsharpenMaskThreshold: 0.1, + }, + version: "v1", + } + const result = validateTransformation(template) + if (!result.valid) { + console.log("Unsharpen mask errors:", result.errors) + } + expect(result.valid).toBe(true) + }) + + it("should require all fields when unsharpen mask is enabled", () => { + const template: Omit = { + key: "adjust-unsharpen-mask", + name: "Unsharpen Mask", + type: "transformation", + value: { + unsharpenMaskRadius: 2, + // Missing other required fields (sigma, amount, threshold) + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + }) + + describe("Additional Transformations Coverage", () => { + it("should validate shadow transformation", () => { + const template: Omit = { + key: "adjust-shadow", + name: "Shadow", + type: "transformation", + value: { + shadow: 5, + shadowBlur: 10, + shadowOffsetX: 5, + shadowOffsetY: 5, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate distort transformation", () => { + const template: Omit = { + key: "adjust-distort", + name: "Distort", + type: "transformation", + value: { + distort: true, + distortType: "perspective", + distortPerspective: { + x1: "10", + y1: "10", + x2: "100", + y2: "10", + x3: "100", + y3: "100", + x4: "10", + y4: "100", + }, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate border transformation", () => { + const template: Omit = { + key: "adjust-border", + name: "Border", + type: "transformation", + value: { + borderWidth: 10, + borderColor: "#000000", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate trim transformation", () => { + const template: Omit = { + key: "adjust-trim", + name: "Trim", + type: "transformation", + value: { + trimEnabled: true, + trim: 10, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate color replace transformation", () => { + const template: Omit = { + key: "adjust-color-replace", + name: "Color Replace", + type: "transformation", + value: { + fromColor: "FF0000", + toColor: "00FF00", + tolerance: 20, + }, + version: "v1", + } + const result = validateTransformation(template) + if (!result.valid) { + console.log("Color replace errors:", result.errors) + } + expect(result.valid).toBe(true) + }) + + it("should validate sharpen transformation", () => { + const template: Omit = { + key: "adjust-sharpen", + name: "Sharpen", + type: "transformation", + value: { + sharpenEnabled: true, + sharpen: 5, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate flip transformation", () => { + const template: Omit = { + key: "adjust-flip", + name: "Flip", + type: "transformation", + value: { + flip: "both", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate opacity transformation", () => { + const template: Omit = { + key: "adjust-opacity", + name: "Opacity", + type: "transformation", + value: { + opacity: 50, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate AI drop shadow", () => { + const template: Omit = { + key: "ai-dropshadow", + name: "Drop Shadow", + type: "transformation", + value: { + dropshadow: true, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate AI upscale", () => { + const template: Omit = { + key: "ai-upscale", + name: "Upscale", + type: "transformation", + value: { + upscale: true, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate AI edit transformation", () => { + const template: Omit = { + key: "ai-edit", + name: "Edit Image", + type: "transformation", + value: { + edit: "replace dog with cat", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate image layer", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "https://example.com/image.jpg", + width: 200, + height: 200, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate complex text layer with all properties", () => { + const template: Omit = { + key: "layers-text", + name: "Text Layer", + type: "transformation", + value: { + text: "Hello World", + fontSize: 48, + fontFamily: "Arial", + color: "000000", + backgroundColor: "FFFFFF", + positionX: "100", + positionY: "200", + width: 400, + innerAlignment: "center", + opacity: 8, + rotation: 45, + radius: 10, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate gradient transformation", () => { + const template: Omit = { + key: "adjust-gradient", + name: "Gradient", + type: "transformation", + value: { + gradientSwitch: true, + gradient: { + from: "#FF0000", + to: "#0000FF", + direction: "bottom", + stopPoint: 50, + }, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate grayscale transformation", () => { + const template: Omit = { + key: "adjust-grayscale", + name: "Grayscale", + type: "transformation", + value: { + grayscale: true, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + }) + + describe("Transformation Formatters", () => { + it("should format background with color", () => { + const values = { backgroundType: "color", background: "#FF5533" } + const transforms: Record = {} + transformationFormatters.background(values, transforms) + expect(transforms.background).toBe("FF5533") + }) + + it("should format background with dominant color", () => { + const values = { + backgroundType: "color", + backgroundDominantAuto: true, + } + const transforms: Record = {} + transformationFormatters.background(values, transforms) + expect(transforms.background).toBe("dominant") + }) + + it("should format gradient with auto dominant", () => { + const values = { + backgroundType: "gradient", + backgroundGradientAutoDominant: true, + backgroundGradientPaletteSize: "3", + backgroundGradientMode: "linear", + } + const transforms: Record = {} + transformationFormatters.background(values, transforms) + expect(transforms.background).toBe("gradient_linear_3") + }) + + it("should format blurred background with negative brightness", () => { + const values = { + backgroundType: "blurred", + backgroundBlurIntensity: "10", + backgroundBlurBrightness: "-50", + } + const transforms: Record = {} + transformationFormatters.background(values, transforms) + // Should create blurred background with intensity and brightness + expect(transforms.background).toBe("blurred_10_N50") + }) + }) + + describe("Validator Edge Cases - Reaching 100% Coverage", () => { + it("should handle empty string in layerX validator", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { text: "Hello", positionX: "", fontSize: 24, radius: 0 }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should handle undefined in layerX validator", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { text: "Hello", fontSize: 24, radius: 0 }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should reject invalid layerX expression", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { text: "Hello", positionX: "invalid_expr", fontSize: 24, radius: 0 }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should handle empty string in layerY validator", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { text: "Hello", positionY: "", fontSize: 24, radius: 0 }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should reject invalid layerY expression", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { text: "Hello", positionY: "badexpr", fontSize: 24, radius: 0 }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate line height as integer", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { text: "Hello", lineHeight: "24", fontSize: 24, radius: 0 }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate line height as expression", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { text: "Hello", lineHeight: "ih_mul_1.5", fontSize: 24, radius: 0 }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should handle empty line height", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { text: "Hello", lineHeight: "", fontSize: 24, radius: 0 }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should reject invalid line height", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { text: "Hello", lineHeight: "not_valid", fontSize: 24, radius: 0 }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should handle empty aspect ratio", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: 800, aspectRatio: "" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should handle undefined aspect ratio", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: 800 }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + }) + + describe("Distort Perspective Validation - Full Coverage", () => { + it("should reject invalid perspective coordinates (non-numeric)", () => { + const template: Omit = { + key: "adjust-distort", + name: "Distort", + type: "transformation", + value: { + distort: true, + distortType: "perspective", + distortPerspective: { + x1: "abc", + y1: "10", + x2: "100", + y2: "10", + x3: "100", + y3: "100", + x4: "10", + y4: "100", + }, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should reject incomplete perspective coordinates", () => { + const template: Omit = { + key: "adjust-distort", + name: "Distort", + type: "transformation", + value: { + distort: true, + distortType: "perspective", + distortPerspective: { + x1: "10", + y1: "10", + x2: "", + y2: "", + }, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should reject invalid perspective coordinate arrangement", () => { + const template: Omit = { + key: "adjust-distort", + name: "Distort", + type: "transformation", + value: { + distort: true, + distortType: "perspective", + distortPerspective: { + x1: "100", + y1: "100", + x2: "10", + y2: "10", + x3: "10", + y3: "10", + x4: "100", + y4: "100", + }, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate arc distortion with positive degree", () => { + const template: Omit = { + key: "adjust-distort", + name: "Distort", + type: "transformation", + value: { + distort: true, + distortType: "arc", + distortArcDegree: "45", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate arc distortion with negative degree", () => { + const template: Omit = { + key: "adjust-distort", + name: "Distort", + type: "transformation", + value: { + distort: true, + distortType: "arc", + distortArcDegree: "-45", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate arc distortion with N prefix for negative", () => { + const template: Omit = { + key: "adjust-distort", + name: "Distort", + type: "transformation", + value: { + distort: true, + distortType: "arc", + distortArcDegree: "N45", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should reject arc with zero degree", () => { + const template: Omit = { + key: "adjust-distort", + name: "Distort", + type: "transformation", + value: { + distort: true, + distortType: "arc", + distortArcDegree: "0", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should reject arc with missing degree", () => { + const template: Omit = { + key: "adjust-distort", + name: "Distort", + type: "transformation", + value: { + distort: true, + distortType: "arc", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should reject arc with non-numeric degree", () => { + const template: Omit = { + key: "adjust-distort", + name: "Distort", + type: "transformation", + value: { + distort: true, + distortType: "arc", + distortArcDegree: "abc", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate perspective with N prefix coordinates", () => { + const template: Omit = { + key: "adjust-distort", + name: "Distort", + type: "transformation", + value: { + distort: true, + distortType: "perspective", + distortPerspective: { + x1: "10", + y1: "10", + x2: "100", + y2: "10", + x3: "100", + y3: "100", + x4: "N5", + y4: "100", + }, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + }) + + describe("Background Field Contexts and Formatters", () => { + it("should format background for generative fill without prompt", () => { + const values = { + backgroundType: "generative_fill", + } + const transforms: Record = {} + transformationFormatters.background(values, transforms) + expect(transforms.background).toBe("genfill") + }) + + it("should format background for generative fill with simple prompt", () => { + const values = { + backgroundType: "generative_fill", + backgroundGenerativeFill: "beach", + } + const transforms: Record = {} + transformationFormatters.background(values, transforms) + expect(transforms.background).toBe("genfill-prompt-beach") + }) + + it("should format background for blurred with auto intensity and brightness", () => { + const values = { + backgroundType: "blurred", + backgroundBlurIntensity: "auto", + backgroundBlurBrightness: "50", + } + const transforms: Record = {} + transformationFormatters.background(values, transforms) + expect(transforms.background).toBe("blurred_auto_50") + }) + + it("should format background for blurred with auto intensity only", () => { + const values = { + backgroundType: "blurred", + backgroundBlurIntensity: "auto", + } + const transforms: Record = {} + transformationFormatters.background(values, transforms) + expect(transforms.background).toBe("blurred_auto") + }) + + it("should format background for blurred with numeric intensity only", () => { + const values = { + backgroundType: "blurred", + backgroundBlurIntensity: "5", + } + const transforms: Record = {} + transformationFormatters.background(values, transforms) + expect(transforms.background).toBe("blurred_5") + }) + + it("should format background for blurred with numeric intensity and brightness", () => { + const values = { + backgroundType: "blurred", + backgroundBlurIntensity: "5", + backgroundBlurBrightness: "25", + } + const transforms: Record = {} + transformationFormatters.background(values, transforms) + expect(transforms.background).toBe("blurred_5_25") + }) + + it("should format background for blurred fallback", () => { + const values = { + backgroundType: "blurred", + backgroundBlurIntensity: "invalid", + } + const transforms: Record = {} + transformationFormatters.background(values, transforms) + expect(transforms.background).toBe("blurred") + }) + + it("should validate gradient with manual colors", () => { + const template: Omit = { + key: "adjust-background", + name: "Background", + type: "transformation", + value: { + backgroundType: "gradient", + backgroundGradient: { + from: "#FF0000", + to: "#0000FF", + direction: "top", + stopPoint: 75, + }, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate background with auto gradient different modes", () => { + const template: Omit = { + key: "adjust-background", + name: "Background", + type: "transformation", + value: { + backgroundType: "gradient", + backgroundGradientAutoDominant: true, + backgroundGradientMode: "radial", + backgroundGradientPaletteSize: "4", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + }) + + describe("Additional Resize & Crop Edge Cases", () => { + it("should validate cm-pad_extract mode", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "cm-pad_extract", + backgroundType: "color", + background: "#FFFFFF", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate c-at_least mode", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "c-at_least", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate width with various expression operators", () => { + const operators = ["add", "sub", "mul", "div", "mod", "pow"] + operators.forEach((op) => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: `iw_${op}_2` }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + }) + + it("should validate height with various base dimensions", () => { + const bases = ["ih", "bh", "ch"] + bases.forEach((base) => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { height: `${base}_mul_0.5` }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + }) + + it("should validate aspect ratio with car expression", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { width: 800, aspectRatio: "car_mul_1.2" }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should handle object focus in maintain_ratio mode", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "c-maintain_ratio", + focus: "object", + focusObject: "car", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should require focusObject in maintain_ratio with object focus", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "c-maintain_ratio", + focus: "object", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate extract with center coordinates only xc", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + 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 validate extract with topleft coordinates only x", () => { + const template: Omit = { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 800, + height: 600, + mode: "cm-extract", + focus: "coordinates", + coordinateMethod: "topleft", + x: "100", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + }) + + describe("Typography and Text Layer Advanced", () => { + it("should validate text with typography array", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { + text: "Hello", + fontSize: 24, + typography: ["bold", "italic"], + radius: 0, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate text with flip array", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { + text: "Hello", + fontSize: 24, + flip: ["horizontal"], + radius: 0, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate text with max radius", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { + text: "Hello", + fontSize: 24, + radius: "max", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate text layer with all alignment options", () => { + const alignments: Array<"left" | "right" | "center"> = ["left", "right", "center"] + alignments.forEach((align) => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { + text: "Hello", + fontSize: 24, + innerAlignment: align, + radius: 0, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + }) + + it("should validate text opacity boundary values", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { + text: "Hello", + fontSize: 24, + opacity: 1, + radius: 0, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + }) + + describe("Image Layer Complex Validations", () => { + it("should validate image layer with border using expression", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + borderWidth: "bw_div_10", + borderColor: "FF0000", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should reject image layer border with invalid expression", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + borderWidth: "invalid_expr", + borderColor: "FF0000", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate image layer with unsharpen mask enabled", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + unsharpenMask: true, + unsharpenMaskRadius: 2, + unsharpenMaskSigma: 1, + unsharpenMaskAmount: 1.5, + unsharpenMaskThreshold: 0.05, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should require all unsharpen mask fields when enabled for image layer", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + unsharpenMask: true, + unsharpenMaskRadius: 2, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate image layer with DPR", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + width: 200, + dprEnabled: true, + dpr: 2, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should reject image layer DPR without dimensions", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + dpr: 2, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate image layer with focus object", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + crop: "cm-extract", + focus: "object", + focusObject: "person", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should require focusObject when image layer has object focus", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + crop: "cm-extract", + focus: "object", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate image layer with focus anchor", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + crop: "cm-extract", + focus: "anchor", + focusAnchor: "center", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should require focusAnchor when image layer has anchor focus", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + crop: "cm-extract", + focus: "anchor", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate image layer with topleft coordinates", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + crop: "cm-extract", + focus: "coordinates", + coordinateMethod: "topleft", + x: "50", + y: "50", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should require at least one topleft coordinate for image layer", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + crop: "cm-extract", + focus: "coordinates", + coordinateMethod: "topleft", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate image layer with center coordinates", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + crop: "cm-extract", + focus: "coordinates", + coordinateMethod: "center", + xc: "100", + yc: "100", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should require at least one center coordinate for image layer", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + crop: "cm-extract", + focus: "coordinates", + coordinateMethod: "center", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should validate image layer with distort perspective", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + distort: true, + distortType: "perspective", + distortPerspective: { + x1: "10", + y1: "10", + x2: "100", + y2: "10", + x3: "100", + y3: "100", + x4: "10", + y4: "100", + }, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate image layer with arc distortion", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + distort: true, + distortType: "arc", + distortArcDegree: "30", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate image layer with all overlay effects", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + width: 200, + height: 200, + blur: 5, + shadow: true, + shadowBlur: 10, + shadowOffsetX: 5, + shadowOffsetY: 5, + grayscale: true, + opacity: 80, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + }) + + describe("Common Number and Expression Validator Coverage", () => { + it("should validate positive number", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + borderWidth: 10, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate ih expression", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + borderWidth: "ih_div_20", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate bh expression", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + borderWidth: "bh_mul_0.05", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should validate ch expression", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + borderWidth: "ch_add_10", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("should reject negative number for common validator", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + borderWidth: -5, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + + it("should reject invalid expression format", () => { + const template: Omit = { + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "overlay.png", + borderWidth: "invalid_format", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(false) + }) + }) +}) diff --git a/packages/imagekit-editor-dev/src/index.tsx b/packages/imagekit-editor-dev/src/index.tsx index 5d74dfd..18ce1c9 100644 --- a/packages/imagekit-editor-dev/src/index.tsx +++ b/packages/imagekit-editor-dev/src/index.tsx @@ -1,4 +1,5 @@ export type { ImageKitEditorProps, ImageKitEditorRef } from "./ImageKitEditor" export { ImageKitEditor } from "./ImageKitEditor" export { DEFAULT_FOCUS_OBJECTS } from "./schema" -export type { FileElement, Signer } from "./store" +export type { FileElement, Signer, Transformation } from "./store" +export { TRANSFORMATION_STATE_VERSION } from "./store" diff --git a/packages/imagekit-editor-dev/src/store.ts b/packages/imagekit-editor-dev/src/store.ts index b0cdd83..e2c1628 100644 --- a/packages/imagekit-editor-dev/src/store.ts +++ b/packages/imagekit-editor-dev/src/store.ts @@ -15,12 +15,15 @@ import { } from "./schema" import { extractImagePath } from "./utils" +export const TRANSFORMATION_STATE_VERSION = "v1" as const + export interface Transformation { id: string key: string name: string type: "transformation" value: IKTransformation + version?: typeof TRANSFORMATION_STATE_VERSION } export type RequiredMetadata = { requireSignedUrl: boolean } @@ -104,7 +107,7 @@ export type EditorActions< addImage: (imageSrc: string | InputFileElement) => void addImages: (imageSrcs: Array>) => void removeImage: (imageSrc: string) => void - setTransformations: (transformations: Omit[]) => void + loadTemplate: (template: Omit[]) => void moveTransformation: ( activeId: UniqueIdentifier, overId: UniqueIdentifier, @@ -302,12 +305,30 @@ const useEditorStore = create()( }) }, - setTransformations: (transformations) => { - const transformationsWithIds = transformations.map((transformation) => ({ + loadTemplate: (template) => { + const transformationsWithIds = template.map((transformation, index) => ({ ...transformation, - id: `transformation-${Date.now()}`, + id: `transformation-${Date.now()}-${index}`, + version: TRANSFORMATION_STATE_VERSION, + })) + + const visibleTransformations: Record = {} + transformationsWithIds.forEach((t) => { + visibleTransformations[t.id] = true + }) + + set((state) => ({ + transformations: transformationsWithIds, + visibleTransformations: { + ...state.visibleTransformations, + ...visibleTransformations, + }, + _internalState: { + sidebarState: "none", + selectedTransformationKey: null, + transformationToEdit: null, + }, })) - set({ transformations: transformationsWithIds }) }, moveTransformation: (activeId, overId) => { diff --git a/packages/imagekit-editor-dev/vite.config.ts b/packages/imagekit-editor-dev/vite.config.ts index 0a8cdda..15d7a68 100644 --- a/packages/imagekit-editor-dev/vite.config.ts +++ b/packages/imagekit-editor-dev/vite.config.ts @@ -16,6 +16,22 @@ export default defineConfig({ outDir: "../imagekit-editor/dist/types", }), ], + test: { + globals: true, + environment: "jsdom", + setupFiles: [], + include: ["src/**/*.{test,spec}.{ts,tsx}"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.{ts,tsx}"], + exclude: [ + "src/**/*.{test,spec}.{ts,tsx}", + "src/index.tsx", + "node_modules/**", + ], + }, + }, build: { lib: { entry: path.resolve(__dirname, "src/index.tsx"), diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json index 230658f..84f66d6 100644 --- a/packages/imagekit-editor/package.json +++ b/packages/imagekit-editor/package.json @@ -1,6 +1,6 @@ { "name": "@imagekit/editor", - "version": "2.1.0", + "version": "2.2.0", "description": "Image Editor powered by ImageKit", "main": "dist/index.cjs.js", "module": "dist/index.es.js", @@ -18,6 +18,9 @@ "url": "https://github.com/imagekit-developer/imagekit-editor/issues" }, "homepage": "https://imagekit.io", + "scripts": { + "test": "echo \"No tests in this package\"" + }, "peerDependencies": { "@chakra-ui/icons": "1.1.1", "@chakra-ui/react": "~1.8.9", diff --git a/turbo.json b/turbo.json index d2abe72..2895ca5 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,10 @@ "cache": false, "persistent": true, "dependsOn": [] + }, + "test": { + "cache": false, + "outputs": [] } } } diff --git a/yarn.lock b/yarn.lock index 5b64324..ee5faf0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,7 +5,14 @@ __metadata: version: 8 cacheKey: 10c0 -"@ampproject/remapping@npm:^2.2.0": +"@acemir/cssom@npm:^0.9.31": + version: 0.9.31 + resolution: "@acemir/cssom@npm:0.9.31" + checksum: 10c0/cbfff98812642104ec3b37de1ad3a53f216ddc437e7b9276a23f46f2453844ea3c3f46c200bc4656a2f747fb26567560b3cc5183d549d119a758926551b5f566 + languageName: node + linkType: hard + +"@ampproject/remapping@npm:^2.2.0, @ampproject/remapping@npm:^2.3.0": version: 2.3.0 resolution: "@ampproject/remapping@npm:2.3.0" dependencies: @@ -15,6 +22,39 @@ __metadata: languageName: node linkType: hard +"@asamuzakjp/css-color@npm:^5.0.1": + version: 5.0.1 + resolution: "@asamuzakjp/css-color@npm:5.0.1" + dependencies: + "@csstools/css-calc": "npm:^3.1.1" + "@csstools/css-color-parser": "npm:^4.0.2" + "@csstools/css-parser-algorithms": "npm:^4.0.0" + "@csstools/css-tokenizer": "npm:^4.0.0" + lru-cache: "npm:^11.2.6" + checksum: 10c0/3e8d74a3b7f3005a325cb8e7f3da1aa32aeac4cd9ce387826dc25b16eaab4dc0e4a6faded8ccc1895959141f4a4a70e8bc38723347b89667b7b224990d16683c + languageName: node + linkType: hard + +"@asamuzakjp/dom-selector@npm:^6.8.1": + version: 6.8.1 + resolution: "@asamuzakjp/dom-selector@npm:6.8.1" + dependencies: + "@asamuzakjp/nwsapi": "npm:^2.3.9" + bidi-js: "npm:^1.0.3" + css-tree: "npm:^3.1.0" + is-potential-custom-element-name: "npm:^1.0.1" + lru-cache: "npm:^11.2.6" + checksum: 10c0/635de2c3b11971c07e2d491fd2833d2499bafbab05b616f5d38041031718879c404456644f60c45e9ba4ca2423e5bb48bf3c46179b0c58a0ea68eaae8c61e85f + languageName: node + linkType: hard + +"@asamuzakjp/nwsapi@npm:^2.3.9": + version: 2.3.9 + resolution: "@asamuzakjp/nwsapi@npm:2.3.9" + checksum: 10c0/869b81382e775499c96c45c6dbe0d0766a6da04bcf0abb79f5333535c4e19946851acaa43398f896e2ecc5a1de9cf3db7cf8c4b1afac1ee3d15e21584546d74d + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.27.1": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" @@ -140,6 +180,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-validator-option@npm:7.27.1" @@ -168,6 +215,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.25.4, @babel/parser@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/parser@npm:7.29.0" + dependencies: + "@babel/types": "npm:^7.29.0" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/333b2aa761264b91577a74bee86141ef733f9f9f6d4fc52548e4847dc35dfbf821f58c46832c637bfa761a6d9909d6a68f7d1ed59e17e4ffbb958dc510c17b62 + languageName: node + linkType: hard + "@babel/parser@npm:^7.27.2, @babel/parser@npm:^7.27.4, @babel/parser@npm:^7.27.5": version: 7.27.5 resolution: "@babel/parser@npm:7.27.5" @@ -252,6 +310,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.25.4, @babel/types@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/types@npm:7.29.0" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10c0/23cc3466e83bcbfab8b9bd0edaafdb5d4efdb88b82b3be6728bbade5ba2f0996f84f63b1c5f7a8c0d67efded28300898a5f930b171bb40b311bca2029c4e9b4f + languageName: node + linkType: hard + "@babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.27.6": version: 7.27.6 resolution: "@babel/types@npm:7.27.6" @@ -262,6 +330,20 @@ __metadata: languageName: node linkType: hard +"@bcoe/v8-coverage@npm:^0.2.3": + version: 0.2.3 + resolution: "@bcoe/v8-coverage@npm:0.2.3" + checksum: 10c0/6b80ae4cb3db53f486da2dc63b6e190a74c8c3cca16bb2733f234a0b6a9382b09b146488ae08e2b22cf00f6c83e20f3e040a2f7894f05c045c946d6a090b1d52 + languageName: node + linkType: hard + +"@bcoe/v8-coverage@npm:^1.0.2": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3 + languageName: node + linkType: hard + "@biomejs/biome@npm:2.1.1": version: 2.1.1 resolution: "@biomejs/biome@npm:2.1.1" @@ -353,6 +435,17 @@ __metadata: languageName: node linkType: hard +"@bramus/specificity@npm:^2.4.2": + version: 2.4.2 + resolution: "@bramus/specificity@npm:2.4.2" + dependencies: + css-tree: "npm:^3.0.0" + bin: + specificity: bin/cli.js + checksum: 10c0/c5f4e04e0bca0d2202598207a5eb0733c8109d12a68a329caa26373bec598d99db5bb785b8865fefa00fc01b08c6068138807ceb11a948fe15e904ed6cf4ba72 + languageName: node + linkType: hard + "@chakra-ui/accordion@npm:1.4.12": version: 1.4.12 resolution: "@chakra-ui/accordion@npm:1.4.12" @@ -1221,6 +1314,59 @@ __metadata: languageName: node linkType: hard +"@csstools/color-helpers@npm:^6.0.2": + version: 6.0.2 + resolution: "@csstools/color-helpers@npm:6.0.2" + checksum: 10c0/4c66574563d7c960010c11e41c2673675baff07c427cca6e8dddffa5777de45770d13ff3efce1c0642798089ad55de52870d9d8141f78db3fa5bba012f2d3789 + languageName: node + linkType: hard + +"@csstools/css-calc@npm:^3.1.1": + version: 3.1.1 + resolution: "@csstools/css-calc@npm:3.1.1" + peerDependencies: + "@csstools/css-parser-algorithms": ^4.0.0 + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10c0/6efcc016d988edf66e54c7bad03e352d61752cbd1b56c7557fd013868aab23505052ded8f912cd4034e216943ea1e04c957d81012489e3eddc14a57b386510ef + languageName: node + linkType: hard + +"@csstools/css-color-parser@npm:^4.0.2": + version: 4.0.2 + resolution: "@csstools/css-color-parser@npm:4.0.2" + dependencies: + "@csstools/color-helpers": "npm:^6.0.2" + "@csstools/css-calc": "npm:^3.1.1" + peerDependencies: + "@csstools/css-parser-algorithms": ^4.0.0 + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10c0/487cf507ef4630f74bd67d84298294ed269900b206ade015a968d20047e07ff46f235b72e26fe0c6b949a03f8f9f00a22c363da49c1b06ca60b32d0188e546be + languageName: node + linkType: hard + +"@csstools/css-parser-algorithms@npm:^4.0.0": + version: 4.0.0 + resolution: "@csstools/css-parser-algorithms@npm:4.0.0" + peerDependencies: + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10c0/94558c2428d6ef0ddef542e86e0a8376aa1263a12a59770abb13ba50d7b83086822c75433f32aa2e7fef00555e1cc88292f9ca5bce79aed232bb3fed73b1528d + languageName: node + linkType: hard + +"@csstools/css-syntax-patches-for-csstree@npm:^1.0.28": + version: 1.1.0 + resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.0" + checksum: 10c0/ef84e09ead31d204e238eb674016b34a54083344348b4e4fd63cb03486dcaa5b53feeff74a6c246763973cca0eb3213a70f49ca8545ce26a3b3d9c97255f4dd1 + languageName: node + linkType: hard + +"@csstools/css-tokenizer@npm:^4.0.0": + version: 4.0.0 + resolution: "@csstools/css-tokenizer@npm:4.0.0" + checksum: 10c0/669cf3d0f9c8e1ffdf8c9955ad8beba0c8cfe03197fe29a4fcbd9ee6f7a18856cfa42c62670021a75183d9ab37f5d14a866e6a9df753a6c07f59e36797a9ea9f + languageName: node + linkType: hard + "@ctrl/tinycolor@npm:^3.4.0": version: 3.6.1 resolution: "@ctrl/tinycolor@npm:3.6.1" @@ -1439,6 +1585,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/aix-ppc64@npm:0.21.5" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/aix-ppc64@npm:0.25.5" @@ -1446,6 +1599,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-arm64@npm:0.21.5" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/android-arm64@npm:0.25.5" @@ -1453,6 +1613,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-arm@npm:0.21.5" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/android-arm@npm:0.25.5" @@ -1460,6 +1627,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-x64@npm:0.21.5" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/android-x64@npm:0.25.5" @@ -1467,6 +1641,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/darwin-arm64@npm:0.21.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/darwin-arm64@npm:0.25.5" @@ -1474,6 +1655,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/darwin-x64@npm:0.21.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/darwin-x64@npm:0.25.5" @@ -1481,6 +1669,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/freebsd-arm64@npm:0.21.5" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/freebsd-arm64@npm:0.25.5" @@ -1488,6 +1683,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/freebsd-x64@npm:0.21.5" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/freebsd-x64@npm:0.25.5" @@ -1495,6 +1697,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-arm64@npm:0.21.5" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/linux-arm64@npm:0.25.5" @@ -1502,6 +1711,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-arm@npm:0.21.5" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/linux-arm@npm:0.25.5" @@ -1509,6 +1725,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-ia32@npm:0.21.5" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/linux-ia32@npm:0.25.5" @@ -1516,6 +1739,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-loong64@npm:0.21.5" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/linux-loong64@npm:0.25.5" @@ -1523,6 +1753,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-mips64el@npm:0.21.5" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/linux-mips64el@npm:0.25.5" @@ -1530,6 +1767,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-ppc64@npm:0.21.5" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/linux-ppc64@npm:0.25.5" @@ -1537,6 +1781,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-riscv64@npm:0.21.5" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/linux-riscv64@npm:0.25.5" @@ -1544,6 +1795,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-s390x@npm:0.21.5" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/linux-s390x@npm:0.25.5" @@ -1551,6 +1809,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-x64@npm:0.21.5" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/linux-x64@npm:0.25.5" @@ -1565,6 +1830,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/netbsd-x64@npm:0.21.5" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/netbsd-x64@npm:0.25.5" @@ -1579,6 +1851,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/openbsd-x64@npm:0.21.5" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/openbsd-x64@npm:0.25.5" @@ -1586,6 +1865,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/sunos-x64@npm:0.21.5" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/sunos-x64@npm:0.25.5" @@ -1593,6 +1879,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-arm64@npm:0.21.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/win32-arm64@npm:0.25.5" @@ -1600,6 +1893,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-ia32@npm:0.21.5" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/win32-ia32@npm:0.25.5" @@ -1607,6 +1907,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-x64@npm:0.21.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.25.5": version: 0.25.5 resolution: "@esbuild/win32-x64@npm:0.25.5" @@ -1614,6 +1921,18 @@ __metadata: languageName: node linkType: hard +"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.6.0": + version: 1.15.0 + resolution: "@exodus/bytes@npm:1.15.0" + peerDependencies: + "@noble/hashes": ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + "@noble/hashes": + optional: true + checksum: 10c0/b48aad9729653385d6ed055c28cfcf0b1b1481cf5d83f4375c12abd7988f1d20f69c80b5f95d4a1cc24d9abe32b9efc352a812d53884c26efea172aca8b6356d + languageName: node + linkType: hard + "@floating-ui/core@npm:^1.7.3": version: 1.7.3 resolution: "@floating-ui/core@npm:1.7.3" @@ -1651,7 +1970,7 @@ __metadata: languageName: node linkType: hard -"@imagekit/editor@npm:2.1.0, @imagekit/editor@workspace:packages/imagekit-editor": +"@imagekit/editor@workspace:*, @imagekit/editor@workspace:packages/imagekit-editor": version: 0.0.0-use.local resolution: "@imagekit/editor@workspace:packages/imagekit-editor" peerDependencies: @@ -1696,6 +2015,13 @@ __metadata: languageName: node linkType: hard +"@istanbuljs/schema@npm:^0.1.2": + version: 0.1.3 + resolution: "@istanbuljs/schema@npm:0.1.3" + checksum: 10c0/61c5286771676c9ca3eb2bd8a7310a9c063fb6e0e9712225c8471c582d157392c88f5353581c8c9adbe0dff98892317d2fdfc56c3499aa42e0194405206a963a + languageName: node + linkType: hard + "@jridgewell/gen-mapping@npm:^0.3.5": version: 0.3.5 resolution: "@jridgewell/gen-mapping@npm:0.3.5" @@ -1738,6 +2064,23 @@ __metadata: languageName: node linkType: hard +"@jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.31": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + languageName: node + linkType: hard + "@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": version: 0.3.25 resolution: "@jridgewell/trace-mapping@npm:0.3.25" @@ -1921,6 +2264,13 @@ __metadata: languageName: node linkType: hard +"@polka/url@npm:^1.0.0-next.24": + version: 1.0.0-next.29 + resolution: "@polka/url@npm:1.0.0-next.29" + checksum: 10c0/0d58e081844095cb029d3c19a659bfefd09d5d51a2f791bc61eba7ea826f13d6ee204a8a448c2f5a855c17df07b37517373ff916dd05801063c0568ae9937684 + languageName: node + linkType: hard + "@popperjs/core@npm:^2.9.3": version: 2.11.8 resolution: "@popperjs/core@npm:2.11.8" @@ -2009,6 +2359,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm-eabi@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.59.0" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@rollup/rollup-android-arm64@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-android-arm64@npm:4.44.0" @@ -2016,6 +2373,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-android-arm64@npm:4.59.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-arm64@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-darwin-arm64@npm:4.44.0" @@ -2023,6 +2387,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-darwin-arm64@npm:4.59.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-x64@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-darwin-x64@npm:4.44.0" @@ -2030,6 +2401,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-x64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-darwin-x64@npm:4.59.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-freebsd-arm64@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-freebsd-arm64@npm:4.44.0" @@ -2037,6 +2415,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-freebsd-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.59.0" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-freebsd-x64@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-freebsd-x64@npm:4.44.0" @@ -2044,6 +2429,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-freebsd-x64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-freebsd-x64@npm:4.59.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-gnueabihf@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.44.0" @@ -2051,6 +2443,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-musleabihf@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.44.0" @@ -2058,6 +2457,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-musleabihf@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.59.0" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-gnu@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.44.0" @@ -2065,6 +2471,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.59.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-musl@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-linux-arm64-musl@npm:4.44.0" @@ -2072,6 +2485,27 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.59.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.59.0" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.59.0" + conditions: os=linux & cpu=loong64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-loongarch64-gnu@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.44.0" @@ -2086,6 +2520,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-ppc64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.59.0" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.59.0" + conditions: os=linux & cpu=ppc64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-gnu@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.44.0" @@ -2093,6 +2541,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.59.0" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-musl@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.44.0" @@ -2100,6 +2555,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.59.0" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-s390x-gnu@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.44.0" @@ -2107,6 +2569,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-s390x-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.59.0" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-gnu@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-linux-x64-gnu@npm:4.44.0" @@ -2114,6 +2583,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.59.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-musl@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-linux-x64-musl@npm:4.44.0" @@ -2121,10 +2597,38 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.44.0": - version: 4.44.0 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.44.0" - conditions: os=win32 & cpu=arm64 +"@rollup/rollup-linux-x64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.59.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-openbsd-x64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-openbsd-x64@npm:4.59.0" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-openharmony-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.59.0" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-arm64-msvc@npm:4.44.0": + version: 4.44.0 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.44.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-arm64-msvc@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.59.0" + conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -2135,6 +2639,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-ia32-msvc@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.59.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.59.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-win32-x64-msvc@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.44.0" @@ -2142,6 +2660,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-x64-msvc@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.59.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rushstack/node-core-library@npm:3.59.0": version: 3.59.0 resolution: "@rushstack/node-core-library@npm:3.59.0" @@ -2246,6 +2771,18 @@ __metadata: languageName: node linkType: hard +"@types/jsdom@npm:^28": + version: 28.0.0 + resolution: "@types/jsdom@npm:28.0.0" + dependencies: + "@types/node": "npm:*" + "@types/tough-cookie": "npm:*" + parse5: "npm:^7.0.0" + undici-types: "npm:^7.21.0" + checksum: 10c0/7b4b06dee1c611e37766ae2c5f92b0a881e3a2da8e38cc34999e812ab030b54b7250b0b9cc9af24dbeadc0fc2d341cc4e0adc5e5ca7d624d134ced1414a1ea5e + languageName: node + linkType: hard + "@types/lodash.mergewith@npm:4.6.6": version: 4.6.6 resolution: "@types/lodash.mergewith@npm:4.6.6" @@ -2262,6 +2799,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:*": + version: 25.4.0 + resolution: "@types/node@npm:25.4.0" + dependencies: + undici-types: "npm:~7.18.0" + checksum: 10c0/da81e8b0a3a57964b1b5f85d134bfefc1b923fd67ed41756842348a049d7915b72e8773f5598d6929b9cb8119c2427993c55d364fd93bd572a3450e58b98a60e + languageName: node + linkType: hard + "@types/node@npm:^20.11.24": version: 20.19.1 resolution: "@types/node@npm:20.19.1" @@ -2341,6 +2887,13 @@ __metadata: languageName: node linkType: hard +"@types/tough-cookie@npm:*": + version: 4.0.5 + resolution: "@types/tough-cookie@npm:4.0.5" + checksum: 10c0/68c6921721a3dcb40451543db2174a145ef915bc8bcbe7ad4e59194a0238e776e782b896c7a59f4b93ac6acefca9161fccb31d1ce3b3445cb6faa467297fb473 + languageName: node + linkType: hard + "@types/warning@npm:^3.0.0": version: 3.0.3 resolution: "@types/warning@npm:3.0.3" @@ -2364,6 +2917,173 @@ __metadata: languageName: node linkType: hard +"@vitest/coverage-v8@npm:^2.1.9": + version: 2.1.9 + resolution: "@vitest/coverage-v8@npm:2.1.9" + dependencies: + "@ampproject/remapping": "npm:^2.3.0" + "@bcoe/v8-coverage": "npm:^0.2.3" + debug: "npm:^4.3.7" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-lib-source-maps: "npm:^5.0.6" + istanbul-reports: "npm:^3.1.7" + magic-string: "npm:^0.30.12" + magicast: "npm:^0.3.5" + std-env: "npm:^3.8.0" + test-exclude: "npm:^7.0.1" + tinyrainbow: "npm:^1.2.0" + peerDependencies: + "@vitest/browser": 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10c0/ccf5871954a630453af9393e84ff40a0f8a4515e988ea32c7ebac5db7c79f17535a12c1c2567cbb78ea01a1eb99abdde94e297f6b6ccd5f7f7fc9b8b01c5963c + languageName: node + linkType: hard + +"@vitest/coverage-v8@npm:^4.0.18": + version: 4.0.18 + resolution: "@vitest/coverage-v8@npm:4.0.18" + dependencies: + "@bcoe/v8-coverage": "npm:^1.0.2" + "@vitest/utils": "npm:4.0.18" + ast-v8-to-istanbul: "npm:^0.3.10" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-reports: "npm:^3.2.0" + magicast: "npm:^0.5.1" + obug: "npm:^2.1.1" + std-env: "npm:^3.10.0" + tinyrainbow: "npm:^3.0.3" + peerDependencies: + "@vitest/browser": 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10c0/e23e0da86f0b2a020c51562bc40ebdc7fc7553c24f8071dfb39a6df0161badbd5eaf2eebbf8ceaef18933a18c1934ff52d1c0c4bde77bb87e0c1feb0c8cbee4d + languageName: node + linkType: hard + +"@vitest/expect@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/expect@npm:2.1.9" + dependencies: + "@vitest/spy": "npm:2.1.9" + "@vitest/utils": "npm:2.1.9" + chai: "npm:^5.1.2" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/98d1cf02917316bebef9e4720723e38298a1c12b3c8f3a81f259bb822de4288edf594e69ff64f0b88afbda6d04d7a4f0c2f720f3fec16b4c45f5e2669f09fdbb + languageName: node + linkType: hard + +"@vitest/mocker@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/mocker@npm:2.1.9" + dependencies: + "@vitest/spy": "npm:2.1.9" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.12" + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/f734490d8d1206a7f44dfdfca459282f5921d73efa72935bb1dc45307578defd38a4131b14853316373ec364cbe910dbc74594ed4137e0da35aa4d9bb716f190 + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:2.1.9, @vitest/pretty-format@npm:^2.1.9": + version: 2.1.9 + resolution: "@vitest/pretty-format@npm:2.1.9" + dependencies: + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/155f9ede5090eabed2a73361094bb35ed4ec6769ae3546d2a2af139166569aec41bb80e031c25ff2da22b71dd4ed51e5468e66a05e6aeda5f14b32e30bc18f00 + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/pretty-format@npm:4.0.18" + dependencies: + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/0086b8c88eeca896d8e4b98fcdef452c8041a1b63eb9e85d3e0bcc96c8aa76d8e9e0b6990ebb0bb0a697c4ebab347e7735888b24f507dbff2742ddce7723fd94 + languageName: node + linkType: hard + +"@vitest/runner@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/runner@npm:2.1.9" + dependencies: + "@vitest/utils": "npm:2.1.9" + pathe: "npm:^1.1.2" + checksum: 10c0/e81f176badb12a815cbbd9bd97e19f7437a0b64e8934d680024b0f768d8670d59cad698ef0e3dada5241b6731d77a7bb3cd2c7cb29f751fd4dd35eb11c42963a + languageName: node + linkType: hard + +"@vitest/snapshot@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/snapshot@npm:2.1.9" + dependencies: + "@vitest/pretty-format": "npm:2.1.9" + magic-string: "npm:^0.30.12" + pathe: "npm:^1.1.2" + checksum: 10c0/394974b3a1fe96186a3c87f933b2f7f1f7b7cc42f9c781d80271dbb4c987809bf035fecd7398b8a3a2d54169e3ecb49655e38a0131d0e7fea5ce88960613b526 + languageName: node + linkType: hard + +"@vitest/spy@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/spy@npm:2.1.9" + dependencies: + tinyspy: "npm:^3.0.2" + checksum: 10c0/12a59b5095e20188b819a1d797e0a513d991b4e6a57db679927c43b362a3eff52d823b34e855a6dd9e73c9fa138dcc5ef52210841a93db5cbf047957a60ca83c + languageName: node + linkType: hard + +"@vitest/ui@npm:^2.0.0": + version: 2.1.9 + resolution: "@vitest/ui@npm:2.1.9" + dependencies: + "@vitest/utils": "npm:2.1.9" + fflate: "npm:^0.8.2" + flatted: "npm:^3.3.1" + pathe: "npm:^1.1.2" + sirv: "npm:^3.0.0" + tinyglobby: "npm:^0.2.10" + tinyrainbow: "npm:^1.2.0" + peerDependencies: + vitest: 2.1.9 + checksum: 10c0/b091f5afd5e7327d1dfc37e26af16d58066bd6c37ec0a1547796f1843eff3170c59062243475fb250ca36d8d7c7293ab78b36b2d112d7839ba8331625ab9b1d3 + languageName: node + linkType: hard + +"@vitest/utils@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/utils@npm:2.1.9" + dependencies: + "@vitest/pretty-format": "npm:2.1.9" + loupe: "npm:^3.1.2" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/81a346cd72b47941f55411f5df4cc230e5f740d1e97e0d3f771b27f007266fc1f28d0438582f6409ea571bc0030ed37f684c64c58d1947d6298d770c21026fdf + languageName: node + linkType: hard + +"@vitest/utils@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/utils@npm:4.0.18" + dependencies: + "@vitest/pretty-format": "npm:4.0.18" + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/4a3c43c1421eb90f38576926496f6c80056167ba111e63f77cf118983902673737a1a38880b890d7c06ec0a12475024587344ee502b3c43093781533022f2aeb + languageName: node + linkType: hard + "@volar/language-core@npm:2.4.17": version: 2.4.17 resolution: "@volar/language-core@npm:2.4.17" @@ -2483,6 +3203,24 @@ __metadata: languageName: node linkType: hard +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + +"ast-v8-to-istanbul@npm:^0.3.10": + version: 0.3.12 + resolution: "ast-v8-to-istanbul@npm:0.3.12" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.31" + estree-walker: "npm:^3.0.3" + js-tokens: "npm:^10.0.0" + checksum: 10c0/bad6ba222b1073c165c8d65dbf366193d4a90536dabe37f93a3df162269b1c9473975756e4c048f708c235efccc26f8e5321c547b7e9563b64b21b2e0f27cbc9 + languageName: node + linkType: hard + "babel-plugin-macros@npm:^3.1.0": version: 3.1.0 resolution: "babel-plugin-macros@npm:3.1.0" @@ -2501,6 +3239,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10c0/07e86102a3eb2ee2a6a1a89164f29d0dbaebd28f2ca3f5ca786f36b8b23d9e417eb3be45a4acf754f837be5ac0a2317de90d3fcb7f4f4dc95720a1f36b26a17b + languageName: node + linkType: hard + "base64-arraybuffer@npm:^1.0.2": version: 1.0.2 resolution: "base64-arraybuffer@npm:1.0.2" @@ -2508,6 +3253,15 @@ __metadata: languageName: node linkType: hard +"bidi-js@npm:^1.0.3": + version: 1.0.3 + resolution: "bidi-js@npm:1.0.3" + dependencies: + require-from-string: "npm:^2.0.2" + checksum: 10c0/fdddea4aa4120a34285486f2267526cd9298b6e8b773ad25e765d4f104b6d7437ab4ba542e6939e3ac834a7570bcf121ee2cf6d3ae7cd7082c4b5bedc8f271e1 + languageName: node + linkType: hard + "brace-expansion@npm:^2.0.1": version: 2.0.1 resolution: "brace-expansion@npm:2.0.1" @@ -2517,6 +3271,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^5.0.2": + version: 5.0.4 + resolution: "brace-expansion@npm:5.0.4" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10c0/359cbcfa80b2eb914ca1f3440e92313fbfe7919ee6b274c35db55bec555aded69dac5ee78f102cec90c35f98c20fa43d10936d0cd9978158823c249257e1643a + languageName: node + linkType: hard + "braces@npm:^3.0.3": version: 3.0.3 resolution: "braces@npm:3.0.3" @@ -2547,6 +3310,13 @@ __metadata: languageName: node linkType: hard +"cac@npm:^6.7.14": + version: 6.7.14 + resolution: "cac@npm:6.7.14" + checksum: 10c0/4ee06aaa7bab8981f0d54e5f5f9d4adcd64058e9697563ce336d8a3878ed018ee18ebe5359b2430eceae87e0758e62ea2019c3f52ae6e211b1bd2e133856cd10 + languageName: node + linkType: hard + "cacache@npm:^19.0.1": version: 19.0.1 resolution: "cacache@npm:19.0.1" @@ -2581,6 +3351,19 @@ __metadata: languageName: node linkType: hard +"chai@npm:^5.1.2": + version: 5.3.3 + resolution: "chai@npm:5.3.3" + dependencies: + assertion-error: "npm:^2.0.1" + check-error: "npm:^2.1.1" + deep-eql: "npm:^5.0.1" + loupe: "npm:^3.1.0" + pathval: "npm:^2.0.0" + checksum: 10c0/b360fd4d38861622e5010c2f709736988b05c7f31042305fa3f4e9911f6adb80ccfb4e302068bf8ed10e835c2e2520cba0f5edc13d878b886987e5aa62483f53 + languageName: node + linkType: hard + "chalk@npm:^5.4.1": version: 5.4.1 resolution: "chalk@npm:5.4.1" @@ -2588,6 +3371,13 @@ __metadata: languageName: node linkType: hard +"check-error@npm:^2.1.1": + version: 2.1.3 + resolution: "check-error@npm:2.1.3" + checksum: 10c0/878e99038fb6476316b74668cd6a498c7e66df3efe48158fa40db80a06ba4258742ac3ee2229c4a2a98c5e73f5dff84eb3e50ceb6b65bbd8f831eafc8338607d + languageName: node + linkType: hard + "chownr@npm:^3.0.0": version: 3.0.0 resolution: "chownr@npm:3.0.0" @@ -2782,6 +3572,28 @@ __metadata: languageName: node linkType: hard +"css-tree@npm:^3.0.0, css-tree@npm:^3.1.0": + version: 3.2.1 + resolution: "css-tree@npm:3.2.1" + dependencies: + mdn-data: "npm:2.27.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/1f65e9ccaa56112a4706d6f003dd43d777f0dbcf848e66fd320f823192533581f8dd58daa906cb80622658332d50284d6be13b87a6ab4556cbbfe9ef535bbf7e + languageName: node + linkType: hard + +"cssstyle@npm:^6.0.1": + version: 6.2.0 + resolution: "cssstyle@npm:6.2.0" + dependencies: + "@asamuzakjp/css-color": "npm:^5.0.1" + "@csstools/css-syntax-patches-for-csstree": "npm:^1.0.28" + css-tree: "npm:^3.1.0" + lru-cache: "npm:^11.2.6" + checksum: 10c0/d5e61973a8c1b4fb9727edddfb9f2677c9a91b1db63787fc0c8bed639a227a97fcf930e5aabc3c64c8280d63169c632015a39da4a84083d1731c949437d0a2a2 + languageName: node + linkType: hard + "csstype@npm:3.0.9": version: 3.0.9 resolution: "csstype@npm:3.0.9" @@ -2796,6 +3608,16 @@ __metadata: languageName: node linkType: hard +"data-urls@npm:^7.0.0": + version: 7.0.0 + resolution: "data-urls@npm:7.0.0" + dependencies: + whatwg-mimetype: "npm:^5.0.0" + whatwg-url: "npm:^16.0.0" + checksum: 10c0/08d88ef50d8966a070ffdaa703e1e4b29f01bb2da364dfbc1612b1c2a4caa8045802c9532d81347b21781100132addb36a585071c8323b12cce97973961dee9f + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.4": version: 4.3.7 resolution: "debug@npm:4.3.7" @@ -2808,6 +3630,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.1.1, debug@npm:^4.3.7": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + "debug@npm:^4.4.0, debug@npm:^4.4.1": version: 4.4.1 resolution: "debug@npm:4.4.1" @@ -2820,6 +3654,20 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:^10.6.0": + version: 10.6.0 + resolution: "decimal.js@npm:10.6.0" + checksum: 10c0/07d69fbcc54167a340d2d97de95f546f9ff1f69d2b45a02fd7a5292412df3cd9eb7e23065e532a318f5474a2e1bccf8392fdf0443ef467f97f3bf8cb0477e5aa + languageName: node + linkType: hard + +"deep-eql@npm:^5.0.1": + version: 5.0.2 + resolution: "deep-eql@npm:5.0.2" + checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247 + languageName: node + linkType: hard + "define-lazy-prop@npm:^2.0.0": version: 2.0.0 resolution: "define-lazy-prop@npm:2.0.0" @@ -2897,6 +3745,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^6.0.0": + version: 6.0.1 + resolution: "entities@npm:6.0.1" + checksum: 10c0/ed836ddac5acb34341094eb495185d527bd70e8632b6c0d59548cbfa23defdbae70b96f9a405c82904efa421230b5b3fd2283752447d737beffd3f3e6ee74414 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -2927,6 +3782,93 @@ __metadata: languageName: node linkType: hard +"es-module-lexer@npm:^1.5.4": + version: 1.7.0 + resolution: "es-module-lexer@npm:1.7.0" + checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b + languageName: node + linkType: hard + +"esbuild@npm:^0.21.3": + version: 0.21.5 + resolution: "esbuild@npm:0.21.5" + dependencies: + "@esbuild/aix-ppc64": "npm:0.21.5" + "@esbuild/android-arm": "npm:0.21.5" + "@esbuild/android-arm64": "npm:0.21.5" + "@esbuild/android-x64": "npm:0.21.5" + "@esbuild/darwin-arm64": "npm:0.21.5" + "@esbuild/darwin-x64": "npm:0.21.5" + "@esbuild/freebsd-arm64": "npm:0.21.5" + "@esbuild/freebsd-x64": "npm:0.21.5" + "@esbuild/linux-arm": "npm:0.21.5" + "@esbuild/linux-arm64": "npm:0.21.5" + "@esbuild/linux-ia32": "npm:0.21.5" + "@esbuild/linux-loong64": "npm:0.21.5" + "@esbuild/linux-mips64el": "npm:0.21.5" + "@esbuild/linux-ppc64": "npm:0.21.5" + "@esbuild/linux-riscv64": "npm:0.21.5" + "@esbuild/linux-s390x": "npm:0.21.5" + "@esbuild/linux-x64": "npm:0.21.5" + "@esbuild/netbsd-x64": "npm:0.21.5" + "@esbuild/openbsd-x64": "npm:0.21.5" + "@esbuild/sunos-x64": "npm:0.21.5" + "@esbuild/win32-arm64": "npm:0.21.5" + "@esbuild/win32-ia32": "npm:0.21.5" + "@esbuild/win32-x64": "npm:0.21.5" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/fa08508adf683c3f399e8a014a6382a6b65542213431e26206c0720e536b31c09b50798747c2a105a4bbba1d9767b8d3615a74c2f7bf1ddf6d836cd11eb672de + languageName: node + linkType: hard + "esbuild@npm:^0.25.0": version: 0.25.5 resolution: "esbuild@npm:0.25.5" @@ -3034,6 +3976,15 @@ __metadata: languageName: node linkType: hard +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + "eventemitter3@npm:^5.0.1": version: 5.0.1 resolution: "eventemitter3@npm:5.0.1" @@ -3056,6 +4007,13 @@ __metadata: languageName: node linkType: hard +"expect-type@npm:^1.1.0": + version: 1.3.0 + resolution: "expect-type@npm:1.3.0" + checksum: 10c0/8412b3fe4f392c420ab41dae220b09700e4e47c639a29ba7ba2e83cc6cffd2b4926f7ac9e47d7e277e8f4f02acda76fd6931cb81fd2b382fa9477ef9ada953fd + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.2 resolution: "exponential-backoff@npm:3.1.2" @@ -3118,6 +4076,25 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + +"fflate@npm:^0.8.2": + version: 0.8.2 + resolution: "fflate@npm:0.8.2" + checksum: 10c0/03448d630c0a583abea594835a9fdb2aaf7d67787055a761515bf4ed862913cfd693b4c4ffd5c3f3b355a70cf1e19033e9ae5aedcca103188aaff91b8bd6e293 + languageName: node + linkType: hard + "fill-range@npm:^7.1.1": version: 7.1.1 resolution: "fill-range@npm:7.1.1" @@ -3134,6 +4111,13 @@ __metadata: languageName: node linkType: hard +"flatted@npm:^3.3.1": + version: 3.4.1 + resolution: "flatted@npm:3.4.1" + checksum: 10c0/3987a7f1e39bc7215cece001354313b462cdb4fb2dde0df4f7acd9e5016fbae56ee6fb3f0870b2150145033be8bda4f01af6f87a00946049651131bbfca7dfa6 + languageName: node + linkType: hard + "focus-lock@npm:^0.9.1": version: 0.9.2 resolution: "focus-lock@npm:0.9.2" @@ -3300,6 +4284,22 @@ __metadata: languageName: node linkType: hard +"glob@npm:^10.4.1": + version: 10.5.0 + resolution: "glob@npm:10.5.0" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^3.1.2" + minimatch: "npm:^9.0.4" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^1.11.1" + bin: + glob: dist/esm/bin.mjs + checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828 + languageName: node + linkType: hard + "globals@npm:^11.1.0": version: 11.12.0 resolution: "globals@npm:11.12.0" @@ -3314,6 +4314,13 @@ __metadata: languageName: node linkType: hard +"has-flag@npm:^4.0.0": + version: 4.0.0 + resolution: "has-flag@npm:4.0.0" + checksum: 10c0/2e789c61b7888d66993e14e8331449e525ef42aac53c627cc53d1c3334e768bcb6abdc4f5f0de1478a25beec6f0bd62c7549058b7ac53e924040d4f301f02fd1 + languageName: node + linkType: hard + "hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" @@ -3339,6 +4346,22 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^6.0.0": + version: 6.0.0 + resolution: "html-encoding-sniffer@npm:6.0.0" + dependencies: + "@exodus/bytes": "npm:^1.6.0" + checksum: 10c0/66dc3f6f5539cc3beb814fcbfae7eacf4ec38cf824d6e1425b72039b51a40f4456bd8541ba66f4f4fe09cdf885ab5cd5bae6ec6339d6895a930b2fdb83c53025 + languageName: node + linkType: hard + +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0 + languageName: node + linkType: hard + "html2canvas@npm:^1.4.1": version: 1.4.1 resolution: "html2canvas@npm:1.4.1" @@ -3356,7 +4379,7 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^7.0.0": +"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.2": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" dependencies: @@ -3366,7 +4389,7 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^7.0.1": +"https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.6": version: 7.0.6 resolution: "https-proxy-agent@npm:7.0.6" dependencies: @@ -3416,6 +4439,8 @@ __metadata: "@types/react-color": "npm:^2" "@types/react-dom": "npm:^17.0.2" "@vitejs/plugin-react": "npm:^4.5.2" + "@vitest/coverage-v8": "npm:^2.1.9" + "@vitest/ui": "npm:^2.0.0" framer-motion: "npm:6.5.1" imagekit-javascript: "npm:^3.0.2" lodash: "npm:^4.17.21" @@ -3429,6 +4454,7 @@ __metadata: typescript: "npm:4.9.3" vite: "npm:^6.3.5" vite-plugin-dts: "npm:5.0.0-beta.3" + vitest: "npm:^2.0.0" zod: "npm:^3.25.0" zustand: "npm:4.5.7" peerDependencies: @@ -3442,7 +4468,10 @@ __metadata: resolution: "imagekit-editor@workspace:." dependencies: "@biomejs/biome": "npm:2.1.1" + "@types/jsdom": "npm:^28" + "@vitest/coverage-v8": "npm:^4.0.18" husky: "npm:^9.1.7" + jsdom: "npm:^28.1.0" lint-staged: "npm:^16.1.2" react-select: "npm:^5.2.1" shx: "npm:^0.4.0" @@ -3569,6 +4598,13 @@ __metadata: languageName: node linkType: hard +"is-potential-custom-element-name@npm:^1.0.1": + version: 1.0.1 + resolution: "is-potential-custom-element-name@npm:1.0.1" + checksum: 10c0/b73e2f22bc863b0939941d369486d308b43d7aef1f9439705e3582bfccaa4516406865e32c968a35f97a99396dac84e2624e67b0a16b0a15086a785e16ce7db9 + languageName: node + linkType: hard + "is-stream@npm:^1.1.0": version: 1.1.0 resolution: "is-stream@npm:1.1.0" @@ -3599,6 +4635,45 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^5.0.6": + version: 5.0.6 + resolution: "istanbul-lib-source-maps@npm:5.0.6" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.23" + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + checksum: 10c0/ffe75d70b303a3621ee4671554f306e0831b16f39ab7f4ab52e54d356a5d33e534d97563e318f1333a6aae1d42f91ec49c76b6cd3f3fb378addcb5c81da0255f + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.1.7, istanbul-reports@npm:^3.2.0": + version: 3.2.0 + resolution: "istanbul-reports@npm:3.2.0" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10c0/d596317cfd9c22e1394f22a8d8ba0303d2074fe2e971887b32d870e4b33f8464b10f8ccbe6847808f7db485f084eba09e6c2ed706b3a978e4b52f07085b8f9bc + languageName: node + linkType: hard + "jackspeak@npm:^3.1.2": version: 3.4.3 resolution: "jackspeak@npm:3.4.3" @@ -3619,6 +4694,13 @@ __metadata: languageName: node linkType: hard +"js-tokens@npm:^10.0.0": + version: 10.0.0 + resolution: "js-tokens@npm:10.0.0" + checksum: 10c0/a93498747812ba3e0c8626f95f75ab29319f2a13613a0de9e610700405760931624433a0de59eb7c27ff8836e526768fb20783861b86ef89be96676f2c996b64 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -3633,6 +4715,40 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:^28.1.0": + version: 28.1.0 + resolution: "jsdom@npm:28.1.0" + dependencies: + "@acemir/cssom": "npm:^0.9.31" + "@asamuzakjp/dom-selector": "npm:^6.8.1" + "@bramus/specificity": "npm:^2.4.2" + "@exodus/bytes": "npm:^1.11.0" + cssstyle: "npm:^6.0.1" + data-urls: "npm:^7.0.0" + decimal.js: "npm:^10.6.0" + html-encoding-sniffer: "npm:^6.0.0" + http-proxy-agent: "npm:^7.0.2" + https-proxy-agent: "npm:^7.0.6" + is-potential-custom-element-name: "npm:^1.0.1" + parse5: "npm:^8.0.0" + saxes: "npm:^6.0.0" + symbol-tree: "npm:^3.2.4" + tough-cookie: "npm:^6.0.0" + undici: "npm:^7.21.0" + w3c-xmlserializer: "npm:^5.0.0" + webidl-conversions: "npm:^8.0.1" + whatwg-mimetype: "npm:^5.0.0" + whatwg-url: "npm:^16.0.0" + xml-name-validator: "npm:^5.0.0" + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10c0/341ecb4005be2dab3247dacc349a20285d7991b5cee3382301fcd69a4294b705b4147e7d9ae1ddfab466ba4b3aace97ded4f7b070de285262221cb2782965b25 + languageName: node + linkType: hard + "jsesc@npm:^3.0.2": version: 3.1.0 resolution: "jsesc@npm:3.1.0" @@ -3802,6 +4918,13 @@ __metadata: languageName: node linkType: hard +"loupe@npm:^3.1.0, loupe@npm:^3.1.2": + version: 3.2.1 + resolution: "loupe@npm:3.2.1" + checksum: 10c0/910c872cba291309664c2d094368d31a68907b6f5913e989d301b5c25f30e97d76d77f23ab3bf3b46d0f601ff0b6af8810c10c31b91d2c6b2f132809ca2cc705 + languageName: node + linkType: hard + "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" @@ -3809,6 +4932,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.2.6": + version: 11.2.6 + resolution: "lru-cache@npm:11.2.6" + checksum: 10c0/73bbffb298760e71b2bfe8ebc16a311c6a60ceddbba919cfedfd8635c2d125fbfb5a39b71818200e67973b11f8d59c5a9e31d6f90722e340e90393663a66e5cd + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -3827,6 +4957,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.30.12": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + "magic-string@npm:^0.30.17": version: 0.30.17 resolution: "magic-string@npm:0.30.17" @@ -3836,6 +4975,37 @@ __metadata: languageName: node linkType: hard +"magicast@npm:^0.3.5": + version: 0.3.5 + resolution: "magicast@npm:0.3.5" + dependencies: + "@babel/parser": "npm:^7.25.4" + "@babel/types": "npm:^7.25.4" + source-map-js: "npm:^1.2.0" + checksum: 10c0/a6cacc0a848af84f03e3f5bda7b0de75e4d0aa9ddce5517fd23ed0f31b5ddd51b2d0ff0b7e09b51f7de0f4053c7a1107117edda6b0732dca3e9e39e6c5a68c64 + languageName: node + linkType: hard + +"magicast@npm:^0.5.1": + version: 0.5.2 + resolution: "magicast@npm:0.5.2" + dependencies: + "@babel/parser": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" + source-map-js: "npm:^1.2.1" + checksum: 10c0/924af677643c5a0a7d6cdb3247c0eb96fa7611b2ba6a5e720d35d81c503d3d9f5948eb5227f80f90f82ea3e7d38cffd10bb988f3fc09020db428e14f26e960d7 + languageName: node + linkType: hard + +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 + languageName: node + linkType: hard + "make-fetch-happen@npm:^14.0.3": version: 14.0.3 resolution: "make-fetch-happen@npm:14.0.3" @@ -3855,6 +5025,13 @@ __metadata: languageName: node linkType: hard +"mdn-data@npm:2.27.1": + version: 2.27.1 + resolution: "mdn-data@npm:2.27.1" + checksum: 10c0/eb8abf5d22e4d1e090346f5e81b67d23cef14c83940e445da5c44541ad874dc8fb9f6ca236e8258c3a489d9fb5884188a4d7d58773adb9089ac2c0b966796393 + languageName: node + linkType: hard + "memoize-one@npm:^6.0.0": version: 6.0.0 resolution: "memoize-one@npm:6.0.0" @@ -3886,6 +5063,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.2.2": + version: 10.2.4 + resolution: "minimatch@npm:10.2.4" + dependencies: + brace-expansion: "npm:^5.0.2" + checksum: 10c0/35f3dfb7b99b51efd46afd378486889f590e7efb10e0f6a10ba6800428cf65c9a8dedb74427d0570b318d749b543dc4e85f06d46d2858bc8cac7e1eb49a95945 + languageName: node + linkType: hard + "minimatch@npm:^9.0.4": version: 9.0.5 resolution: "minimatch@npm:9.0.5" @@ -3999,6 +5185,13 @@ __metadata: languageName: node linkType: hard +"mrmime@npm:^2.0.0": + version: 2.0.1 + resolution: "mrmime@npm:2.0.1" + checksum: 10c0/af05afd95af202fdd620422f976ad67dc18e6ee29beb03dd1ce950ea6ef664de378e44197246df4c7cdd73d47f2e7143a6e26e473084b9e4aa2095c0ad1e1761 + languageName: node + linkType: hard + "ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" @@ -4090,6 +5283,13 @@ __metadata: languageName: node linkType: hard +"obug@npm:^2.1.1": + version: 2.1.1 + resolution: "obug@npm:2.1.1" + checksum: 10c0/59dccd7de72a047e08f8649e94c1015ec72f94eefb6ddb57fb4812c4b425a813bc7e7cd30c9aca20db3c59abc3c85cc7a62bb656a968741d770f4e8e02bc2e78 + languageName: node + linkType: hard + "once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" @@ -4161,6 +5361,24 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^7.0.0": + version: 7.3.0 + resolution: "parse5@npm:7.3.0" + dependencies: + entities: "npm:^6.0.0" + checksum: 10c0/7fd2e4e247e85241d6f2a464d0085eed599a26d7b0a5233790c49f53473232eb85350e8133344d9b3fd58b89339e7ad7270fe1f89d28abe50674ec97b87f80b5 + languageName: node + linkType: hard + +"parse5@npm:^8.0.0": + version: 8.0.0 + resolution: "parse5@npm:8.0.0" + dependencies: + entities: "npm:^6.0.0" + checksum: 10c0/8279892dcd77b2f2229707f60eb039e303adf0288812b2a8fd5acf506a4d432da833c6c5d07a6554bef722c2367a81ef4a1f7e9336564379a7dba3e798bf16b3 + languageName: node + linkType: hard + "path-browserify@npm:^1.0.1": version: 1.0.1 resolution: "path-browserify@npm:1.0.1" @@ -4206,6 +5424,13 @@ __metadata: languageName: node linkType: hard +"pathe@npm:^1.1.2": + version: 1.1.2 + resolution: "pathe@npm:1.1.2" + checksum: 10c0/64ee0a4e587fb0f208d9777a6c56e4f9050039268faaaaecd50e959ef01bf847b7872785c36483fa5cdcdbdfdb31fef2ff222684d4fc21c330ab60395c681897 + languageName: node + linkType: hard + "pathe@npm:^2.0.1, pathe@npm:^2.0.3": version: 2.0.3 resolution: "pathe@npm:2.0.3" @@ -4213,6 +5438,13 @@ __metadata: languageName: node linkType: hard +"pathval@npm:^2.0.0": + version: 2.0.1 + resolution: "pathval@npm:2.0.1" + checksum: 10c0/460f4709479fbf2c45903a65655fc8f0a5f6d808f989173aeef5fdea4ff4f303dc13f7870303999add60ec49d4c14733895c0a869392e9866f1091fa64fd7581 + languageName: node + linkType: hard + "picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" @@ -4234,6 +5466,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.3": + version: 4.0.3 + resolution: "picomatch@npm:4.0.3" + checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 + languageName: node + linkType: hard + "pidtree@npm:^0.6.0": version: 0.6.0 resolution: "pidtree@npm:0.6.0" @@ -4277,6 +5516,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.4.43": + version: 8.5.8 + resolution: "postcss@npm:8.5.8" + dependencies: + nanoid: "npm:^3.3.11" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/dd918f7127ee7c60a0295bae2e72b3787892296e1d1c3c564d7a2a00c68d8df83cadc3178491259daa19ccc54804fb71ed8c937c6787e08d8bd4bedf8d17044c + languageName: node + linkType: hard + "postcss@npm:^8.5.3": version: 8.5.6 resolution: "postcss@npm:8.5.6" @@ -4326,7 +5576,7 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^2.1.0": +"punycode@npm:^2.1.0, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 @@ -4393,7 +5643,7 @@ __metadata: version: 0.0.0-use.local resolution: "react-example@workspace:examples/react-example" dependencies: - "@imagekit/editor": "npm:2.1.0" + "@imagekit/editor": "workspace:*" "@types/node": "npm:^20.11.24" "@types/react": "npm:^17.0.2" "@types/react-dom": "npm:^17.0.2" @@ -4563,6 +5813,13 @@ __metadata: languageName: node linkType: hard +"require-from-string@npm:^2.0.2": + version: 2.0.2 + resolution: "require-from-string@npm:2.0.2" + checksum: 10c0/aaa267e0c5b022fc5fd4eef49d8285086b15f2a1c54b28240fdf03599cbd9c26049fee3eab894f2e1f6ca65e513b030a7c264201e3f005601e80c49fb2937ce2 + languageName: node + linkType: hard + "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" @@ -4695,6 +5952,96 @@ __metadata: languageName: node linkType: hard +"rollup@npm:^4.20.0": + version: 4.59.0 + resolution: "rollup@npm:4.59.0" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.59.0" + "@rollup/rollup-android-arm64": "npm:4.59.0" + "@rollup/rollup-darwin-arm64": "npm:4.59.0" + "@rollup/rollup-darwin-x64": "npm:4.59.0" + "@rollup/rollup-freebsd-arm64": "npm:4.59.0" + "@rollup/rollup-freebsd-x64": "npm:4.59.0" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.59.0" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.59.0" + "@rollup/rollup-linux-arm64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-arm64-musl": "npm:4.59.0" + "@rollup/rollup-linux-loong64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-loong64-musl": "npm:4.59.0" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-ppc64-musl": "npm:4.59.0" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-riscv64-musl": "npm:4.59.0" + "@rollup/rollup-linux-s390x-gnu": "npm:4.59.0" + "@rollup/rollup-linux-x64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-x64-musl": "npm:4.59.0" + "@rollup/rollup-openbsd-x64": "npm:4.59.0" + "@rollup/rollup-openharmony-arm64": "npm:4.59.0" + "@rollup/rollup-win32-arm64-msvc": "npm:4.59.0" + "@rollup/rollup-win32-ia32-msvc": "npm:4.59.0" + "@rollup/rollup-win32-x64-gnu": "npm:4.59.0" + "@rollup/rollup-win32-x64-msvc": "npm:4.59.0" + "@types/estree": "npm:1.0.8" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-freebsd-arm64": + optional: true + "@rollup/rollup-freebsd-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-loong64-gnu": + optional: true + "@rollup/rollup-linux-loong64-musl": + optional: true + "@rollup/rollup-linux-ppc64-gnu": + optional: true + "@rollup/rollup-linux-ppc64-musl": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-musl": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-openbsd-x64": + optional: true + "@rollup/rollup-openharmony-arm64": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10c0/f38742da34cfee5e899302615fa157aa77cb6a2a1495e5e3ce4cc9c540d3262e235bbe60caa31562bbfe492b01fdb3e7a8c43c39d842d3293bcf843123b766fc + languageName: node + linkType: hard + "rollup@npm:^4.34.9": version: 4.44.0 resolution: "rollup@npm:4.44.0" @@ -4786,6 +6133,15 @@ __metadata: languageName: node linkType: hard +"saxes@npm:^6.0.0": + version: 6.0.0 + resolution: "saxes@npm:6.0.0" + dependencies: + xmlchars: "npm:^2.2.0" + checksum: 10c0/3847b839f060ef3476eb8623d099aa502ad658f5c40fd60c105ebce86d244389b0d76fcae30f4d0c728d7705ceb2f7e9b34bb54717b6a7dbedaf5dad2d9a4b74 + languageName: node + linkType: hard + "scheduler@npm:^0.20.2": version: 0.20.2 resolution: "scheduler@npm:0.20.2" @@ -4823,6 +6179,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.5.3": + version: 7.7.4 + resolution: "semver@npm:7.7.4" + bin: + semver: bin/semver.js + checksum: 10c0/5215ad0234e2845d4ea5bb9d836d42b03499546ddafb12075566899fc617f68794bb6f146076b6881d755de17d6c6cc73372555879ec7dce2c2feee947866ad2 + languageName: node + linkType: hard + "semver@npm:~7.3.0": version: 7.3.8 resolution: "semver@npm:7.3.8" @@ -4892,6 +6257,13 @@ __metadata: languageName: node linkType: hard +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 + languageName: node + linkType: hard + "signal-exit@npm:^3.0.0": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" @@ -4906,6 +6278,17 @@ __metadata: languageName: node linkType: hard +"sirv@npm:^3.0.0": + version: 3.0.2 + resolution: "sirv@npm:3.0.2" + dependencies: + "@polka/url": "npm:^1.0.0-next.24" + mrmime: "npm:^2.0.0" + totalist: "npm:^3.0.0" + checksum: 10c0/5930e4397afdb14fbae13751c3be983af4bda5c9aadec832607dc2af15a7162f7d518c71b30e83ae3644b9a24cea041543cc969e5fe2b80af6ce8ea3174b2d04 + languageName: node + linkType: hard + "slice-ansi@npm:^5.0.0": version: 5.0.0 resolution: "slice-ansi@npm:5.0.0" @@ -4954,7 +6337,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:^1.2.1": +"source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf @@ -5015,6 +6398,20 @@ __metadata: languageName: node linkType: hard +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 + languageName: node + linkType: hard + +"std-env@npm:^3.10.0, std-env@npm:^3.8.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 10c0/1814927a45004d36dde6707eaf17552a546769bc79a6421be2c16ce77d238158dfe5de30910b78ec30d95135cc1c59ea73ee22d2ca170f8b9753f84da34c427f + languageName: node + linkType: hard + "string-argv@npm:^0.3.2, string-argv@npm:~0.3.1": version: 0.3.2 resolution: "string-argv@npm:0.3.2" @@ -5104,6 +6501,15 @@ __metadata: languageName: node linkType: hard +"supports-color@npm:^7.1.0": + version: 7.2.0 + resolution: "supports-color@npm:7.2.0" + dependencies: + has-flag: "npm:^4.0.0" + checksum: 10c0/afb4c88521b8b136b5f5f95160c98dee7243dc79d5432db7efc27efb219385bbc7d9427398e43dd6cc730a0f87d5085ce1652af7efbe391327bc0a7d0f7fc124 + languageName: node + linkType: hard + "supports-preserve-symlinks-flag@npm:^1.0.0": version: 1.0.0 resolution: "supports-preserve-symlinks-flag@npm:1.0.0" @@ -5111,6 +6517,13 @@ __metadata: languageName: node linkType: hard +"symbol-tree@npm:^3.2.4": + version: 3.2.4 + resolution: "symbol-tree@npm:3.2.4" + checksum: 10c0/dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509 + languageName: node + linkType: hard + "tar@npm:^7.4.3": version: 7.4.3 resolution: "tar@npm:7.4.3" @@ -5139,6 +6552,17 @@ __metadata: languageName: node linkType: hard +"test-exclude@npm:^7.0.1": + version: 7.0.2 + resolution: "test-exclude@npm:7.0.2" + dependencies: + "@istanbuljs/schema": "npm:^0.1.2" + glob: "npm:^10.4.1" + minimatch: "npm:^10.2.2" + checksum: 10c0/b79b855af9168c6a362146015ccf40f5e3a25e307304ba9bea930818507f6319d230380d5d7b5baa659c981ccc11f1bd21b6f012f85606353dec07e02dee67c9 + languageName: node + linkType: hard + "text-segmentation@npm:^1.0.3": version: 1.0.3 resolution: "text-segmentation@npm:1.0.3" @@ -5155,6 +6579,13 @@ __metadata: languageName: node linkType: hard +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + "tinycolor2@npm:1.4.2": version: 1.4.2 resolution: "tinycolor2@npm:1.4.2" @@ -5162,6 +6593,23 @@ __metadata: languageName: node linkType: hard +"tinyexec@npm:^0.3.1": + version: 0.3.2 + resolution: "tinyexec@npm:0.3.2" + checksum: 10c0/3efbf791a911be0bf0821eab37a3445c2ba07acc1522b1fa84ae1e55f10425076f1290f680286345ed919549ad67527d07281f1c19d584df3b74326909eb1f90 + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.10": + version: 0.2.15 + resolution: "tinyglobby@npm:0.2.15" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.3" + checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844 + languageName: node + linkType: hard + "tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.13": version: 0.2.14 resolution: "tinyglobby@npm:0.2.14" @@ -5172,6 +6620,52 @@ __metadata: languageName: node linkType: hard +"tinypool@npm:^1.0.1": + version: 1.1.1 + resolution: "tinypool@npm:1.1.1" + checksum: 10c0/bf26727d01443061b04fa863f571016950888ea994ba0cd8cba3a1c51e2458d84574341ab8dbc3664f1c3ab20885c8cf9ff1cc4b18201f04c2cde7d317fff69b + languageName: node + linkType: hard + +"tinyrainbow@npm:^1.2.0": + version: 1.2.0 + resolution: "tinyrainbow@npm:1.2.0" + checksum: 10c0/7f78a4b997e5ba0f5ecb75e7ed786f30bab9063716e7dff24dd84013fb338802e43d176cb21ed12480561f5649a82184cf31efb296601a29d38145b1cdb4c192 + languageName: node + linkType: hard + +"tinyrainbow@npm:^3.0.3": + version: 3.0.3 + resolution: "tinyrainbow@npm:3.0.3" + checksum: 10c0/1e799d35cd23cabe02e22550985a3051dc88814a979be02dc632a159c393a998628eacfc558e4c746b3006606d54b00bcdea0c39301133956d10a27aa27e988c + languageName: node + linkType: hard + +"tinyspy@npm:^3.0.2": + version: 3.0.2 + resolution: "tinyspy@npm:3.0.2" + checksum: 10c0/55ffad24e346622b59292e097c2ee30a63919d5acb7ceca87fc0d1c223090089890587b426e20054733f97a58f20af2c349fb7cc193697203868ab7ba00bcea0 + languageName: node + linkType: hard + +"tldts-core@npm:^7.0.25": + version: 7.0.25 + resolution: "tldts-core@npm:7.0.25" + checksum: 10c0/fd07a555a27a6f11ed87365845d80bf7755d490cd52adf6cd474cbb9148fdaa97c79f58a8ebbc19ccfec0578e76ced5f5ae3b88e85338529291918f5fe815914 + languageName: node + linkType: hard + +"tldts@npm:^7.0.5": + version: 7.0.25 + resolution: "tldts@npm:7.0.25" + dependencies: + tldts-core: "npm:^7.0.25" + bin: + tldts: bin/cli.js + checksum: 10c0/8affd92849a4b0e290c5b211dce58ddee79d5e6d7079fc592afb97d95decd90028194b844c0b3cfb9254de02fb73e01f89500f3e47073dd96a3266223ca221b9 + languageName: node + linkType: hard + "to-fast-properties@npm:^2.0.0": version: 2.0.0 resolution: "to-fast-properties@npm:2.0.0" @@ -5195,6 +6689,31 @@ __metadata: languageName: node linkType: hard +"totalist@npm:^3.0.0": + version: 3.0.1 + resolution: "totalist@npm:3.0.1" + checksum: 10c0/4bb1fadb69c3edbef91c73ebef9d25b33bbf69afe1e37ce544d5f7d13854cda15e47132f3e0dc4cafe300ddb8578c77c50a65004d8b6e97e77934a69aa924863 + languageName: node + linkType: hard + +"tough-cookie@npm:^6.0.0": + version: 6.0.0 + resolution: "tough-cookie@npm:6.0.0" + dependencies: + tldts: "npm:^7.0.5" + checksum: 10c0/7b17a461e9c2ac0d0bea13ab57b93b4346d0b8c00db174c963af1e46e4ea8d04148d2a55f2358fc857db0c0c65208a98e319d0c60693e32e0c559a9d9cf20cb5 + languageName: node + linkType: hard + +"tr46@npm:^6.0.0": + version: 6.0.0 + resolution: "tr46@npm:6.0.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10c0/83130df2f649228aa91c17754b66248030a3af34911d713b5ea417066fa338aa4bc8668d06bd98aa21a2210f43fc0a3db8b9099e7747fb5830e40e39a6a1058e + languageName: node + linkType: hard + "tslib@npm:^1.0.0": version: 1.14.1 resolution: "tslib@npm:1.14.1" @@ -5327,6 +6846,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:^7.21.0": + version: 7.22.0 + resolution: "undici-types@npm:7.22.0" + checksum: 10c0/5e6f2513c41d07404c719eb7c1c499b8d4cde042f1269b3bc2be335f059e6ef53eb04f316696c728d5e8064c4d522b98f63d7ae8938adf6317351e07ae9c943e + languageName: node + linkType: hard + "undici-types@npm:~6.21.0": version: 6.21.0 resolution: "undici-types@npm:6.21.0" @@ -5334,6 +6860,20 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.18.0": + version: 7.18.2 + resolution: "undici-types@npm:7.18.2" + checksum: 10c0/85a79189113a238959d7a647368e4f7c5559c3a404ebdb8fc4488145ce9426fcd82252a844a302798dfc0e37e6fb178ff481ed03bc4caf634c5757d9ef43521d + languageName: node + linkType: hard + +"undici@npm:^7.21.0": + version: 7.22.0 + resolution: "undici@npm:7.22.0" + checksum: 10c0/09777c06f3f18f761f03e3a4c9c04fd9fcca8ad02ccea43602ee4adf73fcba082806f1afb637f6ea714ef6279c5323c25b16d435814c63db720f63bfc20d316b + languageName: node + linkType: hard + "unique-filename@npm:^4.0.0": version: 4.0.0 resolution: "unique-filename@npm:4.0.0" @@ -5504,6 +7044,21 @@ __metadata: languageName: node linkType: hard +"vite-node@npm:2.1.9": + version: 2.1.9 + resolution: "vite-node@npm:2.1.9" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.3.7" + es-module-lexer: "npm:^1.5.4" + pathe: "npm:^1.1.2" + vite: "npm:^5.0.0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/0d3589f9f4e9cff696b5b49681fdb75d1638c75053728be52b4013f70792f38cb0120a9c15e3a4b22bdd6b795ad7c2da13bcaf47242d439f0906049e73bdd756 + languageName: node + linkType: hard + "vite-plugin-dts@npm:5.0.0-beta.3": version: 5.0.0-beta.3 resolution: "vite-plugin-dts@npm:5.0.0-beta.3" @@ -5524,6 +7079,49 @@ __metadata: languageName: node linkType: hard +"vite@npm:^5.0.0": + version: 5.4.21 + resolution: "vite@npm:5.4.21" + dependencies: + esbuild: "npm:^0.21.3" + fsevents: "npm:~2.3.3" + postcss: "npm:^8.4.43" + rollup: "npm:^4.20.0" + peerDependencies: + "@types/node": ^18.0.0 || >=20.0.0 + less: "*" + lightningcss: ^1.21.0 + sass: "*" + sass-embedded: "*" + stylus: "*" + sugarss: "*" + terser: ^5.4.0 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/468336a1409f728b464160cbf02672e72271fb688d0e605e776b74a89d27e1029509eef3a3a6c755928d8011e474dbf234824d054d07960be5f23cd176bc72de + languageName: node + linkType: hard + "vite@npm:^6.3.5": version: 6.3.5 resolution: "vite@npm:6.3.5" @@ -5579,6 +7177,56 @@ __metadata: languageName: node linkType: hard +"vitest@npm:^2.0.0": + version: 2.1.9 + resolution: "vitest@npm:2.1.9" + dependencies: + "@vitest/expect": "npm:2.1.9" + "@vitest/mocker": "npm:2.1.9" + "@vitest/pretty-format": "npm:^2.1.9" + "@vitest/runner": "npm:2.1.9" + "@vitest/snapshot": "npm:2.1.9" + "@vitest/spy": "npm:2.1.9" + "@vitest/utils": "npm:2.1.9" + chai: "npm:^5.1.2" + debug: "npm:^4.3.7" + expect-type: "npm:^1.1.0" + magic-string: "npm:^0.30.12" + pathe: "npm:^1.1.2" + std-env: "npm:^3.8.0" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^0.3.1" + tinypool: "npm:^1.0.1" + tinyrainbow: "npm:^1.2.0" + vite: "npm:^5.0.0" + vite-node: "npm:2.1.9" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/node": ^18.0.0 || >=20.0.0 + "@vitest/browser": 2.1.9 + "@vitest/ui": 2.1.9 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/e339e16dccacf4589ff43cb1f38c7b4d14427956ae8ef48702af6820a9842347c2b6c77356aeddb040329759ca508a3cb2b104ddf78103ea5bc98ab8f2c3a54e + languageName: node + linkType: hard + "vscode-uri@npm:^3.0.8": version: 3.1.0 resolution: "vscode-uri@npm:3.1.0" @@ -5586,6 +7234,15 @@ __metadata: languageName: node linkType: hard +"w3c-xmlserializer@npm:^5.0.0": + version: 5.0.0 + resolution: "w3c-xmlserializer@npm:5.0.0" + dependencies: + xml-name-validator: "npm:^5.0.0" + checksum: 10c0/8712774c1aeb62dec22928bf1cdfd11426c2c9383a1a63f2bcae18db87ca574165a0fbe96b312b73652149167ac6c7f4cf5409f2eb101d9c805efe0e4bae798b + languageName: node + linkType: hard + "warning@npm:^4.0.3": version: 4.0.3 resolution: "warning@npm:4.0.3" @@ -5595,6 +7252,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^8.0.1": + version: 8.0.1 + resolution: "webidl-conversions@npm:8.0.1" + checksum: 10c0/3f6f327ca5fa0c065ed8ed0ef3b72f33623376e68f958e9b7bd0df49fdb0b908139ac2338d19fb45bd0e05595bda96cb6d1622222a8b413daa38a17aacc4dd46 + languageName: node + linkType: hard + "webpack-virtual-modules@npm:^0.6.2": version: 0.6.2 resolution: "webpack-virtual-modules@npm:0.6.2" @@ -5602,6 +7266,24 @@ __metadata: languageName: node linkType: hard +"whatwg-mimetype@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-mimetype@npm:5.0.0" + checksum: 10c0/eead164fe73a00dd82f817af6fc0bd22e9c273e1d55bf4bc6bdf2da7ad8127fca82ef00ea6a37892f5f5641f8e34128e09508f92126086baba126b9e0d57feb4 + languageName: node + linkType: hard + +"whatwg-url@npm:^16.0.0": + version: 16.0.1 + resolution: "whatwg-url@npm:16.0.1" + dependencies: + "@exodus/bytes": "npm:^1.11.0" + tr46: "npm:^6.0.0" + webidl-conversions: "npm:^8.0.1" + checksum: 10c0/e75565566abf3a2cdbd9f06c965dbcccee6ec4e9f0d3728ad5e08ceb9944279848bcaa211d35a29cb6d2df1e467dd05cfb59fbddf8a0adcd7d0bce9ffb703fd2 + languageName: node + linkType: hard + "which@npm:^1.2.9": version: 1.3.1 resolution: "which@npm:1.3.1" @@ -5635,6 +7317,18 @@ __metadata: languageName: node linkType: hard +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 + languageName: node + linkType: hard + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0" @@ -5675,6 +7369,20 @@ __metadata: languageName: node linkType: hard +"xml-name-validator@npm:^5.0.0": + version: 5.0.0 + resolution: "xml-name-validator@npm:5.0.0" + checksum: 10c0/3fcf44e7b73fb18be917fdd4ccffff3639373c7cb83f8fc35df6001fecba7942f1dbead29d91ebb8315e2f2ff786b508f0c9dc0215b6353f9983c6b7d62cb1f5 + languageName: node + linkType: hard + +"xmlchars@npm:^2.2.0": + version: 2.2.0 + resolution: "xmlchars@npm:2.2.0" + checksum: 10c0/b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593 + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" From 93f2cb24412852c03f23b3188005eec4185ce2d6 Mon Sep 17 00:00:00 2001 From: Manu Chaudhary Date: Tue, 10 Mar 2026 15:25:31 +0530 Subject: [PATCH 02/30] lint fix --- examples/react-example/src/index.tsx | 70 +++++++++++++------ .../src/ImageKitEditor.tsx | 8 +-- .../src/backward-compatibility.test.ts | 57 +++++++++++---- packages/imagekit-editor-dev/src/store.ts | 6 +- 4 files changed, 101 insertions(+), 40 deletions(-) diff --git a/examples/react-example/src/index.tsx b/examples/react-example/src/index.tsx index c68b0a5..a1b3bef 100644 --- a/examples/react-example/src/index.tsx +++ b/examples/react-example/src/index.tsx @@ -3,8 +3,8 @@ import { ImageKitEditor, type ImageKitEditorProps, type ImageKitEditorRef, - type Transformation, TRANSFORMATION_STATE_VERSION, + type Transformation, } from "@imagekit/editor" import { PiDownload } from "@react-icons/all-files/pi/PiDownload" import React, { useCallback, useEffect } from "react" @@ -50,12 +50,16 @@ function App() { const template = ref.current?.getTemplate() if (template) { // Remove the 'id' field from each transformation for storage - const templateToSave = template.map(({ id, ...rest }: Transformation) => rest) + 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)!`) + alert( + `โœ… Saved template with ${templateToSave.length} transformation(s)!`, + ) } else { alert("โš ๏ธ No transformations to save") } @@ -176,7 +180,8 @@ function App() { console.log("Signed URL", request.url) return Promise.resolve(request.url) }, - }) }, [handleAddImage, handleSaveTemplate]) + }) + }, [handleAddImage, handleSaveTemplate]) const toggle = () => { setOpen((prev: boolean) => !prev) @@ -213,12 +218,14 @@ function App() { padding: "10px 20px", fontSize: "16px", marginRight: "10px", - cursor: savedTemplate || localStorage.getItem("editorTemplate") - ? "pointer" - : "not-allowed", - opacity: savedTemplate || localStorage.getItem("editorTemplate") - ? 1 - : 0.5, + cursor: + savedTemplate || localStorage.getItem("editorTemplate") + ? "pointer" + : "not-allowed", + opacity: + savedTemplate || localStorage.getItem("editorTemplate") + ? 1 + : 0.5, }} > Load Saved Template @@ -264,12 +271,18 @@ function App() {

Types:{" "} - {Array.from( - new Set(savedTemplate.map((t) => t.type)) - ).join(", ")} + {Array.from(new Set(savedTemplate.map((t) => t.type))).join( + ", ", + )}

- + ๐Ÿ“‹ View Template JSON
๐Ÿ“– How to use Template Features:
           
  1. Click "Open ImageKit Editor" and apply some transformations
  2. -
  3. Click the "Save Template" button in the editor header
  4. +
  5. + Click the "Save Template" button in the editor + header +
  6. Close the editor
  7. - 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 +
  8. +
  9. + Use "Clear Template" to remove the saved template
  10. -
  11. 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" + /> + + + + + + {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 */} + + + + + 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 */} - - 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", + }} > - + + + + + + ) } 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. + + + + {/* 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", + }} /> @@ -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({ + ) +} 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 + +