diff --git a/docker-compose.yml b/docker-compose.yml index 67cf146..299a44d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -123,15 +123,6 @@ services: networks: - wild-dragon - # editor: - # build: ./services/editor - # depends_on: - # - mam-api - # ports: - # - "${PORT_EDITOR:-7435}:80" - # networks: - # - wild-dragon - volumes: postgres_data: redis_data: diff --git a/services/editor/.gitignore b/services/editor/.gitignore deleted file mode 100644 index c07a4ca..0000000 --- a/services/editor/.gitignore +++ /dev/null @@ -1,65 +0,0 @@ -# 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 deleted file mode 100644 index 2e510af..0000000 --- a/services/editor/.serena/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/cache -/project.local.yml diff --git a/services/editor/.serena/project.yml b/services/editor/.serena/project.yml deleted file mode 100644 index 77baec7..0000000 --- a/services/editor/.serena/project.yml +++ /dev/null @@ -1,135 +0,0 @@ -# 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 deleted file mode 100644 index 4e17036..0000000 --- a/services/editor/CONTRIBUTING.md +++ /dev/null @@ -1,387 +0,0 @@ -# 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 deleted file mode 100644 index 7aaea50..0000000 --- a/services/editor/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index a2b9866..0000000 --- a/services/editor/INTEGRATION.md +++ /dev/null @@ -1,20 +0,0 @@ -# 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 deleted file mode 100644 index 1e114f1..0000000 --- a/services/editor/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index de0efd7..0000000 --- a/services/editor/README.md +++ /dev/null @@ -1,308 +0,0 @@ -# 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 deleted file mode 100644 index a360bcf..0000000 --- a/services/editor/VENDOR.txt +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 5de12c8..0000000 --- a/services/editor/apps/image/eslint.config.js +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index e81cdf2..0000000 --- a/services/editor/apps/image/index.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - OpenReel Image - Professional Graphic Design Editor - - -
- - - - diff --git a/services/editor/apps/image/package.json b/services/editor/apps/image/package.json deleted file mode 100644 index 364d493..0000000 --- a/services/editor/apps/image/package.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "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 deleted file mode 100644 index 2aa7205..0000000 --- a/services/editor/apps/image/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/services/editor/apps/image/public/favicon.svg b/services/editor/apps/image/public/favicon.svg deleted file mode 100644 index ed85208..0000000 --- a/services/editor/apps/image/public/favicon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/services/editor/apps/image/public/manifest.json b/services/editor/apps/image/public/manifest.json deleted file mode 100644 index 80a6703..0000000 --- a/services/editor/apps/image/public/manifest.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 2a878f7..0000000 --- a/services/editor/apps/image/public/sw.js +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 958a05a..0000000 --- a/services/editor/apps/image/src/App.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index d433869..0000000 --- a/services/editor/apps/image/src/adjustments/black-white.ts +++ /dev/null @@ -1,168 +0,0 @@ -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 deleted file mode 100644 index 6e53b1e..0000000 --- a/services/editor/apps/image/src/adjustments/channel-mixer.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index 941e14a..0000000 --- a/services/editor/apps/image/src/adjustments/color-balance.ts +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index aab063b..0000000 --- a/services/editor/apps/image/src/adjustments/color-lookup.ts +++ /dev/null @@ -1,176 +0,0 @@ -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 deleted file mode 100644 index ba8fb88..0000000 --- a/services/editor/apps/image/src/adjustments/gradient-map.ts +++ /dev/null @@ -1,164 +0,0 @@ -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 deleted file mode 100644 index cfc59d4..0000000 --- a/services/editor/apps/image/src/adjustments/histogram.ts +++ /dev/null @@ -1,305 +0,0 @@ -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 deleted file mode 100644 index 1a8ab36..0000000 --- a/services/editor/apps/image/src/adjustments/photo-filter.ts +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index 46ad1f7..0000000 --- a/services/editor/apps/image/src/adjustments/posterize-threshold.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index b32a6fd..0000000 --- a/services/editor/apps/image/src/adjustments/selective-color.ts +++ /dev/null @@ -1,225 +0,0 @@ -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 deleted file mode 100644 index 0a20495..0000000 --- a/services/editor/apps/image/src/app.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -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 deleted file mode 100644 index 2640307..0000000 --- a/services/editor/apps/image/src/components/editor/EditorInterface.tsx +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index 50f36d2..0000000 --- a/services/editor/apps/image/src/components/editor/ExportDialog.tsx +++ /dev/null @@ -1,626 +0,0 @@ -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 deleted file mode 100644 index 13230c1..0000000 --- a/services/editor/apps/image/src/components/editor/KeyboardShortcutsPanel.tsx +++ /dev/null @@ -1,140 +0,0 @@ -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 deleted file mode 100644 index e60e64b..0000000 --- a/services/editor/apps/image/src/components/editor/SettingsDialog.tsx +++ /dev/null @@ -1,217 +0,0 @@ -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 deleted file mode 100644 index 5e3e9a8..0000000 --- a/services/editor/apps/image/src/components/editor/canvas/Canvas.tsx +++ /dev/null @@ -1,3139 +0,0 @@ -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 deleted file mode 100644 index ee4df9d..0000000 --- a/services/editor/apps/image/src/components/editor/canvas/ContextMenu.tsx +++ /dev/null @@ -1,363 +0,0 @@ -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 deleted file mode 100644 index 1c5acb2..0000000 --- a/services/editor/apps/image/src/components/editor/canvas/Rulers.tsx +++ /dev/null @@ -1,206 +0,0 @@ -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 deleted file mode 100644 index 0d30fb5..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/AlignmentSection.tsx +++ /dev/null @@ -1,240 +0,0 @@ -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 deleted file mode 100644 index cc28b18..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/AppearanceSection.tsx +++ /dev/null @@ -1,180 +0,0 @@ -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 deleted file mode 100644 index f196b2d..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/ArtboardSection.tsx +++ /dev/null @@ -1,136 +0,0 @@ -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 deleted file mode 100644 index 28daff2..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/BackgroundRemovalSection.tsx +++ /dev/null @@ -1,169 +0,0 @@ -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 deleted file mode 100644 index 0096e44..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/BlackWhiteSection.tsx +++ /dev/null @@ -1,226 +0,0 @@ -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 deleted file mode 100644 index b96efa6..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/BlurSharpenToolPanel.tsx +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index 7416d95..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/BrushToolPanel.tsx +++ /dev/null @@ -1,156 +0,0 @@ -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 deleted file mode 100644 index fffa5fb..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/ChannelMixerSection.tsx +++ /dev/null @@ -1,170 +0,0 @@ -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 deleted file mode 100644 index 0e9e4fd..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/CloneStampToolPanel.tsx +++ /dev/null @@ -1,149 +0,0 @@ -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 deleted file mode 100644 index 3ea1874..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/ColorBalanceSection.tsx +++ /dev/null @@ -1,211 +0,0 @@ -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 deleted file mode 100644 index 186819a..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/ColorHarmonySection.tsx +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index 998e1f3..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/CropSection.tsx +++ /dev/null @@ -1,308 +0,0 @@ -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 deleted file mode 100644 index 3a78748..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/CurvesSection.tsx +++ /dev/null @@ -1,267 +0,0 @@ -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 deleted file mode 100644 index be08bd7..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/DodgeBurnToolPanel.tsx +++ /dev/null @@ -1,167 +0,0 @@ -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 deleted file mode 100644 index 82d6c3c..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/EffectsSection.tsx +++ /dev/null @@ -1,379 +0,0 @@ -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 deleted file mode 100644 index 713943b..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/EraserToolPanel.tsx +++ /dev/null @@ -1,153 +0,0 @@ -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 deleted file mode 100644 index 2081834..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/FilterPresetsSection.tsx +++ /dev/null @@ -1,287 +0,0 @@ -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 deleted file mode 100644 index 8558d01..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/GradientMapSection.tsx +++ /dev/null @@ -1,202 +0,0 @@ -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 deleted file mode 100644 index 371c7a6..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/GradientToolPanel.tsx +++ /dev/null @@ -1,176 +0,0 @@ -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 deleted file mode 100644 index 0926af8..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/HealingBrushToolPanel.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index b33f786..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/ImageAdjustmentsSection.tsx +++ /dev/null @@ -1,347 +0,0 @@ -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 deleted file mode 100644 index bb7646c..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/ImageControlsSection.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 984a90b..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/Inspector.tsx +++ /dev/null @@ -1,467 +0,0 @@ -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 deleted file mode 100644 index 7075b0a..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/LevelsSection.tsx +++ /dev/null @@ -1,213 +0,0 @@ -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 deleted file mode 100644 index 2bf770d..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/LiquifyToolPanel.tsx +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index b9356ec..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/MaskSection.tsx +++ /dev/null @@ -1,293 +0,0 @@ -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 deleted file mode 100644 index 46ec658..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/PaintBucketToolPanel.tsx +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index d092ddf..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/PenSettingsSection.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 19a5071..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/PhotoFilterSection.tsx +++ /dev/null @@ -1,179 +0,0 @@ -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 deleted file mode 100644 index c5ee38f..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/PosterizeSection.tsx +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index bc9f909..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/SelectionToolsPanel.tsx +++ /dev/null @@ -1,324 +0,0 @@ -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 deleted file mode 100644 index 9c456f8..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/SelectiveColorSection.tsx +++ /dev/null @@ -1,175 +0,0 @@ -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 deleted file mode 100644 index b20faf7..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/ShapeSection.tsx +++ /dev/null @@ -1,524 +0,0 @@ -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 deleted file mode 100644 index 2d5338d..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/SmudgeToolPanel.tsx +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 672e88b..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/SpongeToolPanel.tsx +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 79dc777..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/SpotHealingToolPanel.tsx +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 9f4f0e3..0000000 --- a/services/editor/apps/image/src/components/editor/inspector/TextSection.tsx +++ /dev/null @@ -1,595 +0,0 @@ -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) => ( - - ))} -
-
- -
- -