diff --git a/.gitignore b/.gitignore index 7eb8f73..dae31b3 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ build/ .DS_Store .env.swp .env.swo +services/editor/node_modules +services/editor/**/node_modules +services/editor/**/dist +services/editor/.pnpm-store diff --git a/docker-compose.yml b/docker-compose.yml index 7b860c3..a996f02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,6 +90,16 @@ services: networks: - wild-dragon + + editor: + build: ./services/editor + depends_on: + - mam-api + ports: + - "${PORT_EDITOR:-47435}:80" + networks: + - wild-dragon + volumes: postgres_data: redis_data: diff --git a/services/editor/.gitignore b/services/editor/.gitignore new file mode 100644 index 0000000..c07a4ca --- /dev/null +++ b/services/editor/.gitignore @@ -0,0 +1,65 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build outputs +dist/ +build/ +.next/ +out/ +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Testing +coverage/ +.nyc_output/ + +# Temporary files +*.tmp +.cache/ +.temp/ +.docs/ +docs/ +# Project-specific +/public/projects/ +*.openreel +apps/cloud/ +apps/ios +apps/android + + + +# Local files +FEATURES_TWITTER.md +.claude-tasks.md +CLAUDE.md +*-PLAN.md +*-PLAN-*.md +.playwright-mcp/ +.wrangler/ + + +mobile-mockup/ \ No newline at end of file diff --git a/services/editor/.serena/.gitignore b/services/editor/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/services/editor/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/services/editor/.serena/project.yml b/services/editor/.serena/project.yml new file mode 100644 index 0000000..77baec7 --- /dev/null +++ b/services/editor/.serena/project.yml @@ -0,0 +1,135 @@ +# the name by which the project can be referenced within Serena +project_name: "openreel-video" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- typescript + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] diff --git a/services/editor/CONTRIBUTING.md b/services/editor/CONTRIBUTING.md new file mode 100644 index 0000000..4e17036 --- /dev/null +++ b/services/editor/CONTRIBUTING.md @@ -0,0 +1,387 @@ +# Contributing to OpenReel + +Thank you for your interest in contributing to OpenReel! This document provides guidelines and instructions for contributing. + +## Table of Contents +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [Project Structure](#project-structure) +- [Coding Standards](#coding-standards) +- [Making Changes](#making-changes) +- [Testing](#testing) +- [Submitting Changes](#submitting-changes) + +## Code of Conduct + +Be respectful, constructive, and professional. We're building something great together! + +## Getting Started + +### Prerequisites +- Node.js 18 or higher +- pnpm (recommended) or npm +- Git +- Modern browser with WebCodecs support (Chrome 94+, Edge 94+) + +### Development Setup + +```bash +# 1. Fork and clone the repository +git clone https://github.com/Augani/openreel-video.git +cd openreel-video + +# 2. Install dependencies +pnpm install + +# 3. Start development server +pnpm dev + +# 4. Open browser to http://localhost:5173 +``` + +## Project Structure + +``` +openreel/ +├── apps/ +│ └── web/ # Main web application +│ ├── public/ # Static assets +│ └── src/ +│ ├── components/ # React components +│ ├── stores/ # State management (Zustand) +│ ├── bridges/ # Core engine bridges +│ └── services/ # Business logic +├── packages/ +│ └── core/ # Shared core logic +│ ├── src/ +│ │ ├── actions/ # Action system +│ │ ├── video/ # Video processing +│ │ ├── audio/ # Audio processing +│ │ ├── graphics/ # Graphics & SVG +│ │ ├── text/ # Text & titles +│ │ └── export/ # Export engine +│ └── types/ # TypeScript types +``` + +## Coding Standards + +### TypeScript + +- **Strict mode**: Always use TypeScript strict mode +- **Types**: Prefer interfaces over types for object shapes +- **No `any`**: Avoid `any` - use `unknown` or proper types +- **Naming**: + - Components: `PascalCase` (e.g., `Timeline`, `Preview`) + - Functions: `camelCase` (e.g., `handleClick`, `processVideo`) + - Constants: `UPPER_SNAKE_CASE` (e.g., `MAX_DURATION`) + - Files: `kebab-case.tsx` or `PascalCase.tsx` for components + +### Code Style + +```typescript +// ✅ Good +interface VideoClip { + id: string; + duration: number; + startTime: number; +} + +function processClip(clip: VideoClip): ProcessedClip { + if (!clip.id) { + throw new Error('Clip ID is required'); + } + + return { + ...clip, + processed: true, + }; +} + +// ❌ Avoid +function processClip(clip: any) { + console.log('Processing...'); // Remove debug logs + const result = clip; // Unclear what's happening + return result; +} +``` + +### React Components + +```typescript +// ✅ Good +interface TimelineProps { + tracks: Track[]; + onClipSelect: (clipId: string) => void; +} + +export const Timeline: React.FC = ({ tracks, onClipSelect }) => { + const handleClick = useCallback((id: string) => { + onClipSelect(id); + }, [onClipSelect]); + + return ( +
+ {tracks.map(track => ( + + ))} +
+ ); +}; +``` + +### Comments + +- **Do**: Comment complex algorithms and business logic +- **Don't**: Comment obvious code +- **Do**: Add JSDoc for public APIs +- **Don't**: Leave TODO comments without issues + +```typescript +// ✅ Good - Explains WHY +// Use binary search for O(log n) performance on large timelines +const clipIndex = binarySearch(clips, targetTime); + +// ❌ Bad - States the obvious +// Loop through clips +for (const clip of clips) { } + +// ✅ Good - Public API documentation +/** + * Applies a filter to a video clip + * @param clipId - The clip identifier + * @param filter - Filter configuration + * @returns Updated clip with filter applied + */ +export function applyFilter(clipId: string, filter: Filter): Clip { + // ... +} +``` + +## Making Changes + +### 1. Create a Branch + +```bash +# Feature branch +git checkout -b feat/add-transition-effects + +# Bug fix branch +git checkout -b fix/timeline-scroll-bug + +# Documentation +git checkout -b docs/update-contributing-guide +``` + +### 2. Make Your Changes + +- Write clean, self-documenting code +- Follow the existing code style +- Keep commits focused and atomic +- Write meaningful commit messages + +### 3. Commit Messages + +Follow conventional commits: + +``` +feat: add crossfade transition effect +fix: resolve timeline scrubbing lag +docs: update API documentation +refactor: simplify video processing pipeline +test: add tests for audio mixer +perf: optimize waveform rendering +``` + +### 4. Keep Your Branch Updated + +```bash +git fetch origin +git rebase origin/main +``` + +## Testing + +### Running Tests + +```bash +# Run all tests (watch mode) +pnpm test + +# Run tests once (CI mode) +pnpm test:run + +# Type checking +pnpm typecheck + +# Linting +pnpm lint +``` + +### Writing Tests + +```typescript +import { describe, it, expect } from 'vitest'; +import { processClip } from './clip-processor'; + +describe('processClip', () => { + it('should process a valid clip', () => { + const clip = { id: '123', duration: 10, startTime: 0 }; + const result = processClip(clip); + + expect(result.processed).toBe(true); + expect(result.id).toBe('123'); + }); + + it('should throw error for invalid clip', () => { + const clip = { id: '', duration: 10, startTime: 0 }; + + expect(() => processClip(clip)).toThrow('Clip ID is required'); + }); +}); +``` + +## Submitting Changes + +### 1. Push Your Branch + +```bash +git push origin feat/your-feature-name +``` + +### 2. Create a Pull Request + +1. Go to GitHub and create a pull request +2. Fill out the PR template: + - **Description**: What does this PR do? + - **Motivation**: Why is this change needed? + - **Testing**: How was this tested? + - **Screenshots**: For UI changes + - **Breaking Changes**: Any breaking changes? + +### 3. PR Template + +```markdown +## Description +Brief description of changes + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation update + +## Testing +- [ ] Tested locally +- [ ] Added/updated tests +- [ ] All tests passing + +## Screenshots (if applicable) +[Add screenshots for UI changes] + +## Checklist +- [ ] Code follows project style guidelines +- [ ] Self-review completed +- [ ] Comments added for complex code +- [ ] Documentation updated +- [ ] No console.log or debug code left +- [ ] Tests pass +``` + +### 4. Code Review Process + +- Respond to feedback promptly +- Make requested changes +- Push updates to the same branch +- Re-request review when ready + +## Areas to Contribute + +### 🐛 Bug Fixes +- Check [Issues](https://github.com/Augani/openreel-video/issues?q=is%3Aissue+is%3Aopen+label%3Abug) +- Reproduce the bug +- Write a failing test +- Fix the bug +- Verify the test passes + +### ✨ New Features +- Discuss in [Discussions](https://github.com/Augani/openreel-video/discussions) first +- Get approval before large changes +- Break into smaller PRs if possible +- Update documentation + +### 📖 Documentation +- Fix typos and errors +- Add examples +- Improve clarity +- Add tutorials + +### 🎨 Effects & Presets +- Create new video effects +- Add transition effects +- Build color grading presets +- Contribute templates + +### 🧪 Testing +- Add missing tests +- Improve test coverage +- Add integration tests +- Performance testing + +### 🌍 Translation +- Add new language support +- Improve existing translations +- Fix translation errors + +## Development Tips + +### Hot Reload +Changes to React components hot reload automatically. For core engine changes, you may need to refresh. + +### Debugging +```typescript +// Use browser DevTools +// Set breakpoints in TypeScript source +// Check Network tab for media loading +// Use Performance profiler for optimization +``` + +### Performance +- Profile before optimizing +- Use Web Workers for heavy processing +- Leverage WebCodecs API for video +- Cache expensive computations +- Use useMemo/useCallback appropriately + +### Common Issues + +**Issue**: Video won't play +- Check browser support for WebCodecs +- Verify codec support +- Check browser console for errors + +**Issue**: Build fails +- Clear node_modules and reinstall +- Check Node.js version (18+) +- Verify pnpm version + +**Issue**: Tests fail +- Try running `pnpm test:run` for a single run +- Check for console errors +- Verify test environment setup +- Run `pnpm typecheck` to check for type errors + +## Questions? + +- **Discord**: [Join our Discord](https://discord.gg/openreeel) +- **Discussions**: [GitHub Discussions](https://github.com/Augani/openreel-video/discussions) +- **Email**: contribute@openreeel.video + +## Recognition + +Contributors are recognized in: +- README.md contributors section +- GitHub contributors page +- Release notes for significant contributions + +Thank you for contributing to OpenReel! 🎬 diff --git a/services/editor/Dockerfile b/services/editor/Dockerfile new file mode 100644 index 0000000..7aaea50 --- /dev/null +++ b/services/editor/Dockerfile @@ -0,0 +1,19 @@ +# syntax=docker/dockerfile:1.6 +FROM node:20-alpine AS builder +RUN apk add --no-cache python3 make g++ git bash +RUN corepack enable && corepack prepare pnpm@9.7.0 --activate +WORKDIR /build +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json mediabunny.d.ts ./ +COPY apps ./apps +COPY packages ./packages +RUN pnpm install --frozen-lockfile=false +RUN pnpm build:wasm || echo "no wasm build step, continuing" +RUN pnpm --filter @openreel/web build +RUN ls -la apps/web/dist + +FROM nginx:alpine AS runtime +RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf +COPY --from=builder /build/apps/web/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/services/editor/INTEGRATION.md b/services/editor/INTEGRATION.md new file mode 100644 index 0000000..a2b9866 --- /dev/null +++ b/services/editor/INTEGRATION.md @@ -0,0 +1,20 @@ +# Z-AMPP <-> openreel-video integration + +Vendored from https://github.com/Augani/openreel-video (MIT). The upstream .git directory was removed so this lives as plain source we can patch freely. + +## Files added (Z-AMPP-only, not upstream) +- Dockerfile, nginx.conf, VENDOR.txt, INTEGRATION.md +- apps/web/src/mam-bridge.ts: boot hook + pickFromMAM() modal +- packages/core/src/export/mam-export-target.ts: helpers for upload-to-MAM + +## Upstream files patched +- apps/web/package.json: build script changed `tsc --noEmit && vite build` -> `vite build`. Original preserved as build:strict. (upstream tsc fails on pre-existing WebGPU + import.meta errors.) +- apps/web/src/bridges/media-bridge.ts: appended importFromURL(url, name, contentType?) as the last method of the MediaBridge class. +- apps/web/src/main.tsx: appended `import "./mam-bridge";` so the bridge boot hook runs. + +## Query params honored +- ?asset= auto-imports that asset on load. +- ?project= stored in localStorage.mamProjectId for save-to-MAM. + +## Ports +Container exposes 80; compose maps ${PORT_EDITOR:-47435}:80. diff --git a/services/editor/LICENSE b/services/editor/LICENSE new file mode 100644 index 0000000..1e114f1 --- /dev/null +++ b/services/editor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 Augustus Otu and Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/services/editor/README.md b/services/editor/README.md new file mode 100644 index 0000000..de0efd7 --- /dev/null +++ b/services/editor/README.md @@ -0,0 +1,308 @@ +# OpenReel Video + +> **The open source CapCut alternative. Professional video editing in your browser. No uploads. No installs. 100% open source.** + +OpenReel Video is a fully-featured browser-based video editor that runs entirely client-side. Built with React, TypeScript, WebCodecs, and WebGPU for professional-grade video editing without the need for expensive software or cloud processing. + +**[Try it Live](https://openreel.video)** | **[Documentation](CONTRIBUTING.md)** | **[Discussions](https://github.com/Augani/openreel-video/discussions)** | **[Twitter](https://x.com/python_xi)** + +![OpenReel Editor](https://img.shields.io/badge/Lines%20of%20Code-130k+-blue) ![License](https://img.shields.io/badge/License-MIT-green) ![Status](https://img.shields.io/badge/Status-Beta-orange) ![Open Source](https://img.shields.io/badge/Open%20Source-100%25-brightgreen) + +--- + +## Why OpenReel? + +- **100% Client-Side** - Your videos never leave your device. No uploads, no cloud processing, complete privacy. +- **No Installation** - Works in Chrome/Edge. Just open and start editing. +- **Professional Features** - Multi-track timeline, keyframe animations, color grading, audio effects, and more. +- **GPU Accelerated** - WebGPU and WebCodecs for smooth 4K editing and fast exports. +- **Free Forever** - MIT licensed, no subscriptions, no watermarks. + +--- + +## Features + +### Video Editing +- **Multi-track timeline** - Unlimited video, audio, image, text, and graphics tracks +- **Real-time preview** - Smooth playback with GPU acceleration +- **Precision editing** - Frame-accurate scrubbing, cut, trim, split, ripple delete +- **Transitions** - Crossfade, dip to black/white, wipe, slide effects +- **Video effects** - Brightness, contrast, saturation, blur, sharpen, glow, vignette, chroma key +- **Blend modes** - Multiply, screen, overlay, add, subtract, and more +- **Speed control** - 0.25x to 4x with audio pitch preservation +- **Crop & transform** - Position, scale, rotation with 3D perspective + +### Graphics & Text +- **Professional text editor** - Rich styling, shadows, outlines, gradients +- **20+ text animations** - Typewriter, fade, slide, bounce, pop, elastic, glitch +- **Karaoke-style subtitles** - Word-by-word highlighting synced to audio +- **Shape tools** - Rectangle, circle, arrow, polygon, star with fill/stroke +- **SVG support** - Import SVGs with color tinting and animations +- **Stickers & emoji** - Built-in library +- **Background generator** - Solid colors, gradients, mesh gradients, patterns +- **Keyframe animations** - Animate any property over time with 20+ easing curves + +### Audio +- **Multi-track mixing** - Unlimited audio tracks with real-time mixing +- **Waveform visualization** - Visual audio editing +- **Audio effects** - EQ, compressor, reverb, delay, chorus, flanger, distortion +- **Volume & panning** - Per-clip controls with fade in/out +- **Beat detection** - Auto-generate markers synced to music +- **Audio ducking** - Auto-reduce music when dialog plays +- **Noise reduction** - 3-pass noise removal (tonal, broadband, rumble) + +### Color Grading +- **Color wheels** - Lift, gamma, gain controls +- **HSL adjustments** - Hue, saturation, lightness fine-tuning +- **Curves editor** - RGB and individual channel curves +- **LUT support** - Import and apply 3D LUTs +- **Built-in presets** - One-click color grading + +### Export +- **MP4 (H.264/H.265)** - Universal compatibility +- **WebM (VP8/VP9/AV1)** - Web-optimized format +- **ProRes** - Professional intermediate format (Proxy, LT, Standard, HQ, 4444) +- **Quality presets** - 4K @ 60fps, 1080p, 720p, 480p +- **Custom settings** - Bitrate, frame rate, codec options, color depth +- **Hardware encoding** - WebCodecs for fast exports +- **AI upscaling** - Enhance resolution with WebGPU shaders +- **Audio export** - MP3, WAV, AAC, FLAC, OGG +- **Image sequences** - JPG, PNG, WebP frame export +- **Progress tracking** - Real-time progress with cancel support + +### Professional Tools +- **Unlimited undo/redo** - Full history with recovery +- **Auto-save** - Never lose work (IndexedDB storage) +- **Keyboard shortcuts** - Professional workflow +- **Snap to grid** - Magnetic alignment +- **Track management** - Show/hide, lock/unlock, reorder +- **Subtitle support** - SRT import with customizable styling +- **Screen recording** - Record screen, camera, or both +- **Project sharing** - Export/import project files + +### Performance +- **WebGPU rendering** - GPU-accelerated compositing +- **WebCodecs API** - Hardware video decoding/encoding +- **Frame caching** - LRU cache for smooth playback +- **Web Workers** - Background processing +- **4K support** - Edit and export in 4K resolution + +--- + +## Quick Start + +### Try Online +Visit **[openreel.video](https://openreel.video)** to start editing immediately. + +### Run Locally + +```bash +# Clone the repository +git clone https://github.com/Augani/openreel-video.git +cd openreel-video + +# Install dependencies (requires Node.js 18+) +pnpm install + +# Start development server +pnpm dev + +# Open http://localhost:5173 +``` + +### Build for Production + +```bash +pnpm build +pnpm preview +``` + +--- + +## Browser Requirements + +| Browser | Version | Status | +|---------|---------|--------| +| Chrome | 94+ | Full support | +| Edge | 94+ | Full support | +| Firefox | 130+ | Full support | +| Safari | 16.4+ | Full support | + +All major browsers now support WebCodecs for hardware-accelerated video encoding/decoding. + +**Recommended:** +- 8GB+ RAM +- Dedicated GPU for 4K editing +- Modern multi-core CPU + +--- + +## Architecture + +### Monorepo Structure + +``` +openreel/ +├── apps/web/ # React frontend (~66k lines) +│ └── src/ +│ ├── components/ # UI components +│ │ └── editor/ # Editor panels (Timeline, Preview, Inspector) +│ ├── stores/ # Zustand state management +│ ├── services/ # Auto-save, shortcuts, screen recording +│ └── bridges/ # Engine coordination +│ +└── packages/core/ # Core engines (~59k lines) + └── src/ + ├── video/ # Video processing, WebGPU rendering + ├── audio/ # Web Audio API, effects, beat detection + ├── graphics/ # Canvas/THREE.js, shapes, SVG + ├── text/ # Text rendering, animations + ├── export/ # MP4/WebM encoding + └── storage/ # IndexedDB, serialization +``` + +### Key Technologies + +- **React 18** + **TypeScript** - Type-safe UI +- **Zustand** - Lightweight state management +- **MediaBunny** - Video/audio processing +- **WebCodecs** - Hardware encoding/decoding +- **WebGPU** - GPU-accelerated rendering +- **Web Audio API** - Professional audio processing +- **THREE.js** - 3D transforms and effects +- **IndexedDB** - Local project storage + +### Design Principles + +- **Action-based editing** - Every edit is an undoable action +- **Immutable state** - Predictable updates with Zustand +- **Engine separation** - Video, audio, graphics engines are independent +- **Progressive enhancement** - Graceful fallbacks (WebGPU → Canvas2D) + +--- + +## AI-Managed Development + +OpenReel is an experiment in AI-assisted open source development. Claude AI helps manage: + +- **Issue triage** - Reviews and responds to issues +- **Code implementation** - Writes features and fixes bugs +- **Code review** - Maintains quality standards +- **Documentation** - Keeps docs up to date + +Human oversight from Augustus ensures strategic direction and final approval on major changes. All code is public, tested, and follows best practices. + +**What this means for contributors:** +- Issues get reviewed quickly (usually within 24 hours) +- Bug fixes ship fast +- Clear, detailed responses to questions +- High code quality standards + +--- + +## Contributing + +We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +**Ways to contribute:** +- Report bugs with reproduction steps +- Suggest features in Discussions +- Submit PRs for bugs or features +- Improve documentation +- Write tests +- Share effect presets + +**Development workflow:** +```bash +# Fork and clone +git clone https://github.com/Augani/openreel-video.git + +# Create feature branch +git checkout -b feat/your-feature + +# Make changes, then test +pnpm typecheck +pnpm test +pnpm lint + +# Commit with conventional commits +git commit -m "feat: add your feature" + +# Push and open PR +git push origin feat/your-feature +``` + +--- + +## Roadmap + +### Completed +- Multi-track timeline with drag-and-drop +- Real-time video preview with GPU acceleration +- Full editing suite (cut, trim, split, transitions) +- Text editor with 20+ animations +- Graphics (shapes, SVG, stickers, backgrounds) +- Audio mixing with effects and beat detection +- Color grading with LUT support +- Keyframe animation system +- Export to MP4/WebM (4K supported) +- Screen recording +- AI upscaling +- Undo/redo with auto-save + +### In Progress +- Nested sequences (timeline in timeline) +- Motion tracking +- More export formats (ProRes, GIF) +- Plugin system + +### Planned +- Adjustment layers +- Advanced masking +- Audio spectral editing +- Collaborative editing +- Mobile optimization + +--- + +## License + +MIT License - Use freely for personal and commercial projects. + +See [LICENSE](LICENSE) for details. + +--- + +## Acknowledgments + +**Built with:** +- [MediaBunny](https://mediabunny.dev) - Media processing +- [React](https://react.dev) - UI framework +- [Zustand](https://zustand-demo.pmnd.rs/) - State management +- [THREE.js](https://threejs.org) - 3D rendering +- [TailwindCSS](https://tailwindcss.com) - Styling + +**Inspired by:** +- DaVinci Resolve - Professional tools done right +- CapCut - Accessible editing for everyone +- Figma - Browser-based professional software + +--- + +## Support + +- **GitHub Issues** - Bug reports and feature requests +- **GitHub Discussions** - Questions and community chat +- **Twitter/X** - [@python_xi](https://x.com/python_xi) + +--- + +## $OPENREEL Token + +CA: `B7wDnfrdtvdG7SCkRjSMJ6LkVwGWvdWrQ75iV8G9pump` + +--- + +**Built with care by [@python_xi](https://x.com/python_xi) and AI working together.** + +*Making professional video editing accessible to everyone. Forever free. Forever open source.* diff --git a/services/editor/VENDOR.txt b/services/editor/VENDOR.txt new file mode 100644 index 0000000..a360bcf --- /dev/null +++ b/services/editor/VENDOR.txt @@ -0,0 +1 @@ +Vendored from Augani/openreel-video @ 2026-05-18T01:29:08Z diff --git a/services/editor/apps/image/eslint.config.js b/services/editor/apps/image/eslint.config.js new file mode 100644 index 0000000..5de12c8 --- /dev/null +++ b/services/editor/apps/image/eslint.config.js @@ -0,0 +1,70 @@ +import js from "@eslint/js"; +import tseslint from "@typescript-eslint/eslint-plugin"; +import tsparser from "@typescript-eslint/parser"; +import reactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; + +export default [ + js.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + globals: { + ...globals.browser, + ...globals.es2021, + ...globals.node, + NodeJS: "readonly", + CanvasTextAlign: "readonly", + CanvasTextBaseline: "readonly", + CanvasLineCap: "readonly", + CanvasLineJoin: "readonly", + CanvasFillRule: "readonly", + GlobalCompositeOperation: "readonly", + ImageBitmap: "readonly", + OffscreenCanvas: "readonly", + OffscreenCanvasRenderingContext2D: "readonly", + React: "readonly", + JSX: "readonly", + }, + }, + plugins: { + "@typescript-eslint": tseslint, + "react-hooks": reactHooks, + }, + rules: { + ...tseslint.configs.recommended.rules, + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "@typescript-eslint/no-explicit-any": "warn", + "no-console": ["warn", { allow: ["warn", "error"] }], + "prefer-const": "warn", + "no-unused-vars": "off", + "no-empty": "warn", + "no-case-declarations": "warn", + "react-hooks/rules-of-hooks": "warn", + "react-hooks/exhaustive-deps": "warn", + }, + linterOptions: { + reportUnusedDisableDirectives: false, + }, + }, + { + ignores: [ + "dist/**", + "node_modules/**", + "*.config.js", + "*.config.ts", + "vite.config.ts", + ], + }, +]; diff --git a/services/editor/apps/image/index.html b/services/editor/apps/image/index.html new file mode 100644 index 0000000..e81cdf2 --- /dev/null +++ b/services/editor/apps/image/index.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + OpenReel Image - Professional Graphic Design Editor + + +
+ + + + diff --git a/services/editor/apps/image/package.json b/services/editor/apps/image/package.json new file mode 100644 index 0000000..364d493 --- /dev/null +++ b/services/editor/apps/image/package.json @@ -0,0 +1,64 @@ +{ + "name": "@openreel/image", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "deploy": "wrangler pages deploy dist --project-name=openreel-image", + "deploy:preview": "wrangler pages deploy dist --project-name=openreel-image --branch=preview", + "test": "vitest", + "test:run": "vitest run", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist node_modules/.vite" + }, + "dependencies": { + "@imgly/background-removal": "^1.7.0", + "@openreel/image-core": "workspace:*", + "@openreel/ui": "workspace:*", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^12.23.24", + "lucide-react": "^0.555.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^3.4.0", + "uuid": "^13.0.0", + "zod": "^4.4.3", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@testing-library/jest-dom": "^6.4.6", + "@testing-library/react": "^16.0.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/uuid": "^11.0.0", + "@typescript-eslint/eslint-plugin": "^8.53.0", + "@typescript-eslint/parser": "^8.53.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "eslint": "^9.39.2", + "eslint-plugin-react-hooks": "^7.0.1", + "globals": "^17.0.0", + "jsdom": "^24.1.0", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.4.5", + "vite": "^5.3.1", + "vitest": "^1.6.0", + "wrangler": "^3.114.17" + } +} diff --git a/services/editor/apps/image/postcss.config.js b/services/editor/apps/image/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/services/editor/apps/image/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/services/editor/apps/image/public/favicon.svg b/services/editor/apps/image/public/favicon.svg new file mode 100644 index 0000000..ed85208 --- /dev/null +++ b/services/editor/apps/image/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/services/editor/apps/image/public/manifest.json b/services/editor/apps/image/public/manifest.json new file mode 100644 index 0000000..80a6703 --- /dev/null +++ b/services/editor/apps/image/public/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "OpenReel Image", + "short_name": "OpenReel", + "description": "Professional browser-based graphic design editor", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#22c55e", + "orientation": "landscape", + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "categories": ["graphics", "design", "productivity"] +} diff --git a/services/editor/apps/image/public/sw.js b/services/editor/apps/image/public/sw.js new file mode 100644 index 0000000..2a878f7 --- /dev/null +++ b/services/editor/apps/image/public/sw.js @@ -0,0 +1,47 @@ +const CACHE_NAME = 'openreel-image-v1'; +const STATIC_ASSETS = [ + '/', + '/index.html', + '/manifest.json', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)) + ) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') return; + + const url = new URL(event.request.url); + if (url.origin !== location.origin) return; + + event.respondWith( + caches.match(event.request).then((cached) => { + const fetchPromise = fetch(event.request) + .then((response) => { + if (response.ok && response.status === 200) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + } + return response; + }) + .catch(() => cached); + + return cached || fetchPromise; + }) + ); +}); diff --git a/services/editor/apps/image/src/App.tsx b/services/editor/apps/image/src/App.tsx new file mode 100644 index 0000000..958a05a --- /dev/null +++ b/services/editor/apps/image/src/App.tsx @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; +import { useUIStore } from './stores/ui-store'; +import { WelcomeScreen } from './components/welcome/WelcomeScreen'; +import { EditorInterface } from './components/editor/EditorInterface'; +import { KeyboardShortcutsPanel } from './components/editor/KeyboardShortcutsPanel'; +import { SettingsDialog } from './components/editor/SettingsDialog'; +import { useKeyboardShortcuts } from './services/keyboard-service'; +import { useAutoSave } from './hooks/useAutoSave'; + +export default function App() { + const { currentView, setCurrentView, showShortcutsPanel, toggleShortcutsPanel, showSettingsDialog, closeSettingsDialog } = useUIStore(); + + useKeyboardShortcuts(); + useAutoSave(); + + useEffect(() => { + document.documentElement.classList.add('dark'); + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && currentView === 'editor') { + setCurrentView('welcome'); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [currentView, setCurrentView]); + + return ( +
+ {currentView === 'welcome' && } + {currentView === 'editor' && } + + +
+ ); +} diff --git a/services/editor/apps/image/src/adjustments/black-white.ts b/services/editor/apps/image/src/adjustments/black-white.ts new file mode 100644 index 0000000..d433869 --- /dev/null +++ b/services/editor/apps/image/src/adjustments/black-white.ts @@ -0,0 +1,168 @@ +export interface BlackWhiteSettings { + reds: number; + yellows: number; + greens: number; + cyans: number; + blues: number; + magentas: number; + tint: { + enabled: boolean; + hue: number; + saturation: number; + }; +} + +export const DEFAULT_BLACK_WHITE: BlackWhiteSettings = { + reds: 40, + yellows: 60, + greens: 40, + cyans: 60, + blues: 20, + magentas: 80, + tint: { + enabled: false, + hue: 30, + saturation: 25, + }, +}; + +export const BLACK_WHITE_PRESETS = { + default: { reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20, magentas: 80 }, + highContrast: { reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20, magentas: 80 }, + infrared: { reds: -70, yellows: 200, greens: -70, cyans: 200, blues: -20, magentas: -20 }, + maximumWhite: { reds: 100, yellows: 100, greens: 100, cyans: 100, blues: 100, magentas: 100 }, + maximumBlack: { reds: -200, yellows: -200, greens: -200, cyans: -200, blues: -200, magentas: -200 }, + neutral: { reds: 33, yellows: 33, greens: 33, cyans: 33, blues: 33, magentas: 33 }, + redFilter: { reds: 106, yellows: 52, greens: -10, cyans: -40, blues: -30, magentas: 94 }, + yellowFilter: { reds: 34, yellows: 106, greens: 54, cyans: -26, blues: -50, magentas: 14 }, + greenFilter: { reds: -44, yellows: 64, greens: 106, cyans: 60, blues: -30, magentas: -70 }, + blueFilter: { reds: -30, yellows: -46, greens: -16, cyans: 30, blues: 106, magentas: 30 }, +}; + +function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + + if (max === min) { + return { h: 0, s: 0, l }; + } + + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + let h: number; + switch (max) { + case r: + h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + break; + case g: + h = ((b - r) / d + 2) / 6; + break; + default: + h = ((r - g) / d + 4) / 6; + break; + } + + return { h, s, l }; +} + +function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { + if (s === 0) { + const gray = Math.round(l * 255); + return { r: gray, g: gray, b: gray }; + } + + const hue2rgb = (p: number, q: number, t: number): number => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + + return { + r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255), + g: Math.round(hue2rgb(p, q, h) * 255), + b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255), + }; +} + +function getColorWeight(hue: number, targetHue: number, spread: number = 60): number { + let diff = Math.abs(hue - targetHue); + if (diff > 180) diff = 360 - diff; + if (diff >= spread) return 0; + return 1 - diff / spread; +} + +export function applyBlackWhite(imageData: ImageData, settings: BlackWhiteSettings): ImageData { + const { width, height, data } = imageData; + const resultData = new Uint8ClampedArray(data.length); + + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const a = data[i + 3]; + + const { h, s } = rgbToHsl(r, g, b); + const hue = h * 360; + + let gray = (r + g + b) / 3; + + if (s > 0.05) { + const redWeight = getColorWeight(hue, 0) + getColorWeight(hue, 360); + const yellowWeight = getColorWeight(hue, 60); + const greenWeight = getColorWeight(hue, 120); + const cyanWeight = getColorWeight(hue, 180); + const blueWeight = getColorWeight(hue, 240); + const magentaWeight = getColorWeight(hue, 300); + + const totalWeight = redWeight + yellowWeight + greenWeight + cyanWeight + blueWeight + magentaWeight; + + if (totalWeight > 0) { + const adjustment = + (redWeight * settings.reds + + yellowWeight * settings.yellows + + greenWeight * settings.greens + + cyanWeight * settings.cyans + + blueWeight * settings.blues + + magentaWeight * settings.magentas) / totalWeight; + + gray = gray * (1 + (adjustment - 50) / 100 * s); + } + } + + gray = Math.max(0, Math.min(255, gray)); + + let finalR = gray; + let finalG = gray; + let finalB = gray; + + if (settings.tint.enabled) { + const tintH = settings.tint.hue / 360; + const tintS = settings.tint.saturation / 100; + const tintL = gray / 255; + + const tinted = hslToRgb(tintH, tintS, tintL); + finalR = tinted.r; + finalG = tinted.g; + finalB = tinted.b; + } + + resultData[i] = finalR; + resultData[i + 1] = finalG; + resultData[i + 2] = finalB; + resultData[i + 3] = a; + } + + return new ImageData(resultData, width, height); +} diff --git a/services/editor/apps/image/src/adjustments/channel-mixer.ts b/services/editor/apps/image/src/adjustments/channel-mixer.ts new file mode 100644 index 0000000..6e53b1e --- /dev/null +++ b/services/editor/apps/image/src/adjustments/channel-mixer.ts @@ -0,0 +1,108 @@ +export interface ChannelMixerSettings { + red: { + red: number; + green: number; + blue: number; + constant: number; + }; + green: { + red: number; + green: number; + blue: number; + constant: number; + }; + blue: { + red: number; + green: number; + blue: number; + constant: number; + }; + monochrome: boolean; + monoRed: number; + monoGreen: number; + monoBlue: number; + monoConstant: number; +} + +export const DEFAULT_CHANNEL_MIXER: ChannelMixerSettings = { + red: { red: 100, green: 0, blue: 0, constant: 0 }, + green: { red: 0, green: 100, blue: 0, constant: 0 }, + blue: { red: 0, green: 0, blue: 100, constant: 0 }, + monochrome: false, + monoRed: 40, + monoGreen: 40, + monoBlue: 20, + monoConstant: 0, +}; + +export const CHANNEL_MIXER_PRESETS = { + default: { + red: { red: 100, green: 0, blue: 0, constant: 0 }, + green: { red: 0, green: 100, blue: 0, constant: 0 }, + blue: { red: 0, green: 0, blue: 100, constant: 0 }, + }, + swapRedBlue: { + red: { red: 0, green: 0, blue: 100, constant: 0 }, + green: { red: 0, green: 100, blue: 0, constant: 0 }, + blue: { red: 100, green: 0, blue: 0, constant: 0 }, + }, + sepia: { + red: { red: 100, green: 50, blue: 0, constant: 0 }, + green: { red: 60, green: 60, blue: 0, constant: 0 }, + blue: { red: 30, green: 30, blue: 30, constant: 0 }, + }, + cyberPunk: { + red: { red: 100, green: 0, blue: 50, constant: 0 }, + green: { red: 0, green: 100, blue: 50, constant: 0 }, + blue: { red: 50, green: 0, blue: 100, constant: 0 }, + }, +}; + +export function applyChannelMixer(imageData: ImageData, settings: ChannelMixerSettings): ImageData { + const { width, height, data } = imageData; + const resultData = new Uint8ClampedArray(data.length); + + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const a = data[i + 3]; + + let newR: number, newG: number, newB: number; + + if (settings.monochrome) { + const gray = + r * (settings.monoRed / 100) + + g * (settings.monoGreen / 100) + + b * (settings.monoBlue / 100) + + settings.monoConstant * 2.55; + + newR = newG = newB = Math.max(0, Math.min(255, gray)); + } else { + newR = + r * (settings.red.red / 100) + + g * (settings.red.green / 100) + + b * (settings.red.blue / 100) + + settings.red.constant * 2.55; + + newG = + r * (settings.green.red / 100) + + g * (settings.green.green / 100) + + b * (settings.green.blue / 100) + + settings.green.constant * 2.55; + + newB = + r * (settings.blue.red / 100) + + g * (settings.blue.green / 100) + + b * (settings.blue.blue / 100) + + settings.blue.constant * 2.55; + } + + resultData[i] = Math.max(0, Math.min(255, newR)); + resultData[i + 1] = Math.max(0, Math.min(255, newG)); + resultData[i + 2] = Math.max(0, Math.min(255, newB)); + resultData[i + 3] = a; + } + + return new ImageData(resultData, width, height); +} diff --git a/services/editor/apps/image/src/adjustments/color-balance.ts b/services/editor/apps/image/src/adjustments/color-balance.ts new file mode 100644 index 0000000..941e14a --- /dev/null +++ b/services/editor/apps/image/src/adjustments/color-balance.ts @@ -0,0 +1,111 @@ +export interface ColorBalanceSettings { + shadows: { + cyanRed: number; + magentaGreen: number; + yellowBlue: number; + }; + midtones: { + cyanRed: number; + magentaGreen: number; + yellowBlue: number; + }; + highlights: { + cyanRed: number; + magentaGreen: number; + yellowBlue: number; + }; + preserveLuminosity: boolean; +} + +export const DEFAULT_COLOR_BALANCE: ColorBalanceSettings = { + shadows: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 }, + midtones: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 }, + highlights: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 }, + preserveLuminosity: true, +}; + +function getLuminance(r: number, g: number, b: number): number { + return r * 0.299 + g * 0.587 + b * 0.114; +} + +function getToneWeight(luminance: number, tone: 'shadows' | 'midtones' | 'highlights'): number { + const normalized = luminance / 255; + + switch (tone) { + case 'shadows': + if (normalized <= 0.25) return 1; + if (normalized <= 0.5) return 1 - (normalized - 0.25) / 0.25; + return 0; + + case 'highlights': + if (normalized >= 0.75) return 1; + if (normalized >= 0.5) return (normalized - 0.5) / 0.25; + return 0; + + case 'midtones': + if (normalized >= 0.25 && normalized <= 0.75) { + const distFromCenter = Math.abs(normalized - 0.5); + return 1 - distFromCenter / 0.25; + } + return 0; + } +} + +export function applyColorBalance(imageData: ImageData, settings: ColorBalanceSettings): ImageData { + const { width, height, data } = imageData; + const resultData = new Uint8ClampedArray(data.length); + + for (let i = 0; i < data.length; i += 4) { + let r = data[i]; + let g = data[i + 1]; + let b = data[i + 2]; + const a = data[i + 3]; + + const luminance = getLuminance(r, g, b); + + const shadowWeight = getToneWeight(luminance, 'shadows'); + const midtoneWeight = getToneWeight(luminance, 'midtones'); + const highlightWeight = getToneWeight(luminance, 'highlights'); + + let rShift = 0, gShift = 0, bShift = 0; + + if (shadowWeight > 0) { + rShift += settings.shadows.cyanRed * shadowWeight; + gShift += settings.shadows.magentaGreen * shadowWeight; + bShift += settings.shadows.yellowBlue * shadowWeight; + } + + if (midtoneWeight > 0) { + rShift += settings.midtones.cyanRed * midtoneWeight; + gShift += settings.midtones.magentaGreen * midtoneWeight; + bShift += settings.midtones.yellowBlue * midtoneWeight; + } + + if (highlightWeight > 0) { + rShift += settings.highlights.cyanRed * highlightWeight; + gShift += settings.highlights.magentaGreen * highlightWeight; + bShift += settings.highlights.yellowBlue * highlightWeight; + } + + r = Math.max(0, Math.min(255, r + rShift)); + g = Math.max(0, Math.min(255, g + gShift)); + b = Math.max(0, Math.min(255, b + bShift)); + + if (settings.preserveLuminosity) { + const newLuminance = getLuminance(r, g, b); + if (newLuminance > 0) { + const ratio = luminance / newLuminance; + r = Math.max(0, Math.min(255, r * ratio)); + g = Math.max(0, Math.min(255, g * ratio)); + b = Math.max(0, Math.min(255, b * ratio)); + } + } + + resultData[i] = r; + resultData[i + 1] = g; + resultData[i + 2] = b; + resultData[i + 3] = a; + } + + return new ImageData(resultData, width, height); +} diff --git a/services/editor/apps/image/src/adjustments/color-lookup.ts b/services/editor/apps/image/src/adjustments/color-lookup.ts new file mode 100644 index 0000000..aab063b --- /dev/null +++ b/services/editor/apps/image/src/adjustments/color-lookup.ts @@ -0,0 +1,176 @@ +export interface ColorLookupSettings { + lutData: Float32Array | null; + lutSize: number; + strength: number; +} + +export const DEFAULT_COLOR_LOOKUP: ColorLookupSettings = { + lutData: null, + lutSize: 0, + strength: 100, +}; + +export function parseCubeLUT(content: string): { data: Float32Array; size: number } | null { + const lines = content.split('\n'); + let size = 0; + const data: number[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith('#') || trimmed === '') continue; + + if (trimmed.startsWith('LUT_3D_SIZE')) { + const match = trimmed.match(/LUT_3D_SIZE\s+(\d+)/); + if (match) { + size = parseInt(match[1], 10); + } + continue; + } + + if (trimmed.startsWith('TITLE') || trimmed.startsWith('DOMAIN_')) continue; + + const values = trimmed.split(/\s+/).map(parseFloat); + if (values.length === 3 && values.every((v) => !isNaN(v))) { + data.push(...values); + } + } + + if (size === 0 || data.length !== size * size * size * 3) { + return null; + } + + return { data: new Float32Array(data), size }; +} + +export function parse3dlLUT(content: string): { data: Float32Array; size: number } | null { + const lines = content.split('\n'); + const data: number[] = []; + let size = 0; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === '' || trimmed.startsWith('#')) continue; + + const values = trimmed.split(/\s+/).map(parseFloat); + + if (values.length === 1 && size === 0) { + size = Math.round(Math.cbrt(values[0])); + continue; + } + + if (values.length === 3 && values.every((v) => !isNaN(v))) { + data.push(values[0] / 4095, values[1] / 4095, values[2] / 4095); + } + } + + if (size === 0) { + size = Math.round(Math.cbrt(data.length / 3)); + } + + if (size === 0 || data.length !== size * size * size * 3) { + return null; + } + + return { data: new Float32Array(data), size }; +} + +function trilinearInterpolate( + lutData: Float32Array, + size: number, + r: number, + g: number, + b: number +): { r: number; g: number; b: number } { + const rScaled = r * (size - 1); + const gScaled = g * (size - 1); + const bScaled = b * (size - 1); + + const r0 = Math.floor(rScaled); + const g0 = Math.floor(gScaled); + const b0 = Math.floor(bScaled); + + const r1 = Math.min(r0 + 1, size - 1); + const g1 = Math.min(g0 + 1, size - 1); + const b1 = Math.min(b0 + 1, size - 1); + + const rFrac = rScaled - r0; + const gFrac = gScaled - g0; + const bFrac = bScaled - b0; + + const getIndex = (ri: number, gi: number, bi: number) => (bi * size * size + gi * size + ri) * 3; + + const c000 = getIndex(r0, g0, b0); + const c100 = getIndex(r1, g0, b0); + const c010 = getIndex(r0, g1, b0); + const c110 = getIndex(r1, g1, b0); + const c001 = getIndex(r0, g0, b1); + const c101 = getIndex(r1, g0, b1); + const c011 = getIndex(r0, g1, b1); + const c111 = getIndex(r1, g1, b1); + + const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + + const interpolate = (channel: number) => { + const c00 = lerp(lutData[c000 + channel], lutData[c100 + channel], rFrac); + const c01 = lerp(lutData[c001 + channel], lutData[c101 + channel], rFrac); + const c10 = lerp(lutData[c010 + channel], lutData[c110 + channel], rFrac); + const c11 = lerp(lutData[c011 + channel], lutData[c111 + channel], rFrac); + + const c0 = lerp(c00, c10, gFrac); + const c1 = lerp(c01, c11, gFrac); + + return lerp(c0, c1, bFrac); + }; + + return { + r: interpolate(0), + g: interpolate(1), + b: interpolate(2), + }; +} + +export function applyColorLookup(imageData: ImageData, settings: ColorLookupSettings): ImageData { + const { width, height, data } = imageData; + const resultData = new Uint8ClampedArray(data.length); + + if (!settings.lutData || settings.lutSize === 0) { + resultData.set(data); + return new ImageData(resultData, width, height); + } + + const strength = settings.strength / 100; + + for (let i = 0; i < data.length; i += 4) { + const r = data[i] / 255; + const g = data[i + 1] / 255; + const b = data[i + 2] / 255; + const a = data[i + 3]; + + const lutColor = trilinearInterpolate(settings.lutData, settings.lutSize, r, g, b); + + resultData[i] = Math.max(0, Math.min(255, (r + (lutColor.r - r) * strength) * 255)); + resultData[i + 1] = Math.max(0, Math.min(255, (g + (lutColor.g - g) * strength) * 255)); + resultData[i + 2] = Math.max(0, Math.min(255, (b + (lutColor.b - b) * strength) * 255)); + resultData[i + 3] = a; + } + + return new ImageData(resultData, width, height); +} + +export function createIdentityLUT(size: number): Float32Array { + const data = new Float32Array(size * size * size * 3); + + for (let b = 0; b < size; b++) { + for (let g = 0; g < size; g++) { + for (let r = 0; r < size; r++) { + const idx = (b * size * size + g * size + r) * 3; + data[idx] = r / (size - 1); + data[idx + 1] = g / (size - 1); + data[idx + 2] = b / (size - 1); + } + } + } + + return data; +} diff --git a/services/editor/apps/image/src/adjustments/gradient-map.ts b/services/editor/apps/image/src/adjustments/gradient-map.ts new file mode 100644 index 0000000..ba8fb88 --- /dev/null +++ b/services/editor/apps/image/src/adjustments/gradient-map.ts @@ -0,0 +1,164 @@ +export interface GradientStop { + position: number; + color: string; +} + +export interface GradientMapSettings { + stops: GradientStop[]; + dither: boolean; + reverse: boolean; +} + +export const DEFAULT_GRADIENT_MAP: GradientMapSettings = { + stops: [ + { position: 0, color: '#000000' }, + { position: 100, color: '#ffffff' }, + ], + dither: false, + reverse: false, +}; + +export const GRADIENT_MAP_PRESETS = { + blackWhite: [ + { position: 0, color: '#000000' }, + { position: 100, color: '#ffffff' }, + ], + sepiaTone: [ + { position: 0, color: '#1a0f00' }, + { position: 50, color: '#8b6914' }, + { position: 100, color: '#ffe7b3' }, + ], + duotoneBlueOrange: [ + { position: 0, color: '#001f4d' }, + { position: 100, color: '#ff8c00' }, + ], + duotonePurpleTeal: [ + { position: 0, color: '#2d1b4e' }, + { position: 100, color: '#00d4aa' }, + ], + sunset: [ + { position: 0, color: '#1a0533' }, + { position: 33, color: '#6b1839' }, + { position: 66, color: '#d44d1b' }, + { position: 100, color: '#ffd700' }, + ], + coolBlue: [ + { position: 0, color: '#000033' }, + { position: 50, color: '#0066cc' }, + { position: 100, color: '#99ccff' }, + ], + warmRed: [ + { position: 0, color: '#1a0000' }, + { position: 50, color: '#cc3300' }, + { position: 100, color: '#ffcc99' }, + ], + greenForest: [ + { position: 0, color: '#001a00' }, + { position: 50, color: '#336600' }, + { position: 100, color: '#99cc66' }, + ], + infrared: [ + { position: 0, color: '#000000' }, + { position: 25, color: '#330066' }, + { position: 50, color: '#ff0066' }, + { position: 75, color: '#ffcc00' }, + { position: 100, color: '#ffffff' }, + ], + thermal: [ + { position: 0, color: '#000033' }, + { position: 25, color: '#6600cc' }, + { position: 50, color: '#ff0000' }, + { position: 75, color: '#ffff00' }, + { position: 100, color: '#ffffff' }, + ], +}; + +function parseColor(color: string): { r: number; g: number; b: number } { + const match = color.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i); + if (match) { + return { + r: parseInt(match[1], 16), + g: parseInt(match[2], 16), + b: parseInt(match[3], 16), + }; + } + return { r: 0, g: 0, b: 0 }; +} + +function interpolateGradient( + stops: GradientStop[], + position: number +): { r: number; g: number; b: number } { + if (stops.length === 0) return { r: 0, g: 0, b: 0 }; + if (stops.length === 1) return parseColor(stops[0].color); + + const sortedStops = [...stops].sort((a, b) => a.position - b.position); + + if (position <= sortedStops[0].position) { + return parseColor(sortedStops[0].color); + } + if (position >= sortedStops[sortedStops.length - 1].position) { + return parseColor(sortedStops[sortedStops.length - 1].color); + } + + for (let i = 0; i < sortedStops.length - 1; i++) { + const stop1 = sortedStops[i]; + const stop2 = sortedStops[i + 1]; + + if (position >= stop1.position && position <= stop2.position) { + const t = (position - stop1.position) / (stop2.position - stop1.position); + const c1 = parseColor(stop1.color); + const c2 = parseColor(stop2.color); + + return { + r: Math.round(c1.r + (c2.r - c1.r) * t), + g: Math.round(c1.g + (c2.g - c1.g) * t), + b: Math.round(c1.b + (c2.b - c1.b) * t), + }; + } + } + + return parseColor(sortedStops[sortedStops.length - 1].color); +} + +function getLuminance(r: number, g: number, b: number): number { + return (r * 0.299 + g * 0.587 + b * 0.114) / 255; +} + +export function applyGradientMap(imageData: ImageData, settings: GradientMapSettings): ImageData { + const { width, height, data } = imageData; + const resultData = new Uint8ClampedArray(data.length); + + const lookupTable: Array<{ r: number; g: number; b: number }> = []; + for (let i = 0; i < 256; i++) { + let position = (i / 255) * 100; + if (settings.reverse) { + position = 100 - position; + } + lookupTable[i] = interpolateGradient(settings.stops, position); + } + + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const a = data[i + 3]; + + let luminance = getLuminance(r, g, b); + + if (settings.dither) { + const noise = (Math.random() - 0.5) * (1 / 255); + luminance = Math.max(0, Math.min(1, luminance + noise)); + } + + const idx = Math.round(luminance * 255); + const mappedColor = lookupTable[idx]; + + resultData[i] = mappedColor.r; + resultData[i + 1] = mappedColor.g; + resultData[i + 2] = mappedColor.b; + resultData[i + 3] = a; + } + + return new ImageData(resultData, width, height); +} diff --git a/services/editor/apps/image/src/adjustments/histogram.ts b/services/editor/apps/image/src/adjustments/histogram.ts new file mode 100644 index 0000000..cfc59d4 --- /dev/null +++ b/services/editor/apps/image/src/adjustments/histogram.ts @@ -0,0 +1,305 @@ +export interface HistogramData { + red: Uint32Array; + green: Uint32Array; + blue: Uint32Array; + luminosity: Uint32Array; +} + +export interface HistogramStatistics { + mean: number; + stdDev: number; + median: number; + min: number; + max: number; + pixelCount: number; + shadowsClipped: number; + highlightsClipped: number; +} + +export interface HistogramResult { + data: HistogramData; + statistics: { + red: HistogramStatistics; + green: HistogramStatistics; + blue: HistogramStatistics; + luminosity: HistogramStatistics; + }; +} + +export interface ColorInfo { + rgb: { r: number; g: number; b: number }; + hsb: { h: number; s: number; b: number }; + hsl: { h: number; s: number; l: number }; + lab: { l: number; a: number; b: number }; + cmyk: { c: number; m: number; y: number; k: number }; + hex: string; +} + +function calculateStatistics(histogram: Uint32Array, totalPixels: number): HistogramStatistics { + let sum = 0; + let min = 255; + let max = 0; + let pixelCount = 0; + + for (let i = 0; i < 256; i++) { + const count = histogram[i]; + if (count > 0) { + sum += i * count; + pixelCount += count; + if (i < min) min = i; + if (i > max) max = i; + } + } + + const mean = pixelCount > 0 ? sum / pixelCount : 0; + + let varianceSum = 0; + for (let i = 0; i < 256; i++) { + const count = histogram[i]; + if (count > 0) { + varianceSum += count * Math.pow(i - mean, 2); + } + } + const stdDev = pixelCount > 0 ? Math.sqrt(varianceSum / pixelCount) : 0; + + let medianCount = 0; + let median = 0; + const halfCount = pixelCount / 2; + for (let i = 0; i < 256; i++) { + medianCount += histogram[i]; + if (medianCount >= halfCount) { + median = i; + break; + } + } + + const shadowsClipped = (histogram[0] / totalPixels) * 100; + const highlightsClipped = (histogram[255] / totalPixels) * 100; + + return { + mean, + stdDev, + median, + min: pixelCount > 0 ? min : 0, + max: pixelCount > 0 ? max : 0, + pixelCount, + shadowsClipped, + highlightsClipped, + }; +} + +export function calculateHistogram(imageData: ImageData): HistogramResult { + const { data } = imageData; + + const histogramData: HistogramData = { + red: new Uint32Array(256), + green: new Uint32Array(256), + blue: new Uint32Array(256), + luminosity: new Uint32Array(256), + }; + + const totalPixels = data.length / 4; + + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + histogramData.red[r]++; + histogramData.green[g]++; + histogramData.blue[b]++; + + const luminosity = Math.round(r * 0.299 + g * 0.587 + b * 0.114); + histogramData.luminosity[luminosity]++; + } + + return { + data: histogramData, + statistics: { + red: calculateStatistics(histogramData.red, totalPixels), + green: calculateStatistics(histogramData.green, totalPixels), + blue: calculateStatistics(histogramData.blue, totalPixels), + luminosity: calculateStatistics(histogramData.luminosity, totalPixels), + }, + }; +} + +export function getColorInfo(r: number, g: number, b: number): ColorInfo { + const rNorm = r / 255; + const gNorm = g / 255; + const bNorm = b / 255; + + const max = Math.max(rNorm, gNorm, bNorm); + const min = Math.min(rNorm, gNorm, bNorm); + const delta = max - min; + + let h = 0; + if (delta !== 0) { + if (max === rNorm) { + h = ((gNorm - bNorm) / delta + (gNorm < bNorm ? 6 : 0)) / 6; + } else if (max === gNorm) { + h = ((bNorm - rNorm) / delta + 2) / 6; + } else { + h = ((rNorm - gNorm) / delta + 4) / 6; + } + } + + const l = (max + min) / 2; + const sHsl = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + + const sBrightness = max === 0 ? 0 : delta / max; + + const k = 1 - max; + const c = max === 0 ? 0 : (1 - rNorm - k) / (1 - k); + const m = max === 0 ? 0 : (1 - gNorm - k) / (1 - k); + const y = max === 0 ? 0 : (1 - bNorm - k) / (1 - k); + + const xyzR = rNorm > 0.04045 ? Math.pow((rNorm + 0.055) / 1.055, 2.4) : rNorm / 12.92; + const xyzG = gNorm > 0.04045 ? Math.pow((gNorm + 0.055) / 1.055, 2.4) : gNorm / 12.92; + const xyzB = bNorm > 0.04045 ? Math.pow((bNorm + 0.055) / 1.055, 2.4) : bNorm / 12.92; + + const x = (xyzR * 0.4124564 + xyzG * 0.3575761 + xyzB * 0.1804375) / 0.95047; + const yVal = xyzR * 0.2126729 + xyzG * 0.7151522 + xyzB * 0.0721750; + const z = (xyzR * 0.0193339 + xyzG * 0.1191920 + xyzB * 0.9503041) / 1.08883; + + const f = (t: number) => t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116; + + const labL = 116 * f(yVal) - 16; + const labA = 500 * (f(x) - f(yVal)); + const labB = 200 * (f(yVal) - f(z)); + + const hex = '#' + + r.toString(16).padStart(2, '0') + + g.toString(16).padStart(2, '0') + + b.toString(16).padStart(2, '0'); + + return { + rgb: { r, g, b }, + hsb: { + h: Math.round(h * 360), + s: Math.round(sBrightness * 100), + b: Math.round(max * 100), + }, + hsl: { + h: Math.round(h * 360), + s: Math.round(sHsl * 100), + l: Math.round(l * 100), + }, + lab: { + l: Math.round(labL), + a: Math.round(labA), + b: Math.round(labB), + }, + cmyk: { + c: Math.round(c * 100), + m: Math.round(m * 100), + y: Math.round(y * 100), + k: Math.round(k * 100), + }, + hex, + }; +} + +export function renderHistogram( + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, + histogram: Uint32Array, + color: string, + width: number, + height: number, + logarithmic: boolean = false +): void { + const maxValue = Math.max(...histogram); + if (maxValue === 0) return; + + ctx.fillStyle = color; + ctx.globalAlpha = 0.7; + + const barWidth = width / 256; + + for (let i = 0; i < 256; i++) { + let normalizedValue: number; + if (logarithmic && histogram[i] > 0) { + normalizedValue = Math.log10(histogram[i] + 1) / Math.log10(maxValue + 1); + } else { + normalizedValue = histogram[i] / maxValue; + } + + const barHeight = normalizedValue * height; + ctx.fillRect(i * barWidth, height - barHeight, barWidth, barHeight); + } + + ctx.globalAlpha = 1; +} + +export function autoLevels(imageData: ImageData, clipPercent: number = 0.1): ImageData { + const { width, height, data } = imageData; + const resultData = new Uint8ClampedArray(data.length); + + const histogram = calculateHistogram(imageData); + const totalPixels = data.length / 4; + const clipPixels = Math.round(totalPixels * (clipPercent / 100)); + + const findClipPoint = (hist: Uint32Array, fromStart: boolean): number => { + let count = 0; + if (fromStart) { + for (let i = 0; i < 256; i++) { + count += hist[i]; + if (count > clipPixels) return i; + } + return 0; + } else { + for (let i = 255; i >= 0; i--) { + count += hist[i]; + if (count > clipPixels) return i; + } + return 255; + } + }; + + const channels = ['red', 'green', 'blue'] as const; + const adjustments = channels.map((channel) => { + const hist = histogram.data[channel]; + const inputBlack = findClipPoint(hist, true); + const inputWhite = findClipPoint(hist, false); + return { inputBlack, inputWhite }; + }); + + for (let i = 0; i < data.length; i += 4) { + for (let c = 0; c < 3; c++) { + const { inputBlack, inputWhite } = adjustments[c]; + const range = inputWhite - inputBlack || 1; + const value = data[i + c]; + const adjusted = ((value - inputBlack) / range) * 255; + resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted))); + } + resultData[i + 3] = data[i + 3]; + } + + return new ImageData(resultData, width, height); +} + +export function autoContrast(imageData: ImageData): ImageData { + const { width, height, data } = imageData; + const resultData = new Uint8ClampedArray(data.length); + + let minLum = 255; + let maxLum = 0; + + for (let i = 0; i < data.length; i += 4) { + const lum = Math.round(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114); + if (lum < minLum) minLum = lum; + if (lum > maxLum) maxLum = lum; + } + + const range = maxLum - minLum || 1; + + for (let i = 0; i < data.length; i += 4) { + for (let c = 0; c < 3; c++) { + const adjusted = ((data[i + c] - minLum) / range) * 255; + resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted))); + } + resultData[i + 3] = data[i + 3]; + } + + return new ImageData(resultData, width, height); +} diff --git a/services/editor/apps/image/src/adjustments/photo-filter.ts b/services/editor/apps/image/src/adjustments/photo-filter.ts new file mode 100644 index 0000000..1a8ab36 --- /dev/null +++ b/services/editor/apps/image/src/adjustments/photo-filter.ts @@ -0,0 +1,117 @@ +export type PhotoFilterPreset = + | 'warming-85' + | 'warming-81' + | 'warming-lba' + | 'cooling-80' + | 'cooling-82' + | 'cooling-lbb' + | 'red' + | 'orange' + | 'yellow' + | 'green' + | 'cyan' + | 'blue' + | 'violet' + | 'magenta' + | 'sepia' + | 'deep-red' + | 'deep-blue' + | 'deep-emerald' + | 'deep-yellow' + | 'underwater' + | 'custom'; + +export interface PhotoFilterSettings { + filter: PhotoFilterPreset; + color: string; + density: number; + preserveLuminosity: boolean; +} + +export const DEFAULT_PHOTO_FILTER: PhotoFilterSettings = { + filter: 'warming-85', + color: '#ec8a00', + density: 25, + preserveLuminosity: true, +}; + +export const PHOTO_FILTER_COLORS: Record = { + 'warming-85': '#ec8a00', + 'warming-81': '#ebb113', + 'warming-lba': '#fa9600', + 'cooling-80': '#006dff', + 'cooling-82': '#00b5ff', + 'cooling-lbb': '#005fcc', + red: '#ea1a1a', + orange: '#f28e00', + yellow: '#f9d71c', + green: '#1ab800', + cyan: '#00e5e5', + blue: '#0000ff', + violet: '#8000ff', + magenta: '#ea00ea', + sepia: '#ac7a33', + 'deep-red': '#a10000', + 'deep-blue': '#000066', + 'deep-emerald': '#003d00', + 'deep-yellow': '#998c00', + underwater: '#00c2b0', + custom: '#ffffff', +}; + +function parseColor(color: string): { r: number; g: number; b: number } { + const match = color.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i); + if (match) { + return { + r: parseInt(match[1], 16), + g: parseInt(match[2], 16), + b: parseInt(match[3], 16), + }; + } + return { r: 255, g: 255, b: 255 }; +} + +function getLuminance(r: number, g: number, b: number): number { + return r * 0.299 + g * 0.587 + b * 0.114; +} + +export function applyPhotoFilter(imageData: ImageData, settings: PhotoFilterSettings): ImageData { + const { width, height, data } = imageData; + const resultData = new Uint8ClampedArray(data.length); + + const filterColor = settings.filter === 'custom' + ? parseColor(settings.color) + : parseColor(PHOTO_FILTER_COLORS[settings.filter]); + + const density = settings.density / 100; + + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const a = data[i + 3]; + + const originalLuminance = getLuminance(r, g, b); + + let newR = r + (filterColor.r - r) * density; + let newG = g + (filterColor.g - g) * density; + let newB = b + (filterColor.b - b) * density; + + if (settings.preserveLuminosity) { + const newLuminance = getLuminance(newR, newG, newB); + if (newLuminance > 0) { + const ratio = originalLuminance / newLuminance; + newR *= ratio; + newG *= ratio; + newB *= ratio; + } + } + + resultData[i] = Math.max(0, Math.min(255, newR)); + resultData[i + 1] = Math.max(0, Math.min(255, newG)); + resultData[i + 2] = Math.max(0, Math.min(255, newB)); + resultData[i + 3] = a; + } + + return new ImageData(resultData, width, height); +} diff --git a/services/editor/apps/image/src/adjustments/posterize-threshold.ts b/services/editor/apps/image/src/adjustments/posterize-threshold.ts new file mode 100644 index 0000000..46ad1f7 --- /dev/null +++ b/services/editor/apps/image/src/adjustments/posterize-threshold.ts @@ -0,0 +1,108 @@ +export interface PosterizeSettings { + levels: number; +} + +export interface ThresholdSettings { + level: number; +} + +export const DEFAULT_POSTERIZE: PosterizeSettings = { + levels: 4, +}; + +export const DEFAULT_THRESHOLD: ThresholdSettings = { + level: 128, +}; + +export function applyPosterize(imageData: ImageData, settings: PosterizeSettings): ImageData { + const { width, height, data } = imageData; + const resultData = new Uint8ClampedArray(data.length); + + const levels = Math.max(2, Math.min(255, Math.round(settings.levels))); + const step = 255 / (levels - 1); + const divisor = 256 / levels; + + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const a = data[i + 3]; + + resultData[i] = Math.round(Math.floor(r / divisor) * step); + resultData[i + 1] = Math.round(Math.floor(g / divisor) * step); + resultData[i + 2] = Math.round(Math.floor(b / divisor) * step); + resultData[i + 3] = a; + } + + return new ImageData(resultData, width, height); +} + +export function applyThreshold(imageData: ImageData, settings: ThresholdSettings): ImageData { + const { width, height, data } = imageData; + const resultData = new Uint8ClampedArray(data.length); + + const level = Math.max(0, Math.min(255, settings.level)); + + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const a = data[i + 3]; + + const luminance = r * 0.299 + g * 0.587 + b * 0.114; + const value = luminance >= level ? 255 : 0; + + resultData[i] = value; + resultData[i + 1] = value; + resultData[i + 2] = value; + resultData[i + 3] = a; + } + + return new ImageData(resultData, width, height); +} + +export function applyAdaptiveThreshold( + imageData: ImageData, + blockSize: number = 11, + constant: number = 2 +): ImageData { + const { width, height, data } = imageData; + const resultData = new Uint8ClampedArray(data.length); + + const grayData = new Uint8Array(width * height); + for (let i = 0; i < data.length; i += 4) { + const idx = i / 4; + grayData[idx] = Math.round(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114); + } + + const halfBlock = Math.floor(blockSize / 2); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let sum = 0; + let count = 0; + + for (let by = -halfBlock; by <= halfBlock; by++) { + for (let bx = -halfBlock; bx <= halfBlock; bx++) { + const nx = Math.min(Math.max(x + bx, 0), width - 1); + const ny = Math.min(Math.max(y + by, 0), height - 1); + sum += grayData[ny * width + nx]; + count++; + } + } + + const mean = sum / count; + const threshold = mean - constant; + const pixelIdx = y * width + x; + const value = grayData[pixelIdx] > threshold ? 255 : 0; + + const i = pixelIdx * 4; + resultData[i] = value; + resultData[i + 1] = value; + resultData[i + 2] = value; + resultData[i + 3] = data[i + 3]; + } + } + + return new ImageData(resultData, width, height); +} diff --git a/services/editor/apps/image/src/adjustments/selective-color.ts b/services/editor/apps/image/src/adjustments/selective-color.ts new file mode 100644 index 0000000..b32a6fd --- /dev/null +++ b/services/editor/apps/image/src/adjustments/selective-color.ts @@ -0,0 +1,225 @@ +export type SelectiveColorRange = + | 'reds' + | 'yellows' + | 'greens' + | 'cyans' + | 'blues' + | 'magentas' + | 'whites' + | 'neutrals' + | 'blacks'; + +export interface SelectiveColorAdjustment { + cyan: number; + magenta: number; + yellow: number; + black: number; +} + +export interface SelectiveColorSettings { + reds: SelectiveColorAdjustment; + yellows: SelectiveColorAdjustment; + greens: SelectiveColorAdjustment; + cyans: SelectiveColorAdjustment; + blues: SelectiveColorAdjustment; + magentas: SelectiveColorAdjustment; + whites: SelectiveColorAdjustment; + neutrals: SelectiveColorAdjustment; + blacks: SelectiveColorAdjustment; + method: 'relative' | 'absolute'; +} + +const DEFAULT_ADJUSTMENT: SelectiveColorAdjustment = { + cyan: 0, + magenta: 0, + yellow: 0, + black: 0, +}; + +export const DEFAULT_SELECTIVE_COLOR: SelectiveColorSettings = { + reds: { ...DEFAULT_ADJUSTMENT }, + yellows: { ...DEFAULT_ADJUSTMENT }, + greens: { ...DEFAULT_ADJUSTMENT }, + cyans: { ...DEFAULT_ADJUSTMENT }, + blues: { ...DEFAULT_ADJUSTMENT }, + magentas: { ...DEFAULT_ADJUSTMENT }, + whites: { ...DEFAULT_ADJUSTMENT }, + neutrals: { ...DEFAULT_ADJUSTMENT }, + blacks: { ...DEFAULT_ADJUSTMENT }, + method: 'relative', +}; + +function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + + if (max === min) { + return { h: 0, s: 0, l }; + } + + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + let h: number; + switch (max) { + case r: + h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + break; + case g: + h = ((b - r) / d + 2) / 6; + break; + default: + h = ((r - g) / d + 4) / 6; + break; + } + + return { h, s, l }; +} + +function getColorRangeWeight(r: number, g: number, b: number, range: SelectiveColorRange): number { + const { h, s, l } = rgbToHsl(r, g, b); + const hue = h * 360; + + switch (range) { + case 'reds': + if (s < 0.1) return 0; + if ((hue >= 345 || hue <= 15)) return s; + if (hue > 15 && hue <= 45) return s * (1 - (hue - 15) / 30); + if (hue >= 315 && hue < 345) return s * ((hue - 315) / 30); + return 0; + + case 'yellows': + if (s < 0.1) return 0; + if (hue >= 45 && hue <= 75) return s; + if (hue > 15 && hue < 45) return s * ((hue - 15) / 30); + if (hue > 75 && hue <= 105) return s * (1 - (hue - 75) / 30); + return 0; + + case 'greens': + if (s < 0.1) return 0; + if (hue >= 105 && hue <= 135) return s; + if (hue > 75 && hue < 105) return s * ((hue - 75) / 30); + if (hue > 135 && hue <= 165) return s * (1 - (hue - 135) / 30); + return 0; + + case 'cyans': + if (s < 0.1) return 0; + if (hue >= 165 && hue <= 195) return s; + if (hue > 135 && hue < 165) return s * ((hue - 135) / 30); + if (hue > 195 && hue <= 225) return s * (1 - (hue - 195) / 30); + return 0; + + case 'blues': + if (s < 0.1) return 0; + if (hue >= 225 && hue <= 255) return s; + if (hue > 195 && hue < 225) return s * ((hue - 195) / 30); + if (hue > 255 && hue <= 285) return s * (1 - (hue - 255) / 30); + return 0; + + case 'magentas': + if (s < 0.1) return 0; + if (hue >= 285 && hue <= 315) return s; + if (hue > 255 && hue < 285) return s * ((hue - 255) / 30); + if (hue > 315 && hue <= 345) return s * (1 - (hue - 315) / 30); + return 0; + + case 'whites': + if (l >= 0.8) return (l - 0.8) / 0.2; + return 0; + + case 'blacks': + if (l <= 0.2) return (0.2 - l) / 0.2; + return 0; + + case 'neutrals': + if (s < 0.2 && l > 0.2 && l < 0.8) { + return (0.2 - s) / 0.2 * Math.min((l - 0.2) / 0.3, (0.8 - l) / 0.3, 1); + } + return 0; + } +} + +function rgbToCmyk(r: number, g: number, b: number): { c: number; m: number; y: number; k: number } { + r /= 255; + g /= 255; + b /= 255; + + const k = 1 - Math.max(r, g, b); + if (k === 1) { + return { c: 0, m: 0, y: 0, k: 1 }; + } + + const c = (1 - r - k) / (1 - k); + const m = (1 - g - k) / (1 - k); + const y = (1 - b - k) / (1 - k); + + return { c, m, y, k }; +} + +function cmykToRgb(c: number, m: number, y: number, k: number): { r: number; g: number; b: number } { + const r = 255 * (1 - c) * (1 - k); + const g = 255 * (1 - m) * (1 - k); + const b = 255 * (1 - y) * (1 - k); + + return { + r: Math.max(0, Math.min(255, Math.round(r))), + g: Math.max(0, Math.min(255, Math.round(g))), + b: Math.max(0, Math.min(255, Math.round(b))), + }; +} + +export function applySelectiveColor(imageData: ImageData, settings: SelectiveColorSettings): ImageData { + const { width, height, data } = imageData; + const resultData = new Uint8ClampedArray(data.length); + + const ranges: SelectiveColorRange[] = [ + 'reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'whites', 'neutrals', 'blacks' + ]; + + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const a = data[i + 3]; + + let { c, m, y, k } = rgbToCmyk(r, g, b); + + for (const range of ranges) { + const weight = getColorRangeWeight(r, g, b, range); + if (weight <= 0) continue; + + const adj = settings[range]; + + if (settings.method === 'relative') { + c = c + (adj.cyan / 100) * c * weight; + m = m + (adj.magenta / 100) * m * weight; + y = y + (adj.yellow / 100) * y * weight; + k = k + (adj.black / 100) * k * weight; + } else { + c = c + (adj.cyan / 100) * weight; + m = m + (adj.magenta / 100) * weight; + y = y + (adj.yellow / 100) * weight; + k = k + (adj.black / 100) * weight; + } + } + + c = Math.max(0, Math.min(1, c)); + m = Math.max(0, Math.min(1, m)); + y = Math.max(0, Math.min(1, y)); + k = Math.max(0, Math.min(1, k)); + + const rgb = cmykToRgb(c, m, y, k); + + resultData[i] = rgb.r; + resultData[i + 1] = rgb.g; + resultData[i + 2] = rgb.b; + resultData[i + 3] = a; + } + + return new ImageData(resultData, width, height); +} diff --git a/services/editor/apps/image/src/app.test.ts b/services/editor/apps/image/src/app.test.ts new file mode 100644 index 0000000..0a20495 --- /dev/null +++ b/services/editor/apps/image/src/app.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from 'vitest'; +import { parseProject } from './services/project-schema'; +import { migrateProject, CURRENT_VERSION } from './services/project-migration'; + +// ── App smoke tests ────────────────────────────────────────────────────────── +// +// These tests exercise the integration seam between the project schema, +// migration utilities, and the store to confirm the whole pipeline is wired up +// and importing correctly. + +describe('OpenReel Image – baseline smoke tests', () => { + // Schema is importable. + it('project schema module is importable', () => { + expect(typeof parseProject).toBe('function'); + }); + + // Migration is importable and exposes the current version constant. + it('migration module exposes CURRENT_VERSION', () => { + expect(typeof CURRENT_VERSION).toBe('number'); + expect(CURRENT_VERSION).toBeGreaterThanOrEqual(1); + }); + + // A minimal valid project document passes schema validation. + it('validates a minimal valid project', () => { + const baseLayer = { + id: 'l1', + name: 'Layer', + type: 'text' as const, + visible: true, + locked: false, + transform: { + x: 0, y: 0, width: 200, height: 50, rotation: 0, + scaleX: 1, scaleY: 1, skewX: 0, skewY: 0, opacity: 1, + }, + blendMode: { mode: 'normal' as const }, + shadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 10, offsetX: 0, offsetY: 4 }, + innerShadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 10, offsetX: 2, offsetY: 2 }, + stroke: { enabled: false, color: '#000000', width: 1, style: 'solid' as const }, + glow: { enabled: false, color: '#ffffff', blur: 20, intensity: 1 }, + filters: { + brightness: 100, contrast: 100, saturation: 100, hue: 0, exposure: 0, + vibrance: 0, highlights: 0, shadows: 0, clarity: 0, blur: 0, + blurType: 'gaussian' as const, blurAngle: 0, sharpen: 0, vignette: 0, + grain: 0, sepia: 0, invert: 0, + }, + parentId: null, + flipHorizontal: false, + flipVertical: false, + mask: null, + clippingMask: false, + levels: { + enabled: false, + master: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 }, + red: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 }, + green: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 }, + blue: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 }, + }, + curves: { + enabled: false, + master: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] }, + red: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] }, + green: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] }, + blue: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] }, + }, + colorBalance: { + enabled: false, + shadows: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 }, + midtones: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 }, + highlights: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 }, + preserveLuminosity: true, + }, + selectiveColor: { + enabled: false, method: 'relative' as const, + reds: { cyan: 0, magenta: 0, yellow: 0, black: 0 }, + yellows: { cyan: 0, magenta: 0, yellow: 0, black: 0 }, + greens: { cyan: 0, magenta: 0, yellow: 0, black: 0 }, + cyans: { cyan: 0, magenta: 0, yellow: 0, black: 0 }, + blues: { cyan: 0, magenta: 0, yellow: 0, black: 0 }, + magentas: { cyan: 0, magenta: 0, yellow: 0, black: 0 }, + whites: { cyan: 0, magenta: 0, yellow: 0, black: 0 }, + neutrals: { cyan: 0, magenta: 0, yellow: 0, black: 0 }, + blacks: { cyan: 0, magenta: 0, yellow: 0, black: 0 }, + }, + blackWhite: { + enabled: false, reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20, + magentas: 80, tintEnabled: false, tintHue: 35, tintSaturation: 25, + }, + photoFilter: { + enabled: false, filter: 'warming-85' as const, color: '#ec8a00', + density: 25, preserveLuminosity: true, + }, + channelMixer: { + enabled: false, monochrome: false, + red: { red: 100, green: 0, blue: 0, constant: 0 }, + green: { red: 0, green: 100, blue: 0, constant: 0 }, + blue: { red: 0, green: 0, blue: 100, constant: 0 }, + }, + gradientMap: { + enabled: false, + stops: [{ position: 0, color: '#000000' }, { position: 1, color: '#ffffff' }], + reverse: false, dither: false, + }, + posterize: { enabled: false, levels: 4 }, + threshold: { enabled: false, level: 128 }, + content: 'Hello', + style: { + fontFamily: 'Inter', fontSize: 24, fontWeight: 400, + fontStyle: 'normal' as const, textDecoration: 'none' as const, + textAlign: 'left' as const, verticalAlign: 'top' as const, + lineHeight: 1.4, letterSpacing: 0, fillType: 'solid' as const, + color: '#ffffff', gradient: null, strokeColor: null, strokeWidth: 0, + backgroundColor: null, backgroundPadding: 8, backgroundRadius: 4, + textShadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 4, offsetX: 0, offsetY: 2 }, + }, + autoSize: true, + }; + + const validProject = { + id: 'p1', + name: 'Smoke Test', + createdAt: Date.now(), + updatedAt: Date.now(), + version: 1, + artboards: [ + { + id: 'ab1', + name: 'Artboard 1', + size: { width: 1080, height: 1080 }, + background: { type: 'color', color: '#ffffff' }, + layerIds: ['l1'], + position: { x: 0, y: 0 }, + }, + ], + layers: { l1: baseLayer }, + assets: {}, + activeArtboardId: 'ab1', + }; + + const result = parseProject(validProject); + expect(result.success).toBe(true); + }); + + // An invalid document is rejected. + it('rejects an invalid project document', () => { + const result = parseProject({ id: 42, broken: true }); + expect(result.success).toBe(false); + }); + + // Migration promotes a v0 document to v1. + it('migrates a v0 project to v1', () => { + const v0 = { + id: 'old', + name: 'Legacy', + createdAt: 0, + updatedAt: 0, + artboards: [{ id: 'ab-old', name: 'Page 1' }], + layers: {}, + assets: {}, + }; + + const migrated = migrateProject(v0 as Record); + expect(migrated.version).toBe(1); + expect(migrated.activeArtboardId).toBe('ab-old'); + }); + + // A project that already has version 1 is returned unchanged. + it('does not re-migrate a current-version project', () => { + const v1 = { + id: 'current', + name: 'New', + createdAt: 0, + updatedAt: 0, + version: 1, + artboards: [], + layers: {}, + assets: {}, + activeArtboardId: null, + }; + + const migrated = migrateProject(v1 as Record); + expect(migrated.version).toBe(1); + }); +}); + diff --git a/services/editor/apps/image/src/components/editor/EditorInterface.tsx b/services/editor/apps/image/src/components/editor/EditorInterface.tsx new file mode 100644 index 0000000..2640307 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/EditorInterface.tsx @@ -0,0 +1,107 @@ +import { useState, lazy, Suspense } from 'react'; +import { Toolbar } from './toolbar/Toolbar'; +import { LeftPanel } from './panels/LeftPanel'; +import { Canvas } from './canvas/Canvas'; +import { Inspector } from './inspector/Inspector'; +import { LayerPanel } from './layers/LayerPanel'; +import { HistoryPanel } from './panels/HistoryPanel'; +import { GuidePanel } from './panels/GuidePanel'; +import { PagesBar } from './pages/PagesBar'; +import { useUIStore } from '../../stores/ui-store'; +import { useProjectStore } from '../../stores/project-store'; +import { Layers, History, Ruler } from 'lucide-react'; + +const ExportDialog = lazy(() => import('./ExportDialog').then(m => ({ default: m.ExportDialog }))); + +type BottomTab = 'layers' | 'history' | 'guides'; + +export function EditorInterface() { + const { isPanelCollapsed, isInspectorCollapsed, isExportDialogOpen, closeExportDialog } = useUIStore(); + const { project } = useProjectStore(); + const [bottomTab, setBottomTab] = useState('layers'); + + if (!project) { + return ( +
+

No project loaded

+
+ ); + } + + return ( +
+ + +
+ {!isPanelCollapsed && ( +
+ +
+ )} + +
+
+ +
+ +
+ + {!isInspectorCollapsed && ( +
+
+ +
+
+
+ + + +
+
+ {bottomTab === 'layers' && } + {bottomTab === 'guides' && } + {bottomTab === 'history' && } +
+
+
+ )} +
+ + {isExportDialogOpen && ( + + + + )} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/ExportDialog.tsx b/services/editor/apps/image/src/components/editor/ExportDialog.tsx new file mode 100644 index 0000000..50f36d2 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/ExportDialog.tsx @@ -0,0 +1,626 @@ +import { useState, useMemo, useEffect } from 'react'; +import { Download, FileImage, Loader2, Link2, Link2Off, Printer, Instagram, Youtube, Twitter, Linkedin, Facebook, Image } from 'lucide-react'; +import { Dialog, DialogFooter } from '../ui/Dialog'; +import { useProjectStore } from '../../stores/project-store'; +import { useUIStore } from '../../stores/ui-store'; +import { + exportProject, + downloadBlob, + getExportFilename, + type ExportFormat, + type ExportQuality, + type ExportOptions, +} from '../../services/export-service'; + +interface ExportDialogProps { + open: boolean; + onClose: () => void; +} + +type FormatInfo = { + id: ExportFormat; + name: string; + description: string; + supportsTransparency: boolean; + supportsQuality: boolean; +}; + +const FORMATS: FormatInfo[] = [ + { id: 'png', name: 'PNG', description: 'Lossless, best for graphics', supportsTransparency: true, supportsQuality: false }, + { id: 'jpg', name: 'JPG', description: 'Smaller size, photos', supportsTransparency: false, supportsQuality: true }, + { id: 'webp', name: 'WebP', description: 'Modern, best compression', supportsTransparency: true, supportsQuality: true }, +]; + +const QUALITY_PRESETS: { id: ExportQuality; name: string; value: number }[] = [ + { id: 'low', name: 'Low', value: 60 }, + { id: 'medium', name: 'Medium', value: 80 }, + { id: 'high', name: 'High', value: 92 }, + { id: 'max', name: 'Maximum', value: 100 }, +]; + +const SCALE_OPTIONS = [ + { value: 0.5, label: '0.5x' }, + { value: 1, label: '1x' }, + { value: 2, label: '2x' }, + { value: 3, label: '3x' }, + { value: 4, label: '4x' }, +]; + +const DPI_OPTIONS = [ + { value: 72, label: '72 DPI', description: 'Screen' }, + { value: 150, label: '150 DPI', description: 'Web print' }, + { value: 300, label: '300 DPI', description: 'Print' }, + { value: 600, label: '600 DPI', description: 'High quality' }, +]; + +type PlatformPreset = { + id: string; + name: string; + icon: React.ElementType; + format: ExportFormat; + quality: ExportQuality; + maxFileSize?: string; + recommendedSize?: { width: number; height: number }; + description: string; +}; + +const PLATFORM_PRESETS: PlatformPreset[] = [ + { + id: 'instagram-post', + name: 'Instagram Post', + icon: Instagram, + format: 'jpg', + quality: 'high', + recommendedSize: { width: 1080, height: 1080 }, + description: 'Square post, max 30MB', + }, + { + id: 'instagram-story', + name: 'Instagram Story', + icon: Instagram, + format: 'jpg', + quality: 'high', + recommendedSize: { width: 1080, height: 1920 }, + description: '9:16 vertical', + }, + { + id: 'youtube-thumbnail', + name: 'YouTube Thumbnail', + icon: Youtube, + format: 'jpg', + quality: 'high', + maxFileSize: '2MB', + recommendedSize: { width: 1280, height: 720 }, + description: '16:9, under 2MB', + }, + { + id: 'twitter-post', + name: 'Twitter/X Post', + icon: Twitter, + format: 'png', + quality: 'high', + recommendedSize: { width: 1200, height: 675 }, + description: '16:9 landscape', + }, + { + id: 'facebook-post', + name: 'Facebook Post', + icon: Facebook, + format: 'jpg', + quality: 'high', + recommendedSize: { width: 1200, height: 630 }, + description: '1.91:1 ratio', + }, + { + id: 'linkedin-post', + name: 'LinkedIn Post', + icon: Linkedin, + format: 'png', + quality: 'high', + recommendedSize: { width: 1200, height: 627 }, + description: 'Professional feed', + }, + { + id: 'web-optimized', + name: 'Web Optimized', + icon: Image, + format: 'webp', + quality: 'medium', + description: 'Smallest file size', + }, + { + id: 'print-ready', + name: 'Print Ready', + icon: Printer, + format: 'png', + quality: 'max', + description: 'Highest quality PNG', + }, +]; + +type SizeMode = 'scale' | 'custom' | 'dpi'; + +export function ExportDialog({ open, onClose }: ExportDialogProps) { + const { project, selectedArtboardId } = useProjectStore(); + const { showNotification } = useUIStore(); + + const [format, setFormat] = useState('png'); + const [quality, setQuality] = useState('high'); + const [scale, setScale] = useState(1); + const [sizeMode, setSizeMode] = useState('scale'); + const [selectedPreset, setSelectedPreset] = useState(null); + const [customWidth, setCustomWidth] = useState(0); + const [customHeight, setCustomHeight] = useState(0); + const [dpi, setDpi] = useState(72); + const [lockAspectRatio, setLockAspectRatio] = useState(true); + const [background, setBackground] = useState<'include' | 'transparent'>('include'); + const [exportAll, setExportAll] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState(0); + const [progressMessage, setProgressMessage] = useState(''); + + const currentFormat = FORMATS.find((f) => f.id === format)!; + const artboard = project?.artboards.find((a) => a.id === selectedArtboardId); + + const effectiveScale = useMemo(() => { + if (!artboard) return 1; + if (sizeMode === 'scale') return scale; + if (sizeMode === 'custom' && customWidth > 0) { + return customWidth / artboard.size.width; + } + if (sizeMode === 'dpi') { + return dpi / 72; + } + return 1; + }, [artboard, sizeMode, scale, customWidth, dpi]); + + const dimensions = useMemo(() => { + if (!artboard) return null; + if (sizeMode === 'custom') { + return { width: customWidth || artboard.size.width, height: customHeight || artboard.size.height }; + } + return { + width: Math.round(artboard.size.width * effectiveScale), + height: Math.round(artboard.size.height * effectiveScale), + }; + }, [artboard, sizeMode, effectiveScale, customWidth, customHeight]); + + useEffect(() => { + if (artboard) { + setCustomWidth(artboard.size.width); + setCustomHeight(artboard.size.height); + } + }, [artboard?.id]); + + const handleCustomWidthChange = (newWidth: number) => { + setCustomWidth(newWidth); + if (lockAspectRatio && artboard && newWidth > 0) { + const aspectRatio = artboard.size.width / artboard.size.height; + setCustomHeight(Math.round(newWidth / aspectRatio)); + } + }; + + const handleCustomHeightChange = (newHeight: number) => { + setCustomHeight(newHeight); + if (lockAspectRatio && artboard && newHeight > 0) { + const aspectRatio = artboard.size.width / artboard.size.height; + setCustomWidth(Math.round(newHeight * aspectRatio)); + } + }; + + const handlePresetSelect = (preset: PlatformPreset) => { + setSelectedPreset(preset.id); + setFormat(preset.format); + setQuality(preset.quality); + + if (preset.recommendedSize && artboard) { + const artboardRatio = artboard.size.width / artboard.size.height; + const presetRatio = preset.recommendedSize.width / preset.recommendedSize.height; + const ratioMatch = Math.abs(artboardRatio - presetRatio) < 0.1; + + if (ratioMatch) { + const targetScale = preset.recommendedSize.width / artboard.size.width; + if (targetScale <= 4 && targetScale >= 0.5) { + setScale(targetScale); + setSizeMode('scale'); + } else { + setSizeMode('custom'); + setCustomWidth(preset.recommendedSize.width); + setCustomHeight(preset.recommendedSize.height); + setLockAspectRatio(false); + } + } + } + }; + + const clearPreset = () => { + setSelectedPreset(null); + }; + + const printDimensions = useMemo(() => { + if (!dimensions) return null; + const inches = { + width: (dimensions.width / dpi).toFixed(2), + height: (dimensions.height / dpi).toFixed(2), + }; + const cm = { + width: ((dimensions.width / dpi) * 2.54).toFixed(2), + height: ((dimensions.height / dpi) * 2.54).toFixed(2), + }; + return { inches, cm }; + }, [dimensions, dpi]); + + const estimatedSize = useMemo(() => { + if (!dimensions) return null; + const pixels = dimensions.width * dimensions.height; + const bytesPerPixel = format === 'png' ? 3 : format === 'jpg' ? 0.5 : 0.4; + const qualityMultiplier = QUALITY_PRESETS.find((q) => q.id === quality)?.value ?? 80; + const estimated = pixels * bytesPerPixel * (qualityMultiplier / 100); + + if (estimated > 1024 * 1024) { + return `~${(estimated / (1024 * 1024)).toFixed(1)} MB`; + } + return `~${Math.round(estimated / 1024)} KB`; + }, [dimensions, format, quality]); + + const handleExport = async () => { + if (!project) return; + + setIsExporting(true); + setProgress(0); + + try { + const options: ExportOptions = { + format, + quality, + scale: effectiveScale, + background: currentFormat.supportsTransparency ? background : 'include', + artboardIds: exportAll ? undefined : selectedArtboardId ? [selectedArtboardId] : undefined, + }; + + const blobs = await exportProject(project, options, (p, msg) => { + setProgress(p); + setProgressMessage(msg); + }); + + const artboards = exportAll + ? project.artboards + : project.artboards.filter((a) => a.id === selectedArtboardId); + + blobs.forEach((blob, index) => { + const artboardName = artboards[index]?.name ?? `artboard-${index + 1}`; + const filename = getExportFilename(project.name, artboardName, format); + downloadBlob(blob, filename); + }); + + showNotification('success', `Exported ${blobs.length} artboard${blobs.length > 1 ? 's' : ''}`); + onClose(); + } catch (error) { + showNotification('error', 'Export failed. Please try again.'); + } finally { + setIsExporting(false); + setProgress(0); + } + }; + + if (!project || !artboard) return null; + + return ( + +
+
+
+ + {selectedPreset && ( + + )} +
+
+ {PLATFORM_PRESETS.map((preset) => { + const Icon = preset.icon; + const isSelected = selectedPreset === preset.id; + return ( + + ); + })} +
+
+ +
+ +
+ {FORMATS.map((f) => ( + + ))} +
+
+ + {currentFormat.supportsQuality && ( +
+ +
+ {QUALITY_PRESETS.map((q) => ( + + ))} +
+
+ )} + +
+ +
+ + + +
+ + {sizeMode === 'scale' && ( +
+ {SCALE_OPTIONS.map((s) => ( + + ))} +
+ )} + + {sizeMode === 'custom' && ( +
+
+ + handleCustomWidthChange(Number(e.target.value))} + className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" + min={1} + max={16384} + /> +
+ +
+ + handleCustomHeightChange(Number(e.target.value))} + className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" + min={1} + max={16384} + /> +
+
+ )} + + {sizeMode === 'dpi' && ( +
+
+ {DPI_OPTIONS.map((d) => ( + + ))} +
+ {printDimensions && ( +
+

Print size at {dpi} DPI:

+

+ {printDimensions.inches.width}" × {printDimensions.inches.height}" ({printDimensions.cm.width} × {printDimensions.cm.height} cm) +

+
+ )} +
+ )} +
+ + {currentFormat.supportsTransparency && ( +
+ +
+ + +
+
+ )} + + {project.artboards.length > 1 && ( +
+ +
+ )} + +
+
+ Dimensions + + {dimensions?.width} × {dimensions?.height} px + +
+
+ Estimated size + {estimatedSize} +
+
+ + {isExporting && ( +
+
+ {progressMessage} + {Math.round(progress)}% +
+
+
+
+
+ )} +
+ + + + + +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/KeyboardShortcutsPanel.tsx b/services/editor/apps/image/src/components/editor/KeyboardShortcutsPanel.tsx new file mode 100644 index 0000000..13230c1 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/KeyboardShortcutsPanel.tsx @@ -0,0 +1,140 @@ +import { X, Keyboard } from 'lucide-react'; + +interface ShortcutItem { + keys: string[]; + description: string; +} + +interface ShortcutGroup { + title: string; + shortcuts: ShortcutItem[]; +} + +const SHORTCUT_GROUPS: ShortcutGroup[] = [ + { + title: 'Tools', + shortcuts: [ + { keys: ['V'], description: 'Select tool' }, + { keys: ['H'], description: 'Hand/Pan tool' }, + { keys: ['T'], description: 'Text tool' }, + { keys: ['S'], description: 'Shape tool' }, + { keys: ['P'], description: 'Pen tool' }, + { keys: ['I'], description: 'Eyedropper' }, + { keys: ['Z'], description: 'Zoom tool' }, + ], + }, + { + title: 'Edit', + shortcuts: [ + { keys: ['⌘', 'Z'], description: 'Undo' }, + { keys: ['⌘', '⇧', 'Z'], description: 'Redo' }, + { keys: ['⌘', 'C'], description: 'Copy' }, + { keys: ['⌘', 'X'], description: 'Cut' }, + { keys: ['⌘', 'V'], description: 'Paste' }, + { keys: ['⌘', 'D'], description: 'Duplicate' }, + { keys: ['Delete'], description: 'Delete selected' }, + ], + }, + { + title: 'Selection', + shortcuts: [ + { keys: ['⌘', 'A'], description: 'Select all' }, + { keys: ['Esc'], description: 'Deselect all' }, + { keys: ['⌘', 'G'], description: 'Group layers' }, + { keys: ['⌘', '⇧', 'G'], description: 'Ungroup layers' }, + ], + }, + { + title: 'Layer Order', + shortcuts: [ + { keys: ['⌘', ']'], description: 'Bring forward' }, + { keys: ['⌘', '['], description: 'Send backward' }, + { keys: ['⌘', '⇧', ']'], description: 'Bring to front' }, + { keys: ['⌘', '⇧', '['], description: 'Send to back' }, + ], + }, + { + title: 'View', + shortcuts: [ + { keys: ['⌘', '+'], description: 'Zoom in' }, + { keys: ['⌘', '-'], description: 'Zoom out' }, + { keys: ['⌘', '0'], description: 'Zoom to fit' }, + { keys: ["⌘", "'"], description: 'Toggle grid' }, + { keys: ['⌘', ';'], description: 'Toggle guides' }, + ], + }, + { + title: 'Other', + shortcuts: [ + { keys: ['?'], description: 'Show shortcuts' }, + { keys: ['⌘', ','], description: 'Settings' }, + ], + }, +]; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +export function KeyboardShortcutsPanel({ isOpen, onClose }: Props) { + if (!isOpen) return null; + + return ( +
+
+
+
+
+ +

Keyboard Shortcuts

+
+ +
+ +
+
+ {SHORTCUT_GROUPS.map((group) => ( +
+

{group.title}

+
+ {group.shortcuts.map((shortcut, index) => ( +
+ + {shortcut.description} + +
+ {shortcut.keys.map((key, keyIndex) => ( + + {key} + + ))} +
+
+ ))} +
+
+ ))} +
+ +
+

+ Press ? to toggle this panel +

+
+
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/SettingsDialog.tsx b/services/editor/apps/image/src/components/editor/SettingsDialog.tsx new file mode 100644 index 0000000..e60e64b --- /dev/null +++ b/services/editor/apps/image/src/components/editor/SettingsDialog.tsx @@ -0,0 +1,217 @@ +import { useState } from 'react'; +import { X, Settings, Grid3X3, MousePointer, Save, Palette, Monitor } from 'lucide-react'; +import { useUIStore } from '../../stores/ui-store'; +import { Slider } from '@openreel/ui'; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +type SettingsTab = 'canvas' | 'snapping' | 'appearance'; + +export function SettingsDialog({ isOpen, onClose }: Props) { + const [activeTab, setActiveTab] = useState('canvas'); + + const { + showGrid, + showGuides, + showRulers, + snapToGrid, + snapToGuides, + snapToObjects, + gridSize, + toggleGrid, + toggleGuides, + toggleRulers, + toggleSnapToGrid, + toggleSnapToGuides, + toggleSnapToObjects, + setGridSize, + } = useUIStore(); + + if (!isOpen) return null; + + const tabs: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ + { id: 'canvas', label: 'Canvas', icon: }, + { id: 'snapping', label: 'Snapping', icon: }, + { id: 'appearance', label: 'Appearance', icon: }, + ]; + + return ( +
+
+
+
+
+ +

Settings

+
+ +
+ +
+
+ {tabs.map((tab) => ( + + ))} +
+ +
+ {activeTab === 'canvas' && ( +
+

Canvas Options

+ +
+ + + + + + +
+
+ + {gridSize}px +
+ setGridSize(value)} + min={5} + max={50} + step={5} + /> +
+
+
+ )} + + {activeTab === 'snapping' && ( +
+

Snap Options

+ +
+ + + + + +
+
+ )} + + {activeTab === 'appearance' && ( +
+

Appearance

+ +
+
+
+ +
+

Theme

+

Interface appearance

+
+
+
+ Dark (System) +
+
+ +
+
+ +
+

Auto Save

+

Automatically save projects

+
+
+

+ Projects are automatically saved to browser storage every 30 seconds. +

+
+
+
+ )} +
+
+
+
+ ); +} + +interface ToggleOptionProps { + label: string; + description: string; + checked: boolean; + onChange: () => void; +} + +function ToggleOption({ label, description, checked, onChange }: ToggleOptionProps) { + return ( +
+
+

{label}

+

{description}

+
+ +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/canvas/Canvas.tsx b/services/editor/apps/image/src/components/editor/canvas/Canvas.tsx new file mode 100644 index 0000000..5e3e9a8 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/canvas/Canvas.tsx @@ -0,0 +1,3139 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import { useUIStore } from '../../../stores/ui-store'; +import { useCanvasStore, type ResizeHandle } from '../../../stores/canvas-store'; +import { calculateSnap } from '../../../utils/snapping'; +import type { Layer, ImageLayer, TextLayer, ShapeLayer, GroupLayer } from '../../../types/project'; +import { Rulers } from './Rulers'; +import { ContextMenu, type ContextMenuPosition, type ContextMenuType } from './ContextMenu'; +import { hasActiveAdjustments, applyAllAdjustments, type LayerAdjustments } from '../../../utils/apply-adjustments'; +import { getToolCursor } from '../../../utils/cursors'; +import { floodFill, type FloodFillOptions } from '../../../utils/flood-fill'; +import { SmudgeTool } from '../../../tools/paint/smudge'; +import { BlurSharpenTool } from '../../../tools/paint/blur-sharpen'; +import { EraserTool } from '../../../tools/paint/eraser'; +import { BrushTool } from '../../../tools/paint/brush'; +import { DEFAULT_BRUSH_DYNAMICS } from '../../../tools/brush/brush-engine'; +import { DodgeBurnTool } from '../../../tools/retouch/dodge-burn'; +import { SpongeTool } from '../../../tools/retouch/sponge'; +import { CloneStampTool } from '../../../tools/retouch/clone-stamp'; +import { HealingBrushTool } from '../../../tools/retouch/healing-brush'; +import { SpotHealingTool } from '../../../tools/retouch/spot-healing'; + +const RULER_SIZE = 20; +const HANDLE_SIZE = 8; +const ROTATION_HANDLE_DISTANCE = 24; + +const MAX_IMAGE_CACHE_SIZE = 50; +const imageCache = new Map(); +const imageCacheOrder: string[] = []; +let renderCallback: (() => void) | null = null; + +const MAX_LAYER_CACHE_SIZE = 30; +interface LayerCacheEntry { + canvas: OffscreenCanvas; + hash: string; + width: number; + height: number; +} +const layerCache = new Map(); +const layerCacheOrder: string[] = []; + +function getLayerHash( + layer: Layer, + _assets: Record +): string { + const { transform } = layer; + const baseHash = `${layer.id}-${transform.width}-${transform.height}-${transform.opacity}-${transform.scaleX}-${transform.scaleY}-${transform.skewX ?? 0}-${transform.skewY ?? 0}-${layer.flipHorizontal ?? false}-${layer.flipVertical ?? false}-${layer.blendMode?.mode ?? 'normal'}`; + + const shadow = layer.shadow; + const shadowHash = shadow?.enabled ? `${shadow.color}-${shadow.blur}-${shadow.offsetX}-${shadow.offsetY}` : 'no-shadow'; + + const innerShadow = layer.innerShadow; + const innerShadowHash = innerShadow?.enabled ? `${innerShadow.color}-${innerShadow.blur}-${innerShadow.offsetX}-${innerShadow.offsetY}` : 'no-inner'; + + const glow = layer.glow; + const glowHash = glow?.enabled ? `${glow.color}-${glow.blur}-${glow.intensity ?? 1}` : 'no-glow'; + + let contentHash = ''; + if (layer.type === 'image') { + const imgLayer = layer as ImageLayer; + const { filters, cropRect } = imgLayer; + const cropHash = cropRect ? `${cropRect.x}-${cropRect.y}-${cropRect.width}-${cropRect.height}` : 'no-crop'; + contentHash = `img-${imgLayer.sourceId}-${cropHash}-${filters.brightness}-${filters.contrast}-${filters.saturation}-${filters.hue}-${filters.blur}-${filters.blurType}-${filters.sepia}-${filters.invert}-${filters.exposure}-${filters.highlights}-${filters.shadows}-${filters.clarity}-${filters.vibrance}`; + } else if (layer.type === 'text') { + const textLayer = layer as TextLayer; + const { style, content } = textLayer; + contentHash = `txt-${content}-${style.fontFamily}-${style.fontSize}-${style.fontWeight}-${style.fontStyle}-${style.color}-${style.textAlign}-${style.lineHeight}-${style.fillType ?? 'solid'}-${style.strokeColor ?? ''}-${style.strokeWidth ?? 0}-${JSON.stringify(style.gradient ?? {})}-${JSON.stringify(style.textShadow ?? {})}`; + } else if (layer.type === 'shape') { + const shapeLayer = layer as ShapeLayer; + const { shapeType, shapeStyle } = shapeLayer; + contentHash = `shp-${shapeType}-${shapeStyle.fill ?? ''}-${shapeStyle.stroke ?? ''}-${shapeStyle.strokeWidth}-${shapeStyle.fillOpacity}-${shapeStyle.strokeOpacity}-${shapeStyle.cornerRadius}-${shapeStyle.fillType ?? 'solid'}-${JSON.stringify(shapeStyle.gradient ?? {})}-${shapeLayer.sides ?? 0}-${shapeLayer.innerRadius ?? 0}`; + } + + return `${baseHash}|${shadowHash}|${innerShadowHash}|${glowHash}|${contentHash}`; +} + +function getCachedLayerCanvas( + layer: Layer, + project: { assets: Record } +): OffscreenCanvas | null { + if (typeof OffscreenCanvas === 'undefined') return null; + + const { width, height } = layer.transform; + if (width <= 0 || height <= 0) return null; + + const hash = getLayerHash(layer, project.assets); + const cached = layerCache.get(layer.id); + + if (cached && cached.hash === hash && cached.width === Math.ceil(width) && cached.height === Math.ceil(height)) { + const idx = layerCacheOrder.indexOf(layer.id); + if (idx > -1) { + layerCacheOrder.splice(idx, 1); + layerCacheOrder.push(layer.id); + } + return cached.canvas; + } + + return null; +} + +function setCachedLayerCanvas( + layerId: string, + canvas: OffscreenCanvas, + hash: string, + width: number, + height: number +): void { + if (layerCache.size >= MAX_LAYER_CACHE_SIZE && !layerCache.has(layerId)) { + const oldest = layerCacheOrder.shift(); + if (oldest) { + layerCache.delete(oldest); + } + } + + const existingIdx = layerCacheOrder.indexOf(layerId); + if (existingIdx > -1) { + layerCacheOrder.splice(existingIdx, 1); + } + layerCacheOrder.push(layerId); + + layerCache.set(layerId, { canvas, hash, width, height }); +} + +function clearLayerCache(layerIds?: Set): void { + if (!layerIds) { + layerCache.clear(); + layerCacheOrder.length = 0; + return; + } + + const toRemove: string[] = []; + layerCache.forEach((_, id) => { + if (!layerIds.has(id)) { + toRemove.push(id); + } + }); + + toRemove.forEach((id) => { + layerCache.delete(id); + const idx = layerCacheOrder.indexOf(id); + if (idx > -1) { + layerCacheOrder.splice(idx, 1); + } + }); +} + +const failedImages = new Set(); + +function getCachedImage(src: string): HTMLImageElement | null { + if (!src) return null; + if (failedImages.has(src)) return null; + + const cached = imageCache.get(src); + if (cached && cached.complete && cached.naturalWidth > 0) { + const idx = imageCacheOrder.indexOf(src); + if (idx > -1) { + imageCacheOrder.splice(idx, 1); + imageCacheOrder.push(src); + } + return cached; + } + + if (!cached) { + if (imageCache.size >= MAX_IMAGE_CACHE_SIZE) { + const oldest = imageCacheOrder.shift(); + if (oldest) { + const oldImg = imageCache.get(oldest); + if (oldImg?.src?.startsWith('blob:')) { + URL.revokeObjectURL(oldImg.src); + } + imageCache.delete(oldest); + } + } + + const img = new window.Image(); + imageCache.set(src, img); + imageCacheOrder.push(src); + + img.onload = () => { + if (renderCallback) { + renderCallback(); + } + }; + + img.onerror = () => { + failedImages.add(src); + imageCache.delete(src); + const idx = imageCacheOrder.indexOf(src); + if (idx > -1) { + imageCacheOrder.splice(idx, 1); + } + console.warn(`Failed to load image: ${src.substring(0, 100)}`); + }; + + img.src = src; + } + + const img = imageCache.get(src); + if (img && img.complete && img.naturalWidth > 0) { + return img; + } + + return null; +} + +interface ViewportBounds { + left: number; + top: number; + right: number; + bottom: number; +} + +function getViewportBounds( + canvasWidth: number, + canvasHeight: number, + artboardWidth: number, + artboardHeight: number, + zoom: number, + panX: number, + panY: number +): ViewportBounds { + const centerX = canvasWidth / 2 + panX; + const centerY = canvasHeight / 2 + panY; + const artboardX = centerX - (artboardWidth * zoom) / 2; + const artboardY = centerY - (artboardHeight * zoom) / 2; + + return { + left: -artboardX / zoom, + top: -artboardY / zoom, + right: (canvasWidth - artboardX) / zoom, + bottom: (canvasHeight - artboardY) / zoom, + }; +} + +function isLayerInViewport(layer: Layer, viewport: ViewportBounds): boolean { + const { x, y, width, height } = layer.transform; + return !( + x + width < viewport.left || + x > viewport.right || + y + height < viewport.top || + y > viewport.bottom + ); +} + +export function Canvas() { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const renderScheduledRef = useRef(false); + const lastRenderHashRef = useRef(''); + const forceRenderRef = useRef(false); + + const { + project, + selectedLayerIds, + selectedArtboardId, + updateLayerTransform, + updateLayer, + selectLayer, + selectLayers, + deselectAllLayers, + addPathLayer, + addTextLayer, + addShapeLayer, + removeLayer, + duplicateLayer, + copyLayers, + cutLayers, + pasteLayers, + copiedLayers, + copyLayerStyle, + pasteLayerStyle, + copiedStyle, + moveLayerToTop, + moveLayerUp, + moveLayerDown, + moveLayerToBottom, + groupLayers, + ungroupLayers, + } = useProjectStore(); + const { zoom, panX, panY, setPan, setZoom, activeTool, showGrid, showRulers, toggleGrid, toggleRulers, gridSize, crop, snapToObjects, snapToGuides, snapToGrid, penSettings, brushSettings, eraserSettings, drawing, startDrawing, addDrawingPoint, finishDrawing, startCrop, updateCropRect, setBrushSettings, gradientSettings, paintBucketSettings, smudgeSettings, blurSharpenSettings, dodgeBurnSettings, spongeSettings, cloneStampSettings, healingBrushSettings, spotHealingSettings } = useUIStore(); + const { setCanvasRef, setContainerRef, startDrag, updateDrag, endDrag, isDragging, dragMode, dragStartX, dragStartY, dragCurrentX, dragCurrentY, guides, smartGuides, setSmartGuides, clearSmartGuides, isMarqueeSelecting, marqueeRect, startMarqueeSelect, updateMarqueeSelect, endMarqueeSelect, activeResizeHandle, setActiveResizeHandle } = useCanvasStore(); + const [cursorStyle, setCursorStyle] = useState('default'); + const initialTransformRef = useRef<{ x: number; y: number; width: number; height: number; rotation: number } | null>(null); + + const [contextMenu, setContextMenu] = useState<{ + position: ContextMenuPosition; + type: ContextMenuType; + } | null>(null); + + const [gradientDrag, setGradientDrag] = useState<{ + startX: number; + startY: number; + endX: number; + endY: number; + layerId: string; + } | null>(null); + + const smudgeToolRef = useRef(null); + const blurSharpenToolRef = useRef(null); + const eraserToolRef = useRef(null); + const brushToolRef = useRef(null); + const dodgeBurnToolRef = useRef(null); + const spongeToolRef = useRef(null); + const cloneStampToolRef = useRef(null); + const healingBrushToolRef = useRef(null); + const spotHealingToolRef = useRef(null); + const paintCanvasRef = useRef(null); + const paintLayerIdRef = useRef(null); + + const artboard = project?.artboards.find((a) => a.id === selectedArtboardId); + + useEffect(() => { + if (canvasRef.current) { + setCanvasRef(canvasRef.current); + } + if (containerRef.current) { + setContainerRef(containerRef.current); + } + }, [setCanvasRef, setContainerRef]); + + const render = useCallback(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext('2d'); + if (!canvas || !ctx || !artboard || !project) return; + + const container = containerRef.current; + if (container) { + canvas.width = container.clientWidth; + canvas.height = container.clientHeight; + } + + const marqueeHash = marqueeRect ? `${marqueeRect.x}-${marqueeRect.y}-${marqueeRect.width}-${marqueeRect.height}` : 'none'; + const gradientHash = gradientDrag ? `${gradientDrag.startX}-${gradientDrag.startY}-${gradientDrag.endX}-${gradientDrag.endY}` : 'none'; + const renderHash = `${zoom}-${panX}-${panY}-${selectedLayerIds.join(',')}-${project.updatedAt}-${showGrid}-${drawing.isDrawing}-${drawing.currentPath?.length ?? 0}-${isMarqueeSelecting}-${marqueeHash}-${gradientHash}`; + if (renderHash === lastRenderHashRef.current && !forceRenderRef.current) { + return; + } + lastRenderHashRef.current = renderHash; + forceRenderRef.current = false; + + ctx.save(); + ctx.fillStyle = '#18181b'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const centerX = canvas.width / 2 + panX; + const centerY = canvas.height / 2 + panY; + const artboardX = centerX - (artboard.size.width * zoom) / 2; + const artboardY = centerY - (artboard.size.height * zoom) / 2; + + ctx.save(); + ctx.translate(artboardX, artboardY); + ctx.scale(zoom, zoom); + + if (artboard.background.type === 'color') { + ctx.fillStyle = artboard.background.color ?? '#ffffff'; + } else if (artboard.background.type === 'transparent') { + const patternSize = 10; + for (let y = 0; y < artboard.size.height; y += patternSize) { + for (let x = 0; x < artboard.size.width; x += patternSize) { + ctx.fillStyle = (x + y) % (patternSize * 2) === 0 ? '#ffffff' : '#e5e5e5'; + ctx.fillRect(x, y, patternSize, patternSize); + } + } + } else { + ctx.fillStyle = '#ffffff'; + } + if (artboard.background.type !== 'transparent') { + ctx.fillRect(0, 0, artboard.size.width, artboard.size.height); + } + + if (showGrid) { + ctx.strokeStyle = 'rgba(128, 128, 128, 0.3)'; + ctx.lineWidth = 1 / zoom; + for (let x = 0; x <= artboard.size.width; x += gridSize) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, artboard.size.height); + ctx.stroke(); + } + for (let y = 0; y <= artboard.size.height; y += gridSize) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(artboard.size.width, y); + ctx.stroke(); + } + } + + const viewport = getViewportBounds( + canvas.width, + canvas.height, + artboard.size.width, + artboard.size.height, + zoom, + panX, + panY + ); + + const sortedLayerIds = [...artboard.layerIds].reverse(); + sortedLayerIds.forEach((layerId) => { + const layer = project.layers[layerId]; + if (!layer || !layer.visible) return; + if (!isLayerInViewport(layer, viewport)) return; + renderLayerWithChildren(ctx, layer, project); + }); + + ctx.restore(); + + selectedLayerIds.forEach((layerId) => { + const layer = project.layers[layerId]; + if (!layer) return; + const { x, y, width, height, rotation } = layer.transform; + + ctx.save(); + ctx.translate(artboardX + (x + width / 2) * zoom, artboardY + (y + height / 2) * zoom); + ctx.rotate((rotation * Math.PI) / 180); + ctx.translate(-(width / 2) * zoom, -(height / 2) * zoom); + + ctx.strokeStyle = '#22c55e'; + ctx.lineWidth = 2; + ctx.setLineDash([]); + ctx.strokeRect(0, 0, width * zoom, height * zoom); + + const handleSize = HANDLE_SIZE; + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#22c55e'; + ctx.lineWidth = 2; + + const handles = [ + { x: 0, y: 0 }, + { x: width * zoom / 2, y: 0 }, + { x: width * zoom, y: 0 }, + { x: width * zoom, y: height * zoom / 2 }, + { x: width * zoom, y: height * zoom }, + { x: width * zoom / 2, y: height * zoom }, + { x: 0, y: height * zoom }, + { x: 0, y: height * zoom / 2 }, + ]; + + handles.forEach((h) => { + ctx.fillRect(h.x - handleSize / 2, h.y - handleSize / 2, handleSize, handleSize); + ctx.strokeRect(h.x - handleSize / 2, h.y - handleSize / 2, handleSize, handleSize); + }); + + ctx.beginPath(); + ctx.moveTo(width * zoom / 2, 0); + ctx.lineTo(width * zoom / 2, -ROTATION_HANDLE_DISTANCE); + ctx.strokeStyle = '#22c55e'; + ctx.lineWidth = 2; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(width * zoom / 2, -ROTATION_HANDLE_DISTANCE, 6, 0, Math.PI * 2); + ctx.fillStyle = '#ffffff'; + ctx.fill(); + ctx.strokeStyle = '#22c55e'; + ctx.lineWidth = 2; + ctx.stroke(); + + ctx.restore(); + }); + + if (drawing.isDrawing && drawing.currentPath.length > 1) { + ctx.save(); + if (activeTool === 'brush') { + ctx.strokeStyle = brushSettings.color; + ctx.lineWidth = brushSettings.size; + ctx.globalAlpha = brushSettings.opacity; + } else { + ctx.strokeStyle = penSettings.color; + ctx.lineWidth = penSettings.width; + ctx.globalAlpha = penSettings.opacity; + } + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + ctx.beginPath(); + ctx.moveTo( + artboardX + drawing.currentPath[0].x * zoom, + artboardY + drawing.currentPath[0].y * zoom + ); + + for (let i = 1; i < drawing.currentPath.length; i++) { + ctx.lineTo( + artboardX + drawing.currentPath[i].x * zoom, + artboardY + drawing.currentPath[i].y * zoom + ); + } + + ctx.stroke(); + ctx.restore(); + } + + if (gradientDrag && activeTool === 'gradient') { + ctx.save(); + + const gStartX = artboardX + gradientDrag.startX * zoom; + const gStartY = artboardY + gradientDrag.startY * zoom; + const gEndX = artboardX + gradientDrag.endX * zoom; + const gEndY = artboardY + gradientDrag.endY * zoom; + + const colors = gradientSettings.reverse + ? [...gradientSettings.colors].reverse() + : gradientSettings.colors; + + const minX = Math.min(gStartX, gEndX); + const minY = Math.min(gStartY, gEndY); + const maxX = Math.max(gStartX, gEndX); + const maxY = Math.max(gStartY, gEndY); + const previewWidth = Math.max(maxX - minX, 50); + const previewHeight = Math.max(maxY - minY, 50); + + let gradient: CanvasGradient; + if (gradientSettings.type === 'radial') { + const centerX = (gStartX + gEndX) / 2; + const centerY = (gStartY + gEndY) / 2; + const radius = Math.sqrt(previewWidth ** 2 + previewHeight ** 2) / 2; + gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius); + } else { + gradient = ctx.createLinearGradient(gStartX, gStartY, gEndX, gEndY); + } + + colors.forEach((color, i) => { + gradient.addColorStop(i / Math.max(colors.length - 1, 1), color); + }); + + ctx.fillStyle = gradient; + ctx.globalAlpha = gradientSettings.opacity; + ctx.fillRect(minX, minY, previewWidth, previewHeight); + + ctx.setLineDash([4, 4]); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(gStartX, gStartY); + ctx.lineTo(gEndX, gEndY); + ctx.stroke(); + + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(gStartX, gStartY, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(gEndX, gEndY, 4, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } + + if (smartGuides.length > 0) { + ctx.save(); + ctx.strokeStyle = '#f43f5e'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + + smartGuides.forEach((guide) => { + ctx.beginPath(); + if (guide.type === 'vertical') { + ctx.moveTo(artboardX + guide.position * zoom, artboardY + guide.start * zoom); + ctx.lineTo(artboardX + guide.position * zoom, artboardY + guide.end * zoom); + } else { + ctx.moveTo(artboardX + guide.start * zoom, artboardY + guide.position * zoom); + ctx.lineTo(artboardX + guide.end * zoom, artboardY + guide.position * zoom); + } + ctx.stroke(); + }); + + ctx.restore(); + } + + if (isMarqueeSelecting && marqueeRect) { + ctx.save(); + ctx.fillStyle = 'rgba(34, 197, 94, 0.1)'; + ctx.strokeStyle = '#22c55e'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + + const mx = artboardX + marqueeRect.x * zoom; + const my = artboardY + marqueeRect.y * zoom; + const mw = marqueeRect.width * zoom; + const mh = marqueeRect.height * zoom; + + ctx.fillRect(mx, my, mw, mh); + ctx.strokeRect(mx, my, mw, mh); + ctx.restore(); + } + + if (crop.isActive && crop.layerId && crop.cropRect) { + const cropLayer = project.layers[crop.layerId]; + if (cropLayer) { + const { x: layerX, y: layerY, width: layerW, height: layerH } = cropLayer.transform; + const { x: cropX, y: cropY, width: cropW, height: cropH } = crop.cropRect; + + ctx.save(); + + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.fillRect( + artboardX + layerX * zoom, + artboardY + layerY * zoom, + layerW * zoom, + cropY * zoom + ); + ctx.fillRect( + artboardX + layerX * zoom, + artboardY + (layerY + cropY + cropH) * zoom, + layerW * zoom, + (layerH - cropY - cropH) * zoom + ); + ctx.fillRect( + artboardX + layerX * zoom, + artboardY + (layerY + cropY) * zoom, + cropX * zoom, + cropH * zoom + ); + ctx.fillRect( + artboardX + (layerX + cropX + cropW) * zoom, + artboardY + (layerY + cropY) * zoom, + (layerW - cropX - cropW) * zoom, + cropH * zoom + ); + + const cX = artboardX + (layerX + cropX) * zoom; + const cY = artboardY + (layerY + cropY) * zoom; + const cW = cropW * zoom; + const cH = cropH * zoom; + + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.strokeRect(cX, cY, cW, cH); + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)'; + ctx.lineWidth = 1; + for (let i = 1; i < 3; i++) { + ctx.beginPath(); + ctx.moveTo(cX + (cW * i) / 3, cY); + ctx.lineTo(cX + (cW * i) / 3, cY + cH); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(cX, cY + (cH * i) / 3); + ctx.lineTo(cX + cW, cY + (cH * i) / 3); + ctx.stroke(); + } + + const handleSize = 10; + const handlePositions = [ + { x: cX, y: cY }, + { x: cX + cW / 2, y: cY }, + { x: cX + cW, y: cY }, + { x: cX + cW, y: cY + cH / 2 }, + { x: cX + cW, y: cY + cH }, + { x: cX + cW / 2, y: cY + cH }, + { x: cX, y: cY + cH }, + { x: cX, y: cY + cH / 2 }, + ]; + + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + handlePositions.forEach((h) => { + ctx.fillRect(h.x - handleSize / 2, h.y - handleSize / 2, handleSize, handleSize); + ctx.strokeRect(h.x - handleSize / 2, h.y - handleSize / 2, handleSize, handleSize); + }); + + ctx.restore(); + } + } + + ctx.restore(); + }, [artboard, project, zoom, panX, panY, selectedLayerIds, showGrid, gridSize, crop, smartGuides, drawing, penSettings, brushSettings, activeTool, isMarqueeSelecting, marqueeRect]); + + const scheduleRender = useCallback(() => { + if (renderScheduledRef.current) return; + renderScheduledRef.current = true; + + requestAnimationFrame(() => { + render(); + renderScheduledRef.current = false; + }); + }, [render]); + + const forceRender = useCallback(() => { + forceRenderRef.current = true; + scheduleRender(); + }, [scheduleRender]); + + useEffect(() => { + scheduleRender(); + }, [scheduleRender]); + + useEffect(() => { + renderCallback = forceRender; + return () => { + renderCallback = null; + }; + }, [forceRender]); + + useEffect(() => { + const handleResize = () => { + forceRender(); + if (containerRef.current) { + setContainerSize({ + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight, + }); + } + }; + window.addEventListener('resize', handleResize); + handleResize(); + return () => window.removeEventListener('resize', handleResize); + }, [forceRender]); + + useEffect(() => { + if (!project) return; + const currentLayerIds = new Set(Object.keys(project.layers)); + clearLayerCache(currentLayerIds); + }, [project?.layers]); + + const screenToCanvas = useCallback( + (screenX: number, screenY: number) => { + const canvas = canvasRef.current; + if (!canvas || !artboard) return { x: 0, y: 0 }; + + const rect = canvas.getBoundingClientRect(); + const canvasX = screenX - rect.left; + const canvasY = screenY - rect.top; + + const centerX = canvas.width / 2 + panX; + const centerY = canvas.height / 2 + panY; + const artboardX = centerX - (artboard.size.width * zoom) / 2; + const artboardY = centerY - (artboard.size.height * zoom) / 2; + + return { + x: (canvasX - artboardX) / zoom, + y: (canvasY - artboardY) / zoom, + }; + }, + [artboard, zoom, panX, panY] + ); + + const findLayerAtPoint = useCallback( + (x: number, y: number): string | null => { + if (!artboard || !project) return null; + + for (const layerId of artboard.layerIds) { + const layer = project.layers[layerId]; + if (!layer || !layer.visible || layer.locked) continue; + + const { transform } = layer; + if ( + x >= transform.x && + x <= transform.x + transform.width && + y >= transform.y && + y <= transform.y + transform.height + ) { + return layerId; + } + } + return null; + }, + [artboard, project] + ); + + const getHandleAtPoint = useCallback( + (canvasX: number, canvasY: number): { handle: ResizeHandle | 'rotate'; layerId: string } | null => { + if (!artboard || !project || selectedLayerIds.length !== 1) return null; + + const canvas = canvasRef.current; + if (!canvas) return null; + + const layerId = selectedLayerIds[0]; + const layer = project.layers[layerId]; + if (!layer) return null; + + const { x, y, width, height, rotation } = layer.transform; + + const centerX = canvas.width / 2 + panX; + const centerY = canvas.height / 2 + panY; + const artboardX = centerX - (artboard.size.width * zoom) / 2; + const artboardY = centerY - (artboard.size.height * zoom) / 2; + + const layerCenterX = artboardX + (x + width / 2) * zoom; + const layerCenterY = artboardY + (y + height / 2) * zoom; + + const rad = -(rotation * Math.PI) / 180; + const dx = canvasX - layerCenterX; + const dy = canvasY - layerCenterY; + const localX = dx * Math.cos(rad) - dy * Math.sin(rad); + const localY = dx * Math.sin(rad) + dy * Math.cos(rad); + + const halfW = (width * zoom) / 2; + const halfH = (height * zoom) / 2; + const threshold = HANDLE_SIZE + 4; + + const rotHandleY = -halfH - ROTATION_HANDLE_DISTANCE; + if (Math.abs(localX) < threshold && Math.abs(localY - rotHandleY) < threshold) { + return { handle: 'rotate', layerId }; + } + + const handlePositions: { handle: ResizeHandle; x: number; y: number }[] = [ + { handle: 'nw', x: -halfW, y: -halfH }, + { handle: 'n', x: 0, y: -halfH }, + { handle: 'ne', x: halfW, y: -halfH }, + { handle: 'e', x: halfW, y: 0 }, + { handle: 'se', x: halfW, y: halfH }, + { handle: 's', x: 0, y: halfH }, + { handle: 'sw', x: -halfW, y: halfH }, + { handle: 'w', x: -halfW, y: 0 }, + ]; + + for (const pos of handlePositions) { + if (Math.abs(localX - pos.x) < threshold && Math.abs(localY - pos.y) < threshold) { + return { handle: pos.handle, layerId }; + } + } + + return null; + }, + [artboard, project, selectedLayerIds, zoom, panX, panY] + ); + + const getCursorForHandle = (handle: ResizeHandle | 'rotate' | null): string => { + if (!handle) return 'default'; + if (handle === 'rotate') return 'crosshair'; + + const cursors: Record = { + 'nw': 'nwse-resize', + 'n': 'ns-resize', + 'ne': 'nesw-resize', + 'e': 'ew-resize', + 'se': 'nwse-resize', + 's': 'ns-resize', + 'sw': 'nesw-resize', + 'w': 'ew-resize', + }; + + return cursors[handle] || 'default'; + }; + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const canvasX = e.clientX - rect.left; + const canvasY = e.clientY - rect.top; + const { x, y } = screenToCanvas(e.clientX, e.clientY); + + if (activeTool === 'hand' || e.button === 1) { + startDrag('pan', e.clientX, e.clientY); + return; + } + + if (activeTool === 'pen') { + startDrawing({ x, y }); + return; + } + + if (activeTool === 'marquee-rect' || activeTool === 'marquee-ellipse') { + deselectAllLayers(); + startMarqueeSelect(x, y); + startDrag('marquee', e.clientX, e.clientY); + return; + } + + if (activeTool === 'lasso' || activeTool === 'lasso-polygon' || activeTool === 'magic-wand') { + const layerId = findLayerAtPoint(x, y); + if (layerId) { + selectLayer(layerId); + } else { + deselectAllLayers(); + } + return; + } + + if (activeTool === 'free-transform' || activeTool === 'warp' || activeTool === 'perspective' || activeTool === 'liquify') { + const handleHit = getHandleAtPoint(canvasX, canvasY); + if (handleHit) { + const layer = project?.layers[handleHit.layerId]; + if (layer) { + initialTransformRef.current = { + x: layer.transform.x, + y: layer.transform.y, + width: layer.transform.width, + height: layer.transform.height, + rotation: layer.transform.rotation, + }; + if (handleHit.handle === 'rotate') { + startDrag('rotate', e.clientX, e.clientY); + } else { + setActiveResizeHandle(handleHit.handle); + startDrag('resize', e.clientX, e.clientY); + } + return; + } + } + + const layerId = findLayerAtPoint(x, y); + if (layerId) { + if (!selectedLayerIds.includes(layerId)) { + selectLayer(layerId); + } + startDrag('move', e.clientX, e.clientY); + } + return; + } + + if (activeTool === 'brush') { + const layerId = findLayerAtPoint(x, y); + if (layerId && project) { + const layer = project.layers[layerId]; + if (layer?.type === 'image') { + const imageLayer = layer as ImageLayer; + const asset = project.assets[imageLayer.sourceId]; + const src = asset?.blobUrl ?? asset?.dataUrl; + if (src) { + const img = getCachedImage(src); + if (img && img.complete && img.naturalWidth > 0) { + const tempCanvas = new OffscreenCanvas(img.naturalWidth, img.naturalHeight); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + if (tempCtx) { + tempCtx.drawImage(img, 0, 0); + paintCanvasRef.current = tempCanvas; + paintLayerIdRef.current = layerId; + + const localX = (x - layer.transform.x) * (img.naturalWidth / layer.transform.width); + const localY = (y - layer.transform.y) * (img.naturalHeight / layer.transform.height); + + const tool = new BrushTool({ + size: brushSettings.size * (img.naturalWidth / layer.transform.width), + hardness: brushSettings.hardness, + opacity: brushSettings.opacity, + flow: brushSettings.flow, + color: brushSettings.color, + blendMode: brushSettings.blendMode, + }); + tool.setCanvas(tempCanvas); + tool.startStroke(localX, localY, 1); + brushToolRef.current = tool; + + if (!selectedLayerIds.includes(layerId)) { + selectLayer(layerId); + } + startDrag('paint', e.clientX, e.clientY); + } + } + } + } + } else { + startDrawing({ x, y }); + } + return; + } + + if (activeTool === 'eraser') { + const layerId = findLayerAtPoint(x, y); + if (layerId && project) { + const layer = project.layers[layerId]; + if (layer?.type === 'image') { + const imageLayer = layer as ImageLayer; + const asset = project.assets[imageLayer.sourceId]; + const src = asset?.blobUrl ?? asset?.dataUrl; + if (src) { + const img = getCachedImage(src); + if (img && img.complete && img.naturalWidth > 0) { + const tempCanvas = new OffscreenCanvas(img.naturalWidth, img.naturalHeight); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + if (tempCtx) { + tempCtx.drawImage(img, 0, 0); + paintCanvasRef.current = tempCanvas; + paintLayerIdRef.current = layerId; + + const localX = (x - layer.transform.x) * (img.naturalWidth / layer.transform.width); + const localY = (y - layer.transform.y) * (img.naturalHeight / layer.transform.height); + + const tool = new EraserTool({ + size: eraserSettings.size * (img.naturalWidth / layer.transform.width), + hardness: eraserSettings.hardness, + opacity: eraserSettings.opacity, + flow: eraserSettings.flow, + mode: eraserSettings.mode, + spacing: 25, + sizeDynamics: { ...DEFAULT_BRUSH_DYNAMICS }, + opacityDynamics: { ...DEFAULT_BRUSH_DYNAMICS }, + flowDynamics: { ...DEFAULT_BRUSH_DYNAMICS }, + }); + tool.startErase(localX, localY, 1); + eraserToolRef.current = tool; + + if (!selectedLayerIds.includes(layerId)) { + selectLayer(layerId); + } + startDrag('paint', e.clientX, e.clientY); + } + } + } + } + } + return; + } + + if (activeTool === 'gradient') { + setGradientDrag({ + startX: x, + startY: y, + endX: x, + endY: y, + layerId: selectedLayerIds[0] ?? '', + }); + startDrag('paint', e.clientX, e.clientY); + return; + } + + if (activeTool === 'paint-bucket') { + const layerId = findLayerAtPoint(x, y); + if (layerId && project) { + const layer = project.layers[layerId]; + if (layer?.type === 'image') { + const imageLayer = layer as ImageLayer; + const asset = project.assets[imageLayer.sourceId]; + const src = asset?.blobUrl ?? asset?.dataUrl; + if (src) { + const img = getCachedImage(src); + if (img && img.complete && img.naturalWidth > 0) { + const localX = x - layer.transform.x; + const localY = y - layer.transform.y; + const scaleX = img.naturalWidth / layer.transform.width; + const scaleY = img.naturalHeight / layer.transform.height; + const imgX = Math.floor(localX * scaleX); + const imgY = Math.floor(localY * scaleY); + + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = img.naturalWidth; + tempCanvas.height = img.naturalHeight; + const tempCtx = tempCanvas.getContext('2d'); + if (tempCtx) { + tempCtx.drawImage(img, 0, 0); + const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); + + const fillOptions: FloodFillOptions = { + tolerance: paintBucketSettings.tolerance, + contiguous: paintBucketSettings.contiguous, + antiAlias: paintBucketSettings.antiAlias, + opacity: paintBucketSettings.opacity, + }; + + const filledData = floodFill(imageData, imgX, imgY, paintBucketSettings.color, fillOptions); + tempCtx.putImageData(filledData, 0, 0); + + const oldBlobUrl = asset?.blobUrl; + tempCanvas.toBlob((blob) => { + if (blob) { + if (oldBlobUrl?.startsWith('blob:')) { + URL.revokeObjectURL(oldBlobUrl); + } + const newBlobUrl = URL.createObjectURL(blob); + const newAssetId = `asset-${Date.now()}`; + useProjectStore.getState().addAsset({ + id: newAssetId, + name: `filled-${imageLayer.name || 'image'}`, + type: 'image', + mimeType: 'image/png', + size: blob.size, + width: tempCanvas.width, + height: tempCanvas.height, + thumbnailUrl: newBlobUrl, + blobUrl: newBlobUrl, + }); + useProjectStore.getState().updateLayer(layerId, { sourceId: newAssetId }); + forceRender(); + } + }, 'image/png'); + } + } + } + } else if (layer?.type === 'shape') { + updateLayer(layerId, { + shapeStyle: { + ...(layer as ShapeLayer).shapeStyle, + fill: paintBucketSettings.color, + fillOpacity: paintBucketSettings.opacity, + }, + } as Partial); + } + } + return; + } + + if (activeTool === 'smudge' || activeTool === 'blur' || activeTool === 'sharpen') { + const layerId = findLayerAtPoint(x, y); + if (layerId && project) { + const layer = project.layers[layerId]; + if (layer?.type === 'image') { + const imageLayer = layer as ImageLayer; + const asset = project.assets[imageLayer.sourceId]; + const src = asset?.blobUrl ?? asset?.dataUrl; + if (src) { + const img = getCachedImage(src); + if (img && img.complete && img.naturalWidth > 0) { + const tempCanvas = new OffscreenCanvas(img.naturalWidth, img.naturalHeight); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + if (tempCtx) { + tempCtx.drawImage(img, 0, 0); + paintCanvasRef.current = tempCanvas; + paintLayerIdRef.current = layerId; + + const localX = (x - layer.transform.x) * (img.naturalWidth / layer.transform.width); + const localY = (y - layer.transform.y) * (img.naturalHeight / layer.transform.height); + + if (activeTool === 'smudge') { + const tool = new SmudgeTool({ + size: smudgeSettings.size * (img.naturalWidth / layer.transform.width), + hardness: 50, + strength: smudgeSettings.strength, + fingerPainting: smudgeSettings.fingerPainting, + sampleAllLayers: smudgeSettings.sampleAllLayers, + fingerColor: brushSettings.color, + }); + tool.setCanvas(tempCanvas); + tool.startStroke(localX, localY, 1); + smudgeToolRef.current = tool; + blurSharpenToolRef.current = null; + } else { + const tool = new BlurSharpenTool({ + size: blurSharpenSettings.size * (img.naturalWidth / layer.transform.width), + hardness: 50, + strength: blurSharpenSettings.strength, + mode: activeTool === 'blur' ? 'blur' : 'sharpen', + sampleAllLayers: blurSharpenSettings.sampleAllLayers, + }); + tool.setCanvas(tempCanvas); + tool.startStroke(localX, localY, 1); + blurSharpenToolRef.current = tool; + smudgeToolRef.current = null; + } + + if (!selectedLayerIds.includes(layerId)) { + selectLayer(layerId); + } + startDrag('paint', e.clientX, e.clientY); + } + } + } + } + } + return; + } + + if (activeTool === 'dodge' || activeTool === 'burn' || activeTool === 'sponge' || activeTool === 'spot-healing') { + const layerId = findLayerAtPoint(x, y); + if (layerId && project) { + const layer = project.layers[layerId]; + if (layer?.type === 'image') { + const imageLayer = layer as ImageLayer; + const asset = project.assets[imageLayer.sourceId]; + const src = asset?.blobUrl ?? asset?.dataUrl; + if (src) { + const img = getCachedImage(src); + if (img && img.complete && img.naturalWidth > 0) { + const tempCanvas = new OffscreenCanvas(img.naturalWidth, img.naturalHeight); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + if (tempCtx) { + tempCtx.drawImage(img, 0, 0); + paintCanvasRef.current = tempCanvas; + paintLayerIdRef.current = layerId; + + const localX = (x - layer.transform.x) * (img.naturalWidth / layer.transform.width); + const localY = (y - layer.transform.y) * (img.naturalHeight / layer.transform.height); + + if (activeTool === 'dodge' || activeTool === 'burn') { + const tool = new DodgeBurnTool({ + size: dodgeBurnSettings.size * (img.naturalWidth / layer.transform.width), + type: activeTool, + range: dodgeBurnSettings.range, + exposure: dodgeBurnSettings.exposure, + }); + tool.setCanvas(tempCanvas); + tool.startStroke(localX, localY, 1); + tool.apply(tempCtx, localX, localY, 1); + dodgeBurnToolRef.current = tool; + } else if (activeTool === 'sponge') { + const tool = new SpongeTool({ + size: spongeSettings.size * (img.naturalWidth / layer.transform.width), + mode: spongeSettings.mode, + flow: spongeSettings.flow, + }); + tool.setCanvas(tempCanvas); + tool.startStroke(localX, localY, 1); + tool.apply(tempCtx, localX, localY, 1); + spongeToolRef.current = tool; + } else if (activeTool === 'spot-healing') { + const tool = new SpotHealingTool({ + size: spotHealingSettings.size * (img.naturalWidth / layer.transform.width), + type: spotHealingSettings.type, + sampleAllLayers: spotHealingSettings.sampleAllLayers, + }); + tool.setCanvas(tempCanvas); + tool.heal(tempCtx, localX, localY); + spotHealingToolRef.current = tool; + } + + if (!selectedLayerIds.includes(layerId)) { + selectLayer(layerId); + } + startDrag('paint', e.clientX, e.clientY); + } + } + } + } + } + return; + } + + if (activeTool === 'clone-stamp' || activeTool === 'healing-brush') { + const layerId = findLayerAtPoint(x, y); + if (layerId && project) { + const layer = project.layers[layerId]; + if (layer?.type === 'image') { + const imageLayer = layer as ImageLayer; + const asset = project.assets[imageLayer.sourceId]; + const src = asset?.blobUrl ?? asset?.dataUrl; + if (src) { + const img = getCachedImage(src); + if (img && img.complete && img.naturalWidth > 0) { + const tempCanvas = new OffscreenCanvas(img.naturalWidth, img.naturalHeight); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + if (tempCtx) { + tempCtx.drawImage(img, 0, 0); + paintCanvasRef.current = tempCanvas; + paintLayerIdRef.current = layerId; + + const localX = (x - layer.transform.x) * (img.naturalWidth / layer.transform.width); + const localY = (y - layer.transform.y) * (img.naturalHeight / layer.transform.height); + + if (e.altKey) { + if (activeTool === 'clone-stamp') { + if (!cloneStampToolRef.current) { + cloneStampToolRef.current = new CloneStampTool({ + size: cloneStampSettings.size * (img.naturalWidth / layer.transform.width), + hardness: cloneStampSettings.hardness, + opacity: cloneStampSettings.opacity, + flow: cloneStampSettings.flow, + aligned: cloneStampSettings.aligned, + }); + } + cloneStampToolRef.current.setSourceCanvas(tempCanvas); + cloneStampToolRef.current.setSource(localX, localY, layerId); + } else { + if (!healingBrushToolRef.current) { + healingBrushToolRef.current = new HealingBrushTool({ + size: healingBrushSettings.size * (img.naturalWidth / layer.transform.width), + hardness: healingBrushSettings.hardness, + aligned: healingBrushSettings.aligned, + }); + } + healingBrushToolRef.current.setCanvases(tempCanvas, tempCanvas); + healingBrushToolRef.current.setSource(localX, localY, layerId); + } + return; + } + + if (activeTool === 'clone-stamp' && cloneStampToolRef.current?.hasSource()) { + cloneStampToolRef.current.updateSettings({ + size: cloneStampSettings.size * (img.naturalWidth / layer.transform.width), + hardness: cloneStampSettings.hardness, + opacity: cloneStampSettings.opacity, + flow: cloneStampSettings.flow, + aligned: cloneStampSettings.aligned, + }); + cloneStampToolRef.current.setSourceCanvas(tempCanvas); + cloneStampToolRef.current.startClone(localX, localY); + cloneStampToolRef.current.clone(tempCtx, localX, localY); + } else if (activeTool === 'healing-brush' && healingBrushToolRef.current?.hasSource()) { + healingBrushToolRef.current.updateSettings({ + size: healingBrushSettings.size * (img.naturalWidth / layer.transform.width), + hardness: healingBrushSettings.hardness, + aligned: healingBrushSettings.aligned, + }); + healingBrushToolRef.current.setCanvases(tempCanvas, tempCanvas); + healingBrushToolRef.current.startHeal(localX, localY); + healingBrushToolRef.current.heal(tempCtx, localX, localY); + } else { + return; + } + + if (!selectedLayerIds.includes(layerId)) { + selectLayer(layerId); + } + startDrag('paint', e.clientX, e.clientY); + } + } + } + } + } + return; + } + + if (activeTool === 'zoom') { + if (e.shiftKey || e.altKey) { + setZoom(Math.max(0.1, zoom / 1.5)); + } else { + setZoom(Math.min(32, zoom * 1.5)); + } + return; + } + + if (activeTool === 'eyedropper') { + const canvas = canvasRef.current; + const ctx = canvas?.getContext('2d'); + if (ctx && canvas) { + const rect = canvas.getBoundingClientRect(); + const pixelX = Math.floor((e.clientX - rect.left) * (canvas.width / rect.width)); + const pixelY = Math.floor((e.clientY - rect.top) * (canvas.height / rect.height)); + const pixel = ctx.getImageData(pixelX, pixelY, 1, 1).data; + const hex = `#${pixel[0].toString(16).padStart(2, '0')}${pixel[1].toString(16).padStart(2, '0')}${pixel[2].toString(16).padStart(2, '0')}`; + setBrushSettings({ color: hex }); + } + return; + } + + if (activeTool === 'crop') { + const layerId = findLayerAtPoint(x, y); + if (layerId) { + const layer = project?.layers[layerId]; + if (layer) { + selectLayer(layerId); + startCrop(layerId, { + x: 0, + y: 0, + width: layer.transform.width, + height: layer.transform.height, + }); + startMarqueeSelect(x, y); + startDrag('crop', e.clientX, e.clientY); + } + } + return; + } + + if (activeTool === 'select') { + const handleHit = getHandleAtPoint(canvasX, canvasY); + if (handleHit) { + const layer = project?.layers[handleHit.layerId]; + if (layer) { + initialTransformRef.current = { + x: layer.transform.x, + y: layer.transform.y, + width: layer.transform.width, + height: layer.transform.height, + rotation: layer.transform.rotation, + }; + if (handleHit.handle === 'rotate') { + startDrag('rotate', e.clientX, e.clientY); + } else { + setActiveResizeHandle(handleHit.handle); + startDrag('resize', e.clientX, e.clientY); + } + return; + } + } + + const layerId = findLayerAtPoint(x, y); + if (layerId) { + if (e.shiftKey) { + selectLayer(layerId, true); + } else if (!selectedLayerIds.includes(layerId)) { + selectLayer(layerId); + } + startDrag('move', e.clientX, e.clientY); + } else { + deselectAllLayers(); + startMarqueeSelect(x, y); + startDrag('marquee', e.clientX, e.clientY); + } + } + }, + [activeTool, screenToCanvas, findLayerAtPoint, selectLayer, deselectAllLayers, startDrag, selectedLayerIds, startDrawing, startMarqueeSelect, getHandleAtPoint, project, setActiveResizeHandle, zoom, setZoom, startCrop, setBrushSettings, eraserSettings] + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current; + + if (drawing.isDrawing) { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + addDrawingPoint({ x, y }); + scheduleRender(); + return; + } + + if (gradientDrag && activeTool === 'gradient') { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + setGradientDrag({ ...gradientDrag, endX: x, endY: y }); + scheduleRender(); + return; + } + + if (isDragging && dragMode === 'paint' && paintLayerIdRef.current && paintCanvasRef.current) { + if (activeTool === 'smudge' && smudgeToolRef.current) { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + const layer = project?.layers[paintLayerIdRef.current]; + if (layer) { + const scale = paintCanvasRef.current.width / layer.transform.width; + const localX = (x - layer.transform.x) * scale; + const localY = (y - layer.transform.y) * scale; + smudgeToolRef.current.continueStroke(localX, localY, 1); + } + return; + } + + if (activeTool === 'eraser' && eraserToolRef.current) { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + const layer = project?.layers[paintLayerIdRef.current]; + if (layer && paintCanvasRef.current) { + const scale = paintCanvasRef.current.width / layer.transform.width; + const localX = (x - layer.transform.x) * scale; + const localY = (y - layer.transform.y) * scale; + eraserToolRef.current.continueErase(localX, localY, 1); + + const ctx = paintCanvasRef.current.getContext('2d', { willReadFrequently: true }); + if (ctx) { + const stroke = eraserToolRef.current.endErase(); + if (stroke) { + eraserToolRef.current.applyErase(ctx, stroke); + eraserToolRef.current.startErase(localX, localY, 1); + } + } + } + scheduleRender(); + return; + } + + if (activeTool === 'brush' && brushToolRef.current) { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + const layer = project?.layers[paintLayerIdRef.current]; + if (layer && paintCanvasRef.current) { + const scale = paintCanvasRef.current.width / layer.transform.width; + const localX = (x - layer.transform.x) * scale; + const localY = (y - layer.transform.y) * scale; + + const ctx = paintCanvasRef.current.getContext('2d', { willReadFrequently: true }); + if (ctx) { + brushToolRef.current.apply(ctx, localX, localY, 1); + } + } + scheduleRender(); + return; + } + + if ((activeTool === 'blur' || activeTool === 'sharpen') && blurSharpenToolRef.current) { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + const layer = project?.layers[paintLayerIdRef.current]; + if (layer && paintCanvasRef.current) { + const scale = paintCanvasRef.current.width / layer.transform.width; + const localX = (x - layer.transform.x) * scale; + const localY = (y - layer.transform.y) * scale; + blurSharpenToolRef.current.continueStroke(localX, localY, 1); + + const ctx = paintCanvasRef.current.getContext('2d', { willReadFrequently: true }); + if (ctx) { + blurSharpenToolRef.current.apply(ctx, localX, localY, 1); + } + } + return; + } + + if ((activeTool === 'dodge' || activeTool === 'burn') && dodgeBurnToolRef.current) { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + const layer = project?.layers[paintLayerIdRef.current]; + if (layer && paintCanvasRef.current) { + const scale = paintCanvasRef.current.width / layer.transform.width; + const localX = (x - layer.transform.x) * scale; + const localY = (y - layer.transform.y) * scale; + dodgeBurnToolRef.current.continueStroke(localX, localY, 1); + + const ctx = paintCanvasRef.current.getContext('2d', { willReadFrequently: true }); + if (ctx) { + dodgeBurnToolRef.current.apply(ctx, localX, localY, 1); + } + } + return; + } + + if (activeTool === 'sponge' && spongeToolRef.current) { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + const layer = project?.layers[paintLayerIdRef.current]; + if (layer && paintCanvasRef.current) { + const scale = paintCanvasRef.current.width / layer.transform.width; + const localX = (x - layer.transform.x) * scale; + const localY = (y - layer.transform.y) * scale; + spongeToolRef.current.continueStroke(localX, localY, 1); + + const ctx = paintCanvasRef.current.getContext('2d', { willReadFrequently: true }); + if (ctx) { + spongeToolRef.current.apply(ctx, localX, localY, 1); + } + } + return; + } + + if (activeTool === 'spot-healing' && spotHealingToolRef.current) { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + const layer = project?.layers[paintLayerIdRef.current]; + if (layer && paintCanvasRef.current) { + const scale = paintCanvasRef.current.width / layer.transform.width; + const localX = (x - layer.transform.x) * scale; + const localY = (y - layer.transform.y) * scale; + + const ctx = paintCanvasRef.current.getContext('2d', { willReadFrequently: true }); + if (ctx) { + spotHealingToolRef.current.heal(ctx, localX, localY); + } + } + return; + } + + if (activeTool === 'clone-stamp' && cloneStampToolRef.current?.hasSource()) { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + const layer = project?.layers[paintLayerIdRef.current]; + if (layer && paintCanvasRef.current) { + const scale = paintCanvasRef.current.width / layer.transform.width; + const localX = (x - layer.transform.x) * scale; + const localY = (y - layer.transform.y) * scale; + + const ctx = paintCanvasRef.current.getContext('2d', { willReadFrequently: true }); + if (ctx) { + cloneStampToolRef.current.clone(ctx, localX, localY); + } + } + return; + } + + if (activeTool === 'healing-brush' && healingBrushToolRef.current?.hasSource()) { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + const layer = project?.layers[paintLayerIdRef.current]; + if (layer && paintCanvasRef.current) { + const scale = paintCanvasRef.current.width / layer.transform.width; + const localX = (x - layer.transform.x) * scale; + const localY = (y - layer.transform.y) * scale; + + const ctx = paintCanvasRef.current.getContext('2d', { willReadFrequently: true }); + if (ctx) { + healingBrushToolRef.current.heal(ctx, localX, localY); + } + } + return; + } + } + + if (!isDragging && canvas && (activeTool === 'select' || activeTool === 'free-transform') && selectedLayerIds.length === 1) { + const rect = canvas.getBoundingClientRect(); + const canvasX = e.clientX - rect.left; + const canvasY = e.clientY - rect.top; + const handleHit = getHandleAtPoint(canvasX, canvasY); + const newCursor = getCursorForHandle(handleHit?.handle ?? null); + setCursorStyle(newCursor); + } + + if (!isDragging) return; + + updateDrag(e.clientX, e.clientY); + + if (dragMode === 'marquee' || dragMode === 'crop') { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + updateMarqueeSelect(x, y); + if (dragMode === 'crop' && crop.layerId && marqueeRect) { + const layer = project?.layers[crop.layerId]; + if (layer) { + const relX = marqueeRect.x - layer.transform.x; + const relY = marqueeRect.y - layer.transform.y; + let width = Math.min(marqueeRect.width, layer.transform.width - relX); + let height = Math.min(marqueeRect.height, layer.transform.height - relY); + + if (crop.lockAspect && crop.initialAspectRatio) { + const currentAspect = width / height; + if (currentAspect > crop.initialAspectRatio) { + width = height * crop.initialAspectRatio; + } else { + height = width / crop.initialAspectRatio; + } + } + + updateCropRect({ + x: Math.max(0, relX), + y: Math.max(0, relY), + width, + height, + }); + } + } + scheduleRender(); + return; + } + + if (dragMode === 'pan') { + const dx = e.clientX - dragCurrentX; + const dy = e.clientY - dragCurrentY; + setPan(panX + dx, panY + dy); + } else if (dragMode === 'rotate' && selectedLayerIds.length === 1 && artboard && canvas && initialTransformRef.current) { + const layerId = selectedLayerIds[0]; + const layer = project?.layers[layerId]; + if (layer) { + const centerX = canvas.width / 2 + panX; + const centerY = canvas.height / 2 + panY; + const artboardX = centerX - (artboard.size.width * zoom) / 2; + const artboardY = centerY - (artboard.size.height * zoom) / 2; + + const layerCenterX = artboardX + (initialTransformRef.current.x + initialTransformRef.current.width / 2) * zoom; + const layerCenterY = artboardY + (initialTransformRef.current.y + initialTransformRef.current.height / 2) * zoom; + + const rect = canvas.getBoundingClientRect(); + const startDx = dragStartX - rect.left - layerCenterX; + const startDy = dragStartY - rect.top - layerCenterY; + const currentDx = e.clientX - rect.left - layerCenterX; + const currentDy = e.clientY - rect.top - layerCenterY; + + const startAngle = Math.atan2(startDy, startDx); + const currentAngle = Math.atan2(currentDy, currentDx); + const deltaAngle = (currentAngle - startAngle) * (180 / Math.PI); + + let newRotation = initialTransformRef.current.rotation + deltaAngle; + + if (e.shiftKey) { + newRotation = Math.round(newRotation / 15) * 15; + } + + while (newRotation > 360) newRotation -= 360; + while (newRotation < 0) newRotation += 360; + + updateLayerTransform(layerId, { rotation: newRotation }); + } + } else if (dragMode === 'resize' && selectedLayerIds.length === 1 && activeResizeHandle && initialTransformRef.current) { + const layerId = selectedLayerIds[0]; + const layer = project?.layers[layerId]; + if (layer) { + const dx = (e.clientX - dragStartX) / zoom; + const dy = (e.clientY - dragStartY) / zoom; + + const init = initialTransformRef.current; + let newX = init.x; + let newY = init.y; + let newWidth = init.width; + let newHeight = init.height; + + const maintainAspect = e.shiftKey; + const aspectRatio = init.width / init.height; + + switch (activeResizeHandle) { + case 'nw': + newX = init.x + dx; + newY = init.y + dy; + newWidth = init.width - dx; + newHeight = init.height - dy; + if (maintainAspect) { + const delta = Math.max(-dx, -dy); + newWidth = init.width + delta; + newHeight = newWidth / aspectRatio; + newX = init.x + init.width - newWidth; + newY = init.y + init.height - newHeight; + } + break; + case 'n': + newY = init.y + dy; + newHeight = init.height - dy; + if (maintainAspect) { + newWidth = newHeight * aspectRatio; + newX = init.x + (init.width - newWidth) / 2; + } + break; + case 'ne': + newY = init.y + dy; + newWidth = init.width + dx; + newHeight = init.height - dy; + if (maintainAspect) { + const delta = Math.max(dx, -dy); + newWidth = init.width + delta; + newHeight = newWidth / aspectRatio; + newY = init.y + init.height - newHeight; + } + break; + case 'e': + newWidth = init.width + dx; + if (maintainAspect) { + newHeight = newWidth / aspectRatio; + newY = init.y + (init.height - newHeight) / 2; + } + break; + case 'se': + newWidth = init.width + dx; + newHeight = init.height + dy; + if (maintainAspect) { + const delta = Math.max(dx, dy); + newWidth = init.width + delta; + newHeight = newWidth / aspectRatio; + } + break; + case 's': + newHeight = init.height + dy; + if (maintainAspect) { + newWidth = newHeight * aspectRatio; + newX = init.x + (init.width - newWidth) / 2; + } + break; + case 'sw': + newX = init.x + dx; + newWidth = init.width - dx; + newHeight = init.height + dy; + if (maintainAspect) { + const delta = Math.max(-dx, dy); + newWidth = init.width + delta; + newHeight = newWidth / aspectRatio; + newX = init.x + init.width - newWidth; + } + break; + case 'w': + newX = init.x + dx; + newWidth = init.width - dx; + if (maintainAspect) { + newHeight = newWidth / aspectRatio; + newY = init.y + (init.height - newHeight) / 2; + } + break; + } + + const minSize = 10; + if (newWidth < minSize) { + if (activeResizeHandle.includes('w')) { + newX = init.x + init.width - minSize; + } + newWidth = minSize; + } + if (newHeight < minSize) { + if (activeResizeHandle.includes('n')) { + newY = init.y + init.height - minSize; + } + newHeight = minSize; + } + + updateLayerTransform(layerId, { + x: newX, + y: newY, + width: newWidth, + height: newHeight, + }); + } + } else if (dragMode === 'move' && selectedLayerIds.length > 0 && artboard) { + const dx = (e.clientX - dragCurrentX) / zoom; + const dy = (e.clientY - dragCurrentY) / zoom; + + const firstLayerId = selectedLayerIds[0]; + const firstLayer = project?.layers[firstLayerId]; + + if (firstLayer) { + const newX = firstLayer.transform.x + dx; + const newY = firstLayer.transform.y + dy; + + const otherLayers = Object.values(project?.layers ?? {}).filter( + (l) => l && !selectedLayerIds.includes(l.id) + ); + + const snapResult = calculateSnap( + { x: newX, y: newY, width: firstLayer.transform.width, height: firstLayer.transform.height }, + otherLayers as Layer[], + { x: 0, y: 0, width: artboard.size.width, height: artboard.size.height }, + guides, + { snapToObjects, snapToGuides, snapToGrid, gridSize, threshold: 8 } + ); + + const adjustedDx = snapResult.x - firstLayer.transform.x; + const adjustedDy = snapResult.y - firstLayer.transform.y; + + selectedLayerIds.forEach((layerId) => { + const layer = project?.layers[layerId]; + if (layer) { + updateLayerTransform(layerId, { + x: layer.transform.x + adjustedDx, + y: layer.transform.y + adjustedDy, + }); + } + }); + + setSmartGuides(snapResult.guides); + } + } + }, + [isDragging, dragMode, dragCurrentX, dragCurrentY, dragStartX, dragStartY, panX, panY, setPan, zoom, selectedLayerIds, project, updateLayerTransform, updateDrag, artboard, guides, snapToObjects, snapToGuides, snapToGrid, gridSize, setSmartGuides, drawing.isDrawing, screenToCanvas, addDrawingPoint, scheduleRender, updateMarqueeSelect, activeResizeHandle, activeTool, getHandleAtPoint, updateCropRect, crop, marqueeRect, gradientDrag] + ); + + const findLayersInRect = useCallback( + (rect: { x: number; y: number; width: number; height: number }): string[] => { + if (!artboard || !project) return []; + + const found: string[] = []; + for (const layerId of artboard.layerIds) { + const layer = project.layers[layerId]; + if (!layer || !layer.visible || layer.locked) continue; + + const { transform } = layer; + const layerLeft = transform.x; + const layerRight = transform.x + transform.width; + const layerTop = transform.y; + const layerBottom = transform.y + transform.height; + + const rectLeft = rect.x; + const rectRight = rect.x + rect.width; + const rectTop = rect.y; + const rectBottom = rect.y + rect.height; + + const intersects = !(layerRight < rectLeft || layerLeft > rectRight || layerBottom < rectTop || layerTop > rectBottom); + + if (intersects) { + found.push(layerId); + } + } + return found; + }, + [artboard, project] + ); + + const handleMouseUp = useCallback(() => { + if (drawing.isDrawing) { + const path = finishDrawing(); + if (path && path.length > 1) { + if (activeTool === 'brush') { + addPathLayer(path, brushSettings.color, brushSettings.size); + } else { + addPathLayer(path, penSettings.color, penSettings.width); + } + } + scheduleRender(); + return; + } + + if (gradientDrag && activeTool === 'gradient') { + const dx = gradientDrag.endX - gradientDrag.startX; + const dy = gradientDrag.endY - gradientDrag.startY; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > 5 && artboard) { + const minX = Math.min(gradientDrag.startX, gradientDrag.endX); + const minY = Math.min(gradientDrag.startY, gradientDrag.endY); + const maxX = Math.max(gradientDrag.startX, gradientDrag.endX); + const maxY = Math.max(gradientDrag.startY, gradientDrag.endY); + + const width = Math.max(maxX - minX, 50); + const height = Math.max(maxY - minY, 50); + + const angle = Math.atan2(dy, dx) * (180 / Math.PI); + + const colors = gradientSettings.reverse + ? [...gradientSettings.colors].reverse() + : gradientSettings.colors; + + const stops = colors.map((color, i) => ({ + offset: i / Math.max(colors.length - 1, 1), + color, + })); + + addShapeLayer('rectangle', { + x: minX, + y: minY, + width, + height, + }); + + const newLayerId = Object.keys(project?.layers ?? {}).find( + (id) => !selectedLayerIds.includes(id) && project?.layers[id]?.name === 'Rectangle' + ); + + if (newLayerId) { + updateLayer(newLayerId, { + name: 'Gradient Fill', + shapeStyle: { + fill: null, + stroke: null, + strokeWidth: 0, + fillOpacity: gradientSettings.opacity, + strokeOpacity: 1, + cornerRadius: 0, + fillType: 'gradient', + gradient: { + type: gradientSettings.type, + angle: gradientSettings.type === 'linear' ? angle : 0, + stops, + }, + }, + } as Partial); + } + } + + setGradientDrag(null); + endDrag(); + scheduleRender(); + return; + } + + if (dragMode === 'paint' && paintLayerIdRef.current && paintCanvasRef.current) { + const hasActiveTool = + (activeTool === 'smudge' && smudgeToolRef.current) || + ((activeTool === 'blur' || activeTool === 'sharpen') && blurSharpenToolRef.current) || + ((activeTool === 'dodge' || activeTool === 'burn') && dodgeBurnToolRef.current) || + (activeTool === 'sponge' && spongeToolRef.current) || + (activeTool === 'spot-healing' && spotHealingToolRef.current) || + (activeTool === 'clone-stamp' && cloneStampToolRef.current) || + (activeTool === 'healing-brush' && healingBrushToolRef.current) || + (activeTool === 'eraser' && eraserToolRef.current) || + (activeTool === 'brush' && brushToolRef.current); + + if (hasActiveTool) { + if (smudgeToolRef.current) { + smudgeToolRef.current.endStroke(); + } + if (blurSharpenToolRef.current) { + blurSharpenToolRef.current.endStroke(); + } + if (dodgeBurnToolRef.current) { + dodgeBurnToolRef.current.endStroke(); + } + if (spongeToolRef.current) { + spongeToolRef.current.endStroke(); + } + if (cloneStampToolRef.current) { + cloneStampToolRef.current.endClone(); + } + if (healingBrushToolRef.current) { + healingBrushToolRef.current.endHeal(); + } + if (eraserToolRef.current && paintCanvasRef.current) { + const ctx = paintCanvasRef.current.getContext('2d', { willReadFrequently: true }); + if (ctx) { + const stroke = eraserToolRef.current.endErase(); + if (stroke) { + eraserToolRef.current.applyErase(ctx, stroke); + } + } + } + if (brushToolRef.current) { + brushToolRef.current.endStroke(); + } + + const tempCanvas = paintCanvasRef.current; + const layerId = paintLayerIdRef.current; + + const currentLayer = project?.layers[layerId] as ImageLayer | undefined; + const oldSourceId = currentLayer?.sourceId; + const oldAsset = oldSourceId ? project?.assets[oldSourceId] : undefined; + const oldBlobUrl = oldAsset?.blobUrl; + + const canvas = document.createElement('canvas'); + canvas.width = tempCanvas.width; + canvas.height = tempCanvas.height; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(tempCanvas, 0, 0); + canvas.toBlob((blob) => { + if (blob) { + if (oldBlobUrl?.startsWith('blob:')) { + URL.revokeObjectURL(oldBlobUrl); + } + const newBlobUrl = URL.createObjectURL(blob); + const newAssetId = `asset-${Date.now()}`; + useProjectStore.getState().addAsset({ + id: newAssetId, + name: `${activeTool}-edited`, + type: 'image', + mimeType: 'image/png', + size: blob.size, + width: canvas.width, + height: canvas.height, + thumbnailUrl: newBlobUrl, + blobUrl: newBlobUrl, + }); + useProjectStore.getState().updateLayer(layerId, { sourceId: newAssetId }); + forceRender(); + } + }, 'image/png'); + } + + smudgeToolRef.current = null; + blurSharpenToolRef.current = null; + dodgeBurnToolRef.current = null; + spongeToolRef.current = null; + spotHealingToolRef.current = null; + eraserToolRef.current = null; + brushToolRef.current = null; + paintCanvasRef.current = null; + paintLayerIdRef.current = null; + } + + endDrag(); + return; + } + + if (dragMode === 'marquee') { + const rect = endMarqueeSelect(); + if (rect && rect.width > 5 && rect.height > 5) { + if (activeTool === 'marquee-rect' || activeTool === 'marquee-ellipse') { + // Keep selection visible for marquee tools - don't select layers + } else { + const layerIds = findLayersInRect(rect); + if (layerIds.length > 0) { + selectLayers(layerIds); + } + } + } + endDrag(); + scheduleRender(); + return; + } + + if (dragMode === 'crop') { + endMarqueeSelect(); + endDrag(); + scheduleRender(); + return; + } + + initialTransformRef.current = null; + setActiveResizeHandle(null); + endDrag(); + clearSmartGuides(); + }, [endDrag, clearSmartGuides, drawing.isDrawing, finishDrawing, addPathLayer, penSettings, brushSettings, scheduleRender, dragMode, endMarqueeSelect, findLayersInRect, selectLayers, setActiveResizeHandle, activeTool, gradientDrag, gradientSettings, artboard, addShapeLayer, updateLayer, project, selectedLayerIds]); + + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const { x, y } = screenToCanvas(e.clientX, e.clientY); + const layerId = findLayerAtPoint(x, y); + + let menuType: ContextMenuType = 'canvas'; + + if (layerId) { + if (!selectedLayerIds.includes(layerId)) { + selectLayer(layerId); + } + + if (selectedLayerIds.length > 1 || (selectedLayerIds.length === 1 && selectedLayerIds[0] !== layerId)) { + const count = selectedLayerIds.includes(layerId) ? selectedLayerIds.length : 1; + if (count > 1) { + menuType = 'multi-layer'; + } else { + const layer = project?.layers[layerId]; + menuType = layer?.type === 'group' ? 'group' : 'layer'; + } + } else { + const layer = project?.layers[layerId]; + menuType = layer?.type === 'group' ? 'group' : 'layer'; + } + } else if (selectedLayerIds.length > 1) { + menuType = 'multi-layer'; + } else if (selectedLayerIds.length === 1) { + const layer = project?.layers[selectedLayerIds[0]]; + menuType = layer?.type === 'group' ? 'group' : 'layer'; + } + + setContextMenu({ + position: { x: e.clientX, y: e.clientY }, + type: menuType, + }); + }, + [screenToCanvas, findLayerAtPoint, selectedLayerIds, selectLayer, project] + ); + + const closeContextMenu = useCallback(() => { + setContextMenu(null); + }, []); + + const handleFlipHorizontal = useCallback(() => { + selectedLayerIds.forEach((id) => { + const layer = project?.layers[id]; + if (layer) { + updateLayer(id, { flipHorizontal: !layer.flipHorizontal }); + } + }); + }, [selectedLayerIds, project, updateLayer]); + + const handleFlipVertical = useCallback(() => { + selectedLayerIds.forEach((id) => { + const layer = project?.layers[id]; + if (layer) { + updateLayer(id, { flipVertical: !layer.flipVertical }); + } + }); + }, [selectedLayerIds, project, updateLayer]); + + const handleResetTransform = useCallback(() => { + selectedLayerIds.forEach((id) => { + updateLayerTransform(id, { + rotation: 0, + scaleX: 1, + scaleY: 1, + skewX: 0, + skewY: 0, + }); + updateLayer(id, { flipHorizontal: false, flipVertical: false }); + }); + }, [selectedLayerIds, updateLayerTransform, updateLayer]); + + const handleToggleVisibility = useCallback(() => { + selectedLayerIds.forEach((id) => { + const layer = project?.layers[id]; + if (layer) { + updateLayer(id, { visible: !layer.visible }); + } + }); + }, [selectedLayerIds, project, updateLayer]); + + const handleToggleLock = useCallback(() => { + selectedLayerIds.forEach((id) => { + const layer = project?.layers[id]; + if (layer) { + updateLayer(id, { locked: !layer.locked }); + } + }); + }, [selectedLayerIds, project, updateLayer]); + + const handleSelectAll = useCallback(() => { + if (artboard) { + selectLayers(artboard.layerIds); + } + }, [artboard, selectLayers]); + + const handleZoomFit = useCallback(() => { + if (artboard && containerRef.current) { + const containerWidth = containerRef.current.clientWidth; + const containerHeight = containerRef.current.clientHeight; + const scaleX = (containerWidth - 100) / artboard.size.width; + const scaleY = (containerHeight - 100) / artboard.size.height; + const newZoom = Math.min(scaleX, scaleY, 1); + setZoom(newZoom); + setPan(0, 0); + } + }, [artboard, setZoom, setPan]); + + const handleAlignLeft = useCallback(() => { + if (selectedLayerIds.length < 2 || !project) return; + const layers = selectedLayerIds.map((id) => project.layers[id]).filter(Boolean); + const minX = Math.min(...layers.map((l) => l.transform.x)); + layers.forEach((layer) => { + updateLayerTransform(layer.id, { x: minX }); + }); + }, [selectedLayerIds, project, updateLayerTransform]); + + const handleAlignCenter = useCallback(() => { + if (selectedLayerIds.length < 2 || !project) return; + const layers = selectedLayerIds.map((id) => project.layers[id]).filter(Boolean); + const centers = layers.map((l) => l.transform.x + l.transform.width / 2); + const avgCenter = centers.reduce((a, b) => a + b, 0) / centers.length; + layers.forEach((layer) => { + updateLayerTransform(layer.id, { x: avgCenter - layer.transform.width / 2 }); + }); + }, [selectedLayerIds, project, updateLayerTransform]); + + const handleAlignRight = useCallback(() => { + if (selectedLayerIds.length < 2 || !project) return; + const layers = selectedLayerIds.map((id) => project.layers[id]).filter(Boolean); + const maxRight = Math.max(...layers.map((l) => l.transform.x + l.transform.width)); + layers.forEach((layer) => { + updateLayerTransform(layer.id, { x: maxRight - layer.transform.width }); + }); + }, [selectedLayerIds, project, updateLayerTransform]); + + const handleAlignTop = useCallback(() => { + if (selectedLayerIds.length < 2 || !project) return; + const layers = selectedLayerIds.map((id) => project.layers[id]).filter(Boolean); + const minY = Math.min(...layers.map((l) => l.transform.y)); + layers.forEach((layer) => { + updateLayerTransform(layer.id, { y: minY }); + }); + }, [selectedLayerIds, project, updateLayerTransform]); + + const handleAlignMiddle = useCallback(() => { + if (selectedLayerIds.length < 2 || !project) return; + const layers = selectedLayerIds.map((id) => project.layers[id]).filter(Boolean); + const middles = layers.map((l) => l.transform.y + l.transform.height / 2); + const avgMiddle = middles.reduce((a, b) => a + b, 0) / middles.length; + layers.forEach((layer) => { + updateLayerTransform(layer.id, { y: avgMiddle - layer.transform.height / 2 }); + }); + }, [selectedLayerIds, project, updateLayerTransform]); + + const handleAlignBottom = useCallback(() => { + if (selectedLayerIds.length < 2 || !project) return; + const layers = selectedLayerIds.map((id) => project.layers[id]).filter(Boolean); + const maxBottom = Math.max(...layers.map((l) => l.transform.y + l.transform.height)); + layers.forEach((layer) => { + updateLayerTransform(layer.id, { y: maxBottom - layer.transform.height }); + }); + }, [selectedLayerIds, project, updateLayerTransform]); + + const selectedLayer = selectedLayerIds.length === 1 ? project?.layers[selectedLayerIds[0]] : null; + + const handleWheel = useCallback( + (e: React.WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const delta = e.deltaY > 0 ? 0.9 : 1.1; + useUIStore.getState().setZoom(zoom * delta); + } else { + setPan(panX - e.deltaX, panY - e.deltaY); + } + }, + [zoom, panX, panY, setPan] + ); + + const effectiveCursor = (() => { + if ((activeTool === 'select' || activeTool === 'free-transform') && cursorStyle !== 'default') { + return cursorStyle; + } + return getToolCursor(activeTool, isDragging, dragMode); + })(); + + return ( +
+ {showRulers && ( + + )} + + + {contextMenu && ( + selectedLayerIds.forEach((id) => duplicateLayer(id))} + onDelete={() => selectedLayerIds.forEach((id) => removeLayer(id))} + onSelectAll={handleSelectAll} + onToggleVisibility={handleToggleVisibility} + onToggleLock={handleToggleLock} + onBringToFront={() => selectedLayerIds.forEach((id) => moveLayerToTop(id))} + onBringForward={() => selectedLayerIds.forEach((id) => moveLayerUp(id))} + onSendBackward={() => selectedLayerIds.forEach((id) => moveLayerDown(id))} + onSendToBack={() => selectedLayerIds.forEach((id) => moveLayerToBottom(id))} + onGroup={() => groupLayers(selectedLayerIds)} + onUngroup={() => selectedLayerIds.length === 1 && ungroupLayers(selectedLayerIds[0])} + onFlipHorizontal={handleFlipHorizontal} + onFlipVertical={handleFlipVertical} + onResetTransform={handleResetTransform} + onCopyStyle={copyLayerStyle} + onPasteStyle={pasteLayerStyle} + onAddText={() => addTextLayer('New Text')} + onAddShape={addShapeLayer} + onToggleGrid={toggleGrid} + onToggleRulers={toggleRulers} + onZoomIn={() => setZoom(zoom * 1.2)} + onZoomOut={() => setZoom(zoom / 1.2)} + onZoomFit={handleZoomFit} + onAlignLeft={handleAlignLeft} + onAlignCenter={handleAlignCenter} + onAlignRight={handleAlignRight} + onAlignTop={handleAlignTop} + onAlignMiddle={handleAlignMiddle} + onAlignBottom={handleAlignBottom} + isVisible={selectedLayer?.visible ?? true} + isLocked={selectedLayer?.locked ?? false} + showGrid={showGrid} + showRulers={showRulers} + hasClipboard={copiedLayers.length > 0} + hasStyleClipboard={copiedStyle !== null} + selectedCount={selectedLayerIds.length} + /> + )} +
+ ); +} + +const BLEND_MODE_MAP: Record = { + 'normal': 'source-over', + 'multiply': 'multiply', + 'screen': 'screen', + 'overlay': 'overlay', + 'darken': 'darken', + 'lighten': 'lighten', + 'color-dodge': 'color-dodge', + 'color-burn': 'color-burn', + 'hard-light': 'hard-light', + 'soft-light': 'soft-light', + 'difference': 'difference', + 'exclusion': 'exclusion', +}; + +function renderLayerWithChildren( + ctx: CanvasRenderingContext2D, + layer: Layer, + project: { layers: Record; assets: Record } +) { + if (layer.type === 'group') { + const group = layer as GroupLayer; + if (!group.visible) return; + + const { transform } = group; + ctx.save(); + + ctx.translate(transform.x, transform.y); + ctx.rotate((transform.rotation * Math.PI) / 180); + ctx.scale(transform.scaleX, transform.scaleY); + ctx.globalAlpha *= transform.opacity; + + const sortedChildIds = [...group.childIds].reverse(); + sortedChildIds.forEach((childId) => { + const child = project.layers[childId]; + if (child && child.visible) { + renderLayer(ctx, child, project); + } + }); + + ctx.restore(); + } else { + renderLayer(ctx, layer, project); + } +} + +type RenderContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + +function renderLayerToOffscreen( + layer: Layer, + project: { assets: Record } +): OffscreenCanvas | null { + if (typeof OffscreenCanvas === 'undefined') return null; + + const { width, height } = layer.transform; + const ceilWidth = Math.ceil(width); + const ceilHeight = Math.ceil(height); + + if (ceilWidth <= 0 || ceilHeight <= 0) return null; + + const shadow = layer.shadow ?? { enabled: false, color: 'rgba(0, 0, 0, 0.5)', blur: 10, offsetX: 0, offsetY: 4 }; + const innerShadow = layer.innerShadow ?? { enabled: false, color: 'rgba(0, 0, 0, 0.5)', blur: 10, offsetX: 2, offsetY: 2 }; + const glow = layer.glow ?? { enabled: false, color: '#ffffff', blur: 20, intensity: 1 }; + + const padding = Math.max( + shadow.enabled ? shadow.blur + Math.abs(shadow.offsetX) + Math.abs(shadow.offsetY) : 0, + glow.enabled ? glow.blur * (glow.intensity ?? 1) * 2 : 0 + ); + + const offscreen = new OffscreenCanvas(ceilWidth + padding * 2, ceilHeight + padding * 2); + const offCtx = offscreen.getContext('2d') as RenderContext | null; + if (!offCtx) return null; + + offCtx.translate(padding, padding); + + if (glow.enabled && glow.blur > 0) { + offCtx.save(); + offCtx.shadowColor = glow.color; + offCtx.shadowBlur = glow.blur * (glow.intensity ?? 1); + offCtx.shadowOffsetX = 0; + offCtx.shadowOffsetY = 0; + + for (let i = 0; i < 3; i++) { + renderLayerContentInternal(offCtx, layer, project); + } + offCtx.restore(); + } + + if (shadow.enabled) { + offCtx.shadowColor = shadow.color; + offCtx.shadowBlur = shadow.blur; + offCtx.shadowOffsetX = shadow.offsetX; + offCtx.shadowOffsetY = shadow.offsetY; + } + + renderLayerContentInternal(offCtx, layer, project); + + offCtx.shadowColor = 'transparent'; + offCtx.shadowBlur = 0; + offCtx.shadowOffsetX = 0; + offCtx.shadowOffsetY = 0; + + if (innerShadow.enabled && innerShadow.blur > 0) { + renderInnerShadowInternal(offCtx, layer, innerShadow); + } + + return offscreen; +} + +function renderLayer( + ctx: CanvasRenderingContext2D, + layer: Layer, + project: { layers?: Record; assets: Record } +) { + const { transform } = layer; + const blendMode = layer.blendMode?.mode ?? 'normal'; + + ctx.save(); + ctx.translate(transform.x, transform.y); + ctx.rotate((transform.rotation * Math.PI) / 180); + ctx.scale(transform.scaleX, transform.scaleY); + + const skewX = transform.skewX ?? 0; + const skewY = transform.skewY ?? 0; + if (skewX !== 0 || skewY !== 0) { + ctx.transform(1, Math.tan(skewY * Math.PI / 180), Math.tan(skewX * Math.PI / 180), 1, 0, 0); + } + + ctx.globalAlpha = transform.opacity; + ctx.globalCompositeOperation = BLEND_MODE_MAP[blendMode] ?? 'source-over'; + + const cachedCanvas = getCachedLayerCanvas(layer, project); + if (cachedCanvas) { + const shadow = layer.shadow ?? { enabled: false, color: 'rgba(0, 0, 0, 0.5)', blur: 10, offsetX: 0, offsetY: 4 }; + const glow = layer.glow ?? { enabled: false, color: '#ffffff', blur: 20, intensity: 1 }; + const padding = Math.max( + shadow.enabled ? shadow.blur + Math.abs(shadow.offsetX) + Math.abs(shadow.offsetY) : 0, + glow.enabled ? glow.blur * (glow.intensity ?? 1) * 2 : 0 + ); + ctx.drawImage(cachedCanvas, -padding, -padding); + ctx.restore(); + return; + } + + const offscreen = renderLayerToOffscreen(layer, project); + if (offscreen) { + const hash = getLayerHash(layer, project.assets); + setCachedLayerCanvas(layer.id, offscreen, hash, Math.ceil(transform.width), Math.ceil(transform.height)); + + const shadow = layer.shadow ?? { enabled: false, color: 'rgba(0, 0, 0, 0.5)', blur: 10, offsetX: 0, offsetY: 4 }; + const glow = layer.glow ?? { enabled: false, color: '#ffffff', blur: 20, intensity: 1 }; + const padding = Math.max( + shadow.enabled ? shadow.blur + Math.abs(shadow.offsetX) + Math.abs(shadow.offsetY) : 0, + glow.enabled ? glow.blur * (glow.intensity ?? 1) * 2 : 0 + ); + ctx.drawImage(offscreen, -padding, -padding); + ctx.restore(); + return; + } + + const shadow = layer.shadow ?? { enabled: false, color: 'rgba(0, 0, 0, 0.5)', blur: 10, offsetX: 0, offsetY: 4 }; + const innerShadow = layer.innerShadow ?? { enabled: false, color: 'rgba(0, 0, 0, 0.5)', blur: 10, offsetX: 2, offsetY: 2 }; + const glow = layer.glow ?? { enabled: false, color: '#ffffff', blur: 20, intensity: 1 }; + + if (glow.enabled && glow.blur > 0) { + ctx.save(); + ctx.shadowColor = glow.color; + ctx.shadowBlur = glow.blur * (glow.intensity ?? 1); + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + + for (let i = 0; i < 3; i++) { + renderLayerContent(ctx, layer, project); + } + ctx.restore(); + } + + if (shadow.enabled) { + ctx.shadowColor = shadow.color; + ctx.shadowBlur = shadow.blur; + ctx.shadowOffsetX = shadow.offsetX; + ctx.shadowOffsetY = shadow.offsetY; + } + + renderLayerContent(ctx, layer, project); + + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + + if (innerShadow.enabled && innerShadow.blur > 0) { + renderInnerShadow(ctx, layer, innerShadow); + } + + ctx.restore(); +} + +function renderLayerContent( + ctx: CanvasRenderingContext2D, + layer: Layer, + project: { assets: Record } +) { + renderLayerContentInternal(ctx, layer, project); +} + +function renderLayerContentInternal( + ctx: RenderContext, + layer: Layer, + project: { assets: Record } +) { + switch (layer.type) { + case 'image': + renderImageLayerInternal(ctx, layer as ImageLayer, project); + break; + case 'text': + renderTextLayerInternal(ctx, layer as TextLayer); + break; + case 'shape': + renderShapeLayerInternal(ctx, layer as ShapeLayer); + break; + } +} + +function renderInnerShadow( + ctx: CanvasRenderingContext2D, + layer: Layer, + innerShadow: { color: string; blur: number; offsetX: number; offsetY: number } +) { + renderInnerShadowInternal(ctx, layer, innerShadow); +} + +function renderInnerShadowInternal( + ctx: RenderContext, + layer: Layer, + innerShadow: { color: string; blur: number; offsetX: number; offsetY: number } +) { + const { width, height } = layer.transform; + + ctx.save(); + ctx.beginPath(); + ctx.rect(0, 0, width, height); + ctx.clip(); + + ctx.shadowColor = innerShadow.color; + ctx.shadowBlur = innerShadow.blur; + ctx.shadowOffsetX = innerShadow.offsetX; + ctx.shadowOffsetY = innerShadow.offsetY; + + ctx.fillStyle = 'rgba(0, 0, 0, 1)'; + ctx.globalCompositeOperation = 'source-atop'; + + const spread = innerShadow.blur + Math.max(Math.abs(innerShadow.offsetX), Math.abs(innerShadow.offsetY)) + 50; + + ctx.beginPath(); + ctx.moveTo(-spread, -spread); + ctx.lineTo(width + spread, -spread); + ctx.lineTo(width + spread, height + spread); + ctx.lineTo(-spread, height + spread); + ctx.closePath(); + + ctx.moveTo(0, 0); + ctx.lineTo(0, height); + ctx.lineTo(width, height); + ctx.lineTo(width, 0); + ctx.closePath(); + + ctx.fill('evenodd'); + + ctx.restore(); +} + +function applyMotionBlur( + ctx: RenderContext, + img: HTMLImageElement, + width: number, + height: number, + amount: number, + angle: number +) { + const steps = Math.min(Math.ceil(amount / 2), 20); + const radians = (angle * Math.PI) / 180; + const dx = Math.cos(radians) * (amount / steps); + const dy = Math.sin(radians) * (amount / steps); + + for (let i = -steps; i <= steps; i++) { + const alpha = 1 / (Math.abs(i) + 1); + ctx.globalAlpha = alpha / (steps * 2); + ctx.drawImage(img, i * dx, i * dy, width, height); + } + ctx.globalAlpha = 1; +} + +function applyRadialBlur( + ctx: RenderContext, + img: HTMLImageElement, + width: number, + height: number, + amount: number +) { + const steps = Math.min(Math.ceil(amount / 2), 15); + const centerX = width / 2; + const centerY = height / 2; + + for (let i = 0; i < steps; i++) { + const scale = 1 + (i * amount) / (steps * 100); + const alpha = 1 / (i + 1); + ctx.globalAlpha = alpha / steps; + + ctx.save(); + ctx.translate(centerX, centerY); + ctx.scale(scale, scale); + ctx.translate(-centerX, -centerY); + ctx.drawImage(img, 0, 0, width, height); + ctx.restore(); + } + ctx.globalAlpha = 1; +} + +function drawImageWithCrop( + ctx: RenderContext, + img: HTMLImageElement, + layerWidth: number, + layerHeight: number, + cropRect: { x: number; y: number; width: number; height: number } | null +) { + if (!cropRect) { + ctx.drawImage(img, 0, 0, layerWidth, layerHeight); + return; + } + + ctx.drawImage(img, cropRect.x, cropRect.y, cropRect.width, cropRect.height, 0, 0, layerWidth, layerHeight); +} + +function renderImageLayerInternal( + ctx: RenderContext, + layer: ImageLayer, + project: { assets: Record } +) { + const asset = project.assets[layer.sourceId]; + if (!asset) { + ctx.fillStyle = '#3b82f6'; + ctx.fillRect(0, 0, layer.transform.width, layer.transform.height); + ctx.fillStyle = '#ffffff'; + ctx.font = '14px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('Image', layer.transform.width / 2, layer.transform.height / 2); + return; + } + + const src = asset.dataUrl ?? asset.blobUrl ?? ''; + const img = getCachedImage(src); + + if (!img) { + ctx.fillStyle = '#27272a'; + ctx.fillRect(0, 0, layer.transform.width, layer.transform.height); + ctx.fillStyle = '#71717a'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('Loading...', layer.transform.width / 2, layer.transform.height / 2); + return; + } + + const flipH = layer.flipHorizontal ?? false; + const flipV = layer.flipVertical ?? false; + const { cropRect } = layer; + + const adjustments: LayerAdjustments = { + levels: layer.levels, + curves: layer.curves, + colorBalance: layer.colorBalance, + selectiveColor: layer.selectiveColor, + blackWhite: layer.blackWhite, + photoFilter: layer.photoFilter, + channelMixer: layer.channelMixer, + gradientMap: layer.gradientMap, + posterize: layer.posterize, + threshold: layer.threshold, + }; + + const needsAdjustments = hasActiveAdjustments(adjustments); + + if (flipH || flipV) { + ctx.save(); + ctx.translate( + flipH ? layer.transform.width : 0, + flipV ? layer.transform.height : 0 + ); + ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1); + } + + const { filters } = layer; + const filterParts: string[] = []; + + let effectiveBrightness = filters.brightness; + if (filters.exposure !== 0) { + effectiveBrightness = effectiveBrightness * (1 + filters.exposure / 100); + } + if (filters.highlights > 0) { + effectiveBrightness = effectiveBrightness * (1 + filters.highlights / 200); + } + if (filters.shadows > 0) { + effectiveBrightness = effectiveBrightness * (1 + filters.shadows / 300); + } + if (effectiveBrightness !== 100) { + filterParts.push(`brightness(${Math.round(effectiveBrightness)}%)`); + } + + let effectiveContrast = filters.contrast; + if (filters.clarity !== 0) { + effectiveContrast = effectiveContrast * (1 + filters.clarity / 150); + } + if (filters.highlights < 0) { + effectiveContrast = effectiveContrast * (1 + filters.highlights / 400); + } + if (filters.shadows < 0) { + effectiveContrast = effectiveContrast * (1 + filters.shadows / 400); + } + if (effectiveContrast !== 100) { + filterParts.push(`contrast(${Math.round(effectiveContrast)}%)`); + } + + let effectiveSaturation = filters.saturation; + if (filters.vibrance !== 0) { + effectiveSaturation = effectiveSaturation * (1 + filters.vibrance / 150); + } + if (effectiveSaturation !== 100) { + filterParts.push(`saturate(${Math.round(effectiveSaturation)}%)`); + } + + if (filters.hue !== 0) { + filterParts.push(`hue-rotate(${filters.hue}deg)`); + } + if (filters.sepia > 0) { + filterParts.push(`sepia(${filters.sepia}%)`); + } + if (filters.invert > 0) { + filterParts.push(`invert(${filters.invert}%)`); + } + + if (filters.blur > 0 && filters.blurType === 'gaussian') { + filterParts.push(`blur(${filters.blur}px)`); + } + + const width = Math.ceil(layer.transform.width); + const height = Math.ceil(layer.transform.height); + + if (needsAdjustments && typeof OffscreenCanvas !== 'undefined' && width > 0 && height > 0) { + const tempCanvas = new OffscreenCanvas(width, height); + const tempCtx = tempCanvas.getContext('2d'); + if (tempCtx) { + if (filterParts.length > 0) { + tempCtx.filter = filterParts.join(' '); + } + + if (filters.blur > 0 && filters.blurType === 'motion') { + applyMotionBlur(tempCtx, img, width, height, filters.blur, filters.blurAngle); + } else if (filters.blur > 0 && filters.blurType === 'radial') { + applyRadialBlur(tempCtx, img, width, height, filters.blur); + } else { + drawImageWithCrop(tempCtx, img, width, height, cropRect); + } + + tempCtx.filter = 'none'; + + let imageData = tempCtx.getImageData(0, 0, width, height); + imageData = applyAllAdjustments(imageData, adjustments); + tempCtx.putImageData(imageData, 0, 0); + + ctx.drawImage(tempCanvas, 0, 0, layer.transform.width, layer.transform.height); + } + } else { + if (filterParts.length > 0) { + ctx.filter = filterParts.join(' '); + } + + if (filters.blur > 0 && filters.blurType === 'motion') { + applyMotionBlur(ctx, img, layer.transform.width, layer.transform.height, filters.blur, filters.blurAngle); + } else if (filters.blur > 0 && filters.blurType === 'radial') { + applyRadialBlur(ctx, img, layer.transform.width, layer.transform.height, filters.blur); + } else { + drawImageWithCrop(ctx, img, layer.transform.width, layer.transform.height, cropRect); + } + + ctx.filter = 'none'; + } + + if (flipH || flipV) { + ctx.restore(); + } +} + +function renderTextLayerInternal(ctx: RenderContext, layer: TextLayer) { + const { style, content, transform } = layer; + const flipH = layer.flipHorizontal ?? false; + const flipV = layer.flipVertical ?? false; + + if (flipH || flipV) { + ctx.save(); + ctx.translate( + flipH ? transform.width : 0, + flipV ? transform.height : 0 + ); + ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1); + } + + ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize}px ${style.fontFamily}`; + ctx.textAlign = style.textAlign as CanvasTextAlign; + ctx.textBaseline = 'top'; + + const lines = content.split('\n'); + const lineHeight = style.fontSize * style.lineHeight; + + let textX = 0; + if (style.textAlign === 'center') textX = transform.width / 2; + else if (style.textAlign === 'right') textX = transform.width; + + if (style.backgroundColor) { + const padding = style.backgroundPadding ?? 8; + const radius = style.backgroundRadius ?? 4; + const textWidth = Math.max(...lines.map((line) => ctx.measureText(line).width)); + const textHeight = lines.length * lineHeight; + + let bgX = -padding; + if (style.textAlign === 'center') bgX = (transform.width - textWidth) / 2 - padding; + else if (style.textAlign === 'right') bgX = transform.width - textWidth - padding; + + ctx.fillStyle = style.backgroundColor; + ctx.beginPath(); + const bgW = textWidth + padding * 2; + const bgH = textHeight + padding * 2; + const bgY = -padding; + const r = Math.min(radius, bgW / 2, bgH / 2); + ctx.moveTo(bgX + r, bgY); + ctx.lineTo(bgX + bgW - r, bgY); + ctx.quadraticCurveTo(bgX + bgW, bgY, bgX + bgW, bgY + r); + ctx.lineTo(bgX + bgW, bgY + bgH - r); + ctx.quadraticCurveTo(bgX + bgW, bgY + bgH, bgX + bgW - r, bgY + bgH); + ctx.lineTo(bgX + r, bgY + bgH); + ctx.quadraticCurveTo(bgX, bgY + bgH, bgX, bgY + bgH - r); + ctx.lineTo(bgX, bgY + r); + ctx.quadraticCurveTo(bgX, bgY, bgX + r, bgY); + ctx.closePath(); + ctx.fill(); + } + + let fillStyle: string | CanvasGradient = style.color; + if (style.fillType === 'gradient' && style.gradient && lines.length > 0) { + const lineWidths = lines.map((line) => ctx.measureText(line).width); + const textWidth = lineWidths.length > 0 ? Math.max(...lineWidths) : 0; + const textHeight = lines.length * lineHeight; + + if (textWidth > 0 && textHeight > 0) { + let gradientStartX = 0; + if (style.textAlign === 'center') gradientStartX = (transform.width - textWidth) / 2; + else if (style.textAlign === 'right') gradientStartX = transform.width - textWidth; + + if (style.gradient.type === 'linear') { + const angleRad = (style.gradient.angle * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const halfWidth = textWidth / 2; + const halfHeight = textHeight / 2; + const len = Math.abs(halfWidth * cos) + Math.abs(halfHeight * sin); + + const centerX = gradientStartX + halfWidth; + const centerY = halfHeight; + const gradient = ctx.createLinearGradient( + centerX - len * cos, + centerY - len * sin, + centerX + len * cos, + centerY + len * sin + ); + style.gradient.stops.forEach((stop) => { + gradient.addColorStop(stop.offset, stop.color); + }); + fillStyle = gradient; + } else { + const centerX = gradientStartX + textWidth / 2; + const centerY = textHeight / 2; + const radius = Math.max(textWidth, textHeight) / 2; + const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius); + style.gradient.stops.forEach((stop) => { + gradient.addColorStop(stop.offset, stop.color); + }); + fillStyle = gradient; + } + } + } + + const textShadow = style.textShadow; + if (textShadow?.enabled) { + ctx.shadowColor = textShadow.color ?? 'rgba(0, 0, 0, 0.5)'; + ctx.shadowBlur = textShadow.blur ?? 4; + ctx.shadowOffsetX = textShadow.offsetX ?? 0; + ctx.shadowOffsetY = textShadow.offsetY ?? 2; + } + + lines.forEach((line, i) => { + const y = i * lineHeight; + + if (style.strokeColor && (style.strokeWidth ?? 0) > 0) { + ctx.strokeStyle = style.strokeColor; + ctx.lineWidth = style.strokeWidth ?? 1; + ctx.lineJoin = 'round'; + ctx.miterLimit = 2; + ctx.strokeText(line, textX, y); + } + + ctx.fillStyle = fillStyle; + ctx.fillText(line, textX, y); + }); + + if (textShadow?.enabled) { + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + } + + if (flipH || flipV) { + ctx.restore(); + } +} + +function renderShapeLayerInternal(ctx: RenderContext, layer: ShapeLayer) { + const { shapeType, shapeStyle, transform } = layer; + const { width, height } = transform; + const flipH = layer.flipHorizontal ?? false; + const flipV = layer.flipVertical ?? false; + + if (flipH || flipV) { + ctx.save(); + ctx.translate( + flipH ? width : 0, + flipV ? height : 0 + ); + ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1); + } + + ctx.beginPath(); + + switch (shapeType) { + case 'rectangle': { + let tl = 0, tr = 0, br = 0, bl = 0; + + if (shapeStyle.individualCorners && shapeStyle.corners) { + tl = Math.min(shapeStyle.corners.topLeft, width / 2, height / 2); + tr = Math.min(shapeStyle.corners.topRight, width / 2, height / 2); + br = Math.min(shapeStyle.corners.bottomRight, width / 2, height / 2); + bl = Math.min(shapeStyle.corners.bottomLeft, width / 2, height / 2); + } else if (shapeStyle.cornerRadius > 0) { + const r = Math.min(shapeStyle.cornerRadius, width / 2, height / 2); + tl = tr = br = bl = r; + } + + if (tl > 0 || tr > 0 || br > 0 || bl > 0) { + ctx.moveTo(tl, 0); + ctx.lineTo(width - tr, 0); + if (tr > 0) ctx.quadraticCurveTo(width, 0, width, tr); + else ctx.lineTo(width, 0); + ctx.lineTo(width, height - br); + if (br > 0) ctx.quadraticCurveTo(width, height, width - br, height); + else ctx.lineTo(width, height); + ctx.lineTo(bl, height); + if (bl > 0) ctx.quadraticCurveTo(0, height, 0, height - bl); + else ctx.lineTo(0, height); + ctx.lineTo(0, tl); + if (tl > 0) ctx.quadraticCurveTo(0, 0, tl, 0); + else ctx.lineTo(0, 0); + ctx.closePath(); + } else { + ctx.rect(0, 0, width, height); + } + break; + } + + case 'ellipse': + ctx.ellipse(width / 2, height / 2, width / 2, height / 2, 0, 0, Math.PI * 2); + break; + + case 'triangle': + ctx.moveTo(width / 2, 0); + ctx.lineTo(width, height); + ctx.lineTo(0, height); + ctx.closePath(); + break; + + case 'polygon': { + const cx = width / 2; + const cy = height / 2; + const radius = Math.min(width, height) / 2; + const sides = layer.sides ?? 6; + for (let i = 0; i < sides; i++) { + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; + const px = cx + radius * Math.cos(angle); + const py = cy + radius * Math.sin(angle); + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); + break; + } + + case 'star': { + const cx = width / 2; + const cy = height / 2; + const outerRadius = Math.min(width, height) / 2; + const innerRatio = layer.innerRadius ?? 0.4; + const innerRadius = outerRadius * innerRatio; + const points = layer.sides ?? 5; + for (let i = 0; i < points * 2; i++) { + const radius = i % 2 === 0 ? outerRadius : innerRadius; + const angle = (i * Math.PI) / points - Math.PI / 2; + const px = cx + radius * Math.cos(angle); + const py = cy + radius * Math.sin(angle); + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); + break; + } + + case 'line': + ctx.moveTo(0, height / 2); + ctx.lineTo(width, height / 2); + break; + + case 'arrow': { + const arrowHeadSize = Math.min(width, height) * 0.3; + ctx.moveTo(0, height / 2); + ctx.lineTo(width - arrowHeadSize, height / 2); + ctx.moveTo(width, height / 2); + ctx.lineTo(width - arrowHeadSize, height / 2 - arrowHeadSize / 2); + ctx.moveTo(width, height / 2); + ctx.lineTo(width - arrowHeadSize, height / 2 + arrowHeadSize / 2); + break; + } + + case 'path': + if (layer.points && layer.points.length > 1) { + ctx.moveTo(layer.points[0].x, layer.points[0].y); + for (let i = 1; i < layer.points.length; i++) { + ctx.lineTo(layer.points[i].x, layer.points[i].y); + } + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + } + break; + + default: + ctx.rect(0, 0, width, height); + } + + const fillType = shapeStyle.fillType ?? 'solid'; + const hasFill = fillType === 'solid' ? !!shapeStyle.fill : fillType === 'gradient' ? !!shapeStyle.gradient : fillType === 'noise' ? !!shapeStyle.noise : false; + + if (hasFill) { + ctx.globalAlpha *= shapeStyle.fillOpacity; + + if (fillType === 'noise' && shapeStyle.noise) { + ctx.fillStyle = shapeStyle.noise.baseColor; + ctx.fill(); + + ctx.save(); + ctx.clip(); + + const { noise } = shapeStyle; + const noiseSize = noise.size; + const density = noise.density; + + ctx.fillStyle = noise.noiseColor; + for (let y = 0; y < height; y += noiseSize) { + for (let x = 0; x < width; x += noiseSize) { + if (Math.random() < density) { + ctx.fillRect(x, y, noiseSize, noiseSize); + } + } + } + + ctx.restore(); + } else if (fillType === 'gradient' && shapeStyle.gradient) { + let gradient: CanvasGradient; + if (shapeStyle.gradient.type === 'linear') { + const angleRad = (shapeStyle.gradient.angle * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const halfW = width / 2; + const halfH = height / 2; + const len = Math.abs(halfW * cos) + Math.abs(halfH * sin); + gradient = ctx.createLinearGradient( + halfW - len * cos, + halfH - len * sin, + halfW + len * cos, + halfH + len * sin + ); + } else { + const radius = Math.max(width, height) / 2; + gradient = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, radius); + } + shapeStyle.gradient.stops.forEach((stop) => { + gradient.addColorStop(stop.offset, stop.color); + }); + ctx.fillStyle = gradient; + ctx.fill(); + } else if (shapeStyle.fill) { + ctx.fillStyle = shapeStyle.fill; + ctx.fill(); + } + + ctx.globalAlpha /= shapeStyle.fillOpacity; + } + + if (shapeStyle.stroke) { + ctx.strokeStyle = shapeStyle.stroke; + ctx.lineWidth = shapeStyle.strokeWidth; + ctx.globalAlpha *= shapeStyle.strokeOpacity; + + const sw = shapeStyle.strokeWidth; + switch (shapeStyle.strokeDash ?? 'solid') { + case 'dashed': + ctx.setLineDash([sw * 3, sw * 2]); + break; + case 'dotted': + ctx.setLineDash([sw, sw * 2]); + ctx.lineCap = 'round'; + break; + case 'dash-dot': + ctx.setLineDash([sw * 4, sw * 2, sw, sw * 2]); + break; + case 'long-dash': + ctx.setLineDash([sw * 6, sw * 3]); + break; + default: + ctx.setLineDash([]); + } + + ctx.stroke(); + ctx.setLineDash([]); + } + + if (flipH || flipV) { + ctx.restore(); + } +} diff --git a/services/editor/apps/image/src/components/editor/canvas/ContextMenu.tsx b/services/editor/apps/image/src/components/editor/canvas/ContextMenu.tsx new file mode 100644 index 0000000..ee4df9d --- /dev/null +++ b/services/editor/apps/image/src/components/editor/canvas/ContextMenu.tsx @@ -0,0 +1,363 @@ +import { useEffect, useRef } from 'react'; +import { + Copy, + Clipboard, + Scissors, + Trash2, + Eye, + EyeOff, + Lock, + Unlock, + ArrowUpToLine, + ArrowDownToLine, + ChevronUp, + ChevronDown, + FlipHorizontal, + FlipVertical, + RotateCcw, + FolderPlus, + FolderOpen, + Type, + Square, + Circle, + Triangle, + Star, + Hexagon, + Minus, + Grid3X3, + Ruler, + ZoomIn, + ZoomOut, + Maximize, + AlignLeft, + AlignCenter, + AlignRight, + AlignStartVertical, + AlignCenterVertical, + AlignEndVertical, + Paintbrush, + MousePointer, +} from 'lucide-react'; + +export interface ContextMenuPosition { + x: number; + y: number; +} + +export type ContextMenuType = 'layer' | 'multi-layer' | 'canvas' | 'group'; + +interface MenuItem { + label: string; + icon?: React.ReactNode; + shortcut?: string; + action: () => void; + disabled?: boolean; + divider?: boolean; + submenu?: MenuItem[]; +} + +interface ContextMenuProps { + position: ContextMenuPosition; + type: ContextMenuType; + onClose: () => void; + onCut: () => void; + onCopy: () => void; + onPaste: () => void; + onDuplicate: () => void; + onDelete: () => void; + onSelectAll: () => void; + onToggleVisibility: () => void; + onToggleLock: () => void; + onBringToFront: () => void; + onBringForward: () => void; + onSendBackward: () => void; + onSendToBack: () => void; + onGroup: () => void; + onUngroup: () => void; + onFlipHorizontal: () => void; + onFlipVertical: () => void; + onResetTransform: () => void; + onCopyStyle: () => void; + onPasteStyle: () => void; + onAddText: () => void; + onAddShape: (type: 'rectangle' | 'ellipse' | 'triangle' | 'star' | 'polygon' | 'line') => void; + onToggleGrid: () => void; + onToggleRulers: () => void; + onZoomIn: () => void; + onZoomOut: () => void; + onZoomFit: () => void; + onAlignLeft: () => void; + onAlignCenter: () => void; + onAlignRight: () => void; + onAlignTop: () => void; + onAlignMiddle: () => void; + onAlignBottom: () => void; + isVisible: boolean; + isLocked: boolean; + showGrid: boolean; + showRulers: boolean; + hasClipboard: boolean; + hasStyleClipboard: boolean; + selectedCount: number; +} + +export function ContextMenu({ + position, + type, + onClose, + onCut, + onCopy, + onPaste, + onDuplicate, + onDelete, + onSelectAll, + onToggleVisibility, + onToggleLock, + onBringToFront, + onBringForward, + onSendBackward, + onSendToBack, + onGroup, + onUngroup, + onFlipHorizontal, + onFlipVertical, + onResetTransform, + onCopyStyle, + onPasteStyle, + onAddText, + onAddShape, + onToggleGrid, + onToggleRulers, + onZoomIn, + onZoomOut, + onZoomFit, + onAlignLeft, + onAlignCenter, + onAlignRight, + onAlignTop, + onAlignMiddle, + onAlignBottom, + isVisible, + isLocked, + showGrid, + showRulers, + hasClipboard, + hasStyleClipboard, + selectedCount, +}: ContextMenuProps) { + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [onClose]); + + useEffect(() => { + if (menuRef.current) { + const rect = menuRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let adjustedX = position.x; + let adjustedY = position.y; + + if (position.x + rect.width > viewportWidth) { + adjustedX = viewportWidth - rect.width - 8; + } + if (position.y + rect.height > viewportHeight) { + adjustedY = viewportHeight - rect.height - 8; + } + + menuRef.current.style.left = `${adjustedX}px`; + menuRef.current.style.top = `${adjustedY}px`; + } + }, [position]); + + const getMenuItems = (): MenuItem[] => { + if (type === 'canvas') { + return [ + { label: 'Paste', icon: , shortcut: '⌘V', action: onPaste, disabled: !hasClipboard }, + { label: 'Paste Style', icon: , shortcut: '⌘⇧V', action: onPasteStyle, disabled: !hasStyleClipboard }, + { label: '', action: () => {}, divider: true }, + { label: 'Select All', icon: , shortcut: '⌘A', action: onSelectAll }, + { label: '', action: () => {}, divider: true }, + { label: 'Add Text', icon: , shortcut: 'T', action: onAddText }, + { + label: 'Add Shape', + icon: , + action: () => {}, + submenu: [ + { label: 'Rectangle', icon: , action: () => onAddShape('rectangle') }, + { label: 'Ellipse', icon: , action: () => onAddShape('ellipse') }, + { label: 'Triangle', icon: , action: () => onAddShape('triangle') }, + { label: 'Star', icon: , action: () => onAddShape('star') }, + { label: 'Polygon', icon: , action: () => onAddShape('polygon') }, + { label: 'Line', icon: , action: () => onAddShape('line') }, + ], + }, + { label: '', action: () => {}, divider: true }, + { label: showGrid ? 'Hide Grid' : 'Show Grid', icon: , shortcut: "⌘'", action: onToggleGrid }, + { label: showRulers ? 'Hide Rulers' : 'Show Rulers', icon: , shortcut: '⌘R', action: onToggleRulers }, + { label: '', action: () => {}, divider: true }, + { label: 'Zoom In', icon: , shortcut: '⌘+', action: onZoomIn }, + { label: 'Zoom Out', icon: , shortcut: '⌘-', action: onZoomOut }, + { label: 'Zoom to Fit', icon: , shortcut: '⌘0', action: onZoomFit }, + ]; + } + + if (type === 'multi-layer') { + return [ + { label: 'Cut', icon: , shortcut: '⌘X', action: onCut }, + { label: 'Copy', icon: , shortcut: '⌘C', action: onCopy }, + { label: 'Paste', icon: , shortcut: '⌘V', action: onPaste, disabled: !hasClipboard }, + { label: 'Duplicate', icon: , shortcut: '⌘D', action: onDuplicate }, + { label: 'Delete', icon: , shortcut: '⌫', action: onDelete }, + { label: '', action: () => {}, divider: true }, + { label: `Group ${selectedCount} Layers`, icon: , shortcut: '⌘G', action: onGroup }, + { label: '', action: () => {}, divider: true }, + { + label: 'Align', + icon: , + action: () => {}, + submenu: [ + { label: 'Align Left', icon: , action: onAlignLeft }, + { label: 'Align Center', icon: , action: onAlignCenter }, + { label: 'Align Right', icon: , action: onAlignRight }, + { label: '', action: () => {}, divider: true }, + { label: 'Align Top', icon: , action: onAlignTop }, + { label: 'Align Middle', icon: , action: onAlignMiddle }, + { label: 'Align Bottom', icon: , action: onAlignBottom }, + ], + }, + { label: '', action: () => {}, divider: true }, + { label: 'Bring to Front', icon: , shortcut: '⌘⇧]', action: onBringToFront }, + { label: 'Send to Back', icon: , shortcut: '⌘⇧[', action: onSendToBack }, + ]; + } + + if (type === 'group') { + return [ + { label: 'Cut', icon: , shortcut: '⌘X', action: onCut }, + { label: 'Copy', icon: , shortcut: '⌘C', action: onCopy }, + { label: 'Paste', icon: , shortcut: '⌘V', action: onPaste, disabled: !hasClipboard }, + { label: 'Duplicate', icon: , shortcut: '⌘D', action: onDuplicate }, + { label: 'Delete', icon: , shortcut: '⌫', action: onDelete }, + { label: '', action: () => {}, divider: true }, + { label: 'Ungroup', icon: , shortcut: '⌘⇧G', action: onUngroup }, + { label: '', action: () => {}, divider: true }, + { label: isVisible ? 'Hide' : 'Show', icon: isVisible ? : , shortcut: '⌘⇧H', action: onToggleVisibility }, + { label: isLocked ? 'Unlock' : 'Lock', icon: isLocked ? : , shortcut: '⌘⇧L', action: onToggleLock }, + { label: '', action: () => {}, divider: true }, + { label: 'Bring to Front', icon: , shortcut: '⌘⇧]', action: onBringToFront }, + { label: 'Bring Forward', icon: , shortcut: '⌘]', action: onBringForward }, + { label: 'Send Backward', icon: , shortcut: '⌘[', action: onSendBackward }, + { label: 'Send to Back', icon: , shortcut: '⌘⇧[', action: onSendToBack }, + { label: '', action: () => {}, divider: true }, + { label: 'Flip Horizontal', icon: , action: onFlipHorizontal }, + { label: 'Flip Vertical', icon: , action: onFlipVertical }, + { label: 'Reset Transform', icon: , action: onResetTransform }, + ]; + } + + return [ + { label: 'Cut', icon: , shortcut: '⌘X', action: onCut }, + { label: 'Copy', icon: , shortcut: '⌘C', action: onCopy }, + { label: 'Paste', icon: , shortcut: '⌘V', action: onPaste, disabled: !hasClipboard }, + { label: 'Duplicate', icon: , shortcut: '⌘D', action: onDuplicate }, + { label: 'Delete', icon: , shortcut: '⌫', action: onDelete }, + { label: '', action: () => {}, divider: true }, + { label: 'Copy Style', icon: , shortcut: '⌘⌥C', action: onCopyStyle }, + { label: 'Paste Style', icon: , shortcut: '⌘⌥V', action: onPasteStyle, disabled: !hasStyleClipboard }, + { label: '', action: () => {}, divider: true }, + { label: isVisible ? 'Hide' : 'Show', icon: isVisible ? : , shortcut: '⌘⇧H', action: onToggleVisibility }, + { label: isLocked ? 'Unlock' : 'Lock', icon: isLocked ? : , shortcut: '⌘⇧L', action: onToggleLock }, + { label: '', action: () => {}, divider: true }, + { label: 'Bring to Front', icon: , shortcut: '⌘⇧]', action: onBringToFront }, + { label: 'Bring Forward', icon: , shortcut: '⌘]', action: onBringForward }, + { label: 'Send Backward', icon: , shortcut: '⌘[', action: onSendBackward }, + { label: 'Send to Back', icon: , shortcut: '⌘⇧[', action: onSendToBack }, + { label: '', action: () => {}, divider: true }, + { label: 'Flip Horizontal', icon: , action: onFlipHorizontal }, + { label: 'Flip Vertical', icon: , action: onFlipVertical }, + { label: 'Reset Transform', icon: , action: onResetTransform }, + ]; + }; + + const renderMenuItem = (item: MenuItem, index: number) => { + if (item.divider) { + return
; + } + + if (item.submenu) { + return ( +
+ +
+
+ {item.submenu.map((subItem, subIndex) => renderMenuItem(subItem, subIndex))} +
+
+
+ ); + } + + return ( + + ); + }; + + const menuItems = getMenuItems(); + + return ( +
e.preventDefault()} + > + {menuItems.map((item, index) => renderMenuItem(item, index))} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/canvas/Rulers.tsx b/services/editor/apps/image/src/components/editor/canvas/Rulers.tsx new file mode 100644 index 0000000..1c5acb2 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/canvas/Rulers.tsx @@ -0,0 +1,206 @@ +import { useEffect, useRef } from 'react'; +import { useUIStore } from '../../../stores/ui-store'; +import { useProjectStore } from '../../../stores/project-store'; + +const RULER_SIZE = 20; +const RULER_BG = '#1f1f23'; +const RULER_TEXT = '#71717a'; +const RULER_TICK = '#3f3f46'; +const RULER_HIGHLIGHT = '#3b82f6'; + +interface RulersProps { + containerWidth: number; + containerHeight: number; +} + +export function Rulers({ containerWidth, containerHeight }: RulersProps) { + const horizontalRef = useRef(null); + const verticalRef = useRef(null); + const cornerRef = useRef(null); + + const { zoom, panX, panY, showRulers } = useUIStore(); + const { project, selectedArtboardId } = useProjectStore(); + + const artboard = project?.artboards.find((a) => a.id === selectedArtboardId); + + useEffect(() => { + if (!showRulers || !artboard) return; + if (containerWidth <= RULER_SIZE || containerHeight <= RULER_SIZE) return; + + const hCanvas = horizontalRef.current; + const vCanvas = verticalRef.current; + if (!hCanvas || !vCanvas) return; + + const hCtx = hCanvas.getContext('2d'); + const vCtx = vCanvas.getContext('2d'); + if (!hCtx || !vCtx) return; + + hCanvas.width = containerWidth - RULER_SIZE; + hCanvas.height = RULER_SIZE; + vCanvas.width = RULER_SIZE; + vCanvas.height = containerHeight - RULER_SIZE; + + const centerX = containerWidth / 2 + panX; + const centerY = containerHeight / 2 + panY; + const artboardX = centerX - (artboard.size.width * zoom) / 2; + const artboardY = centerY - (artboard.size.height * zoom) / 2; + + renderHorizontalRuler(hCtx, containerWidth, artboardX, artboard.size.width, zoom); + renderVerticalRuler(vCtx, containerHeight, artboardY, artboard.size.height, zoom); + }, [containerWidth, containerHeight, zoom, panX, panY, showRulers, artboard]); + + if (!showRulers) return null; + + return ( + <> +
+ + + + ); +} + +function getTickInterval(zoom: number): { major: number; minor: number } { + const baseUnit = 100; + const scaledUnit = baseUnit / zoom; + + if (scaledUnit < 50) return { major: 50, minor: 10 }; + if (scaledUnit < 100) return { major: 100, minor: 20 }; + if (scaledUnit < 200) return { major: 100, minor: 25 }; + if (scaledUnit < 500) return { major: 200, minor: 50 }; + if (scaledUnit < 1000) return { major: 500, minor: 100 }; + return { major: 1000, minor: 200 }; +} + +function renderHorizontalRuler( + ctx: CanvasRenderingContext2D, + width: number, + artboardX: number, + artboardWidth: number, + zoom: number +) { + ctx.fillStyle = RULER_BG; + ctx.fillRect(0, 0, width, RULER_SIZE); + + const { major, minor } = getTickInterval(zoom); + + ctx.strokeStyle = RULER_TICK; + ctx.fillStyle = RULER_TEXT; + ctx.font = '9px Inter, system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + + const startX = -Math.ceil(artboardX / (minor * zoom)) * minor; + const endX = artboardWidth + Math.ceil((width - artboardX - artboardWidth * zoom) / (minor * zoom)) * minor; + + for (let i = startX; i <= endX; i += minor) { + const screenX = artboardX + i * zoom - RULER_SIZE; + if (screenX < 0 || screenX > width) continue; + + const isMajor = i % major === 0; + const tickHeight = isMajor ? 12 : 6; + + ctx.beginPath(); + ctx.moveTo(screenX, RULER_SIZE); + ctx.lineTo(screenX, RULER_SIZE - tickHeight); + ctx.stroke(); + + if (isMajor) { + ctx.fillText(String(i), screenX, 2); + } + } + + const artboardStart = artboardX - RULER_SIZE; + const artboardEnd = artboardX + artboardWidth * zoom - RULER_SIZE; + + ctx.strokeStyle = RULER_HIGHLIGHT; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(Math.max(0, artboardStart), RULER_SIZE - 1); + ctx.lineTo(Math.min(width, artboardEnd), RULER_SIZE - 1); + ctx.stroke(); + ctx.lineWidth = 1; +} + +function renderVerticalRuler( + ctx: CanvasRenderingContext2D, + height: number, + artboardY: number, + artboardHeight: number, + zoom: number +) { + ctx.fillStyle = RULER_BG; + ctx.fillRect(0, 0, RULER_SIZE, height); + + const { major, minor } = getTickInterval(zoom); + + ctx.strokeStyle = RULER_TICK; + ctx.fillStyle = RULER_TEXT; + ctx.font = '9px Inter, system-ui, sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + + const startY = -Math.ceil(artboardY / (minor * zoom)) * minor; + const endY = artboardHeight + Math.ceil((height - artboardY - artboardHeight * zoom) / (minor * zoom)) * minor; + + for (let i = startY; i <= endY; i += minor) { + const screenY = artboardY + i * zoom - RULER_SIZE; + if (screenY < 0 || screenY > height) continue; + + const isMajor = i % major === 0; + const tickWidth = isMajor ? 12 : 6; + + ctx.beginPath(); + ctx.moveTo(RULER_SIZE, screenY); + ctx.lineTo(RULER_SIZE - tickWidth, screenY); + ctx.stroke(); + + if (isMajor) { + ctx.save(); + ctx.translate(10, screenY); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = 'center'; + ctx.fillText(String(i), 0, 0); + ctx.restore(); + } + } + + const artboardStart = artboardY - RULER_SIZE; + const artboardEnd = artboardY + artboardHeight * zoom - RULER_SIZE; + + ctx.strokeStyle = RULER_HIGHLIGHT; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(RULER_SIZE - 1, Math.max(0, artboardStart)); + ctx.lineTo(RULER_SIZE - 1, Math.min(height, artboardEnd)); + ctx.stroke(); + ctx.lineWidth = 1; +} diff --git a/services/editor/apps/image/src/components/editor/inspector/AlignmentSection.tsx b/services/editor/apps/image/src/components/editor/inspector/AlignmentSection.tsx new file mode 100644 index 0000000..0d30fb5 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/AlignmentSection.tsx @@ -0,0 +1,240 @@ +import { useProjectStore } from '../../../stores/project-store'; +import type { Layer } from '../../../types/project'; +import { + AlignHorizontalJustifyStart, + AlignHorizontalJustifyCenter, + AlignHorizontalJustifyEnd, + AlignVerticalJustifyStart, + AlignVerticalJustifyCenter, + AlignVerticalJustifyEnd, + AlignHorizontalSpaceBetween, + AlignVerticalSpaceBetween, +} from 'lucide-react'; + +interface Props { + layers: Layer[]; +} + +export function AlignmentSection({ layers }: Props) { + const { project, selectedArtboardId, updateLayerTransform } = useProjectStore(); + + const artboard = project?.artboards.find((a) => a.id === selectedArtboardId); + + if (!artboard || layers.length === 0) return null; + + const alignLeft = () => { + if (layers.length === 1) { + updateLayerTransform(layers[0].id, { x: 0 }); + } else { + const minX = Math.min(...layers.map((l) => l.transform.x)); + layers.forEach((layer) => { + updateLayerTransform(layer.id, { x: minX }); + }); + } + }; + + const alignCenterH = () => { + if (layers.length === 1) { + const layer = layers[0]; + updateLayerTransform(layer.id, { + x: (artboard.size.width - layer.transform.width) / 2, + }); + } else { + const bounds = getBounds(layers); + const centerX = bounds.x + bounds.width / 2; + layers.forEach((layer) => { + updateLayerTransform(layer.id, { + x: centerX - layer.transform.width / 2, + }); + }); + } + }; + + const alignRight = () => { + if (layers.length === 1) { + const layer = layers[0]; + updateLayerTransform(layer.id, { + x: artboard.size.width - layer.transform.width, + }); + } else { + const maxRight = Math.max(...layers.map((l) => l.transform.x + l.transform.width)); + layers.forEach((layer) => { + updateLayerTransform(layer.id, { + x: maxRight - layer.transform.width, + }); + }); + } + }; + + const alignTop = () => { + if (layers.length === 1) { + updateLayerTransform(layers[0].id, { y: 0 }); + } else { + const minY = Math.min(...layers.map((l) => l.transform.y)); + layers.forEach((layer) => { + updateLayerTransform(layer.id, { y: minY }); + }); + } + }; + + const alignCenterV = () => { + if (layers.length === 1) { + const layer = layers[0]; + updateLayerTransform(layer.id, { + y: (artboard.size.height - layer.transform.height) / 2, + }); + } else { + const bounds = getBounds(layers); + const centerY = bounds.y + bounds.height / 2; + layers.forEach((layer) => { + updateLayerTransform(layer.id, { + y: centerY - layer.transform.height / 2, + }); + }); + } + }; + + const alignBottom = () => { + if (layers.length === 1) { + const layer = layers[0]; + updateLayerTransform(layer.id, { + y: artboard.size.height - layer.transform.height, + }); + } else { + const maxBottom = Math.max(...layers.map((l) => l.transform.y + l.transform.height)); + layers.forEach((layer) => { + updateLayerTransform(layer.id, { + y: maxBottom - layer.transform.height, + }); + }); + } + }; + + const distributeH = () => { + if (layers.length < 3) return; + + const sorted = [...layers].sort((a, b) => a.transform.x - b.transform.x); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + const totalWidth = last.transform.x + last.transform.width - first.transform.x; + const layersWidth = sorted.reduce((sum, l) => sum + l.transform.width, 0); + const gap = (totalWidth - layersWidth) / (sorted.length - 1); + + let x = first.transform.x; + sorted.forEach((layer) => { + updateLayerTransform(layer.id, { x }); + x += layer.transform.width + gap; + }); + }; + + const distributeV = () => { + if (layers.length < 3) return; + + const sorted = [...layers].sort((a, b) => a.transform.y - b.transform.y); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + const totalHeight = last.transform.y + last.transform.height - first.transform.y; + const layersHeight = sorted.reduce((sum, l) => sum + l.transform.height, 0); + const gap = (totalHeight - layersHeight) / (sorted.length - 1); + + let y = first.transform.y; + sorted.forEach((layer) => { + updateLayerTransform(layer.id, { y }); + y += layer.transform.height + gap; + }); + }; + + const isSingleLayer = layers.length === 1; + + return ( +
+

+ Alignment +

+ +
+ } + onClick={alignLeft} + title={isSingleLayer ? 'Align to canvas left' : 'Align left edges'} + /> + } + onClick={alignCenterH} + title={isSingleLayer ? 'Center horizontally on canvas' : 'Align horizontal centers'} + /> + } + onClick={alignRight} + title={isSingleLayer ? 'Align to canvas right' : 'Align right edges'} + /> + } + onClick={alignTop} + title={isSingleLayer ? 'Align to canvas top' : 'Align top edges'} + /> + } + onClick={alignCenterV} + title={isSingleLayer ? 'Center vertically on canvas' : 'Align vertical centers'} + /> + } + onClick={alignBottom} + title={isSingleLayer ? 'Align to canvas bottom' : 'Align bottom edges'} + /> +
+ + {layers.length >= 3 && ( +
+ } + onClick={distributeH} + title="Distribute horizontally" + label="Distribute H" + /> + } + onClick={distributeV} + title="Distribute vertically" + label="Distribute V" + /> +
+ )} +
+ ); +} + +interface AlignButtonProps { + icon: React.ReactNode; + onClick: () => void; + title: string; + label?: string; +} + +function AlignButton({ icon, onClick, title, label }: AlignButtonProps) { + return ( + + ); +} + +function getBounds(layers: Layer[]): { x: number; y: number; width: number; height: number } { + const minX = Math.min(...layers.map((l) => l.transform.x)); + const minY = Math.min(...layers.map((l) => l.transform.y)); + const maxX = Math.max(...layers.map((l) => l.transform.x + l.transform.width)); + const maxY = Math.max(...layers.map((l) => l.transform.y + l.transform.height)); + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; +} diff --git a/services/editor/apps/image/src/components/editor/inspector/AppearanceSection.tsx b/services/editor/apps/image/src/components/editor/inspector/AppearanceSection.tsx new file mode 100644 index 0000000..cc28b18 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/AppearanceSection.tsx @@ -0,0 +1,180 @@ +import { useProjectStore } from '../../../stores/project-store'; +import type { Layer, BlendMode } from '../../../types/project'; + +interface Props { + layer: Layer; +} + +const BLEND_MODES: BlendMode['mode'][] = [ + 'normal', + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'exclusion', +]; + +export function AppearanceSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + + const handleBlendModeChange = (mode: BlendMode['mode']) => { + updateLayer(layer.id, { blendMode: { mode } }); + }; + + const handleShadowToggle = () => { + updateLayer(layer.id, { + shadow: { ...layer.shadow, enabled: !layer.shadow.enabled }, + }); + }; + + const handleShadowChange = (key: string, value: string | number) => { + updateLayer(layer.id, { + shadow: { ...layer.shadow, [key]: value }, + }); + }; + + const handleStrokeToggle = () => { + updateLayer(layer.id, { + stroke: { ...layer.stroke, enabled: !layer.stroke.enabled }, + }); + }; + + const handleStrokeChange = (key: string, value: string | number) => { + updateLayer(layer.id, { + stroke: { ...layer.stroke, [key]: value }, + }); + }; + + return ( +
+
+ + +
+ +
+
+ + +
+ + {layer.shadow.enabled && ( +
+
+ + handleShadowChange('color', e.target.value)} + className="w-6 h-6 rounded border border-input cursor-pointer" + /> +
+
+ + handleShadowChange('blur', Number(e.target.value))} + min={0} + max={50} + className="flex-1 h-1 accent-primary" + /> + + {layer.shadow.blur} + +
+
+ + handleShadowChange('offsetX', Number(e.target.value))} + className="w-16 px-2 py-1 text-xs bg-background border border-input rounded-md" + /> + + handleShadowChange('offsetY', Number(e.target.value))} + className="w-16 px-2 py-1 text-xs bg-background border border-input rounded-md" + /> +
+
+ )} +
+ +
+
+ + +
+ + {layer.stroke.enabled && ( +
+
+ + handleStrokeChange('color', e.target.value)} + className="w-6 h-6 rounded border border-input cursor-pointer" + /> +
+
+ + handleStrokeChange('width', Number(e.target.value))} + min={1} + max={20} + className="flex-1 h-1 accent-primary" + /> + + {layer.stroke.width} + +
+
+ )} +
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/ArtboardSection.tsx b/services/editor/apps/image/src/components/editor/inspector/ArtboardSection.tsx new file mode 100644 index 0000000..f196b2d --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/ArtboardSection.tsx @@ -0,0 +1,136 @@ +import { useProjectStore } from '../../../stores/project-store'; +import type { Artboard, CanvasBackground } from '../../../types/project'; + +interface Props { + artboard: Artboard; +} + +export function ArtboardSection({ artboard }: Props) { + const { updateArtboard } = useProjectStore(); + + const handleSizeChange = (key: 'width' | 'height', value: number) => { + updateArtboard(artboard.id, { + size: { ...artboard.size, [key]: value }, + }); + }; + + const handleBackgroundTypeChange = (type: CanvasBackground['type']) => { + let background: CanvasBackground; + switch (type) { + case 'color': + background = { type: 'color', color: '#ffffff' }; + break; + case 'transparent': + background = { type: 'transparent' }; + break; + case 'gradient': + background = { + type: 'gradient', + gradient: { + type: 'linear', + angle: 180, + stops: [ + { offset: 0, color: '#ffffff' }, + { offset: 1, color: '#000000' }, + ], + }, + }; + break; + default: + background = { type: 'color', color: '#ffffff' }; + } + updateArtboard(artboard.id, { background }); + }; + + const handleBackgroundColorChange = (color: string) => { + updateArtboard(artboard.id, { + background: { type: 'color', color }, + }); + }; + + return ( +
+
+ + updateArtboard(artboard.id, { name: e.target.value })} + className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+ +
+
+ + handleSizeChange('width', Number(e.target.value))} + className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary" + min={1} + max={8000} + /> +
+
+ + handleSizeChange('height', Number(e.target.value))} + className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary" + min={1} + max={8000} + /> +
+
+ +
+ + + + {artboard.background.type === 'color' && ( +
+ handleBackgroundColorChange(e.target.value)} + className="w-8 h-8 rounded border border-input cursor-pointer" + /> + handleBackgroundColorChange(e.target.value)} + className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono" + /> +
+ )} + + {artboard.background.type === 'transparent' && ( +
+
+

+ Transparency pattern +

+
+ )} +
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/BackgroundRemovalSection.tsx b/services/editor/apps/image/src/components/editor/inspector/BackgroundRemovalSection.tsx new file mode 100644 index 0000000..28daff2 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/BackgroundRemovalSection.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react'; +import { Wand2, Loader2 } from 'lucide-react'; +import { Slider } from '@openreel/ui'; +import { useProjectStore } from '../../../stores/project-store'; +import type { ImageLayer } from '../../../types/project'; +import { + getBackgroundRemovalService, + BackgroundMode, + DEFAULT_OPTIONS, +} from '../../../services/background-removal-service'; + +interface Props { + layer: ImageLayer; +} + +export function BackgroundRemovalSection({ layer }: Props) { + const { project, addAsset, updateLayer } = useProjectStore(); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const [mode, setMode] = useState('transparent'); + const [backgroundColor, setBackgroundColor] = useState(DEFAULT_OPTIONS.backgroundColor!); + const [blurAmount, setBlurAmount] = useState(DEFAULT_OPTIONS.blurAmount!); + + const asset = project?.assets[layer.sourceId]; + + const handleRemoveBackground = async () => { + if (!asset?.dataUrl && !asset?.thumbnailUrl) return; + + setIsProcessing(true); + setProgress(0); + + try { + const service = getBackgroundRemovalService(); + const imageUrl = asset.dataUrl || asset.thumbnailUrl; + + const resultDataUrl = await service.removeBackground( + imageUrl, + { + mode, + backgroundColor, + blurAmount, + }, + setProgress + ); + + const newAssetId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + addAsset({ + id: newAssetId, + name: `${asset.name} (no bg)`, + type: 'image', + mimeType: 'image/png', + size: 0, + width: asset.width, + height: asset.height, + thumbnailUrl: resultDataUrl, + dataUrl: resultDataUrl, + }); + + updateLayer(layer.id, { sourceId: newAssetId }); + } catch (error) { + console.error('Background removal failed:', error); + } finally { + setIsProcessing(false); + setProgress(0); + } + }; + + return ( +
+

+ Background Removal +

+ +
+
+ +
+ {(['transparent', 'color', 'blur'] as BackgroundMode[]).map((m) => ( + + ))} +
+
+ + {mode === 'color' && ( +
+ + setBackgroundColor(e.target.value)} + className="w-8 h-8 rounded border border-input cursor-pointer" + /> + setBackgroundColor(e.target.value)} + className="flex-1 px-2 py-1 text-xs bg-background border border-input rounded-md font-mono" + /> +
+ )} + + {mode === 'blur' && ( +
+
+ + {blurAmount}px +
+ setBlurAmount(v)} + min={5} + max={30} + step={1} + /> +
+ )} + + {isProcessing && ( +
+
+
+
+

+ {progress < 15 ? 'Loading AI model...' : + progress < 90 ? 'Analyzing image...' : + 'Finalizing...'} + {' '}{Math.round(progress)}% +

+
+ )} + + + +

+ AI-powered background removal for any image +

+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/BlackWhiteSection.tsx b/services/editor/apps/image/src/components/editor/inspector/BlackWhiteSection.tsx new file mode 100644 index 0000000..0096e44 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/BlackWhiteSection.tsx @@ -0,0 +1,226 @@ +import { useState } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import type { Layer } from '../../../types/project'; +import type { BlackWhiteAdjustment } from '../../../types/adjustments'; +import { DEFAULT_BLACK_WHITE } from '../../../types/adjustments'; +import { BLACK_WHITE_PRESETS } from '../../../adjustments/black-white'; +import { SunMoon, RotateCcw } from 'lucide-react'; + +interface Props { + layer: Layer; +} + +const COLOR_SLIDERS: { key: keyof BlackWhiteAdjustment; label: string; color: string }[] = [ + { key: 'reds', label: 'Reds', color: 'bg-red-500' }, + { key: 'yellows', label: 'Yellows', color: 'bg-yellow-500' }, + { key: 'greens', label: 'Greens', color: 'bg-green-500' }, + { key: 'cyans', label: 'Cyans', color: 'bg-cyan-500' }, + { key: 'blues', label: 'Blues', color: 'bg-blue-500' }, + { key: 'magentas', label: 'Magentas', color: 'bg-pink-500' }, +]; + +const PRESET_OPTIONS = [ + { id: 'default', label: 'Default' }, + { id: 'highContrast', label: 'High Contrast' }, + { id: 'infrared', label: 'Infrared' }, + { id: 'maximumBlack', label: 'Maximum Black' }, + { id: 'maximumWhite', label: 'Maximum White' }, + { id: 'neutralDensity', label: 'Neutral Density' }, + { id: 'redFilter', label: 'Red Filter' }, + { id: 'yellowFilter', label: 'Yellow Filter' }, + { id: 'greenFilter', label: 'Green Filter' }, + { id: 'blueFilter', label: 'Blue Filter' }, +] as const; + +function ChannelSlider({ label, color, value, onChange }: { label: string; color: string; value: number; onChange: (v: number) => void }) { + const percentage = ((value + 200) / 400) * 100; + + return ( +
+
+
+ + {label} +
+ {value}% +
+ onChange(Number(e.target.value))} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-2.5 + [&::-webkit-slider-thumb]:h-2.5 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:shadow-sm + [&::-webkit-slider-thumb]:cursor-pointer" + style={{ + background: `linear-gradient(to right, hsl(var(--secondary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)` + }} + /> +
+ ); +} + +export function BlackWhiteSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + const [isExpanded, setIsExpanded] = useState(false); + + const blackWhite = layer.blackWhite; + + const handleValueChange = (key: keyof BlackWhiteAdjustment, value: number | boolean) => { + updateLayer(layer.id, { + blackWhite: { + ...blackWhite, + [key]: value, + }, + }); + }; + + const handlePresetChange = (presetId: string) => { + const preset = BLACK_WHITE_PRESETS[presetId as keyof typeof BLACK_WHITE_PRESETS]; + if (preset) { + updateLayer(layer.id, { + blackWhite: { + ...blackWhite, + ...preset, + }, + }); + } + }; + + const handleEnabledChange = (enabled: boolean) => { + updateLayer(layer.id, { + blackWhite: { + ...blackWhite, + enabled, + }, + }); + }; + + const resetBlackWhite = () => { + updateLayer(layer.id, { + blackWhite: { ...DEFAULT_BLACK_WHITE }, + }); + }; + + return ( +
+ + + {isExpanded && ( +
+
+ + +
+ +
+ {COLOR_SLIDERS.map(({ key, label, color }) => ( + handleValueChange(key, v)} + /> + ))} +
+ +
+ + {blackWhite.tintEnabled && ( +
+
+
+ Hue + {blackWhite.tintHue}° +
+ handleValueChange('tintHue', Number(e.target.value))} + className="w-full h-1.5 appearance-none rounded-full cursor-pointer" + style={{ + background: `linear-gradient(to right, hsl(0, 70%, 50%), hsl(60, 70%, 50%), hsl(120, 70%, 50%), hsl(180, 70%, 50%), hsl(240, 70%, 50%), hsl(300, 70%, 50%), hsl(360, 70%, 50%))` + }} + /> +
+
+
+ Saturation + {blackWhite.tintSaturation}% +
+ handleValueChange('tintSaturation', Number(e.target.value))} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-2.5 + [&::-webkit-slider-thumb]:h-2.5 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:shadow-sm + [&::-webkit-slider-thumb]:cursor-pointer" + /> +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/BlurSharpenToolPanel.tsx b/services/editor/apps/image/src/components/editor/inspector/BlurSharpenToolPanel.tsx new file mode 100644 index 0000000..b96efa6 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/BlurSharpenToolPanel.tsx @@ -0,0 +1,121 @@ +import { useUIStore } from '../../../stores/ui-store'; +import { Droplets, RotateCcw } from 'lucide-react'; + +export function BlurSharpenToolPanel() { + const { blurSharpenSettings, setBlurSharpenSettings } = useUIStore(); + + const resetSettings = () => { + setBlurSharpenSettings({ + size: 30, + strength: 50, + mode: 'blur', + sampleAllLayers: false, + }); + }; + + return ( +
+
+
+ +

+ {blurSharpenSettings.mode === 'blur' ? 'Blur' : 'Sharpen'} Tool +

+
+ +
+ +

+ {blurSharpenSettings.mode === 'blur' + ? 'Paint to blur and soften areas.' + : 'Paint to sharpen and enhance details.'} +

+ +
+
+ Mode +
+ + +
+
+ +
+
+ Size + {blurSharpenSettings.size}px +
+ setBlurSharpenSettings({ size: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Strength + {blurSharpenSettings.strength}% +
+ setBlurSharpenSettings({ strength: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+ +
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/BrushToolPanel.tsx b/services/editor/apps/image/src/components/editor/inspector/BrushToolPanel.tsx new file mode 100644 index 0000000..7416d95 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/BrushToolPanel.tsx @@ -0,0 +1,156 @@ +import { useUIStore } from '../../../stores/ui-store'; +import { Paintbrush, RotateCcw } from 'lucide-react'; + +export function BrushToolPanel() { + const { brushSettings, setBrushSettings } = useUIStore(); + + const resetSettings = () => { + setBrushSettings({ + size: 20, + hardness: 100, + opacity: 1, + flow: 1, + color: '#000000', + blendMode: 'normal', + }); + }; + + return ( +
+
+
+ +

Brush

+
+ +
+ +
+
+
+ Color +
+
+ setBrushSettings({ color: e.target.value })} + className="w-10 h-10 rounded border border-border cursor-pointer" + /> + setBrushSettings({ color: e.target.value })} + className="flex-1 px-2 py-1 text-xs font-mono bg-secondary/50 border border-border rounded" + /> +
+
+ +
+
+ Size + {brushSettings.size}px +
+ setBrushSettings({ size: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Hardness + {brushSettings.hardness}% +
+ setBrushSettings({ hardness: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Opacity + {Math.round(brushSettings.opacity * 100)}% +
+ setBrushSettings({ opacity: Number(e.target.value) / 100 })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Flow + {Math.round(brushSettings.flow * 100)}% +
+ setBrushSettings({ flow: Number(e.target.value) / 100 })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+ Blend Mode +
+ {(['normal', 'multiply', 'screen', 'overlay'] as const).map((mode) => ( + + ))} +
+
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/ChannelMixerSection.tsx b/services/editor/apps/image/src/components/editor/inspector/ChannelMixerSection.tsx new file mode 100644 index 0000000..fffa5fb --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/ChannelMixerSection.tsx @@ -0,0 +1,170 @@ +import { useState } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import type { Layer } from '../../../types/project'; +import type { ChannelMixerAdjustment, ChannelMixerChannel } from '../../../types/adjustments'; +import { DEFAULT_CHANNEL_MIXER } from '../../../types/adjustments'; +import { Blend, RotateCcw } from 'lucide-react'; + +interface Props { + layer: Layer; +} + +type OutputChannel = 'red' | 'green' | 'blue'; + +const CHANNEL_COLORS: Record = { + red: 'bg-red-500', + green: 'bg-green-500', + blue: 'bg-blue-500', +}; + +function ChannelSlider({ label, color, value, onChange }: { label: string; color: string; value: number; onChange: (v: number) => void }) { + const percentage = ((value + 200) / 400) * 100; + + return ( +
+
+
+ + {label} +
+ {value}% +
+ onChange(Number(e.target.value))} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-2.5 + [&::-webkit-slider-thumb]:h-2.5 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:shadow-sm + [&::-webkit-slider-thumb]:cursor-pointer" + style={{ + background: `linear-gradient(to right, hsl(var(--secondary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)` + }} + /> +
+ ); +} + +export function ChannelMixerSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + const [activeChannel, setActiveChannel] = useState('red'); + const [isExpanded, setIsExpanded] = useState(false); + + const channelMixer = layer.channelMixer; + const currentChannel = channelMixer[activeChannel]; + + const handleValueChange = (key: keyof ChannelMixerChannel, value: number) => { + updateLayer(layer.id, { + channelMixer: { + ...channelMixer, + [activeChannel]: { + ...currentChannel, + [key]: value, + }, + } as ChannelMixerAdjustment, + }); + }; + + const handleMonochromeChange = (monochrome: boolean) => { + updateLayer(layer.id, { + channelMixer: { + ...channelMixer, + monochrome, + } as ChannelMixerAdjustment, + }); + }; + + const handleEnabledChange = (enabled: boolean) => { + updateLayer(layer.id, { + channelMixer: { + ...channelMixer, + enabled, + } as ChannelMixerAdjustment, + }); + }; + + const resetChannelMixer = () => { + updateLayer(layer.id, { + channelMixer: { ...DEFAULT_CHANNEL_MIXER }, + }); + }; + + return ( +
+ + + {isExpanded && ( +
+
+
+ {(['red', 'green', 'blue'] as OutputChannel[]).map((channel) => ( + + ))} +
+ +
+ +
+ handleValueChange('red', v)} /> + handleValueChange('green', v)} /> + handleValueChange('blue', v)} /> + handleValueChange('constant', v)} /> +
+ + +
+ )} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/CloneStampToolPanel.tsx b/services/editor/apps/image/src/components/editor/inspector/CloneStampToolPanel.tsx new file mode 100644 index 0000000..0e9e4fd --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/CloneStampToolPanel.tsx @@ -0,0 +1,149 @@ +import { useUIStore } from '../../../stores/ui-store'; +import { Stamp, RotateCcw } from 'lucide-react'; + +export function CloneStampToolPanel() { + const { cloneStampSettings, setCloneStampSettings } = useUIStore(); + + const resetSettings = () => { + setCloneStampSettings({ + size: 30, + hardness: 50, + opacity: 1, + flow: 1, + aligned: true, + sampleAllLayers: false, + sourcePoint: null, + }); + }; + + return ( +
+
+
+ +

Clone Stamp

+
+ +
+ +

+ Hold Alt/Option and click to set source point, then paint to clone. +

+ + {cloneStampSettings.sourcePoint && ( +
+ Source: ({Math.round(cloneStampSettings.sourcePoint.x)}, {Math.round(cloneStampSettings.sourcePoint.y)}) +
+ )} + +
+
+
+ Size + {cloneStampSettings.size}px +
+ setCloneStampSettings({ size: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Hardness + {cloneStampSettings.hardness}% +
+ setCloneStampSettings({ hardness: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Opacity + {Math.round(cloneStampSettings.opacity * 100)}% +
+ setCloneStampSettings({ opacity: Number(e.target.value) / 100 })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Flow + {Math.round(cloneStampSettings.flow * 100)}% +
+ setCloneStampSettings({ flow: Number(e.target.value) / 100 })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/ColorBalanceSection.tsx b/services/editor/apps/image/src/components/editor/inspector/ColorBalanceSection.tsx new file mode 100644 index 0000000..3ea1874 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/ColorBalanceSection.tsx @@ -0,0 +1,211 @@ +import { useState } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import type { Layer } from '../../../types/project'; +import type { ColorBalanceValues } from '../../../types/adjustments'; +import { DEFAULT_COLOR_BALANCE } from '../../../types/adjustments'; +import { Palette, RotateCcw } from 'lucide-react'; + +interface Props { + layer: Layer; +} + +type ToneType = 'shadows' | 'midtones' | 'highlights'; + +interface BalanceSliderProps { + leftLabel: string; + rightLabel: string; + leftColor: string; + rightColor: string; + value: number; + onChange: (value: number) => void; +} + +function BalanceSlider({ + leftLabel, + rightLabel, + leftColor, + rightColor, + value, + onChange, +}: BalanceSliderProps) { + const percentage = ((value + 100) / 200) * 100; + + return ( +
+
+ + {leftLabel} + + {value} + + {rightLabel} + +
+ onChange(Number(e.target.value))} + className="w-full h-1.5 appearance-none rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-2.5 + [&::-webkit-slider-thumb]:h-2.5 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-foreground + [&::-webkit-slider-thumb]:shadow-sm + [&::-webkit-slider-thumb]:cursor-pointer" + style={{ + background: `linear-gradient(to right, ${leftColor} 0%, hsl(var(--secondary)) ${percentage}%, ${rightColor} 100%)`, + }} + /> +
+ ); +} + +export function ColorBalanceSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + const [activeTone, setActiveTone] = useState('midtones'); + const [isExpanded, setIsExpanded] = useState(true); + + const colorBalance = layer.colorBalance; + const currentTone = colorBalance[activeTone]; + + const handleToneChange = (key: keyof ColorBalanceValues, value: number) => { + updateLayer(layer.id, { + colorBalance: { + ...colorBalance, + [activeTone]: { + ...currentTone, + [key]: value, + }, + }, + }); + }; + + const handleEnabledChange = (enabled: boolean) => { + updateLayer(layer.id, { + colorBalance: { + ...colorBalance, + enabled, + }, + }); + }; + + const handlePreserveLuminosityChange = (preserveLuminosity: boolean) => { + updateLayer(layer.id, { + colorBalance: { + ...colorBalance, + preserveLuminosity, + }, + }); + }; + + const resetColorBalance = () => { + updateLayer(layer.id, { + colorBalance: { ...DEFAULT_COLOR_BALANCE }, + }); + }; + + const tones: { id: ToneType; label: string }[] = [ + { id: 'shadows', label: 'Shadows' }, + { id: 'midtones', label: 'Midtones' }, + { id: 'highlights', label: 'Highlights' }, + ]; + + return ( +
+ + + {isExpanded && ( +
+
+
+ {tones.map((tone) => ( + + ))} +
+ +
+ +
+ handleToneChange('cyanRed', v)} + /> + + handleToneChange('magentaGreen', v)} + /> + + handleToneChange('yellowBlue', v)} + /> +
+ + +
+ )} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/ColorHarmonySection.tsx b/services/editor/apps/image/src/components/editor/inspector/ColorHarmonySection.tsx new file mode 100644 index 0000000..186819a --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/ColorHarmonySection.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import { getAllHarmonies, type HarmonyType } from '../../../utils/color-harmony'; +import { Palette, Copy, Check } from 'lucide-react'; +import { ColorPalettes, QuickColorSwatches } from '../../ui/ColorPalettes'; +import { SavedColorsSection } from '../../ui/SavedColorsSection'; +import { useColorStore } from '../../../stores/color-store'; + +interface Props { + baseColor: string; + onColorSelect?: (color: string) => void; +} + +export function ColorHarmonySection({ baseColor, onColorSelect }: Props) { + const [copiedColor, setCopiedColor] = useState(null); + const [selectedHarmony, setSelectedHarmony] = useState('complementary'); + const { addRecentColor } = useColorStore(); + + const isValidHex = /^#[0-9A-Fa-f]{6}$/.test(baseColor); + if (!isValidHex) return null; + + const harmonies = getAllHarmonies(baseColor); + const activeHarmony = harmonies.find((h) => h.type === selectedHarmony) ?? harmonies[0]; + + const handleColorSelect = (color: string) => { + addRecentColor(color); + onColorSelect?.(color); + }; + + const handleCopyColor = async (color: string) => { + try { + await navigator.clipboard.writeText(color); + setCopiedColor(color); + setTimeout(() => setCopiedColor(null), 1500); + } catch { + // Clipboard API not available + } + }; + + return ( +
+
+ +

+ Color Harmony +

+
+ +
+ {harmonies.map((harmony) => ( + + ))} +
+ +
+
+ {activeHarmony.colors.map((color, index) => ( +
+ +
+ ))} +
+ +

+ Click a color to apply, or copy its hex code +

+
+ + {onColorSelect && ( + <> + + + + + )} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/CropSection.tsx b/services/editor/apps/image/src/components/editor/inspector/CropSection.tsx new file mode 100644 index 0000000..998e1f3 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/CropSection.tsx @@ -0,0 +1,308 @@ +import { useCallback, useMemo } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import { useUIStore, CropAspectRatio } from '../../../stores/ui-store'; +import type { ImageLayer } from '../../../types/project'; +import { Crop, Check, X, RotateCcw, Lock, Unlock } from 'lucide-react'; + +const imageCache = new Map(); +function getCachedImage(src: string): HTMLImageElement | null { + if (!src) return null; + if (imageCache.has(src)) return imageCache.get(src)!; + const img = new Image(); + img.src = src; + imageCache.set(src, img); + return img; +} + +interface Props { + layer: ImageLayer; +} + +const ASPECT_RATIOS: { value: CropAspectRatio; label: string; ratio?: number }[] = [ + { value: 'free', label: 'Free' }, + { value: 'original', label: 'Original' }, + { value: '1:1', label: '1:1', ratio: 1 }, + { value: '4:3', label: '4:3', ratio: 4 / 3 }, + { value: '3:4', label: '3:4', ratio: 3 / 4 }, + { value: '16:9', label: '16:9', ratio: 16 / 9 }, + { value: '9:16', label: '9:16', ratio: 9 / 16 }, + { value: '3:2', label: '3:2', ratio: 3 / 2 }, + { value: '2:3', label: '2:3', ratio: 2 / 3 }, +]; + +export function CropSection({ layer }: Props) { + const { updateLayer, project } = useProjectStore(); + const { crop, startCrop, cancelCrop, applyCrop, setCropAspectRatio, updateCropRect, setCropLockAspect } = useUIStore(); + + const lockAspect = crop.lockAspect; + const setLockAspect = setCropLockAspect; + + const isCropping = crop.isActive && crop.layerId === layer.id; + + const imageDimensions = useMemo(() => { + if (!project) return null; + const asset = project.assets[layer.sourceId]; + if (!asset) return null; + const src = asset.blobUrl ?? asset.dataUrl; + if (!src) return null; + const img = getCachedImage(src); + if (img && img.complete && img.naturalWidth > 0) { + return { width: img.naturalWidth, height: img.naturalHeight }; + } + return asset.width && asset.height ? { width: asset.width, height: asset.height } : null; + }, [project, layer.sourceId]); + + const handleStartCrop = useCallback(() => { + const initialRect = layer.cropRect ?? { + x: 0, + y: 0, + width: layer.transform.width, + height: layer.transform.height, + }; + startCrop(layer.id, initialRect); + }, [layer, startCrop]); + + const handleApplyCrop = useCallback(() => { + const result = applyCrop(); + if (result && result.cropRect) { + const existingCropRect = layer.cropRect; + let finalCropRect: { x: number; y: number; width: number; height: number }; + + if (existingCropRect) { + const scaleX = existingCropRect.width / layer.transform.width; + const scaleY = existingCropRect.height / layer.transform.height; + finalCropRect = { + x: existingCropRect.x + result.cropRect.x * scaleX, + y: existingCropRect.y + result.cropRect.y * scaleY, + width: result.cropRect.width * scaleX, + height: result.cropRect.height * scaleY, + }; + } else if (imageDimensions) { + const scaleX = imageDimensions.width / layer.transform.width; + const scaleY = imageDimensions.height / layer.transform.height; + finalCropRect = { + x: result.cropRect.x * scaleX, + y: result.cropRect.y * scaleY, + width: result.cropRect.width * scaleX, + height: result.cropRect.height * scaleY, + }; + } else { + finalCropRect = result.cropRect; + } + + updateLayer(result.layerId, { + cropRect: finalCropRect, + transform: { + ...layer.transform, + x: layer.transform.x + result.cropRect.x, + y: layer.transform.y + result.cropRect.y, + width: result.cropRect.width, + height: result.cropRect.height, + }, + }); + } + }, [applyCrop, updateLayer, layer, imageDimensions]); + + const handleResetCrop = useCallback(() => { + if (isCropping) { + updateCropRect({ + x: 0, + y: 0, + width: layer.transform.width, + height: layer.transform.height, + }); + } else { + updateLayer(layer.id, { cropRect: null }); + } + }, [isCropping, layer, updateCropRect, updateLayer]); + + const handleAspectRatioChange = useCallback( + (ratio: CropAspectRatio) => { + setCropAspectRatio(ratio); + + if (!crop.cropRect) return; + + const aspectConfig = ASPECT_RATIOS.find((r) => r.value === ratio); + if (!aspectConfig?.ratio) return; + + const currentWidth = crop.cropRect.width; + const currentHeight = crop.cropRect.height; + const currentCenterX = crop.cropRect.x + currentWidth / 2; + const currentCenterY = crop.cropRect.y + currentHeight / 2; + + let newWidth = currentWidth; + let newHeight = currentWidth / aspectConfig.ratio; + + if (newHeight > layer.transform.height) { + newHeight = layer.transform.height; + newWidth = newHeight * aspectConfig.ratio; + } + + if (newWidth > layer.transform.width) { + newWidth = layer.transform.width; + newHeight = newWidth / aspectConfig.ratio; + } + + let newX = currentCenterX - newWidth / 2; + let newY = currentCenterY - newHeight / 2; + + newX = Math.max(0, Math.min(newX, layer.transform.width - newWidth)); + newY = Math.max(0, Math.min(newY, layer.transform.height - newHeight)); + + updateCropRect({ + x: Math.round(newX), + y: Math.round(newY), + width: Math.round(newWidth), + height: Math.round(newHeight), + }); + }, + [crop.cropRect, layer.transform, setCropAspectRatio, updateCropRect] + ); + + const hasCrop = layer.cropRect !== null; + + return ( +
+
+

Crop

+ {hasCrop && !isCropping && ( + + )} +
+ + {!isCropping ? ( + + ) : ( +
+
+
+ + +
+
+ {ASPECT_RATIOS.map((ar) => ( + + ))} +
+
+ + {crop.cropRect && ( +
+ +
+
+ + + updateCropRect({ + ...crop.cropRect!, + x: Math.max(0, Number(e.target.value)), + }) + } + className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ + + updateCropRect({ + ...crop.cropRect!, + y: Math.max(0, Number(e.target.value)), + }) + } + className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ + + updateCropRect({ + ...crop.cropRect!, + width: Math.max(1, Number(e.target.value)), + }) + } + className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ + + updateCropRect({ + ...crop.cropRect!, + height: Math.max(1, Number(e.target.value)), + }) + } + className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+
+ )} + +
+ + + +
+
+ )} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/CurvesSection.tsx b/services/editor/apps/image/src/components/editor/inspector/CurvesSection.tsx new file mode 100644 index 0000000..3a78748 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/CurvesSection.tsx @@ -0,0 +1,267 @@ +import { useState, useRef, useCallback } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import type { Layer } from '../../../types/project'; +import type { CurvePoint } from '../../../types/adjustments'; +import { DEFAULT_CURVES } from '../../../types/adjustments'; +import { TrendingUp, RotateCcw } from 'lucide-react'; + +interface Props { + layer: Layer; +} + +type ChannelType = 'master' | 'red' | 'green' | 'blue'; + +interface CurveEditorProps { + points: CurvePoint[]; + onChange: (points: CurvePoint[]) => void; + channel: ChannelType; +} + +function CurveEditor({ points, onChange, channel }: CurveEditorProps) { + const svgRef = useRef(null); + const [draggingIndex, setDraggingIndex] = useState(null); + const [hoverIndex, setHoverIndex] = useState(null); + + const channelColors: Record = { + master: 'hsl(var(--foreground))', + red: '#ef4444', + green: '#22c55e', + blue: '#3b82f6', + }; + + const sortedPoints = [...points].sort((a, b) => a.input - b.input); + + const getPathD = useCallback(() => { + if (sortedPoints.length < 2) return ''; + + const pathPoints = sortedPoints.map((p) => ({ + x: (p.input / 255) * 100, + y: 100 - (p.output / 255) * 100, + })); + + let d = `M ${pathPoints[0].x} ${pathPoints[0].y}`; + + for (let i = 1; i < pathPoints.length; i++) { + const prev = pathPoints[i - 1]; + const curr = pathPoints[i]; + const cpx = (prev.x + curr.x) / 2; + d += ` C ${cpx} ${prev.y}, ${cpx} ${curr.y}, ${curr.x} ${curr.y}`; + } + + return d; + }, [sortedPoints]); + + const handleMouseDown = (index: number, e: React.MouseEvent) => { + e.preventDefault(); + if (index === 0 || index === sortedPoints.length - 1) return; + setDraggingIndex(index); + }; + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (draggingIndex === null || !svgRef.current) return; + + const rect = svgRef.current.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 255; + const y = (1 - (e.clientY - rect.top) / rect.height) * 255; + + const newPoints = [...sortedPoints]; + newPoints[draggingIndex] = { + input: Math.max(1, Math.min(254, Math.round(x))), + output: Math.max(0, Math.min(255, Math.round(y))), + }; + onChange(newPoints); + }, + [draggingIndex, sortedPoints, onChange] + ); + + const handleMouseUp = () => { + setDraggingIndex(null); + }; + + const handleClick = (e: React.MouseEvent) => { + if (draggingIndex !== null || !svgRef.current) return; + + const rect = svgRef.current.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 255; + const y = (1 - (e.clientY - rect.top) / rect.height) * 255; + + if (sortedPoints.length >= 14) return; + + const newPoint: CurvePoint = { + input: Math.round(x), + output: Math.round(y), + }; + onChange([...sortedPoints, newPoint]); + }; + + const handleDoubleClick = (index: number, e: React.MouseEvent) => { + e.stopPropagation(); + if (index === 0 || index === sortedPoints.length - 1) return; + const newPoints = sortedPoints.filter((_, i) => i !== index); + onChange(newPoints); + }; + + return ( +
+ + + + + + + + + + + + + + {sortedPoints.map((point, index) => { + const x = (point.input / 255) * 100; + const y = 100 - (point.output / 255) * 100; + const isEndpoint = index === 0 || index === sortedPoints.length - 1; + const isHovered = hoverIndex === index; + const isDragging = draggingIndex === index; + + return ( + handleMouseDown(index, e)} + onDoubleClick={(e) => handleDoubleClick(index, e)} + onMouseEnter={() => setHoverIndex(index)} + onMouseLeave={() => setHoverIndex(null)} + /> + ); + })} + +
+ 0 + Input + 255 +
+
+ ); +} + +export function CurvesSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + const [activeChannel, setActiveChannel] = useState('master'); + const [isExpanded, setIsExpanded] = useState(true); + + const curves = layer.curves; + + const handlePointsChange = (points: CurvePoint[]) => { + updateLayer(layer.id, { + curves: { + ...curves, + [activeChannel]: { points }, + }, + }); + }; + + const handleEnabledChange = (enabled: boolean) => { + updateLayer(layer.id, { + curves: { + ...curves, + enabled, + }, + }); + }; + + const resetCurves = () => { + updateLayer(layer.id, { + curves: { ...DEFAULT_CURVES }, + }); + }; + + const channelColors: Record = { + master: 'bg-foreground', + red: 'bg-red-500', + green: 'bg-green-500', + blue: 'bg-blue-500', + }; + + return ( +
+ + + {isExpanded && ( +
+
+
+ {(['master', 'red', 'green', 'blue'] as ChannelType[]).map((channel) => ( + + ))} +
+ +
+ + + +

+ Click to add point • Double-click to remove • Drag to adjust +

+
+ )} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/DodgeBurnToolPanel.tsx b/services/editor/apps/image/src/components/editor/inspector/DodgeBurnToolPanel.tsx new file mode 100644 index 0000000..be08bd7 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/DodgeBurnToolPanel.tsx @@ -0,0 +1,167 @@ +import { useUIStore } from '../../../stores/ui-store'; +import { Sun, Moon } from 'lucide-react'; + +interface SliderProps { + label: string; + value: number; + min: number; + max: number; + step?: number; + unit?: string; + onChange: (value: number) => void; +} + +function Slider({ label, value, min, max, step = 1, unit = '', onChange }: SliderProps) { + const percentage = ((value - min) / (max - min)) * 100; + + return ( +
+
+ {label} + + {value.toFixed(step < 1 ? 0 : 0)}{unit} + +
+ onChange(Number(e.target.value))} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-2.5 + [&::-webkit-slider-thumb]:h-2.5 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:shadow-sm + [&::-webkit-slider-thumb]:cursor-pointer" + style={{ + background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`, + }} + /> +
+ ); +} + +export function DodgeBurnToolPanel() { + const { activeTool, dodgeBurnSettings, setDodgeBurnSettings } = useUIStore(); + + if (activeTool !== 'dodge' && activeTool !== 'burn') { + return null; + } + + const toolTypes = [ + { id: 'dodge' as const, icon: Sun, label: 'Dodge' }, + { id: 'burn' as const, icon: Moon, label: 'Burn' }, + ]; + + const ranges = [ + { id: 'shadows' as const, label: 'Shadows' }, + { id: 'midtones' as const, label: 'Midtones' }, + { id: 'highlights' as const, label: 'Highlights' }, + ]; + + return ( +
+
+ {dodgeBurnSettings.type === 'dodge' ? ( + + ) : ( + + )} + + {dodgeBurnSettings.type === 'dodge' ? 'Dodge Tool' : 'Burn Tool'} + +
+ +
+
+ Tool +
+ {toolTypes.map((type) => ( + + ))} +
+
+ +
+ Range +
+ {ranges.map((range) => ( + + ))} +
+
+ + setDodgeBurnSettings({ exposure: v })} + /> + + setDodgeBurnSettings({ size: v })} + /> + +
+
+
+
+

+ {dodgeBurnSettings.type === 'dodge' ? 'Lightens' : 'Darkens'} {dodgeBurnSettings.range} +

+
+ +
+ Tips +
    +
  • • {dodgeBurnSettings.type === 'dodge' ? 'Lightens' : 'Darkens'} selected tonal range
  • +
  • • Lower exposure for subtle adjustments
  • +
  • • Build up effect with multiple strokes
  • +
+
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/EffectsSection.tsx b/services/editor/apps/image/src/components/editor/inspector/EffectsSection.tsx new file mode 100644 index 0000000..82d6c3c --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/EffectsSection.tsx @@ -0,0 +1,379 @@ +import { useProjectStore } from '../../../stores/project-store'; +import type { Layer, Shadow, InnerShadow, Stroke, Glow } from '../../../types/project'; +import { Slider } from '@openreel/ui'; +import { ChevronDown, Droplets, Pencil, Sparkles, CircleDot } from 'lucide-react'; +import { useState } from 'react'; + +interface Props { + layer: Layer; +} + +type EffectSection = 'shadow' | 'innerShadow' | 'stroke' | 'glow' | null; + +interface EffectHeaderProps { + icon: React.ElementType; + label: string; + enabled: boolean; + isOpen: boolean; + onToggle: () => void; + onEnabledChange: (enabled: boolean) => void; +} + +function EffectHeader({ icon: Icon, label, enabled, isOpen, onToggle, onEnabledChange }: EffectHeaderProps) { + return ( +
+ +
+
+ ); +} + +export function EffectsSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + const [openSection, setOpenSection] = useState('shadow'); + + const handleShadowChange = (updates: Partial) => { + updateLayer(layer.id, { + shadow: { ...layer.shadow, ...updates }, + }); + }; + + const handleInnerShadowChange = (updates: Partial) => { + updateLayer(layer.id, { + innerShadow: { ...(layer.innerShadow ?? { enabled: false, color: 'rgba(0, 0, 0, 0.5)', blur: 10, offsetX: 2, offsetY: 2 }), ...updates }, + }); + }; + + const handleStrokeChange = (updates: Partial) => { + updateLayer(layer.id, { + stroke: { ...layer.stroke, ...updates }, + }); + }; + + const handleGlowChange = (updates: Partial) => { + updateLayer(layer.id, { + glow: { ...layer.glow, ...updates }, + }); + }; + + const toggleSection = (section: EffectSection) => { + setOpenSection(openSection === section ? null : section); + }; + + return ( +
+ +
+ toggleSection('shadow')} + onEnabledChange={(enabled) => handleShadowChange({ enabled })} + /> + {openSection === 'shadow' && ( +
+
+ +
+ handleShadowChange({ color: e.target.value })} + className="w-8 h-8 rounded border border-input cursor-pointer" + disabled={!layer.shadow.enabled} + /> + handleShadowChange({ color: e.target.value })} + className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono disabled:opacity-50" + disabled={!layer.shadow.enabled} + /> +
+
+ +
+
+ + {layer.shadow.blur}px +
+ handleShadowChange({ blur })} + min={0} + max={100} + step={1} + disabled={!layer.shadow.enabled} + /> +
+ +
+
+
+ + {layer.shadow.offsetX}px +
+ handleShadowChange({ offsetX })} + min={-50} + max={50} + step={1} + disabled={!layer.shadow.enabled} + /> +
+
+
+ + {layer.shadow.offsetY}px +
+ handleShadowChange({ offsetY })} + min={-50} + max={50} + step={1} + disabled={!layer.shadow.enabled} + /> +
+
+
+ )} +
+ +
+ toggleSection('innerShadow')} + onEnabledChange={(enabled) => handleInnerShadowChange({ enabled })} + /> + {openSection === 'innerShadow' && ( +
+
+ +
+ handleInnerShadowChange({ color: e.target.value })} + className="w-8 h-8 rounded border border-input cursor-pointer" + disabled={!layer.innerShadow?.enabled} + /> + handleInnerShadowChange({ color: e.target.value })} + className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono disabled:opacity-50" + disabled={!layer.innerShadow?.enabled} + /> +
+
+ +
+
+ + {layer.innerShadow?.blur ?? 10}px +
+ handleInnerShadowChange({ blur })} + min={0} + max={50} + step={1} + disabled={!layer.innerShadow?.enabled} + /> +
+ +
+
+
+ + {layer.innerShadow?.offsetX ?? 2}px +
+ handleInnerShadowChange({ offsetX })} + min={-30} + max={30} + step={1} + disabled={!layer.innerShadow?.enabled} + /> +
+
+
+ + {layer.innerShadow?.offsetY ?? 2}px +
+ handleInnerShadowChange({ offsetY })} + min={-30} + max={30} + step={1} + disabled={!layer.innerShadow?.enabled} + /> +
+
+
+ )} +
+ +
+ toggleSection('stroke')} + onEnabledChange={(enabled) => handleStrokeChange({ enabled })} + /> + {openSection === 'stroke' && ( +
+
+ +
+ handleStrokeChange({ color: e.target.value })} + className="w-8 h-8 rounded border border-input cursor-pointer" + disabled={!layer.stroke.enabled} + /> + handleStrokeChange({ color: e.target.value })} + className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono disabled:opacity-50" + disabled={!layer.stroke.enabled} + /> +
+
+ +
+
+ + {layer.stroke.width}px +
+ handleStrokeChange({ width })} + min={1} + max={20} + step={1} + disabled={!layer.stroke.enabled} + /> +
+ +
+ +
+ {(['solid', 'dashed', 'dotted'] as const).map((style) => ( + + ))} +
+
+
+ )} +
+ +
+ toggleSection('glow')} + onEnabledChange={(enabled) => handleGlowChange({ enabled })} + /> + {openSection === 'glow' && ( +
+
+ +
+ handleGlowChange({ color: e.target.value })} + className="w-8 h-8 rounded border border-input cursor-pointer" + disabled={!layer.glow.enabled} + /> + handleGlowChange({ color: e.target.value })} + className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono disabled:opacity-50" + disabled={!layer.glow.enabled} + /> +
+
+ +
+
+ + {layer.glow.blur}px +
+ handleGlowChange({ blur })} + min={0} + max={100} + step={1} + disabled={!layer.glow.enabled} + /> +
+ +
+
+ + {Math.round(layer.glow.intensity * 100)}% +
+ handleGlowChange({ intensity })} + min={0} + max={2} + step={0.1} + disabled={!layer.glow.enabled} + /> +
+
+ )} +
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/EraserToolPanel.tsx b/services/editor/apps/image/src/components/editor/inspector/EraserToolPanel.tsx new file mode 100644 index 0000000..713943b --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/EraserToolPanel.tsx @@ -0,0 +1,153 @@ +import { useUIStore } from '../../../stores/ui-store'; +import { Eraser, Square, Pencil, Circle } from 'lucide-react'; + +interface SliderProps { + label: string; + value: number; + min: number; + max: number; + step?: number; + unit?: string; + onChange: (value: number) => void; +} + +function Slider({ label, value, min, max, step = 1, unit = '', onChange }: SliderProps) { + const percentage = ((value - min) / (max - min)) * 100; + + return ( +
+
+ {label} + + {value.toFixed(step < 1 ? 0 : 0)}{unit} + +
+ onChange(Number(e.target.value))} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-2.5 + [&::-webkit-slider-thumb]:h-2.5 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:shadow-sm + [&::-webkit-slider-thumb]:cursor-pointer" + style={{ + background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`, + }} + /> +
+ ); +} + +export function EraserToolPanel() { + const { activeTool, eraserSettings, setEraserSettings } = useUIStore(); + + if (activeTool !== 'eraser') { + return null; + } + + const eraserModes = [ + { id: 'brush' as const, icon: Circle, label: 'Brush' }, + { id: 'pencil' as const, icon: Pencil, label: 'Pencil' }, + { id: 'block' as const, icon: Square, label: 'Block' }, + ]; + + return ( +
+
+ + Eraser Tool +
+ +
+
+ Mode +
+ {eraserModes.map((mode) => ( + + ))} +
+
+ + setEraserSettings({ size: v })} + /> + + setEraserSettings({ hardness: v })} + /> + + setEraserSettings({ opacity: v / 100 })} + /> + + setEraserSettings({ flow: v / 100 })} + /> + +
+
+
+
+

+ Brush preview +

+
+ +
+ Tips +
    +
  • • Hold Shift for straight lines
  • +
  • • [ and ] to adjust size
  • +
  • • Shift+[ and ] for hardness
  • +
+
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/FilterPresetsSection.tsx b/services/editor/apps/image/src/components/editor/inspector/FilterPresetsSection.tsx new file mode 100644 index 0000000..2081834 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/FilterPresetsSection.tsx @@ -0,0 +1,287 @@ +import { useState, useMemo } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import type { ImageLayer, Filter } from '../../../types/project'; +import { Sparkles, Check } from 'lucide-react'; + +interface Props { + layer: ImageLayer; +} + +interface FilterPreset { + id: string; + name: string; + category: 'basic' | 'vintage' | 'cinematic' | 'mood'; + filters: Filter; + thumbnail?: string; +} + +const FILTER_PRESETS: FilterPreset[] = [ + { + id: 'original', + name: 'Original', + category: 'basic', + filters: { brightness: 100, contrast: 100, saturation: 100, hue: 0, exposure: 0, vibrance: 0, highlights: 0, shadows: 0, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 }, + }, + { + id: 'vivid', + name: 'Vivid', + category: 'basic', + filters: { brightness: 105, contrast: 115, saturation: 130, hue: 0, exposure: 0, vibrance: 30, highlights: 0, shadows: 0, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 10, vignette: 0, grain: 0, sepia: 0, invert: 0 }, + }, + { + id: 'warm', + name: 'Warm', + category: 'mood', + filters: { brightness: 105, contrast: 105, saturation: 110, hue: 15, exposure: 5, vibrance: 15, highlights: 0, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 }, + }, + { + id: 'cool', + name: 'Cool', + category: 'mood', + filters: { brightness: 100, contrast: 105, saturation: 95, hue: -15, exposure: 0, vibrance: 0, highlights: 5, shadows: 0, clarity: 5, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 }, + }, + { + id: 'bw', + name: 'B&W', + category: 'basic', + filters: { brightness: 105, contrast: 115, saturation: 0, hue: 0, exposure: 0, vibrance: 0, highlights: 0, shadows: 0, clarity: 15, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 20, grain: 5, sepia: 0, invert: 0 }, + }, + { + id: 'vintage', + name: 'Vintage', + category: 'vintage', + filters: { brightness: 95, contrast: 90, saturation: 75, hue: 20, exposure: -5, vibrance: -10, highlights: -10, shadows: 15, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 30, grain: 15, sepia: 20, invert: 0 }, + }, + { + id: 'fade', + name: 'Fade', + category: 'vintage', + filters: { brightness: 110, contrast: 85, saturation: 80, hue: 0, exposure: 5, vibrance: -5, highlights: 10, shadows: 20, clarity: -10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 15, grain: 0, sepia: 0, invert: 0 }, + }, + { + id: 'dramatic', + name: 'Dramatic', + category: 'cinematic', + filters: { brightness: 95, contrast: 130, saturation: 90, hue: 0, exposure: -5, vibrance: 10, highlights: -15, shadows: -10, clarity: 25, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 15, vignette: 25, grain: 0, sepia: 0, invert: 0 }, + }, + { + id: 'moody', + name: 'Moody', + category: 'mood', + filters: { brightness: 90, contrast: 110, saturation: 85, hue: -10, exposure: -10, vibrance: 0, highlights: -20, shadows: 5, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 35, grain: 5, sepia: 0, invert: 0 }, + }, + { + id: 'bright', + name: 'Bright', + category: 'basic', + filters: { brightness: 120, contrast: 105, saturation: 105, hue: 0, exposure: 15, vibrance: 10, highlights: 10, shadows: 20, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 }, + }, + { + id: 'sepia', + name: 'Sepia', + category: 'vintage', + filters: { brightness: 105, contrast: 95, saturation: 40, hue: 35, exposure: 0, vibrance: -20, highlights: 0, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 20, grain: 10, sepia: 50, invert: 0 }, + }, + { + id: 'cinematic', + name: 'Cinematic', + category: 'cinematic', + filters: { brightness: 95, contrast: 115, saturation: 95, hue: -5, exposure: 0, vibrance: 5, highlights: -10, shadows: 5, clarity: 15, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 10, vignette: 20, grain: 3, sepia: 0, invert: 0 }, + }, + { + id: 'pop', + name: 'Pop', + category: 'mood', + filters: { brightness: 110, contrast: 120, saturation: 140, hue: 5, exposure: 5, vibrance: 40, highlights: 5, shadows: 0, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 0, grain: 0, sepia: 0, invert: 0 }, + }, + { + id: 'matte', + name: 'Matte', + category: 'cinematic', + filters: { brightness: 105, contrast: 85, saturation: 90, hue: 0, exposure: 0, vibrance: -5, highlights: 5, shadows: 15, clarity: -5, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 10, grain: 0, sepia: 0, invert: 0 }, + }, + { + id: 'retro', + name: 'Retro', + category: 'vintage', + filters: { brightness: 100, contrast: 95, saturation: 70, hue: 25, exposure: -5, vibrance: -15, highlights: -5, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 25, grain: 20, sepia: 15, invert: 0 }, + }, + { + id: 'punch', + name: 'Punch', + category: 'basic', + filters: { brightness: 100, contrast: 125, saturation: 115, hue: 0, exposure: 0, vibrance: 20, highlights: 0, shadows: -10, clarity: 20, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 20, vignette: 0, grain: 0, sepia: 0, invert: 0 }, + }, +]; + +function filtersMatch(a: Filter, b: Filter): boolean { + return ( + a.brightness === b.brightness && + a.contrast === b.contrast && + a.saturation === b.saturation && + a.hue === b.hue && + a.exposure === b.exposure && + a.vibrance === b.vibrance && + a.highlights === b.highlights && + a.shadows === b.shadows && + a.clarity === b.clarity && + a.blur === b.blur && + a.blurType === b.blurType && + a.blurAngle === b.blurAngle && + a.sharpen === b.sharpen && + a.vignette === b.vignette && + a.grain === b.grain && + a.sepia === b.sepia && + a.invert === b.invert + ); +} + +function interpolateFilters(target: Filter, intensity: number): Filter { + const lerp = (defaultVal: number, targetVal: number) => defaultVal + (targetVal - defaultVal) * (intensity / 100); + return { + brightness: Math.round(lerp(100, target.brightness)), + contrast: Math.round(lerp(100, target.contrast)), + saturation: Math.round(lerp(100, target.saturation)), + hue: Math.round(lerp(0, target.hue)), + exposure: Math.round(lerp(0, target.exposure)), + vibrance: Math.round(lerp(0, target.vibrance)), + highlights: Math.round(lerp(0, target.highlights)), + shadows: Math.round(lerp(0, target.shadows)), + clarity: Math.round(lerp(0, target.clarity)), + blur: Math.round(lerp(0, target.blur)), + blurType: target.blurType, + blurAngle: Math.round(lerp(0, target.blurAngle)), + sharpen: Math.round(lerp(0, target.sharpen)), + vignette: Math.round(lerp(0, target.vignette)), + grain: Math.round(lerp(0, target.grain)), + sepia: Math.round(lerp(0, target.sepia)), + invert: Math.round(lerp(0, target.invert)), + }; +} + +export function FilterPresetsSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + const [intensity, setIntensity] = useState(100); + const [activePresetId, setActivePresetId] = useState(() => { + const match = FILTER_PRESETS.find((p) => filtersMatch(layer.filters, p.filters)); + return match?.id ?? null; + }); + + const currentPreset = useMemo( + () => FILTER_PRESETS.find((p) => p.id === activePresetId), + [activePresetId] + ); + + const handlePresetSelect = (preset: FilterPreset) => { + setActivePresetId(preset.id); + const filters = intensity === 100 ? preset.filters : interpolateFilters(preset.filters, intensity); + updateLayer(layer.id, { filters }); + }; + + const handleIntensityChange = (newIntensity: number) => { + setIntensity(newIntensity); + if (currentPreset) { + const filters = interpolateFilters(currentPreset.filters, newIntensity); + updateLayer(layer.id, { filters }); + } + }; + + const isOriginal = activePresetId === 'original' || filtersMatch(layer.filters, FILTER_PRESETS[0].filters); + + return ( +
+
+

+ Filters +

+ {!isOriginal && ( + + )} +
+ + {activePresetId && activePresetId !== 'original' && ( +
+
+ + {intensity}% +
+ handleIntensityChange(Number(e.target.value))} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:cursor-pointer" + /> +
+ )} + +
+ {FILTER_PRESETS.map((preset) => { + const isActive = activePresetId === preset.id; + const previewStyle = getFilterPreviewStyle(preset.filters); + + return ( + + ); + })} +
+
+ ); +} + +function getFilterPreviewStyle(filters: Filter): React.CSSProperties { + const filterParts: string[] = []; + + if (filters.brightness !== 100) { + filterParts.push(`brightness(${filters.brightness}%)`); + } + if (filters.contrast !== 100) { + filterParts.push(`contrast(${filters.contrast}%)`); + } + if (filters.saturation !== 100) { + filterParts.push(`saturate(${filters.saturation}%)`); + } + if (filters.hue !== 0) { + filterParts.push(`hue-rotate(${filters.hue}deg)`); + } + + return { + filter: filterParts.length > 0 ? filterParts.join(' ') : undefined, + }; +} diff --git a/services/editor/apps/image/src/components/editor/inspector/GradientMapSection.tsx b/services/editor/apps/image/src/components/editor/inspector/GradientMapSection.tsx new file mode 100644 index 0000000..8558d01 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/GradientMapSection.tsx @@ -0,0 +1,202 @@ +import { useState } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import type { Layer } from '../../../types/project'; +import type { GradientMapStop } from '../../../types/adjustments'; +import { DEFAULT_GRADIENT_MAP } from '../../../types/adjustments'; +import { Paintbrush, RotateCcw, Plus, X } from 'lucide-react'; + +interface Props { + layer: Layer; +} + +const GRADIENT_PRESETS = [ + { name: 'B&W', stops: [{ position: 0, color: '#000000' }, { position: 1, color: '#ffffff' }] }, + { name: 'Sepia', stops: [{ position: 0, color: '#2b1810' }, { position: 0.5, color: '#8b5a2b' }, { position: 1, color: '#f5deb3' }] }, + { name: 'Duotone Blue', stops: [{ position: 0, color: '#001133' }, { position: 1, color: '#66ccff' }] }, + { name: 'Duotone Orange', stops: [{ position: 0, color: '#331100' }, { position: 1, color: '#ff9900' }] }, + { name: 'Sunset', stops: [{ position: 0, color: '#1a0533' }, { position: 0.5, color: '#ff6b35' }, { position: 1, color: '#f7c59f' }] }, +]; + +export function GradientMapSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + const [isExpanded, setIsExpanded] = useState(false); + + const gradientMap = layer.gradientMap; + + const handleStopChange = (index: number, updates: Partial) => { + const newStops = [...gradientMap.stops]; + newStops[index] = { ...newStops[index], ...updates }; + updateLayer(layer.id, { + gradientMap: { ...gradientMap, stops: newStops }, + }); + }; + + const addStop = () => { + const newStops = [...gradientMap.stops, { position: 0.5, color: '#808080' }]; + newStops.sort((a, b) => a.position - b.position); + updateLayer(layer.id, { + gradientMap: { ...gradientMap, stops: newStops }, + }); + }; + + const removeStop = (index: number) => { + if (gradientMap.stops.length <= 2) return; + const newStops = gradientMap.stops.filter((_, i) => i !== index); + updateLayer(layer.id, { + gradientMap: { ...gradientMap, stops: newStops }, + }); + }; + + const handleReverseChange = (reverse: boolean) => { + updateLayer(layer.id, { + gradientMap: { ...gradientMap, reverse }, + }); + }; + + const handleDitherChange = (dither: boolean) => { + updateLayer(layer.id, { + gradientMap: { ...gradientMap, dither }, + }); + }; + + const handleEnabledChange = (enabled: boolean) => { + updateLayer(layer.id, { + gradientMap: { ...gradientMap, enabled }, + }); + }; + + const applyPreset = (preset: typeof GRADIENT_PRESETS[0]) => { + updateLayer(layer.id, { + gradientMap: { ...gradientMap, stops: preset.stops }, + }); + }; + + const resetGradientMap = () => { + updateLayer(layer.id, { + gradientMap: { ...DEFAULT_GRADIENT_MAP }, + }); + }; + + const gradientStyle = `linear-gradient(to right, ${gradientMap.stops + .map((s) => `${s.color} ${s.position * 100}%`) + .join(', ')})`; + + return ( +
+ + + {isExpanded && ( +
+
+
+ {GRADIENT_PRESETS.map((preset) => ( + + ))} +
+ +
+ +
+ +
+ {gradientMap.stops.map((stop, index) => ( +
+ handleStopChange(index, { color: e.target.value })} + className="w-6 h-6 rounded border-none cursor-pointer" + /> + handleStopChange(index, { position: Number(e.target.value) / 100 })} + className="flex-1 h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-2.5 + [&::-webkit-slider-thumb]:h-2.5 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> + {Math.round(stop.position * 100)}% + {gradientMap.stops.length > 2 && ( + + )} +
+ ))} +
+ + + +
+ + +
+
+ )} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/GradientToolPanel.tsx b/services/editor/apps/image/src/components/editor/inspector/GradientToolPanel.tsx new file mode 100644 index 0000000..371c7a6 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/GradientToolPanel.tsx @@ -0,0 +1,176 @@ +import { useUIStore } from '../../../stores/ui-store'; +import { SquareStack, RotateCcw, X, Plus } from 'lucide-react'; + +const gradientTypes = [ + { id: 'linear', label: 'Linear' }, + { id: 'radial', label: 'Radial' }, + { id: 'angle', label: 'Angle' }, + { id: 'reflected', label: 'Reflected' }, + { id: 'diamond', label: 'Diamond' }, +] as const; + +export function GradientToolPanel() { + const { gradientSettings, setGradientSettings } = useUIStore(); + + const resetSettings = () => { + setGradientSettings({ + type: 'linear', + colors: ['#000000', '#ffffff'], + opacity: 1, + reverse: false, + dither: true, + }); + }; + + const updateColor = (index: number, color: string) => { + const newColors = [...gradientSettings.colors]; + newColors[index] = color; + setGradientSettings({ colors: newColors }); + }; + + const addColor = () => { + if (gradientSettings.colors.length >= 5) return; + const newColors = [...gradientSettings.colors, '#808080']; + setGradientSettings({ colors: newColors }); + }; + + const removeColor = (index: number) => { + if (gradientSettings.colors.length <= 2) return; + const newColors = gradientSettings.colors.filter((_, i) => i !== index); + setGradientSettings({ colors: newColors }); + }; + + const gradientStyle = `linear-gradient(to right, ${gradientSettings.colors.join(', ')})`; + + return ( +
+
+
+ +

Gradient

+
+ +
+ +

+ Click and drag on canvas to create gradient. +

+ +
+
+ Type +
+ {gradientTypes.map((type) => ( + + ))} +
+
+ +
+ Preview +
+
+ +
+
+ Colors + {gradientSettings.colors.length < 5 && ( + + )} +
+
+ {gradientSettings.colors.map((color, index) => ( +
+ updateColor(index, e.target.value)} + className="w-8 h-8 rounded border border-border cursor-pointer" + /> + updateColor(index, e.target.value)} + className="flex-1 px-2 py-1 text-xs font-mono bg-secondary/50 border border-border rounded" + /> + {gradientSettings.colors.length > 2 && ( + + )} +
+ ))} +
+
+ +
+
+ Opacity + {Math.round(gradientSettings.opacity * 100)}% +
+ setGradientSettings({ opacity: Number(e.target.value) / 100 })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/HealingBrushToolPanel.tsx b/services/editor/apps/image/src/components/editor/inspector/HealingBrushToolPanel.tsx new file mode 100644 index 0000000..0926af8 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/HealingBrushToolPanel.tsx @@ -0,0 +1,117 @@ +import { useUIStore } from '../../../stores/ui-store'; +import { Bandage, RotateCcw } from 'lucide-react'; + +export function HealingBrushToolPanel() { + const { healingBrushSettings, setHealingBrushSettings } = useUIStore(); + + const resetSettings = () => { + setHealingBrushSettings({ + size: 30, + hardness: 50, + mode: 'normal', + sourcePoint: null, + aligned: true, + }); + }; + + return ( +
+
+
+ +

Healing Brush

+
+ +
+ +

+ Hold Alt/Option and click to set source, then paint to heal while matching texture and lighting. +

+ + {healingBrushSettings.sourcePoint && ( +
+ Source: ({Math.round(healingBrushSettings.sourcePoint.x)}, {Math.round(healingBrushSettings.sourcePoint.y)}) +
+ )} + +
+
+
+ Size + {healingBrushSettings.size}px +
+ setHealingBrushSettings({ size: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Hardness + {healingBrushSettings.hardness}% +
+ setHealingBrushSettings({ hardness: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+ Mode +
+ {(['normal', 'replace', 'multiply', 'screen'] as const).map((mode) => ( + + ))} +
+
+ +
+ +
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/ImageAdjustmentsSection.tsx b/services/editor/apps/image/src/components/editor/inspector/ImageAdjustmentsSection.tsx new file mode 100644 index 0000000..b33f786 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/ImageAdjustmentsSection.tsx @@ -0,0 +1,347 @@ +import { useProjectStore } from '../../../stores/project-store'; +import type { ImageLayer, Filter, BlurType } from '../../../types/project'; +import { Sun, Contrast, Palette, Thermometer, Focus, Sparkles, CircleDot, Scan, Film, Minus, Move, Target, SunMedium, Vibrate, Sunrise, SunDim, Aperture } from 'lucide-react'; + +interface Props { + layer: ImageLayer; +} + +interface AdjustmentSliderProps { + icon: React.ReactNode; + label: string; + value: number; + min: number; + max: number; + defaultValue: number; + onChange: (value: number) => void; + unit?: string; +} + +function AdjustmentSlider({ icon, label, value, min, max, defaultValue, onChange, unit = '' }: AdjustmentSliderProps) { + const isModified = value !== defaultValue; + const percentage = ((value - min) / (max - min)) * 100; + + return ( +
+
+
+ {icon} + +
+
+ + {value}{unit} + + {isModified && ( + + )} +
+
+
+ onChange(Number(e.target.value))} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:shadow-sm + [&::-webkit-slider-thumb]:cursor-pointer + [&::-webkit-slider-thumb]:transition-transform + [&::-webkit-slider-thumb]:hover:scale-110" + style={{ + background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)` + }} + /> +
+
+ ); +} + +export function ImageAdjustmentsSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + + const handleFilterChange = (key: keyof Filter, value: number | BlurType) => { + updateLayer(layer.id, { + filters: { ...layer.filters, [key]: value }, + }); + }; + + const handleBlurTypeChange = (type: BlurType) => { + updateLayer(layer.id, { + filters: { ...layer.filters, blurType: type }, + }); + }; + + const resetAllFilters = () => { + updateLayer(layer.id, { + filters: { + brightness: 100, + contrast: 100, + saturation: 100, + hue: 0, + exposure: 0, + vibrance: 0, + highlights: 0, + shadows: 0, + clarity: 0, + blur: 0, + blurType: 'gaussian', + blurAngle: 0, + sharpen: 0, + vignette: 0, + grain: 0, + sepia: 0, + invert: 0, + }, + }); + }; + + const hasModifications = + layer.filters.brightness !== 100 || + layer.filters.contrast !== 100 || + layer.filters.saturation !== 100 || + layer.filters.hue !== 0 || + layer.filters.exposure !== 0 || + layer.filters.vibrance !== 0 || + layer.filters.highlights !== 0 || + layer.filters.shadows !== 0 || + layer.filters.clarity !== 0 || + layer.filters.blur !== 0 || + layer.filters.sharpen !== 0 || + layer.filters.vignette !== 0 || + layer.filters.grain !== 0 || + layer.filters.sepia !== 0 || + layer.filters.invert !== 0; + + return ( +
+
+

+ Adjustments +

+ {hasModifications && ( + + )} +
+ +
+ } + label="Brightness" + value={layer.filters.brightness} + min={0} + max={200} + defaultValue={100} + onChange={(v) => handleFilterChange('brightness', v)} + unit="%" + /> + + } + label="Contrast" + value={layer.filters.contrast} + min={0} + max={200} + defaultValue={100} + onChange={(v) => handleFilterChange('contrast', v)} + unit="%" + /> + + } + label="Saturation" + value={layer.filters.saturation} + min={0} + max={200} + defaultValue={100} + onChange={(v) => handleFilterChange('saturation', v)} + unit="%" + /> + + } + label="Temperature" + value={layer.filters.hue} + min={-180} + max={180} + defaultValue={0} + onChange={(v) => handleFilterChange('hue', v)} + unit="°" + /> + + } + label="Exposure" + value={layer.filters.exposure} + min={-100} + max={100} + defaultValue={0} + onChange={(v) => handleFilterChange('exposure', v)} + /> + + } + label="Vibrance" + value={layer.filters.vibrance} + min={-100} + max={100} + defaultValue={0} + onChange={(v) => handleFilterChange('vibrance', v)} + /> + + } + label="Highlights" + value={layer.filters.highlights} + min={-100} + max={100} + defaultValue={0} + onChange={(v) => handleFilterChange('highlights', v)} + /> + + } + label="Shadows" + value={layer.filters.shadows} + min={-100} + max={100} + defaultValue={0} + onChange={(v) => handleFilterChange('shadows', v)} + /> + + } + label="Clarity" + value={layer.filters.clarity} + min={-100} + max={100} + defaultValue={0} + onChange={(v) => handleFilterChange('clarity', v)} + /> + + } + label="Blur" + value={layer.filters.blur} + min={0} + max={50} + defaultValue={0} + onChange={(v) => handleFilterChange('blur', v)} + unit="px" + /> + + {layer.filters.blur > 0 && ( +
+
+ +
+ {([ + { type: 'gaussian' as BlurType, icon: , label: 'Gaussian' }, + { type: 'motion' as BlurType, icon: , label: 'Motion' }, + { type: 'radial' as BlurType, icon: , label: 'Radial' }, + ]).map(({ type, icon, label }) => ( + + ))} +
+
+ + {layer.filters.blurType === 'motion' && ( + } + label="Angle" + value={layer.filters.blurAngle} + min={0} + max={360} + defaultValue={0} + onChange={(v) => handleFilterChange('blurAngle', v)} + unit="°" + /> + )} +
+ )} + + } + label="Sharpen" + value={layer.filters.sharpen} + min={0} + max={100} + defaultValue={0} + onChange={(v) => handleFilterChange('sharpen', v)} + unit="%" + /> + + } + label="Vignette" + value={layer.filters.vignette} + min={0} + max={100} + defaultValue={0} + onChange={(v) => handleFilterChange('vignette', v)} + unit="%" + /> + + } + label="Grain" + value={layer.filters.grain} + min={0} + max={100} + defaultValue={0} + onChange={(v) => handleFilterChange('grain', v)} + unit="%" + /> + + } + label="Sepia" + value={layer.filters.sepia} + min={0} + max={100} + defaultValue={0} + onChange={(v) => handleFilterChange('sepia', v)} + unit="%" + /> + + } + label="Invert" + value={layer.filters.invert} + min={0} + max={100} + defaultValue={0} + onChange={(v) => handleFilterChange('invert', v)} + unit="%" + /> +
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/ImageControlsSection.tsx b/services/editor/apps/image/src/components/editor/inspector/ImageControlsSection.tsx new file mode 100644 index 0000000..bb7646c --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/ImageControlsSection.tsx @@ -0,0 +1,31 @@ +import { Crop, ImageIcon } from 'lucide-react'; +import type { ImageLayer } from '../../../types/project'; + +interface Props { + layer: ImageLayer; +} + +export function ImageControlsSection({ layer }: Props) { + return ( +
+

+ Image +

+ +
+
+ + Source: {layer.sourceId ? 'Linked' : 'None'} +
+ {layer.cropRect && ( +
+ + + Cropped: {Math.round(layer.cropRect.width)} × {Math.round(layer.cropRect.height)} + +
+ )} +
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/Inspector.tsx b/services/editor/apps/image/src/components/editor/inspector/Inspector.tsx new file mode 100644 index 0000000..984a90b --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/Inspector.tsx @@ -0,0 +1,467 @@ +import { memo, lazy, Suspense, useState, createContext, useContext, ReactNode, JSX } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import { useUIStore } from '../../../stores/ui-store'; +import { TransformSection } from './TransformSection'; +import { AlignmentSection } from './AlignmentSection'; +import { AppearanceSection } from './AppearanceSection'; +import { EffectsSection } from './EffectsSection'; +import { ArtboardSection } from './ArtboardSection'; +import { PenSettingsSection } from './PenSettingsSection'; +import { ColorHarmonySection } from './ColorHarmonySection'; +import { ChevronRight, Sliders, Palette, Wand2, Sparkles, Image as ImageIcon, Layers } from 'lucide-react'; +import { ScrollArea } from '@openreel/ui'; +import type { Layer, ImageLayer, TextLayer, ShapeLayer } from '../../../types/project'; +import type { Tool } from '../../../stores/ui-store'; + +const TOOL_FOCUSED_TOOLS = new Set([ + 'pen', 'brush', 'eraser', 'gradient', 'paint-bucket', + 'dodge', 'burn', 'sponge', 'blur', 'sharpen', 'smudge', + 'clone-stamp', 'healing-brush', 'spot-healing', 'liquify', + 'marquee-rect', 'marquee-ellipse', 'lasso', 'lasso-polygon', 'magic-wand', + 'free-transform', 'warp', 'perspective', 'crop' +]); + +const ImageAdjustmentsSection = lazy(() => import('./ImageAdjustmentsSection').then(m => ({ default: m.ImageAdjustmentsSection }))); +const FilterPresetsSection = lazy(() => import('./FilterPresetsSection').then(m => ({ default: m.FilterPresetsSection }))); +const CropSection = lazy(() => import('./CropSection').then(m => ({ default: m.CropSection }))); +const ImageControlsSection = lazy(() => import('./ImageControlsSection').then(m => ({ default: m.ImageControlsSection }))); +const BackgroundRemovalSection = lazy(() => import('./BackgroundRemovalSection').then(m => ({ default: m.BackgroundRemovalSection }))); +const TextSection = lazy(() => import('./TextSection').then(m => ({ default: m.TextSection }))); +const ShapeSection = lazy(() => import('./ShapeSection').then(m => ({ default: m.ShapeSection }))); +const LevelsSection = lazy(() => import('./LevelsSection').then(m => ({ default: m.LevelsSection }))); +const CurvesSection = lazy(() => import('./CurvesSection').then(m => ({ default: m.CurvesSection }))); +const ColorBalanceSection = lazy(() => import('./ColorBalanceSection').then(m => ({ default: m.ColorBalanceSection }))); +const SelectiveColorSection = lazy(() => import('./SelectiveColorSection').then(m => ({ default: m.SelectiveColorSection }))); +const BlackWhiteSection = lazy(() => import('./BlackWhiteSection').then(m => ({ default: m.BlackWhiteSection }))); +const PhotoFilterSection = lazy(() => import('./PhotoFilterSection').then(m => ({ default: m.PhotoFilterSection }))); +const ChannelMixerSection = lazy(() => import('./ChannelMixerSection').then(m => ({ default: m.ChannelMixerSection }))); +const GradientMapSection = lazy(() => import('./GradientMapSection').then(m => ({ default: m.GradientMapSection }))); +const PosterizeSection = lazy(() => import('./PosterizeSection').then(m => ({ default: m.PosterizeSection }))); +const ThresholdSection = lazy(() => import('./ThresholdSection').then(m => ({ default: m.ThresholdSection }))); +const MaskSection = lazy(() => import('./MaskSection').then(m => ({ default: m.MaskSection }))); +const SelectionToolsPanel = lazy(() => import('./SelectionToolsPanel').then(m => ({ default: m.SelectionToolsPanel }))); +const EraserToolPanel = lazy(() => import('./EraserToolPanel').then(m => ({ default: m.EraserToolPanel }))); +const DodgeBurnToolPanel = lazy(() => import('./DodgeBurnToolPanel').then(m => ({ default: m.DodgeBurnToolPanel }))); +const CloneStampToolPanel = lazy(() => import('./CloneStampToolPanel').then(m => ({ default: m.CloneStampToolPanel }))); +const HealingBrushToolPanel = lazy(() => import('./HealingBrushToolPanel').then(m => ({ default: m.HealingBrushToolPanel }))); +const SpotHealingToolPanel = lazy(() => import('./SpotHealingToolPanel').then(m => ({ default: m.SpotHealingToolPanel }))); +const SpongeToolPanel = lazy(() => import('./SpongeToolPanel').then(m => ({ default: m.SpongeToolPanel }))); +const LiquifyToolPanel = lazy(() => import('./LiquifyToolPanel').then(m => ({ default: m.LiquifyToolPanel }))); +const TransformToolPanel = lazy(() => import('./TransformToolPanel').then(m => ({ default: m.TransformToolPanel }))); +const BrushToolPanel = lazy(() => import('./BrushToolPanel').then(m => ({ default: m.BrushToolPanel }))); +const BlurSharpenToolPanel = lazy(() => import('./BlurSharpenToolPanel').then(m => ({ default: m.BlurSharpenToolPanel }))); +const SmudgeToolPanel = lazy(() => import('./SmudgeToolPanel').then(m => ({ default: m.SmudgeToolPanel }))); +const GradientToolPanel = lazy(() => import('./GradientToolPanel').then(m => ({ default: m.GradientToolPanel }))); +const PaintBucketToolPanel = lazy(() => import('./PaintBucketToolPanel').then(m => ({ default: m.PaintBucketToolPanel }))); + +function SectionLoader() { + return ( +
+
+
+
+
+
+
+ ); +} + +type AccordionContextType = { + openItems: string[]; + toggle: (id: string) => void; +}; + +const AccordionContext = createContext(null); + +interface AccordionProps { + children: ReactNode; + defaultOpen?: string[]; +} + +function Accordion({ children, defaultOpen = [] }: AccordionProps) { + const [openItems, setOpenItems] = useState(defaultOpen); + + const toggle = (id: string) => { + setOpenItems(prev => + prev.includes(id) + ? prev.filter(item => item !== id) + : [...prev, id] + ); + }; + + return ( + +
{children}
+
+ ); +} + +interface AccordionItemProps { + id: string; + icon?: React.ElementType; + title: string; + children: ReactNode; + badge?: number; +} + +function AccordionItem({ id, icon: Icon, title, children, badge }: AccordionItemProps) { + const context = useContext(AccordionContext); + if (!context) return null; + + const { openItems, toggle } = context; + const isOpen = openItems.includes(id); + + return ( +
+ + {isOpen && ( +
+ {children} +
+ )} +
+ ); +} + +function renderToolPanel(tool: Tool, imageLayer?: ImageLayer): JSX.Element | null { + const SELECTION_TOOLS = ['marquee-rect', 'marquee-ellipse', 'lasso', 'lasso-polygon', 'magic-wand']; + const TRANSFORM_TOOLS = ['free-transform', 'warp', 'perspective']; + + if (SELECTION_TOOLS.includes(tool)) { + return ( + }> + + + ); + } + + if (TRANSFORM_TOOLS.includes(tool)) { + return ( + }> + + + ); + } + + switch (tool) { + case 'pen': + return ; + case 'brush': + return ( + }> + + + ); + case 'eraser': + return ( + }> + + + ); + case 'gradient': + return ( + }> + + + ); + case 'dodge': + case 'burn': + return ( + }> + + + ); + case 'sponge': + return ( + }> + + + ); + case 'blur': + case 'sharpen': + return ( + }> + + + ); + case 'smudge': + return ( + }> + + + ); + case 'clone-stamp': + return ( + }> + + + ); + case 'healing-brush': + return ( + }> + + + ); + case 'spot-healing': + return ( + }> + + + ); + case 'liquify': + return ( + }> + + + ); + case 'paint-bucket': + return ( + }> + + + ); + case 'crop': + if (imageLayer) { + return ( + }> + + + ); + } + return null; + default: + return null; + } +} + +function InspectorContent() { + const { project, selectedLayerIds, selectedArtboardId } = useProjectStore(); + const { activeTool } = useUIStore(); + + const selectedLayers = selectedLayerIds + .map((id) => project?.layers[id]) + .filter((layer): layer is Layer => layer !== undefined); + + const singleLayer = selectedLayers.length === 1 ? selectedLayers[0] : null; + const imageLayer = singleLayer?.type === 'image' ? (singleLayer as ImageLayer) : undefined; + + if (TOOL_FOCUSED_TOOLS.has(activeTool)) { + const toolPanel = renderToolPanel(activeTool, imageLayer); + if (toolPanel) { + return ( + +
+ {toolPanel} +
+
+ ); + } + } + + if (selectedLayers.length > 1) { + return ( + +
+
+
+ +
+
+

+ {selectedLayers.length} layers +

+

Multiple selection

+
+
+ +
+
+ ); + } + + if (!singleLayer) { + const artboard = project?.artboards.find((a) => a.id === selectedArtboardId); + if (artboard) { + return ( + +
+

Artboard

+ +
+
+ ); + } + + return ( +
+
+
+ +
+

+ Select a layer to view
and edit its properties +

+
+
+ ); + } + + const getLayerIcon = () => { + switch (singleLayer.type) { + case 'image': return ImageIcon; + case 'text': return () => T; + case 'shape': return () => ; + default: return Layers; + } + }; + + const LayerIcon = getLayerIcon(); + + return ( + +
+
+
+
+ +
+
+

+ {singleLayer.name} +

+

{singleLayer.type} layer

+
+
+
+ + + +
+ +
+ +
+
+
+ + +
+ +
+
+ + + + + + {singleLayer.type === 'image' && ( + }> + +
+ + + +
+
+ + + + + + + + + + +
+ + + + +
+
+ + +
+ + + + + + +
+
+ + + + +
+ )} + + {singleLayer.type === 'text' && ( + }> + +
+ +
+
+ +
+ { + useProjectStore.getState().updateLayer(singleLayer.id, { + style: { ...(singleLayer as TextLayer).style, color }, + }); + }} + /> +
+
+
+ )} + + {singleLayer.type === 'shape' && ( + }> + +
+ +
+
+ {(singleLayer as ShapeLayer).shapeStyle.fill && ( + +
+ { + useProjectStore.getState().updateLayer(singleLayer.id, { + shapeStyle: { ...(singleLayer as ShapeLayer).shapeStyle, fill: color }, + }); + }} + /> +
+
+ )} +
+ )} +
+
+
+ ); +} + +export const Inspector = memo(InspectorContent); diff --git a/services/editor/apps/image/src/components/editor/inspector/LevelsSection.tsx b/services/editor/apps/image/src/components/editor/inspector/LevelsSection.tsx new file mode 100644 index 0000000..7075b0a --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/LevelsSection.tsx @@ -0,0 +1,213 @@ +import { useState } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import type { Layer } from '../../../types/project'; +import type { LevelsChannel } from '../../../types/adjustments'; +import { DEFAULT_LEVELS } from '../../../types/adjustments'; +import { Activity, RotateCcw } from 'lucide-react'; + +interface Props { + layer: Layer; +} + +type ChannelType = 'master' | 'red' | 'green' | 'blue'; + +interface LevelsSliderProps { + label: string; + value: number; + min: number; + max: number; + step?: number; + onChange: (value: number) => void; +} + +function LevelsSlider({ label, value, min, max, step = 1, onChange }: LevelsSliderProps) { + const percentage = ((value - min) / (max - min)) * 100; + + return ( +
+
+ {label} + {value.toFixed(step < 1 ? 2 : 0)} +
+ onChange(Number(e.target.value))} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-2.5 + [&::-webkit-slider-thumb]:h-2.5 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:shadow-sm + [&::-webkit-slider-thumb]:cursor-pointer" + style={{ + background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)` + }} + /> +
+ ); +} + +export function LevelsSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + const [activeChannel, setActiveChannel] = useState('master'); + const [isExpanded, setIsExpanded] = useState(true); + + const levels = layer.levels; + const currentChannel = levels[activeChannel]; + + const handleChannelChange = (key: keyof LevelsChannel, value: number) => { + updateLayer(layer.id, { + levels: { + ...levels, + [activeChannel]: { + ...currentChannel, + [key]: value, + }, + }, + }); + }; + + const handleEnabledChange = (enabled: boolean) => { + updateLayer(layer.id, { + levels: { + ...levels, + enabled, + }, + }); + }; + + const resetLevels = () => { + updateLayer(layer.id, { + levels: { ...DEFAULT_LEVELS }, + }); + }; + + const channelColors: Record = { + master: 'bg-foreground', + red: 'bg-red-500', + green: 'bg-green-500', + blue: 'bg-blue-500', + }; + + return ( +
+ + + {isExpanded && ( +
+
+
+ {(['master', 'red', 'green', 'blue'] as ChannelType[]).map((channel) => ( + + ))} +
+ +
+ +
+
+ Input Levels +
+
+ handleChannelChange('inputBlack', v)} + /> +
+
+ handleChannelChange('inputWhite', v)} + /> +
+
+
+ + handleChannelChange('gamma', v)} + /> + +
+ Output Levels +
+
+ handleChannelChange('outputBlack', v)} + /> +
+
+ handleChannelChange('outputWhite', v)} + /> +
+
+
+
+
+ )} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/LiquifyToolPanel.tsx b/services/editor/apps/image/src/components/editor/inspector/LiquifyToolPanel.tsx new file mode 100644 index 0000000..2bf770d --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/LiquifyToolPanel.tsx @@ -0,0 +1,152 @@ +import { useUIStore } from '../../../stores/ui-store'; +import { Waves, RotateCcw, ArrowRight, Undo2, Sparkles, RotateCw, RotateCcw as Counterclockwise, Minus, Plus, ArrowLeft, Snowflake, Flame } from 'lucide-react'; + +const liquifyTools = [ + { id: 'forward-warp', label: 'Forward Warp', icon: ArrowRight }, + { id: 'reconstruct', label: 'Reconstruct', icon: Undo2 }, + { id: 'smooth', label: 'Smooth', icon: Sparkles }, + { id: 'twirl-clockwise', label: 'Twirl CW', icon: RotateCw }, + { id: 'twirl-counterclockwise', label: 'Twirl CCW', icon: Counterclockwise }, + { id: 'pucker', label: 'Pucker', icon: Minus }, + { id: 'bloat', label: 'Bloat', icon: Plus }, + { id: 'push-left', label: 'Push Left', icon: ArrowLeft }, + { id: 'freeze', label: 'Freeze', icon: Snowflake }, + { id: 'thaw', label: 'Thaw', icon: Flame }, +] as const; + +export function LiquifyToolPanel() { + const { liquifySettings, setLiquifySettings } = useUIStore(); + + const resetSettings = () => { + setLiquifySettings({ + brushSize: 100, + brushDensity: 50, + brushPressure: 100, + brushRate: 80, + tool: 'forward-warp', + }); + }; + + return ( +
+
+
+ +

Liquify

+
+ +
+ +
+
+ Tool +
+ {liquifyTools.map((tool) => { + const Icon = tool.icon; + return ( + + ); + })} +
+
+ +
+
+ Brush Size + {liquifySettings.brushSize}px +
+ setLiquifySettings({ brushSize: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Density + {liquifySettings.brushDensity}% +
+ setLiquifySettings({ brushDensity: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Pressure + {liquifySettings.brushPressure}% +
+ setLiquifySettings({ brushPressure: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Rate + {liquifySettings.brushRate}% +
+ setLiquifySettings({ brushRate: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/MaskSection.tsx b/services/editor/apps/image/src/components/editor/inspector/MaskSection.tsx new file mode 100644 index 0000000..b9356ec --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/MaskSection.tsx @@ -0,0 +1,293 @@ +import { useProjectStore } from '../../../stores/project-store'; +import { useSelectionStore } from '../../../stores/selection-store'; +import type { Layer } from '../../../types/project'; +import type { LayerMask } from '../../../types/mask'; +import { + Circle, + Eye, + EyeOff, + Link, + Unlink, + Trash2, + RotateCcw, + Plus, + Download, +} from 'lucide-react'; + +interface Props { + layer: Layer; +} + +interface SliderProps { + label: string; + value: number; + min: number; + max: number; + step?: number; + onChange: (value: number) => void; +} + +function Slider({ label, value, min, max, step = 1, onChange }: SliderProps) { + const percentage = ((value - min) / (max - min)) * 100; + + return ( +
+
+ {label} + + {value.toFixed(step < 1 ? 0 : 0)} + {label === 'Density' || label === 'Feather' ? '%' : 'px'} + +
+ onChange(Number(e.target.value))} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-2.5 + [&::-webkit-slider-thumb]:h-2.5 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:shadow-sm + [&::-webkit-slider-thumb]:cursor-pointer" + style={{ + background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`, + }} + /> +
+ ); +} + +export function MaskSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + const { active: selection, clearSelection } = useSelectionStore(); + + const mask = layer.mask; + const hasMask = mask !== null; + const hasSelection = selection !== null; + + const handleAddMask = (reveal: boolean) => { + const baseMask: LayerMask = { + id: `mask-${Date.now()}`, + type: 'pixel', + enabled: true, + linked: true, + density: 100, + feather: 0, + invert: !reveal, + data: null, + vectorPath: selection ? [...selection.path] : null, + }; + + updateLayer(layer.id, { mask: baseMask }); + + if (selection) { + clearSelection(); + } + }; + + const handleDeleteMask = () => { + updateLayer(layer.id, { mask: null }); + }; + + const handleToggleMaskEnabled = () => { + if (!mask) return; + updateLayer(layer.id, { + mask: { ...mask, enabled: !mask.enabled }, + }); + }; + + const handleToggleMaskLinked = () => { + if (!mask) return; + updateLayer(layer.id, { + mask: { ...mask, linked: !mask.linked }, + }); + }; + + const handleToggleMaskInvert = () => { + if (!mask) return; + updateLayer(layer.id, { + mask: { ...mask, invert: !mask.invert }, + }); + }; + + const handleDensityChange = (density: number) => { + if (!mask) return; + updateLayer(layer.id, { + mask: { ...mask, density }, + }); + }; + + const handleFeatherChange = (feather: number) => { + if (!mask) return; + updateLayer(layer.id, { + mask: { ...mask, feather }, + }); + }; + + const handleToggleClippingMask = () => { + updateLayer(layer.id, { clippingMask: !layer.clippingMask }); + }; + + return ( +
+
+ Masks +
+ +
+ {!hasMask ? ( +
+

+ {hasSelection + ? 'Create mask from current selection' + : 'Add a mask to control layer visibility'} +

+ +
+ + +
+
+ ) : ( +
+
+
+
+

+ {mask.type === 'pixel' ? 'Pixel Mask' : 'Vector Mask'} +

+

+ {mask.enabled ? 'Enabled' : 'Disabled'} + {mask.invert ? ' • Inverted' : ''} +

+
+
+ +
+ + + + +
+ + + + + + {hasSelection && ( +
+ + Apply Selection + +
+ + +
+
+ )} +
+ )} + +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/PaintBucketToolPanel.tsx b/services/editor/apps/image/src/components/editor/inspector/PaintBucketToolPanel.tsx new file mode 100644 index 0000000..46ec658 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/PaintBucketToolPanel.tsx @@ -0,0 +1,120 @@ +import { useUIStore } from '../../../stores/ui-store'; +import { PaintBucket, RotateCcw } from 'lucide-react'; + +export function PaintBucketToolPanel() { + const { paintBucketSettings, setPaintBucketSettings, brushSettings, setBrushSettings } = useUIStore(); + + const resetSettings = () => { + setPaintBucketSettings({ + color: '#000000', + tolerance: 32, + contiguous: true, + antiAlias: true, + opacity: 1, + fillType: 'foreground', + }); + }; + + return ( +
+
+
+ +

Paint Bucket

+
+ +
+ +

+ Click on canvas to fill area with color. +

+ +
+
+ Fill Color +
+ setBrushSettings({ color: e.target.value })} + className="w-10 h-10 rounded border border-border cursor-pointer" + /> + setBrushSettings({ color: e.target.value })} + className="flex-1 px-2 py-1.5 text-xs font-mono bg-secondary/50 border border-border rounded" + /> +
+
+ +
+
+ Tolerance + {paintBucketSettings.tolerance} +
+ setPaintBucketSettings({ tolerance: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Opacity + {Math.round(paintBucketSettings.opacity * 100)}% +
+ setPaintBucketSettings({ opacity: Number(e.target.value) / 100 })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/PenSettingsSection.tsx b/services/editor/apps/image/src/components/editor/inspector/PenSettingsSection.tsx new file mode 100644 index 0000000..d092ddf --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/PenSettingsSection.tsx @@ -0,0 +1,78 @@ +import { useUIStore } from '../../../stores/ui-store'; +import { Pencil } from 'lucide-react'; + +export function PenSettingsSection() { + const { penSettings, setPenSettings } = useUIStore(); + + return ( +
+
+ +

+ Pen Settings +

+
+ +
+
+
+ + setPenSettings({ color: e.target.value })} + className="w-8 h-6 rounded border border-border cursor-pointer" + /> +
+
+ +
+
+ + {penSettings.width}px +
+ setPenSettings({ width: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:cursor-pointer" + /> +
+ +
+
+ + {Math.round(penSettings.opacity * 100)}% +
+ setPenSettings({ opacity: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:cursor-pointer" + /> +
+
+ +

+ Click and drag on the canvas to draw +

+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/PhotoFilterSection.tsx b/services/editor/apps/image/src/components/editor/inspector/PhotoFilterSection.tsx new file mode 100644 index 0000000..19a5071 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/PhotoFilterSection.tsx @@ -0,0 +1,179 @@ +import { useState } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import type { Layer } from '../../../types/project'; +import type { PhotoFilterAdjustment } from '../../../types/adjustments'; +import { DEFAULT_PHOTO_FILTER } from '../../../types/adjustments'; +import { PHOTO_FILTER_COLORS } from '../../../adjustments/photo-filter'; +import { SunDim, RotateCcw } from 'lucide-react'; + +interface Props { + layer: Layer; +} + +const FILTER_OPTIONS = [ + { id: 'warming-85', label: 'Warming (85)', group: 'Warming' }, + { id: 'warming-81', label: 'Warming (81)', group: 'Warming' }, + { id: 'cooling-80', label: 'Cooling (80)', group: 'Cooling' }, + { id: 'cooling-82', label: 'Cooling (82)', group: 'Cooling' }, + { id: 'custom', label: 'Custom Color', group: 'Custom' }, +] as const; + +type FilterType = typeof FILTER_OPTIONS[number]['id']; + +export function PhotoFilterSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + const [isExpanded, setIsExpanded] = useState(false); + + const photoFilter = layer.photoFilter; + + const handleFilterChange = (filter: FilterType) => { + const color = filter === 'custom' ? photoFilter.color : (PHOTO_FILTER_COLORS[filter as keyof typeof PHOTO_FILTER_COLORS] ?? photoFilter.color); + updateLayer(layer.id, { + photoFilter: { + ...photoFilter, + filter, + color, + }, + }); + }; + + const handleDensityChange = (density: number) => { + updateLayer(layer.id, { + photoFilter: { + ...photoFilter, + density, + }, + }); + }; + + const handleColorChange = (color: string) => { + updateLayer(layer.id, { + photoFilter: { + ...photoFilter, + filter: 'custom', + color, + } as PhotoFilterAdjustment, + }); + }; + + const handlePreserveLuminosityChange = (preserveLuminosity: boolean) => { + updateLayer(layer.id, { + photoFilter: { + ...photoFilter, + preserveLuminosity, + }, + }); + }; + + const handleEnabledChange = (enabled: boolean) => { + updateLayer(layer.id, { + photoFilter: { + ...photoFilter, + enabled, + }, + }); + }; + + const resetPhotoFilter = () => { + updateLayer(layer.id, { + photoFilter: { ...DEFAULT_PHOTO_FILTER }, + }); + }; + + const densityPercentage = photoFilter.density; + + return ( +
+ + + {isExpanded && ( +
+
+ + +
+ +
+ Color + handleColorChange(e.target.value)} + className="w-6 h-6 rounded border-none cursor-pointer" + /> + {photoFilter.color} +
+ +
+
+ Density + {photoFilter.density}% +
+ handleDensityChange(Number(e.target.value))} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-2.5 + [&::-webkit-slider-thumb]:h-2.5 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:shadow-sm + [&::-webkit-slider-thumb]:cursor-pointer" + style={{ + background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${densityPercentage}%, hsl(var(--secondary)) ${densityPercentage}%, hsl(var(--secondary)) 100%)` + }} + /> +
+ + +
+ )} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/PosterizeSection.tsx b/services/editor/apps/image/src/components/editor/inspector/PosterizeSection.tsx new file mode 100644 index 0000000..c5ee38f --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/PosterizeSection.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import type { Layer } from '../../../types/project'; +import { DEFAULT_POSTERIZE } from '../../../types/adjustments'; +import { Layers, RotateCcw } from 'lucide-react'; + +interface Props { + layer: Layer; +} + +export function PosterizeSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + const [isExpanded, setIsExpanded] = useState(false); + + const posterize = layer.posterize; + + const handleLevelsChange = (levels: number) => { + updateLayer(layer.id, { + posterize: { ...posterize, levels }, + }); + }; + + const handleEnabledChange = (enabled: boolean) => { + updateLayer(layer.id, { + posterize: { ...posterize, enabled }, + }); + }; + + const resetPosterize = () => { + updateLayer(layer.id, { + posterize: { ...DEFAULT_POSTERIZE }, + }); + }; + + const percentage = ((posterize.levels - 2) / 253) * 100; + + return ( +
+ + + {isExpanded && ( +
+
+ Levels +
+ {posterize.levels} + +
+
+ + handleLevelsChange(Number(e.target.value))} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-2.5 + [&::-webkit-slider-thumb]:h-2.5 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:shadow-sm + [&::-webkit-slider-thumb]:cursor-pointer" + style={{ + background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)` + }} + /> + +
+ 2 + 255 +
+
+ )} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/SelectionToolsPanel.tsx b/services/editor/apps/image/src/components/editor/inspector/SelectionToolsPanel.tsx new file mode 100644 index 0000000..bc9f909 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/SelectionToolsPanel.tsx @@ -0,0 +1,324 @@ +import { useState } from 'react'; +import { useUIStore } from '../../../stores/ui-store'; +import { useSelectionStore } from '../../../stores/selection-store'; +import { useProjectStore } from '../../../stores/project-store'; +import { + Square, + Circle, + Lasso, + Pentagon, + Wand2, + Plus, + Minus, + BoxSelect, + Trash2, + RotateCcw, + Download, + Upload, + ChevronDown, + X, +} from 'lucide-react'; + +interface SliderProps { + label: string; + value: number; + min: number; + max: number; + step?: number; + onChange: (value: number) => void; +} + +function Slider({ label, value, min, max, step = 1, onChange }: SliderProps) { + const percentage = ((value - min) / (max - min)) * 100; + + return ( +
+
+ {label} + + {value.toFixed(step < 1 ? 1 : 0)} + +
+ onChange(Number(e.target.value))} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-2.5 + [&::-webkit-slider-thumb]:h-2.5 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:shadow-sm + [&::-webkit-slider-thumb]:cursor-pointer" + style={{ + background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`, + }} + /> +
+ ); +} + +export function SelectionToolsPanel() { + const { + activeTool, + setActiveTool, + selectionToolSettings, + setSelectionToolSettings, + magicWandSettings, + setMagicWandSettings, + } = useUIStore(); + + const { + active: selection, + saved: savedSelections, + clearSelection, + invertSelection, + featherSelection, + expandSelection, + contractSelection, + saveSelection, + loadSelection, + deleteSelection, + } = useSelectionStore(); + + const [showLoadMenu, setShowLoadMenu] = useState(false); + + const { project } = useProjectStore(); + const artboard = project?.artboards?.find((a) => a.id === project.activeArtboardId); + const canvasBounds = artboard + ? { x: 0, y: 0, width: artboard.size.width, height: artboard.size.height } + : { x: 0, y: 0, width: 1920, height: 1080 }; + + const isSelectionTool = [ + 'marquee-rect', + 'marquee-ellipse', + 'lasso', + 'lasso-polygon', + 'magic-wand', + ].includes(activeTool); + + const hasSelection = selection !== null; + + const selectionTools = [ + { id: 'marquee-rect' as const, icon: Square, label: 'Rectangular' }, + { id: 'marquee-ellipse' as const, icon: Circle, label: 'Elliptical' }, + { id: 'lasso' as const, icon: Lasso, label: 'Lasso' }, + { id: 'lasso-polygon' as const, icon: Pentagon, label: 'Polygonal' }, + { id: 'magic-wand' as const, icon: Wand2, label: 'Magic Wand' }, + ]; + + const selectionModes = [ + { id: 'new' as const, icon: Square, label: 'New' }, + { id: 'add' as const, icon: Plus, label: 'Add' }, + { id: 'subtract' as const, icon: Minus, label: 'Subtract' }, + { id: 'intersect' as const, icon: BoxSelect, label: 'Intersect' }, + ]; + + if (!isSelectionTool && !hasSelection) { + return null; + } + + return ( +
+
+ Selection Tools +
+ +
+
+ {selectionTools.map((tool) => ( + + ))} +
+ + {isSelectionTool && ( + <> +
+ Mode +
+ {selectionModes.map((mode) => ( + + ))} +
+
+ + setSelectionToolSettings({ feather: v })} + /> + + + + {activeTool === 'magic-wand' && ( +
+ setMagicWandSettings({ tolerance: v })} + /> + + + + +
+ )} + + )} + + {hasSelection && ( +
+ Selection Actions + +
+ + +
+ +
+
+ + +
+ +
+ +
+ +
+ + {showLoadMenu && savedSelections.length > 0 && ( +
+ {savedSelections.map((sel, idx) => ( +
+ + +
+ ))} +
+ )} +
+
+
+ )} +
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/SelectiveColorSection.tsx b/services/editor/apps/image/src/components/editor/inspector/SelectiveColorSection.tsx new file mode 100644 index 0000000..9c456f8 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/SelectiveColorSection.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import type { Layer } from '../../../types/project'; +import type { SelectiveColorValues, SelectiveColorAdjustment } from '../../../types/adjustments'; +import { DEFAULT_SELECTIVE_COLOR } from '../../../types/adjustments'; +import { Palette, RotateCcw } from 'lucide-react'; + +interface Props { + layer: Layer; +} + +type ColorRange = 'reds' | 'yellows' | 'greens' | 'cyans' | 'blues' | 'magentas' | 'whites' | 'neutrals' | 'blacks'; + +const COLOR_RANGES: { id: ColorRange; label: string; color: string }[] = [ + { id: 'reds', label: 'Reds', color: 'bg-red-500' }, + { id: 'yellows', label: 'Yellows', color: 'bg-yellow-500' }, + { id: 'greens', label: 'Greens', color: 'bg-green-500' }, + { id: 'cyans', label: 'Cyans', color: 'bg-cyan-500' }, + { id: 'blues', label: 'Blues', color: 'bg-blue-500' }, + { id: 'magentas', label: 'Magentas', color: 'bg-pink-500' }, + { id: 'whites', label: 'Whites', color: 'bg-white border border-border' }, + { id: 'neutrals', label: 'Neutrals', color: 'bg-gray-500' }, + { id: 'blacks', label: 'Blacks', color: 'bg-gray-900' }, +]; + +function ColorSlider({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) { + const percentage = ((value + 100) / 200) * 100; + + return ( +
+
+ {label} + {value}% +
+ onChange(Number(e.target.value))} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-2.5 + [&::-webkit-slider-thumb]:h-2.5 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary + [&::-webkit-slider-thumb]:shadow-sm + [&::-webkit-slider-thumb]:cursor-pointer" + style={{ + background: `linear-gradient(to right, hsl(var(--secondary)) 0%, hsl(var(--secondary)) 50%, hsl(var(--primary)) 50%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)` + }} + /> +
+ ); +} + +export function SelectiveColorSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + const [activeRange, setActiveRange] = useState('reds'); + const [isExpanded, setIsExpanded] = useState(false); + + const selectiveColor = layer.selectiveColor; + const currentRange = selectiveColor[activeRange]; + + const handleValueChange = (key: keyof SelectiveColorValues, value: number) => { + updateLayer(layer.id, { + selectiveColor: { + ...selectiveColor, + [activeRange]: { + ...currentRange, + [key]: value, + }, + } as SelectiveColorAdjustment, + }); + }; + + const handleMethodChange = (method: 'relative' | 'absolute') => { + updateLayer(layer.id, { + selectiveColor: { + ...selectiveColor, + method, + } as SelectiveColorAdjustment, + }); + }; + + const handleEnabledChange = (enabled: boolean) => { + updateLayer(layer.id, { + selectiveColor: { + ...selectiveColor, + enabled, + } as SelectiveColorAdjustment, + }); + }; + + const resetSelectiveColor = () => { + updateLayer(layer.id, { + selectiveColor: { ...DEFAULT_SELECTIVE_COLOR }, + }); + }; + + return ( +
+ + + {isExpanded && ( +
+
+
+ {COLOR_RANGES.map((range) => ( +
+ +
+ +
+ handleValueChange('cyan', v)} /> + handleValueChange('magenta', v)} /> + handleValueChange('yellow', v)} /> + handleValueChange('black', v)} /> +
+ +
+ {(['relative', 'absolute'] as const).map((method) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/ShapeSection.tsx b/services/editor/apps/image/src/components/editor/inspector/ShapeSection.tsx new file mode 100644 index 0000000..b20faf7 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/ShapeSection.tsx @@ -0,0 +1,524 @@ +import { useState } from 'react'; +import { useProjectStore } from '../../../stores/project-store'; +import type { ShapeLayer, ShapeStyle, Gradient, FillType, StrokeDashType, NoiseFill } from '../../../types/project'; +import { DEFAULT_NOISE_FILL } from '../../../types/project'; +import { Slider } from '@openreel/ui'; +import { GradientPicker } from '../../ui/GradientPicker'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@openreel/ui'; +import { ChevronDown, Link, Unlink } from 'lucide-react'; + +const DASH_PATTERNS: { value: StrokeDashType; label: string; preview: string }[] = [ + { value: 'solid', label: 'Solid', preview: '━━━━━━' }, + { value: 'dashed', label: 'Dashed', preview: '─ ─ ─ ─' }, + { value: 'dotted', label: 'Dotted', preview: '· · · · ·' }, + { value: 'dash-dot', label: 'Dash-Dot', preview: '─ · ─ ·' }, + { value: 'long-dash', label: 'Long Dash', preview: '── ── ──' }, +]; + +interface Props { + layer: ShapeLayer; +} + +export function ShapeSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + const [isFillOpen, setIsFillOpen] = useState(true); + const [isStrokeOpen, setIsStrokeOpen] = useState(false); + + const handleStyleChange = (updates: Partial) => { + updateLayer(layer.id, { + shapeStyle: { ...layer.shapeStyle, ...updates }, + }); + }; + + const handleFillTypeChange = (fillType: FillType) => { + if (fillType === 'gradient' && !layer.shapeStyle.gradient) { + handleStyleChange({ + fillType, + gradient: { + type: 'linear', + angle: 90, + stops: [ + { offset: 0, color: layer.shapeStyle.fill ?? '#3b82f6' }, + { offset: 1, color: '#8b5cf6' }, + ], + }, + }); + } else if (fillType === 'noise' && !layer.shapeStyle.noise) { + handleStyleChange({ + fillType, + noise: { + ...DEFAULT_NOISE_FILL, + baseColor: layer.shapeStyle.fill ?? DEFAULT_NOISE_FILL.baseColor, + }, + }); + } else { + handleStyleChange({ fillType }); + } + }; + + const handleNoiseChange = (updates: Partial) => { + handleStyleChange({ + noise: { ...(layer.shapeStyle.noise ?? DEFAULT_NOISE_FILL), ...updates }, + }); + }; + + const handleGradientChange = (gradient: Gradient) => { + handleStyleChange({ gradient }); + }; + + return ( +
+

+ Shape +

+ + + + + + +
+
+ + + +
+ + {layer.shapeStyle.fillType === 'solid' && ( +
+
+ + {layer.shapeStyle.fill && ( + <> + handleStyleChange({ fill: e.target.value })} + className="w-8 h-8 rounded border border-input cursor-pointer" + /> + handleStyleChange({ fill: e.target.value })} + className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono" + /> + + )} +
+
+ )} + + {layer.shapeStyle.fillType === 'gradient' && ( + + )} + + {layer.shapeStyle.fillType === 'noise' && ( +
+
+ +
+ handleNoiseChange({ baseColor: e.target.value })} + className="w-8 h-8 rounded border border-input cursor-pointer" + /> + handleNoiseChange({ baseColor: e.target.value })} + className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono" + /> +
+
+
+ +
+ handleNoiseChange({ noiseColor: e.target.value })} + className="w-8 h-8 rounded border border-input cursor-pointer" + /> + handleNoiseChange({ noiseColor: e.target.value })} + className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono" + /> +
+
+
+
+ + {Math.round((layer.shapeStyle.noise?.density ?? 0.5) * 100)}% +
+ handleNoiseChange({ density: density / 100 })} + min={5} + max={100} + step={1} + /> +
+
+
+ + {layer.shapeStyle.noise?.size ?? 2}px +
+ handleNoiseChange({ size })} + min={1} + max={10} + step={1} + /> +
+
+ )} + +
+
+ + {Math.round(layer.shapeStyle.fillOpacity * 100)}% +
+ handleStyleChange({ fillOpacity: opacity / 100 })} + min={0} + max={100} + step={1} + /> +
+
+
+
+ + + + + + +
+
+ + {layer.shapeStyle.stroke && ( + <> + handleStyleChange({ stroke: e.target.value })} + className="w-8 h-8 rounded border border-input cursor-pointer" + /> + handleStyleChange({ stroke: e.target.value })} + className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono" + /> + + )} +
+ + {layer.shapeStyle.stroke && ( + <> +
+
+ + {layer.shapeStyle.strokeWidth}px +
+ handleStyleChange({ strokeWidth: width })} + min={1} + max={20} + step={1} + /> +
+ +
+
+ + {Math.round(layer.shapeStyle.strokeOpacity * 100)}% +
+ handleStyleChange({ strokeOpacity: opacity / 100 })} + min={0} + max={100} + step={1} + /> +
+ +
+ +
+ {DASH_PATTERNS.map((pattern) => ( + + ))} +
+
+ + )} +
+
+
+ + {layer.shapeType === 'rectangle' && ( +
+
+ + +
+ + {!layer.shapeStyle.individualCorners ? ( +
+
+ + {layer.shapeStyle.cornerRadius}px +
+ handleStyleChange({ cornerRadius: radius })} + min={0} + max={100} + step={1} + /> +
+ ) : ( +
+
+
+ + {layer.shapeStyle.corners?.topLeft ?? 0}px +
+ + handleStyleChange({ + corners: { ...(layer.shapeStyle.corners ?? { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }), topLeft: radius }, + }) + } + min={0} + max={100} + step={1} + /> +
+
+
+ + {layer.shapeStyle.corners?.topRight ?? 0}px +
+ + handleStyleChange({ + corners: { ...(layer.shapeStyle.corners ?? { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }), topRight: radius }, + }) + } + min={0} + max={100} + step={1} + /> +
+
+
+ + {layer.shapeStyle.corners?.bottomLeft ?? 0}px +
+ + handleStyleChange({ + corners: { ...(layer.shapeStyle.corners ?? { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }), bottomLeft: radius }, + }) + } + min={0} + max={100} + step={1} + /> +
+
+
+ + {layer.shapeStyle.corners?.bottomRight ?? 0}px +
+ + handleStyleChange({ + corners: { ...(layer.shapeStyle.corners ?? { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }), bottomRight: radius }, + }) + } + min={0} + max={100} + step={1} + /> +
+
+ )} +
+ )} + + {layer.shapeType === 'polygon' && ( +
+
+ + {layer.sides ?? 6} +
+ updateLayer(layer.id, { sides })} + min={3} + max={12} + step={1} + /> +
+ )} + + {layer.shapeType === 'star' && ( +
+
+
+ + {layer.sides ?? 5} +
+ updateLayer(layer.id, { sides })} + min={3} + max={20} + step={1} + /> +
+
+
+ + {Math.round((layer.innerRadius ?? 0.4) * 100)}% +
+ updateLayer(layer.id, { innerRadius: ratio / 100 })} + min={10} + max={90} + step={1} + /> +
+
+ )} +
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/SmudgeToolPanel.tsx b/services/editor/apps/image/src/components/editor/inspector/SmudgeToolPanel.tsx new file mode 100644 index 0000000..2d5338d --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/SmudgeToolPanel.tsx @@ -0,0 +1,100 @@ +import { useUIStore } from '../../../stores/ui-store'; +import { Blend, RotateCcw } from 'lucide-react'; + +export function SmudgeToolPanel() { + const { smudgeSettings, setSmudgeSettings } = useUIStore(); + + const resetSettings = () => { + setSmudgeSettings({ + size: 30, + strength: 50, + fingerPainting: false, + sampleAllLayers: false, + }); + }; + + return ( +
+
+
+ +

Smudge Tool

+
+ +
+ +

+ Drag to smudge and blend colors together. +

+ +
+
+
+ Size + {smudgeSettings.size}px +
+ setSmudgeSettings({ size: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Strength + {smudgeSettings.strength}% +
+ setSmudgeSettings({ strength: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/SpongeToolPanel.tsx b/services/editor/apps/image/src/components/editor/inspector/SpongeToolPanel.tsx new file mode 100644 index 0000000..672e88b --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/SpongeToolPanel.tsx @@ -0,0 +1,104 @@ +import { useUIStore } from '../../../stores/ui-store'; +import { Droplet, RotateCcw } from 'lucide-react'; + +export function SpongeToolPanel() { + const { spongeSettings, setSpongeSettings } = useUIStore(); + + const resetSettings = () => { + setSpongeSettings({ + size: 30, + flow: 50, + mode: 'desaturate', + }); + }; + + return ( +
+
+
+ +

Sponge Tool

+
+ +
+ +

+ Paint to saturate or desaturate color in specific areas. +

+ +
+
+ Mode +
+ + +
+
+ +
+
+ Size + {spongeSettings.size}px +
+ setSpongeSettings({ size: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+
+ Flow + {spongeSettings.flow}% +
+ setSpongeSettings({ flow: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/SpotHealingToolPanel.tsx b/services/editor/apps/image/src/components/editor/inspector/SpotHealingToolPanel.tsx new file mode 100644 index 0000000..79dc777 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/SpotHealingToolPanel.tsx @@ -0,0 +1,93 @@ +import { useUIStore } from '../../../stores/ui-store'; +import { Bandage, RotateCcw } from 'lucide-react'; + +export function SpotHealingToolPanel() { + const { spotHealingSettings, setSpotHealingSettings } = useUIStore(); + + const resetSettings = () => { + setSpotHealingSettings({ + size: 30, + type: 'content-aware', + sampleAllLayers: false, + }); + }; + + return ( +
+
+
+ +

Spot Healing Brush

+
+ +
+ +

+ Paint over blemishes or imperfections to automatically remove them. +

+ +
+
+
+ Size + {spotHealingSettings.size}px +
+ setSpotHealingSettings({ size: Number(e.target.value) })} + className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-primary" + /> +
+ +
+ Type +
+ {([ + { id: 'content-aware', label: 'Content-Aware' }, + { id: 'proximity-match', label: 'Proximity Match' }, + { id: 'create-texture', label: 'Create Texture' }, + ] as const).map((option) => ( + + ))} +
+
+ +
+ +
+
+
+ ); +} diff --git a/services/editor/apps/image/src/components/editor/inspector/TextSection.tsx b/services/editor/apps/image/src/components/editor/inspector/TextSection.tsx new file mode 100644 index 0000000..9f4f0e3 --- /dev/null +++ b/services/editor/apps/image/src/components/editor/inspector/TextSection.tsx @@ -0,0 +1,595 @@ +import { useProjectStore } from '../../../stores/project-store'; +import type { TextLayer, TextStyle, TextFillType, Gradient } from '../../../types/project'; +import { AlignLeft, AlignCenter, AlignRight, Bold, Italic, Underline, CaseUpper, CaseLower, CaseSensitive, Strikethrough, Type } from 'lucide-react'; +import { FontPicker } from '../../ui/FontPicker'; +import { GradientPicker } from '../../ui/GradientPicker'; +import { Slider, Switch } from '@openreel/ui'; + +interface Props { + layer: TextLayer; +} + +const FONT_SIZES = [8, 10, 12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 96, 128]; + +interface TextPreset { + id: string; + name: string; + style: Partial; +} + +const TEXT_PRESETS: TextPreset[] = [ + { + id: 'heading-1', + name: 'Heading 1', + style: { fontSize: 72, fontWeight: 700, lineHeight: 1.1 }, + }, + { + id: 'heading-2', + name: 'Heading 2', + style: { fontSize: 48, fontWeight: 700, lineHeight: 1.2 }, + }, + { + id: 'heading-3', + name: 'Heading 3', + style: { fontSize: 36, fontWeight: 600, lineHeight: 1.2 }, + }, + { + id: 'subheading', + name: 'Subheading', + style: { fontSize: 24, fontWeight: 500, lineHeight: 1.3 }, + }, + { + id: 'body', + name: 'Body', + style: { fontSize: 16, fontWeight: 400, lineHeight: 1.5 }, + }, + { + id: 'body-large', + name: 'Body Large', + style: { fontSize: 18, fontWeight: 400, lineHeight: 1.6 }, + }, + { + id: 'caption', + name: 'Caption', + style: { fontSize: 12, fontWeight: 400, lineHeight: 1.4, color: '#a3a3a3' }, + }, + { + id: 'quote', + name: 'Quote', + style: { fontSize: 24, fontWeight: 400, fontStyle: 'italic' as const, lineHeight: 1.5 }, + }, +]; + +export function TextSection({ layer }: Props) { + const { updateLayer } = useProjectStore(); + + const handleContentChange = (content: string) => { + updateLayer(layer.id, { content }); + }; + + const handleStyleChange = (updates: Partial) => { + updateLayer(layer.id, { + style: { ...layer.style, ...updates }, + }); + }; + + const toggleBold = () => { + handleStyleChange({ + fontWeight: layer.style.fontWeight >= 700 ? 400 : 700, + }); + }; + + const toggleItalic = () => { + handleStyleChange({ + fontStyle: layer.style.fontStyle === 'italic' ? 'normal' : 'italic', + }); + }; + + const toggleUnderline = () => { + handleStyleChange({ + textDecoration: layer.style.textDecoration === 'underline' ? 'none' : 'underline', + }); + }; + + const toggleStrikethrough = () => { + handleStyleChange({ + textDecoration: layer.style.textDecoration === 'line-through' ? 'none' : 'line-through', + }); + }; + + const transformToUppercase = () => { + handleContentChange(layer.content.toUpperCase()); + }; + + const transformToLowercase = () => { + handleContentChange(layer.content.toLowerCase()); + }; + + const transformToCapitalize = () => { + const capitalized = layer.content + .toLowerCase() + .replace(/(?:^|\s)\S/g, (char) => char.toUpperCase()); + handleContentChange(capitalized); + }; + + const applyPreset = (preset: TextPreset) => { + handleStyleChange(preset.style); + }; + + return ( +
+

+ Text +

+ +
+
+ + +
+
+ {TEXT_PRESETS.map((preset) => ( + + ))} +
+
+ +
+ +