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/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..c84c050 100644 --- a/examples/react-example/src/index.tsx +++ b/examples/react-example/src/index.tsx @@ -1,6 +1,11 @@ import { Icon } from "@chakra-ui/react" -import { ImageKitEditor, type ImageKitEditorProps } from "@imagekit/editor" -import type { ImageKitEditorRef } from "@imagekit/editor/dist/ImageKitEditor" +import { + createLocalStorageProvider, + 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 +17,10 @@ function App() { ImageKitEditorProps<{ requireSignedUrl: boolean; fileName: string }> >() const ref = React.useRef(null) + const [savedTemplate, setSavedTemplate] = React.useState< + Omit[] | null + >(null) + const [shouldLoadTemplate, setShouldLoadTemplate] = React.useState(false) /** * Function moved from EditorLayout component @@ -23,6 +32,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 +123,7 @@ function App() { icon: , isVisible: true, onClick: (images, currentImage) => { - console.log(images, currentImage) + console.log("Export images:", images, currentImage) }, }, // { @@ -89,18 +149,175 @@ function App() { console.log("Signed URL", request.url) return Promise.resolve(request.url) }, + templateStorage: createLocalStorageProvider(), }) }, [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..dd6d0d6 100644 --- a/package.json +++ b/package.json @@ -16,19 +16,31 @@ "dev": "turbo run dev", "start": "turbo run start", "build": "turbo run build", + "test": "turbo run test --filter=imagekit-editor-dev", + "test:coverage": "turbo run test:coverage --filter=imagekit-editor-dev", "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", "prepare": "husky", - "lint": "biome ci", - "lint:fix": "biome format --write ./" + "lint": "biome ci --files-ignore-unknown=true", + "lint:fix": "biome format --write ./ --files-ignore-unknown=true" }, "devDependencies": { "@biomejs/biome": "2.1.1", + "@testing-library/dom": "8.20.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "12.1.5", + "@types/human-date": "^1", + "@types/jsdom": "^28", + "@vitest/coverage-v8": "^2.1.9", + "@vitest/ui": "^2.1.9", + "happy-dom": "^20.9.0", "husky": "^9.1.7", + "jsdom": "^28.1.0", "lint-staged": "^16.1.2", "shx": "^0.4.0", - "turbo": "^2.0.1" + "turbo": "^2.0.1", + "vitest": "^2.1.9" }, "packageManager": "yarn@4.9.2", "lint-staged": { @@ -40,6 +52,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..0d9c7b5 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": "3.0.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": { @@ -48,6 +52,7 @@ "@hookform/resolvers": "^5.1.1", "@imagekit/javascript": "^5.1.0", "@react-icons/all-files": "https://github.com/react-icons/react-icons/releases/download/v5.4.0/react-icons-all-files-5.4.0.tgz", + "@tanstack/react-virtual": "^3.13.12", "framer-motion": "6.5.1", "imagekit-javascript": "^3.0.2", "lodash": "^4.17.21", diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx index 675f583..1572c67 100644 --- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx +++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx @@ -1,22 +1,80 @@ 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 type { GetTemplatePermissions } from "./context/TemplatePermissionsContext" +import { TemplatePermissionsContextProvider } from "./context/TemplatePermissionsContext" +import { TemplateStorageContextProvider } from "./context/TemplateStorageContext" +import { + isTemplateAccessDeniedError, + 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 +85,31 @@ interface EditorProps { exportOptions?: HeaderProps["exportOptions"] focusObjects?: ReadonlyArray onClose: (args: { dirty: boolean; destroy: () => void }) => void + /** + * Template persistence (list/save/delete/pin). Implemented by the host app — + * the editor does not perform media library or other remote API calls itself. + * Omit or pass `null` to disable template sync UI. + */ + templateStorage?: TemplateStorageProvider | null + /** + * Host-controlled, per-template permissions for template management UI. + * If omitted, the editor defaults to allowing all actions. + */ + getTemplatePermissions?: GetTemplatePermissions } function ImageKitEditorImpl( props: EditorProps, ref: React.Ref, ) { - const { theme, initialImages, signer, focusObjects } = props + const { + theme, + initialImages, + signer, + focusObjects, + templateStorage, + getTemplatePermissions, + } = props const { addImage, addImages, @@ -41,10 +117,74 @@ function ImageKitEditorImpl( transformations, initialize, destroy, + loadTemplate, } = useEditorStore() + const resolvedProvider = useMemo( + () => templateStorage ?? null, + [templateStorage], + ) + + const saveTemplateImperative = useCallback(async () => { + // Avoid importing hooks here; implement via store+provider with version gating. + if (!resolvedProvider) return + const state = useEditorStore.getState() + if (state.templateStorageWriteBlocked) return + + const saveStartedAtVersion = state.localChangeVersion + state.setSyncStatus("saving") + try { + const saved = await resolvedProvider.saveTemplate({ + id: state.templateId ?? undefined, + name: state.templateName, + transformations: state.transformations.map( + ({ id: _id, ...rest }) => rest, + ), + ...(state.templateIsPrivate !== null + ? { isPrivate: state.templateIsPrivate } + : {}), + }) + const after = useEditorStore.getState() + after.hydrateTemplateMetadata({ + templateId: saved.id, + templateName: saved.name, + templateIsPrivate: + typeof saved.isPrivate === "boolean" ? saved.isPrivate : null, + }) + if (after.localChangeVersion === saveStartedAtVersion) { + after.markSynced(saveStartedAtVersion) + after.setSyncStatus("saved") + } else { + after.setSyncStatus("unsaved") + } + after.setLastSavedAt(Date.now()) + } catch (err) { + if (isTemplateAccessDeniedError(err)) { + useEditorStore + .getState() + .blockTemplateStorageWrites( + err instanceof Error + ? err.message + : "You no longer have access to this template.", + ) + return + } + state.setSyncStatus( + "error", + err instanceof Error ? err.message : "Failed to save template", + ) + } + }, [resolvedProvider]) + const handleOnClose = () => { - const dirty = transformations.length > 0 + // `dirty` should represent *unsynced* changes (host uses it to decide + // whether to show a close confirmation). + const state = useEditorStore.getState() + const dirty = + state.transformationConfigFormDirty || + (resolvedProvider + ? state.localChangeVersion !== state.lastSyncedVersion + : !state.isPristine) props.onClose({ dirty, destroy }) } @@ -73,8 +213,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 +232,19 @@ 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/ActionBar.tsx b/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx index 139957b..27eb7cb 100644 --- a/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx +++ b/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx @@ -66,6 +66,7 @@ export const ActionBar: FC = ({ size="md" fontWeight="normal" leftIcon={} + _hover={{ bg: "gray.100" }} onClick={() => setShowOriginal(!showOriginal)} > {showOriginal ? "Show Transformed" : "Show Original"} @@ -105,6 +106,7 @@ export const ActionBar: FC = ({ variant="ghost" size="md" fontWeight="normal" + _hover={{ bg: "gray.100" }} onClick={() => window.open(currentImage, "_blank")} > Open image in new tab diff --git a/packages/imagekit-editor-dev/src/components/editor/layout.tsx b/packages/imagekit-editor-dev/src/components/editor/layout.tsx index c724450..9ad0ee5 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,35 @@ 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() + + const closeTemplatesLibrary = () => setIsTemplatesOpen(false) return ( <> -
+
setIsTemplatesOpen(true)} + /> + {isTemplatesOpen ? ( + + e.stopPropagation()} + > + + + + + + ) : 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..e6c997a --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx @@ -0,0 +1,62 @@ +import { Button, type ButtonProps, Icon, IconButton } from "@chakra-ui/react" +import type React from "react" +import { forwardRef } from "react" +import { chakraAny } from "../../utils" + +interface NavbarItemProps extends Omit { + icon?: React.ReactElement + label: string + variant?: "button" | "icon" +} + +export const NavbarItem = forwardRef( + function NavbarItem( + { icon, label, variant = "button", children, ...props }, + ref, + ) { + const ButtonAny = chakraAny(Button) + const IconButtonAny = chakraAny(IconButton) + 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, + color: "editorBattleshipGrey.700", + _hover: { + bg: "gray.100", + }, + } + + // If only icon is provided (no children or label to display), use icon variant + if (variant === "icon" || (!children && icon && !label)) { + return ( + + ) : undefined + } + {...commonStyles} + {...(props as unknown as Record)} + /> + ) + } + + return ( + )} + > + {children || label} + + ) + }, +) diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.test.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.test.tsx new file mode 100644 index 0000000..7c832db --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.test.tsx @@ -0,0 +1,176 @@ +import { ChakraProvider } from "@chakra-ui/react" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { TemplateStorageContextProvider } from "../../context/TemplateStorageContext" +import type { TemplateRecord } from "../../storage" +import type { TemplateStorageProvider } from "../../storage/types" +import { useEditorStore } from "../../store" +import { SettingsModal } from "./SettingsModal" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTemplate(partial: Partial = {}): TemplateRecord { + const now = Date.now() + return { + id: "t-1", + clientNumber: "c1", + isPrivate: false, + name: "My Template", + transformations: [], + isPinned: false, + createdBy: { userId: "u1", name: "Creator", email: "creator@example.com" }, + updatedBy: { userId: "u1", name: "Creator", email: "creator@example.com" }, + createdAt: now, + updatedAt: now, + ...partial, + } +} + +function renderModal(opts: { + data?: TemplateRecord + onClose?: () => void + onSaved?: (r: TemplateRecord) => void + onDeleteRequested?: ((id: string) => Promise) | undefined + saveTemplate?: (r: unknown) => Promise +}) { + const data = opts.data ?? makeTemplate() + + const provider: TemplateStorageProvider = { + getProviderName: () => "test", + getCurrentUserSession: () => ({ id: "u1" }), + listTemplates: async () => [], + getTemplate: async () => data, + saveTemplate: + opts.saveTemplate ?? + (async (_r: unknown) => makeTemplate({ id: "saved" })), + setTemplatePinned: vi.fn(), + } + + return render( + + + + + , + ) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("SettingsModal", () => { + beforeEach(() => { + useEditorStore.getState().destroy() + }) + + describe("delete confirmation flow", () => { + it("does NOT call onDeleteRequested immediately when Delete is clicked", async () => { + const onDeleteRequested = vi.fn(async () => {}) + renderModal({ onDeleteRequested }) + + expect(await screen.findByText("Template Settings")).toBeTruthy() + + act(() => { + fireEvent.click(screen.getByText("Delete")) + }) + + // onDeleteRequested must NOT have been called yet + expect(onDeleteRequested).not.toHaveBeenCalled() + }) + + it("shows the confirmation panel after clicking Delete", async () => { + renderModal({ onDeleteRequested: vi.fn(async () => {}) }) + + expect(await screen.findByText("Template Settings")).toBeTruthy() + + act(() => { + fireEvent.click(screen.getByText("Delete")) + }) + + // Confirmation panel should now be visible + expect(screen.getByText("Delete template?")).toBeTruthy() + expect(screen.getByText(/This action cannot be reversed/)).toBeTruthy() + expect(screen.getByText("Yes, delete")).toBeTruthy() + }) + + it("does NOT call onDeleteRequested when confirmation is dismissed with Cancel", async () => { + const onDeleteRequested = vi.fn(async () => {}) + renderModal({ onDeleteRequested }) + + expect(await screen.findByText("Template Settings")).toBeTruthy() + + // Open confirmation panel + act(() => { + fireEvent.click(screen.getByText("Delete")) + }) + + expect(screen.getByText("Delete template?")).toBeTruthy() + + // Dismiss without confirming — click the Cancel inside the confirmation panel + act(() => { + fireEvent.click(screen.getByTestId("delete-confirm-cancel")) + }) + + // Panel should be gone + expect(screen.queryByText("Delete template?")).toBeNull() + // onDeleteRequested still must not have been called + expect(onDeleteRequested).not.toHaveBeenCalled() + }) + + it("calls onDeleteRequested only after clicking Yes, delete", async () => { + const onDeleteRequested = vi.fn(async () => {}) + const onClose = vi.fn() + + renderModal({ onDeleteRequested, onClose }) + + expect(await screen.findByText("Template Settings")).toBeTruthy() + + // Open confirmation panel + act(() => { + fireEvent.click(screen.getByText("Delete")) + }) + + expect(screen.getByTestId("delete-confirm-submit")).toBeTruthy() + + // Confirm deletion + act(() => { + fireEvent.click(screen.getByTestId("delete-confirm-submit")) + }) + + await waitFor(() => { + expect(onDeleteRequested).toHaveBeenCalledTimes(1) + expect(onDeleteRequested).toHaveBeenCalledWith("t-1") + }) + + expect(onClose).toHaveBeenCalled() + }) + + it("Delete button is disabled while confirmation panel is open", async () => { + renderModal({ onDeleteRequested: vi.fn(async () => {}) }) + + expect(await screen.findByText("Template Settings")).toBeTruthy() + + // Before opening: Delete button is NOT disabled + const deleteBtn = screen.getByText("Delete") + expect(deleteBtn.closest("[aria-disabled='true']")).toBeNull() + + // Open confirmation panel + act(() => { + fireEvent.click(deleteBtn) + }) + + // After opening: the footer Delete button should be aria-disabled="true" + const deleteBtnsAfter = screen.getAllByText("Delete") + const footerDelete = deleteBtnsAfter[deleteBtnsAfter.length - 1] + expect(footerDelete.closest("[aria-disabled='true']")).not.toBeNull() + }) + }) +}) 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..15fe419 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx @@ -0,0 +1,550 @@ +import { Box, Flex, 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 type React from "react" +import { useEffect, useMemo, useRef, useState } from "react" +import Select, { type StylesConfig } from "react-select" +import { useTemplatePermissions } from "../../context/TemplatePermissionsContext" +import { useTemplateStorage } from "../../context/TemplateStorageContext" +import { isTemplateAccessDeniedError } from "../../storage/templateAccessError" +import type { TemplateRecord } from "../../storage/types" +import { useEditorStore } from "../../store" +import { chakraAny, formatTemplateNameForUI } from "../../utils" + +// --------------------------------------------------------------------------- +// Type casts — Chakra's strict generic signatures conflict with our JSX usage +// --------------------------------------------------------------------------- +const FlexAny = chakraAny(Flex) +const TextAny = chakraAny(Text) +const IconButtonAny = chakraAny(IconButton) +const InputAny = chakraAny(Input) + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface SettingsModalProps { + /** The template whose settings are being edited. All operations act on this record. */ + data: TemplateRecord + onClose(): void + /** Called with the updated record after a successful save. */ + onSaved?(updated: TemplateRecord): void + /** + * Called after the user confirms deletion. + * + * `SettingsModal` does not delete templates itself. Callers must provide a + * single delete implementation that can also handle required side-effects + * (list refresh, editor reset, etc.). + */ + onDeleteRequested?(id: string): Promise | void +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type Visibility = "everyone" | "onlyMe" + +function visibilityFromRecord(record: TemplateRecord): Visibility { + return record.isPrivate ? "onlyMe" : "everyone" +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function SettingsModal({ + data, + onClose, + onSaved, + onDeleteRequested, +}: SettingsModalProps) { + const provider = useTemplateStorage() + const permissions = useTemplatePermissions(data) + const templateStorageWriteBlocked = useEditorStore( + (s) => s.templateStorageWriteBlocked, + ) + + const onCloseRef = useRef(onClose) + useEffect(() => { + onCloseRef.current = onClose + }) + + const [localName, setLocalName] = useState(() => + formatTemplateNameForUI(data.name), + ) + const [localVisibility, setLocalVisibility] = useState(() => + visibilityFromRecord(data), + ) + + const [isDeleting, setIsDeleting] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + // Close on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation() + onClose() + } + } + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [onClose]) + + // ------------------------------------------------------------------------- + // Save: patches only name + visibility; preserves all other fields + // ------------------------------------------------------------------------- + const handleSave = async () => { + if (!provider || !localName.trim() || templateStorageWriteBlocked) return + + setIsSaving(true) + try { + const updated = await provider.saveTemplate({ + ...data, + name: localName.trim(), + isPrivate: localVisibility === "onlyMe", + // transformations are preserved from the original data record + transformations: data.transformations, + }) + onSaved?.(updated) + onClose() + } catch (err) { + if (isTemplateAccessDeniedError(err)) { + useEditorStore + .getState() + .blockTemplateStorageWrites( + err instanceof Error + ? err.message + : "You no longer have access to this template.", + ) + onClose() + return + } + } finally { + setIsSaving(false) + } + } + + // ------------------------------------------------------------------------- + // Delete + // ------------------------------------------------------------------------- + const handleDeleteConfirmed = async () => { + if (!onDeleteRequested) return + + setIsDeleting(true) + setShowDeleteConfirm(false) + try { + await onDeleteRequested(data.id) + onClose() + } catch (err) { + if (isTemplateAccessDeniedError(err)) { + useEditorStore + .getState() + .blockTemplateStorageWrites( + err instanceof Error + ? err.message + : "You no longer have access to this template.", + ) + onClose() + return + } + console.error("Failed to delete template:", err) + } finally { + setIsDeleting(false) + } + } + + // ------------------------------------------------------------------------- + // react-select styles + // ------------------------------------------------------------------------- + const selectStyles = useMemo< + StylesConfig<{ value: string; label: string }, false> + >( + () => ({ + control: (base) => ({ + ...base, + fontSize: "12px", + minHeight: "32px", + borderColor: "#E2E8F0", + backgroundColor: permissions.changeVisibility + ? base.backgroundColor + : "#F7FAFC", + opacity: permissions.changeVisibility ? 1 : 0.6, + }), + menu: (base) => ({ ...base, zIndex: 10 }), + option: (base) => ({ ...base, fontSize: "12px" }), + }), + [permissions.changeVisibility], + ) + + // Get height of the current viewport + const viewportHeight = useMemo(() => { + return window.innerHeight + }, []) + + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + 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" + isDisabled={!permissions.rename} + /> + + + {/* Visibility */} + + + Visibility + + + controllerField.onChange(selectedOption?.value) + } + onBlur={controllerField.onBlur} + styles={{ + control: (base) => ({ + ...base, + fontSize: "12px", + minHeight: "32px", + borderColor: "#E2E8F0", + }), + menu: (base) => ({ + ...base, + zIndex: 10, + }), + option: (base) => ({ + ...base, + fontSize: "12px", + }), + }} + /> ) }} /> @@ -564,7 +788,10 @@ export const TransformationConfigSidebar: React.FC = () => { } const finalValue = v < 0 && isNumberWithN ? `N${Math.abs(v)}` : String(v) - setValue(field.name, finalValue) + setValue(field.name, finalValue, { + shouldDirty: true, + shouldTouch: true, + }) }} onChange={(e) => { const val = e.target.value @@ -577,12 +804,18 @@ export const TransformationConfigSidebar: React.FC = () => { val.toUpperCase().startsWith("N") if (val === "") { - setValue(field.name, "") + setValue(field.name, "", { + shouldDirty: true, + shouldTouch: true, + }) return } if (val === "-") { - setValue(field.name, "-") + setValue(field.name, "-", { + shouldDirty: true, + shouldTouch: true, + }) return } @@ -590,7 +823,10 @@ export const TransformationConfigSidebar: React.FC = () => { field.fieldProps?.autoOption && val.match(/au?t?o?/i) ) { - setValue(field.name, "auto") + setValue(field.name, "auto", { + shouldDirty: true, + shouldTouch: true, + }) } else if ( !field.fieldProps?.skipStepCheck && field.fieldProps?.step && @@ -605,14 +841,23 @@ export const TransformationConfigSidebar: React.FC = () => { field.fieldProps.min < 0 && isNumberWithN ? `N${Math.abs(field.fieldProps.min)}` : String(field.fieldProps.min) - setValue(field.name, finalVal) + setValue(field.name, finalVal, { + shouldDirty: true, + shouldTouch: true, + }) } else if ( field.fieldProps?.max !== undefined && Number(numSafeVal) > field.fieldProps.max ) { - setValue(field.name, field.fieldProps.max) + setValue(field.name, field.fieldProps.max, { + shouldDirty: true, + shouldTouch: true, + }) } else { - setValue(field.name, val) + setValue(field.name, val, { + shouldDirty: true, + shouldTouch: true, + }) } }} /> @@ -622,7 +867,12 @@ export const TransformationConfigSidebar: React.FC = () => { colorScheme={ watch(field.name) === "auto" ? "blue" : "gray" } - onClick={() => setValue(field.name, "auto")} + onClick={() => + setValue(field.name, "auto", { + shouldDirty: true, + shouldTouch: true, + }) + } > Auto @@ -649,7 +899,12 @@ export const TransformationConfigSidebar: React.FC = () => { ) } defaultValue={field.fieldProps?.defaultValue as number} - onChange={(val) => setValue(field.name, val.toString())} + onChange={(val) => + setValue(field.name, val.toString(), { + shouldDirty: true, + shouldTouch: true, + }) + } focusThumbOnChange={false} > @@ -663,7 +918,12 @@ export const TransformationConfigSidebar: React.FC = () => { void + } fieldProps={field.fieldProps as ColorPickerProps} isClearable={field.fieldProps?.isClearable ?? false} /> @@ -672,7 +932,12 @@ export const TransformationConfigSidebar: React.FC = () => { void + } errors={errors} /> ) : null} @@ -680,14 +945,24 @@ export const TransformationConfigSidebar: React.FC = () => { setValue(field.name, value)} + onChange={(value) => + setValue(field.name, value, { + shouldDirty: true, + shouldTouch: true, + }) + } /> ) : null} {field.fieldType === "radio-card" ? ( setValue(field.name, value)} + onChange={(value) => + setValue(field.name, value, { + shouldDirty: true, + shouldTouch: true, + }) + } {...field.fieldProps} /> ) : null} @@ -695,14 +970,22 @@ export const TransformationConfigSidebar: React.FC = () => { setValue(field.name, value)} + onChange={(value) => + setValue(field.name, value, { + shouldDirty: true, + shouldTouch: true, + }) + } {...field.fieldProps} /> ) : null} {field.fieldType === "padding-input" ? ( { - setValue(field.name, value) + setValue(field.name, value, { + shouldDirty: true, + shouldTouch: true, + }) trigger(field.name) }} errors={errors as PaddingErrors} @@ -714,14 +997,26 @@ export const TransformationConfigSidebar: React.FC = () => { {field.fieldType === "zoom" ? ( setValue(field.name, value)} - {...field.fieldProps} + onChange={(value) => + setValue(field.name, value, { + shouldDirty: true, + shouldTouch: true, + }) + } + defaultValue={ + typeof field.fieldProps?.defaultValue === "number" + ? (field.fieldProps.defaultValue as number) + : undefined + } /> ) : null} {field.fieldType === "distort-perspective-input" ? ( { - setValue(field.name, value) + setValue(field.name, value, { + shouldDirty: true, + shouldTouch: true, + }) trigger(field.name) }} errors={errors as PerspectiveErrors} @@ -733,7 +1028,10 @@ export const TransformationConfigSidebar: React.FC = () => { {field.fieldType === "radius-input" ? ( { - setValue(field.name, value) + setValue(field.name, value, { + shouldDirty: true, + shouldTouch: true, + }) trigger(field.name) }} errors={errors as RadiusErrors} @@ -815,19 +1113,19 @@ export const TransformationConfigSidebar: React.FC = () => { {isDirty ? "Discard changes" : "Close"} - - { - - Apply & Close - + {footerActions.menuItems.map((item) => ( + + {item.label} + + ))} diff --git a/packages/imagekit-editor-dev/src/components/sidebar/transformationFooterActions.test.ts b/packages/imagekit-editor-dev/src/components/sidebar/transformationFooterActions.test.ts new file mode 100644 index 0000000..07451b8 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/sidebar/transformationFooterActions.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest" +import { getTransformationFooterActionsConfig } from "./transformation-config-sidebar" + +describe("getTransformationFooterActionsConfig", () => { + it("is fullySynced when no dirty, no unsynced, and last save is saved", () => { + const cfg = getTransformationFooterActionsConfig({ + isDirty: false, + syncStatus: "saved", + hasAppliedInSession: true, + templateStorageWriteBlocked: false, + hasUnsyncedChanges: false, + }) + expect(cfg.mode).toBe("fullySynced") + expect(cfg.primary.disabled).toBe(true) + }) + + it("shows applyFlow when form is dirty", () => { + const cfg = getTransformationFooterActionsConfig({ + isDirty: true, + syncStatus: "saved", + hasAppliedInSession: true, + templateStorageWriteBlocked: false, + hasUnsyncedChanges: false, + }) + expect(cfg.mode).toBe("applyFlow") + expect(cfg.primary.label).toBe("Apply") + }) + + it("shows saveFlow after apply when there are unsynced changes", () => { + const cfg = getTransformationFooterActionsConfig({ + isDirty: false, + syncStatus: "saved", + hasAppliedInSession: true, + templateStorageWriteBlocked: false, + hasUnsyncedChanges: true, + }) + expect(cfg.mode).toBe("saveFlow") + expect(cfg.primary.label).toBe("Save Changes") + }) +}) diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx new file mode 100644 index 0000000..b257301 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx @@ -0,0 +1,532 @@ +import { ChakraProvider } from "@chakra-ui/react" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { TemplateStorageContextProvider } from "../../context/TemplateStorageContext" +import type { TemplateRecord } from "../../storage" +import { useEditorStore } from "../../store" +import { TemplatesLibraryView } from "./TemplatesLibraryView" + +function makeTemplate(partial: Partial): TemplateRecord { + const now = Date.now() + return { + id: "t-1", + clientNumber: "c1", + isPrivate: true, + name: "Template 1", + transformations: [], + isPinned: false, + createdBy: { userId: "u1", name: "Creator", email: "c@example.com" }, + updatedBy: { userId: "u1", name: "Creator", email: "c@example.com" }, + createdAt: now, + updatedAt: now, + ...partial, + } +} + +function renderWithProvider(opts: { + templates: TemplateRecord[] + onClose?: () => void +}) { + const provider = { + getProviderName: () => "library", + getCurrentUserSession: () => ({}), + listTemplates: async () => opts.templates, + getTemplate: async () => null, + // biome-ignore lint/suspicious/noExplicitAny: test stub + saveTemplate: async (_r: any) => makeTemplate({ id: "saved" }), + setTemplatePinned: vi.fn(async (id: string, pinned: boolean) => { + const original = opts.templates.find((t) => t.id === id) + if (!original) throw new Error("template not found in stub") + return { ...original, isPinned: pinned } + }), + deleteTemplate: vi.fn(async () => {}), + } + + return render( + + {/* biome-ignore lint/suspicious/noExplicitAny: test stub */} + + {})} /> + + , + ) +} + +function mockLayoutForVirtualizer(opts?: { estimatedRowCount?: number }) { + const estimatedRowCount = opts?.estimatedRowCount ?? 3000 + // TanStack Virtual measures element sizes; JSDOM reports 0 by default. + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + const el = this as HTMLElement + const width = Number.parseFloat(el.style.width || "800") || 800 + const height = + Number.parseFloat(el.style.height || "") || + (el.dataset.testid?.includes("templates-library-scroll") ? 400 : 84) + return { + x: 0, + y: 0, + top: 0, + left: 0, + right: width, + bottom: height, + width, + height, + toJSON: () => ({}), + } as DOMRect + }, + ) + + // Provide stable layout metrics used by the virtualizer. + Object.defineProperty(HTMLElement.prototype, "clientHeight", { + configurable: true, + get() { + const el = this as HTMLElement + return el.dataset.testid?.includes("templates-library-scroll") ? 400 : 84 + }, + }) + + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get() { + const el = this as HTMLElement + return el.dataset.testid?.includes("templates-library-scroll") ? 400 : 84 + }, + }) + + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get() { + return 84 * estimatedRowCount + }, + }) +} + +function mockResizeObserver() { + class ResizeObserverMock { + callback: ResizeObserverCallback + constructor(cb: ResizeObserverCallback) { + this.callback = cb + } + observe = (target: Element) => { + // Fire once so virtualizer sees a measurement signal. + this.callback( + [ + { + target, + contentRect: target.getBoundingClientRect(), + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ) + } + unobserve = () => {} + disconnect = () => {} + } + // biome-ignore lint/suspicious/noExplicitAny: test environment shim + ;(globalThis as any).ResizeObserver = ResizeObserverMock +} + +describe("TemplatesLibraryView (virtualized)", () => { + beforeEach(() => { + useEditorStore.getState().destroy() + vi.useRealTimers() + mockLayoutForVirtualizer({ estimatedRowCount: 1000 }) + mockResizeObserver() + // TanStack Virtual batches updates through rAF; make it deterministic in JSDOM. + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + cb(performance.now()) + return 0 + }) + vi.stubGlobal("cancelAnimationFrame", () => {}) + // TanStack Virtual may use scrollTo on the scroll element. + Object.defineProperty(HTMLElement.prototype, "scrollTo", { + value: vi.fn(), + writable: true, + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it("applies visibility filter consistently when both options are selected", async () => { + const templates: TemplateRecord[] = [ + makeTemplate({ + id: "t-private", + name: "Private template", + isPrivate: true, + updatedAt: Date.now(), + createdAt: Date.now(), + }), + makeTemplate({ + id: "t-shared", + name: "Shared template", + isPrivate: false, + updatedAt: Date.now() - 1, + createdAt: Date.now() - 1, + }), + ] + + useEditorStore.setState({ + isPristine: true, + templateId: null, + templateName: "New template", + transformations: [], + syncStatus: "saved", + localChangeVersion: 1, + lastSyncedVersion: 1, + } as unknown as Parameters[0]) + + renderWithProvider({ templates }) + + expect(await screen.findByText("All templates")).toBeTruthy() + + // Baseline: both templates are present. + expect( + await screen.findByTestId("templates-library-row-t-private"), + ).toBeTruthy() + expect( + await screen.findByTestId("templates-library-row-t-shared"), + ).toBeTruthy() + + // Click each chip in a separate act() so React re-renders between clicks. + // Batching both inside one act() causes the second click to read stale + // visibilityFilter state (safeValue is captured at render time), making + // the final value ["shared"] instead of ["private","shared"]. + const clickChip = (label: string) => { + const candidates = screen.getAllByText(label) + const chip = candidates + .map((n) => n.closest("[aria-checked]")) + .find((el): el is HTMLElement => Boolean(el)) + if (!chip) throw new Error(`Filter chip not found for label: ${label}`) + fireEvent.click(chip) + } + + act(() => { + clickChip("Only to me") + }) + act(() => { + clickChip("Shared with everyone") + }) + + await waitFor(() => { + expect(screen.getByTestId("templates-library-row-t-private")).toBeTruthy() + expect(screen.getByTestId("templates-library-row-t-shared")).toBeTruthy() + }) + }) + + it( + "does not render thousands of rows at once; renders more on scroll", + async () => { + const now = Date.now() + const templates = Array.from({ length: 1000 }).map((_, i) => + makeTemplate({ + id: `t-${i}`, + name: `Template ${i}`, + // Ensure deterministic sort: newest first. + updatedAt: now - i, + createdAt: now - i, + createdBy: { + userId: `u-${i}`, + name: `User ${i}`, + email: `u${i}@ex.com`, + }, + }), + ) + + useEditorStore.setState({ + isPristine: true, + templateId: null, + templateName: "New template", + transformations: [], + syncStatus: "saved", + localChangeVersion: 1, + lastSyncedVersion: 1, + } as unknown as Parameters[0]) + + renderWithProvider({ templates }) + + // Wait for view to load. + expect(await screen.findByText("All templates")).toBeTruthy() + + const scrollEl = await screen.findByTestId("templates-library-scroll") + + // Top row should render (Template 0 is newest due to updatedAt). + expect(await screen.findByText("Template 0")).toBeTruthy() + + // A far-down row should not be mounted initially. + expect(screen.queryByText("Template 900")).toBeNull() + + // Scroll down enough to bring later items into view. + act(() => { + fireEvent.scroll(scrollEl, { target: { scrollTop: 84 * 900 } }) + }) + + await waitFor(() => { + expect(screen.getByText("Template 900")).toBeTruthy() + }) + }, + 15 * 1000, + ) + + it('includes a virtualized "Current" row when the active template exists', async () => { + const now = Date.now() + const active = makeTemplate({ + id: "t-active", + name: "Active Template", + updatedAt: now, + createdAt: now, + createdBy: { userId: "u-a", name: "Ada", email: "ada@ex.com" }, + }) + + useEditorStore.setState({ + isPristine: false, + templateId: "t-active", + templateName: "Active Template", + transformations: [], + syncStatus: "saved", + localChangeVersion: 1, + lastSyncedVersion: 1, + } as unknown as Parameters[0]) + + renderWithProvider({ templates: [active] }) + + expect(await screen.findByText("All templates")).toBeTruthy() + await screen.findByTestId("templates-library-scroll") + + expect(await screen.findByText("Current")).toBeTruthy() + expect(await screen.findByText("Active Template")).toBeTruthy() + }) + + it("supports ArrowUp/ArrowDown to activate + cycle through search results", async () => { + const now = Date.now() + const templates = [ + makeTemplate({ + id: "t-0", + name: "Alpha", + updatedAt: now, + createdAt: now, + createdBy: { userId: "u-0", name: "Ada", email: "ada@ex.com" }, + }), + makeTemplate({ + id: "t-1", + name: "Alpine", + updatedAt: now - 1, + createdAt: now - 1, + createdBy: { userId: "u-1", name: "Grace", email: "grace@ex.com" }, + }), + makeTemplate({ + id: "t-2", + name: "Beta", + updatedAt: now - 2, + createdAt: now - 2, + createdBy: { userId: "u-2", name: "Linus", email: "linus@ex.com" }, + }), + ] + + useEditorStore.setState({ + isPristine: true, + templateId: null, + templateName: "New template", + transformations: [], + syncStatus: "saved", + localChangeVersion: 1, + lastSyncedVersion: 1, + } as unknown as Parameters[0]) + + renderWithProvider({ templates }) + + expect(await screen.findByText("All templates")).toBeTruthy() + await screen.findByTestId("templates-library-scroll") + + const searchInput = await screen.findByPlaceholderText( + "Search templates...", + ) + + // Apply search (debounced). + act(() => { + fireEvent.change(searchInput, { target: { value: "al" } }) + }) + + // Wait for debounce window to elapse. + await act(async () => { + await new Promise((r) => setTimeout(r, 250)) + }) + + await waitFor(() => { + expect(screen.getByText("Alpha")).toBeTruthy() + expect(screen.getByText("Alpine")).toBeTruthy() + expect(screen.queryByText("Beta")).toBeNull() + }) + + // ArrowDown should activate first result. + act(() => { + fireEvent.keyDown(searchInput, { key: "ArrowDown" }) + }) + expect( + screen + .getByTestId("templates-library-row-t-0") + .getAttribute("data-active"), + ).toBe("true") + expect(HTMLElement.prototype.scrollTo).toHaveBeenCalled() + + // ArrowDown advances. + act(() => { + fireEvent.keyDown(searchInput, { key: "ArrowDown" }) + }) + expect( + screen + .getByTestId("templates-library-row-t-1") + .getAttribute("data-active"), + ).toBe("true") + + // ArrowDown cycles back to first result. + act(() => { + fireEvent.keyDown(searchInput, { key: "ArrowDown" }) + }) + expect( + screen + .getByTestId("templates-library-row-t-0") + .getAttribute("data-active"), + ).toBe("true") + + // ArrowUp cycles to last result. + act(() => { + fireEvent.keyDown(searchInput, { key: "ArrowUp" }) + }) + expect( + screen + .getByTestId("templates-library-row-t-1") + .getAttribute("data-active"), + ).toBe("true") + + vi.useRealTimers() + }) + + it("does not cycle with ArrowUp/ArrowDown when list size exceeds 200", async () => { + const now = Date.now() + const templates = Array.from({ length: 205 }).map((_, i) => + makeTemplate({ + id: `t-${i}`, + name: `Template ${i}`, + updatedAt: now - i, + createdAt: now - i, + createdBy: { + userId: `u-${i}`, + name: `User ${i}`, + email: `u${i}@ex.com`, + }, + }), + ) + + useEditorStore.setState({ + isPristine: true, + templateId: null, + templateName: "New template", + transformations: [], + syncStatus: "saved", + localChangeVersion: 1, + lastSyncedVersion: 1, + } as unknown as Parameters[0]) + + renderWithProvider({ templates }) + + expect(await screen.findByText("All templates")).toBeTruthy() + const scrollEl = await screen.findByTestId("templates-library-scroll") + + const searchInput = await screen.findByPlaceholderText( + "Search templates...", + ) + + // Move to the last row via ArrowDown. + act(() => { + for (let i = 0; i < 205; i++) { + fireEvent.keyDown(searchInput, { key: "ArrowDown" }) + } + }) + + // Scroll near the bottom so the last row is mounted for assertion. + act(() => { + fireEvent.scroll(scrollEl, { target: { scrollTop: 84 * 204 } }) + }) + + await waitFor(() => { + expect(screen.getByText("Template 204")).toBeTruthy() + }) + + expect( + screen + .getByTestId("templates-library-row-t-204") + .getAttribute("data-active"), + ).toBe("true") + + // One more ArrowDown should NOT wrap back to the first row. + act(() => { + fireEvent.keyDown(searchInput, { key: "ArrowDown" }) + }) + + expect( + screen + .getByTestId("templates-library-row-t-204") + .getAttribute("data-active"), + ).toBe("true") + }) + + it("loads the selected template on Enter in the library", async () => { + const now = Date.now() + const t1 = makeTemplate({ + id: "t-1", + name: "Template 1", + updatedAt: now, + createdAt: now, + createdBy: { userId: "u-1", name: "Ada", email: "ada@ex.com" }, + }) + const t2 = makeTemplate({ + id: "t-2", + name: "Template 2", + updatedAt: now - 1, + createdAt: now - 1, + createdBy: { userId: "u-2", name: "Grace", email: "grace@ex.com" }, + }) + const templates = [t1, t2] + + useEditorStore.setState({ + isPristine: true, + templateId: null, + templateName: "New template", + transformations: [], + syncStatus: "saved", + localChangeVersion: 1, + lastSyncedVersion: 1, + } as unknown as Parameters[0]) + + renderWithProvider({ templates }) + + expect(await screen.findByText("All templates")).toBeTruthy() + const searchInput = await screen.findByPlaceholderText( + "Search templates...", + ) + + // Navigate to first template with ArrowDown + act(() => { + fireEvent.keyDown(searchInput, { key: "ArrowDown" }) + }) + + expect( + screen + .getByTestId("templates-library-row-t-1") + .getAttribute("data-active"), + ).toBe("true") + + // Press Enter to load it + act(() => { + fireEvent.keyDown(searchInput, { key: "Enter" }) + }) + + // Verify the template was loaded into the store + expect(useEditorStore.getState().templateId).toBe("t-1") + expect(useEditorStore.getState().templateName).toBe("Template 1") + }) +}) 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..5256aa3 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx @@ -0,0 +1,905 @@ +import { + Avatar, + Badge, + Box, + Button, + Divider, + Flex, + Icon, + Input, + InputGroup, + InputLeftElement, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Spinner, + Text, + Tooltip, +} from "@chakra-ui/react" +import { PiArrowLeft } from "@react-icons/all-files/pi/PiArrowLeft" +import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown" +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 { 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 { useVirtualizer } from "@tanstack/react-virtual" +import humanDate from "human-date" +import type React from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useTemplatePermissions } from "../../context/TemplatePermissionsContext" +import { useTemplateStorage } from "../../context/TemplateStorageContext" +import { useDebounce } from "../../hooks/useDebounce" +import type { TemplateRecord } from "../../storage" +import { useEditorStore } from "../../store" +import { + chakraAny, + formatTemplateNameForUI, + getDisplayTemplates, + truncateTemplateName, +} from "../../utils" +import FilterChipsField from "../common/FilterChipsField" +import MultiSelectListField from "../common/MultiSelectListField" +import { SettingsModal } from "../header/SettingsModal" + +interface Props { + onClose(): void +} + +const FlexAny = chakraAny(Flex) +const TextAny = chakraAny(Text) +const AvatarAny = chakraAny(Avatar) +const ButtonAny = chakraAny(Button) +const SpinnerAny = chakraAny(Spinner) +const BadgeAny = chakraAny(Badge) +const InputGroupAny = chakraAny(InputGroup) +const InputLeftElementAny = chakraAny(InputLeftElement) +const InputAny = chakraAny(Input) +const TooltipAny = chakraAny(Tooltip) +const IconAny = chakraAny(Icon) +const PopoverContentAny = chakraAny(PopoverContent) +const PopoverBodyAny = chakraAny(PopoverBody) +const DividerAny = chakraAny(Divider) + +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 [isSettingsOpen, setIsSettingsOpen] = useState(false) + const [settingsRecord, setSettingsRecord] = useState( + null, + ) + const [activeVirtualIndex, setActiveVirtualIndex] = useState( + null, + ) + // Used to make keyboard navigation deterministic even under batched updates + // (e.g. rapid key presses or tight loops in tests). + const activeVirtualIndexRef = useRef(null) + + const { loadTemplate, resetToNewTemplate, hydrateTemplateMetadata } = + useEditorStore() + const templateId = useEditorStore((s) => s.templateId) + const templateName = useEditorStore((s) => s.templateName) + const isPristine = useEditorStore((s) => s.isPristine) + const hasUnsyncedChanges = useEditorStore( + (s) => s.localChangeVersion !== s.lastSyncedVersion, + ) + + 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 scrollParentRef = useRef(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 = getDisplayTemplates({ + templates, + templateId, + templateName, + shouldShowCurrent, + search, + searchMode: "nameOrCreator", + }) + .filter((t) => { + if (visibilityFilter.length === 0) return true + const allowPrivate = visibilityFilter.includes("private") + const allowShared = visibilityFilter.includes("shared") + // If both are selected, visibility is effectively unfiltered (OR across both buckets). + if (allowPrivate && allowShared) return true + if (allowPrivate) return t.isPrivate + if (allowShared) return !t.isPrivate + return true + }) + .filter((t) => + creatorFilter.length > 0 + ? creatorFilter.includes(t.createdBy.userId) + : true, + ) + + // getDisplayTemplates already returns a pinned+recent sorted list. + return base + }, [ + templates, + templateId, + templateName, + search, + visibilityFilter, + creatorFilter, + shouldShowCurrent, + ]) + + const handleSelect = (record: TemplateRecord) => { + if (!hasUnsyncedChanges) { + loadTemplate(record.transformations) + hydrateTemplateMetadata({ + templateId: record.id, + templateName: record.name, + templateIsPrivate: record.isPrivate, + }) + onClose() + } + } + + const handleTogglePin = async (record: TemplateRecord) => { + if (!provider) return + + try { + setPinningId(record.id) + const updated = await provider.setTemplatePinned( + record.id, + !record.isPinned, + ) + + 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)) + } + } + + const deleteTemplateAndCleanup = useCallback( + async (record: TemplateRecord) => { + if (!provider) return + if (!provider.deleteTemplate) return + + await provider.deleteTemplate(record.id) + setTemplates((prev) => prev.filter((t) => t.id !== record.id)) + + if (record.id === useEditorStore.getState().templateId) { + resetToNewTemplate() + } + }, + [provider, resetToNewTemplate], + ) + + const handleOpenSettings = useCallback((record: TemplateRecord) => { + setSettingsRecord(record) + setIsSettingsOpen(true) + }, []) + + const showCurrentRow = shouldShowCurrent && activeTemplate !== null + + const virtualRowCount = showCurrentRow ? filtered.length + 1 : filtered.length + + const getRowByVirtualIndex = useCallback( + (virtualIndex: number): { record: TemplateRecord; isCurrent: boolean } => { + if (showCurrentRow) { + if (virtualIndex === 0) { + // biome-ignore lint/style/noNonNullAssertion: guarded by showCurrentRow + return { record: activeTemplate!, isCurrent: true } + } + return { record: filtered[virtualIndex - 1], isCurrent: false } + } + + return { record: filtered[virtualIndex], isCurrent: false } + }, + [filtered, showCurrentRow, activeTemplate], + ) + + const rowVirtualizer = useVirtualizer({ + count: virtualRowCount, + getScrollElement: () => scrollParentRef.current, + estimateSize: () => 84, + overscan: 10, + getItemKey: (index: number) => { + if (showCurrentRow && index === 0) { + return `current:${activeTemplate?.id ?? "unknown"}` + } + const row = getRowByVirtualIndex(index) + return row.record.id + }, + }) + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset active row on filter/search changes + useEffect(() => { + setActiveVirtualIndex(null) + activeVirtualIndexRef.current = null + }, [search, visibilityFilter, creatorFilter, templates.length, templateId]) + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (virtualRowCount === 0) return + + if (e.key === "Enter") { + e.preventDefault() + const activeIndex = activeVirtualIndexRef.current + if (activeIndex === null) return + + const { record } = getRowByVirtualIndex(activeIndex) + handleSelect(record) + return + } + + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return + e.preventDefault() + + // Avoid wrap-around (cyclic navigation) for very large lists. When the + // virtualized list is big, wrapping makes it too easy to "lose" your place. + const shouldCycle = virtualRowCount <= 200 + const current = activeVirtualIndexRef.current ?? -1 + const next = shouldCycle + ? e.key === "ArrowDown" + ? (current + 1 + virtualRowCount) % virtualRowCount + : (current - 1 + virtualRowCount) % virtualRowCount + : e.key === "ArrowDown" + ? Math.min(current + 1, virtualRowCount - 1) + : Math.max(current - 1, 0) + + activeVirtualIndexRef.current = next + setActiveVirtualIndex(next) + rowVirtualizer.scrollToIndex(next, { align: "auto" }) + } + + return ( + + {/* Static top section: title, filters */} + + + } + color="editorBattleshipGrey.500" + _hover={{ color: "editorBattleshipGrey.700", bg: "transparent" }} + px="0" + > + Go back + + + + + + All templates + + + Browse and load templates created by you or shared with you. + + + } + px="4" + onClick={() => { + resetToNewTemplate() + onClose() + }} + > + New template + + + + {/* Controls bar */} + + + + + + ) => + setSearchInput(e.target.value) + } + onKeyDown={handleSearchKeyDown} + 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 + /> + + setCreatorFilter([])} + > + Clear selected + + + + + + + + + {/* Scrollable table area */} + + + {loading ? ( + + + + ) : ( + <> + {/* Table header */} + + {/* Pin column spacer to align with row */} + + + Name + + + Created by + + + Visibility + + + Last updated + + + + + {/* Filtered templates */} + {filtered.length === 0 && !showCurrentRow ? ( + + + {search || + visibilityFilter.length > 0 || + creatorFilter.length > 0 + ? "No templates match your filters" + : shouldShowCurrent + ? "No other saved templates" + : "No saved templates yet"} + + + ) : ( + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const { record, isCurrent } = getRowByVirtualIndex( + virtualRow.index, + ) + const isActive = activeVirtualIndex === virtualRow.index + + return ( + + { + // Current row is informational; selecting it is a no-op. + } + : handleSelect + } + onTogglePin={handleTogglePin} + isPinning={pinningId === record.id} + onDelete={deleteTemplateAndCleanup} + onSettings={handleOpenSettings} + isCurrent={isCurrent} + isActive={isActive} + /> + + ) + })} + + )} + + )} + + + {isSettingsOpen && settingsRecord && ( + { + setIsSettingsOpen(false) + setSettingsRecord(null) + }} + onSaved={(updated) => { + // Refresh the template list so the updated name/visibility is reflected + setTemplates((prev) => + prev.map((t) => (t.id === updated.id ? updated : t)), + ) + }} + onDeleteRequested={async () => { + await deleteTemplateAndCleanup(settingsRecord) + }} + /> + )} + + ) +} + +interface TemplateRowProps { + record: TemplateRecord + onSelect(record: TemplateRecord): void + onTogglePin(record: TemplateRecord): void + onDelete(record: TemplateRecord): void + onSettings(record: TemplateRecord): void + isPinning: boolean + isCurrent?: boolean + isActive?: boolean +} + +function TemplateRow({ + record, + onSelect, + onTogglePin, + onDelete, + onSettings, + isPinning, + isCurrent = false, + isActive = false, +}: TemplateRowProps) { + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const permissions = useTemplatePermissions(record) + const recordNameUI = formatTemplateNameForUI(record.name) + return ( + { + if (!isCurrent) onSelect(record) + }} + > + {/* Pin */} + + {permissions.pin && ( + ) => { + e.stopPropagation() + onTogglePin(record) + }} + > + {isPinning ? ( + + ) : ( + + )} + + )} + + + {/* Name + transform count */} + + + + + {truncateTemplateName(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: Settings button + delete confirmation popup */} + setShowDeleteConfirm(false)} + placement="bottom-end" + closeOnBlur + > + + e.stopPropagation()} + > + + ) => { + e.stopPropagation() + onSettings(record) + }} + aria-label="Template Settings" + > + + + + + + {permissions.delete && ( + ) => e.stopPropagation()} + > + + + Are you sure you want to delete this template? This action is + irreversible. + + + setShowDeleteConfirm(false)} + color="editorBattleshipGrey.500" + _hover={{ + color: "editorBattleshipGrey.800", + bg: "editorGray.50", + }} + > + Cancel + + } + onClick={() => { + setShowDeleteConfirm(false) + onDelete(record) + }} + > + Delete + + + + + )} + + + ) +} diff --git a/packages/imagekit-editor-dev/src/context/TemplatePermissionsContext.tsx b/packages/imagekit-editor-dev/src/context/TemplatePermissionsContext.tsx new file mode 100644 index 0000000..7f6d6bb --- /dev/null +++ b/packages/imagekit-editor-dev/src/context/TemplatePermissionsContext.tsx @@ -0,0 +1,152 @@ +import type React from "react" +import { createContext, useContext, useMemo } from "react" +import type { TemplateRecord } from "../storage" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Raw permission buckets returned by the host-supplied `getTemplatePermissions` + * callback. Each bucket maps to one or more editor actions. + * + * - `create` – can create new templates + * - `view` – template is visible in lists / can be loaded + * - `manage` – can rename, save transforms, open settings + * - `changeVisibility` – can toggle isPrivate (creator-only in most hosts) + * - `delete` – can delete the template + * - `pin` – can pin / unpin the template + * - `reason` – optional human-readable denial message per bucket + */ +export type TemplatePermissionBuckets = { + create: boolean + view: boolean + manage: boolean + changeVisibility: boolean + delete: boolean + pin: boolean + reason?: Partial< + Record< + "create" | "view" | "manage" | "changeVisibility" | "delete" | "pin", + string | undefined + > + > +} + +/** + * Granular action-level permissions derived from `TemplatePermissionBuckets`. + * Components consume this for conditional rendering. + */ +export type TemplatePermissions = { + create: boolean + rename: boolean + changeVisibility: boolean + save: boolean + delete: boolean + pin: boolean +} + +/** + * Host-supplied callback: given a template record, return its permission buckets. + * If omitted, all actions are allowed (open / anonymous usage). + */ +export type GetTemplatePermissions = ( + template: TemplateRecord, +) => TemplatePermissionBuckets + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +const ALLOW_ALL: TemplatePermissionBuckets = { + create: true, + view: true, + manage: true, + changeVisibility: true, + delete: true, + pin: true, +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +const TemplatePermissionsContext = createContext( + null, +) + +export function TemplatePermissionsContextProvider({ + getTemplatePermissions, + children, +}: { + getTemplatePermissions?: GetTemplatePermissions + children: React.ReactNode +}) { + return ( + + {children} + + ) +} + +export function useGetTemplatePermissions(): GetTemplatePermissions | null { + return useContext(TemplatePermissionsContext) +} + +// --------------------------------------------------------------------------- +// Resolution helpers +// --------------------------------------------------------------------------- + +export function resolveTemplatePermissionBuckets(args: { + template: TemplateRecord | null + getTemplatePermissions: GetTemplatePermissions | null +}): TemplatePermissionBuckets { + const { template, getTemplatePermissions } = args + + if (!template || !getTemplatePermissions) return ALLOW_ALL + + return getTemplatePermissions(template) +} + +export function resolveTemplatePermissions(args: { + template: TemplateRecord | null + getTemplatePermissions: GetTemplatePermissions | null +}): TemplatePermissions { + const b = resolveTemplatePermissionBuckets(args) + + return { + create: b.create, + rename: b.manage, + changeVisibility: b.changeVisibility, + save: b.manage, + delete: b.delete, + pin: b.pin, + } +} + +// --------------------------------------------------------------------------- +// Hooks +// --------------------------------------------------------------------------- + +export function useTemplatePermissions( + template: TemplateRecord | null, +): TemplatePermissions { + const getTemplatePermissions = useGetTemplatePermissions() + + return useMemo( + () => resolveTemplatePermissions({ template, getTemplatePermissions }), + [template, getTemplatePermissions], + ) +} + +export function useTemplatePermissionBuckets( + template: TemplateRecord | null, +): TemplatePermissionBuckets { + const getTemplatePermissions = useGetTemplatePermissions() + + return useMemo( + () => + resolveTemplatePermissionBuckets({ template, getTemplatePermissions }), + [template, getTemplatePermissions], + ) +} 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..f4f156f --- /dev/null +++ b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts @@ -0,0 +1,97 @@ +import { useEffect, useRef } from "react" +import { useTemplateStorage } from "../context/TemplateStorageContext" +import { useEditorStore } from "../store" +import { useTemplateSync } from "./useTemplateSync" + +export const DEBOUNCE_MS = 600 +export const INTERVAL_SAVE_MS = 2 * 60 * 1000 + +/** + * Automatically persists template *metadata* (name / visibility) to the storage + * provider. Transformations are intentionally NOT auto-saved; they are kept as + * local unsynced changes until the user explicitly saves (Cmd/Ctrl+S or save UI). + * + * 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) + const intervalTimerRef = useRef | null>(null) + const { saveNow } = useTemplateSync() + + useEffect(() => { + if (!provider) return + + const scheduleNextInterval = (ms = INTERVAL_SAVE_MS) => { + if (intervalTimerRef.current) clearTimeout(intervalTimerRef.current) + intervalTimerRef.current = setTimeout(() => { + const state = useEditorStore.getState() + if (state.templateStorageWriteBlocked || state.isPristine) { + scheduleNextInterval() + return + } + if (state.localChangeVersion === state.lastSyncedVersion) { + scheduleNextInterval() + return + } + void saveNow({ reason: "auto_interval" }).finally(() => { + scheduleNextInterval() + }) + }, ms) + } + + // Start periodic auto-save loop (resets on successful saves). + scheduleNextInterval() + + const unsubscribe = useEditorStore.subscribe( + (state) => ({ + templateName: state.templateName, + templateIsPrivate: state.templateIsPrivate, + }), + () => { + if (timerRef.current) clearTimeout(timerRef.current) + + timerRef.current = setTimeout(() => { + void saveNow({ reason: "auto_metadata" }).finally(() => { + // Reset the 2-minute timer after a save attempt. + scheduleNextInterval() + }) + }, DEBOUNCE_MS) + }, + { + equalityFn: (a, b) => + a.templateName === b.templateName && + a.templateIsPrivate === b.templateIsPrivate, + }, + ) + + const unsubscribeLastSaved = useEditorStore.subscribe( + (s) => s.lastSavedAt, + (ts) => { + if (!ts) return + scheduleNextInterval() + }, + ) + + return () => { + unsubscribe() + unsubscribeLastSaved() + if (timerRef.current) clearTimeout(timerRef.current) + if (intervalTimerRef.current) clearTimeout(intervalTimerRef.current) + } + }, [provider, saveNow]) +} 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..8c31f9b --- /dev/null +++ b/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts @@ -0,0 +1,25 @@ +import { useCallback, useEffect } from "react" +import { useTemplateStorage } from "../context/TemplateStorageContext" +import { useTemplateSync } from "./useTemplateSync" + +export function useSaveTemplate() { + const provider = useTemplateStorage() + const { saveNow } = useTemplateSync() + const save = useCallback(() => saveNow({ reason: "manual" }), [saveNow]) + + useEffect(() => { + if (!provider) return + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault() + void save() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [provider, save]) + + return { save } +} diff --git a/packages/imagekit-editor-dev/src/hooks/useTemplateSync.ts b/packages/imagekit-editor-dev/src/hooks/useTemplateSync.ts new file mode 100644 index 0000000..3c53922 --- /dev/null +++ b/packages/imagekit-editor-dev/src/hooks/useTemplateSync.ts @@ -0,0 +1,100 @@ +import { useCallback, useRef } from "react" +import { useTemplateStorage } from "../context/TemplateStorageContext" +import type { SaveTemplateInput, TemplateRecord } from "../storage" +import { isTemplateAccessDeniedError } from "../storage/templateAccessError" +import { useEditorStore } from "../store" +import { shouldMarkSyncedAfterSave } from "../sync/templateSyncVersioning" + +export type SaveReason = + | "manual" + | "auto_metadata" + | "auto_interval" + | "sidebar" + | "settings" + | "imperative" + +export function useTemplateSync() { + const provider = useTemplateStorage() + const savingRef = useRef(false) + + const saveNow = useCallback( + async ( + args: { + reason: SaveReason + overrides?: Partial> + } = { reason: "manual" }, + ): Promise => { + if (!provider) return null + if (savingRef.current) return null + + const state = useEditorStore.getState() + if (state.templateStorageWriteBlocked) return null + + const saveStartedAtVersion = state.localChangeVersion + savingRef.current = true + state.setSyncStatus("saving") + + try { + const record = await provider.saveTemplate({ + id: state.templateId ?? undefined, + name: args.overrides?.name ?? state.templateName, + transformations: state.transformations.map( + ({ id: _id, ...rest }) => rest, + ), + ...(args.overrides?.isPrivate !== undefined + ? { isPrivate: args.overrides.isPrivate } + : state.templateIsPrivate !== null + ? { isPrivate: state.templateIsPrivate } + : {}), + }) + + const after = useEditorStore.getState() + after.hydrateTemplateMetadata({ + templateId: record.id, + templateName: record.name, + templateIsPrivate: + typeof record.isPrivate === "boolean" ? record.isPrivate : null, + }) + + const markSynced = shouldMarkSyncedAfterSave({ + saveStartedAtVersion, + localChangeVersionAtCompletion: after.localChangeVersion, + }) + + if (markSynced) { + after.markSynced(saveStartedAtVersion) + after.setSyncStatus("saved") + } else { + // Changes happened mid-save; do not claim we are fully synced. + after.setSyncStatus("unsaved") + } + after.setLastSavedAt(Date.now()) + + return record + } catch (err) { + if (isTemplateAccessDeniedError(err)) { + useEditorStore + .getState() + .blockTemplateStorageWrites( + err instanceof Error + ? err.message + : "You no longer have access to this template.", + ) + return null + } + useEditorStore + .getState() + .setSyncStatus( + "error", + err instanceof Error ? err.message : "Failed to save template", + ) + return null + } finally { + savingRef.current = false + } + }, + [provider], + ) + + return { saveNow, hasProvider: Boolean(provider) } +} diff --git a/packages/imagekit-editor-dev/src/index.tsx b/packages/imagekit-editor-dev/src/index.tsx index 5d74dfd..d1f45a2 100644 --- a/packages/imagekit-editor-dev/src/index.tsx +++ b/packages/imagekit-editor-dev/src/index.tsx @@ -1,4 +1,22 @@ +export type { + GetTemplatePermissions, + TemplatePermissionBuckets, + TemplatePermissions, +} from "./context/TemplatePermissionsContext" export type { ImageKitEditorProps, ImageKitEditorRef } from "./ImageKitEditor" export { ImageKitEditor } from "./ImageKitEditor" export { DEFAULT_FOCUS_OBJECTS } from "./schema" -export type { FileElement, Signer } from "./store" +export type { + SaveTemplateInput, + TemplateRecord, + TemplateStorageHttpClient, + TemplateStorageProvider, +} from "./storage" +export { + applyTemplateStorageAccessFailure, + isTemplateAccessDeniedError, + normalizeTransformationStepsForPersistence, + TemplateAccessDeniedError, +} 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..212e8f2 --- /dev/null +++ b/packages/imagekit-editor-dev/src/storage/index.ts @@ -0,0 +1,13 @@ +export { normalizeTransformationStepsForPersistence } from "./serializeTransformations" +export { + applyTemplateStorageAccessFailure, + isTemplateAccessDeniedError, + TemplateAccessDeniedError, +} from "./templateAccessError" +export type { + SaveTemplateInput, + TemplateCreator, + TemplateRecord, + TemplateStorageHttpClient, + TemplateStorageProvider, +} from "./types" diff --git a/packages/imagekit-editor-dev/src/storage/serializeTransformations.ts b/packages/imagekit-editor-dev/src/storage/serializeTransformations.ts new file mode 100644 index 0000000..62fff6a --- /dev/null +++ b/packages/imagekit-editor-dev/src/storage/serializeTransformations.ts @@ -0,0 +1,12 @@ +import { TRANSFORMATION_STATE_VERSION } from "../store" +import type { SaveTemplateInput } from "./types" + +/** Ensures each step has `version: "v1"` for API / persistence validators. */ +export function normalizeTransformationStepsForPersistence( + transformations: SaveTemplateInput["transformations"], +): SaveTemplateInput["transformations"] { + return transformations.map((step) => ({ + ...step, + version: step.version ?? TRANSFORMATION_STATE_VERSION, + })) +} diff --git a/packages/imagekit-editor-dev/src/storage/templateAccessError.ts b/packages/imagekit-editor-dev/src/storage/templateAccessError.ts new file mode 100644 index 0000000..0cd1609 --- /dev/null +++ b/packages/imagekit-editor-dev/src/storage/templateAccessError.ts @@ -0,0 +1,48 @@ +/** + * Thrown by host {@link TemplateStorageProvider} implementations when the + * backend responds with 401/403 (e.g. template made private or access revoked). + * Dashboard / API clients should `throw new TemplateAccessDeniedError(...)` or + * attach `status: 401 | 403` to the rejected error so the editor can reset UI. + */ +export class TemplateAccessDeniedError extends Error { + readonly status: number + + constructor(message?: string, status: number = 403) { + super(message ?? "You no longer have access to this template.") + this.name = "TemplateAccessDeniedError" + this.status = status + } +} + +export function isTemplateAccessDeniedError(err: unknown): boolean { + if (err instanceof TemplateAccessDeniedError) { + return true + } + if (err && typeof err === "object" && "status" in err) { + const s = (err as { status: unknown }).status + if (s === 401 || s === 403) { + return true + } + } + return false +} + +export type TemplateStorageFailureActions = { + denyTemplateStorageAccessAndReset: (message?: string) => void +} + +/** Clears the loaded template and surfaces an error when access was revoked. */ +export function applyTemplateStorageAccessFailure( + err: unknown, + actions: TemplateStorageFailureActions, +): boolean { + if (!isTemplateAccessDeniedError(err)) { + return false + } + const message = + err instanceof TemplateAccessDeniedError + ? err.message + : "You no longer have access to this template." + actions.denyTemplateStorageAccessAndReset(message) + return true +} 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..e6dc3e5 --- /dev/null +++ b/packages/imagekit-editor-dev/src/storage/types.ts @@ -0,0 +1,60 @@ +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[] + /** Whether the active user has this template pinned. */ + isPinned: boolean + createdBy: TemplateCreator + updatedBy: TemplateCreator + createdAt: number + updatedAt: number + lastUsedAt?: number +} + +export type SaveTemplateInput = { + id?: string + name: string + transformations: Omit[] + clientNumber?: string + isPrivate?: boolean + isPinned?: boolean + 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 + setTemplatePinned(id: string, isPinned: boolean): Promise + getProviderName(): string + getCurrentUserSession(): unknown +} + +/** + * Minimal HTTP surface for host-implemented template storage (e.g. dashboard `use-http` request). + */ +export interface TemplateStorageHttpClient { + get(path: string): Promise + post(path: string, body?: unknown): Promise + patch(path: string, body?: unknown): Promise + put(path: string, body?: unknown): Promise + delete(path: string): Promise +} diff --git a/packages/imagekit-editor-dev/src/store.ts b/packages/imagekit-editor-dev/src/store.ts index b0cdd83..3d4e2fb 100644 --- a/packages/imagekit-editor-dev/src/store.ts +++ b/packages/imagekit-editor-dev/src/store.ts @@ -13,14 +13,20 @@ import { transformationFormatters, transformationSchema, } from "./schema" +import { bumpLocalChangeVersion as bumpVersion } from "./sync/templateSyncVersioning" 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 +75,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 +93,37 @@ export interface EditorState< currentTransformKey: string focusObjects?: ReadonlyArray _internalState: InternalState + templateName: string + templateId: string | null + /** + * Template visibility scope. For dashboard integration this maps to: + * - true => onlyMe (private) + * - false => everyone (shared) + * - null => unknown/unloaded + */ + templateIsPrivate: boolean | null + syncStatus: SyncStatus + storageError?: string + isPristine: boolean + /** + * After a 401/403 template write failure, saves are blocked so a follow-up + * save cannot POST a duplicate after the store clears `templateId`. + */ + templateStorageWriteBlocked: boolean + + /** Versioned sync model to keep UI stable under save/edit races. */ + localChangeVersion: number + lastSyncedVersion: number + /** + * Timestamp (ms) of the last successful save to remote storage. + * Used to debounce/reset periodic auto-save scheduling. + */ + lastSavedAt: number | null + /** + * True while the transformation config sidebar form has unapplied edits (RHF isDirty). + * Used by header status and close confirmation alongside versioned unsynced state. + */ + transformationConfigFormDirty: boolean } export type EditorActions< @@ -94,6 +133,8 @@ export type EditorActions< imageList?: Array> signer?: Signer focusObjects?: ReadonlyArray + templateName?: string + templateId?: string }) => void destroy: () => void setCurrentImage: (imageSrc: string | undefined) => void @@ -104,7 +145,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 +161,36 @@ export type EditorActions< updatedTransformation: Omit, ) => void setShowOriginal: (showOriginal: boolean) => void + setTemplateName: (name: string) => void + setTemplateId: (id: string | null) => void + setTemplateIsPrivate: (isPrivate: boolean | null) => void + /** + * Sets template metadata from storage responses without bumping local version. + * Use this when hydrating from server/list responses (save success, load from library). + */ + hydrateTemplateMetadata: (meta: { + templateId: string | null + templateName: string + templateIsPrivate: boolean | null + }) => void + setSyncStatus: (status: SyncStatus, error?: string) => void + setIsPristine: (pristine: boolean) => void + bumpLocalChangeVersion: () => void + markSynced: (version?: number) => void + setLastSavedAt: (ts: number | null) => void + setTransformationConfigFormDirty: (dirty: boolean) => void + resetToNewTemplate: () => void + /** + * Blocks any further writes to template storage while keeping the current + * template state intact (so the user can keep viewing/editing locally). + * Intended for 401/403 write failures. + */ + blockTemplateStorageWrites: (message?: string) => void + /** + * Clears the loaded template and surfaces an error when access is revoked + * for viewing/loading the template. + */ + denyTemplateStorageAccessAndReset: (message?: string) => void _setSidebarState: (state: "none" | "type" | "config") => void _setSelectedTransformationKey: (key: string | null) => void @@ -181,6 +252,17 @@ const DEFAULT_STATE: EditorState = { selectedTransformationKey: null, transformationToEdit: null, }, + templateName: "Untitled Template", + templateId: null, + templateIsPrivate: null, + syncStatus: "unsaved", + storageError: undefined, + isPristine: true, + templateStorageWriteBlocked: false, + localChangeVersion: 0, + lastSyncedVersion: 0, + lastSavedAt: null, + transformationConfigFormDirty: false, } const useEditorStore = create()( @@ -201,6 +283,20 @@ 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 host provides a template id/name, assume we're starting from a synced template. + if (initialData?.templateId || initialData?.templateName) { + updates.syncStatus = "saved" + updates.localChangeVersion = 0 + updates.lastSyncedVersion = 0 + } if (Object.keys(updates).length > 0) { set(updates as EditorState) } @@ -302,12 +398,41 @@ 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, })) - set({ transformations: transformationsWithIds }) + + const visibleTransformations: Record = {} + transformationsWithIds.forEach((t) => { + // enabled absent or true → visible; false → hidden + visibleTransformations[t.id] = t.enabled !== false + }) + + set((state) => { + const nextVersion = bumpVersion(state.localChangeVersion) + return { + transformations: transformationsWithIds, + visibleTransformations: { + ...state.visibleTransformations, + ...visibleTransformations, + }, + _internalState: { + sidebarState: "none", + selectedTransformationKey: null, + transformationToEdit: null, + }, + isPristine: false, + // Loading an existing template implies we're in sync with storage. + syncStatus: "saved", + localChangeVersion: nextVersion, + lastSyncedVersion: nextVersion, + templateStorageWriteBlocked: false, + transformationConfigFormDirty: false, + } + }) }, moveTransformation: (activeId, overId) => { @@ -322,24 +447,38 @@ 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, + localChangeVersion: bumpVersion(state.localChangeVersion), + } } 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, + localChangeVersion: bumpVersion(state.localChangeVersion), + } + }) }, addTransformation: (transformation, position) => { @@ -355,6 +494,8 @@ const useEditorStore = create()( ...state.visibleTransformations, [id]: true, }, + isPristine: false, + localChangeVersion: bumpVersion(state.localChangeVersion), } }) @@ -371,6 +512,8 @@ const useEditorStore = create()( ...state.visibleTransformations, [id]: true, }, + isPristine: false, + localChangeVersion: bumpVersion(state.localChangeVersion), } }) @@ -382,6 +525,8 @@ const useEditorStore = create()( transformations: state.transformations.filter( (transformation) => transformation.id !== id, ), + isPristine: false, + localChangeVersion: bumpVersion(state.localChangeVersion), })) }, @@ -393,6 +538,8 @@ const useEditorStore = create()( transformations: state.transformations.map((t) => t.id === id ? { ...updatedTransformation, id } : t, ), + isPristine: false, + localChangeVersion: bumpVersion(state.localChangeVersion), })) }, @@ -402,6 +549,125 @@ const useEditorStore = create()( })) }, + setTemplateName: (name) => { + set((state) => ({ + templateName: name, + isPristine: state.templateName === name ? state.isPristine : false, + localChangeVersion: + state.templateName === name + ? state.localChangeVersion + : bumpVersion(state.localChangeVersion), + })) + }, + + setTemplateId: (id) => { + set({ templateId: id }) + }, + + setTemplateIsPrivate: (isPrivate) => { + set((state) => ({ + templateIsPrivate: isPrivate, + localChangeVersion: + state.templateIsPrivate === isPrivate + ? state.localChangeVersion + : bumpVersion(state.localChangeVersion), + })) + }, + + hydrateTemplateMetadata: ({ + templateId, + templateName, + templateIsPrivate, + }) => { + set(() => ({ + templateId, + templateName, + templateIsPrivate, + })) + }, + + setSyncStatus: (status, error?) => { + set({ syncStatus: status, storageError: error }) + }, + + bumpLocalChangeVersion: () => { + set((state) => ({ + localChangeVersion: bumpVersion(state.localChangeVersion), + })) + }, + + markSynced: (version) => { + set((state) => ({ + lastSyncedVersion: version ?? state.localChangeVersion, + })) + }, + + setLastSavedAt: (ts) => { + set({ lastSavedAt: ts }) + }, + + setTransformationConfigFormDirty: (dirty) => { + set({ transformationConfigFormDirty: dirty }) + }, + + setIsPristine: (pristine: boolean) => { + set({ isPristine: pristine }) + }, + + resetToNewTemplate: () => { + set({ + transformations: [], + visibleTransformations: {}, + templateName: "Untitled Template", + templateId: null, + templateIsPrivate: null, + syncStatus: "unsaved", + storageError: undefined, + isPristine: true, + templateStorageWriteBlocked: false, + localChangeVersion: 0, + lastSyncedVersion: 0, + lastSavedAt: null, + transformationConfigFormDirty: false, + _internalState: { + sidebarState: "none", + selectedTransformationKey: null, + transformationToEdit: null, + }, + }) + }, + + blockTemplateStorageWrites: (message) => { + set({ + syncStatus: "error", + storageError: message ?? "You no longer have access to this template.", + templateStorageWriteBlocked: true, + }) + }, + + denyTemplateStorageAccessAndReset: (message) => { + set({ + transformations: [], + visibleTransformations: {}, + templateName: "Untitled Template", + templateId: null, + templateIsPrivate: null, + syncStatus: "error", + storageError: message ?? "You no longer have access to this template.", + isPristine: true, + templateStorageWriteBlocked: true, + localChangeVersion: 0, + lastSyncedVersion: 0, + lastSavedAt: null, + transformationConfigFormDirty: false, + _internalState: { + sidebarState: "none", + selectedTransformationKey: null, + transformationToEdit: null, + }, + }) + }, + _setSidebarState: (sidebarState) => { set((state) => ({ _internalState: { ...state._internalState, sidebarState }, diff --git a/packages/imagekit-editor-dev/src/sync/templateSyncVersioning.test.ts b/packages/imagekit-editor-dev/src/sync/templateSyncVersioning.test.ts new file mode 100644 index 0000000..aaa7a3f --- /dev/null +++ b/packages/imagekit-editor-dev/src/sync/templateSyncVersioning.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest" +import { + bumpLocalChangeVersion, + hasUnsyncedChanges, + shouldMarkSyncedAfterSave, +} from "./templateSyncVersioning" + +describe("templateSyncVersioning", () => { + it("detects unsynced changes via version mismatch", () => { + expect( + hasUnsyncedChanges({ localChangeVersion: 1, lastSyncedVersion: 1 }), + ).toBe(false) + expect( + hasUnsyncedChanges({ localChangeVersion: 2, lastSyncedVersion: 1 }), + ).toBe(true) + }) + + it("bumps local version monotonically", () => { + expect(bumpLocalChangeVersion(0)).toBe(1) + expect(bumpLocalChangeVersion(41)).toBe(42) + }) + + it("marks synced only when no changes happened during save", () => { + expect( + shouldMarkSyncedAfterSave({ + saveStartedAtVersion: 3, + localChangeVersionAtCompletion: 3, + }), + ).toBe(true) + + expect( + shouldMarkSyncedAfterSave({ + saveStartedAtVersion: 3, + localChangeVersionAtCompletion: 4, + }), + ).toBe(false) + }) +}) diff --git a/packages/imagekit-editor-dev/src/sync/templateSyncVersioning.ts b/packages/imagekit-editor-dev/src/sync/templateSyncVersioning.ts new file mode 100644 index 0000000..c1ceeaa --- /dev/null +++ b/packages/imagekit-editor-dev/src/sync/templateSyncVersioning.ts @@ -0,0 +1,21 @@ +export type TemplateSyncVersions = { + /** Monotonically increases whenever local committed state changes. */ + localChangeVersion: number + /** Latest version known to be persisted in the remote storage. */ + lastSyncedVersion: number +} + +export function hasUnsyncedChanges(v: TemplateSyncVersions): boolean { + return v.localChangeVersion !== v.lastSyncedVersion +} + +export function bumpLocalChangeVersion(prev: number): number { + return prev + 1 +} + +export function shouldMarkSyncedAfterSave(args: { + saveStartedAtVersion: number + localChangeVersionAtCompletion: number +}): boolean { + return args.saveStartedAtVersion === args.localChangeVersionAtCompletion +} diff --git a/packages/imagekit-editor-dev/src/theme.ts b/packages/imagekit-editor-dev/src/theme.ts index b5f4e2e..23d9caa 100644 --- a/packages/imagekit-editor-dev/src/theme.ts +++ b/packages/imagekit-editor-dev/src/theme.ts @@ -1,7 +1,25 @@ 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", + "50": "#f7fafc", "100": "#e2dfe5", "200": "#c7c5ca", "300": "#a7a5aa", @@ -24,6 +42,21 @@ export const themeOverrides = { "800": "#022664", "900": "#011332", }, + // Alias Chakra's default "blue" palette to our editor brand blue. + // This keeps MenuItem active/focus states, focus rings, and all `colorScheme="blue"` + // components visually consistent across the editor. + blue: { + "50": "#E6EFFF", + "100": "#B9D2FE", + "200": "#8CB5FD", + "300": "#5F98FC", + "400": "#327BFB", + "500": "#055EFA", + "600": "#044BC8", + "700": "#033896", + "800": "#022664", + "900": "#011332", + }, editorBlueyGrey: "#9da3ae", editorLightBlueGrey: "#3D85C6", editorBrick: "#d0312a", diff --git a/packages/imagekit-editor-dev/src/utils/chakraAny.ts b/packages/imagekit-editor-dev/src/utils/chakraAny.ts new file mode 100644 index 0000000..4464f3c --- /dev/null +++ b/packages/imagekit-editor-dev/src/utils/chakraAny.ts @@ -0,0 +1,11 @@ +import type React from "react" + +/** + * Chakra components sometimes have strict generic/polymorphic typings that can + * get in the way of our JSX usage (especially `as="button"` and certain prop + * combinations). This helper centralizes the escape hatch so individual + * components don't need repeated `as unknown as React.ElementType` boilerplate. + */ +export function chakraAny(component: unknown): React.ElementType { + return component as unknown as React.ElementType +} diff --git a/packages/imagekit-editor-dev/src/utils/index.ts b/packages/imagekit-editor-dev/src/utils/index.ts index caae86e..2f0f1af 100644 --- a/packages/imagekit-editor-dev/src/utils/index.ts +++ b/packages/imagekit-editor-dev/src/utils/index.ts @@ -1,6 +1,7 @@ export const __DEV__ = process.env.NODE_ENV !== "production" export const SIMPLE_OVERLAY_TEXT_REGEX = /^[a-zA-Z0-9-._ ]*$/ +export const TEMPLATE_NAME_UI_MAX_LENGTH = 30 export const safeBtoa = (str: string): string => { if (typeof window !== "undefined") { @@ -12,6 +13,54 @@ export const safeBtoa = (str: string): string => { } } +const decodeHtmlEntitiesOnce = (input: string): string => { + if (!input || !input.includes("&")) return input + + // Some names were saved with malformed entities missing the trailing semicolon, + // e.g. "&lt>" where the "lt" is missing ";". Normalize those first. + const normalized = input.replace(/&(amp|lt|gt|quot|apos|nbsp)(?!;)/g, "&$1;") + + // Decode a small, deterministic set of HTML entities + numeric references. + // Intentionally does NOT decode broader named entities like © to keep + // behavior stable across environments and avoid surprising transforms. + const named: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + } + + return normalized.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (m, g1) => { + if (typeof g1 !== "string") return m + if (g1.startsWith("#x") || g1.startsWith("#X")) { + const codePoint = Number.parseInt(g1.slice(2), 16) + return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : m + } + if (g1.startsWith("#")) { + const codePoint = Number.parseInt(g1.slice(1), 10) + return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : m + } + return named[g1] ?? m + }) +} + +/** + * Normalizes a template name for display in the UI. + * Handles cases where names were stored with HTML entities (sometimes double-encoded). + */ +export const formatTemplateNameForUI = (rawName: string): string => { + let s = rawName ?? "" + // Decode up to a few times to handle double-encoding like "&lt;" → "<" → "<". + for (let i = 0; i < 3; i++) { + const next = decodeHtmlEntitiesOnce(s) + if (next === s) break + s = next + } + return s +} + /** * Step validation without floating‑point error. * We scale both value and step to integers using their max decimal precision @@ -93,3 +142,18 @@ export const extractImagePath = (imageUrl: string): string => { return segments[0] || "" } } + +export const truncateTemplateName = (name: string) => { + const normalized = formatTemplateNameForUI(name) + if (normalized.length <= TEMPLATE_NAME_UI_MAX_LENGTH) { + return normalized + } + return `${normalized.slice(0, TEMPLATE_NAME_UI_MAX_LENGTH)}...` +} + +export { chakraAny } from "./chakraAny" +export { + getDisplayTemplates, + shouldHideTemplateBecauseMatchesUnsavedCurrent, + sortTemplatesPinnedThenRecent, +} from "./templateList" diff --git a/packages/imagekit-editor-dev/src/utils/templateList.test.ts b/packages/imagekit-editor-dev/src/utils/templateList.test.ts new file mode 100644 index 0000000..6e4af84 --- /dev/null +++ b/packages/imagekit-editor-dev/src/utils/templateList.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest" +import type { TemplateRecord } from "../storage" +import { + getDisplayTemplates, + shouldHideTemplateBecauseMatchesUnsavedCurrent, + sortTemplatesPinnedThenRecent, +} from "./templateList" + +function makeTemplate(partial: Partial): TemplateRecord { + const now = 1_000_000 + return { + id: "t-1", + clientNumber: "c1", + isPrivate: true, + name: "Template 1", + transformations: [], + isPinned: false, + createdBy: { userId: "u1", name: "Creator", email: "c@example.com" }, + updatedBy: { userId: "u1", name: "Creator", email: "c@example.com" }, + createdAt: now, + updatedAt: now, + ...partial, + } +} + +describe("templateList utilities", () => { + describe("shouldHideTemplateBecauseMatchesUnsavedCurrent", () => { + it("hides a record with same name when current is unsaved (templateId null) and Current row is shown", () => { + const record = makeTemplate({ id: "saved", name: "In progress" }) + expect( + shouldHideTemplateBecauseMatchesUnsavedCurrent({ + record, + templateId: null, + shouldShowCurrent: true, + templateName: "In progress", + }), + ).toBe(true) + }) + + it("does not hide when Current row is not shown", () => { + const record = makeTemplate({ id: "saved", name: "In progress" }) + expect( + shouldHideTemplateBecauseMatchesUnsavedCurrent({ + record, + templateId: null, + shouldShowCurrent: false, + templateName: "In progress", + }), + ).toBe(false) + }) + + it("does not hide when a saved template is active (templateId not null)", () => { + const record = makeTemplate({ id: "saved", name: "Same" }) + expect( + shouldHideTemplateBecauseMatchesUnsavedCurrent({ + record, + templateId: "active-id", + shouldShowCurrent: true, + templateName: "Same", + }), + ).toBe(false) + }) + }) + + describe("sortTemplatesPinnedThenRecent", () => { + it("sorts pinned before unpinned", () => { + const a = makeTemplate({ id: "a", isPinned: false, updatedAt: 5 }) + const b = makeTemplate({ id: "b", isPinned: true, updatedAt: 1 }) + const sorted = [a, b].sort(sortTemplatesPinnedThenRecent) + expect(sorted.map((t) => t.id)).toEqual(["b", "a"]) + }) + + it("sorts by lastUsedAt when present, else updatedAt", () => { + const olderButUsed = makeTemplate({ + id: "used", + isPinned: false, + updatedAt: 10, + lastUsedAt: 200, + }) + const newerButNotUsed = makeTemplate({ + id: "updated", + isPinned: false, + updatedAt: 300, + lastUsedAt: undefined, + }) + const sorted = [olderButUsed, newerButNotUsed].sort( + sortTemplatesPinnedThenRecent, + ) + expect(sorted.map((t) => t.id)).toEqual(["updated", "used"]) + }) + }) + + describe("getDisplayTemplates", () => { + it("excludes the active template by id", () => { + const t1 = makeTemplate({ id: "t1", name: "One", updatedAt: 1 }) + const t2 = makeTemplate({ id: "t2", name: "Two", updatedAt: 2 }) + const list = getDisplayTemplates({ + templates: [t1, t2], + templateId: "t2", + templateName: "Two", + shouldShowCurrent: true, + search: "", + }) + expect(list.map((t) => t.id)).toEqual(["t1"]) + }) + + it("hides the saved record that matches unsaved current name when templateId is null", () => { + const savedSameName = makeTemplate({ + id: "saved", + name: "In progress", + updatedAt: 2, + }) + const other = makeTemplate({ id: "other", name: "Other", updatedAt: 1 }) + const list = getDisplayTemplates({ + templates: [savedSameName, other], + templateId: null, + templateName: "In progress", + shouldShowCurrent: true, + search: "", + }) + expect(list.map((t) => t.id)).toEqual(["other"]) + }) + + it("filters by name search (case-insensitive) by default", () => { + const alpha = makeTemplate({ id: "a", name: "Alpha", updatedAt: 1 }) + const beta = makeTemplate({ id: "b", name: "Beta", updatedAt: 2 }) + const list = getDisplayTemplates({ + templates: [alpha, beta], + templateId: null, + templateName: "New", + shouldShowCurrent: false, + search: "alP", + }) + expect(list.map((t) => t.id)).toEqual(["a"]) + }) + + it('supports searchMode "nameOrCreator"', () => { + const byCreator = makeTemplate({ + id: "c", + name: "Unrelated", + createdBy: { userId: "u2", name: "Ada Lovelace", email: "ada@ex.com" }, + updatedAt: 1, + }) + const other = makeTemplate({ + id: "o", + name: "Other", + createdBy: { userId: "u3", name: "Grace", email: "g@ex.com" }, + updatedAt: 2, + }) + const list = getDisplayTemplates({ + templates: [byCreator, other], + templateId: null, + templateName: "New", + shouldShowCurrent: false, + search: "ada", + searchMode: "nameOrCreator", + }) + expect(list.map((t) => t.id)).toEqual(["c"]) + }) + + it("returns pinned+recent sorted results", () => { + const pinnedOld = makeTemplate({ + id: "p", + name: "Pinned", + isPinned: true, + updatedAt: 1, + }) + const unpinnedNew = makeTemplate({ + id: "u", + name: "Unpinned", + isPinned: false, + updatedAt: 999, + }) + const list = getDisplayTemplates({ + templates: [unpinnedNew, pinnedOld], + templateId: null, + templateName: "New", + shouldShowCurrent: false, + search: "", + }) + expect(list.map((t) => t.id)).toEqual(["p", "u"]) + }) + }) +}) diff --git a/packages/imagekit-editor-dev/src/utils/templateList.ts b/packages/imagekit-editor-dev/src/utils/templateList.ts new file mode 100644 index 0000000..02d61e6 --- /dev/null +++ b/packages/imagekit-editor-dev/src/utils/templateList.ts @@ -0,0 +1,80 @@ +import type { TemplateRecord } from "../storage" + +export function sortTemplatesPinnedThenRecent( + a: TemplateRecord, + b: TemplateRecord, +): number { + const aPinned = a.isPinned ? 1 : 0 + const bPinned = b.isPinned ? 1 : 0 + if (aPinned !== bPinned) return bPinned - aPinned + + const aTime = a.lastUsedAt ?? a.updatedAt + const bTime = b.lastUsedAt ?? b.updatedAt + return bTime - aTime +} + +export function shouldHideTemplateBecauseMatchesUnsavedCurrent(args: { + record: TemplateRecord + /** Current template id in the editor store. */ + templateId: string | null + /** Whether the UI should show the "Current" row (i.e. editor has live state). */ + shouldShowCurrent: boolean + /** Current template name in the editor store. */ + templateName: string +}): boolean { + const { record, templateId, shouldShowCurrent, templateName } = args + return ( + shouldShowCurrent && templateId === null && record.name === templateName + ) +} + +type SearchMode = "name" | "nameOrCreator" + +function matchesSearch( + record: TemplateRecord, + searchLower: string, + mode: SearchMode, +): boolean { + if (!searchLower) return true + if (record.name.toLowerCase().includes(searchLower)) return true + if (mode === "name") return false + return ( + record.createdBy.name.toLowerCase().includes(searchLower) || + record.createdBy.email.toLowerCase().includes(searchLower) + ) +} + +export function getDisplayTemplates(args: { + templates: TemplateRecord[] + templateId: string | null + templateName: string + shouldShowCurrent: boolean + search: string + searchMode?: SearchMode +}): TemplateRecord[] { + const { + templates, + templateId, + templateName, + shouldShowCurrent, + search, + searchMode = "name", + } = args + + const searchLower = search.toLowerCase() + + return templates + .filter((t) => t.id !== templateId) + .filter( + (t) => + !shouldHideTemplateBecauseMatchesUnsavedCurrent({ + record: t, + templateId, + shouldShowCurrent, + templateName, + }), + ) + .filter((t) => matchesSearch(t, searchLower, searchMode)) + .slice() + .sort(sortTemplatesPinnedThenRecent) +} diff --git a/packages/imagekit-editor-dev/src/utils/templateNameFormatting.test.ts b/packages/imagekit-editor-dev/src/utils/templateNameFormatting.test.ts new file mode 100644 index 0000000..e6374e2 --- /dev/null +++ b/packages/imagekit-editor-dev/src/utils/templateNameFormatting.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest" +import { formatTemplateNameForUI } from "./index" + +describe("formatTemplateNameForUI", () => { + it("returns plain names unchanged", () => { + expect(formatTemplateNameForUI("Untitled Template")).toBe( + "Untitled Template", + ) + expect(formatTemplateNameForUI("A & B")).toBe("A & B") + expect(formatTemplateNameForUI("Hello <>")).toBe("Hello <>") + }) + + it("decodes common HTML entities", () => { + expect(formatTemplateNameForUI("A & B")).toBe("A & B") + expect(formatTemplateNameForUI("<tag>")).toBe("") + expect(formatTemplateNameForUI("She said "hi"")).toBe( + 'She said "hi"', + ) + expect(formatTemplateNameForUI("It's ok")).toBe("It's ok") + expect(formatTemplateNameForUI("a b")).toBe("a b") + }) + + it("decodes numeric entities (decimal + hex)", () => { + expect(formatTemplateNameForUI("<")).toBe("<") + expect(formatTemplateNameForUI("<")).toBe("<") + expect(formatTemplateNameForUI(">>")).toBe(">>") + expect(formatTemplateNameForUI("👍")).toBe("👍") + }) + + it("decodes double-encoded entity sequences", () => { + expect(formatTemplateNameForUI("&lt;")).toBe("<") + expect(formatTemplateNameForUI("&lt;&gt;")).toBe("<>") + expect(formatTemplateNameForUI("Tom &amp; Jerry")).toBe("Tom & Jerry") + }) + + it("handles malformed entities missing semicolons (common in stored template names)", () => { + expect(formatTemplateNameForUI("&lt>>")).toBe("<>>") + + // Other missing-semicolon variants + expect(formatTemplateNameForUI("&lt;div>")).toBe("
") + expect(formatTemplateNameForUI("A&nbspB")).toBe("A B") + }) + + it("leaves unknown entities as-is", () => { + expect(formatTemplateNameForUI("&doesNotExist;")).toBe("&doesNotExist;") + expect(formatTemplateNameForUI("© 2026")).toBe("© 2026") + }) +}) diff --git a/packages/imagekit-editor-dev/vite.config.ts b/packages/imagekit-editor-dev/vite.config.ts index 0a8cdda..b42d49f 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: "happy-dom", + 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..4211de8 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": "3.0.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..1f56d7b 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,14 @@ "cache": false, "persistent": true, "dependsOn": [] + }, + "test": { + "cache": false, + "outputs": [] + }, + "test:coverage": { + "cache": false, + "outputs": [] } } } diff --git a/yarn.lock b/yarn.lock index 5b64324..5cbe0e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,7 +5,21 @@ __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 + +"@adobe/css-tools@npm:^4.4.0": + version: 4.4.4 + resolution: "@adobe/css-tools@npm:4.4.4" + checksum: 10c0/8f3e6cfaa5e6286e6f05de01d91d060425be2ebaef490881f5fe6da8bbdb336835c5d373ea337b0c3b0a1af4be048ba18780f0f6021d30809b4545922a7e13d9 + 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 +29,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" @@ -26,6 +73,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.10.4": + version: 7.29.0 + resolution: "@babel/code-frame@npm:7.29.0" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.28.5" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/d34cc504e7765dfb576a663d97067afb614525806b5cad1a5cc1a7183b916fec8ff57fa233585e3926fd5a9e6b31aae6df91aa81ae9775fb7a28f658d3346f0d + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.27.2": version: 7.27.5 resolution: "@babel/compat-data@npm:7.27.5" @@ -140,6 +198,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 +233,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.25.4": + 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 +328,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 +348,13 @@ __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 + "@biomejs/biome@npm:2.1.1": version: 2.1.1 resolution: "@biomejs/biome@npm:2.1.1" @@ -353,6 +446,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 +1325,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 +1596,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 +1610,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 +1624,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 +1638,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 +1652,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 +1666,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 +1680,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 +1694,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 +1708,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 +1722,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 +1736,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 +1750,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 +1764,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 +1778,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 +1792,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 +1806,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 +1820,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 +1841,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 +1862,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 +1876,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 +1890,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 +1904,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 +1918,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 +1932,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 +1981,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 +2026,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 +2075,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": + 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 +2275,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 +2370,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 +2384,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 +2398,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 +2412,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 +2426,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 +2440,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 +2454,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 +2468,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 +2482,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 +2496,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 +2531,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 +2552,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 +2566,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 +2580,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 +2594,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,6 +2608,27 @@ __metadata: languageName: node linkType: hard +"@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" @@ -2128,6 +2636,13 @@ __metadata: 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 + "@rollup/rollup-win32-ia32-msvc@npm:4.44.0": version: 4.44.0 resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.44.0" @@ -2135,6 +2650,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 +2671,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" @@ -2191,6 +2727,69 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-virtual@npm:^3.13.12": + version: 3.13.24 + resolution: "@tanstack/react-virtual@npm:3.13.24" + dependencies: + "@tanstack/virtual-core": "npm:3.14.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/f409b2bb67965a513b75a1403e622c0b86c88c67419f757c79f670615979e38dc7ad5569a02c924741697df3d4301a0f45208fd4b9f935a5d58dd83e1db5622a + languageName: node + linkType: hard + +"@tanstack/virtual-core@npm:3.14.0": + version: 3.14.0 + resolution: "@tanstack/virtual-core@npm:3.14.0" + checksum: 10c0/9e07e7f74f5e02dfc47b358f7b5089e680d8b14b9c5b90e9497be6f57c76ca98d185fbe5008795b89919346bfc3676ebe1c61b34980eefe6674c88ec6ff4b136 + languageName: node + linkType: hard + +"@testing-library/dom@npm:8.20.1, @testing-library/dom@npm:^8.0.0": + version: 8.20.1 + resolution: "@testing-library/dom@npm:8.20.1" + dependencies: + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.1.3" + chalk: "npm:^4.1.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + pretty-format: "npm:^27.0.2" + checksum: 10c0/614013756706467f2a7f3f693c18377048c210ec809884f0f9be866f7d865d075805ad15f5d100e8a699467fdde09085bf79e23a00ea0a6ab001d9583ef15e5d + languageName: node + linkType: hard + +"@testing-library/jest-dom@npm:^6.9.1": + version: 6.9.1 + resolution: "@testing-library/jest-dom@npm:6.9.1" + dependencies: + "@adobe/css-tools": "npm:^4.4.0" + aria-query: "npm:^5.0.0" + css.escape: "npm:^1.5.1" + dom-accessibility-api: "npm:^0.6.3" + picocolors: "npm:^1.1.1" + redent: "npm:^3.0.0" + checksum: 10c0/4291ebd2f0f38d14cefac142c56c337941775a5807e2a3d6f1a14c2fbd6be76a18e498ed189e95bedc97d9e8cf1738049bc76c85b5bc5e23fae7c9e10f7b3a12 + languageName: node + linkType: hard + +"@testing-library/react@npm:12.1.5": + version: 12.1.5 + resolution: "@testing-library/react@npm:12.1.5" + dependencies: + "@babel/runtime": "npm:^7.12.5" + "@testing-library/dom": "npm:^8.0.0" + "@types/react-dom": "npm:<18.0.0" + peerDependencies: + react: <18.0.0 + react-dom: <18.0.0 + checksum: 10c0/3c2433d2fdb6535261f62cd85d79657989cebd96f9072da03c098a1cfa56dec4dfec83d7c2e93633a3ccebdb178ea8578261533d11551600966edab77af00c8b + languageName: node + linkType: hard + "@types/argparse@npm:1.0.38": version: 1.0.38 resolution: "@types/argparse@npm:1.0.38" @@ -2198,6 +2797,13 @@ __metadata: languageName: node linkType: hard +"@types/aria-query@npm:^5.0.1": + version: 5.0.4 + resolution: "@types/aria-query@npm:5.0.4" + checksum: 10c0/dc667bc6a3acc7bba2bccf8c23d56cb1f2f4defaa704cfef595437107efaa972d3b3db9ec1d66bc2711bfc35086821edd32c302bffab36f2e79b97f312069f08 + languageName: node + linkType: hard + "@types/babel__core@npm:^7.20.5": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" @@ -2246,6 +2852,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 +2887,24 @@ __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.0.0": + version: 25.6.0 + resolution: "@types/node@npm:25.6.0" + dependencies: + undici-types: "npm:~7.19.0" + checksum: 10c0/d2d2015630ff098a201407f55f5077a20270ae4f465c739b40865cd9933b91b9c5d2b85568eadaf3db0801b91e267333ca7eb39f007428b173d1cdab4b339ac5 + languageName: node + linkType: hard + "@types/node@npm:^20.11.24": version: 20.19.1 resolution: "@types/node@npm:20.19.1" @@ -2296,7 +2939,7 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:^17.0.2": +"@types/react-dom@npm:<18.0.0, @types/react-dom@npm:^17.0.2": version: 17.0.26 resolution: "@types/react-dom@npm:17.0.26" peerDependencies: @@ -2341,6 +2984,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" @@ -2348,6 +2998,22 @@ __metadata: languageName: node linkType: hard +"@types/whatwg-mimetype@npm:^3.0.2": + version: 3.0.2 + resolution: "@types/whatwg-mimetype@npm:3.0.2" + checksum: 10c0/dad39d1e4abe760a0a963c84bbdbd26b1df0eb68aff83bdf6ecbb50ad781ead777f6906d19a87007790b750f7500a12e5624d31fc6a1529d14bd19b5c3a316d1 + languageName: node + linkType: hard + +"@types/ws@npm:^8.18.1": + version: 8.18.1 + resolution: "@types/ws@npm:8.18.1" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/61aff1129143fcc4312f083bc9e9e168aa3026b7dd6e70796276dcfb2c8211c4292603f9c4864fae702f2ed86e4abd4d38aa421831c2fd7f856c931a481afbab + languageName: node + linkType: hard + "@vitejs/plugin-react@npm:^4.5.2": version: 4.5.2 resolution: "@vitejs/plugin-react@npm:4.5.2" @@ -2364,6 +3030,130 @@ __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/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/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.1.9": + 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 + "@volar/language-core@npm:2.4.17": version: 2.4.17 resolution: "@volar/language-core@npm:2.4.17" @@ -2449,7 +3239,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^4.0.0": +"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" dependencies: @@ -2458,6 +3248,13 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^5.0.0": + version: 5.2.0 + resolution: "ansi-styles@npm:5.2.0" + checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df + languageName: node + linkType: hard + "ansi-styles@npm:^6.0.0, ansi-styles@npm:^6.1.0, ansi-styles@npm:^6.2.1": version: 6.2.1 resolution: "ansi-styles@npm:6.2.1" @@ -2483,6 +3280,62 @@ __metadata: languageName: node linkType: hard +"aria-query@npm:5.1.3": + version: 5.1.3 + resolution: "aria-query@npm:5.1.3" + dependencies: + deep-equal: "npm:^2.0.5" + checksum: 10c0/edcbc8044c4663d6f88f785e983e6784f98cb62b4ba1e9dd8d61b725d0203e4cfca38d676aee984c31f354103461102a3d583aa4fbe4fd0a89b679744f4e5faf + languageName: node + linkType: hard + +"aria-query@npm:^5.0.0": + version: 5.3.2 + resolution: "aria-query@npm:5.3.2" + checksum: 10c0/003c7e3e2cff5540bf7a7893775fc614de82b0c5dde8ae823d47b7a28a9d4da1f7ed85f340bdb93d5649caa927755f0e31ecc7ab63edfdfc00c8ef07e505e03e + languageName: node + linkType: hard + +"array-buffer-byte-length@npm:^1.0.0": + version: 1.0.2 + resolution: "array-buffer-byte-length@npm:1.0.2" + dependencies: + call-bound: "npm:^1.0.3" + is-array-buffer: "npm:^3.0.5" + checksum: 10c0/74e1d2d996941c7a1badda9cabb7caab8c449db9086407cad8a1b71d2604cc8abf105db8ca4e02c04579ec58b7be40279ddb09aea4784832984485499f48432d + 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 + +"async-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-function@npm:1.0.0" + checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73 + languageName: node + linkType: hard + +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186 + languageName: node + linkType: hard + +"available-typed-arrays@npm:^1.0.7": + version: 1.0.7 + resolution: "available-typed-arrays@npm:1.0.7" + dependencies: + possible-typed-array-names: "npm:^1.0.0" + checksum: 10c0/d07226ef4f87daa01bd0fe80f8f310982e345f372926da2e5296aecc25c41cab440916bbaa4c5e1034b453af3392f67df5961124e4b586df1e99793a1374bdb2 + 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 +3354,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 +3368,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 +3386,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 +3425,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" @@ -2567,6 +3452,38 @@ __metadata: languageName: node linkType: hard +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938 + languageName: node + linkType: hard + +"call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": + version: 1.0.9 + resolution: "call-bind@npm:1.0.9" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + get-intrinsic: "npm:^1.3.0" + set-function-length: "npm:^1.2.2" + checksum: 10c0/a6621f6da1444481919ce3b4983dff725691e0754d3507ae483ce56e54985f2da7d6f1df512c56dbf28660745cf1ca52553f1fc9aef5557f3ce353ef14fab714 + languageName: node + linkType: hard + +"call-bound@npm:^1.0.2, call-bound@npm:^1.0.3, call-bound@npm:^1.0.4": + version: 1.0.4 + resolution: "call-bound@npm:1.0.4" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + get-intrinsic: "npm:^1.3.0" + checksum: 10c0/f4796a6a0941e71c766aea672f63b72bc61234c4f4964dc6d7606e3664c307e7d77845328a8f3359ce39ddb377fed67318f9ee203dea1d47e46165dcf2917644 + languageName: node + linkType: hard + "callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" @@ -2581,6 +3498,29 @@ __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:^4.1.0": + version: 4.1.2 + resolution: "chalk@npm:4.1.2" + dependencies: + ansi-styles: "npm:^4.1.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/4a3fef5cc34975c898ffe77141450f679721df9dde00f6c304353fa9c8b571929123b26a0e4617bde5018977eb655b31970c297b91b63ee83bb82aeb04666880 + languageName: node + linkType: hard + "chalk@npm:^5.4.1": version: 5.4.1 resolution: "chalk@npm:5.4.1" @@ -2588,6 +3528,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 +3729,35 @@ __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 + +"css.escape@npm:^1.5.1": + version: 1.5.1 + resolution: "css.escape@npm:1.5.1" + checksum: 10c0/5e09035e5bf6c2c422b40c6df2eb1529657a17df37fda5d0433d722609527ab98090baf25b13970ca754079a0f3161dd3dfc0e743563ded8cfa0749d861c1525 + 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 +3772,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 +3794,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 +3818,57 @@ __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 + +"deep-equal@npm:^2.0.5": + version: 2.2.3 + resolution: "deep-equal@npm:2.2.3" + dependencies: + array-buffer-byte-length: "npm:^1.0.0" + call-bind: "npm:^1.0.5" + es-get-iterator: "npm:^1.1.3" + get-intrinsic: "npm:^1.2.2" + is-arguments: "npm:^1.1.1" + is-array-buffer: "npm:^3.0.2" + is-date-object: "npm:^1.0.5" + is-regex: "npm:^1.1.4" + is-shared-array-buffer: "npm:^1.0.2" + isarray: "npm:^2.0.5" + object-is: "npm:^1.1.5" + object-keys: "npm:^1.1.1" + object.assign: "npm:^4.1.4" + regexp.prototype.flags: "npm:^1.5.1" + side-channel: "npm:^1.0.4" + which-boxed-primitive: "npm:^1.0.2" + which-collection: "npm:^1.0.1" + which-typed-array: "npm:^1.1.13" + checksum: 10c0/a48244f90fa989f63ff5ef0cc6de1e4916b48ea0220a9c89a378561960814794a5800c600254482a2c8fd2e49d6c2e196131dc983976adb024c94a42dfe4949f + languageName: node + linkType: hard + +"define-data-property@npm:^1.0.1, define-data-property@npm:^1.1.4": + version: 1.1.4 + resolution: "define-data-property@npm:1.1.4" + dependencies: + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.0.1" + checksum: 10c0/dea0606d1483eb9db8d930d4eac62ca0fa16738b0b3e07046cddfacf7d8c868bbe13fa0cb263eb91c7d0d527960dc3f2f2471a69ed7816210307f6744fe62e37 + languageName: node + linkType: hard + "define-lazy-prop@npm:^2.0.0": version: 2.0.0 resolution: "define-lazy-prop@npm:2.0.0" @@ -2827,6 +3876,17 @@ __metadata: languageName: node linkType: hard +"define-properties@npm:^1.2.1": + version: 1.2.1 + resolution: "define-properties@npm:1.2.1" + dependencies: + define-data-property: "npm:^1.0.1" + has-property-descriptors: "npm:^1.0.0" + object-keys: "npm:^1.1.1" + checksum: 10c0/88a152319ffe1396ccc6ded510a3896e77efac7a1bfbaa174a7b00414a1747377e0bb525d303794a47cf30e805c2ec84e575758512c6e44a993076d29fd4e6c3 + languageName: node + linkType: hard + "detect-node-es@npm:^1.1.0": version: 1.1.0 resolution: "detect-node-es@npm:1.1.0" @@ -2834,6 +3894,20 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.5.9": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 10c0/b2c2eda4fae568977cdac27a9f0c001edf4f95a6a6191dfa611e3721db2478d1badc01db5bb4fa8a848aeee13e442a6c2a4386d65ec65a1436f24715a2f8d053 + languageName: node + linkType: hard + +"dom-accessibility-api@npm:^0.6.3": + version: 0.6.3 + resolution: "dom-accessibility-api@npm:0.6.3" + checksum: 10c0/10bee5aa514b2a9a37c87cd81268db607a2e933a050074abc2f6fa3da9080ebed206a320cbc123567f2c3087d22292853bdfdceaffdd4334ffe2af9510b29360 + languageName: node + linkType: hard + "dom-helpers@npm:^5.0.1": version: 5.2.1 resolution: "dom-helpers@npm:5.2.1" @@ -2844,6 +3918,17 @@ __metadata: languageName: node linkType: hard +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -2897,6 +3982,20 @@ __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 + +"entities@npm:^7.0.1": + version: 7.0.1 + resolution: "entities@npm:7.0.1" + checksum: 10c0/b4fb9937bb47ecb00aaaceb9db9cdd1cc0b0fb649c0e843d05cf5dbbd2e9d2df8f98721d8b1b286445689c72af7b54a7242fc2d63ef7c9739037a8c73363e7ca + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -2927,6 +4026,133 @@ __metadata: languageName: node linkType: hard +"es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85 + languageName: node + linkType: hard + +"es-get-iterator@npm:^1.1.3": + version: 1.1.3 + resolution: "es-get-iterator@npm:1.1.3" + dependencies: + call-bind: "npm:^1.0.2" + get-intrinsic: "npm:^1.1.3" + has-symbols: "npm:^1.0.3" + is-arguments: "npm:^1.1.1" + is-map: "npm:^2.0.2" + is-set: "npm:^2.0.2" + is-string: "npm:^1.0.7" + isarray: "npm:^2.0.5" + stop-iteration-iterator: "npm:^1.0.0" + checksum: 10c0/ebd11effa79851ea75d7f079405f9d0dc185559fd65d986c6afea59a0ff2d46c2ed8675f19f03dce7429d7f6c14ff9aede8d121fbab78d75cfda6a263030bac0 + 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 + +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" + dependencies: + es-errors: "npm:^1.3.0" + checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c + 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 +4260,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 +4291,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 +4360,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 +4395,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" @@ -3143,6 +4411,15 @@ __metadata: languageName: node linkType: hard +"for-each@npm:^0.3.5": + version: 0.3.5 + resolution: "for-each@npm:0.3.5" + dependencies: + is-callable: "npm:^1.2.7" + checksum: 10c0/0e0b50f6a843a282637d43674d1fb278dda1dd85f4f99b640024cfb10b85058aac0cc781bf689d5fe50b4b7f638e91e548560723a4e76e04fe96ae35ef039cee + languageName: node + linkType: hard + "foreground-child@npm:^3.1.0": version: 3.3.1 resolution: "foreground-child@npm:3.3.1" @@ -3238,6 +4515,20 @@ __metadata: languageName: node linkType: hard +"functions-have-names@npm:^1.2.3": + version: 1.2.3 + resolution: "functions-have-names@npm:1.2.3" + checksum: 10c0/33e77fd29bddc2d9bb78ab3eb854c165909201f88c75faa8272e35899e2d35a8a642a15e7420ef945e1f64a9670d6aa3ec744106b2aa42be68ca5114025954ca + languageName: node + linkType: hard + +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8 + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -3259,6 +4550,27 @@ __metadata: languageName: node linkType: hard +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.2, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" + dependencies: + async-function: "npm:^1.0.0" + async-generator-function: "npm:^1.0.0" + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" + function-bind: "npm:^1.1.2" + generator-function: "npm:^2.0.0" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d + languageName: node + linkType: hard + "get-nonce@npm:^1.0.0": version: 1.0.1 resolution: "get-nonce@npm:1.0.1" @@ -3266,6 +4578,16 @@ __metadata: languageName: node linkType: hard +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c + languageName: node + linkType: hard + "get-stream@npm:^4.0.0": version: 4.1.0 resolution: "get-stream@npm:4.1.0" @@ -3300,6 +4622,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" @@ -3307,6 +4645,13 @@ __metadata: languageName: node linkType: hard +"gopd@npm:^1.0.1, gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead + languageName: node + linkType: hard + "graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -3314,6 +4659,59 @@ __metadata: languageName: node linkType: hard +"happy-dom@npm:^20.9.0": + version: 20.9.0 + resolution: "happy-dom@npm:20.9.0" + dependencies: + "@types/node": "npm:>=20.0.0" + "@types/whatwg-mimetype": "npm:^3.0.2" + "@types/ws": "npm:^8.18.1" + entities: "npm:^7.0.1" + whatwg-mimetype: "npm:^3.0.0" + ws: "npm:^8.18.3" + checksum: 10c0/4fb814057b85d13d9459c1af39de28d6e7504ba99c12123179e6c69d8c3e1cbd8c48c4104c7d793e89d7aa1b5f360a8e72ad56d33cc23cc6f42a27e2fbd25748 + languageName: node + linkType: hard + +"has-bigints@npm:^1.0.2": + version: 1.1.0 + resolution: "has-bigints@npm:1.1.0" + checksum: 10c0/2de0cdc4a1ccf7a1e75ffede1876994525ac03cc6f5ae7392d3415dd475cd9eee5bceec63669ab61aa997ff6cceebb50ef75561c7002bed8988de2b9d1b40788 + 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 + +"has-property-descriptors@npm:^1.0.0, has-property-descriptors@npm:^1.0.2": + version: 1.0.2 + resolution: "has-property-descriptors@npm:1.0.2" + dependencies: + es-define-property: "npm:^1.0.0" + checksum: 10c0/253c1f59e80bb476cf0dde8ff5284505d90c3bdb762983c3514d36414290475fe3fd6f574929d84de2a8eec00d35cf07cb6776205ff32efd7c50719125f00236 + languageName: node + linkType: hard + +"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e + languageName: node + linkType: hard + +"has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: "npm:^1.0.3" + checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c + languageName: node + linkType: hard + "hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" @@ -3339,6 +4737,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 +4770,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 +4780,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 +4790,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" @@ -3410,6 +4831,7 @@ __metadata: "@imagekit/javascript": "npm:^5.1.0" "@microsoft/api-extractor": "npm:7.34.9" "@react-icons/all-files": "https://github.com/react-icons/react-icons/releases/download/v5.4.0/react-icons-all-files-5.4.0.tgz" + "@tanstack/react-virtual": "npm:^3.13.12" "@types/lodash": "npm:^4" "@types/node": "npm:^20.11.24" "@types/react": "npm:^17.0.2" @@ -3442,11 +4864,22 @@ __metadata: resolution: "imagekit-editor@workspace:." dependencies: "@biomejs/biome": "npm:2.1.1" + "@testing-library/dom": "npm:8.20.1" + "@testing-library/jest-dom": "npm:^6.9.1" + "@testing-library/react": "npm:12.1.5" + "@types/human-date": "npm:^1" + "@types/jsdom": "npm:^28" + "@vitest/coverage-v8": "npm:^2.1.9" + "@vitest/ui": "npm:^2.1.9" + happy-dom: "npm:^20.9.0" + 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" turbo: "npm:^2.0.1" + vitest: "npm:^2.1.9" languageName: unknown linkType: soft @@ -3481,6 +4914,24 @@ __metadata: languageName: node linkType: hard +"indent-string@npm:^4.0.0": + version: 4.0.0 + resolution: "indent-string@npm:4.0.0" + checksum: 10c0/1e1904ddb0cb3d6cce7cd09e27a90184908b7a5d5c21b92e232c93579d314f0b83c246ffb035493d0504b1e9147ba2c9b21df0030f48673fba0496ecd698161f + languageName: node + linkType: hard + +"internal-slot@npm:^1.1.0": + version: 1.1.0 + resolution: "internal-slot@npm:1.1.0" + dependencies: + es-errors: "npm:^1.3.0" + hasown: "npm:^2.0.2" + side-channel: "npm:^1.1.0" + checksum: 10c0/03966f5e259b009a9bf1a78d60da920df198af4318ec004f57b8aef1dd3fe377fbc8cce63a96e8c810010302654de89f9e19de1cd8ad0061d15be28a695465c7 + languageName: node + linkType: hard + "interpret@npm:^1.0.0": version: 1.4.0 resolution: "interpret@npm:1.4.0" @@ -3488,20 +4939,67 @@ __metadata: languageName: node linkType: hard -"ip-address@npm:^9.0.5": - version: 9.0.5 - resolution: "ip-address@npm:9.0.5" +"ip-address@npm:^9.0.5": + version: 9.0.5 + resolution: "ip-address@npm:9.0.5" + dependencies: + jsbn: "npm:1.1.0" + sprintf-js: "npm:^1.1.3" + checksum: 10c0/331cd07fafcb3b24100613e4b53e1a2b4feab11e671e655d46dc09ee233da5011284d09ca40c4ecbdfe1d0004f462958675c224a804259f2f78d2465a87824bc + languageName: node + linkType: hard + +"is-arguments@npm:^1.1.1": + version: 1.2.0 + resolution: "is-arguments@npm:1.2.0" + dependencies: + call-bound: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.2" + checksum: 10c0/6377344b31e9fcb707c6751ee89b11f132f32338e6a782ec2eac9393b0cbd32235dad93052998cda778ee058754860738341d8114910d50ada5615912bb929fc + languageName: node + linkType: hard + +"is-array-buffer@npm:^3.0.2, is-array-buffer@npm:^3.0.5": + version: 3.0.5 + resolution: "is-array-buffer@npm:3.0.5" + dependencies: + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.3" + get-intrinsic: "npm:^1.2.6" + checksum: 10c0/c5c9f25606e86dbb12e756694afbbff64bc8b348d1bc989324c037e1068695131930199d6ad381952715dad3a9569333817f0b1a72ce5af7f883ce802e49c83d + languageName: node + linkType: hard + +"is-arrayish@npm:^0.2.1": + version: 0.2.1 + resolution: "is-arrayish@npm:0.2.1" + checksum: 10c0/e7fb686a739068bb70f860b39b67afc62acc62e36bb61c5f965768abce1873b379c563e61dd2adad96ebb7edf6651111b385e490cf508378959b0ed4cac4e729 + languageName: node + linkType: hard + +"is-bigint@npm:^1.1.0": + version: 1.1.0 + resolution: "is-bigint@npm:1.1.0" + dependencies: + has-bigints: "npm:^1.0.2" + checksum: 10c0/f4f4b905ceb195be90a6ea7f34323bf1c18e3793f18922e3e9a73c684c29eeeeff5175605c3a3a74cc38185fe27758f07efba3dbae812e5c5afbc0d2316b40e4 + languageName: node + linkType: hard + +"is-boolean-object@npm:^1.2.1": + version: 1.2.2 + resolution: "is-boolean-object@npm:1.2.2" dependencies: - jsbn: "npm:1.1.0" - sprintf-js: "npm:^1.1.3" - checksum: 10c0/331cd07fafcb3b24100613e4b53e1a2b4feab11e671e655d46dc09ee233da5011284d09ca40c4ecbdfe1d0004f462958675c224a804259f2f78d2465a87824bc + call-bound: "npm:^1.0.3" + has-tostringtag: "npm:^1.0.2" + checksum: 10c0/36ff6baf6bd18b3130186990026f5a95c709345c39cd368468e6c1b6ab52201e9fd26d8e1f4c066357b4938b0f0401e1a5000e08257787c1a02f3a719457001e languageName: node linkType: hard -"is-arrayish@npm:^0.2.1": - version: 0.2.1 - resolution: "is-arrayish@npm:0.2.1" - checksum: 10c0/e7fb686a739068bb70f860b39b67afc62acc62e36bb61c5f965768abce1873b379c563e61dd2adad96ebb7edf6651111b385e490cf508378959b0ed4cac4e729 +"is-callable@npm:^1.2.7": + version: 1.2.7 + resolution: "is-callable@npm:1.2.7" + checksum: 10c0/ceebaeb9d92e8adee604076971dd6000d38d6afc40bb843ea8e45c5579b57671c3f3b50d7f04869618242c6cee08d1b67806a8cb8edaaaf7c0748b3720d6066f languageName: node linkType: hard @@ -3514,6 +5012,16 @@ __metadata: languageName: node linkType: hard +"is-date-object@npm:^1.0.5": + version: 1.1.0 + resolution: "is-date-object@npm:1.1.0" + dependencies: + call-bound: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.2" + checksum: 10c0/1a4d199c8e9e9cac5128d32e6626fa7805175af9df015620ac0d5d45854ccf348ba494679d872d37301032e35a54fc7978fba1687e8721b2139aea7870cafa2f + languageName: node + linkType: hard + "is-docker@npm:^2.0.0, is-docker@npm:^2.1.1": version: 2.2.1 resolution: "is-docker@npm:2.2.1" @@ -3562,6 +5070,23 @@ __metadata: languageName: node linkType: hard +"is-map@npm:^2.0.2, is-map@npm:^2.0.3": + version: 2.0.3 + resolution: "is-map@npm:2.0.3" + checksum: 10c0/2c4d431b74e00fdda7162cd8e4b763d6f6f217edf97d4f8538b94b8702b150610e2c64961340015fe8df5b1fcee33ccd2e9b62619c4a8a3a155f8de6d6d355fc + languageName: node + linkType: hard + +"is-number-object@npm:^1.1.1": + version: 1.1.1 + resolution: "is-number-object@npm:1.1.1" + dependencies: + call-bound: "npm:^1.0.3" + has-tostringtag: "npm:^1.0.2" + checksum: 10c0/97b451b41f25135ff021d85c436ff0100d84a039bb87ffd799cbcdbea81ef30c464ced38258cdd34f080be08fc3b076ca1f472086286d2aa43521d6ec6a79f53 + languageName: node + linkType: hard + "is-number@npm:^7.0.0": version: 7.0.0 resolution: "is-number@npm:7.0.0" @@ -3569,6 +5094,41 @@ __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-regex@npm:^1.1.4, is-regex@npm:^1.2.1": + version: 1.2.1 + resolution: "is-regex@npm:1.2.1" + dependencies: + call-bound: "npm:^1.0.2" + gopd: "npm:^1.2.0" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10c0/1d3715d2b7889932349241680032e85d0b492cfcb045acb75ffc2c3085e8d561184f1f7e84b6f8321935b4aea39bc9c6ba74ed595b57ce4881a51dfdbc214e04 + languageName: node + linkType: hard + +"is-set@npm:^2.0.2, is-set@npm:^2.0.3": + version: 2.0.3 + resolution: "is-set@npm:2.0.3" + checksum: 10c0/f73732e13f099b2dc879c2a12341cfc22ccaca8dd504e6edae26484bd5707a35d503fba5b4daad530a9b088ced1ae6c9d8200fd92e09b428fe14ea79ce8080b7 + languageName: node + linkType: hard + +"is-shared-array-buffer@npm:^1.0.2": + version: 1.0.4 + resolution: "is-shared-array-buffer@npm:1.0.4" + dependencies: + call-bound: "npm:^1.0.3" + checksum: 10c0/65158c2feb41ff1edd6bbd6fd8403a69861cf273ff36077982b5d4d68e1d59278c71691216a4a64632bd76d4792d4d1d2553901b6666d84ade13bba5ea7bc7db + languageName: node + linkType: hard + "is-stream@npm:^1.1.0": version: 1.1.0 resolution: "is-stream@npm:1.1.0" @@ -3576,6 +5136,44 @@ __metadata: languageName: node linkType: hard +"is-string@npm:^1.0.7, is-string@npm:^1.1.1": + version: 1.1.1 + resolution: "is-string@npm:1.1.1" + dependencies: + call-bound: "npm:^1.0.3" + has-tostringtag: "npm:^1.0.2" + checksum: 10c0/2f518b4e47886bb81567faba6ffd0d8a8333cf84336e2e78bf160693972e32ad00fe84b0926491cc598dee576fdc55642c92e62d0cbe96bf36f643b6f956f94d + languageName: node + linkType: hard + +"is-symbol@npm:^1.1.1": + version: 1.1.1 + resolution: "is-symbol@npm:1.1.1" + dependencies: + call-bound: "npm:^1.0.2" + has-symbols: "npm:^1.1.0" + safe-regex-test: "npm:^1.1.0" + checksum: 10c0/f08f3e255c12442e833f75a9e2b84b2d4882fdfd920513cf2a4a2324f0a5b076c8fd913778e3ea5d258d5183e9d92c0cd20e04b03ab3df05316b049b2670af1e + languageName: node + linkType: hard + +"is-weakmap@npm:^2.0.2": + version: 2.0.2 + resolution: "is-weakmap@npm:2.0.2" + checksum: 10c0/443c35bb86d5e6cc5929cd9c75a4024bb0fff9586ed50b092f94e700b89c43a33b186b76dbc6d54f3d3d09ece689ab38dcdc1af6a482cbe79c0f2da0a17f1299 + languageName: node + linkType: hard + +"is-weakset@npm:^2.0.3": + version: 2.0.4 + resolution: "is-weakset@npm:2.0.4" + dependencies: + call-bound: "npm:^1.0.3" + get-intrinsic: "npm:^1.2.6" + checksum: 10c0/6491eba08acb8dc9532da23cb226b7d0192ede0b88f16199e592e4769db0a077119c1f5d2283d1e0d16d739115f70046e887e477eb0e66cd90e1bb29f28ba647 + languageName: node + linkType: hard + "is-wsl@npm:^2.2.0": version: 2.2.0 resolution: "is-wsl@npm:2.2.0" @@ -3585,6 +5183,13 @@ __metadata: languageName: node linkType: hard +"isarray@npm:^2.0.5": + version: 2.0.5 + resolution: "isarray@npm:2.0.5" + checksum: 10c0/4199f14a7a13da2177c66c31080008b7124331956f47bca57dd0b6ea9f11687aa25e565a2c7a2b519bc86988d10398e3049a1f5df13c9f6b7664154690ae79fd + languageName: node + linkType: hard + "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -3599,6 +5204,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": + 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" @@ -3633,6 +5277,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 +5480,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 +5494,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 +5519,24 @@ __metadata: languageName: node linkType: hard +"lz-string@npm:^1.5.0": + version: 1.5.0 + resolution: "lz-string@npm:1.5.0" + bin: + lz-string: bin/bin.js + checksum: 10c0/36128e4de34791838abe979b19927c26e67201ca5acf00880377af7d765b38d1c60847e01c5ec61b1a260c48029084ab3893a3925fd6e48a04011364b089991b + 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 +5546,26 @@ __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 + +"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 +5585,20 @@ __metadata: languageName: node linkType: hard +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f + 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 +5630,22 @@ __metadata: languageName: node linkType: hard +"min-indent@npm:^1.0.0": + version: 1.0.1 + resolution: "min-indent@npm:1.0.1" + checksum: 10c0/7e207bd5c20401b292de291f02913230cb1163abca162044f7db1d951fa245b174dc00869d40dd9a9f32a885ad6a5f3e767ee104cf278f399cb4e92d3f582d5c + 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 +5759,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 +5857,44 @@ __metadata: languageName: node linkType: hard +"object-inspect@npm:^1.13.3, object-inspect@npm:^1.13.4": + version: 1.13.4 + resolution: "object-inspect@npm:1.13.4" + checksum: 10c0/d7f8711e803b96ea3191c745d6f8056ce1f2496e530e6a19a0e92d89b0fa3c76d910c31f0aa270432db6bd3b2f85500a376a83aaba849a8d518c8845b3211692 + languageName: node + linkType: hard + +"object-is@npm:^1.1.5": + version: 1.1.6 + resolution: "object-is@npm:1.1.6" + dependencies: + call-bind: "npm:^1.0.7" + define-properties: "npm:^1.2.1" + checksum: 10c0/506af444c4dce7f8e31f34fc549e2fb8152d6b9c4a30c6e62852badd7f520b579c679af433e7a072f9d78eb7808d230dc12e1cf58da9154dfbf8813099ea0fe0 + languageName: node + linkType: hard + +"object-keys@npm:^1.1.1": + version: 1.1.1 + resolution: "object-keys@npm:1.1.1" + checksum: 10c0/b11f7ccdbc6d406d1f186cdadb9d54738e347b2692a14439ca5ac70c225fa6db46db809711b78589866d47b25fc3e8dee0b4c722ac751e11180f9380e3d8601d + languageName: node + linkType: hard + +"object.assign@npm:^4.1.4": + version: 4.1.7 + resolution: "object.assign@npm:4.1.7" + dependencies: + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.3" + define-properties: "npm:^1.2.1" + es-object-atoms: "npm:^1.0.0" + has-symbols: "npm:^1.1.0" + object-keys: "npm:^1.1.1" + checksum: 10c0/3b2732bd860567ea2579d1567525168de925a8d852638612846bd8082b3a1602b7b89b67b09913cbb5b9bd6e95923b2ae73580baa9d99cb4e990564e8cbf5ddc + 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 +5966,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 +6029,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 +6043,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 +6071,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 +6121,24 @@ __metadata: languageName: node linkType: hard +"possible-typed-array-names@npm:^1.0.0": + version: 1.1.0 + resolution: "possible-typed-array-names@npm:1.1.0" + checksum: 10c0/c810983414142071da1d644662ce4caebce890203eb2bc7bf119f37f3fe5796226e117e6cca146b521921fa6531072674174a3325066ac66fce089a53e1e5196 + 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" @@ -4288,6 +6150,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^27.0.2": + version: 27.5.1 + resolution: "pretty-format@npm:27.5.1" + dependencies: + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^17.0.1" + checksum: 10c0/0cbda1031aa30c659e10921fa94e0dd3f903ecbbbe7184a729ad66f2b6e7f17891e8c7d7654c458fa4ccb1a411ffb695b4f17bbcd3fe075fabe181027c4040ed + languageName: node + linkType: hard + "proc-log@npm:^5.0.0": version: 5.0.0 resolution: "proc-log@npm:5.0.0" @@ -4326,7 +6199,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 +6266,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" @@ -4444,6 +6317,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^17.0.1": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 10c0/2bdb6b93fbb1820b024b496042cce405c57e2f85e777c9aabd55f9b26d145408f9f74f5934676ffdc46f3dcff656d78413a6e43968e7b3f92eea35b3052e9053 + languageName: node + linkType: hard + "react-refresh@npm:^0.17.0": version: 0.17.0 resolution: "react-refresh@npm:0.17.0" @@ -4556,6 +6436,30 @@ __metadata: languageName: node linkType: hard +"redent@npm:^3.0.0": + version: 3.0.0 + resolution: "redent@npm:3.0.0" + dependencies: + indent-string: "npm:^4.0.0" + strip-indent: "npm:^3.0.0" + checksum: 10c0/d64a6b5c0b50eb3ddce3ab770f866658a2b9998c678f797919ceb1b586bab9259b311407280bd80b804e2a7c7539b19238ae6a2a20c843f1a7fcff21d48c2eae + languageName: node + linkType: hard + +"regexp.prototype.flags@npm:^1.5.1": + version: 1.5.4 + resolution: "regexp.prototype.flags@npm:1.5.4" + dependencies: + call-bind: "npm:^1.0.8" + define-properties: "npm:^1.2.1" + es-errors: "npm:^1.3.0" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + set-function-name: "npm:^2.0.2" + checksum: 10c0/83b88e6115b4af1c537f8dabf5c3744032cb875d63bc05c288b1b8c0ef37cbe55353f95d8ca817e8843806e3e150b118bc624e4279b24b4776b4198232735a77 + languageName: node + linkType: hard + "require-directory@npm:^2.1.1": version: 2.1.1 resolution: "require-directory@npm:2.1.1" @@ -4563,6 +6467,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" @@ -4687,11 +6598,101 @@ __metadata: peerDependenciesMeta: rolldown: optional: true - rollup: + rollup: + optional: true + bin: + rollup-plugin-visualizer: dist/bin/cli.js + checksum: 10c0/ec6ca9ed125bce9994ba49a340bda730661d8e8dc5c5dc014dc757185182e1eda49c6708f990cb059095e71a3741a5248f1e6ba0ced7056020692888e06b1ddf + 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-plugin-visualizer: dist/bin/cli.js - checksum: 10c0/ec6ca9ed125bce9994ba49a340bda730661d8e8dc5c5dc014dc757185182e1eda49c6708f990cb059095e71a3741a5248f1e6ba0ced7056020692888e06b1ddf + rollup: dist/bin/rollup + checksum: 10c0/f38742da34cfee5e899302615fa157aa77cb6a2a1495e5e3ce4cc9c540d3262e235bbe60caa31562bbfe492b01fdb3e7a8c43c39d842d3293bcf843123b766fc languageName: node linkType: hard @@ -4779,6 +6780,17 @@ __metadata: languageName: node linkType: hard +"safe-regex-test@npm:^1.1.0": + version: 1.1.0 + resolution: "safe-regex-test@npm:1.1.0" + dependencies: + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + is-regex: "npm:^1.2.1" + checksum: 10c0/f2c25281bbe5d39cddbbce7f86fca5ea9b3ce3354ea6cd7c81c31b006a5a9fff4286acc5450a3b9122c56c33eba69c56b9131ad751457b2b4a585825e6a10665 + languageName: node + linkType: hard + "safer-buffer@npm:>= 2.1.2 < 3.0.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" @@ -4786,6 +6798,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 +6844,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" @@ -4834,6 +6864,32 @@ __metadata: languageName: node linkType: hard +"set-function-length@npm:^1.2.2": + version: 1.2.2 + resolution: "set-function-length@npm:1.2.2" + dependencies: + define-data-property: "npm:^1.1.4" + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + get-intrinsic: "npm:^1.2.4" + gopd: "npm:^1.0.1" + has-property-descriptors: "npm:^1.0.2" + checksum: 10c0/82850e62f412a258b71e123d4ed3873fa9377c216809551192bb6769329340176f109c2eeae8c22a8d386c76739855f78e8716515c818bcaef384b51110f0f3c + languageName: node + linkType: hard + +"set-function-name@npm:^2.0.2": + version: 2.0.2 + resolution: "set-function-name@npm:2.0.2" + dependencies: + define-data-property: "npm:^1.1.4" + es-errors: "npm:^1.3.0" + functions-have-names: "npm:^1.2.3" + has-property-descriptors: "npm:^1.0.2" + checksum: 10c0/fce59f90696c450a8523e754abb305e2b8c73586452619c2bad5f7bf38c7b6b4651895c9db895679c5bef9554339cf3ef1c329b66ece3eda7255785fbe299316 + languageName: node + linkType: hard + "shebang-command@npm:^1.2.0": version: 1.2.0 resolution: "shebang-command@npm:1.2.0" @@ -4892,6 +6948,61 @@ __metadata: languageName: node linkType: hard +"side-channel-list@npm:^1.0.0": + version: 1.0.1 + resolution: "side-channel-list@npm:1.0.1" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.4" + checksum: 10c0/d346c787fd2f9f1c2fdea14f00e8250118db0e7596d85a6cb9faa75f105d31a73a8f7a341c93d7df2a2429098c3d37a77bd3be9e88c37094b8c01807bc77c7a2 + languageName: node + linkType: hard + +"side-channel-map@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-map@npm:1.0.1" + dependencies: + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.5" + object-inspect: "npm:^1.13.3" + checksum: 10c0/010584e6444dd8a20b85bc926d934424bd809e1a3af941cace229f7fdcb751aada0fb7164f60c2e22292b7fa3c0ff0bce237081fd4cdbc80de1dc68e95430672 + languageName: node + linkType: hard + +"side-channel-weakmap@npm:^1.0.2": + version: 1.0.2 + resolution: "side-channel-weakmap@npm:1.0.2" + dependencies: + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.5" + object-inspect: "npm:^1.13.3" + side-channel-map: "npm:^1.0.1" + checksum: 10c0/71362709ac233e08807ccd980101c3e2d7efe849edc51455030327b059f6c4d292c237f94dc0685031dd11c07dd17a68afde235d6cf2102d949567f98ab58185 + languageName: node + linkType: hard + +"side-channel@npm:^1.0.4, side-channel@npm:^1.1.0": + version: 1.1.0 + resolution: "side-channel@npm:1.1.0" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.3" + side-channel-list: "npm:^1.0.0" + side-channel-map: "npm:^1.0.1" + side-channel-weakmap: "npm:^1.0.2" + checksum: 10c0/cb20dad41eb032e6c24c0982e1e5a24963a28aa6122b4f05b3f3d6bf8ae7fd5474ef382c8f54a6a3ab86e0cac4d41a23bd64ede3970e5bfb50326ba02a7996e6 + 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 +7017,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 +7076,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 +7137,30 @@ __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.8.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 10c0/1814927a45004d36dde6707eaf17552a546769bc79a6421be2c16ce77d238158dfe5de30910b78ec30d95135cc1c59ea73ee22d2ca170f8b9753f84da34c427f + languageName: node + linkType: hard + +"stop-iteration-iterator@npm:^1.0.0": + version: 1.1.0 + resolution: "stop-iteration-iterator@npm:1.1.0" + dependencies: + es-errors: "npm:^1.3.0" + internal-slot: "npm:^1.1.0" + checksum: 10c0/de4e45706bb4c0354a4b1122a2b8cc45a639e86206807ce0baf390ee9218d3ef181923fa4d2b67443367c491aa255c5fbaa64bb74648e3c5b48299928af86c09 + 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" @@ -5080,6 +7226,15 @@ __metadata: languageName: node linkType: hard +"strip-indent@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-indent@npm:3.0.0" + dependencies: + min-indent: "npm:^1.0.0" + checksum: 10c0/ae0deaf41c8d1001c5d4fbe16cb553865c1863da4fae036683b474fa926af9fc121e155cb3fc57a68262b2ae7d5b8420aa752c97a6428c315d00efe2a3875679 + languageName: node + linkType: hard + "strip-json-comments@npm:~3.1.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1" @@ -5104,6 +7259,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 +7275,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 +7310,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 +7337,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 +7351,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 +7378,45 @@ __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 + +"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 +7440,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 +7597,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 +7611,27 @@ __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-types@npm:~7.19.0": + version: 7.19.2 + resolution: "undici-types@npm:7.19.2" + checksum: 10c0/7159f10546f9f6c47d36776bb1bbf8671e87c1e587a6fee84ae1f111ae8de4f914efa8ca0dfcd224f4f4a9dfc3f6028f627ccb5ddaccf82d7fd54671b89fac3e + 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 +7802,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 +7837,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 +7935,56 @@ __metadata: languageName: node linkType: hard +"vitest@npm:^2.1.9": + 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 +7992,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 +8010,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 +8024,71 @@ __metadata: languageName: node linkType: hard +"whatwg-mimetype@npm:^3.0.0": + version: 3.0.0 + resolution: "whatwg-mimetype@npm:3.0.0" + checksum: 10c0/323895a1cda29a5fb0b9ca82831d2c316309fede0365047c4c323073e3239067a304a09a1f4b123b9532641ab604203f33a1403b5ca6a62ef405bcd7a204080f + 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-boxed-primitive@npm:^1.0.2": + version: 1.1.1 + resolution: "which-boxed-primitive@npm:1.1.1" + dependencies: + is-bigint: "npm:^1.1.0" + is-boolean-object: "npm:^1.2.1" + is-number-object: "npm:^1.1.1" + is-string: "npm:^1.1.1" + is-symbol: "npm:^1.1.1" + checksum: 10c0/aceea8ede3b08dede7dce168f3883323f7c62272b49801716e8332ff750e7ae59a511ae088840bc6874f16c1b7fd296c05c949b0e5b357bfe3c431b98c417abe + languageName: node + linkType: hard + +"which-collection@npm:^1.0.1": + version: 1.0.2 + resolution: "which-collection@npm:1.0.2" + dependencies: + is-map: "npm:^2.0.3" + is-set: "npm:^2.0.3" + is-weakmap: "npm:^2.0.2" + is-weakset: "npm:^2.0.3" + checksum: 10c0/3345fde20964525a04cdf7c4a96821f85f0cc198f1b2ecb4576e08096746d129eb133571998fe121c77782ac8f21cbd67745a3d35ce100d26d4e684c142ea1f2 + languageName: node + linkType: hard + +"which-typed-array@npm:^1.1.13": + version: 1.1.20 + resolution: "which-typed-array@npm:1.1.20" + dependencies: + available-typed-arrays: "npm:^1.0.7" + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.4" + for-each: "npm:^0.3.5" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-tostringtag: "npm:^1.0.2" + checksum: 10c0/16fcdada95c8afb821cd1117f0ab50b4d8551677ac08187f21d4e444530913c9ffd2dac634f0c1183345f96344b69280f40f9a8bc52164ef409e555567c2604b + languageName: node + linkType: hard + "which@npm:^1.2.9": version: 1.3.1 resolution: "which@npm:1.3.1" @@ -5635,6 +8122,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 +8174,35 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.3": + version: 8.20.0 + resolution: "ws@npm:8.20.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/956ac5f11738c914089b65878b9223692ace77337ba55379ae68e1ecbeae9b47a0c6eb9403688f609999a58c80d83d99865fe0029b229d308b08c1ef93d4ea14 + 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"