diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..2c36aa3 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,36 @@ +name: Node CI + +on: + push: + pull_request: + +jobs: + pack: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name + runs-on: ubuntu-latest + + steps: + - name: ⬇️ Check out code + uses: actions/checkout@v4 + + - name: 🟢 Enable Corepack + run: corepack enable + + - name: 🟢 Set up Node 20 + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: yarn + + - name: 📦 Install deps, build, pack + run: | + yarn install --frozen-lockfile + yarn package + env: + CI: true + + - name: 📤 Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: imagekit-editor-package + path: builds/imagekit-editor-*.tgz \ No newline at end of file diff --git a/.github/workflows/node-publish.yml b/.github/workflows/node-publish.yml new file mode 100644 index 0000000..c5cfd3c --- /dev/null +++ b/.github/workflows/node-publish.yml @@ -0,0 +1,51 @@ +name: Publish Package to npmjs + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: ⬇️ Check out code + uses: actions/checkout@v4 + + - name: 🟢 Enable Corepack + run: corepack enable + + - name: 🟢 Set up Node 20 + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: yarn + registry-url: 'https://registry.npmjs.org' + + - name: Build and Publish + run: | + yarn install --frozen-lockfile + + yarn build + + # print the NPM user name for validation + npm whoami + + VERSION=$(node -p "require('./package.json').version" ) + + # Only publish stable versions to the latest tag + if [[ "$VERSION" =~ ^[^-]+$ ]]; then + NPM_TAG="latest" + else + NPM_TAG="beta" + fi + + echo "Publishing $VERSION with $NPM_TAG tag." + + yarn workspace @imagekit/editor npm publish --tag "$NPM_TAG" --provenance --access public + + env: + NODE_AUTH_TOKEN: ${{secrets.npm_token}} + CI: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cc927d --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.DS_* +*.log +logs +**/*.backup.* +**/*.back.* + +node_modules +bower_components + +*.sublime* + +psd +thumb +sketch + +packages/imagekit-editor/dist/* +packages/imagekit-editor/*.tgz +.turbo +.yarn +builds \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..af5adff --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +lint-staged \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2789c26 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v20.13.0 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..29adfaa --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "editor.tabSize": 2, + "typescript.preferences.importModuleSpecifier": "relative", + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "editor.codeActionsOnSave": { + "source.organizeImports.biome": "explicit", + "source.fixAll.biome": "explicit" + }, + "typescript.tsdk": "node_modules/typescript/lib", + "editor.defaultFormatter": "biomejs.biome", + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + } +} diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..270d9e8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,263 @@ +# Contributing to ImageKit Editor + +Thank you for your interest in contributing to the ImageKit Editor! We welcome contributions from the community and are grateful for your support. + +## Table of Contents + +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [Project Structure](#project-structure) +- [Making Changes](#making-changes) +- [Testing](#testing) +- [Submitting Changes](#submitting-changes) +- [Coding Standards](#coding-standards) +- [Commit Message Guidelines](#commit-message-guidelines) +- [Issue Guidelines](#issue-guidelines) +- [Pull Request Guidelines](#pull-request-guidelines) + +## Getting Started + +1. Fork the repository on GitHub +2. Clone your fork locally +3. Set up the development environment +4. Create a new branch for your changes +5. Make your changes +6. Test your changes +7. Submit a pull request + +## Development Setup + +### Prerequisites + +- Node.js (version 20, for development) +- Yarn package manager +- Git + +### Installation + +1. Clone your fork: +```bash +git clone https://github.com/YOUR_USERNAME/imagekit-editor.git +cd imagekit-editor +``` + +2. Install dependencies: +```bash +yarn install +``` + +3. Start the development server: +```bash +yarn dev +``` + +4. Build the project: +```bash +yarn build +``` + +5. Generate a tarball for testing (optional): +```bash +yarn package +``` + +This builds the project and creates a `.tgz` file in the `builds/` directory that you can use to test the package locally in other projects before publishing. + +## Project Structure + +``` +imagekit-editor/ +├── packages/ +│ ├── imagekit-editor/ # Published package +│ │ ├── dist/ # Built files +│ │ └── package.json +│ └── imagekit-editor-dev/ # Development package +│ ├── src/ # Source code +│ │ ├── components/ # React components +│ │ ├── hooks/ # Custom hooks +│ │ ├── schema/ # Validation schemas +│ │ ├── utils/ # Utility functions +│ │ ├── ImageKitEditor.tsx +│ │ ├── index.tsx +│ │ └── store.ts # State management +│ ├── package.json +│ ├── tsconfig.json +│ └── vite.config.ts +├── README.md +├── CONTRIBUTING.md +└── package.json +``` + +## Making Changes + +### Before You Start + +1. Check if there's already an issue for what you want to work on +2. If not, create an issue to discuss your proposed changes +3. Wait for feedback from maintainers before starting work on large changes + +### Development Workflow + +1. Create a new branch from `main`: +```bash +git checkout -b feature/your-feature-name +``` + +2. Make your changes in the `packages/imagekit-editor-dev/src` directory + +3. Test your changes: +```bash +yarn dev # Start development server +yarn build # Build the project +``` + +4. Commit your changes following our [commit message guidelines](#commit-message-guidelines) + +5. Push your branch and create a pull request + +## Testing + +Currently, the project uses manual testing through the development server. When contributing: + +1. Test your changes thoroughly in the development environment +2. Verify that the build process completes successfully +3. Test with different image types and transformations +4. Ensure TypeScript compilation passes without errors + +## Submitting Changes + +### Pull Request Process + +1. Update the README.md if your changes affect the public API +2. Ensure your code follows the project's coding standards +3. Write clear, descriptive commit messages +4. Include a detailed description of your changes in the PR +5. Link any related issues in your PR description + +### Pull Request Template + +When creating a pull request, please include: + +- **Description**: What changes does this PR introduce? +- **Motivation**: Why are these changes needed? +- **Testing**: How have you tested these changes? +- **Screenshots**: If applicable, include screenshots of UI changes +- **Breaking Changes**: List any breaking changes +- **Related Issues**: Link to related issues + +## Coding Standards + +### TypeScript + +- Use TypeScript for all new code +- Provide proper type definitions +- Avoid using `any` type +- Use interfaces for object shapes +- Export types that might be useful for consumers + +### React + +- Use functional components with hooks +- Follow React best practices +- Use proper prop types and interfaces +- Implement proper error boundaries where needed + +### Code Style + +- Use 2 spaces for indentation +- Use semicolons +- Use double quotes for strings +- Use trailing commas in objects and arrays +- Follow ESLint and Prettier configurations + +## Commit Message Guidelines + +We follow conventional commit format: + +``` +: + +[optional body] + +[optional footer] +``` + +### Types + +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation changes +- `style`: Code style changes (formatting, etc.) +- `refactor`: Code refactoring +- `test`: Adding or updating tests +- `chore`: Maintenance tasks + +### Examples + +``` +feat: add new transformation type for image rotation + +fix: resolve issue with signed URL generation + +docs: update API documentation for signer function + +refactor: improve component structure for better maintainability +``` + +## Issue Guidelines + +### Bug Reports + +When reporting bugs, please include: + +- Clear description of the issue +- Steps to reproduce +- Expected behavior +- Actual behavior +- Browser and version information +- Screenshots or error messages if applicable + +### Feature Requests + +When requesting features, please include: + +- Clear description of the feature +- Use case and motivation +- Proposed implementation (if you have ideas) +- Any related issues or discussions + +## Pull Request Guidelines + +### Before Submitting + +- [ ] Code follows the project's coding standards +- [ ] Changes have been tested thoroughly +- [ ] Documentation has been updated if needed +- [ ] Commit messages follow the conventional format +- [ ] No breaking changes without discussion +- [ ] TypeScript compilation passes +- [ ] Build process completes successfully + +### Review Process + +1. Maintainers will review your PR +2. Address any feedback or requested changes +3. Once approved, your PR will be merged +4. Your changes will be included in the next release + +## Getting Help + +If you need help or have questions: + +- Check existing issues and discussions +- Create a new issue for bugs or feature requests +- Reach out to maintainers via email: [developer@imagekit.io](mailto:developer@imagekit.io) +- Join our community forum: [community.imagekit.io](https://community.imagekit.io) + +## Recognition + +Contributors will be recognized in our release notes and documentation. Thank you for helping make ImageKit Editor better! + +--- + +By contributing to this project, you agree that your contributions will be licensed under the same MIT License that covers the project. diff --git a/README.md b/README.md index bd21963..4b64ad9 100644 --- a/README.md +++ b/README.md @@ -1 +1,278 @@ -# ImageKit AI Powered Image Editor +[ImageKit.io](https://imagekit.io) + +# ImageKit Editor + +[![npm version](https://img.shields.io/npm/v/@imagekit/editor)](https://www.npmjs.com/package/@imagekit/editor) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Twitter Follow](https://img.shields.io/twitter/follow/imagekitio?label=Follow&style=social)](https://twitter.com/ImagekitIo) + +A powerful, React-based image editor component powered by ImageKit transformations. This editor provides an intuitive interface for applying various image transformations, managing transformation history, and exporting edited images. + +## Features + +- 🖼️ **Visual Image Editor**: Interactive UI for applying ImageKit transformations +- 📝 **Transformation History**: Track and manage applied transformations using ImageKit's chain transformations +- 🎨 **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 + +## Installation + +Install the package using npm or yarn: + +```bash +npm install @imagekit/editor +``` + +### Peer Dependencies + +The editor requires the following peer dependencies to be installed in your project: + +```bash +npm install @chakra-ui/icons@1.1.1 @chakra-ui/react@~1.8.9 @emotion/react@^11.14.0 @emotion/styled@^11.14.1 framer-motion@6.5.1 react@^17.0.2 react-dom@^17.0.2 react-select@^5.2.1 +``` + +## Quick Start + +```tsx +import { + Icon, + Portal, +} from '@chakra-ui/react'; +import { DownloadIcon } from '@chakra-ui/icons'; +import type { ImageKitEditorProps, ImageKitEditorRef } from '@imagekit/editor'; +import { ImageKitEditor } from '@imagekit/editor'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface Props { + selectedFiles: Array<{ + url: string; + name: string; + // ... other file properties + }>; + onClose(): void; +} + +const ImageEditor = ({ selectedFiles, onClose }: Props) => { + const [editorProps, setEditorProps] = useState(); + const imagekitEditorRef = useRef(null); + + const initEditor = useCallback(async () => { + const initialImages = selectedFiles.map(file => ({ + src: file.url, + metadata: { + requireSignedUrl: file.url.includes('ik-s='), + ...file, + }, + })); + + setEditorProps({ + initialImages, + onClose, + // Optional: Add signer for private images + signer: async ({ metadata, transformation }) => { + // Your URL signing logic here + return getSignedUrl(metadata, transformation); + }, + // Optional: Add custom export options + exportOptions: { + label: 'Download Options', + icon: , + options: [ + { + label: 'Download', + isVisible: (imageList: string[]) => imageList.length === 1, + onClick: (imageList: string[]) => { + // Your single file download logic here + }, + }, + { + label: 'Copy URLs', + isVisible: (imageList: string[]) => imageList.length > 0, + onClick: async (imageList: string[]) => { + // Your copy URLs logic here + + const urlsText = imageList.join('\n'); + await navigator.clipboard.writeText(urlsText); + }, + }, + ], + }, + }); + }, [selectedFiles, onClose]); + + useEffect(() => { + initEditor(); + }, [initEditor]); + + if (!editorProps) { + return null; + } + + return ( + + + + ); +}; + +export default ImageEditor; +``` + +## API Reference + +### ImageKitEditor Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `onClose` | `() => void` | ✅ | Callback function called when the editor is closed | +| `initialImages` | `Array` | ❌ | Initial images to load in the editor | +| `signer` | `Signer` | ❌ | Function to generate signed URLs for private images | +| `onAddImage` | `() => void` | ❌ | Callback function for adding new images | +| `exportOptions` | `ExportOptions` | ❌ | Configuration for export functionality | + +### ImageKitEditor Ref Methods + +The editor exposes the following methods through a ref: + +```tsx +interface ImageKitEditorRef { + loadImage: (image: string | FileElement) => void; + loadImages: (images: Array) => void; + setCurrentImage: (imageSrc: string) => void; +} +``` + +### Export Options + +You can configure export functionality in two ways: + +#### Simple Export +```tsx +exportOptions={{ + label: 'Download', + icon: , + onClick: (images: string[]) => { + // Handle export + } +}} +``` + +#### Multiple Export Options +```tsx +exportOptions={{ + label: 'Export', + options: [ + { + label: 'Download JSON', + isVisible: true, + onClick: (images: string[]) => { + // Export transformation data + } + }, + { + label: 'Copy URLs', + isVisible: (images) => images.length > 0, + onClick: (images: string[]) => { + // Copy image URLs + } + } + ] +}} +``` + +### File Element Interface + +For advanced use cases with metadata and signed URLs: + +```tsx +interface FileElement { + src: string; + metadata: Metadata & { + requireSignedUrl?: boolean; + }; +} +``` + +The `metadata` object can contain any contextual information your application needs, such as file IDs, user context, etc. This metadata is passed to the signer function, allowing you to generate appropriate signed URLs based on the specific file and user context. + +## Advanced Usage + +### Signed URLs + +For private images that require signed URLs, you can pass file metadata that will be available in the signer function: + +```tsx +import { Signer, ImageKitEditor } from '@imagekit/editor'; + +interface Metadata { + requireSignedUrl: boolean; // Required for signed URL generation + + // Add any other metadata properties you need, for example: + fileId: string; + userId: string; +} + +const signer: Signer = async (signerRequest, abortController) => { + // Access file context from the signer request + const { url, transformation, metadata } = signerRequest; + const { fileId, userId } = metadata; + + // Your signing logic using the metadata context + // The abortController can be used to cancel the request if needed + return await generateSignedUrl({ + url, + fileId, + userId, + transformation, + signal: abortController?.signal, // Pass abort signal to your API call + }); +}; + + +``` + +## TypeScript Support + +The editor is built with TypeScript and provides comprehensive type definitions. You can import types for better development experience: + +```tsx +import type { + ImageKitEditorProps, + ImageKitEditorRef, + FileElement, + Signer +} from '@imagekit/editor'; +``` + +## Contributing + +We welcome contributions! Please see our [contributing guidelines](./CONTRIBUTING.md) for more details. + +## Support + +- 📖 [Documentation](https://imagekit.io/docs) +- 💬 [Community Forum](https://community.imagekit.io) +- 📧 [Support Email](mailto:support@imagekit.io) + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +--- + +Made with ❤️ by [ImageKit](https://imagekit.io) diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..d32c43f --- /dev/null +++ b/biome.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.1.1/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!**/node_modules/**", "!**/builds/**", "!**/dist/**"], + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "useExhaustiveDependencies": "warn" + }, + "nursery": { + "useUniqueElementIds": "off" + }, + "a11y": { + "useSemanticElements": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "asNeeded" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/examples/react-example/.gitignore b/examples/react-example/.gitignore new file mode 100644 index 0000000..4d29575 --- /dev/null +++ b/examples/react-example/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/react-example/index.html b/examples/react-example/index.html new file mode 100644 index 0000000..b7ed18a --- /dev/null +++ b/examples/react-example/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + ImageKit Editor - React Example + + + + + +
+ + + + \ No newline at end of file diff --git a/examples/react-example/package.json b/examples/react-example/package.json new file mode 100644 index 0000000..0587ae3 --- /dev/null +++ b/examples/react-example/package.json @@ -0,0 +1,41 @@ +{ + "name": "react-example", + "version": "0.1.0", + "private": true, + "dependencies": { + "@imagekit/editor": "1.0.0", + "@types/node": "^20.11.24", + "@types/react": "^17.0.2", + "@types/react-dom": "^17.0.2", + "react": "^17.0.2", + "react-dom": "^17.0.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.5.2", + "typescript": "4.9.3", + "vite": "^6.3.5" + }, + "scripts": { + "dev": "vite --port 3000", + "start": "vite --port 3000", + "preview": "vite preview" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/react-example/public/favicon.ico b/examples/react-example/public/favicon.ico new file mode 100644 index 0000000..a11777c Binary files /dev/null and b/examples/react-example/public/favicon.ico differ diff --git a/examples/react-example/public/logo192.png b/examples/react-example/public/logo192.png new file mode 100644 index 0000000..fc44b0a Binary files /dev/null and b/examples/react-example/public/logo192.png differ diff --git a/examples/react-example/public/logo512.png b/examples/react-example/public/logo512.png new file mode 100644 index 0000000..a4e47a6 Binary files /dev/null and b/examples/react-example/public/logo512.png differ diff --git a/examples/react-example/public/manifest.json b/examples/react-example/public/manifest.json new file mode 100644 index 0000000..080d6c7 --- /dev/null +++ b/examples/react-example/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/react-example/public/robots.txt b/examples/react-example/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/examples/react-example/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/examples/react-example/src/index.tsx b/examples/react-example/src/index.tsx new file mode 100644 index 0000000..c227ce9 --- /dev/null +++ b/examples/react-example/src/index.tsx @@ -0,0 +1,104 @@ +import { Icon } from "@chakra-ui/react" +import { ImageKitEditor, type ImageKitEditorProps } from "@imagekit/editor" +import type { ImageKitEditorRef } from "@imagekit/editor/dist/ImageKitEditor" +import { PiDownload } from "@react-icons/all-files/pi/PiDownload" +import React, { useCallback, useEffect } from "react" +import ReactDOM from "react-dom" + +function App() { + const [open, setOpen] = React.useState(true) + const [editorProps, setEditorProps] = + React.useState>() + const ref = React.useRef(null) + + /** + * Function moved from EditorLayout component + * Adds a random image with timestamp to ensure uniqueness + */ + const handleAddImage = useCallback(() => { + const timestamp = Date.now() + const randomImage = `https://ik.imagekit.io/v3sxk1svj/placeholder.jpg?updatedAt=${timestamp}` + ref.current?.loadImage(randomImage) + }, []) + + useEffect(() => { + setEditorProps({ + initialImages: [ + { + url: "https://ik.imagekit.io/v3sxk1svj/white%20BMW%20car%20on%20street.jpg", + metadata: { + requireSignedUrl: false, + }, + }, + { + url: "https://ik.imagekit.io/v3sxk1svj/Young%20Living%20Patchouili%20bot....jpg", + metadata: { + requireSignedUrl: false, + }, + }, + // ...Array.from({ length: 10000 }).map((_, i) => ({ + // url: `https://ik.imagekit.io/v3sxk1svj/placeholder.jpg?updatedAt=${Date.now()}&v=${i}`, + // metadata: { + // requireSignedUrl: false, + // }, + // })), + ], + onAddImage: handleAddImage, + onClose: () => setOpen(false), + exportOptions: [ + { + type: "button", + label: "Export", + icon: , + isVisible: true, + onClick: (images) => { + console.log(images) + }, + }, + { + type: "menu", + label: "Export", + icon: , + isVisible: true, + options: [ + { + label: "Export", + icon: , + isVisible: true, + onClick: (images) => { + console.log(images) + }, + }, + ], + }, + ], + signer: async (request) => { + console.log(request) + await new Promise((resolve) => setTimeout(resolve, 10000)) + console.log("Signed URL", request.url) + return Promise.resolve(request.url) + }, + }) + }, [handleAddImage]) + + const toggle = () => { + setOpen((prev) => !prev) + } + + return ( + <> + + {open && editorProps && } + + ) +} + +const root = document.getElementById("root") +ReactDOM.render( + + + , + root, +) diff --git a/examples/react-example/src/logo.svg b/examples/react-example/src/logo.svg new file mode 100644 index 0000000..9dfc1c0 --- /dev/null +++ b/examples/react-example/src/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/react-example/tsconfig.json b/examples/react-example/tsconfig.json new file mode 100644 index 0000000..4af9b48 --- /dev/null +++ b/examples/react-example/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "noUnusedLocals": false + }, + "include": ["src"] +} diff --git a/examples/react-example/vite.config.ts b/examples/react-example/vite.config.ts new file mode 100644 index 0000000..f713b90 --- /dev/null +++ b/examples/react-example/vite.config.ts @@ -0,0 +1,26 @@ +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +export default defineConfig({ + plugins: [ + react({ + // Explicitly set to use React 17 JSX transform + jsxRuntime: "classic", + }), + ], + resolve: { + extensions: [".ts", ".tsx", ".js", ".jsx", ".json"], + }, + server: { + port: 3000, + open: true, + }, + clearScreen: false, + appType: "spa", + build: { + outDir: "build", + sourcemap: true, + emptyOutDir: true, + }, + publicDir: "public", +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..14f4ec4 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "imagekit-editor", + "private": true, + "description": "AI Image Editor powered by ImageKit", + "keywords": [], + "author": { + "name": "ImageKit Developer", + "email": "developer@imagekit.io", + "url": "https://imagekit.io" + }, + "workspaces": [ + "packages/*", + "examples/*" + ], + "scripts": { + "dev": "turbo run dev", + "start": "turbo run start", + "build": "turbo run build", + "version": "yarn workspace @imagekit/editor version", + "package": "yarn build && yarn workspace @imagekit/editor pack --out ../../builds/imagekit-editor-%v.tgz", + "release": "yarn build && yarn workspace @imagekit/editor pack --out ../../builds/imagekit-editor-%v.tgz && yarn workspace @imagekit/editor publish", + "prepare": "husky", + "lint": "biome ci" + }, + "devDependencies": { + "@biomejs/biome": "2.1.1", + "husky": "^9.1.7", + "lint-staged": "^16.1.2", + "turbo": "^2.0.1" + }, + "packageManager": "yarn@4.9.2", + "lint-staged": { + "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": [ + "biome ci --files-ignore-unknown=true" + ], + "*": [ + "biome ci --files-ignore-unknown=true" + ] + }, + "dependencies": { + "react-select": "^5.2.1" + } +} diff --git a/packages/imagekit-editor-dev/.babelrc b/packages/imagekit-editor-dev/.babelrc new file mode 100644 index 0000000..e5e62ce --- /dev/null +++ b/packages/imagekit-editor-dev/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + "@babel/preset-env", + ["@babel/preset-react", { + "runtime": "automatic" + }], + "@babel/preset-typescript" + ], + "plugins": [ + ["@emotion/babel-plugin"] + ] +} diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json new file mode 100644 index 0000000..45bbed6 --- /dev/null +++ b/packages/imagekit-editor-dev/package.json @@ -0,0 +1,61 @@ +{ + "name": "imagekit-editor-dev", + "version": "0.0.0", + "description": "AI Image Editor powered by ImageKit", + "scripts": { + "prepack": "yarn build", + "build": "vite build", + "dev": "DEBUG=* vite build --watch", + "start": "vite build --watch", + "analyze": "vite build --mode analyze", + "preview": "vite preview" + }, + "keywords": [], + "author": { + "name": "ImageKit Developer", + "email": "developer@imagekit.io", + "url": "https://imagekit.io" + }, + "peerDependencies": { + "react": ">=16.8.6", + "react-dom": ">=16.8.6" + }, + "devDependencies": { + "@emotion/babel-plugin": "^11.13.5", + "@microsoft/api-extractor": "7.34.9", + "@types/lodash": "^4", + "@types/node": "^20.11.24", + "@types/react": "^17.0.2", + "@types/react-color": "^2", + "@types/react-dom": "^17.0.2", + "@vitejs/plugin-react": "^4.5.2", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "rollup-plugin-visualizer": "^5.12.0", + "terser": "^5.43.1", + "typescript": "4.9.3", + "vite": "^6.3.5", + "vite-plugin-dts": "5.0.0-beta.3" + }, + "dependencies": { + "@chakra-ui/icons": "1.1.1", + "@chakra-ui/react": "1.8.9", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@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", + "framer-motion": "6.5.1", + "imagekit-javascript": "^3.0.2", + "lodash": "^4.17.21", + "react-best-gradient-color-picker": "^3.0.14", + "react-hook-form": "^7.60.0", + "react-select": "^5.2.1", + "zod": "^3.25.0", + "zustand": "4.5.7" + }, + "license": "MIT" +} diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx new file mode 100644 index 0000000..bd9f3db --- /dev/null +++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx @@ -0,0 +1,103 @@ +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 { EditorLayout, EditorWrapper } from "./components/editor" +import type { HeaderProps } from "./components/header" +import { + type FileElement, + type RequiredMetadata, + type Signer, + useEditorStore, +} from "./store" +import { themeOverrides } from "./theme" + +export interface ImageKitEditorRef { + loadImage: (image: string | FileElement) => void + loadImages: (images: Array) => void + setCurrentImage: (imageSrc: string) => void +} + +interface EditorProps { + theme?: Dict + initialImages?: Array> + signer?: Signer + onAddImage?: () => void + exportOptions?: HeaderProps["exportOptions"] + + onClose: (args: { dirty: boolean; destroy: () => void }) => void +} + +function ImageKitEditorImpl( + props: EditorProps, + ref: React.Ref, +) { + const { theme, initialImages, signer } = props + const { + addImage, + addImages, + setCurrentImage, + transformations, + initialize, + destroy, + } = useEditorStore() + + const handleOnClose = () => { + const dirty = transformations.length > 0 + props.onClose({ dirty, destroy }) + } + + React.useEffect(() => { + if ( + initialImages?.some( + (img) => typeof img !== "string" && img.metadata.requireSignedUrl, + ) && + !signer + ) { + console.warn( + "ImageKitEditor: Some images require signed URL but no signer function is provided", + ) + } + + initialize({ + imageList: initialImages, + signer, + }) + }, [initialImages, signer, initialize]) + + useImperativeHandle( + ref, + () => ({ + loadImage: addImage, + loadImages: addImages, + setCurrentImage, + }), + [addImage, addImages, setCurrentImage], + ) + + const mergedThemes = merge(defaultTheme, themeOverrides, theme) + + return ( + + + + + + + + ) +} + +type ImageKitEditorComponent = ( + props: EditorProps & React.RefAttributes, +) => React.ReactElement | null + +export const ImageKitEditor = forwardRef( + ImageKitEditorImpl, +) as ImageKitEditorComponent + +export type { EditorProps as ImageKitEditorProps } diff --git a/packages/imagekit-editor-dev/src/components/RetryableImage.tsx b/packages/imagekit-editor-dev/src/components/RetryableImage.tsx new file mode 100644 index 0000000..1f19380 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/RetryableImage.tsx @@ -0,0 +1,257 @@ +import type { ImageProps } from "@chakra-ui/react" +import { + Box, + Button, + Center, + Image as ChakraImage, + Flex, + Spinner, + Text, + VStack, +} from "@chakra-ui/react" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useVisibility } from "../hooks/useVisibility" + +export interface RetryableImageProps extends ImageProps { + maxRetries?: number + retryDelay?: number + onRetryExhausted?: () => void + onRetry?: (attempt: number) => void + showRetryButton?: boolean + compactError?: boolean + isLoading?: boolean + lazy?: boolean + rootMargin?: string + intersectionRoot?: Element | null +} + +const baseUrl = (url?: string) => { + if (!url) return "" + try { + const u = new URL( + url, + typeof window !== "undefined" ? window.location.href : undefined, + ) + return `${u.origin}${u.pathname}` + } catch { + return url.split("?")[0].split("#")[0] + } +} + +export default function RetryableImage(props: RetryableImageProps) { + const { + src, + maxRetries = 10, + retryDelay = 10000, + onRetryExhausted, + onRetry, + showRetryButton = true, + compactError = false, + isLoading: externalLoading, + alt, + lazy = true, + rootMargin = "400px", + intersectionRoot, + ...imgProps + } = props + + const [loading, setLoading] = useState(false) + const [error, setError] = useState<{ message: string } | null>(null) + const [attempt, setAttempt] = useState(0) + const [displayedSrc, setDisplayedSrc] = useState( + undefined, + ) + const [probing, setProbing] = useState(false) + + const currentSrcBase = useMemo( + () => baseUrl(typeof src === "string" ? src : undefined), + [src], + ) + const lastSuccessBaseRef = useRef("") + + const retryTimeoutRef = useRef(null) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + if (retryTimeoutRef.current) window.clearTimeout(retryTimeoutRef.current) + } + }, []) + + const { ref: rootRef, visible } = useVisibility( + lazy, + rootMargin, + intersectionRoot ?? undefined, + ) + + const beginLoad = useCallback(() => { + if (!mountedRef.current || !src) return + + if ( + lastSuccessBaseRef.current && + lastSuccessBaseRef.current === currentSrcBase + ) { + setLoading(true) + setError(null) + } else { + setDisplayedSrc(undefined) + setLoading(true) + setError(null) + } + + setProbing(true) + }, [currentSrcBase, src]) + + useEffect(() => { + if (!src) return + if (lazy && !visible) return + setAttempt(0) + beginLoad(0) + }, [src, visible, lazy]) + + const scheduleRetry = useCallback(() => { + if (attempt + 1 > maxRetries) { + setLoading(false) + setError({ message: "Image failed to load after retries." }) + onRetryExhausted?.() + setProbing(false) + return + } + const next = attempt + 1 + setAttempt(next) + onRetry?.(next) + retryTimeoutRef.current = window.setTimeout(() => { + if (!mountedRef.current) return + beginLoad() + }, retryDelay) + }, [attempt, maxRetries, onRetry, onRetryExhausted, retryDelay, beginLoad]) + + const handleProbeLoad = () => { + if (!src) return + setDisplayedSrc(String(src)) + setLoading(false) + setError(null) + setProbing(false) + lastSuccessBaseRef.current = currentSrcBase + } + + const handleProbeError = () => { + setProbing(false) + scheduleRetry() + } + + const overlayActive = !!externalLoading || loading + + const handleVisibleLoad = () => { + setLoading(false) + setError(null) + lastSuccessBaseRef.current = currentSrcBase + } + const handleVisibleError = () => { + scheduleRetry() + } + + return ( + + {error ? ( +
+ + + {!compactError && ( + + Failed to load image + {maxRetries ? ` after ${maxRetries} attempts` : ""} + + )} + {compactError && ( + + ❌ + + )} + {showRetryButton && !compactError && ( + + )} + + +
+ ) : ( + <> + {displayedSrc ? ( + <> + + {overlayActive && ( +
+ +
+ )} + + ) : ( +
+ {lazy && !visible ? : } +
+ )} + + {probing && src && ( + + )} + + )} +
+ ) +} + +export const MemoRetryableImage = React.memo(RetryableImage) diff --git a/packages/imagekit-editor-dev/src/components/common/AnchorField.tsx b/packages/imagekit-editor-dev/src/components/common/AnchorField.tsx new file mode 100644 index 0000000..885f919 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/AnchorField.tsx @@ -0,0 +1,111 @@ +import { Box, Button, Flex, Tooltip } from "@chakra-ui/react" +import { memo } from "react" + +type AnchorPosition = + | "center" + | "top" + | "bottom" + | "left" + | "right" + | "top_left" + | "top_right" + | "bottom_left" + | "bottom_right" + | string + +interface AnchorFieldProps { + value: AnchorPosition + onChange: (value: AnchorPosition) => void + positions?: AnchorPosition[] +} + +const Positions: Array<{ + value: AnchorPosition + label: string + col: number + row: number +}> = [ + { value: "top_left", label: "Top Left", col: 0, row: 0 }, + { value: "top", label: "Top", col: 1, row: 0 }, + { value: "top_right", label: "Top Right", col: 2, row: 0 }, + { value: "left", label: "Left", col: 0, row: 1 }, + { value: "center", label: "Center", col: 1, row: 1 }, + { value: "right", label: "Right", col: 2, row: 1 }, + { value: "bottom_left", label: "Bottom Left", col: 0, row: 2 }, + { value: "bottom", label: "Bottom", col: 1, row: 2 }, + { value: "bottom_right", label: "Bottom Right", col: 2, row: 2 }, +] + +const AnchorField: React.FC = ({ + value, + onChange, + positions = [ + "center", + "top", + "bottom", + "left", + "right", + "top_left", + "top_right", + "bottom_left", + "bottom_right", + ], +}) => { + return ( + + + + {Positions.map((position) => ( + + + + ))} + + + + ) +} + +export default memo(AnchorField) diff --git a/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx b/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx new file mode 100644 index 0000000..1a870bd --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx @@ -0,0 +1,129 @@ +import { + Box, + Flex, + HStack, + Icon, + Text, + useColorModeValue, +} from "@chakra-ui/react" +import type * as React from "react" + +type CheckboxCardOption = { + label: string + value: string + icon?: React.ReactNode + isDisabled?: boolean +} + +type CheckboxCardFieldProps = { + id?: string + value?: string[] + options: CheckboxCardOption[] + onChange: (values: string[]) => void + columns?: number + maxSelections?: number +} + +const toggleValue = ( + current: string[] = [], + v: string, + max?: number, +): string[] => { + const set = new Set(current) + if (set.has(v)) { + set.delete(v) + return Array.from(set) + } + // add + if (typeof max === "number" && current.length >= max) return current + set.add(v) + return Array.from(set) +} + +export const CheckboxCardField: React.FC = ({ + id, + value = [], + options, + onChange, + columns = 3, + maxSelections, +}) => { + const selectedBg = useColorModeValue("blue.50", "blue.900") + const selectedBorder = useColorModeValue("blue.400", "blue.300") + const hoverBg = useColorModeValue("gray.50", "whiteAlpha.100") + const isMaxed = + typeof maxSelections === "number" && value.length >= maxSelections + + const handleKeyDown = ( + e: React.KeyboardEvent, + v: string, + disabled?: boolean, + ) => { + if (disabled) return + if (e.key === " " || e.key === "Enter") { + e.preventDefault() + onChange(toggleValue(value, v, maxSelections)) + } + } + + return ( + [data-checkbox-card]": { + flexBasis: `calc(${100 / columns}% - 8px)`, + minWidth: 0, + }, + }} + > + {options.map((opt) => { + const isChecked = value.includes(opt.value) + const disabled = opt.isDisabled || (!isChecked && isMaxed) + return ( + { + if (disabled) return + onChange(toggleValue(value, 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 CheckboxCardField diff --git a/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx b/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx new file mode 100644 index 0000000..76eee42 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx @@ -0,0 +1,119 @@ +import { + Flex, + Input, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, +} from "@chakra-ui/react" +import { memo, useEffect, useState } from "react" +import ColorPicker from "react-best-gradient-color-picker" +import { useDebounce } from "../../hooks/useDebounce" + +const ColorPickerField = ({ + fieldName, + value, + setValue, +}: { + fieldName: string + value: string + setValue: (name: string, value: string) => void +}) => { + const [localValue, setLocalValue] = useState(value) + + const handleColorChange = (color: string) => { + const parts = color.match(/[\d.]+/g)?.map(Number) ?? [] + + if (parts.length < 3) return + + const [r, g, b, a] = parts + + const clamp8 = (v: number) => Math.max(0, Math.min(255, v)) + + const rgbHex = [r, g, b] + .map(clamp8) + .map((v) => v.toString(16).padStart(2, "0")) + .join("") + + if (a === undefined) { + setLocalValue(`#${rgbHex}`) + } else { + const alphaDec = a > 1 ? a / 100 : a + const alphaInt = clamp8(Math.round(alphaDec * 255)) + setLocalValue(`#${rgbHex}${alphaInt.toString(16).padStart(2, "0")}`) + } + } + + const debouncedValue = useDebounce(localValue, 500) + + useEffect(() => { + setValue(fieldName, debouncedValue) + }, [debouncedValue, fieldName, setValue]) + + return ( + + + { + const newValue = e.target.value + if (newValue.match(/^#[0-9A-Fa-f]{0,8}$/)) { + setLocalValue(newValue) + } else if (newValue === "") { + setLocalValue("") + } + }} + borderColor="gray.200" + placeholder="#FFFFFF" + fontFamily="mono" + borderRadius="4px" + borderRightRadius="0" + width="calc(100% - var(--chakra-space-10))" + /> + + + + + + + + + + + + + ) +} + +export default memo(ColorPickerField) diff --git a/packages/imagekit-editor-dev/src/components/common/Hover.tsx b/packages/imagekit-editor-dev/src/components/common/Hover.tsx new file mode 100644 index 0000000..f3cfd1d --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/Hover.tsx @@ -0,0 +1,49 @@ +import { Box, type BoxProps, Flex, type FlexProps } from "@chakra-ui/react" +import { useState } from "react" + +interface FlexHoverProps extends FlexProps { + children(isHover: boolean): JSX.Element +} + +interface BoxHoverProps extends BoxProps { + children(isHover: boolean): JSX.Element +} + +const Hover = ({ + children, + ...props +}: BoxHoverProps | FlexHoverProps): JSX.Element => { + const [isHover, setIsHover] = useState(false) + + if (props.display === "flex") { + return ( + { + setIsHover(true) + }} + onMouseLeave={() => { + setIsHover(false) + }} + > + {children(isHover)} + + ) + } + + return ( + { + setIsHover(true) + }} + onMouseLeave={() => { + setIsHover(false) + }} + > + {children(isHover)} + + ) +} + +export default Hover diff --git a/packages/imagekit-editor-dev/src/components/common/RadioCardField.tsx b/packages/imagekit-editor-dev/src/components/common/RadioCardField.tsx new file mode 100644 index 0000000..7f0d82e --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/common/RadioCardField.tsx @@ -0,0 +1,94 @@ +import { + Box, + Flex, + HStack, + Icon, + Text, + useColorModeValue, +} from "@chakra-ui/react" +import type * as React from "react" + +type RadioCardOption = { + label: string + value: string + icon?: React.ReactNode +} + +type RadioCardFieldProps = { + id?: string + value?: string | null + options: RadioCardOption[] + onChange: (value: string) => void + columns?: number +} + +export const RadioCardField: React.FC = ({ + id, + value, + options, + onChange, + columns = 3, +}) => { + const selectedBg = useColorModeValue("blue.50", "blue.900") + const selectedBorder = useColorModeValue("blue.400", "blue.300") + const hoverBg = useColorModeValue("gray.50", "whiteAlpha.100") + + const handleKeyDown = (e: React.KeyboardEvent, v: string) => { + if (e.key === " " || e.key === "Enter") { + e.preventDefault() + onChange(v) + } + } + + return ( + [data-radio-card]": { + flexBasis: `calc(${100 / columns}% - 8px)`, + minWidth: 0, + }, + }} + > + {options.map((opt) => { + const isSelected = value === opt.value + return ( + onChange(opt.value)} + onKeyDown={(e) => handleKeyDown(e, opt.value)} + cursor="pointer" + borderWidth="1px" + borderRadius="md" + p="2" + transition="all 0.12s ease-in-out" + borderColor={isSelected ? selectedBorder : "gray.200"} + bg={isSelected ? selectedBg : "transparent"} + _hover={{ bg: isSelected ? selectedBg : hoverBg }} + _focusVisible={{ + boxShadow: "0 0 0 2px var(--chakra-colors-blue-400)", + outline: "none", + }} + > + + {opt.icon ? : null} + + {opt.label} + + + + ) + })} + + ) +} + +export default RadioCardField diff --git a/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx b/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx new file mode 100644 index 0000000..8f46c3d --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx @@ -0,0 +1,169 @@ +import { ExternalLinkIcon } from "@chakra-ui/icons" +import { + Box, + Button, + Divider, + Flex, + HStack, + Icon, + IconButton, + Slider, + SliderFilledTrack, + SliderThumb, + SliderTrack, + Text, + Tooltip, +} from "@chakra-ui/react" +import { PiGridFour } from "@react-icons/all-files/pi/PiGridFour" +import { PiImageSquare } from "@react-icons/all-files/pi/PiImageSquare" +import { PiListBullets } from "@react-icons/all-files/pi/PiListBullets" +import { type FC, useEffect, useState } from "react" +import { useEditorStore } from "../../store" + +interface ActionBarProps { + viewMode: "list" | "grid" + setViewMode: (mode: "list" | "grid") => void + gridImageSize: number + setGridImageSize: (size: number) => void +} + +export const ActionBar: FC = ({ + viewMode, + setViewMode, + gridImageSize, + setGridImageSize, +}) => { + const { currentImage, showOriginal, setShowOriginal } = useEditorStore() + + const [imageDimensions, setImageDimensions] = useState<{ + width: number + height: number + } | null>(null) + + useEffect(() => { + if (currentImage) { + const img = new Image() + img.onload = () => { + setImageDimensions({ + width: img.naturalWidth, + height: img.naturalHeight, + }) + } + img.src = currentImage + } + }, [currentImage]) + + return ( + + + + + {viewMode === "list" && imageDimensions && ( + <> + + + Dimensions:{" "} + + {imageDimensions.width} x {imageDimensions.height} + + + + )} + + + + + + + + + + {viewMode === "grid" && ( + <> + + + setGridImageSize(val)} + > + + + + + + + + + + )} + + + + } + onClick={() => setViewMode(viewMode === "grid" ? "list" : "grid")} + /> + + + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/editor/GridView.tsx b/packages/imagekit-editor-dev/src/components/editor/GridView.tsx new file mode 100644 index 0000000..1176616 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/editor/GridView.tsx @@ -0,0 +1,170 @@ +import { + Box, + Center, + Flex, + Icon, + IconButton, + Spinner, + Text, +} from "@chakra-ui/react" +import { PiPlus } from "@react-icons/all-files/pi/PiPlus" +import { PiX } from "@react-icons/all-files/pi/PiX" +import type { FC } from "react" +import { useEditorStore } from "../../store" +import Hover from "../common/Hover" +import RetryableImage from "../RetryableImage" + +interface GridViewProps { + imageSize: number + onAddImage?: () => void +} + +export const GridView: FC = ({ imageSize, onAddImage }) => { + const { + currentImage, + setCurrentImage, + imageList, + originalImageList, + signingImages, + removeImage, + } = useEditorStore() + return ( + + + + + + + + Add New Image + + + + + {imageList.map((imageSrc, index) => { + const originalUrl = originalImageList[index]?.url + const key = originalUrl ?? imageSrc + const isSigning = originalUrl ? signingImages[originalUrl] : false + return ( + + {(isHovered) => ( + setCurrentImage(imageSrc)} + borderWidth={imageSrc === currentImage ? "2px" : "1px"} + borderColor={ + imageSrc === currentImage ? "blue.500" : "gray.200" + } + borderStyle="solid" + borderRadius="md" + transition="all 0.2s" + _hover={{ transform: "scale(1.02)", boxShadow: "md" }} + width={`${imageSize}px`} + height={`${imageSize}px`} + display="flex" + alignItems="center" + justifyContent="center" + bg="white" + > + {isHovered && ( + { + e.stopPropagation() + if (originalUrl) { + removeImage(originalUrl) + } + }} + aria-label="Remove image" + icon={} + /> + )} + + + + + } + isLoading={isSigning} + /> + + )} + + ) + })} + + + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/editor/ListView.tsx b/packages/imagekit-editor-dev/src/components/editor/ListView.tsx new file mode 100644 index 0000000..a3936b6 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/editor/ListView.tsx @@ -0,0 +1,67 @@ +import { Center, Flex, Spinner } from "@chakra-ui/react" +import type { FC } from "react" +import { useEditorStore } from "../../store" +import RetryableImage from "../RetryableImage" +import { Toolbar } from "../toolbar" + +interface ListViewProps { + onAddImage?: () => void +} + +export const ListView: FC = ({ onAddImage }) => { + const { + currentImage, + setCurrentImage, + imageList, + originalImageList, + signingImages, + _internalState, + } = useEditorStore() + + return ( + <> + + + + + + } + isLoading={(() => { + const idx = imageList.findIndex((img) => img === currentImage) + if (idx === -1) return false + const originalUrl = originalImageList[idx]?.url + return originalUrl ? signingImages[originalUrl] : false + })()} + /> + + + { + setCurrentImage(imageSrc) + }} + /> + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/editor/index.tsx b/packages/imagekit-editor-dev/src/components/editor/index.tsx new file mode 100644 index 0000000..2100604 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/editor/index.tsx @@ -0,0 +1,2 @@ +export * from "./layout" +export * from "./wrapper" diff --git a/packages/imagekit-editor-dev/src/components/editor/layout.tsx b/packages/imagekit-editor-dev/src/components/editor/layout.tsx new file mode 100644 index 0000000..c724450 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/editor/layout.tsx @@ -0,0 +1,44 @@ +import { Flex } from "@chakra-ui/react" +import { useState } from "react" +import { Header, type HeaderProps } from "../header" +import { Sidebar } from "../sidebar" +import { ActionBar } from "./ActionBar" +import { GridView } from "./GridView" +import { ListView } from "./ListView" + +interface Props { + onAddImage?: () => void + onClose: () => void + exportOptions?: HeaderProps["exportOptions"] +} + +export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) { + const [viewMode, setViewMode] = useState<"list" | "grid">("list") + const [gridImageSize, setGridImageSize] = useState(300) + + return ( + <> +
+ + + + + {viewMode === "list" && } + {viewMode === "grid" && ( + + )} + + + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/editor/wrapper.tsx b/packages/imagekit-editor-dev/src/components/editor/wrapper.tsx new file mode 100644 index 0000000..a1a6101 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/editor/wrapper.tsx @@ -0,0 +1,37 @@ +import { Box, Flex } from "@chakra-ui/react" + +interface EditorWrapperProps { + children: React.ReactNode +} + +export function EditorWrapper({ children }: EditorWrapperProps) { + return ( + + + {children} + + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx new file mode 100644 index 0000000..f16c21c --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/header/index.tsx @@ -0,0 +1,141 @@ +import { + Button, + Divider, + Flex, + Icon, + Menu, + MenuButton, + MenuItem, + MenuList, + Spacer, + Text, +} from "@chakra-ui/react" +import { PiImageSquare } from "@react-icons/all-files/pi/PiImageSquare" +import { PiImagesSquare } from "@react-icons/all-files/pi/PiImagesSquare" +import { PiX } from "@react-icons/all-files/pi/PiX" +import React, { useMemo } from "react" +import { useEditorStore } from "../../store" + +interface ExportOptionButton { + type: "button" + label: string + icon?: React.ReactElement + isVisible: boolean | ((images: string[], currentImage?: string) => boolean) + onClick: (images: string[], currentImage?: string) => void +} + +interface ExportOptionMenu { + type: "menu" + label: string + icon?: React.ReactElement + isVisible: boolean | ((images: string[], currentImage?: string) => boolean) + options: Array> +} + +export interface HeaderProps { + onClose: () => void + exportOptions?: Array +} + +export const Header = ({ onClose, exportOptions }: HeaderProps) => { + const { imageList, currentImage } = useEditorStore() + + const headerText = useMemo(() => { + if (imageList.length === 1) { + return decodeURIComponent( + currentImage?.split("/").pop()?.split("?")?.[0] || "", + ) + } + return `${imageList.length} Images` + }, [imageList, currentImage]) + + return ( + + + {headerText} + + {exportOptions + ?.filter((exportOption) => + typeof exportOption.isVisible === "boolean" + ? exportOption.isVisible + : exportOption.isVisible(imageList, currentImage), + ) + .map((exportOption) => ( + + + {exportOption.type === "button" ? ( + + ) : ( + + + + + + {exportOption.options + .filter((option) => + typeof option.isVisible === "boolean" + ? option.isVisible + : option.isVisible(imageList, currentImage), + ) + .map((option) => ( + option.onClick(imageList, currentImage)} + > + {option.label} + + ))} + + + )} + + ))} + + + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/sidebar/index.tsx b/packages/imagekit-editor-dev/src/components/sidebar/index.tsx new file mode 100644 index 0000000..ed3b953 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/sidebar/index.tsx @@ -0,0 +1,185 @@ +import { Box, Button, HStack, Icon, Text, VStack } from "@chakra-ui/react" +import type { + DragEndEvent, + DragStartEvent, + UniqueIdentifier, +} from "@dnd-kit/core" +import { + closestCenter, + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core" +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable" +import { PiPlus } from "@react-icons/all-files/pi/PiPlus" +import { PiRectangleDashed } from "@react-icons/all-files/pi/PiRectangleDashed" +import { RxTransform } from "@react-icons/all-files/rx/RxTransform" +import { useEffect, useState } from "react" +import { useEditorStore } from "../../store" +import { SidebarBody } from "./sidebar-body" +import { SidebarFooter } from "./sidebar-footer" +import { SidebarHeader } from "./sidebar-header" +import { SidebarRoot } from "./sidebar-root" +import { SortableTransformationItem } from "./sortable-transformation-item" +import { TransformationConfigSidebar } from "./transformation-config-sidebar" + +import { TransformationTypeSidebar } from "./transformation-type-sidebar" + +export const Sidebar = () => { + const { + transformations, + moveTransformation, + _internalState, + _setSidebarState, + _setSelectedTransformationKey, + _setTransformationToEdit, + } = useEditorStore() + + useEffect(() => { + if ( + transformations.length === 0 && + _internalState.sidebarState === "none" + ) { + _setSidebarState("type") + } + }, [transformations.length, _setSidebarState, _internalState.sidebarState]) + + const [activeId, setActiveId] = useState(null) + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + ) + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id) + } + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + + if (over && active.id !== over.id) { + moveTransformation(active.id, over.id) + } + + setActiveId(null) + } + + return ( + <> + + + + Transformations + + + {transformations.length > 0 ? ( + <> + + + transformation.id, + )} + strategy={verticalListSortingStrategy} + > + {transformations.map((transformation) => { + return ( + + + + ) + })} + + + {activeId ? ( + + + + + + { + transformations.find((item) => item.id === activeId) + ?.name + } + + + + ) : null} + + + + + + + + + ) : ( + + + + Select Transformation + + + )} + + + {_internalState.sidebarState === "type" && } + + {_internalState.sidebarState === "config" && ( + + )} + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/sidebar/sidebar-body.tsx b/packages/imagekit-editor-dev/src/components/sidebar/sidebar-body.tsx new file mode 100644 index 0000000..047d357 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/sidebar/sidebar-body.tsx @@ -0,0 +1,15 @@ +import { Flex, type FlexProps } from "@chakra-ui/react" + +interface SidebarBodyProps extends FlexProps {} + +export const SidebarBody = ( + props: React.PropsWithChildren, +) => { + const { children, ...rest } = props + + return ( + + {children} + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/sidebar/sidebar-footer.tsx b/packages/imagekit-editor-dev/src/components/sidebar/sidebar-footer.tsx new file mode 100644 index 0000000..fd69fc6 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/sidebar/sidebar-footer.tsx @@ -0,0 +1,23 @@ +import { Flex, type FlexProps } from "@chakra-ui/react" +import type { PropsWithChildren } from "react" + +interface SidebarFooterProps extends FlexProps {} + +export const SidebarFooter = (props: PropsWithChildren) => { + const { children, ...rest } = props + + return ( + + {children} + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/sidebar/sidebar-header.tsx b/packages/imagekit-editor-dev/src/components/sidebar/sidebar-header.tsx new file mode 100644 index 0000000..b5a57e8 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/sidebar/sidebar-header.tsx @@ -0,0 +1,24 @@ +import { Flex, type FlexProps } from "@chakra-ui/react" + +interface SidebarHeaderProps extends FlexProps {} + +export const SidebarHeader = ( + props: React.PropsWithChildren, +) => { + const { children, ...rest } = props + + return ( + + {children} + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/sidebar/sidebar-root.tsx b/packages/imagekit-editor-dev/src/components/sidebar/sidebar-root.tsx new file mode 100644 index 0000000..49d39fc --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/sidebar/sidebar-root.tsx @@ -0,0 +1,24 @@ +import { Flex, type FlexProps } from "@chakra-ui/react" +import type { PropsWithChildren } from "react" + +interface SidebarRootProps extends FlexProps {} + +export const SidebarRoot: React.FC> = ({ + children, + ...rest +}) => { + return ( + + {children} + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx b/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx new file mode 100644 index 0000000..6bca676 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx @@ -0,0 +1,262 @@ +import { + Box, + HStack, + Icon, + Menu, + MenuButton, + MenuItem, + MenuList, + Text, + Tooltip, +} from "@chakra-ui/react" +import { useSortable } from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { PiArrowDown } from "@react-icons/all-files/pi/PiArrowDown" +import { PiArrowUp } from "@react-icons/all-files/pi/PiArrowUp" +import { PiDotsSixVerticalBold } from "@react-icons/all-files/pi/PiDotsSixVerticalBold" +import { PiDotsThreeVertical } from "@react-icons/all-files/pi/PiDotsThreeVertical" +import { PiEye } from "@react-icons/all-files/pi/PiEye" +import { PiEyeSlash } from "@react-icons/all-files/pi/PiEyeSlash" +import { PiPencilSimple } from "@react-icons/all-files/pi/PiPencilSimple" +import { PiPlus } from "@react-icons/all-files/pi/PiPlus" +import { PiTrash } from "@react-icons/all-files/pi/PiTrash" +import { RxTransform } from "@react-icons/all-files/rx/RxTransform" +import { type Transformation, useEditorStore } from "../../store" +import Hover from "../common/Hover" + +export type TransformationPosition = "inplace" | number + +interface SortableTransformationItemProps { + transformation: Transformation +} + +export const SortableTransformationItem = ({ + transformation, +}: SortableTransformationItemProps) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: transformation.id, + }) + + const { + transformations, + moveTransformation, + visibleTransformations, + removeTransformation, + toggleTransformationVisibility, + _setSidebarState, + _setSelectedTransformationKey, + _setTransformationToEdit, + _internalState, + } = useEditorStore() + + const style = transform + ? { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + : undefined + + const isVisible = visibleTransformations[transformation.id] + + const isEditting = + _internalState.transformationToEdit?.position === "inplace" && + _internalState.transformationToEdit?.transformationId === transformation.id + + return ( + + {(isHover) => ( + { + _setSidebarState("config") + _setSelectedTransformationKey(transformation.key) + _setTransformationToEdit(transformation.id, "inplace") + }} + {...attributes} + {...listeners} + > + {isHover ? ( + e.stopPropagation()} + height="24px" + display="flex" + alignItems="center" + w="5" + > + + + ) : ( + + + + )} + + + {transformation.name} + + + {isHover && ( + + + { + e.stopPropagation() + toggleTransformationVisibility(transformation.id) + }} + > + + + + + + e.stopPropagation()} + p={0} + bg="transparent" + _hover={{ bg: "transparent" }} + > + + + + + } + onClick={(e) => { + e.stopPropagation() + _setSidebarState("type") + _setTransformationToEdit(transformation.id, "above") + }} + > + Add transformation before + + } + onClick={(e) => { + e.stopPropagation() + _setSidebarState("type") + _setTransformationToEdit(transformation.id, "below") + }} + > + Add transformation after + + } + onClick={(e) => { + e.stopPropagation() + _setSidebarState("config") + _setSelectedTransformationKey(transformation.key) + _setTransformationToEdit(transformation.id, "inplace") + }} + > + Edit transformation + + } + onClick={(e) => { + e.stopPropagation() + const currentIndex = transformations.findIndex( + (t) => t.id === transformation.id, + ) + if (currentIndex > 0) { + const targetId = transformations[currentIndex - 1].id + moveTransformation(transformation.id, targetId) + } + }} + isDisabled={ + transformations.findIndex( + (t) => t.id === transformation.id, + ) <= 0 + } + > + Move up + + } + onClick={(e) => { + e.stopPropagation() + const currentIndex = transformations.findIndex( + (t) => t.id === transformation.id, + ) + if (currentIndex < transformations.length - 1) { + const targetId = transformations[currentIndex + 1].id + moveTransformation(transformation.id, targetId) + } + }} + isDisabled={ + transformations.findIndex( + (t) => t.id === transformation.id, + ) >= + transformations.length - 1 + } + > + Move down + + } + color="red.500" + onClick={(e) => { + e.stopPropagation() + removeTransformation(transformation.id) + if ( + _internalState.selectedTransformationKey === + transformation.key + ) { + _setSidebarState("none") + _setSelectedTransformationKey(null) + _setTransformationToEdit(null) + } + }} + > + Delete + + + + + )} + + )} + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/sidebar/transformation-config-sidebar.tsx b/packages/imagekit-editor-dev/src/components/sidebar/transformation-config-sidebar.tsx new file mode 100644 index 0000000..294187d --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/sidebar/transformation-config-sidebar.tsx @@ -0,0 +1,615 @@ +import { + Alert, + AlertDescription, + AlertTitle, + Box, + Button, + ButtonGroup, + Flex, + FormControl, + FormErrorMessage, + FormHelperText, + FormLabel, + HStack, + Icon, + IconButton, + Input, + Link, + Menu, + MenuButton, + MenuItem, + MenuList, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Slider, + SliderFilledTrack, + SliderThumb, + SliderTrack, + Switch, + Text, + Textarea, + VStack, +} from "@chakra-ui/react" +import { zodResolver } from "@hookform/resolvers/zod" +import { PiArrowLeft } from "@react-icons/all-files/pi/PiArrowLeft" +import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown" +import { PiInfo } from "@react-icons/all-files/pi/PiInfo" +import { PiX } from "@react-icons/all-files/pi/PiX" +import { useEffect, useMemo } from "react" +import { Controller, type SubmitHandler, useForm } from "react-hook-form" +import Select from "react-select" +import CreateableSelect from "react-select/creatable" +import { z } from "zod/v3" +import type { TransformationField } from "../../schema" +import { transformationSchema } from "../../schema" +import { useEditorStore } from "../../store" +import { isStepAligned } from "../../utils" +import AnchorField from "../common/AnchorField" +import CheckboxCardField from "../common/CheckboxCardField" +import ColorPickerField from "../common/ColorPickerField" +import RadioCardField from "../common/RadioCardField" +import { SidebarBody } from "./sidebar-body" +import { SidebarFooter } from "./sidebar-footer" +import { SidebarHeader } from "./sidebar-header" +import { SidebarRoot } from "./sidebar-root" + +export const TransformationConfigSidebar: React.FC = () => { + const { + transformations, + addTransformation, + updateTransformation, + imageList, + _setSidebarState, + _internalState, + _setTransformationToEdit, + _setSelectedTransformationKey, + } = useEditorStore() + + const selectedTransformation = useMemo(() => { + return transformationSchema + .find( + (transformation) => + transformation.key === + _internalState.selectedTransformationKey?.split("-")[0], + ) + ?.items.find( + (item) => item.key === _internalState.selectedTransformationKey, + ) + }, [_internalState.selectedTransformationKey]) + + const transformationToEdit = _internalState.transformationToEdit + + const editedTransformationValue = useMemo(() => { + if (!transformationToEdit) return undefined + return transformations.find( + (transformation) => + transformation.id === transformationToEdit.transformationId, + )?.value as Record | undefined + }, [transformations, transformationToEdit]) + + const defaultValues = useMemo(() => { + if ( + transformationToEdit && + selectedTransformation && + transformationToEdit.position === "inplace" + ) { + const currentValues: Record = {} + + selectedTransformation.transformations.forEach((field) => { + if ( + editedTransformationValue && + field.name in editedTransformationValue + ) { + currentValues[field.name] = editedTransformationValue[field.name] + } else { + currentValues[field.name] = field.fieldProps?.defaultValue ?? "" + } + }) + + return currentValues + } else if (selectedTransformation) { + return selectedTransformation.transformations.reduce( + (acc, field) => { + acc[field.name] = field.fieldProps?.defaultValue ?? "" + return acc + }, + {} as Record, + ) + } + return {} + }, [transformationToEdit, selectedTransformation, editedTransformationValue]) + + const { + register, + handleSubmit, + formState: { errors, isDirty }, + reset, + watch, + setValue, + control, + } = useForm>({ + resolver: zodResolver(selectedTransformation?.schema ?? z.object({})), + defaultValues: defaultValues, + }) + + useEffect(() => { + reset(defaultValues) + }, [reset, defaultValues]) + + const values = watch() + + const onClose = () => { + if (transformations.length === 0) { + _setSidebarState("type") + } else { + _setSidebarState("none") + } + _setSelectedTransformationKey(null) + _setTransformationToEdit(null) + } + + const onBack = () => { + _setSidebarState("type") + } + + const onApply = (data: Record) => { + if (!selectedTransformation) { + return + } + + const transformationToEdit = _internalState.transformationToEdit + + if (transformationToEdit && transformationToEdit.position === "inplace") { + updateTransformation(transformationToEdit.transformationId, { + type: "transformation", + name: selectedTransformation.name, + key: selectedTransformation.key, + value: data, + }) + } else if ( + transformationToEdit && + (transformationToEdit.position === "above" || + transformationToEdit.position === "below") + ) { + const index = transformations.findIndex( + (transformation) => transformation.id === transformationToEdit.targetId, + ) + + const transformationId = addTransformation( + { + type: "transformation", + name: selectedTransformation.name, + key: selectedTransformation.key, + value: data, + }, + index + (transformationToEdit.position === "above" ? 0 : 1), + ) + + _setTransformationToEdit(transformationId, "inplace") + } else { + const transformationId = addTransformation({ + type: "transformation", + name: selectedTransformation.name, + key: selectedTransformation.key, + value: data, + }) + + _setTransformationToEdit(transformationId, "inplace") + } + } + + const onSubmit = ( + shouldClose = false, + ): SubmitHandler> => { + return (data) => { + onApply(data) + if (shouldClose) { + onClose() + } + } + } + + if (!selectedTransformation) { + return null + } + + return ( + + + {!_internalState.transformationToEdit ? ( + } + onClick={onBack} + variant="ghost" + size="sm" + aria-label="Back Button" + /> + ) : null} + + {selectedTransformation.name} + + + {(selectedTransformation.description || + selectedTransformation.docsLink) && ( + + + } + variant="ghost" + size="sm" + aria-label="Info Button" + /> + + + + {selectedTransformation.description && ( + {selectedTransformation.description} + )} + {selectedTransformation.docsLink && ( + + Click here to view docs + + )} + + + + )} + {_internalState.transformationToEdit && ( + } + onClick={onClose} + variant="ghost" + size="sm" + aria-label="Close Button" + ml="auto" + /> + )} + + + + {selectedTransformation.transformations + .filter((field) => { + if (field.isVisible) { + return field.isVisible(values) + } + return true + }) + .map((field: TransformationField) => ( + + + {field.label} + + {field.fieldType === "select" ? ( + ( + + ) : null} + {field.fieldType === "textarea" ? ( +