diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index da9c2de..3ae2a6c 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:coverage 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/.husky/pre-commit b/.husky/pre-commit index af5adff..6684d66 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,5 @@ -lint-staged \ No newline at end of file +# Run lint autofixes +yarn lint:fix + +# Check lint again +yarn lint \ No newline at end of file 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..bd21603 100644 --- a/examples/react-example/src/index.tsx +++ b/examples/react-example/src/index.tsx @@ -1,6 +1,10 @@ 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, + 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 +16,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 +31,57 @@ 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]) + + /** + * 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: [ @@ -63,7 +122,7 @@ function App() { icon: , isVisible: true, onClick: (images, currentImage) => { - console.log(images, currentImage) + console.log("Export images:", images, currentImage) }, }, // { @@ -89,18 +148,175 @@ function App() { console.log("Signed URL", request.url) return Promise.resolve(request.url) }, + storageProvider: "localStorage", }) }, [handleAddImage]) 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..cd06372 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", @@ -25,7 +26,11 @@ }, "devDependencies": { "@biomejs/biome": "2.1.1", + "@types/human-date": "^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" @@ -40,6 +45,7 @@ ] }, "dependencies": { + "human-date": "^1.4.0", "react-select": "^5.2.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..4f0b836 100644 --- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx +++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx @@ -1,22 +1,79 @@ 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, 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 + + /** + * Explicitly saves the current template to the configured storage provider. + * No-op if no storage provider is configured. + */ + saveTemplate: () => Promise } interface EditorProps { @@ -27,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, @@ -41,8 +109,43 @@ function ImageKitEditorImpl( transformations, initialize, destroy, + 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 }) @@ -73,8 +176,18 @@ function ImageKitEditorImpl( loadImage: addImage, loadImages: addImages, setCurrentImage, + getTemplate: () => transformations, + loadTemplate, + saveTemplate: saveTemplateImperative, }), - [addImage, addImages, setCurrentImage], + [ + addImage, + addImages, + setCurrentImage, + transformations, + loadTemplate, + saveTemplateImperative, + ], ) const mergedThemes = merge(defaultTheme, themeOverrides, theme) @@ -82,13 +195,15 @@ function ImageKitEditorImpl( return ( - - - + + + + + ) 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..7807c90 --- /dev/null +++ b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts @@ -0,0 +1,3106 @@ +import { describe, expect, it } from "vitest" +import { transformationFormatters, transformationSchema } from "./schema" +import type { Transformation } from "./store" +import { TRANSFORMATION_STATE_VERSION } from "./store" + +/** + * 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: _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: Record = { + 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) + }) + }) + + 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 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", + 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) + }) + }) + + 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/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/common/FilterChipsField.tsx b/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx new file mode 100644 index 0000000..bf0e5e7 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx @@ -0,0 +1,113 @@ +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..b410852 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx @@ -0,0 +1,208 @@ +import { + Avatar, + Box, + Checkbox, + Divider, + Flex, + HStack, + Icon, + Input, + 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" + margin="2" + > + { + if (!disabled) toggleValue(opt.value) + }} + pointerEvents="none" + flexShrink={0} + borderColor="gray.300" + bg="white" + mr="2" + /> + + + + + + {opt.label} + + {opt.email && ( + + ({opt.email}) + + )} + + + ) + } + + const renderedCount = selectedFirst + ? selected.length + other.length + : selected.length + + return ( + + {isSearchable ? ( + + + + setQuery(e.target.value)} + variant="unstyled" + bg="transparent" + borderColor="transparent" + _hover={{ borderColor: "transparent" }} + _focus={{ + borderColor: "transparent", + boxShadow: "none", + }} + /> + + + ) : 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 c724450..fd8189f 100644 --- a/packages/imagekit-editor-dev/src/components/editor/layout.tsx +++ b/packages/imagekit-editor-dev/src/components/editor/layout.tsx @@ -1,7 +1,10 @@ -import { Flex } from "@chakra-ui/react" -import { useState } from "react" +import { Box, Flex } from "@chakra-ui/react" +import { useEffect, useState } from "react" +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" @@ -15,10 +18,33 @@ interface Props { export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) { const [viewMode, setViewMode] = useState<"list" | "grid">("list") const [gridImageSize, setGridImageSize] = useState(300) + 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() return ( <> -
+
setIsTemplatesOpen(true)} + /> + {isTemplatesOpen ? ( + + + + setIsTemplatesOpen(false)} /> + + + + ) : null} ) } diff --git a/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx b/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx new file mode 100644 index 0000000..08bb59a --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx @@ -0,0 +1,48 @@ +import { Button, type ButtonProps, Icon, IconButton } from "@chakra-ui/react" +import type React from "react" + +interface NavbarItemProps extends Omit { + icon?: React.ReactElement + label: string + variant?: "button" | "icon" +} + +export const NavbarItem = ({ + icon, + label, + variant = "button", + children, + ...props +}: NavbarItemProps) => { + const commonStyles = { + variant: "ghost" as const, + borderRadius: "md" as const, + px: "4" as const, + py: "2" as const, + mx: "2" as const, + fontSize: "sm" as const, + fontWeight: "medium" as const, + _hover: { + bg: "editorBattleshipGrey.50", + }, + } + + // If only icon is provided (no children or label to display), use icon variant + if (variant === "icon" || (!children && icon && !label)) { + return ( + : undefined} + color="editorBattleshipGrey.500" + {...commonStyles} + {...props} + /> + ) + } + + return ( + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx new file mode 100644 index 0000000..2d44cfc --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx @@ -0,0 +1,290 @@ +import { + Box, + Button, + Flex, + FormControl, + FormLabel, + Icon, + IconButton, + Input, + Text, +} from "@chakra-ui/react" +import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe" +import { PiLock } from "@react-icons/all-files/pi/PiLock" +import { PiTrash } from "@react-icons/all-files/pi/PiTrash" +import { PiX } from "@react-icons/all-files/pi/PiX" +import { useEffect, useState } from "react" +import Select from "react-select" +import { useTemplateStorage } from "../../context/TemplateStorageContext" +import { useEditorStore } from "../../store" + +interface SettingsModalProps { + onClose: () => void +} + +export function SettingsModal({ onClose }: SettingsModalProps) { + const provider = useTemplateStorage() + const templateId = useEditorStore((s) => s.templateId) + const templateName = useEditorStore((s) => s.templateName) + const setTemplateName = useEditorStore((s) => s.setTemplateName) + const transformations = useEditorStore((s) => s.transformations) + const setSyncStatus = useEditorStore((s) => s.setSyncStatus) + const resetToNewTemplate = useEditorStore((s) => s.resetToNewTemplate) + + const [localName, setLocalName] = useState(templateName) + const [localVisibility, setLocalVisibility] = useState<"everyone" | "onlyMe">( + "onlyMe", + ) + const [isDeleting, setIsDeleting] = useState(false) + const [isSaving, setIsSaving] = useState(false) + + // Fetch current template visibility + useEffect(() => { + let cancelled = false + + if (!provider || !templateId) { + return + } + + provider + .getTemplate(templateId) + .then((record) => { + if (cancelled || !record) return + setLocalVisibility(record.isPrivate ? "onlyMe" : "everyone") + }) + .catch(() => { + // Ignore errors + }) + + return () => { + cancelled = true + } + }, [provider, templateId]) + + const handleSave = async () => { + if (!provider || !localName.trim()) return + + setIsSaving(true) + setSyncStatus("saving") + + try { + await provider.saveTemplate({ + id: templateId ?? undefined, + name: localName.trim(), + transformations: transformations.map(({ id: _id, ...rest }) => rest), + isPrivate: localVisibility === "onlyMe", + }) + + setTemplateName(localName.trim()) + setSyncStatus("saved") + onClose() + } catch (err) { + setSyncStatus( + "error", + err instanceof Error ? err.message : "Failed to save", + ) + } finally { + setIsSaving(false) + } + } + + const handleDelete = async () => { + if (!provider || !templateId) return + + setIsDeleting(true) + + try { + await provider.deleteTemplate(templateId) + resetToNewTemplate() + onClose() + } catch (err) { + console.error("Failed to delete template:", err) + } finally { + setIsDeleting(false) + } + } + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation() + onClose() + } + } + window.addEventListener("keydown", handleKeyDown) + return () => { + window.removeEventListener("keydown", handleKeyDown) + } + }, [onClose]) + + return ( + + e.stopPropagation()} + > + {/* Header */} + + + Template Settings + + } + aria-label="Close settings" + /> + + + {/* Content */} + + + {/* Template Name */} + + + Template Name + + setLocalName(e.target.value)} + placeholder="Enter template name" + fontSize="sm" + /> + + + {/* Visibility */} + + + Visibility + + 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..0e629bd --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx @@ -0,0 +1,184 @@ +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 { 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..cd19017 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx @@ -0,0 +1,547 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Avatar, + Badge, + Box, + Button, + Divider, + Flex, + Icon, + Input, + InputGroup, + InputLeftElement, + Popover, + 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 { PiPushPin } from "@react-icons/all-files/pi/PiPushPin" +import { PiPushPinFill } from "@react-icons/all-files/pi/PiPushPinFill" +import { PiSquaresFourLight } from "@react-icons/all-files/pi/PiSquaresFourLight" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useTemplateStorage } from "../../context/TemplateStorageContext" +import type { TemplateRecord } from "../../storage" +import { useEditorStore } from "../../store" + +const MAX_VISIBLE = 5 + +interface TemplatesDropdownProps { + onViewAllTemplates?: () => void +} + +export function TemplatesDropdown({ + onViewAllTemplates, +}: TemplatesDropdownProps) { + const provider = useTemplateStorage() + 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) + + 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]) + + useEffect(() => { + // Refetch templates when sync status changes to "saved" to reflect updates + if (syncStatus === "saved") { + fetchTemplates() + } + }, [syncStatus, fetchTemplates]) + + 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 = 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]) + + if (!provider) return null + + 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 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() + 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 ( + <> + + + + + + Templates + + + + + + + + + + + + setSearch(e.target.value)} + 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", + }} + /> + + + + + + {shouldShowCurrent && ( + + {/* Visibility Icon (fallback to private when unknown) */} + + + {/* Name + badge */} + + + + {templateName} + + + Current + + + + + {/* Transform count on the right */} + + {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)} + transition="background-color 0.15s" + > + {/* 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 ? ( + + ) : ( + + )} + + + + )) + )} + + + {/* Always show "View all templates" when callback is provided, or when there are more templates than visible */} + {onViewAllTemplates ? ( + <> + + + + + + ) : templates.length > MAX_VISIBLE + (shouldShowCurrent ? 1 : 0) ? ( + <> + + + + {templates.length} templates total + + + + ) : null} + + + + + 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..312bf32 100644 --- a/packages/imagekit-editor-dev/src/components/header/index.tsx +++ b/packages/imagekit-editor-dev/src/components/header/index.tsx @@ -1,5 +1,4 @@ import { - Button, Divider, Flex, Icon, @@ -8,17 +7,23 @@ 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 { PiGear } from "@react-icons/all-files/pi/PiGear" +import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe" +import { PiLock } from "@react-icons/all-files/pi/PiLock" import { PiX } from "@react-icons/all-files/pi/PiX" -import React, { useMemo } from "react" +import React, { useEffect, useState } from "react" +import { useTemplateStorage } from "../../context/TemplateStorageContext" import { type FileElement, type RequiredMetadata, useEditorStore, } from "../../store" +import { NavbarItem } from "./NavbarItem" +import { SettingsModal } from "./SettingsModal" +import { TemplateNameInput } from "./TemplateNameInput" +import { TemplateStatus } from "./TemplateStatus" +import { TemplatesDropdown } from "./TemplatesDropdown" interface ExportOptionButton< Metadata extends RequiredMetadata = RequiredMetadata, @@ -50,19 +55,69 @@ 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 templateId = useEditorStore((s) => s.templateId) + const syncStatus = useEditorStore((s) => s.syncStatus) + const provider = useTemplateStorage() - const headerText = useMemo(() => { - if (imageList.length === 1) { - return decodeURIComponent( - currentImage?.split("/").pop()?.split("?")?.[0] || "", - ) + const [isPrivate, setIsPrivate] = useState(null) + const [isSettingsOpen, setIsSettingsOpen] = useState(false) + + useEffect(() => { + let cancelled = false + + if (!provider || !templateId) { + setIsPrivate(null) + return + } + + provider + .getTemplate(templateId) + .then((record) => { + if (cancelled) return + setIsPrivate(record ? record.isPrivate : null) + }) + .catch(() => { + if (cancelled) return + setIsPrivate(null) + }) + + return () => { + cancelled = true + } + }, [provider, templateId]) + + // Refetch template visibility when it's saved + useEffect(() => { + let cancelled = false + + if (!provider || !templateId || syncStatus !== "saved") { + return } - return `${imageList.length} Images` - }, [imageList, currentImage]) + + provider + .getTemplate(templateId) + .then((record) => { + if (cancelled) return + setIsPrivate(record ? record.isPrivate : null) + }) + .catch(() => { + if (cancelled) return + setIsPrivate(null) + }) + + return () => { + cancelled = true + } + }, [provider, templateId, syncStatus]) return ( { borderBottomColor="editorBattleshipGrey.100" flexShrink={0} > - - {headerText} + {provider ? ( + <> + + {templateId && ( + + )} + + + + } + variant="icon" + onClick={() => setIsSettingsOpen(true)} + /> + + + + + + + ) : null} + + + {exportOptions ?.filter((exportOption) => @@ -92,21 +182,11 @@ export const Header = ({ onClose, exportOptions }: HeaderProps) => { ) .map((exportOption) => ( - {exportOption.type === "button" ? ( - + /> ) : ( {exportOption.label} @@ -174,20 +246,19 @@ export const Header = ({ onClose, exportOptions }: HeaderProps) => { )} ))} - - + /> + {isSettingsOpen && ( + setIsSettingsOpen(false)} /> + )} ) } 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..b91a449 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx @@ -0,0 +1,748 @@ +import { + Avatar, + Badge, + Box, + Button, + Divider, + Flex, + Icon, + Input, + InputGroup, + InputLeftElement, + Menu, + MenuButton, + MenuItem, + MenuList, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Spinner, + Text, +} from "@chakra-ui/react" +import { BsThreeDots } from "@react-icons/all-files/bs/BsThreeDots" +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 { PiPlus } from "@react-icons/all-files/pi/PiPlus" +import { PiPushPin } from "@react-icons/all-files/pi/PiPushPin" +import { PiPushPinFill } from "@react-icons/all-files/pi/PiPushPinFill" +import { PiTrash } from "@react-icons/all-files/pi/PiTrash" +import humanDate from "human-date" +import { useCallback, useEffect, useMemo, useState } from "react" +import { useTemplateStorage } from "../../context/TemplateStorageContext" +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 { + onClose(): void +} + +function formatRelativeTime(ts: number): string { + 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) { + 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 [pinningId, setPinningId] = useState(null) + + const { loadTemplate, setTemplateName, setTemplateId, resetToNewTemplate } = + useEditorStore() + const templateId = useEditorStore((s) => s.templateId) + 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 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(() => { + const base = 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, + ) + + // 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) => { + if (isPristine || syncStatus === "saved") { + loadTemplate(record.transformations) + setTemplateName(record.name) + setTemplateId(record.id) + onClose() + } + } + + 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 { + 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 { + // Silently ignore pin failures in this view + } finally { + setPinningId((current) => (current === record.id ? null : current)) + } + } + + return ( + + {/* Static top section: title, filters */} + + + + + + + + All templates + + + Browse and load templates created by you or shared with you. + + + + + + {/* Controls bar */} + + + + + + setSearchInput(e.target.value)} + 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", + }} + /> + + + + + + + + + 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 */} + + {/* Pin column spacer to align with row */} + + + Name + + + Created by + + + Visibility + + + Last updated + + + + + {/* Current row */} + {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 */} + {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) => ( + { + if (!provider) return + if (!provider.deleteTemplate) return + await provider.deleteTemplate(r.id) + setTemplates((prev) => prev.filter((t) => t.id !== r.id)) + }} + /> + )) + )} + + )} + + + + ) +} + +interface TemplateRowProps { + record: TemplateRecord + onSelect(record: TemplateRecord): void + onTogglePin(record: TemplateRecord): void + onDelete(record: TemplateRecord): void + isPinning: boolean + isCurrent?: boolean + canDelete?: boolean +} + +function TemplateRow({ + record, + onSelect, + onTogglePin, + onDelete, + isPinning, + isCurrent = false, + canDelete = true, +}: TemplateRowProps) { + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + return ( + { + if (!isCurrent) onSelect(record) + }} + > + {/* Pin */} + + { + e.stopPropagation() + onTogglePin(record) + }} + > + {isPinning ? ( + + ) : ( + + )} + + + + {/* Name + transform count */} + + + + {record.name} + + {isCurrent && ( + + Current + + )} + + + {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)} + + + + {/* Row actions menu + delete confirmation popup */} + setShowDeleteConfirm(false)} + placement="bottom-end" + closeOnBlur + > + + e.stopPropagation()} + > + + e.stopPropagation()} + > + + + e.stopPropagation()} + > + } + color={canDelete ? "red.500" : "gray.400"} + display="flex" + alignItems="center" + _hover={{ bg: canDelete ? "red.50" : "transparent" }} + isDisabled={!canDelete} + onClick={(e) => { + if (!canDelete) return + e.stopPropagation() + setShowDeleteConfirm(true) + }} + > + Delete + + + + + + e.stopPropagation()} + > + + + Are you sure you want to delete this template? This action is + irreversible. + + + + + + + + + + ) +} 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..64aa75d --- /dev/null +++ b/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx @@ -0,0 +1,25 @@ +import type React from "react" +import { 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..3d0f36e --- /dev/null +++ b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts @@ -0,0 +1,87 @@ +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 5d74dfd..fb34fbd 100644 --- a/packages/imagekit-editor-dev/src/index.tsx +++ b/packages/imagekit-editor-dev/src/index.tsx @@ -1,4 +1,11 @@ export type { ImageKitEditorProps, ImageKitEditorRef } from "./ImageKitEditor" export { ImageKitEditor } from "./ImageKitEditor" export { DEFAULT_FOCUS_OBJECTS } from "./schema" -export type { FileElement, Signer } from "./store" +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/field-config.test.ts b/packages/imagekit-editor-dev/src/schema/field-config.test.ts new file mode 100644 index 0000000..80882eb --- /dev/null +++ b/packages/imagekit-editor-dev/src/schema/field-config.test.ts @@ -0,0 +1,512 @@ +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/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/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 9ec8a4f..1455b1e 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -706,11 +706,19 @@ 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/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 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..fbfd074 --- /dev/null +++ b/packages/imagekit-editor-dev/src/storage/index.ts @@ -0,0 +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 new file mode 100644 index 0000000..f40cad9 --- /dev/null +++ b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts @@ -0,0 +1,121 @@ +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 { + const templatesKey = options.templatesKey ?? DEFAULT_TEMPLATES_KEY + + function readTemplates(): TemplateRecord[] { + try { + const raw = localStorage.getItem(templatesKey) + if (!raw) return [] + const parsed = JSON.parse(raw) as Record[] + return parsed.map(normalizeRecord) + } 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: SaveTemplateInput): 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 existing = templates[index] + const updated: TemplateRecord = { + ...existing, + name: record.name, + transformations: record.transformations, + isPrivate: record.isPrivate ?? existing.isPrivate, + pinnedBy: record.pinnedBy ?? existing.pinnedBy, + updatedAt: record.updatedAt ?? now, + updatedBy: record.updatedBy ?? LOCAL_USER, + } + templates[index] = updated + writeTemplates(templates) + return updated + } + } + + 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) + 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..ab9fc02 --- /dev/null +++ b/packages/imagekit-editor-dev/src/storage/types.ts @@ -0,0 +1,50 @@ +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 + /** + * Optional override for updatedAt. When provided, the local storage provider + * will respect this value instead of always touching updatedAt. + */ + updatedAt?: number +} + +export interface TemplateStorageProvider { + listTemplates(): Promise + getTemplate(id: string): Promise + saveTemplate(record: SaveTemplateInput): 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 b0cdd83..ac306d6 100644 --- a/packages/imagekit-editor-dev/src/store.ts +++ b/packages/imagekit-editor-dev/src/store.ts @@ -15,12 +15,17 @@ 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 + /** Persisted visibility flag. Absent or true = visible; false = hidden. */ + enabled?: boolean } export type RequiredMetadata = { requireSignedUrl: boolean } @@ -69,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, > { @@ -85,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< @@ -94,6 +106,8 @@ export type EditorActions< imageList?: Array> signer?: Signer focusObjects?: ReadonlyArray + templateName?: string + templateId?: string }) => void destroy: () => void setCurrentImage: (imageSrc: string | undefined) => void @@ -104,7 +118,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, @@ -120,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 @@ -181,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()( @@ -201,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) } @@ -302,12 +333,32 @@ 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) => { + // enabled absent or true → visible; false → hidden + visibleTransformations[t.id] = t.enabled !== false + }) + + set((state) => ({ + transformations: transformationsWithIds, + visibleTransformations: { + ...state.visibleTransformations, + ...visibleTransformations, + }, + _internalState: { + sidebarState: "none", + selectedTransformationKey: null, + transformationToEdit: null, + }, + isPristine: false, })) - set({ transformations: transformationsWithIds }) }, moveTransformation: (activeId, overId) => { @@ -322,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) => { @@ -355,6 +415,7 @@ const useEditorStore = create()( ...state.visibleTransformations, [id]: true, }, + isPristine: false, } }) @@ -371,6 +432,7 @@ const useEditorStore = create()( ...state.visibleTransformations, [id]: true, }, + isPristine: false, } }) @@ -382,6 +444,7 @@ const useEditorStore = create()( transformations: state.transformations.filter( (transformation) => transformation.id !== id, ), + isPristine: false, })) }, @@ -393,6 +456,7 @@ const useEditorStore = create()( transformations: state.transformations.map((t) => t.id === id ? { ...updatedTransformation, id } : t, ), + isPristine: false, })) }, @@ -402,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 }, 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/packages/imagekit-editor-dev/vite.config.ts b/packages/imagekit-editor-dev/vite.config.ts index 0a8cdda..40053ea 100644 --- a/packages/imagekit-editor-dev/vite.config.ts +++ b/packages/imagekit-editor-dev/vite.config.ts @@ -16,6 +16,25 @@ 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/schema/**/*.{ts,tsx}"], + exclude: ["src/**/*.{test,spec}.{ts,tsx}", "node_modules/**"], + thresholds: { + // Only enforced on src/schema files - focusing on validation logic + lines: 90, // Realistic threshold given UI visibility code + branches: 90, + statements: 90, + perFile: false, // Global threshold across all schema files + }, + }, + }, 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..8dc8d5c 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,25 @@ __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" + 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 +2806,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 +2894,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 +2924,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 +3210,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 +3246,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 +3260,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 +3278,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 +3317,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 +3358,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 +3378,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 +3579,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 +3615,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 +3637,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 +3661,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 +3752,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 +3789,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 +3983,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 +4014,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 +4083,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 +4118,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 +4291,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 +4321,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 +4353,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 +4386,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 +4396,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: @@ -3376,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" @@ -3416,6 +4453,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 +4468,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 +4482,12 @@ __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" react-select: "npm:^5.2.1" shx: "npm:^0.4.0" @@ -3569,6 +4614,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 +4651,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 +4710,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 +4731,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 +4934,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 +4948,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 +4973,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 +4991,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 +5041,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 +5079,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 +5201,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 +5299,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 +5377,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 +5440,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 +5454,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 +5482,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 +5532,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 +5592,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 +5659,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 +5829,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 +5968,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 +6149,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 +6195,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 +6273,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 +6294,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 +6353,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 +6414,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 +6517,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 +6533,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 +6568,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 +6595,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 +6609,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 +6636,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 +6705,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 +6862,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 +6876,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 +7060,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 +7095,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 +7193,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 +7250,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 +7268,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 +7282,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 +7333,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 +7385,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"